sujalappa commited on
Commit
7a751e9
·
verified ·
1 Parent(s): 1234541

GeoScan web app

Browse files
geoscan/geoscan/config.py CHANGED
@@ -1,172 +1,173 @@
1
- """Run settings.
2
-
3
- Every tunable parameter lives here, with its default. A YAML file (see
4
- ``config/default.yaml``) can override any subset of them. The resolved settings
5
- are written into each run's manifest, so every output is traceable to the exact
6
- parameters that produced it.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from dataclasses import asdict, dataclass, field, fields, is_dataclass
12
- from pathlib import Path
13
- from typing import Any
14
-
15
- import yaml
16
-
17
-
18
- @dataclass
19
- class GridConfig:
20
- base_res_m: float = 30.0 # terrain / lineament grid (SRTM native ~30 m)
21
- emit_factor: int = 2 # EMIT grid = 60 m (native EMIT pixel)
22
- aster_factor: int = 3 # ASTER GED grid = 90 m (closest multiple to native 100 m)
23
- align_m: float = 180.0 # origin snapped to this so all grids share edges
24
- structure_pad_m: float = 1500.0 # extra DEM around the AOI so edge lineaments are complete
25
-
26
-
27
- @dataclass
28
- class AcquireConfig:
29
- ee_project: str | None = None
30
- start_date: str = "2022-08-01"
31
- end_date: str | None = None # None = today
32
- min_aoi_coverage: float = 0.05 # skip EMIT scenes covering less of the AOI than this
33
- max_request_mb: float = 30.0 # Earth Engine computePixels payload limit is ~48 MB
34
- retries: int = 4
35
-
36
-
37
- @dataclass
38
- class SpectralConfig:
39
- mc_draws: int = 64 # Monte-Carlo draws for uncertainty propagation
40
- seed: int = 42
41
- shoulder_halfwidth_nm: float = 10.0 # shoulders are averaged over +/- this window
42
- detect_sigma: float = 2.0 # detection = depth > detect_sigma * sigma
43
- min_depth: float = 0.01 # ... and depth above this absolute floor
44
- ndvi_max: float = 0.30 # pixels greener than this are masked as vegetation
45
- min_albedo: float = 0.04 # darker pixels (shadow, water, burn scars) are masked
46
- chunk_pixels: int = 4096
47
-
48
-
49
- @dataclass
50
- class L2BConfig:
51
- group2_min_fit: float = 0.30 # 2 um vibrational group (Tetracorder group 2)
52
- group2_max_unc: float = 0.025
53
- group1_min_fit: float = 0.25 # 1 um electronic Fe group (Tetracorder group 1)
54
- group1_max_unc: float = 0.020
55
-
56
-
57
- @dataclass
58
- class StructureConfig:
59
- n_azimuths: int = 8 # hillshade illumination directions (every 360/n deg)
60
- sun_elevation_deg: float = 30.0
61
- smooth_sigma_px: float = 1.0
62
- canny_low_quantile: float = 0.80 # adaptive Canny thresholds (terrain independent)
63
- canny_high_quantile: float = 0.95
64
- hough_threshold: int = 40
65
- min_segment_m: float = 450.0
66
- max_gap_m: float = 90.0
67
- merge_angle_deg: float = 12.0
68
- merge_perp_m: float = 90.0
69
- merge_gap_m: float = 300.0
70
- dedup_angle_deg: float = 10.0
71
- dedup_perp_m: float = 60.0
72
- min_length_m: float = 800.0
73
- density_radius_m: float = 1000.0
74
- support_min_fraction: float = 0.5 # share of a line that must lie on edges of one azimuth
75
- local_contrast: bool = True # CLAHE on hillshades + local normalisation of ridge filter
76
- clahe_window_m: float = 1500.0
77
- clahe_clip: float = 0.01
78
- ridge_valley: bool = True # add Hessian ridge/valley axes to the edge maps
79
- ridge_sigmas_m: tuple = (30.0, 60.0)
80
- ridge_quantile: float = 0.90
81
- profile_offset_m: float = 90.0 # cross-profile sample distance either side of a line
82
- scarp_min_step_m: float = 2.0 # one-sided relief needed to call a line a scarp
83
- scarp_step_ratio: float = 1.0 # scarp if step > ratio * |centre - mean(sides)|
84
- exclude_scarps: bool = True # leave scarps out of distance / density / S_prox
85
- dem_source: str = "srtm" # srtm | aw3d30 | copernicus
86
- canny_tile_m: float = 0.0 # > 0: Canny quantile thresholds per tile of this size (0 = whole grid)
87
-
88
-
89
- @dataclass
90
- class AlterationConfig:
91
- """Four alteration layers (methodology sections 3-4)."""
92
- sigma_core_m: float = 90.0 # core vent field kernel (4.3)
93
- sigma_halo_m: float = 375.0 # hydrothermal halo kernel (4.3)
94
- theta_decay: float = 0.05 # confidence = 1 - exp(-W_halo / theta)
95
- reference_percentile: float = 98.0 # a = clip(F / P98(F > 0), 0, 1) (4.4)
96
- sericite_position_nm: tuple = (2190.0, 2215.0) # Al-OH minimum accepted as white mica
97
- propylitic_l2b_classes: tuple = ("chlorite", "epidote", "prehnite", "amphibole")
98
- quartz_weights: tuple = (0.55, 0.45) # QSI = 0.55 QI + 0.45 SI (4.5)
99
- quartz_background_percentile: float = 15.0 # a_qtz = (QSI - P15) / (P98 - P15)
100
- # EMIT L2B evidence in the alteration layers (weight = fit / uncertainty, as in the legacy method):
101
- # "none" L2A detections only (default)
102
- # "combine_all" add QC-passed L2B identifications everywhere (incl. vegetation-masked pixels)
103
- # "combine_valid" add L2B only on pixels where L2A has a valid observation
104
- l2b_evidence: str = "none"
105
- l2b_classes: dict = field(default_factory=lambda: {
106
- "muscovite": ["white_mica"], "chlorite": ["chlorite", "epidote"], "gossan": ["ferric_oxide", "jarosite"]})
107
- # Lithology background removal: subtract the per-unit median of each layer (mapped rock units),
108
- # keep the positive residual ("stronger than normal for this rock"), then P98-normalise.
109
- lithology_background: bool = False
110
- lithology_file: str | None = None # default: data/<aoi>/ground_truth/lithology*.geojson
111
- lithology_field: str = "LITHOLOGICAL_UNIT"
112
- min_unit_area_km2: float = 0.25 # smaller units (and unmapped ground) use the block median
113
- min_support: float = 0.20 # usable-pixel fraction in the halo below which a map shows "no data"
114
- support_sigma_m: float | None = None # neighbourhood for data support: None = sigma_halo / 2 (~190 m),
115
- # > 0 = Gaussian of this sigma, 0 = pixel or an immediate neighbour usable
116
- display_upsample: int = 4
117
-
118
-
119
- @dataclass
120
- class TargetConfig:
121
- """Copper Prospectivity Index and drill-target selection (methodology sections 5-6)."""
122
- w_hydrothermal: float = 0.70
123
- w_structure: float = 0.30
124
- w_muscovite: float = 0.30
125
- w_quartz: float = 0.28
126
- w_gossan: float = 0.22
127
- w_chlorite: float = 0.20
128
- structure_lambda_m: float = 250.0 # S_prox = exp(-d / lambda) (4.6)
129
- min_cpi: float = 0.25
130
- n_targets: int = 50
131
- min_spacing_m: float = 250.0
132
- tier1: float = 0.70
133
- tier2: float = 0.50
134
- tier3: float = 0.28
135
- require_data_support: bool = True # no targets where EMIT had no usable pixels (vegetation/cloud)
136
-
137
-
138
- @dataclass
139
- class Settings:
140
- grid: GridConfig = field(default_factory=GridConfig)
141
- acquire: AcquireConfig = field(default_factory=AcquireConfig)
142
- spectral: SpectralConfig = field(default_factory=SpectralConfig)
143
- l2b: L2BConfig = field(default_factory=L2BConfig)
144
- structure: StructureConfig = field(default_factory=StructureConfig)
145
- alteration: AlterationConfig = field(default_factory=AlterationConfig)
146
- targets: TargetConfig = field(default_factory=TargetConfig)
147
- run_label: str = "" # shown on every map, e.g. "EXTENDED COVERAGE (NDVI <= 0.35) - lower confidence"
148
-
149
- def to_dict(self) -> dict[str, Any]:
150
- return asdict(self)
151
-
152
-
153
- def _apply(obj: Any, values: dict[str, Any], path: str = "") -> None:
154
- known = {f.name: f for f in fields(obj)}
155
- for key, value in (values or {}).items():
156
- if key not in known:
157
- raise KeyError(f"Unknown setting '{path}{key}'")
158
- current = getattr(obj, key)
159
- if is_dataclass(current):
160
- _apply(current, value, f"{path}{key}.")
161
- else:
162
- setattr(obj, key, value)
163
-
164
-
165
- def load_settings(path: str | Path | None = None, overrides: dict[str, Any] | None = None) -> Settings:
166
- settings = Settings()
167
- if path:
168
- with open(path, encoding="utf-8") as f:
169
- _apply(settings, yaml.safe_load(f) or {})
170
- if overrides:
171
- _apply(settings, overrides)
172
- return settings
 
 
1
+ """Run settings.
2
+
3
+ Every tunable parameter lives here, with its default. A YAML file (see
4
+ ``config/default.yaml``) can override any subset of them. The resolved settings
5
+ are written into each run's manifest, so every output is traceable to the exact
6
+ parameters that produced it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict, dataclass, field, fields, is_dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+
18
+ @dataclass
19
+ class GridConfig:
20
+ base_res_m: float = 30.0 # terrain / lineament grid (SRTM native ~30 m)
21
+ emit_factor: int = 2 # EMIT grid = 60 m (native EMIT pixel)
22
+ aster_factor: int = 3 # ASTER GED grid = 90 m (closest multiple to native 100 m)
23
+ align_m: float = 180.0 # origin snapped to this so all grids share edges
24
+ structure_pad_m: float = 1500.0 # extra DEM around the AOI so edge lineaments are complete
25
+
26
+
27
+ @dataclass
28
+ class AcquireConfig:
29
+ ee_project: str | None = None
30
+ start_date: str = "2022-08-01"
31
+ end_date: str | None = None # None = today
32
+ min_aoi_coverage: float = 0.05 # skip EMIT scenes covering less of the AOI than this
33
+ max_request_mb: float = 30.0 # Earth Engine computePixels payload limit is ~48 MB
34
+ retries: int = 4
35
+
36
+
37
+ @dataclass
38
+ class SpectralConfig:
39
+ mc_draws: int = 64 # Monte-Carlo draws for uncertainty propagation
40
+ seed: int = 42
41
+ shoulder_halfwidth_nm: float = 10.0 # shoulders are averaged over +/- this window
42
+ detect_sigma: float = 2.0 # detection = depth > detect_sigma * sigma
43
+ min_depth: float = 0.01 # ... and depth above this absolute floor
44
+ ndvi_max: float = 0.30 # pixels greener than this are masked as vegetation
45
+ min_albedo: float = 0.04 # darker pixels (shadow, water, burn scars) are masked
46
+ chunk_pixels: int = 4096
47
+
48
+
49
+ @dataclass
50
+ class L2BConfig:
51
+ group2_min_fit: float = 0.30 # 2 um vibrational group (Tetracorder group 2)
52
+ group2_max_unc: float = 0.025
53
+ group1_min_fit: float = 0.25 # 1 um electronic Fe group (Tetracorder group 1)
54
+ group1_max_unc: float = 0.020
55
+
56
+
57
+ @dataclass
58
+ class StructureConfig:
59
+ n_azimuths: int = 8 # hillshade illumination directions (every 360/n deg)
60
+ sun_elevation_deg: float = 30.0
61
+ smooth_sigma_px: float = 1.0
62
+ canny_low_quantile: float = 0.80 # adaptive Canny thresholds (terrain independent)
63
+ canny_high_quantile: float = 0.95
64
+ hough_threshold: int = 40
65
+ min_segment_m: float = 450.0
66
+ max_gap_m: float = 90.0
67
+ merge_angle_deg: float = 12.0
68
+ merge_perp_m: float = 90.0
69
+ merge_gap_m: float = 300.0
70
+ dedup_angle_deg: float = 10.0
71
+ dedup_perp_m: float = 60.0
72
+ min_length_m: float = 800.0
73
+ density_radius_m: float = 1000.0
74
+ support_min_fraction: float = 0.5 # share of a line that must lie on edges of one azimuth
75
+ local_contrast: bool = True # CLAHE on hillshades + local normalisation of ridge filter
76
+ clahe_window_m: float = 1500.0
77
+ clahe_clip: float = 0.01
78
+ ridge_valley: bool = True # add Hessian ridge/valley axes to the edge maps
79
+ ridge_sigmas_m: tuple = (30.0, 60.0)
80
+ ridge_quantile: float = 0.90
81
+ profile_offset_m: float = 90.0 # cross-profile sample distance either side of a line
82
+ scarp_min_step_m: float = 2.0 # one-sided relief needed to call a line a scarp
83
+ scarp_step_ratio: float = 1.0 # scarp if step > ratio * |centre - mean(sides)|
84
+ exclude_scarps: bool = True # leave scarps out of distance / density / S_prox
85
+ dem_source: str = "srtm" # srtm | aw3d30 | copernicus
86
+ canny_tile_m: float = 0.0 # > 0: Canny quantile thresholds per tile of this size (0 = whole grid)
87
+
88
+
89
+ @dataclass
90
+ class AlterationConfig:
91
+ """Four alteration layers (methodology sections 3-4)."""
92
+ sigma_core_m: float = 90.0 # core vent field kernel (4.3)
93
+ sigma_halo_m: float = 375.0 # hydrothermal halo kernel (4.3)
94
+ theta_decay: float = 0.05 # confidence = 1 - exp(-W_halo / theta)
95
+ reference_percentile: float = 98.0 # a = clip(F / P98(F > 0), 0, 1) (4.4)
96
+ sericite_position_nm: tuple = (2190.0, 2215.0) # Al-OH minimum accepted as white mica
97
+ propylitic_l2b_classes: tuple = ("chlorite", "epidote", "prehnite", "amphibole")
98
+ quartz_weights: tuple = (0.55, 0.45) # QSI = 0.55 QI + 0.45 SI (4.5)
99
+ quartz_background_percentile: float = 15.0 # a_qtz = (QSI - P15) / (P98 - P15)
100
+ # EMIT L2B evidence in the alteration layers (weight = fit / uncertainty, as in the legacy method):
101
+ # "none" L2A detections only (default)
102
+ # "combine_all" add QC-passed L2B identifications everywhere (incl. vegetation-masked pixels)
103
+ # "combine_valid" add L2B only on pixels where L2A has a valid observation
104
+ l2b_evidence: str = "none"
105
+ l2b_classes: dict = field(default_factory=lambda: {
106
+ "muscovite": ["white_mica"], "chlorite": ["chlorite", "epidote"], "gossan": ["ferric_oxide", "jarosite"]})
107
+ # Lithology background removal: subtract the per-unit median of each layer (mapped rock units),
108
+ # keep the positive residual ("stronger than normal for this rock"), then P98-normalise.
109
+ lithology_background: bool = False
110
+ lithology_file: str | None = None # default: data/<aoi>/ground_truth/lithology*.geojson
111
+ lithology_field: str = "LITHOLOGICAL_UNIT"
112
+ min_unit_area_km2: float = 0.25 # smaller units (and unmapped ground) use the block median
113
+ min_support: float = 0.20 # usable-pixel fraction in the halo below which a map shows "no data"
114
+ support_sigma_m: float | None = None # neighbourhood for data support: None = sigma_halo / 2 (~190 m),
115
+ # > 0 = Gaussian of this sigma, 0 = pixel or an immediate neighbour usable
116
+ display_upsample: int = 4
117
+
118
+
119
+ @dataclass
120
+ class TargetConfig:
121
+ """Copper Prospectivity Index and drill-target selection (methodology sections 5-6)."""
122
+ w_hydrothermal: float = 0.70
123
+ w_structure: float = 0.30
124
+ w_muscovite: float = 0.30
125
+ w_quartz: float = 0.28
126
+ w_gossan: float = 0.22
127
+ w_chlorite: float = 0.20
128
+ structure_lambda_m: float = 250.0 # S_prox = exp(-d / lambda) (4.6)
129
+ min_cpi: float = 0.25
130
+ n_targets: int = 50
131
+ min_spacing_m: float = 250.0
132
+ tier1: float = 0.70
133
+ tier2: float = 0.50
134
+ tier3: float = 0.28
135
+ require_data_support: bool = True # no field-check locations where EMIT had no usable pixels
136
+ zone_min_area_km2: float = 0.01 # prospective zones smaller than this (~11 cells) are dropped
137
+
138
+
139
+ @dataclass
140
+ class Settings:
141
+ grid: GridConfig = field(default_factory=GridConfig)
142
+ acquire: AcquireConfig = field(default_factory=AcquireConfig)
143
+ spectral: SpectralConfig = field(default_factory=SpectralConfig)
144
+ l2b: L2BConfig = field(default_factory=L2BConfig)
145
+ structure: StructureConfig = field(default_factory=StructureConfig)
146
+ alteration: AlterationConfig = field(default_factory=AlterationConfig)
147
+ targets: TargetConfig = field(default_factory=TargetConfig)
148
+ run_label: str = "" # shown on every map, e.g. "EXTENDED COVERAGE (NDVI <= 0.35) - lower confidence"
149
+
150
+ def to_dict(self) -> dict[str, Any]:
151
+ return asdict(self)
152
+
153
+
154
+ def _apply(obj: Any, values: dict[str, Any], path: str = "") -> None:
155
+ known = {f.name: f for f in fields(obj)}
156
+ for key, value in (values or {}).items():
157
+ if key not in known:
158
+ raise KeyError(f"Unknown setting '{path}{key}'")
159
+ current = getattr(obj, key)
160
+ if is_dataclass(current):
161
+ _apply(current, value, f"{path}{key}.")
162
+ else:
163
+ setattr(obj, key, value)
164
+
165
+
166
+ def load_settings(path: str | Path | None = None, overrides: dict[str, Any] | None = None) -> Settings:
167
+ settings = Settings()
168
+ if path:
169
+ with open(path, encoding="utf-8") as f:
170
+ _apply(settings, yaml.safe_load(f) or {})
171
+ if overrides:
172
+ _apply(settings, overrides)
173
+ return settings
geoscan/geoscan/pipeline.py CHANGED
@@ -455,10 +455,10 @@ class Pipeline:
455
  return {"minerals": list(MINERALS)}
456
 
457
  def step_targets(self) -> dict:
458
- """CPI and ranked drill targets (methodology sections 5-6)."""
459
  import geopandas as gpd
460
  from shapely.geometry import Point
461
- from .prospectivity.targets import cpi, select_targets
462
  cfg = self.settings.targets
463
  out = self.ws.outputs
464
 
@@ -475,29 +475,57 @@ class Pipeline:
475
  f"H = {cfg.w_muscovite:g} musc + {cfg.w_quartz:g} qtz + {cfg.w_gossan:g} goss + "
476
  f"{cfg.w_chlorite:g} chl, S_prox = exp(-d/{cfg.structure_lambda_m:g} m)")
477
 
 
 
 
 
 
 
 
 
 
 
 
478
  eligible = inside & ((a["data_support"] >= self.settings.alteration.min_support)
479
  if cfg.require_data_support else True)
480
  attrs = {"Muscovite_Pct": 100 * a["muscovite"], "Quartz_Pct": 100 * a["quartz"],
481
  "Gossan_Pct": 100 * a["gossan"], "Chlorite_Pct": 100 * a["chlorite"],
482
  "Hydrothermal_Index": h, "Structural_Proximity": s_prox, "Distance_to_Lineament_m": dist,
483
  "EMIT_Data_Support": a["data_support"]}
484
- df = select_targets(c, grid, attrs, eligible, cfg)
485
  for col in ("Muscovite_Pct", "Quartz_Pct", "Gossan_Pct", "Chlorite_Pct"):
486
  if col in df:
487
  df[col] = df[col].round(1)
488
  if "Distance_to_Lineament_m" in df:
489
  df["Distance_to_Lineament_m"] = df["Distance_to_Lineament_m"].round(0)
490
- self._table("tables/top50_targets.csv", df, "Ranked drill targets (CPI, 250 m minimum spacing)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  if len(df):
492
  gdf = gpd.GeoDataFrame(df, geometry=[Point(xy) for xy in zip(df["Longitude"], df["Latitude"])], crs=4326)
493
- p = self.ws.out("vectors", "top50_targets.geojson")
494
- gdf.to_file(p, driver="GeoJSON")
495
- self._record(p, "Ranked drill targets (EPSG:4326)", features=len(gdf))
 
496
  self._zones(np.nan_to_num(c), grid, (cfg.tier3, cfg.tier2, cfg.tier1, 1.001),
497
  ("Tier 3 General Anomaly", "Tier 2 Medium Priority", "Tier 1 High Priority"),
498
- "vectors/cpi_target_zones.geojson", "CPI target zones")
499
- tiers = df["Priority_Tier"].value_counts().to_dict() if len(df) else {}
500
- return {"targets": len(df), "tiers": tiers, "cpi_max": round(float(np.nanmax(c)), 4)}
 
501
 
502
  def step_maps(self) -> dict:
503
  from .report import render
 
455
  return {"minerals": list(MINERALS)}
456
 
457
  def step_targets(self) -> dict:
458
+ """CPI, ranked prospective zones and suggested field-check locations (methodology sections 5-6)."""
459
  import geopandas as gpd
460
  from shapely.geometry import Point
461
+ from .prospectivity.targets import cpi, prospective_zones, select_locations
462
  cfg = self.settings.targets
463
  out = self.ws.outputs
464
 
 
475
  f"H = {cfg.w_muscovite:g} musc + {cfg.w_quartz:g} qtz + {cfg.w_gossan:g} goss + "
476
  f"{cfg.w_chlorite:g} chl, S_prox = exp(-d/{cfg.structure_lambda_m:g} m)")
477
 
478
+ # ---- prospective zones: the main result (a region, not a point)
479
+ lines = None
480
+ lp = out / "vectors/lineaments.gpkg"
481
+ if lp.exists():
482
+ lines = gpd.read_file(lp).to_crs(grid.crs)
483
+ if self.settings.structure.exclude_scarps and len(lines):
484
+ lines = lines[lines["morphology"] != "scarp"]
485
+ zones, zone_rank = prospective_zones(c, grid, a, s_prox, cfg, lines, a["data_support"],
486
+ self.settings.alteration.min_support)
487
+
488
+ # ---- suggested field-check locations: where to start looking inside the zones
489
  eligible = inside & ((a["data_support"] >= self.settings.alteration.min_support)
490
  if cfg.require_data_support else True)
491
  attrs = {"Muscovite_Pct": 100 * a["muscovite"], "Quartz_Pct": 100 * a["quartz"],
492
  "Gossan_Pct": 100 * a["gossan"], "Chlorite_Pct": 100 * a["chlorite"],
493
  "Hydrothermal_Index": h, "Structural_Proximity": s_prox, "Distance_to_Lineament_m": dist,
494
  "EMIT_Data_Support": a["data_support"]}
495
+ df = select_locations(c, grid, attrs, eligible, cfg, zone_rank)
496
  for col in ("Muscovite_Pct", "Quartz_Pct", "Gossan_Pct", "Chlorite_Pct"):
497
  if col in df:
498
  df[col] = df[col].round(1)
499
  if "Distance_to_Lineament_m" in df:
500
  df["Distance_to_Lineament_m"] = df["Distance_to_Lineament_m"].round(0)
501
+ zones["Field_Check_Locations"] = [int((df["Zone"] == z).sum()) if len(df) else 0 for z in zones["Zone_ID"]] \
502
+ if len(zones) else []
503
+
504
+ for stale in ("tables/top50_targets.csv", "vectors/top50_targets.geojson"): # names used before zones
505
+ (out / stale).unlink(missing_ok=True)
506
+ self._outputs.pop(stale, None)
507
+ self._table("tables/prospective_zones.csv", pd.DataFrame(zones.drop(columns="geometry")),
508
+ f"Prospective zones: connected areas with CPI >= {cfg.tier2:g} (Tier 1 / 2) and isolated anomalies "
509
+ f"with CPI >= {cfg.tier3:g} (Tier 3), ranked by tier and peak CPI")
510
+ zp = self.ws.out("vectors", "prospective_zones.geojson")
511
+ zones.to_crs(4326).to_file(zp, driver="GeoJSON")
512
+ self._record(zp, "Prospective zones, ranked (EPSG:4326)", features=len(zones))
513
+ self._table("tables/field_check_locations.csv", df,
514
+ f"Suggested field-check locations (highest CPI cells, >= {cfg.min_spacing_m:g} m apart). "
515
+ "Starting points for field work inside the zones, not drill collars.")
516
+ fp = self.ws.out("vectors", "field_check_locations.geojson")
517
  if len(df):
518
  gdf = gpd.GeoDataFrame(df, geometry=[Point(xy) for xy in zip(df["Longitude"], df["Latitude"])], crs=4326)
519
+ gdf.to_file(fp, driver="GeoJSON")
520
+ self._record(fp, "Suggested field-check locations (EPSG:4326)", features=len(gdf))
521
+ else:
522
+ fp.unlink(missing_ok=True)
523
  self._zones(np.nan_to_num(c), grid, (cfg.tier3, cfg.tier2, cfg.tier1, 1.001),
524
  ("Tier 3 General Anomaly", "Tier 2 Medium Priority", "Tier 1 High Priority"),
525
+ "vectors/cpi_target_zones.geojson", "CPI tier bands")
526
+ tiers = zones["Priority_Tier"].value_counts().to_dict() if len(zones) else {}
527
+ return {"zones": len(zones), "zone_tiers": tiers, "zone_area_km2": round(float(zones["Area_km2"].sum()), 3)
528
+ if len(zones) else 0.0, "field_check_locations": len(df), "cpi_max": round(float(np.nanmax(c)), 4)}
529
 
530
  def step_maps(self) -> dict:
531
  from .report import render
geoscan/geoscan/prospectivity/targets.py CHANGED
@@ -1,78 +1,183 @@
1
- """Copper Prospectivity Index (CPI) and drill-target selection (methodology sections 5-6).
2
-
3
- H = 0.30 a_musc + 0.28 a_qtz + 0.22 a_goss + 0.20 a_chl
4
- CPI = 1_block * (0.70 H + 0.30 S_prox), S_prox = exp(-d_lineament / 250 m)
5
-
6
- CPI is not re-stretched, so the tier thresholds (0.70 / 0.50 / 0.28) are absolute.
7
-
8
- Targets: cells with CPI >= 0.25 (and usable EMIT data when ``require_data_support``) are
9
- sorted by CPI and accepted greedily if >= 250 m from every accepted collar, up to 50.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import numpy as np
15
- import pandas as pd
16
- from pyproj import Transformer
17
-
18
- from ..config import TargetConfig
19
- from ..grid import Grid
20
-
21
-
22
- def cpi(a: dict[str, np.ndarray], s_prox: np.ndarray, inside: np.ndarray, cfg: TargetConfig):
23
- h = (cfg.w_muscovite * a["muscovite"] + cfg.w_quartz * a["quartz"] +
24
- cfg.w_gossan * a["gossan"] + cfg.w_chlorite * a["chlorite"])
25
- c = cfg.w_hydrothermal * h + cfg.w_structure * s_prox
26
- return np.where(inside, c, np.nan).astype("float32"), np.where(inside, h, np.nan).astype("float32")
27
-
28
-
29
- def tier(value: float, cfg: TargetConfig) -> tuple[str, str]:
30
- if value >= cfg.tier1:
31
- return "Tier 1: High Priority", "Diamond core drilling (DDH)"
32
- if value >= cfg.tier2:
33
- return "Tier 2: Medium Priority", "RC drilling / trenching across strike"
34
- if value >= cfg.tier3:
35
- return "Tier 3: General Anomaly", "Geological field mapping & rock-chip geochemistry"
36
- return "Below Tier 3", "Field verification"
37
-
38
-
39
- def target_name(rank: int) -> str:
40
- if rank <= 26:
41
- return f"Target {chr(64 + rank)}"
42
- return f"Target A{chr(64 + rank - 26)}"
43
-
44
-
45
- def select_targets(c: np.ndarray, grid: Grid, layers: dict[str, np.ndarray], eligible: np.ndarray,
46
- cfg: TargetConfig) -> pd.DataFrame:
47
- ok = eligible & np.isfinite(c) & (c >= cfg.min_cpi)
48
- rows, cols = np.nonzero(ok)
49
- order = np.argsort(-c[rows, cols], kind="stable")
50
- rows, cols = rows[order], cols[order]
51
- xs = grid.x0 + (cols + 0.5) * grid.res
52
- ys = grid.y0 - (rows + 0.5) * grid.res
53
-
54
- chosen: list[int] = []
55
- cx, cy = np.empty(0), np.empty(0)
56
- for i in range(len(rows)):
57
- if cx.size and np.min((cx - xs[i]) ** 2 + (cy - ys[i]) ** 2) < cfg.min_spacing_m ** 2:
58
- continue
59
- chosen.append(i)
60
- cx, cy = np.append(cx, xs[i]), np.append(cy, ys[i])
61
- if len(chosen) >= cfg.n_targets:
62
- break
63
-
64
- lon, lat = Transformer.from_crs(grid.crs, "EPSG:4326", always_xy=True).transform(xs[chosen], ys[chosen])
65
- recs = []
66
- for rank, (i, lo, la) in enumerate(zip(chosen, lon, lat), start=1):
67
- r, cc = rows[i], cols[i]
68
- t, rec = tier(float(c[r, cc]), cfg)
69
- row = {"Rank": rank, "Target_ID": f"UNM-TGT-{rank:02d}", "Target_Name": target_name(rank),
70
- "Latitude": round(float(la), 6), "Longitude": round(float(lo), 6),
71
- "Easting": round(float(xs[i]), 1), "Northing": round(float(ys[i]), 1),
72
- "CPI": round(float(c[r, cc]), 4), "CPI_Percent": round(100 * float(c[r, cc]), 1),
73
- "Priority_Tier": t}
74
- for k, v in layers.items():
75
- row[k] = round(float(v[r, cc]), 4) if np.isfinite(v[r, cc]) else None
76
- row["Exploration_Recommendation"] = rec
77
- recs.append(row)
78
- return pd.DataFrame(recs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Copper Prospectivity Index (CPI), prospective zones and suggested field-check locations
2
+ (methodology sections 5-6).
3
+
4
+ H = 0.30 a_musc + 0.28 a_qtz + 0.22 a_goss + 0.20 a_chl
5
+ CPI = 1_block * (0.70 H + 0.30 S_prox), S_prox = exp(-d_lineament / 250 m)
6
+
7
+ CPI is not re-stretched, so the tier thresholds (0.70 / 0.50 / 0.28) are absolute.
8
+
9
+ The result of this kind of work is a *region*, not a point. The data are 60-90 m pixels spread over a
10
+ few hundred metres, and they describe the surface, not the ore. So the main product is the set of
11
+ **prospective zones**: connected areas with CPI >= the Tier 2 threshold (plus isolated Tier 3 anomalies),
12
+ ranked by tier and then by peak CPI. The Tier 3 contour around them is their outer halo.
13
+
14
+ **Suggested field-check locations** are a convenience for planning traverses: the highest-CPI cells,
15
+ kept >= 250 m apart so each anomaly gets about one. They mark where to start looking inside a zone.
16
+ They are not drill collars, and their exact position is not significant.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import geopandas as gpd
22
+ import numpy as np
23
+ import pandas as pd
24
+ from pyproj import Transformer
25
+ from rasterio.features import shapes
26
+ from scipy.ndimage import label
27
+ from shapely.geometry import shape
28
+ from shapely.ops import unary_union
29
+
30
+ from ..config import TargetConfig
31
+ from ..grid import Grid
32
+
33
+ MINERALS = ("muscovite", "quartz", "gossan", "chlorite")
34
+ TIER_LABELS = {1: "Tier 1: High Priority", 2: "Tier 2: Medium Priority", 3: "Tier 3: General Anomaly"}
35
+ FOLLOW_UP = {
36
+ 1: "Detailed geological mapping, systematic rock-chip / soil sampling and ground geophysics",
37
+ 2: "Reconnaissance mapping and rock-chip sampling",
38
+ 3: "Low priority: check during regional traverses",
39
+ 0: "Field verification",
40
+ }
41
+
42
+
43
+ def cpi(a: dict[str, np.ndarray], s_prox: np.ndarray, inside: np.ndarray, cfg: TargetConfig):
44
+ h = (cfg.w_muscovite * a["muscovite"] + cfg.w_quartz * a["quartz"] +
45
+ cfg.w_gossan * a["gossan"] + cfg.w_chlorite * a["chlorite"])
46
+ c = cfg.w_hydrothermal * h + cfg.w_structure * s_prox
47
+ return np.where(inside, c, np.nan).astype("float32"), np.where(inside, h, np.nan).astype("float32")
48
+
49
+
50
+ def tier_number(value: float, cfg: TargetConfig) -> int:
51
+ return 1 if value >= cfg.tier1 else 2 if value >= cfg.tier2 else 3 if value >= cfg.tier3 else 0
52
+
53
+
54
+ def tier(value: float, cfg: TargetConfig) -> tuple[str, str]:
55
+ """(tier label, suggested follow-up). Follow-up is staged field work; nothing here justifies drilling."""
56
+ n = tier_number(value, cfg)
57
+ return TIER_LABELS.get(n, "Below Tier 3"), FOLLOW_UP[n]
58
+
59
+
60
+ def location_name(rank: int) -> str:
61
+ if rank <= 26:
62
+ return f"Location {chr(64 + rank)}"
63
+ return f"Location A{chr(64 + rank - 26)}"
64
+
65
+
66
+ def prospective_zones(c: np.ndarray, grid: Grid, a: dict[str, np.ndarray], s_prox: np.ndarray, cfg: TargetConfig,
67
+ lineaments: gpd.GeoDataFrame | None = None, support: np.ndarray | None = None,
68
+ min_support: float = 0.20):
69
+ """Ranked prospective zones.
70
+
71
+ A zone is a connected area with CPI >= tier2 (Tier 1 if it reaches tier1 somewhere, otherwise Tier 2).
72
+ A weak anomaly that is separate from every such zone and only reaches tier3 is a Tier 3 zone.
73
+ The tier3 contour around the Tier 1 / 2 zones is their outer halo; it is not part of the zone.
74
+ (Using tier3 as the boundary merges whole districts into one blob: at Thanewasna it covered 56 % of the
75
+ block for 74 % of the lode area, against 16.5 % of the block for 48.5 % of the lodes with tier2.)
76
+
77
+ Returns (GeoDataFrame in grid CRS, int raster with the zone rank per cell, 0 = no zone).
78
+ """
79
+ eight = np.ones((3, 3), int)
80
+ finite = np.isfinite(c)
81
+ core, n_core = label(finite & (c >= cfg.tier2), structure=eight)
82
+ halo, n_halo = label(finite & (c >= cfg.tier3), structure=eight)
83
+ masks = [core == k for k in range(1, n_core + 1)]
84
+ with_core = set(np.unique(halo[core > 0])) # halo components that contain a core
85
+ masks += [halo == k for k in range(1, n_halo + 1) if k not in with_core]
86
+
87
+ cell_km2 = grid.res ** 2 / 1e6
88
+ ll = Transformer.from_crs(grid.crs, "EPSG:4326", always_xy=True)
89
+ weights = {"muscovite": cfg.w_muscovite, "quartz": cfg.w_quartz, "gossan": cfg.w_gossan, "chlorite": cfg.w_chlorite}
90
+ lines = unary_union(list(lineaments.geometry)) if lineaments is not None and len(lineaments) else None
91
+
92
+ recs, geoms, kept = [], [], []
93
+ for m in masks:
94
+ area = float(m.sum()) * cell_km2
95
+ if area < cfg.zone_min_area_km2:
96
+ continue
97
+ geom = unary_union([shape(g) for g, v in shapes(m.astype("uint8"), mask=m, transform=grid.transform,
98
+ connectivity=8)])
99
+ geom = geom.buffer(grid.res / 2).buffer(-grid.res / 2).simplify(grid.res / 4) # soften the pixel steps
100
+ if geom.is_empty:
101
+ continue
102
+ cz = c[m]
103
+ peak = float(cz.max())
104
+ means = {k2: float(np.nanmean(a[k2][m])) for k2 in MINERALS}
105
+ contrib = sorted(((weights[k2] * means[k2], k2) for k2 in MINERALS), reverse=True)
106
+ struct = float(np.nanmean(s_prox[m]))
107
+ evidence = [k2 for _, k2 in contrib[:2]] + (["structure"] if struct >= 0.5 else [])
108
+ pr, pc = np.unravel_index(np.argmax(np.where(m, c, -1)), c.shape) # the peak cell
109
+ lon, lat = ll.transform(grid.x0 + (pc + 0.5) * grid.res, grid.y0 - (pr + 0.5) * grid.res)
110
+ t = tier_number(peak, cfg)
111
+ # same rule as the map hatching: a cell is assessed if EMIT saw the ground there OR detected a mineral
112
+ emit = np.maximum.reduce([a["muscovite"][m], a["chlorite"][m], a["gossan"][m]])
113
+ seen = 1.0 if support is None else float(((np.nan_to_num(support[m]) >= min_support) | (emit >= 0.05)).mean())
114
+ basis = "EMIT alteration, quartz and structure" if seen >= 0.8 else \
115
+ "Partly assessed by EMIT; elsewhere quartz and structure only" if seen >= 0.2 else \
116
+ "Quartz and structure only (EMIT not assessed)"
117
+ recs.append({
118
+ "tier": t, "Priority_Tier": TIER_LABELS[t], "Area_km2": round(area, 3),
119
+ "CPI_Max_Pct": round(100 * peak, 1), "CPI_Mean_Pct": round(100 * float(cz.mean()), 1),
120
+ "Tier1_Area_km2": round(float((cz >= cfg.tier1).sum()) * cell_km2, 3),
121
+ **{f"{k2.capitalize()}_Pct": round(100 * means[k2], 1) for k2 in MINERALS},
122
+ "Structural_Proximity": round(struct, 2),
123
+ "Lineaments_km": round(geom.intersection(lines).length / 1000, 2) if lines is not None else 0.0,
124
+ "Main_Evidence": ", ".join(evidence), "Evidence_Basis": basis,
125
+ "Peak_Latitude": round(float(lat), 4), "Peak_Longitude": round(float(lon), 4),
126
+ "Suggested_Follow_Up": FOLLOW_UP[t],
127
+ })
128
+ geoms.append(geom)
129
+ kept.append(m)
130
+
131
+ order = sorted(range(len(recs)), key=lambda i: (recs[i]["tier"], -recs[i]["CPI_Max_Pct"], -recs[i]["Area_km2"]))
132
+ rank_raster = np.zeros(c.shape, "int32")
133
+ out = []
134
+ for rank, i in enumerate(order, start=1):
135
+ rank_raster[kept[i]] = rank
136
+ out.append({"Zone": rank, "Zone_ID": f"Z{rank:02d}", **{k: v for k, v in recs[i].items() if k != "tier"}})
137
+ gdf = gpd.GeoDataFrame(out, geometry=[geoms[i] for i in order], crs=grid.crs)
138
+ return gdf, rank_raster
139
+
140
+
141
+ def select_locations(c: np.ndarray, grid: Grid, layers: dict[str, np.ndarray], eligible: np.ndarray,
142
+ cfg: TargetConfig, zone_rank: np.ndarray | None = None) -> pd.DataFrame:
143
+ """Suggested field-check locations: highest-CPI cells, greedily kept >= min_spacing_m apart."""
144
+ ok = eligible & np.isfinite(c) & (c >= cfg.min_cpi)
145
+ rows, cols = np.nonzero(ok)
146
+ order = np.argsort(-c[rows, cols], kind="stable")
147
+ rows, cols = rows[order], cols[order]
148
+ xs = grid.x0 + (cols + 0.5) * grid.res
149
+ ys = grid.y0 - (rows + 0.5) * grid.res
150
+
151
+ chosen: list[int] = []
152
+ cx, cy = np.empty(0), np.empty(0)
153
+ for i in range(len(rows)):
154
+ if cx.size and np.min((cx - xs[i]) ** 2 + (cy - ys[i]) ** 2) < cfg.min_spacing_m ** 2:
155
+ continue
156
+ chosen.append(i)
157
+ cx, cy = np.append(cx, xs[i]), np.append(cy, ys[i])
158
+ if len(chosen) >= cfg.n_targets:
159
+ break
160
+
161
+ lon, lat = Transformer.from_crs(grid.crs, "EPSG:4326", always_xy=True).transform(xs[chosen], ys[chosen])
162
+ recs = []
163
+ for rank, (i, lo, la) in enumerate(zip(chosen, lon, lat), start=1):
164
+ r, cc = rows[i], cols[i]
165
+ t, follow = tier(float(c[r, cc]), cfg)
166
+ z = int(zone_rank[r, cc]) if zone_rank is not None else 0
167
+ where = f"Z{z:02d}" if z else "outer halo" if c[r, cc] >= cfg.tier3 else "outside zones"
168
+ row = {"Rank": rank, "Location_ID": f"FC-{rank:02d}", "Location_Name": location_name(rank),
169
+ "Zone": where,
170
+ "Latitude": round(float(la), 5), "Longitude": round(float(lo), 5),
171
+ "Easting": round(float(xs[i]), 1), "Northing": round(float(ys[i]), 1),
172
+ "CPI": round(float(c[r, cc]), 4), "CPI_Percent": round(100 * float(c[r, cc]), 1),
173
+ "Priority_Tier": t}
174
+ for k, v in layers.items():
175
+ row[k] = round(float(v[r, cc]), 4) if np.isfinite(v[r, cc]) else None
176
+ row["Check_Within_m"] = int(round(cfg.min_spacing_m))
177
+ row["Suggested_Follow_Up"] = follow
178
+ recs.append(row)
179
+ return pd.DataFrame(recs)
180
+
181
+
182
+ # backwards-compatible name used by older scripts
183
+ select_targets = select_locations
geoscan/geoscan/report/contour_maps.py CHANGED
@@ -1,242 +1,253 @@
1
- """Publication-style contour maps in longitude / latitude.
2
-
3
- Filled contours of a smooth surface, labelled isolines, clipped to the AOI, over an SRTM
4
- hillshade, with lineaments, scale bar and north arrow. Areas with no usable data are shaded
5
- and labelled rather than filled.
6
-
7
- Surfaces are resampled finer (bicubic) for drawing only; the GeoTIFFs keep native resolution.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from pathlib import Path
13
-
14
- import matplotlib
15
-
16
- matplotlib.use("Agg")
17
- import matplotlib.patheffects as pe # noqa: E402
18
- import matplotlib.pyplot as plt # noqa: E402
19
- import numpy as np # noqa: E402
20
- from matplotlib.lines import Line2D # noqa: E402
21
- from matplotlib.patches import Patch, PathPatch # noqa: E402
22
- from matplotlib.path import Path as MplPath # noqa: E402
23
- from pyproj import Transformer # noqa: E402
24
- from scipy.ndimage import distance_transform_edt, zoom # noqa: E402
25
-
26
- from ..grid import Grid # noqa: E402
27
- from .maps import save_figure # noqa: E402
28
-
29
- STROKE = [pe.withStroke(linewidth=2.2, foreground="white")]
30
- matplotlib.rcParams["hatch.color"] = "#a8a8a8"
31
- matplotlib.rcParams["hatch.linewidth"] = 0.5
32
-
33
-
34
- def lonlat_mesh(grid: Grid, factor: int = 1):
35
- """Cell-centre lon/lat arrays for ``grid`` subdivided by ``factor``."""
36
- res = grid.res / factor
37
- xs = grid.x0 + (np.arange(grid.width * factor) + 0.5) * res
38
- ys = grid.y0 - (np.arange(grid.height * factor) + 0.5) * res
39
- X, Y = np.meshgrid(xs, ys)
40
- lon, lat = Transformer.from_crs(grid.crs, "EPSG:4326", always_xy=True).transform(X, Y)
41
- return lon, lat
42
-
43
-
44
- def _fill_nearest(arr: np.ndarray) -> np.ndarray:
45
- valid = np.isfinite(arr)
46
- if valid.all() or not valid.any():
47
- return np.where(valid, arr, 0.0)
48
- _, (ri, ci) = distance_transform_edt(~valid, return_indices=True)
49
- return arr[ri, ci]
50
-
51
-
52
- def upsample(arr: np.ndarray, factor: int, support: np.ndarray | None = None,
53
- min_support: float | None = None) -> np.ndarray:
54
- """Bicubic resampling for display. With ``support``, the no-data boundary is the
55
- ``min_support`` isoline of the resampled support surface, so it is smooth too."""
56
- valid = np.isfinite(arr)
57
- if factor <= 1:
58
- return arr
59
- fine = zoom(_fill_nearest(arr), factor, order=3, grid_mode=True, mode="nearest")
60
- if support is not None and min_support is not None:
61
- fine_s = zoom(np.nan_to_num(support, nan=0.0), factor, order=3, grid_mode=True, mode="nearest")
62
- inside = zoom(np.isfinite(support).astype(float), factor, order=1, grid_mode=True, mode="nearest") > 0.5
63
- keep = (fine_s >= min_support) & inside
64
- else:
65
- keep = zoom(valid.astype(float), factor, order=1, grid_mode=True, mode="nearest") > 0.5
66
- return np.where(keep, fine, np.nan)
67
-
68
-
69
- def _aoi_patch(aoi_wgs84, ax):
70
- verts, codes = [], []
71
- for poly in getattr(aoi_wgs84, "geoms", [aoi_wgs84]):
72
- for ring in [poly.exterior, *poly.interiors]:
73
- xy = np.asarray(ring.coords)[:, :2]
74
- verts.extend(xy)
75
- codes.extend([MplPath.MOVETO] + [MplPath.LINETO] * (len(xy) - 2) + [MplPath.CLOSEPOLY])
76
- return PathPatch(MplPath(verts, codes), transform=ax.transData, facecolor="none", edgecolor="none")
77
-
78
-
79
- class ContourMap:
80
- def __init__(self, aoi_wgs84, hillshade=None, lines_wgs84=None, figsize=(10, 8.6)):
81
- self.aoi = aoi_wgs84
82
- self.hillshade = hillshade # (array, grid)
83
- self.lines = lines_wgs84
84
- self.figsize = figsize
85
-
86
- def _frame(self, title: str, subtitle: str | None):
87
- fig, ax = plt.subplots(figsize=self.figsize, dpi=250)
88
- fig.patch.set_facecolor("white")
89
- if self.hillshade is not None:
90
- hs, g = self.hillshade
91
- lon, lat = lonlat_mesh(g)
92
- ax.pcolormesh(lon, lat, hs, cmap="gray", shading="gouraud", alpha=0.55, zorder=1, rasterized=True)
93
- ax.set_title(title, fontsize=11.5, fontweight="bold", pad=22 if subtitle else 10)
94
- if subtitle:
95
- ax.text(0.5, 1.012, subtitle, transform=ax.transAxes, ha="center", va="bottom", fontsize=8.5,
96
- color="#444444")
97
- return fig, ax
98
-
99
- def _finish(self, fig, ax, path: Path, legend_handles=None, lines_label=True):
100
- minx, miny, maxx, maxy = self.aoi.bounds
101
- for poly in getattr(self.aoi, "geoms", [self.aoi]):
102
- x, y = poly.exterior.xy
103
- ax.plot(x, y, color="#111111", lw=1.6, zorder=8)
104
- handles = list(legend_handles or [])
105
- if self.lines is not None and len(self.lines):
106
- for g in self.lines.geometry:
107
- x, y = g.xy
108
- ax.plot(x, y, color="#00b8d4", lw=1.1, ls="--", alpha=0.9, zorder=7)
109
- if lines_label:
110
- handles.append(Line2D([0], [0], color="#00b8d4", lw=1.3, ls="--",
111
- label=f"Topographic lineaments (n={len(self.lines)})"))
112
- pad_x, pad_y = 0.02 * (maxx - minx), 0.02 * (maxy - miny)
113
- ax.set_xlim(minx - pad_x, maxx + pad_x)
114
- ax.set_ylim(miny - pad_y, maxy + pad_y)
115
- ax.set_aspect(1.0 / np.cos(np.radians((miny + maxy) / 2)))
116
- ax.set_xlabel("Longitude (°E)", fontsize=10, fontweight="bold")
117
- ax.set_ylabel("Latitude (°N)", fontsize=10, fontweight="bold")
118
- ax.grid(True, alpha=0.25, ls=":")
119
- ax.ticklabel_format(useOffset=False, style="plain")
120
-
121
- # 1 km scale bar and north arrow
122
- km_deg = 1.0 / (111.32 * np.cos(np.radians((miny + maxy) / 2)))
123
- sx, sy = minx + 3 * pad_x, miny + 2.5 * pad_y
124
- ax.plot([sx, sx + km_deg], [sy, sy], color="black", lw=3.5, solid_capstyle="butt", zorder=10)
125
- ax.text(sx + km_deg / 2, sy + 1.2 * pad_y, "1 km", ha="center", fontsize=8.5, fontweight="bold",
126
- path_effects=STROKE, zorder=10)
127
- ax.annotate("N", xy=(1.035, 0.99), xytext=(1.035, 0.88), xycoords="axes fraction", ha="center", va="top",
128
- fontsize=12, fontweight="bold", arrowprops=dict(arrowstyle="-|>", color="black", lw=2),
129
- annotation_clip=False, zorder=10)
130
- if handles:
131
- ax.legend(handles=handles, loc="lower left", bbox_to_anchor=(1.01, 0.0), fontsize=8,
132
- framealpha=0.95, edgecolor="#cccccc")
133
- fig.tight_layout()
134
- path.parent.mkdir(parents=True, exist_ok=True)
135
- save_figure(fig, path, bbox_inches="tight")
136
- plt.close(fig)
137
-
138
- def surface(self, path: Path, arr: np.ndarray, grid: Grid, title: str, cbar_label: str, *,
139
- cmap="YlOrRd", vmin=0.0, vmax=100.0, levels=(30, 55, 75), fmt="%d%%", subtitle=None,
140
- upsample_factor=4, support=None, min_support=None, contour_colors=None, alpha=0.82,
141
- targets=None, n_labels=0, n_stars=10, hatch_mask=None, hatch_label=None,
142
- nodata_label="Not assessed (no usable EMIT data)"):
143
- fig, ax = self._frame(title, subtitle)
144
- fine = upsample(arr, upsample_factor, support, min_support)
145
- lon, lat = lonlat_mesh(grid, upsample_factor)
146
- clip = _aoi_patch(self.aoi, ax)
147
- ax.add_patch(clip)
148
-
149
- handles = []
150
- nodata = ~np.isfinite(fine)
151
- # Only mark gaps that fall inside the AOI (outside it is clipped anyway).
152
- step = max(1, lon.size // 40000)
153
- sample = np.column_stack([lon.ravel()[::step], lat.ravel()[::step]])
154
- in_aoi = clip.get_path().contains_points(sample)
155
- gap_share = (nodata.ravel()[::step] & in_aoi).sum() / max(in_aoi.sum(), 1)
156
- gaps_inside = gap_share > 0.01 # ignore slivers along the boundary
157
- if gaps_inside:
158
- nd = ax.contourf(lon, lat, nodata.astype(float), levels=[0.5, 1.5], colors=["#e6e6e6"],
159
- hatches=["///"], alpha=0.6, zorder=2)
160
- nd.set_clip_path(clip)
161
- handles.append(Patch(facecolor="#e6e6e6", hatch="///", edgecolor="#a8a8a8", label=nodata_label))
162
-
163
- fill_levels = np.linspace(vmin, vmax, 41)
164
- cf = ax.contourf(lon, lat, np.clip(fine, vmin, vmax), levels=fill_levels, cmap=cmap, alpha=alpha,
165
- extend="neither", zorder=3)
166
- cf.set_clip_path(clip)
167
- lv = [l for l in levels if np.nanmin(fine) < l < np.nanmax(fine)] if np.isfinite(fine).any() else []
168
- if lv:
169
- colors = [contour_colors[levels.index(l)] for l in lv] if contour_colors else "#1a1a1a"
170
- cs = ax.contour(lon, lat, fine, levels=lv, colors=colors, linewidths=np.linspace(0.9, 1.5, len(lv)),
171
- alpha=0.9, zorder=5)
172
- cs.set_clip_path(clip)
173
- labels = ax.clabel(cs, inline=True, fontsize=7.5, fmt=fmt)
174
- stroke = [pe.withStroke(linewidth=2.2, foreground="black")] if contour_colors else STROKE
175
- plt.setp(labels, path_effects=stroke)
176
- if hatch_mask is not None and np.any(hatch_mask):
177
- fine_h = zoom(np.asarray(hatch_mask, float), upsample_factor, order=1, grid_mode=True, mode="nearest") \
178
- if upsample_factor > 1 else np.asarray(hatch_mask, float)
179
- hh = ax.contourf(lon, lat, fine_h, levels=[0.5, 1.5], colors=["none"], hatches=["\\\\\\\\"], zorder=4)
180
- hh.set_clip_path(clip)
181
- handles.append(Patch(facecolor="none", hatch="\\\\\\\\", edgecolor="#555555", label=hatch_label))
182
- if targets is not None and len(targets):
183
- top = targets.head(max(n_stars, n_labels))
184
- if n_labels:
185
- ax.scatter(top["Longitude"], top["Latitude"], s=70, marker="*", c="white", edgecolors="#d50000",
186
- linewidths=1.4, zorder=9)
187
- span = self.aoi.bounds[2] - self.aoi.bounds[0]
188
- minx, miny, maxx, maxy = self.aoi.bounds
189
- r = 0.055 * span
190
- dirs = [(np.cos(a), np.sin(a)) for a in np.radians([45, 135, 315, 225, 90, 270, 0, 180])]
191
- placed = [(x, y) for x, y in zip(top["Longitude"], top["Latitude"])] # avoid the stars too
192
- for _, t in top.head(n_labels).iterrows():
193
- best, best_d = None, -1.0
194
- for dx, dy in dirs:
195
- for k in (1.0, 1.6):
196
- cx, cy = t["Longitude"] + k * r * dx, t["Latitude"] + k * r * dy * 0.8
197
- if not (minx <= cx <= maxx and miny <= cy <= maxy):
198
- continue
199
- d = min(np.hypot((cx - px) / 1.8, cy - py) for px, py in placed)
200
- if d > best_d:
201
- best, best_d = (cx, cy), d
202
- best = best or (t["Longitude"] + r, t["Latitude"] + r)
203
- placed.append(best)
204
- ax.annotate(f"{t['Target_Name']}\n({t['CPI_Percent']:.0f}%)", xy=(t["Longitude"], t["Latitude"]),
205
- xytext=best, ha="center", va="center",
206
- fontsize=6.8, fontweight="bold", color="white", zorder=11,
207
- bbox=dict(boxstyle="round,pad=0.2", fc="#d50000", ec="white", lw=0.8, alpha=0.95),
208
- arrowprops=dict(arrowstyle="->", color="black", lw=0.9))
209
- handles.append(Line2D([0], [0], marker="*", color="w", markerfacecolor="white",
210
- markeredgecolor="#d50000", markersize=11, label=f"Top {n_labels} targets"))
211
- else:
212
- ax.scatter(top["Longitude"], top["Latitude"], s=55, marker="*", c="#ffd600", edgecolors="black",
213
- linewidths=0.8, zorder=9)
214
- handles.append(Line2D([0], [0], marker="*", color="w", markerfacecolor="#ffd600",
215
- markeredgecolor="black", markersize=10, label=f"Top {len(top)} CPI targets"))
216
- cb = fig.colorbar(cf, ax=ax, orientation="horizontal", pad=0.07, shrink=0.7,
217
- ticks=np.linspace(vmin, vmax, 6))
218
- cb.set_label(cbar_label, fontsize=9.5, fontweight="bold")
219
- self._finish(fig, ax, path, handles)
220
-
221
- def lineaments(self, path: Path, lines_wgs84, title: str, subtitle=None):
222
- fig, ax = self._frame(title, subtitle)
223
- colors = {"N-S": "#d50000", "NE-SW": "#ff6d00", "E-W": "#2962ff", "NW-SE": "#00c853"}
224
- lw = 1.0 + 2.5 * (lines_wgs84["length_m"] - lines_wgs84["length_m"].min()) / \
225
- max(lines_wgs84["length_m"].max() - lines_wgs84["length_m"].min(), 1.0)
226
- is_scarp = lines_wgs84.get("morphology", "").eq("scarp") if "morphology" in lines_wgs84 else \
227
- np.zeros(len(lines_wgs84), bool)
228
- for (_, row), w, scarp in zip(lines_wgs84.iterrows(), lw, is_scarp):
229
- x, y = row.geometry.xy
230
- if scarp:
231
- ax.plot(x, y, color="#757575", lw=max(1.0, 0.7 * w), ls="--", zorder=5)
232
- else:
233
- ax.plot(x, y, color=colors[row["strike_class"]], lw=w, solid_capstyle="round", zorder=6)
234
- structural = lines_wgs84[~np.asarray(is_scarp)]
235
- counts = structural["strike_class"].value_counts()
236
- handles = [Line2D([0], [0], color=c, lw=2.5, label=f"{k} (n={counts.get(k, 0)})") for k, c in colors.items()]
237
- if np.any(is_scarp):
238
- handles.append(Line2D([0], [0], color="#757575", lw=1.5, ls="--",
239
- label=f"Scarp / escarpment edge (n={int(np.sum(is_scarp))}, not used in CPI)"))
240
- saved, self.lines = self.lines, None
241
- self._finish(fig, ax, path, handles)
242
- self.lines = saved
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Publication-style contour maps in longitude / latitude.
2
+
3
+ Filled contours of a smooth surface, labelled isolines, clipped to the AOI, over an SRTM
4
+ hillshade, with lineaments, scale bar and north arrow. Areas with no usable data are shaded
5
+ and labelled rather than filled.
6
+
7
+ Surfaces are resampled finer (bicubic) for drawing only; the GeoTIFFs keep native resolution.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import matplotlib
15
+
16
+ matplotlib.use("Agg")
17
+ import matplotlib.patheffects as pe # noqa: E402
18
+ import matplotlib.pyplot as plt # noqa: E402
19
+ import numpy as np # noqa: E402
20
+ from matplotlib.lines import Line2D # noqa: E402
21
+ from matplotlib.patches import Patch, PathPatch # noqa: E402
22
+ from matplotlib.path import Path as MplPath # noqa: E402
23
+ from pyproj import Transformer # noqa: E402
24
+ from scipy.ndimage import distance_transform_edt, zoom # noqa: E402
25
+
26
+ from ..grid import Grid # noqa: E402
27
+ from .maps import save_figure # noqa: E402
28
+
29
+ STROKE = [pe.withStroke(linewidth=2.2, foreground="white")]
30
+ matplotlib.rcParams["hatch.color"] = "#a8a8a8"
31
+ matplotlib.rcParams["hatch.linewidth"] = 0.5
32
+
33
+
34
+ def lonlat_mesh(grid: Grid, factor: int = 1):
35
+ """Cell-centre lon/lat arrays for ``grid`` subdivided by ``factor``."""
36
+ res = grid.res / factor
37
+ xs = grid.x0 + (np.arange(grid.width * factor) + 0.5) * res
38
+ ys = grid.y0 - (np.arange(grid.height * factor) + 0.5) * res
39
+ X, Y = np.meshgrid(xs, ys)
40
+ lon, lat = Transformer.from_crs(grid.crs, "EPSG:4326", always_xy=True).transform(X, Y)
41
+ return lon, lat
42
+
43
+
44
+ def _fill_nearest(arr: np.ndarray) -> np.ndarray:
45
+ valid = np.isfinite(arr)
46
+ if valid.all() or not valid.any():
47
+ return np.where(valid, arr, 0.0)
48
+ _, (ri, ci) = distance_transform_edt(~valid, return_indices=True)
49
+ return arr[ri, ci]
50
+
51
+
52
+ def upsample(arr: np.ndarray, factor: int, support: np.ndarray | None = None,
53
+ min_support: float | None = None) -> np.ndarray:
54
+ """Bicubic resampling for display. With ``support``, the no-data boundary is the
55
+ ``min_support`` isoline of the resampled support surface, so it is smooth too."""
56
+ valid = np.isfinite(arr)
57
+ if factor <= 1:
58
+ return arr
59
+ fine = zoom(_fill_nearest(arr), factor, order=3, grid_mode=True, mode="nearest")
60
+ if support is not None and min_support is not None:
61
+ fine_s = zoom(np.nan_to_num(support, nan=0.0), factor, order=3, grid_mode=True, mode="nearest")
62
+ inside = zoom(np.isfinite(support).astype(float), factor, order=1, grid_mode=True, mode="nearest") > 0.5
63
+ keep = (fine_s >= min_support) & inside
64
+ else:
65
+ keep = zoom(valid.astype(float), factor, order=1, grid_mode=True, mode="nearest") > 0.5
66
+ return np.where(keep, fine, np.nan)
67
+
68
+
69
+ def _aoi_patch(aoi_wgs84, ax):
70
+ verts, codes = [], []
71
+ for poly in getattr(aoi_wgs84, "geoms", [aoi_wgs84]):
72
+ for ring in [poly.exterior, *poly.interiors]:
73
+ xy = np.asarray(ring.coords)[:, :2]
74
+ verts.extend(xy)
75
+ codes.extend([MplPath.MOVETO] + [MplPath.LINETO] * (len(xy) - 2) + [MplPath.CLOSEPOLY])
76
+ return PathPatch(MplPath(verts, codes), transform=ax.transData, facecolor="none", edgecolor="none")
77
+
78
+
79
+ class ContourMap:
80
+ def __init__(self, aoi_wgs84, hillshade=None, lines_wgs84=None, figsize=(10, 8.6)):
81
+ self.aoi = aoi_wgs84
82
+ self.hillshade = hillshade # (array, grid)
83
+ self.lines = lines_wgs84
84
+ self.figsize = figsize
85
+
86
+ def _frame(self, title: str, subtitle: str | None):
87
+ fig, ax = plt.subplots(figsize=self.figsize, dpi=250)
88
+ fig.patch.set_facecolor("white")
89
+ if self.hillshade is not None:
90
+ hs, g = self.hillshade
91
+ lon, lat = lonlat_mesh(g)
92
+ ax.pcolormesh(lon, lat, hs, cmap="gray", shading="gouraud", alpha=0.55, zorder=1, rasterized=True)
93
+ ax.set_title(title, fontsize=11.5, fontweight="bold", pad=22 if subtitle else 10)
94
+ if subtitle:
95
+ ax.text(0.5, 1.012, subtitle, transform=ax.transAxes, ha="center", va="bottom", fontsize=8.5,
96
+ color="#444444")
97
+ return fig, ax
98
+
99
+ def _finish(self, fig, ax, path: Path, legend_handles=None, lines_label=True):
100
+ minx, miny, maxx, maxy = self.aoi.bounds
101
+ for poly in getattr(self.aoi, "geoms", [self.aoi]):
102
+ x, y = poly.exterior.xy
103
+ ax.plot(x, y, color="#111111", lw=1.6, zorder=8)
104
+ handles = list(legend_handles or [])
105
+ if self.lines is not None and len(self.lines):
106
+ for g in self.lines.geometry:
107
+ x, y = g.xy
108
+ ax.plot(x, y, color="#00b8d4", lw=1.1, ls="--", alpha=0.9, zorder=7)
109
+ if lines_label:
110
+ handles.append(Line2D([0], [0], color="#00b8d4", lw=1.3, ls="--",
111
+ label=f"Topographic lineaments (n={len(self.lines)})"))
112
+ pad_x, pad_y = 0.02 * (maxx - minx), 0.02 * (maxy - miny)
113
+ ax.set_xlim(minx - pad_x, maxx + pad_x)
114
+ ax.set_ylim(miny - pad_y, maxy + pad_y)
115
+ ax.set_aspect(1.0 / np.cos(np.radians((miny + maxy) / 2)))
116
+ ax.set_xlabel("Longitude (°E)", fontsize=10, fontweight="bold")
117
+ ax.set_ylabel("Latitude (°N)", fontsize=10, fontweight="bold")
118
+ ax.grid(True, alpha=0.25, ls=":")
119
+ ax.ticklabel_format(useOffset=False, style="plain")
120
+
121
+ # 1 km scale bar and north arrow
122
+ km_deg = 1.0 / (111.32 * np.cos(np.radians((miny + maxy) / 2)))
123
+ sx, sy = minx + 3 * pad_x, miny + 2.5 * pad_y
124
+ ax.plot([sx, sx + km_deg], [sy, sy], color="black", lw=3.5, solid_capstyle="butt", zorder=10)
125
+ ax.text(sx + km_deg / 2, sy + 1.2 * pad_y, "1 km", ha="center", fontsize=8.5, fontweight="bold",
126
+ path_effects=STROKE, zorder=10)
127
+ ax.annotate("N", xy=(1.035, 0.99), xytext=(1.035, 0.88), xycoords="axes fraction", ha="center", va="top",
128
+ fontsize=12, fontweight="bold", arrowprops=dict(arrowstyle="-|>", color="black", lw=2),
129
+ annotation_clip=False, zorder=10)
130
+ if handles:
131
+ ax.legend(handles=handles, loc="lower left", bbox_to_anchor=(1.01, 0.0), fontsize=8,
132
+ framealpha=0.95, edgecolor="#cccccc")
133
+ fig.tight_layout()
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ save_figure(fig, path, bbox_inches="tight")
136
+ plt.close(fig)
137
+
138
+ def surface(self, path: Path, arr: np.ndarray, grid: Grid, title: str, cbar_label: str, *,
139
+ cmap="YlOrRd", vmin=0.0, vmax=100.0, levels=(30, 55, 75), fmt="%d%%", subtitle=None,
140
+ upsample_factor=4, support=None, min_support=None, contour_colors=None, alpha=0.82,
141
+ targets=None, n_labels=0, n_stars=10, hatch_mask=None, hatch_label=None, zones=None, n_zones=12,
142
+ nodata_label="Not assessed (no usable EMIT data)"):
143
+ fig, ax = self._frame(title, subtitle)
144
+ fine = upsample(arr, upsample_factor, support, min_support)
145
+ lon, lat = lonlat_mesh(grid, upsample_factor)
146
+ clip = _aoi_patch(self.aoi, ax)
147
+ ax.add_patch(clip)
148
+
149
+ handles = []
150
+ nodata = ~np.isfinite(fine)
151
+ # Only mark gaps that fall inside the AOI (outside it is clipped anyway).
152
+ step = max(1, lon.size // 40000)
153
+ sample = np.column_stack([lon.ravel()[::step], lat.ravel()[::step]])
154
+ in_aoi = clip.get_path().contains_points(sample)
155
+ gap_share = (nodata.ravel()[::step] & in_aoi).sum() / max(in_aoi.sum(), 1)
156
+ gaps_inside = gap_share > 0.01 # ignore slivers along the boundary
157
+ if gaps_inside:
158
+ nd = ax.contourf(lon, lat, nodata.astype(float), levels=[0.5, 1.5], colors=["#e6e6e6"],
159
+ hatches=["///"], alpha=0.6, zorder=2)
160
+ nd.set_clip_path(clip)
161
+ handles.append(Patch(facecolor="#e6e6e6", hatch="///", edgecolor="#a8a8a8", label=nodata_label))
162
+
163
+ fill_levels = np.linspace(vmin, vmax, 41)
164
+ cf = ax.contourf(lon, lat, np.clip(fine, vmin, vmax), levels=fill_levels, cmap=cmap, alpha=alpha,
165
+ extend="neither", zorder=3)
166
+ cf.set_clip_path(clip)
167
+ lv = [l for l in levels if np.nanmin(fine) < l < np.nanmax(fine)] if np.isfinite(fine).any() else []
168
+ if lv:
169
+ colors = [contour_colors[levels.index(l)] for l in lv] if contour_colors else "#1a1a1a"
170
+ cs = ax.contour(lon, lat, fine, levels=lv, colors=colors, linewidths=np.linspace(0.9, 1.5, len(lv)),
171
+ alpha=0.9, zorder=5)
172
+ cs.set_clip_path(clip)
173
+ labels = ax.clabel(cs, inline=True, fontsize=7.5, fmt=fmt)
174
+ stroke = [pe.withStroke(linewidth=2.2, foreground="black")] if contour_colors else STROKE
175
+ plt.setp(labels, path_effects=stroke)
176
+ if hatch_mask is not None and np.any(hatch_mask):
177
+ fine_h = zoom(np.asarray(hatch_mask, float), upsample_factor, order=1, grid_mode=True, mode="nearest") \
178
+ if upsample_factor > 1 else np.asarray(hatch_mask, float)
179
+ hh = ax.contourf(lon, lat, fine_h, levels=[0.5, 1.5], colors=["none"], hatches=["\\\\\\\\"], zorder=4)
180
+ hh.set_clip_path(clip)
181
+ handles.append(Patch(facecolor="none", hatch="\\\\\\\\", edgecolor="#555555", label=hatch_label))
182
+ if zones is not None and len(zones): # zone numbers at each zone's peak (the zones are the result)
183
+ for _, z in zones.head(n_zones).iterrows():
184
+ ax.annotate(z["Zone_ID"], xy=(z["Peak_Longitude"], z["Peak_Latitude"]), xytext=(0, 9),
185
+ textcoords="offset points", ha="center", va="center",
186
+ fontsize=5.8, fontweight="bold", color="black", zorder=10,
187
+ bbox=dict(boxstyle="round,pad=0.18", fc="white", ec="black", lw=0.7, alpha=0.92))
188
+ handles.append(Line2D([0], [0], marker="s", color="w", markerfacecolor="white", markeredgecolor="black",
189
+ markersize=8, label=f"Prospective zone = inside the {levels[1]:g} % line "
190
+ f"(top {min(n_zones, len(zones))} numbered)"))
191
+ n_labels = 0 # points stay as plain markers, without name boxes
192
+ if targets is not None and len(targets):
193
+ top = targets.head(max(n_stars, n_labels))
194
+ if n_labels:
195
+ ax.scatter(top["Longitude"], top["Latitude"], s=70, marker="*", c="white", edgecolors="#d50000",
196
+ linewidths=1.4, zorder=9)
197
+ span = self.aoi.bounds[2] - self.aoi.bounds[0]
198
+ minx, miny, maxx, maxy = self.aoi.bounds
199
+ r = 0.055 * span
200
+ dirs = [(np.cos(a), np.sin(a)) for a in np.radians([45, 135, 315, 225, 90, 270, 0, 180])]
201
+ placed = [(x, y) for x, y in zip(top["Longitude"], top["Latitude"])] # avoid the stars too
202
+ for _, t in top.head(n_labels).iterrows():
203
+ best, best_d = None, -1.0
204
+ for dx, dy in dirs:
205
+ for k in (1.0, 1.6):
206
+ cx, cy = t["Longitude"] + k * r * dx, t["Latitude"] + k * r * dy * 0.8
207
+ if not (minx <= cx <= maxx and miny <= cy <= maxy):
208
+ continue
209
+ d = min(np.hypot((cx - px) / 1.8, cy - py) for px, py in placed)
210
+ if d > best_d:
211
+ best, best_d = (cx, cy), d
212
+ best = best or (t["Longitude"] + r, t["Latitude"] + r)
213
+ placed.append(best)
214
+ ax.annotate(f"{t.get('Location_Name', t.get('Target_Name', ''))}\n({t['CPI_Percent']:.0f}%)", xy=(t["Longitude"], t["Latitude"]),
215
+ xytext=best, ha="center", va="center",
216
+ fontsize=6.8, fontweight="bold", color="white", zorder=11,
217
+ bbox=dict(boxstyle="round,pad=0.2", fc="#d50000", ec="white", lw=0.8, alpha=0.95),
218
+ arrowprops=dict(arrowstyle="->", color="black", lw=0.9))
219
+ handles.append(Line2D([0], [0], marker="*", color="w", markerfacecolor="white",
220
+ markeredgecolor="#d50000", markersize=11, label=f"Top {n_labels} field-check locations"))
221
+ else:
222
+ ax.scatter(top["Longitude"], top["Latitude"], s=55, marker="*", c="#ffd600", edgecolors="black",
223
+ linewidths=0.8, zorder=9)
224
+ handles.append(Line2D([0], [0], marker="*", color="w", markerfacecolor="#ffd600",
225
+ markeredgecolor="black", markersize=10,
226
+ label=f"Suggested field-check locations (top {len(top)})"))
227
+ cb = fig.colorbar(cf, ax=ax, orientation="horizontal", pad=0.07, shrink=0.7,
228
+ ticks=np.linspace(vmin, vmax, 6))
229
+ cb.set_label(cbar_label, fontsize=9.5, fontweight="bold")
230
+ self._finish(fig, ax, path, handles)
231
+
232
+ def lineaments(self, path: Path, lines_wgs84, title: str, subtitle=None):
233
+ fig, ax = self._frame(title, subtitle)
234
+ colors = {"N-S": "#d50000", "NE-SW": "#ff6d00", "E-W": "#2962ff", "NW-SE": "#00c853"}
235
+ lw = 1.0 + 2.5 * (lines_wgs84["length_m"] - lines_wgs84["length_m"].min()) / \
236
+ max(lines_wgs84["length_m"].max() - lines_wgs84["length_m"].min(), 1.0)
237
+ is_scarp = lines_wgs84.get("morphology", "").eq("scarp") if "morphology" in lines_wgs84 else \
238
+ np.zeros(len(lines_wgs84), bool)
239
+ for (_, row), w, scarp in zip(lines_wgs84.iterrows(), lw, is_scarp):
240
+ x, y = row.geometry.xy
241
+ if scarp:
242
+ ax.plot(x, y, color="#757575", lw=max(1.0, 0.7 * w), ls="--", zorder=5)
243
+ else:
244
+ ax.plot(x, y, color=colors[row["strike_class"]], lw=w, solid_capstyle="round", zorder=6)
245
+ structural = lines_wgs84[~np.asarray(is_scarp)]
246
+ counts = structural["strike_class"].value_counts()
247
+ handles = [Line2D([0], [0], color=c, lw=2.5, label=f"{k} (n={counts.get(k, 0)})") for k, c in colors.items()]
248
+ if np.any(is_scarp):
249
+ handles.append(Line2D([0], [0], color="#757575", lw=1.5, ls="--",
250
+ label=f"Scarp / escarpment edge (n={int(np.sum(is_scarp))}, not used in CPI)"))
251
+ saved, self.lines = self.lines, None
252
+ self._finish(fig, ax, path, handles)
253
+ self.lines = saved
geoscan/geoscan/report/render.py CHANGED
@@ -56,8 +56,10 @@ def render_all(p) -> int:
56
  lines = gpd.read_file(lines_path) if lines_path.exists() else None
57
  lines_ll = lines.to_crs(4326) if lines is not None else None
58
  cmap_ = ContourMap(aoi_ll, bg, lines_ll)
59
- targets_csv = out / "tables/top50_targets.csv"
60
  targets = pd.read_csv(targets_csv) if targets_csv.exists() else None
 
 
61
 
62
  def rec(path, title):
63
  nonlocal count
@@ -104,22 +106,23 @@ def render_all(p) -> int:
104
  emit_max = np.nanmax(np.stack([np.nan_to_num(_band(stack_path, k)[0]) for k in ("muscovite", "chlorite", "gossan")]), 0)
105
  no_emit = np.isfinite(sup) & (sup < ac.min_support) & (emit_max < 0.05)
106
  n = 0 if targets is None else len(targets)
107
- tiers = targets["Priority_Tier"].value_counts().to_dict() if n else {}
 
108
  path = m / "cpi_target_map.png"
109
  cmap_.surface(path, 100 * c, grid,
110
  f"{block} Copper Exploration Block — Multi-Sensor Alteration & Targeting Model\n"
111
- "Integrated Copper Prospectivity Index (CPI) & Top Ranked Drill Targets",
112
  "Integrated Copper Prospectivity Index (CPI, %)", cmap="plasma",
113
  levels=(round(100 * tc.tier3), round(100 * tc.tier2), round(100 * tc.tier1)),
114
  contour_colors=["#ffffff", "#ffeb3b", "#ff1744"],
115
  subtitle=f"CPI = {tc.w_hydrothermal:.2f}·({tc.w_muscovite:.2f} musc + {tc.w_quartz:.2f} qtz + "
116
  f"{tc.w_gossan:.2f} goss + {tc.w_chlorite:.2f} chl) + "
117
  f"{tc.w_structure:.2f}·exp(−d/{tc.structure_lambda_m:.0f} m) · "
118
- f"{n} targets ≥ {tc.min_spacing_m:.0f} m apart · " + ", ".join(f"{k.split(':')[0]}: {v}"
119
- for k, v in sorted(tiers.items())),
120
- upsample_factor=2, targets=targets, n_labels=min(15, n), hatch_mask=no_emit,
121
  hatch_label="Alteration not assessed here: CPI from quartz + structure only")
122
- rec(path, "CPI target map with top-ranked drill targets")
123
 
124
  # ================= structure =================
125
  if lines_ll is not None and len(lines_ll):
 
56
  lines = gpd.read_file(lines_path) if lines_path.exists() else None
57
  lines_ll = lines.to_crs(4326) if lines is not None else None
58
  cmap_ = ContourMap(aoi_ll, bg, lines_ll)
59
+ targets_csv = out / "tables/field_check_locations.csv"
60
  targets = pd.read_csv(targets_csv) if targets_csv.exists() else None
61
+ zones_csv = out / "tables/prospective_zones.csv"
62
+ zones = pd.read_csv(zones_csv) if zones_csv.exists() else None
63
 
64
  def rec(path, title):
65
  nonlocal count
 
106
  emit_max = np.nanmax(np.stack([np.nan_to_num(_band(stack_path, k)[0]) for k in ("muscovite", "chlorite", "gossan")]), 0)
107
  no_emit = np.isfinite(sup) & (sup < ac.min_support) & (emit_max < 0.05)
108
  n = 0 if targets is None else len(targets)
109
+ nz = 0 if zones is None else len(zones)
110
+ tiers = zones["Priority_Tier"].value_counts().to_dict() if nz else {}
111
  path = m / "cpi_target_map.png"
112
  cmap_.surface(path, 100 * c, grid,
113
  f"{block} Copper Exploration Block — Multi-Sensor Alteration & Targeting Model\n"
114
+ "Copper Prospectivity Index (CPI): Prospective Zones & Suggested Field-Check Locations",
115
  "Integrated Copper Prospectivity Index (CPI, %)", cmap="plasma",
116
  levels=(round(100 * tc.tier3), round(100 * tc.tier2), round(100 * tc.tier1)),
117
  contour_colors=["#ffffff", "#ffeb3b", "#ff1744"],
118
  subtitle=f"CPI = {tc.w_hydrothermal:.2f}·({tc.w_muscovite:.2f} musc + {tc.w_quartz:.2f} qtz + "
119
  f"{tc.w_gossan:.2f} goss + {tc.w_chlorite:.2f} chl) + "
120
  f"{tc.w_structure:.2f}·exp(−d/{tc.structure_lambda_m:.0f} m) · "
121
+ f"{nz} zones (" + ", ".join(f"{k.split(':')[0]}: {v}" for k, v in sorted(tiers.items())) +
122
+ f") · {n} field-check locations ≥ {tc.min_spacing_m:.0f} m apart",
123
+ upsample_factor=2, targets=targets, n_labels=min(15, n), hatch_mask=no_emit, zones=zones,
124
  hatch_label="Alteration not assessed here: CPI from quartz + structure only")
125
+ rec(path, "CPI map with ranked prospective zones and suggested field-check locations")
126
 
127
  # ================= structure =================
128
  if lines_ll is not None and len(lines_ll):
geoscan/geoscan/validation.py CHANGED
@@ -1,74 +1,91 @@
1
- """Validation of prospectivity surfaces against known mineralisation.
2
-
3
- All metrics compare a score surface with ground truth inside the AOI:
4
-
5
- * ``auc`` Mann-Whitney AUC: probability that a random positive scores higher than a
6
- random negative (0.5 = no skill, 1.0 = perfect).
7
- * ``bootstrap_auc`` stratified bootstrap confidence interval and one-sided permutation p-value.
8
- * ``success_rate`` share of mineralised area captured when the AOI is ranked by score
9
- (Agterberg & Bonham-Carter 2005 style success-rate curve).
10
- * ``target_hits`` how many ranked targets fall within a distance of a lode, versus the number
11
- expected if the same number of points were placed at random in the AOI.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import numpy as np
17
- import pandas as pd
18
- from scipy.stats import rankdata
19
-
20
-
21
- def auc(pos: np.ndarray, neg: np.ndarray) -> float:
22
- pos, neg = pos[np.isfinite(pos)], neg[np.isfinite(neg)]
23
- if not pos.size or not neg.size:
24
- return np.nan
25
- r = rankdata(np.concatenate([pos, neg]))
26
- return float((r[:pos.size].sum() - pos.size * (pos.size + 1) / 2) / (pos.size * neg.size))
27
-
28
-
29
- def bootstrap_auc(pos: np.ndarray, neg: np.ndarray, n: int = 2000, seed: int = 0) -> dict:
30
- pos, neg = pos[np.isfinite(pos)], neg[np.isfinite(neg)]
31
- rng = np.random.default_rng(seed)
32
- a = auc(pos, neg)
33
- boots = [auc(rng.choice(pos, pos.size), rng.choice(neg, neg.size)) for _ in range(n)]
34
- allv = np.concatenate([pos, neg])
35
- perm = []
36
- for _ in range(n):
37
- s = rng.permutation(allv)
38
- perm.append(auc(s[:pos.size], s[pos.size:]))
39
- return {"auc": round(a, 3), "ci_low": round(float(np.nanpercentile(boots, 2.5)), 3),
40
- "ci_high": round(float(np.nanpercentile(boots, 97.5)), 3),
41
- "p_value": round(float((np.sum(np.array(perm) >= a) + 1) / (n + 1)), 4),
42
- "n_pos": int(pos.size), "n_neg": int(neg.size)}
43
-
44
-
45
- def success_rate(score: np.ndarray, positive: np.ndarray, valid: np.ndarray) -> pd.DataFrame:
46
- """Cumulative share of positive cells vs share of AOI area, ranking cells by score (high first)."""
47
- s, p = score[valid], positive[valid]
48
- ok = np.isfinite(s)
49
- s, p = s[ok], p[ok]
50
- order = np.argsort(-s, kind="stable")
51
- cum_pos = np.cumsum(p[order]) / max(p.sum(), 1)
52
- area = np.arange(1, s.size + 1) / s.size
53
- return pd.DataFrame({"area_fraction": area, "captured_fraction": cum_pos})
54
-
55
-
56
- def captured_at(curve: pd.DataFrame, fractions=(0.1, 0.2, 0.3)) -> dict:
57
- return {f"captured_top{int(100 * f)}pct": round(float(np.interp(f, curve["area_fraction"],
58
- curve["captured_fraction"])), 3)
59
- for f in fractions}
60
-
61
-
62
- def target_hits(target_xy: np.ndarray, lodes_utm, aoi_utm, radii=(100.0, 200.0)) -> dict:
63
- from shapely.geometry import Point
64
- union = lodes_utm.union_all() if hasattr(lodes_utm, "union_all") else lodes_utm.unary_union
65
- out = {"n_targets": int(len(target_xy))}
66
- if not len(target_xy):
67
- return out
68
- d = np.array([union.distance(Point(x, y)) for x, y in target_xy])
69
- out["median_distance_to_lode_m"] = round(float(np.median(d)), 1)
70
- for r in radii:
71
- frac_area = union.buffer(r).intersection(aoi_utm).area / aoi_utm.area
72
- out[f"hits_within_{int(r)}m"] = int((d <= r).sum())
73
- out[f"expected_random_{int(r)}m"] = round(float(frac_area * len(target_xy)), 1)
74
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validation of prospectivity surfaces against known mineralisation.
2
+
3
+ All metrics compare a score surface with ground truth inside the AOI:
4
+
5
+ * ``auc`` Mann-Whitney AUC: probability that a random positive scores higher than a
6
+ random negative (0.5 = no skill, 1.0 = perfect).
7
+ * ``bootstrap_auc`` stratified bootstrap confidence interval and one-sided permutation p-value.
8
+ * ``success_rate`` share of mineralised area captured when the AOI is ranked by score
9
+ (Agterberg & Bonham-Carter 2005 style success-rate curve).
10
+ * ``target_hits`` how many ranked targets fall within a distance of a lode, versus the number
11
+ expected if the same number of points were placed at random in the AOI.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ from scipy.stats import rankdata
19
+
20
+
21
+ def auc(pos: np.ndarray, neg: np.ndarray) -> float:
22
+ pos, neg = pos[np.isfinite(pos)], neg[np.isfinite(neg)]
23
+ if not pos.size or not neg.size:
24
+ return np.nan
25
+ r = rankdata(np.concatenate([pos, neg]))
26
+ return float((r[:pos.size].sum() - pos.size * (pos.size + 1) / 2) / (pos.size * neg.size))
27
+
28
+
29
+ def bootstrap_auc(pos: np.ndarray, neg: np.ndarray, n: int = 2000, seed: int = 0) -> dict:
30
+ pos, neg = pos[np.isfinite(pos)], neg[np.isfinite(neg)]
31
+ rng = np.random.default_rng(seed)
32
+ a = auc(pos, neg)
33
+ boots = [auc(rng.choice(pos, pos.size), rng.choice(neg, neg.size)) for _ in range(n)]
34
+ allv = np.concatenate([pos, neg])
35
+ perm = []
36
+ for _ in range(n):
37
+ s = rng.permutation(allv)
38
+ perm.append(auc(s[:pos.size], s[pos.size:]))
39
+ return {"auc": round(a, 3), "ci_low": round(float(np.nanpercentile(boots, 2.5)), 3),
40
+ "ci_high": round(float(np.nanpercentile(boots, 97.5)), 3),
41
+ "p_value": round(float((np.sum(np.array(perm) >= a) + 1) / (n + 1)), 4),
42
+ "n_pos": int(pos.size), "n_neg": int(neg.size)}
43
+
44
+
45
+ def success_rate(score: np.ndarray, positive: np.ndarray, valid: np.ndarray) -> pd.DataFrame:
46
+ """Cumulative share of positive cells vs share of AOI area, ranking cells by score (high first)."""
47
+ s, p = score[valid], positive[valid]
48
+ ok = np.isfinite(s)
49
+ s, p = s[ok], p[ok]
50
+ order = np.argsort(-s, kind="stable")
51
+ cum_pos = np.cumsum(p[order]) / max(p.sum(), 1)
52
+ area = np.arange(1, s.size + 1) / s.size
53
+ return pd.DataFrame({"area_fraction": area, "captured_fraction": cum_pos})
54
+
55
+
56
+ def captured_at(curve: pd.DataFrame, fractions=(0.1, 0.2, 0.3)) -> dict:
57
+ return {f"captured_top{int(100 * f)}pct": round(float(np.interp(f, curve["area_fraction"],
58
+ curve["captured_fraction"])), 3)
59
+ for f in fractions}
60
+
61
+
62
+ def target_hits(target_xy: np.ndarray, lodes_utm, aoi_utm, radii=(100.0, 200.0)) -> dict:
63
+ from shapely.geometry import Point
64
+ union = lodes_utm.union_all() if hasattr(lodes_utm, "union_all") else lodes_utm.unary_union
65
+ out = {"n_targets": int(len(target_xy))}
66
+ if not len(target_xy):
67
+ return out
68
+ d = np.array([union.distance(Point(x, y)) for x, y in target_xy])
69
+ out["median_distance_to_lode_m"] = round(float(np.median(d)), 1)
70
+ for r in radii:
71
+ frac_area = union.buffer(r).intersection(aoi_utm).area / aoi_utm.area
72
+ out[f"hits_within_{int(r)}m"] = int((d <= r).sum())
73
+ out[f"expected_random_{int(r)}m"] = round(float(frac_area * len(target_xy)), 1)
74
+ return out
75
+
76
+
77
+ def zone_capture(zone_masks: dict[str, np.ndarray], truth: np.ndarray, inside: np.ndarray) -> pd.DataFrame:
78
+ """How much of the ground truth falls inside each set of zones, against the area the zones cover.
79
+
80
+ zone_masks: label -> boolean raster (e.g. "Tier 1 zones", "Tier 1 + 2 zones", ...).
81
+ enrichment = share of truth captured / share of the block covered (1.0 = no better than chance).
82
+ """
83
+ n_in, n_truth = int(inside.sum()), int((truth & inside).sum())
84
+ rows = []
85
+ for name, m in zone_masks.items():
86
+ m = m & inside
87
+ area, cap = m.sum() / max(n_in, 1), (m & truth).sum() / max(n_truth, 1)
88
+ rows.append({"zones": name, "pct_of_block": round(100 * float(area), 1),
89
+ "pct_of_truth_captured": round(100 * float(cap), 1),
90
+ "enrichment": round(float(cap / area), 1) if area > 0 else None})
91
+ return pd.DataFrame(rows)
geoscan/geoscan/webapp/catalog.py CHANGED
@@ -45,22 +45,22 @@ def _p(key, label, group, step, tag, lo, hi, inc, unit="", help_="", kind="float
45
 
46
  # Adjustable parameters (numbers the method chose; tags as in the methodology).
47
  PARAMS = dict([
48
- _p("targets.w_muscovite", "Weight: muscovite", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01,
49
  help_="H = w_musc·musc + w_qtz·qtz + w_goss·goss + w_chl·chl. The four weights must add up to 1."),
50
- _p("targets.w_quartz", "Weight: quartz", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01),
51
- _p("targets.w_gossan", "Weight: gossan", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01),
52
- _p("targets.w_chlorite", "Weight: chlorite", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01),
53
- _p("targets.w_structure", "Structure share of CPI", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01,
54
  help_="CPI = (1 − s)·H + s·exp(−d/λ). s = 0.30 by default."),
55
- _p("targets.structure_lambda_m", "Structural decay distance λ", "CPI and drill targets", "targets", "EXP",
56
  50, 2000, 10, "m", "Distance over which the structural score falls to 37 %."),
57
- _p("targets.tier1", "Tier 1 threshold (CPI)", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01),
58
- _p("targets.tier2", "Tier 2 threshold (CPI)", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01),
59
- _p("targets.tier3", "Tier 3 threshold (CPI)", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01),
60
- _p("targets.min_cpi", "Minimum CPI for a target", "CPI and drill targets", "targets", "EXP", 0, 1, 0.01),
61
- _p("targets.min_spacing_m", "Minimum spacing between targets", "CPI and drill targets", "targets", "ASM",
62
  30, 2000, 10, "m"),
63
- _p("targets.n_targets", "Maximum number of targets", "CPI and drill targets", "targets", "ASM", 1, 200, 1,
64
  kind="int"),
65
  _p("alteration.sigma_core_m", "Core kernel σ", "Alteration layers", "alteration", "EXP", 30, 500, 5, "m",
66
  "Gaussian kernel for the vent/core part of the potential field."),
 
45
 
46
  # Adjustable parameters (numbers the method chose; tags as in the methodology).
47
  PARAMS = dict([
48
+ _p("targets.w_muscovite", "Weight: muscovite", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01,
49
  help_="H = w_musc·musc + w_qtz·qtz + w_goss·goss + w_chl·chl. The four weights must add up to 1."),
50
+ _p("targets.w_quartz", "Weight: quartz", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01),
51
+ _p("targets.w_gossan", "Weight: gossan", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01),
52
+ _p("targets.w_chlorite", "Weight: chlorite", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01),
53
+ _p("targets.w_structure", "Structure share of CPI", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01,
54
  help_="CPI = (1 − s)·H + s·exp(−d/λ). s = 0.30 by default."),
55
+ _p("targets.structure_lambda_m", "Structural decay distance λ", "CPI, zones and field-check locations", "targets", "EXP",
56
  50, 2000, 10, "m", "Distance over which the structural score falls to 37 %."),
57
+ _p("targets.tier1", "Tier 1 threshold (CPI)", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01),
58
+ _p("targets.tier2", "Tier 2 threshold = zone boundary (CPI)", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01),
59
+ _p("targets.tier3", "Tier 3 threshold (CPI)", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01),
60
+ _p("targets.min_cpi", "Minimum CPI for a field-check location", "CPI, zones and field-check locations", "targets", "EXP", 0, 1, 0.01),
61
+ _p("targets.min_spacing_m", "Minimum spacing between field-check locations", "CPI, zones and field-check locations", "targets", "ASM",
62
  30, 2000, 10, "m"),
63
+ _p("targets.n_targets", "Maximum number of field-check locations", "CPI, zones and field-check locations", "targets", "ASM", 1, 200, 1,
64
  kind="int"),
65
  _p("alteration.sigma_core_m", "Core kernel σ", "Alteration layers", "alteration", "EXP", 30, 500, 5, "m",
66
  "Gaussian kernel for the vent/core part of the potential field."),
geoscan/geoscan/webapp/exports.py CHANGED
@@ -18,7 +18,7 @@ import pandas as pd
18
  from ..raster_io import read_raster, write_raster
19
  from . import catalog, geophysics, layers
20
 
21
- MAPS = {"cpi_target_map.png": "CPI target map with ranked targets",
22
  "alteration_muscovite_map.png": "Muscovite / sericite (phyllic) contour map",
23
  "alteration_chlorite_map.png": "Chlorite / epidote (propylitic) contour map",
24
  "alteration_gossan_map.png": "Gossan / iron oxide contour map",
@@ -36,11 +36,15 @@ RASTERS = {"targets/cpi.tif": "CPI, hydrothermal index, structural proximity",
36
  "terrain/dem.tif": "Elevation (m)", "terrain/hillshade.tif": "Hillshade", "terrain/slope.tif": "Slope (°)",
37
  "aster_ged/tir_indices.tif": "ASTER GED thermal indices"}
38
  DROP_BANDS = {"data_support", "ged_ndvi", "ged_num_obs"}
39
- TABLES = {"top50_targets.csv": "Ranked drill targets", "alteration_summary.csv": "Alteration layers and zone areas",
 
 
40
  "lineaments.csv": "Lineament attributes", "lineament_rose.csv": "Lineament strike histogram",
41
  "aster_ged_index_summary.csv": "ASTER GED index statistics"}
42
- VECTOR_LABELS = {"top50_targets.geojson": "Ranked drill targets (points)",
43
- "cpi_target_zones.geojson": "CPI target zones (polygons)",
 
 
44
  "lineaments.geojson": "Lineaments (lines)", "aoi.geojson": "Area of interest",
45
  "alteration_muscovite_zones.geojson": "Muscovite zones (polygons)",
46
  "alteration_chlorite_zones.geojson": "Chlorite zones (polygons)",
 
18
  from ..raster_io import read_raster, write_raster
19
  from . import catalog, geophysics, layers
20
 
21
+ MAPS = {"cpi_target_map.png": "CPI map: prospective zones and suggested field-check locations",
22
  "alteration_muscovite_map.png": "Muscovite / sericite (phyllic) contour map",
23
  "alteration_chlorite_map.png": "Chlorite / epidote (propylitic) contour map",
24
  "alteration_gossan_map.png": "Gossan / iron oxide contour map",
 
36
  "terrain/dem.tif": "Elevation (m)", "terrain/hillshade.tif": "Hillshade", "terrain/slope.tif": "Slope (°)",
37
  "aster_ged/tir_indices.tif": "ASTER GED thermal indices"}
38
  DROP_BANDS = {"data_support", "ged_ndvi", "ged_num_obs"}
39
+ TABLES = {"prospective_zones.csv": "Prospective zones, ranked (the main result)",
40
+ "field_check_locations.csv": "Suggested field-check locations (starting points, not drill collars)",
41
+ "top50_targets.csv": "Ranked locations (earlier naming)", "alteration_summary.csv": "Alteration layers and zone areas",
42
  "lineaments.csv": "Lineament attributes", "lineament_rose.csv": "Lineament strike histogram",
43
  "aster_ged_index_summary.csv": "ASTER GED index statistics"}
44
+ VECTOR_LABELS = {"prospective_zones.geojson": "Prospective zones, ranked (polygons, the main result)",
45
+ "field_check_locations.geojson": "Suggested field-check locations (points)",
46
+ "top50_targets.geojson": "Ranked locations (points, earlier naming)",
47
+ "cpi_target_zones.geojson": "CPI tier bands (polygons)",
48
  "lineaments.geojson": "Lineaments (lines)", "aoi.geojson": "Area of interest",
49
  "alteration_muscovite_zones.geojson": "Muscovite zones (polygons)",
50
  "alteration_chlorite_zones.geojson": "Chlorite zones (polygons)",
geoscan/geoscan/webapp/geophysics.py CHANGED
@@ -1,295 +1,295 @@
1
- """Reference geophysics layers (view and download only; never used in the CPI or target ranking).
2
-
3
- Source: GSI Bhukosh ground gravity / magnetic station exports placed in
4
- data/<area>/ground_truth/bhukosh/GRAVITY_CLIP/ (field bouguer_an, mGal)
5
- data/<area>/ground_truth/bhukosh/MAGNETIC_CLIP/ (field magnetic_a, nT)
6
-
7
- For each layer: the stations (duplicates removed), and - when there are enough stations - a surface
8
- interpolated between them (thin-plate spline), masked where no station is near, with contour lines.
9
- The surface is an interpolation of sparse points (stations are typically ~1 km apart): it shows the
10
- regional pattern only and must not be read at target scale.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import json
16
- from pathlib import Path
17
-
18
- import geopandas as gpd
19
- import numpy as np
20
- from matplotlib import colormaps
21
- from PIL import Image
22
- from rasterio.features import rasterize
23
- from scipy.interpolate import RBFInterpolator
24
- from scipy.spatial import cKDTree
25
- from shapely.geometry import LineString, MultiLineString
26
- from shapely.ops import unary_union
27
- from skimage.measure import find_contours
28
-
29
- from ..aoi import load_aoi
30
- from ..grid import Grid, grid_for_aoi
31
- from ..raster_io import write_raster
32
- from . import catalog
33
- from .layers import _to_mercator
34
- from rasterio.warp import Resampling
35
-
36
- LAYERS = {
37
- "magnetic": {"folder": "MAGNETIC_CLIP", "field": "magnetic_a", "label": "Magnetic anomaly (ground stations)",
38
- "unit": "nT", "cmap": "RdBu_r", "diverging": True,
39
- "extra": {"observed_m": "observed_total_field_nT", "igrf_nt": "igrf_nT"},
40
- "what": "Ground magnetic anomaly: the measured total field minus the Earth's reference field (IGRF). "
41
- "Highs mark magnetite-rich rock (mafic units); lows can mark non-magnetic rock or zones where "
42
- "fluids destroyed magnetite."},
43
- "gravity": {"folder": "GRAVITY_CLIP", "field": "bouguer_an", "label": "Bouguer gravity anomaly (ground stations)",
44
- "unit": "mGal", "cmap": "viridis", "diverging": False,
45
- "extra": {"elevation_": "elevation_m", "observed_g": "observed_gravity_mGal",
46
- "theoretica": "theoretical_gravity_mGal"},
47
- "what": "Bouguer gravity anomaly: gravity corrected for latitude, height and terrain mass. "
48
- "Highs mark denser rock (mafic units, massive sulphide); lows mark lighter rock (granite, cover)."},
49
- }
50
- SOURCE = "Geological Survey of India, Bhukosh portal (ground gravity / magnetic stations)"
51
- CAVEAT = ("Reference only: not used in the CPI or the target ranking. The surface and contours are interpolated "
52
- "between sparse stations and show the regional pattern only.")
53
- MIN_STATIONS_FOR_SURFACE = 6
54
- GRID_RES_M = 60.0
55
-
56
-
57
- def _dir(block: str, key: str) -> Path:
58
- return catalog.DATA / block / "ground_truth" / "bhukosh" / LAYERS[key]["folder"]
59
-
60
-
61
- def _cache(block: str) -> Path:
62
- p = catalog.OUTPUTS / "_reference_cache" / block
63
- p.mkdir(parents=True, exist_ok=True)
64
- return p
65
-
66
-
67
- def _aoi(block: str):
68
- aoi = load_aoi(catalog.DATA / block / "aoi" / "aoi.geojson", block)
69
- return aoi, grid_for_aoi(aoi, GRID_RES_M, 180.0)
70
-
71
-
72
- def stations(block: str, key: str) -> gpd.GeoDataFrame | None:
73
- """Stations in the area's UTM zone, exact duplicates (within 10 m) removed, tidy column names."""
74
- d = _dir(block, key)
75
- if not d.exists() or not any(d.glob("*.shp")):
76
- return None
77
- spec = LAYERS[key]
78
- aoi, grid = _aoi(block)
79
- g = gpd.read_file(d).to_crs(grid.crs)
80
- g = g[np.isfinite(g[spec["field"]].astype(float))].reset_index(drop=True)
81
- if len(g) == 0:
82
- return None
83
- xy = np.c_[g.geometry.x, g.geometry.y]
84
- tree, keep = cKDTree(xy), np.ones(len(g), bool)
85
- for i in range(len(g)):
86
- if keep[i]:
87
- for j in tree.query_ball_point(xy[i], 10.0):
88
- if j > i:
89
- keep[j] = False
90
- g = g[keep].reset_index(drop=True)
91
- out = gpd.GeoDataFrame({"station": np.arange(1, len(g) + 1), "value": g[spec["field"]].astype(float).round(3),
92
- "unit": spec["unit"]}, geometry=g.geometry, crs=grid.crs)
93
- for src, name in spec["extra"].items():
94
- if src in g:
95
- out[name] = g[src].astype(float).round(3)
96
- ll = out.geometry.to_crs(4326)
97
- out["longitude"], out["latitude"] = ll.x.round(6), ll.y.round(6)
98
- out["inside_area"] = out.within(aoi.to_crs(grid.crs))
99
- return out
100
-
101
-
102
- def _spacing(g: gpd.GeoDataFrame) -> float:
103
- xy = np.c_[g.geometry.x, g.geometry.y]
104
- if len(xy) < 2:
105
- return float("nan")
106
- return float(np.median(cKDTree(xy).query(xy, 2)[0][:, 1]))
107
-
108
-
109
- def surface(block: str, key: str):
110
- """Interpolated surface on a 60 m grid (NaN outside the area or far from any station), or (None, grid)."""
111
- g = stations(block, key)
112
- aoi, grid = _aoi(block)
113
- if g is None or len(g) < MIN_STATIONS_FOR_SURFACE:
114
- return None, grid
115
- xy = np.c_[g.geometry.x, g.geometry.y]
116
- xs = grid.x0 + (np.arange(grid.width) + 0.5) * grid.res
117
- ys = grid.y0 - (np.arange(grid.height) + 0.5) * grid.res
118
- X, Y = np.meshgrid(xs, ys)
119
- pts = np.c_[X.ravel(), Y.ravel()]
120
- centre, scale = xy.mean(0), float(xy.std(0).mean()) # spread of the stations (east and north separately)
121
- rbf = RBFInterpolator((xy - centre) / scale, g["value"].values, kernel="thin_plate_spline", smoothing=1e-3)
122
- z = rbf((pts - centre) / scale).reshape(grid.shape)
123
- near = cKDTree(xy).query(pts)[0].reshape(grid.shape) <= 1.5 * _spacing(g)
124
- inside = rasterize([(aoi.to_crs(grid.crs), 1)], out_shape=grid.shape, transform=grid.transform).astype(bool)
125
- lo, hi = g["value"].min(), g["value"].max()
126
- z = np.clip(z, lo - 0.1 * (hi - lo), hi + 0.1 * (hi - lo)) # no wild overshoot between stations
127
- return np.where(near & inside, z, np.nan).astype("float32"), grid
128
-
129
-
130
- def _colour_range(g: gpd.GeoDataFrame) -> tuple[float, float]:
131
- """Colour limits: the 2nd-98th percentile of the station values (relative lows to relative highs)."""
132
- lo, hi = np.percentile(g["value"], [2, 98]) if len(g) >= 10 else (g["value"].min(), g["value"].max())
133
- if hi <= lo:
134
- lo, hi = lo - 1.0, hi + 1.0
135
- return float(lo), float(hi)
136
-
137
-
138
- def _nice_step(span: float, target: int = 9) -> float:
139
- raw = span / max(target, 1)
140
- mag = 10 ** np.floor(np.log10(raw)) if raw > 0 else 1.0
141
- return float(min((1, 2, 2.5, 5, 10), key=lambda m: abs(m * mag - raw)) * mag)
142
-
143
-
144
- def _levels(z: np.ndarray) -> list[float]:
145
- lo, hi = float(np.nanmin(z)), float(np.nanmax(z))
146
- if hi <= lo:
147
- return []
148
- step = _nice_step(hi - lo)
149
- return [round(v, 6) for v in np.arange(np.ceil(lo / step) * step, hi, step)]
150
-
151
-
152
- def contours(block: str, key: str) -> gpd.GeoDataFrame | None:
153
- z, grid = surface(block, key)
154
- if z is None:
155
- return None
156
- filled = np.where(np.isfinite(z), z, np.nanmin(z) - 1e6) # contours stop at the masked edge
157
- recs, geoms = [], []
158
- for lev in _levels(z):
159
- lines = []
160
- for path in find_contours(filled, lev):
161
- ok = np.isfinite(z[np.clip(path[:, 0].round().astype(int), 0, grid.height - 1),
162
- np.clip(path[:, 1].round().astype(int), 0, grid.width - 1)])
163
- xs = grid.x0 + (path[:, 1] + 0.5) * grid.res
164
- ys = grid.y0 - (path[:, 0] + 0.5) * grid.res
165
- seg = []
166
- for x, y, k in zip(xs, ys, ok):
167
- if k:
168
- seg.append((x, y))
169
- else:
170
- if len(seg) > 2:
171
- lines.append(LineString(seg))
172
- seg = []
173
- if len(seg) > 2:
174
- lines.append(LineString(seg))
175
- if lines:
176
- m = unary_union(lines).simplify(grid.res / 3)
177
- geoms.append(m if isinstance(m, (LineString, MultiLineString)) else MultiLineString(
178
- [x for x in getattr(m, "geoms", []) if isinstance(x, LineString)]))
179
- recs.append({"layer": key, "level": lev, "unit": LAYERS[key]["unit"]})
180
- return gpd.GeoDataFrame(recs, geometry=geoms, crs=grid.crs) if recs else None
181
-
182
-
183
- def info(block: str) -> list[dict]:
184
- """Layers available for an area, with the facts a reader needs to judge them."""
185
- out = []
186
- for key, spec in LAYERS.items():
187
- g = stations(block, key)
188
- if g is None:
189
- continue
190
- sp = _spacing(g)
191
- out.append({"key": key, "label": spec["label"], "unit": spec["unit"], "stations": int(len(g)),
192
- "stations_inside": int(g["inside_area"].sum()),
193
- "spacing_m": None if np.isnan(sp) else round(sp),
194
- "min": float(g["value"].min()), "max": float(g["value"].max()),
195
- "has_surface": len(g) >= MIN_STATIONS_FOR_SURFACE, "what": spec["what"],
196
- "source": SOURCE, "caveat": CAVEAT})
197
- return out
198
-
199
-
200
- def overlay(block: str, key: str) -> dict:
201
- """Coloured surface PNG (EPSG:3857) + bounds + legend for the web map; {'none': True} without a surface."""
202
- c = _cache(block)
203
- png, meta = c / f"{key}.png", c / f"{key}.json"
204
- src = next(_dir(block, key).glob("*.shp"))
205
- if png.exists() and meta.exists() and png.stat().st_mtime >= src.stat().st_mtime:
206
- return json.loads(meta.read_text())
207
- spec = LAYERS[key]
208
- z, grid = surface(block, key)
209
- g = stations(block, key)
210
- lo, hi = _colour_range(g)
211
- stops = [colormaps[spec["cmap"]](t) for t in np.linspace(0, 1, 9)]
212
- legend = {"label": spec["label"], "unit": spec["unit"], "min": round(lo, 1), "max": round(hi, 1), "kind": "ramp",
213
- "colors": ["#%02x%02x%02x" % tuple(int(255 * v) for v in s[:3]) for s in stops],
214
- "levels": [], "reference": True}
215
- if z is None:
216
- info_ = {"none": True, "bounds": None, "legend": legend}
217
- png.write_bytes(b"")
218
- else:
219
- arr, bounds = _to_mercator(z, grid, 4, Resampling.bilinear)
220
- x = np.clip((arr - lo) / (hi - lo + 1e-12), 0, 1)
221
- rgba = (colormaps[spec["cmap"]](np.nan_to_num(x)) * 255).astype("uint8")
222
- rgba[..., 3] = np.where(np.isfinite(arr), 205, 0)
223
- Image.fromarray(rgba, "RGBA").save(png)
224
- legend["contour_step"] = _nice_step(float(np.nanmax(z) - np.nanmin(z)))
225
- info_ = {"bounds": bounds, "legend": legend}
226
- meta.write_text(json.dumps(info_))
227
- return info_
228
-
229
-
230
- def figure(block: str, key: str) -> Path:
231
- """Publication-style PNG: surface, labelled contours and stations with their values."""
232
- import matplotlib
233
- matplotlib.use("Agg")
234
- import matplotlib.pyplot as plt
235
- from matplotlib.ticker import MaxNLocator
236
- from pyproj import Transformer
237
- out = _cache(block) / f"reference_{key}_map.png"
238
- src = next(_dir(block, key).glob("*.shp"))
239
- if out.exists() and out.stat().st_mtime >= src.stat().st_mtime:
240
- return out
241
- spec = LAYERS[key]
242
- g = stations(block, key)
243
- z, grid = surface(block, key)
244
- aoi, _ = _aoi(block)
245
- ov = overlay(block, key)["legend"]
246
- fig, ax = plt.subplots(figsize=(11, 8), dpi=170)
247
- if z is not None:
248
- ll = Transformer.from_crs(grid.crs, 4326, always_xy=True)
249
- xs = grid.x0 + (np.arange(grid.width) + 0.5) * grid.res
250
- ys = grid.y0 - (np.arange(grid.height) + 0.5) * grid.res
251
- LON, LAT = ll.transform(*np.meshgrid(xs, ys))
252
- cf = ax.contourf(LON, LAT, z, levels=np.linspace(ov["min"], ov["max"], 41), cmap=spec["cmap"], extend="both")
253
- lv = _levels(z)
254
- if lv:
255
- cs = ax.contour(LON, LAT, z, levels=lv, colors="black", linewidths=0.6)
256
- ax.clabel(cs, fmt="%g", fontsize=7)
257
- fig.colorbar(cf, ax=ax, orientation="horizontal", pad=0.08, shrink=0.6, ticks=MaxNLocator(8),
258
- label=f"{spec['label']} ({spec['unit']})")
259
- sc = ax.scatter(g["longitude"], g["latitude"], c=g["value"], cmap=spec["cmap"], vmin=ov["min"], vmax=ov["max"],
260
- s=46, edgecolors="black", linewidths=0.8, zorder=5)
261
- if z is None:
262
- fig.colorbar(sc, ax=ax, orientation="horizontal", pad=0.08, shrink=0.6, ticks=MaxNLocator(8),
263
- label=f"{spec['label']} ({spec['unit']})")
264
- span = float(g["value"].max() - g["value"].min())
265
- for _, r in g.iterrows():
266
- ax.annotate(f"{r['value']:.0f}" if span >= 50 else f"{r['value']:g}", (r["longitude"], r["latitude"]), xytext=(4, 4), textcoords="offset points", fontsize=6.5)
267
- x, y = aoi.geometry.exterior.xy
268
- ax.plot(x, y, color="black", lw=1.6)
269
- ax.set_aspect(1 / np.cos(np.radians(np.mean(y))))
270
- ax.ticklabel_format(useOffset=False, style="plain")
271
- ax.set_xlabel("Longitude (°E)")
272
- ax.set_ylabel("Latitude (°N)")
273
- sp = _spacing(g)
274
- ax.set_title(f"{block.capitalize()}: {spec['label']}\n"
275
- f"{len(g)} stations" + ("" if np.isnan(sp) else f", about {sp / 1000:.1f} km apart") +
276
- (" · surface interpolated between stations" if z is not None else " · too few stations for a surface") +
277
- "\nReference only: not used in the CPI or the target ranking", fontsize=10, fontweight="bold")
278
- fig.text(0.01, 0.01, f"Source: {SOURCE}", fontsize=6.5, color="#555")
279
- fig.tight_layout()
280
- fig.savefig(out, bbox_inches="tight")
281
- plt.close(fig)
282
- return out
283
-
284
-
285
- def raster_file(block: str, key: str, path: Path) -> bool:
286
- """Write the interpolated surface as a GeoTIFF (UTM). Returns False when there is no surface."""
287
- z, grid = surface(block, key)
288
- if z is None:
289
- return False
290
- g = stations(block, key)
291
- write_raster(path, z[None], grid, [f"{key}_{LAYERS[key]['unit']}"],
292
- tags={"source": SOURCE, "stations": len(g), "median_station_spacing_m": round(_spacing(g)),
293
- "method": "thin-plate-spline interpolation of ground stations; masked beyond 1.5 x spacing",
294
- "note": CAVEAT})
295
- return True
 
1
+ """Reference geophysics layers (view and download only; never used in the CPI or the zone ranking).
2
+
3
+ Source: GSI Bhukosh ground gravity / magnetic station exports placed in
4
+ data/<area>/ground_truth/bhukosh/GRAVITY_CLIP/ (field bouguer_an, mGal)
5
+ data/<area>/ground_truth/bhukosh/MAGNETIC_CLIP/ (field magnetic_a, nT)
6
+
7
+ For each layer: the stations (duplicates removed), and - when there are enough stations - a surface
8
+ interpolated between them (thin-plate spline), masked where no station is near, with contour lines.
9
+ The surface is an interpolation of sparse points (stations are typically ~1 km apart): it shows the
10
+ regional pattern only and must not be read at the scale of a single zone.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ import geopandas as gpd
19
+ import numpy as np
20
+ from matplotlib import colormaps
21
+ from PIL import Image
22
+ from rasterio.features import rasterize
23
+ from scipy.interpolate import RBFInterpolator
24
+ from scipy.spatial import cKDTree
25
+ from shapely.geometry import LineString, MultiLineString
26
+ from shapely.ops import unary_union
27
+ from skimage.measure import find_contours
28
+
29
+ from ..aoi import load_aoi
30
+ from ..grid import Grid, grid_for_aoi
31
+ from ..raster_io import write_raster
32
+ from . import catalog
33
+ from .layers import _to_mercator
34
+ from rasterio.warp import Resampling
35
+
36
+ LAYERS = {
37
+ "magnetic": {"folder": "MAGNETIC_CLIP", "field": "magnetic_a", "label": "Magnetic anomaly (ground stations)",
38
+ "unit": "nT", "cmap": "RdBu_r", "diverging": True,
39
+ "extra": {"observed_m": "observed_total_field_nT", "igrf_nt": "igrf_nT"},
40
+ "what": "Ground magnetic anomaly: the measured total field minus the Earth's reference field (IGRF). "
41
+ "Highs mark magnetite-rich rock (mafic units); lows can mark non-magnetic rock or zones where "
42
+ "fluids destroyed magnetite."},
43
+ "gravity": {"folder": "GRAVITY_CLIP", "field": "bouguer_an", "label": "Bouguer gravity anomaly (ground stations)",
44
+ "unit": "mGal", "cmap": "viridis", "diverging": False,
45
+ "extra": {"elevation_": "elevation_m", "observed_g": "observed_gravity_mGal",
46
+ "theoretica": "theoretical_gravity_mGal"},
47
+ "what": "Bouguer gravity anomaly: gravity corrected for latitude, height and terrain mass. "
48
+ "Highs mark denser rock (mafic units, massive sulphide); lows mark lighter rock (granite, cover)."},
49
+ }
50
+ SOURCE = "Geological Survey of India, Bhukosh portal (ground gravity / magnetic stations)"
51
+ CAVEAT = ("Reference only: not used in the CPI or the zone ranking. The surface and contours are interpolated "
52
+ "between sparse stations and show the regional pattern only.")
53
+ MIN_STATIONS_FOR_SURFACE = 6
54
+ GRID_RES_M = 60.0
55
+
56
+
57
+ def _dir(block: str, key: str) -> Path:
58
+ return catalog.DATA / block / "ground_truth" / "bhukosh" / LAYERS[key]["folder"]
59
+
60
+
61
+ def _cache(block: str) -> Path:
62
+ p = catalog.OUTPUTS / "_reference_cache" / block
63
+ p.mkdir(parents=True, exist_ok=True)
64
+ return p
65
+
66
+
67
+ def _aoi(block: str):
68
+ aoi = load_aoi(catalog.DATA / block / "aoi" / "aoi.geojson", block)
69
+ return aoi, grid_for_aoi(aoi, GRID_RES_M, 180.0)
70
+
71
+
72
+ def stations(block: str, key: str) -> gpd.GeoDataFrame | None:
73
+ """Stations in the area's UTM zone, exact duplicates (within 10 m) removed, tidy column names."""
74
+ d = _dir(block, key)
75
+ if not d.exists() or not any(d.glob("*.shp")):
76
+ return None
77
+ spec = LAYERS[key]
78
+ aoi, grid = _aoi(block)
79
+ g = gpd.read_file(d).to_crs(grid.crs)
80
+ g = g[np.isfinite(g[spec["field"]].astype(float))].reset_index(drop=True)
81
+ if len(g) == 0:
82
+ return None
83
+ xy = np.c_[g.geometry.x, g.geometry.y]
84
+ tree, keep = cKDTree(xy), np.ones(len(g), bool)
85
+ for i in range(len(g)):
86
+ if keep[i]:
87
+ for j in tree.query_ball_point(xy[i], 10.0):
88
+ if j > i:
89
+ keep[j] = False
90
+ g = g[keep].reset_index(drop=True)
91
+ out = gpd.GeoDataFrame({"station": np.arange(1, len(g) + 1), "value": g[spec["field"]].astype(float).round(3),
92
+ "unit": spec["unit"]}, geometry=g.geometry, crs=grid.crs)
93
+ for src, name in spec["extra"].items():
94
+ if src in g:
95
+ out[name] = g[src].astype(float).round(3)
96
+ ll = out.geometry.to_crs(4326)
97
+ out["longitude"], out["latitude"] = ll.x.round(6), ll.y.round(6)
98
+ out["inside_area"] = out.within(aoi.to_crs(grid.crs))
99
+ return out
100
+
101
+
102
+ def _spacing(g: gpd.GeoDataFrame) -> float:
103
+ xy = np.c_[g.geometry.x, g.geometry.y]
104
+ if len(xy) < 2:
105
+ return float("nan")
106
+ return float(np.median(cKDTree(xy).query(xy, 2)[0][:, 1]))
107
+
108
+
109
+ def surface(block: str, key: str):
110
+ """Interpolated surface on a 60 m grid (NaN outside the area or far from any station), or (None, grid)."""
111
+ g = stations(block, key)
112
+ aoi, grid = _aoi(block)
113
+ if g is None or len(g) < MIN_STATIONS_FOR_SURFACE:
114
+ return None, grid
115
+ xy = np.c_[g.geometry.x, g.geometry.y]
116
+ xs = grid.x0 + (np.arange(grid.width) + 0.5) * grid.res
117
+ ys = grid.y0 - (np.arange(grid.height) + 0.5) * grid.res
118
+ X, Y = np.meshgrid(xs, ys)
119
+ pts = np.c_[X.ravel(), Y.ravel()]
120
+ centre, scale = xy.mean(0), float(xy.std(0).mean()) # spread of the stations (east and north separately)
121
+ rbf = RBFInterpolator((xy - centre) / scale, g["value"].values, kernel="thin_plate_spline", smoothing=1e-3)
122
+ z = rbf((pts - centre) / scale).reshape(grid.shape)
123
+ near = cKDTree(xy).query(pts)[0].reshape(grid.shape) <= 1.5 * _spacing(g)
124
+ inside = rasterize([(aoi.to_crs(grid.crs), 1)], out_shape=grid.shape, transform=grid.transform).astype(bool)
125
+ lo, hi = g["value"].min(), g["value"].max()
126
+ z = np.clip(z, lo - 0.1 * (hi - lo), hi + 0.1 * (hi - lo)) # no wild overshoot between stations
127
+ return np.where(near & inside, z, np.nan).astype("float32"), grid
128
+
129
+
130
+ def _colour_range(g: gpd.GeoDataFrame) -> tuple[float, float]:
131
+ """Colour limits: the 2nd-98th percentile of the station values (relative lows to relative highs)."""
132
+ lo, hi = np.percentile(g["value"], [2, 98]) if len(g) >= 10 else (g["value"].min(), g["value"].max())
133
+ if hi <= lo:
134
+ lo, hi = lo - 1.0, hi + 1.0
135
+ return float(lo), float(hi)
136
+
137
+
138
+ def _nice_step(span: float, target: int = 9) -> float:
139
+ raw = span / max(target, 1)
140
+ mag = 10 ** np.floor(np.log10(raw)) if raw > 0 else 1.0
141
+ return float(min((1, 2, 2.5, 5, 10), key=lambda m: abs(m * mag - raw)) * mag)
142
+
143
+
144
+ def _levels(z: np.ndarray) -> list[float]:
145
+ lo, hi = float(np.nanmin(z)), float(np.nanmax(z))
146
+ if hi <= lo:
147
+ return []
148
+ step = _nice_step(hi - lo)
149
+ return [round(v, 6) for v in np.arange(np.ceil(lo / step) * step, hi, step)]
150
+
151
+
152
+ def contours(block: str, key: str) -> gpd.GeoDataFrame | None:
153
+ z, grid = surface(block, key)
154
+ if z is None:
155
+ return None
156
+ filled = np.where(np.isfinite(z), z, np.nanmin(z) - 1e6) # contours stop at the masked edge
157
+ recs, geoms = [], []
158
+ for lev in _levels(z):
159
+ lines = []
160
+ for path in find_contours(filled, lev):
161
+ ok = np.isfinite(z[np.clip(path[:, 0].round().astype(int), 0, grid.height - 1),
162
+ np.clip(path[:, 1].round().astype(int), 0, grid.width - 1)])
163
+ xs = grid.x0 + (path[:, 1] + 0.5) * grid.res
164
+ ys = grid.y0 - (path[:, 0] + 0.5) * grid.res
165
+ seg = []
166
+ for x, y, k in zip(xs, ys, ok):
167
+ if k:
168
+ seg.append((x, y))
169
+ else:
170
+ if len(seg) > 2:
171
+ lines.append(LineString(seg))
172
+ seg = []
173
+ if len(seg) > 2:
174
+ lines.append(LineString(seg))
175
+ if lines:
176
+ m = unary_union(lines).simplify(grid.res / 3)
177
+ geoms.append(m if isinstance(m, (LineString, MultiLineString)) else MultiLineString(
178
+ [x for x in getattr(m, "geoms", []) if isinstance(x, LineString)]))
179
+ recs.append({"layer": key, "level": lev, "unit": LAYERS[key]["unit"]})
180
+ return gpd.GeoDataFrame(recs, geometry=geoms, crs=grid.crs) if recs else None
181
+
182
+
183
+ def info(block: str) -> list[dict]:
184
+ """Layers available for an area, with the facts a reader needs to judge them."""
185
+ out = []
186
+ for key, spec in LAYERS.items():
187
+ g = stations(block, key)
188
+ if g is None:
189
+ continue
190
+ sp = _spacing(g)
191
+ out.append({"key": key, "label": spec["label"], "unit": spec["unit"], "stations": int(len(g)),
192
+ "stations_inside": int(g["inside_area"].sum()),
193
+ "spacing_m": None if np.isnan(sp) else round(sp),
194
+ "min": float(g["value"].min()), "max": float(g["value"].max()),
195
+ "has_surface": len(g) >= MIN_STATIONS_FOR_SURFACE, "what": spec["what"],
196
+ "source": SOURCE, "caveat": CAVEAT})
197
+ return out
198
+
199
+
200
+ def overlay(block: str, key: str) -> dict:
201
+ """Coloured surface PNG (EPSG:3857) + bounds + legend for the web map; {'none': True} without a surface."""
202
+ c = _cache(block)
203
+ png, meta = c / f"{key}.png", c / f"{key}.json"
204
+ src = next(_dir(block, key).glob("*.shp"))
205
+ if png.exists() and meta.exists() and png.stat().st_mtime >= src.stat().st_mtime:
206
+ return json.loads(meta.read_text())
207
+ spec = LAYERS[key]
208
+ z, grid = surface(block, key)
209
+ g = stations(block, key)
210
+ lo, hi = _colour_range(g)
211
+ stops = [colormaps[spec["cmap"]](t) for t in np.linspace(0, 1, 9)]
212
+ legend = {"label": spec["label"], "unit": spec["unit"], "min": round(lo, 1), "max": round(hi, 1), "kind": "ramp",
213
+ "colors": ["#%02x%02x%02x" % tuple(int(255 * v) for v in s[:3]) for s in stops],
214
+ "levels": [], "reference": True}
215
+ if z is None:
216
+ info_ = {"none": True, "bounds": None, "legend": legend}
217
+ png.write_bytes(b"")
218
+ else:
219
+ arr, bounds = _to_mercator(z, grid, 4, Resampling.bilinear)
220
+ x = np.clip((arr - lo) / (hi - lo + 1e-12), 0, 1)
221
+ rgba = (colormaps[spec["cmap"]](np.nan_to_num(x)) * 255).astype("uint8")
222
+ rgba[..., 3] = np.where(np.isfinite(arr), 205, 0)
223
+ Image.fromarray(rgba, "RGBA").save(png)
224
+ legend["contour_step"] = _nice_step(float(np.nanmax(z) - np.nanmin(z)))
225
+ info_ = {"bounds": bounds, "legend": legend}
226
+ meta.write_text(json.dumps(info_))
227
+ return info_
228
+
229
+
230
+ def figure(block: str, key: str) -> Path:
231
+ """Publication-style PNG: surface, labelled contours and stations with their values."""
232
+ import matplotlib
233
+ matplotlib.use("Agg")
234
+ import matplotlib.pyplot as plt
235
+ from matplotlib.ticker import MaxNLocator
236
+ from pyproj import Transformer
237
+ out = _cache(block) / f"reference_{key}_map.png"
238
+ src = next(_dir(block, key).glob("*.shp"))
239
+ if out.exists() and out.stat().st_mtime >= src.stat().st_mtime:
240
+ return out
241
+ spec = LAYERS[key]
242
+ g = stations(block, key)
243
+ z, grid = surface(block, key)
244
+ aoi, _ = _aoi(block)
245
+ ov = overlay(block, key)["legend"]
246
+ fig, ax = plt.subplots(figsize=(11, 8), dpi=170)
247
+ if z is not None:
248
+ ll = Transformer.from_crs(grid.crs, 4326, always_xy=True)
249
+ xs = grid.x0 + (np.arange(grid.width) + 0.5) * grid.res
250
+ ys = grid.y0 - (np.arange(grid.height) + 0.5) * grid.res
251
+ LON, LAT = ll.transform(*np.meshgrid(xs, ys))
252
+ cf = ax.contourf(LON, LAT, z, levels=np.linspace(ov["min"], ov["max"], 41), cmap=spec["cmap"], extend="both")
253
+ lv = _levels(z)
254
+ if lv:
255
+ cs = ax.contour(LON, LAT, z, levels=lv, colors="black", linewidths=0.6)
256
+ ax.clabel(cs, fmt="%g", fontsize=7)
257
+ fig.colorbar(cf, ax=ax, orientation="horizontal", pad=0.08, shrink=0.6, ticks=MaxNLocator(8),
258
+ label=f"{spec['label']} ({spec['unit']})")
259
+ sc = ax.scatter(g["longitude"], g["latitude"], c=g["value"], cmap=spec["cmap"], vmin=ov["min"], vmax=ov["max"],
260
+ s=46, edgecolors="black", linewidths=0.8, zorder=5)
261
+ if z is None:
262
+ fig.colorbar(sc, ax=ax, orientation="horizontal", pad=0.08, shrink=0.6, ticks=MaxNLocator(8),
263
+ label=f"{spec['label']} ({spec['unit']})")
264
+ span = float(g["value"].max() - g["value"].min())
265
+ for _, r in g.iterrows():
266
+ ax.annotate(f"{r['value']:.0f}" if span >= 50 else f"{r['value']:g}", (r["longitude"], r["latitude"]), xytext=(4, 4), textcoords="offset points", fontsize=6.5)
267
+ x, y = aoi.geometry.exterior.xy
268
+ ax.plot(x, y, color="black", lw=1.6)
269
+ ax.set_aspect(1 / np.cos(np.radians(np.mean(y))))
270
+ ax.ticklabel_format(useOffset=False, style="plain")
271
+ ax.set_xlabel("Longitude (°E)")
272
+ ax.set_ylabel("Latitude (°N)")
273
+ sp = _spacing(g)
274
+ ax.set_title(f"{block.capitalize()}: {spec['label']}\n"
275
+ f"{len(g)} stations" + ("" if np.isnan(sp) else f", about {sp / 1000:.1f} km apart") +
276
+ (" · surface interpolated between stations" if z is not None else " · too few stations for a surface") +
277
+ "\nReference only: not used in the CPI or the zone ranking", fontsize=10, fontweight="bold")
278
+ fig.text(0.01, 0.01, f"Source: {SOURCE}", fontsize=6.5, color="#555")
279
+ fig.tight_layout()
280
+ fig.savefig(out, bbox_inches="tight")
281
+ plt.close(fig)
282
+ return out
283
+
284
+
285
+ def raster_file(block: str, key: str, path: Path) -> bool:
286
+ """Write the interpolated surface as a GeoTIFF (UTM). Returns False when there is no surface."""
287
+ z, grid = surface(block, key)
288
+ if z is None:
289
+ return False
290
+ g = stations(block, key)
291
+ write_raster(path, z[None], grid, [f"{key}_{LAYERS[key]['unit']}"],
292
+ tags={"source": SOURCE, "stations": len(g), "median_station_spacing_m": round(_spacing(g)),
293
+ "method": "thin-plate-spline interpolation of ground stations; masked beyond 1.5 x spacing",
294
+ "note": CAVEAT})
295
+ return True
geoscan/geoscan/webapp/help_text.py CHANGED
@@ -1,275 +1,287 @@
1
- """Plain-language explanations shown by the (i) buttons in the web app.
2
-
3
- Each entry: what (what it is), how (how the method uses it, formula), up / down (effect of raising /
4
- lowering it), why (why the default), evidence (what validation says, optional). Keep these in step with
5
- the methodology document.
6
- """
7
-
8
- INFO = {
9
- # ------------------------------------------------------------------ method options
10
- "structure.dem_source": {
11
- "what": "The elevation model (terrain map) that lineaments are traced from.",
12
- "how": "Hillshades from 8 sun directions and valley/ridge axes are computed from this elevation model, "
13
- "then straight edges are found and joined into lines.",
14
- "choices": "ALOS AW3D30 (JAXA, from stereo optical images, 2006-2011). "
15
- "SRTM (NASA radar, February 2000; used in the 24 September version). "
16
- "Copernicus GLO-30 (TanDEM-X radar, 2011-2015).",
17
- "why": "AW3D30 was the most accurate model at the 47 Thanewasna borehole collars (1.4 m error, against "
18
- "3.1 m for SRTM). It also gave the best results at both blocks when the complete map was rebuilt.",
19
- "evidence": "Thanewasna, structure layer against Cu lodes: AUC 0.77 with AW3D30, 0.71 with SRTM. "
20
- "Against boreholes: 0.85 and 0.72.",
21
- },
22
- "structure.canny_tile_m": {
23
- "what": "How the 'is this a strong edge?' threshold is set when lines are traced.",
24
- "how": "An edge counts when its contrast is in the top 20 % (to extend a line) or top 5 % (to start one). "
25
- "'Whole area' ranks every pixel of the area together. 'Per 3 km tile' ranks each 3 km square on its own.",
26
- "up": "Per 3 km tile: flat ground gets its own fair share of edges, so subtle structures in plains are found.",
27
- "down": "Whole area: hilly parts use up the top-5 % edges and flat parts get almost none.",
28
- "why": "Per-tile thresholds found more of the ore-parallel structure at Thanewasna. At Kelipur they also "
29
- "found more lines parallel to the mapped foliation.",
30
- },
31
- # ------------------------------------------------------------------ CPI and targets
32
- "targets.w_muscovite": {
33
- "what": "How much muscovite / sericite (phyllic alteration) counts in the hydrothermal score H.",
34
- "how": "H = w_musc·muscovite + w_qtz·quartz + w_goss·gossan + w_chl·chlorite, each layer 0-1. "
35
- "The four weights must add up to 1.",
36
- "up": "Areas with strong white mica rise in the ranking.",
37
- "down": "White-mica areas count less; the other three minerals decide more.",
38
- "why": "Expert-assigned, following porphyry-style zoning. The sericite (phyllic) zone sits closest to the "
39
- "ore, so it gets the largest weight. This is a judgement, not a measured value.",
40
- "evidence": "Thanewasna: muscovite layer AUC 0.62 at lodes, 0.76 at boreholes.",
41
- },
42
- "targets.w_quartz": {
43
- "what": "How much quartz / silicification counts in the hydrothermal score H.",
44
- "how": "H = w_musc·muscovite + w_qtz·quartz + w_goss·gossan + w_chl·chlorite, each layer 0-1. "
45
- "The four weights must add up to 1.",
46
- "up": "Silica-rich areas, from ASTER thermal data at 90 m, rise in the ranking.",
47
- "down": "Quartz counts less.",
48
- "why": "Expert-assigned; silicification often accompanies the phyllic zone.",
49
- "evidence": "Thanewasna: the quartz layer shows no skill at the lodes (AUC 0.51) and some at boreholes (0.68). "
50
- "The ASTER pixels (90 m) are coarse for a 2 km block.",
51
- },
52
- "targets.w_gossan": {
53
- "what": "How much gossan / iron oxide counts in the hydrothermal score H.",
54
- "how": "H = w_musc·muscovite + w_qtz·quartz + w_goss·gossan + w_chl·chlorite, each layer 0-1. "
55
- "The four weights must add up to 1.",
56
- "up": "Iron-stained, oxidised ground rises in the ranking.",
57
- "down": "Iron oxide counts less. Useful where laterite or ferruginous soil (not gossan) is widespread.",
58
- "why": "Expert-assigned. A gossan is the weathered cap of sulphides.",
59
- "evidence": "Thanewasna: the strongest single layer (AUC 0.77 at lodes, 0.83 at boreholes). Two independent "
60
- "satellites (ASTER, Sentinel-2) show the same iron-oxide pattern.",
61
- },
62
- "targets.w_chlorite": {
63
- "what": "How much chlorite / epidote (propylitic alteration) counts in the hydrothermal score H.",
64
- "how": "H = w_musc·muscovite + w_qtz·quartz + w_goss·gossan + w_chl·chlorite, each layer 0-1. "
65
- "The four weights must add up to 1.",
66
- "up": "Chlorite-rich areas rise in the ranking.",
67
- "down": "Chlorite counts less.",
68
- "why": "Expert-assigned and smallest, because the propylitic zone is the outer halo, not the ore.",
69
- "evidence": "Thanewasna: chlorite is lower at lode-intersecting boreholes than at halo boreholes (AUC 0.29). "
70
- "In this deposit it behaves as a distal indicator, so a lower weight is worth testing.",
71
- },
72
- "targets.w_structure": {
73
- "what": "How much the nearness to a lineament counts in the final score, compared with the minerals.",
74
- "how": "CPI = (1 − s)·H + s·S_prox, where S_prox = exp(−distance to nearest lineament / λ).",
75
- "up": "Targets are pulled towards mapped lineaments. Areas with no mapped line score lower.",
76
- "down": "The minerals decide almost alone.",
77
- "why": "Expert-assigned: alteration is the main evidence (70 %) and structure supports it (30 %).",
78
- "evidence": "Thanewasna: structure layer alone AUC 0.77 at lodes, 0.85 at boreholes. Remember that "
79
- "lineaments come from terrain and miss buried structures.",
80
- },
81
- "targets.structure_lambda_m": {
82
- "what": "How quickly the benefit of being near a lineament fades with distance.",
83
- "how": "S_prox = exp(−d / λ). At d = λ the structural score is 37 %, at 2λ it is 14 %, at 3λ 5 %.",
84
- "up": "Lineaments influence a wider strip. The map gets smoother and more ground benefits.",
85
- "down": "Only ground right next to a lineament benefits.",
86
- "why": "Assumed width of fracture-controlled alteration around a structure (a few hundred metres).",
87
- "evidence": "Thanewasna: λ = 400 m gave lode AUC 0.74, against 0.73 with 250 m. The difference is small.",
88
- },
89
- "targets.tier1": {
90
- "what": "Score needed for a Tier 1 (high-priority) target.",
91
- "how": "CPI ≥ Tier 1 → 'Tier 1: High Priority' (recommendation: diamond core drilling). "
92
- "CPI is not re-stretched, so these are absolute values.",
93
- "up": "Fewer targets are called Tier 1.",
94
- "down": "More targets are called Tier 1.",
95
- "why": "Expert-assigned class boundaries. Changing tiers only changes labels and contour lines, "
96
- "not which spots are chosen.",
97
- },
98
- "targets.tier2": {
99
- "what": "Score needed for a Tier 2 (medium-priority) target.",
100
- "how": "Tier 2 ≤ CPI < Tier 1 → 'Tier 2: Medium Priority' (recommendation: RC drilling / trenching across strike).",
101
- "up": "Fewer Tier 2, more Tier 3.", "down": "More Tier 2.",
102
- "why": "Expert-assigned class boundary; labels only.",
103
- },
104
- "targets.tier3": {
105
- "what": "Score needed for a Tier 3 (general anomaly) target.",
106
- "how": "Tier 3 ≤ CPI < Tier 2 → 'Tier 3: General Anomaly' (recommendation: field mapping and rock-chip geochemistry). "
107
- "It is also the lowest contour on the CPI map.",
108
- "up": "Fewer anomalies are labelled.", "down": "More weak anomalies are labelled.",
109
- "why": "Expert-assigned class boundary.",
110
- },
111
- "targets.min_cpi": {
112
- "what": "No target is placed where the score is below this value.",
113
- "how": "Only spots with CPI ≥ this value are considered when picking targets.",
114
- "up": "Fewer targets, all in strong anomalies.",
115
- "down": "More targets, reaching into weak anomalies (still at most the maximum number).",
116
- "why": "Expert-assigned, just below the Tier 3 boundary.",
117
- },
118
- "targets.min_spacing_m": {
119
- "what": "The minimum distance between two targets.",
120
- "how": "Spots are taken from the highest score down. A spot closer than this to an already chosen target is "
121
- "skipped. It is the weaker neighbour of that target, part of the same anomaly.",
122
- "up": "Fewer, more spread-out targets: roughly one per anomaly.",
123
- "down": "Several targets can sit on one large anomaly. Use this to test lodes that lie close together.",
124
- "why": "Assumed: about the size of one drill fence / anomaly, so one anomaly does not take all the top ranks.",
125
- },
126
- "targets.n_targets": {
127
- "what": "The largest number of targets listed.",
128
- "how": "Picking stops when this many targets are found (or when no spot above the minimum score is left).",
129
- "up": "A longer list.", "down": "A shorter list.",
130
- "why": "A practical list length for field planning.",
131
- },
132
- # ------------------------------------------------------------------ alteration layers
133
- "alteration.sigma_core_m": {
134
- "what": "Size of the sharp 'hot spot' drawn around each EMIT detection.",
135
- "how": "Every detection (a 60 m pixel) is spread with a Gaussian of this σ. The core keeps the peak where "
136
- "the mineral was actually seen.",
137
- "up": "Hot spots are blurred and nearby detections merge.",
138
- "down": "Hot spots stay pixel-sharp and spotty.",
139
- "why": "Expert choice: about 1.5 EMIT pixels, enough to remove pixel noise but keep detail.",
140
- },
141
- "alteration.sigma_halo_m": {
142
- "what": "Size of the wider zone drawn around each detection, standing for the alteration halo.",
143
- "how": "A second, wider Gaussian of this σ is combined with the core. It decides how far a zone reaches "
144
- "beyond the detected pixels.",
145
- "up": "Wider, more continuous zones; isolated detections spread further.",
146
- "down": "Tighter zones that hug the detections.",
147
- "why": "Expert choice: alteration halos around a mineralised system are typically a few hundred metres wide.",
148
- },
149
- "alteration.theta_decay": {
150
- "what": "How many detections must be close together before a halo is believed.",
151
- "how": "confidence = 1 − exp(−W / θ), where W is the share of detections within the halo around a pixel.",
152
- "up": "A single lonely detection fades away (treated as noise); only clusters show strongly.",
153
- "down": "Even one detection shows at full strength.",
154
- "why": "Expert choice.",
155
- },
156
- "alteration.reference_percentile": {
157
- "what": "Which value is shown as 100 % on the mineral maps.",
158
- "how": "Intensity = field / (value of the field at this percentile inside the area), capped at 100 %.",
159
- "up": "Fewer pixels reach 100 %; the strongest areas stand out more.",
160
- "down": "More of the map looks saturated at 100 %.",
161
- "why": "The 98th percentile ignores the most extreme 2 % of pixels. Percentages are relative to "
162
- "this area, so 80 % here is not comparable with 80 % in another area.",
163
- },
164
- "alteration.sericite_min_nm": {
165
- "what": "Shortest wavelength of the Al-OH absorption that is accepted as white mica (muscovite / sericite).",
166
- "how": "Muscovite has its main absorption near 2200 nm. Detections whose absorption minimum falls outside "
167
- "the window are not counted as muscovite.",
168
- "up": "Excludes sodic micas (paragonite, near 2190 nm).",
169
- "down": "Accepts more sodic micas.",
170
- "why": "From spectral-library positions of the muscovite family. Kaolinite and smectite absorb at similar "
171
- "positions, so the window cannot exclude them completely.",
172
- },
173
- "alteration.sericite_max_nm": {
174
- "what": "Longest wavelength of the Al-OH absorption that is accepted as white mica.",
175
- "how": "Muscovite has its main absorption near 2200 nm. Detections whose absorption minimum falls outside "
176
- "the window are not counted as muscovite.",
177
- "up": "Accepts phengitic (Fe/Mg-rich) micas, which absorb at 2215-2225 nm.",
178
- "down": "Keeps only classic muscovite / sericite.",
179
- "why": "From spectral-library positions of the muscovite family.",
180
- },
181
- "alteration.quartz_qi_weight": {
182
- "what": "Balance between the two ASTER thermal indices in the quartz layer.",
183
- "how": "QSI = w·QI + (1 − w)·SI. QI = B11² / (B10·B12) is the published quartz index (Ninomiya 2003). "
184
- "SI = B13 / B12 is a silica ratio inherited from earlier work with no verified primary source.",
185
- "up": "Relies more on the published quartz index.",
186
- "down": "Relies more on the unverified silica ratio.",
187
- "why": "Expert choice. This weighting is one of the review points in the methodology.",
188
- },
189
- "alteration.quartz_background_percentile": {
190
- "what": "How much of the weakest quartz signal is treated as ordinary rock (0 %).",
191
- "how": "Quartz intensity = (QSI − P_background) / (P98 − P_background), clipped to 0-100 %.",
192
- "up": "Only the strongest quartz shows; more of the area becomes 0 %.",
193
- "down": "More of the area shows some quartz.",
194
- "why": "Project assumption: the lowest 15 % of the area is background.",
195
- },
196
- # ------------------------------------------------------------------ lineaments
197
- "structure.min_length_m": {
198
- "what": "Lineaments shorter than this are dropped.",
199
- "how": "After the line pieces are joined, any line shorter than this is removed.",
200
- "up": "Only major structures remain.",
201
- "down": "More short lines, including drainage and noise.",
202
- "why": "Expert choice: a structure that guides fluids over a deposit is usually at least ~1 km long.",
203
- },
204
- "structure.min_segment_m": {
205
- "what": "Shortest straight piece the line finder will accept before joining pieces.",
206
- "how": "A Hough transform finds straight pieces on the edge map; pieces shorter than this are ignored.",
207
- "up": "Fewer, cleaner pieces.", "down": "More fragments.",
208
- "why": "Project assumption.",
209
- },
210
- "structure.max_gap_m": {
211
- "what": "Largest break that is bridged inside one straight piece.",
212
- "how": "Edges along the same line with a gap up to this size are treated as one piece.",
213
- "up": "Broken lines are joined more easily.", "down": "Lines break at small gaps.",
214
- "why": "Project assumption (3 pixels at 30 m).",
215
- },
216
- "structure.canny_low_quantile": {
217
- "what": "How weak an edge may be and still extend a line.",
218
- "how": "Edges in the top (1 − value) of contrast can continue a line that a strong edge started.",
219
- "up": "Fewer, shorter lines.", "down": "Lines run further through weak edges.",
220
- "why": "Project assumption (top 20 %).",
221
- },
222
- "structure.canny_high_quantile": {
223
- "what": "How strong an edge must be to start a line.",
224
- "how": "Only edges in the top (1 − value) of contrast can start a line.",
225
- "up": "Only the sharpest features start lines.", "down": "More lines start.",
226
- "why": "Project assumption (top 5 %).",
227
- },
228
- "structure.scarp_min_step_m": {
229
- "what": "When a line is called a scarp (a cliff or plateau edge) instead of a valley or ridge.",
230
- "how": "The terrain is compared 90 m either side of the line. If one side is consistently higher by at "
231
- "least this much, the line is a scarp.",
232
- "up": "Fewer lines are called scarps.", "down": "More lines are called scarps.",
233
- "why": "Expert choice. Erosional scarps (such as the Deccan plateau edge) are not fluid pathways.",
234
- },
235
- "structure.exclude_scarps": {
236
- "what": "Whether scarps are left out of the CPI.",
237
- "how": "Scarps always stay in the lineament map and vectors. When this is on, they are not used for the "
238
- "structural score or density.",
239
- "up": "On: cliff edges do not attract targets.", "down": "Off: every line counts.",
240
- "why": "Project assumption, checked at both blocks.",
241
- },
242
- # ------------------------------------------------------------------ EMIT detection
243
- "spectral.detect_sigma": {
244
- "what": "How sure we must be that an absorption is real before calling it a detection.",
245
- "how": "Detected when band depth > k × its uncertainty. The uncertainty comes from EMIT's own per-band "
246
- "error, propagated with 64 Monte-Carlo draws. k = 2 is about 95 % confidence.",
247
- "up": "Fewer, more certain detections.", "down": "More, less certain detections.",
248
- "why": "Project assumption: the usual 2σ significance level.",
249
- },
250
- "spectral.min_depth": {
251
- "what": "Weakest absorption that can count as a detection, however precise it is.",
252
- "how": "Detected only when band depth is also above this value (0.01 = a 1 % dip).",
253
- "up": "Shallow absorptions are ignored.", "down": "Very shallow dips can count.",
254
- "why": "Project assumption.",
255
- },
256
- "spectral.min_albedo": {
257
- "what": "Pixels darker than this are skipped.",
258
- "how": "Pixels with reflectance below this value are masked before band depths are measured.",
259
- "up": "More dark ground (shadow, water, burnt areas) is skipped.", "down": "Darker pixels are used.",
260
- "why": "Project assumption: band depths are unreliable on very dark surfaces.",
261
- },
262
- }
263
-
264
- LAYER_INFO = {
265
- "cpi": "Copper Prospectivity Index: (1 − s) × hydrothermal score + s × nearness to lineaments. "
266
- "0-100 %; contours at the Tier 3 / 2 / 1 thresholds.",
267
- "muscovite": "White mica (sericite, phyllic alteration) from the EMIT Al-OH absorption near 2200 nm. "
268
- "Relative intensity: 100 % = strongest 2 % of this area.",
269
- "chlorite": "Chlorite / epidote (propylitic alteration) from the EMIT Fe-OH (2250 nm) and Mg-OH (2330 nm) "
270
- "absorptions, with carbonate-dominated pixels excluded using EMIT L2B.",
271
- "gossan": "Iron oxide (Fe³⁺) from the EMIT 900 nm absorption. It cannot separate gossan from laterite or "
272
- "ferruginous soil.",
273
- "quartz": "Quartz / silicification from ASTER thermal emissivity (90 m): QSI = w·QI + (1 − w)·SI.",
274
- "lineament_density": "Length of lineaments (km) per km² within 1 km.",
275
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plain-language explanations shown by the (i) buttons in the web app.
2
+
3
+ Each entry: what (what it is), how (how the method uses it, formula), up / down (effect of raising /
4
+ lowering it), why (why the default), evidence (what validation says, optional). Keep these in step with
5
+ the methodology document.
6
+ """
7
+
8
+ INFO = {
9
+ # ------------------------------------------------------------------ method options
10
+ "structure.dem_source": {
11
+ "what": "The elevation model (terrain map) that lineaments are traced from.",
12
+ "how": "Hillshades from 8 sun directions and valley/ridge axes are computed from this elevation model, "
13
+ "then straight edges are found and joined into lines.",
14
+ "choices": "ALOS AW3D30 (JAXA, from stereo optical images, 2006-2011). "
15
+ "SRTM (NASA radar, February 2000; used in the 24 September version). "
16
+ "Copernicus GLO-30 (TanDEM-X radar, 2011-2015).",
17
+ "why": "AW3D30 was the most accurate model at the 47 Thanewasna borehole collars (1.4 m error, against "
18
+ "3.1 m for SRTM). It also gave the best results at both blocks when the complete map was rebuilt.",
19
+ "evidence": "Thanewasna, structure layer against Cu lodes: AUC 0.77 with AW3D30, 0.71 with SRTM. "
20
+ "Against boreholes: 0.85 and 0.72.",
21
+ },
22
+ "structure.canny_tile_m": {
23
+ "what": "How the 'is this a strong edge?' threshold is set when lines are traced.",
24
+ "how": "An edge counts when its contrast is in the top 20 % (to extend a line) or top 5 % (to start one). "
25
+ "'Whole area' ranks every pixel of the area together. 'Per 3 km tile' ranks each 3 km square on its own.",
26
+ "up": "Per 3 km tile: flat ground gets its own fair share of edges, so subtle structures in plains are found.",
27
+ "down": "Whole area: hilly parts use up the top-5 % edges and flat parts get almost none.",
28
+ "why": "Per-tile thresholds found more of the ore-parallel structure at Thanewasna. At Kelipur they also "
29
+ "found more lines parallel to the mapped foliation.",
30
+ },
31
+ # ------------------------------------------------------------------ CPI and targets
32
+ "targets.w_muscovite": {
33
+ "what": "How much muscovite / sericite (phyllic alteration) counts in the hydrothermal score H.",
34
+ "how": "H = w_musc·muscovite + w_qtz·quartz + w_goss·gossan + w_chl·chlorite, each layer 0-1. "
35
+ "The four weights must add up to 1.",
36
+ "up": "Areas with strong white mica rise in the ranking.",
37
+ "down": "White-mica areas count less; the other three minerals decide more.",
38
+ "why": "Expert-assigned, following porphyry-style zoning. The sericite (phyllic) zone sits closest to the "
39
+ "ore, so it gets the largest weight. This is a judgement, not a measured value.",
40
+ "evidence": "Thanewasna: muscovite layer AUC 0.62 at lodes, 0.76 at boreholes.",
41
+ },
42
+ "targets.w_quartz": {
43
+ "what": "How much quartz / silicification counts in the hydrothermal score H.",
44
+ "how": "H = w_musc·muscovite + w_qtz·quartz + w_goss·gossan + w_chl·chlorite, each layer 0-1. "
45
+ "The four weights must add up to 1.",
46
+ "up": "Silica-rich areas, from ASTER thermal data at 90 m, rise in the ranking.",
47
+ "down": "Quartz counts less.",
48
+ "why": "Expert-assigned; silicification often accompanies the phyllic zone.",
49
+ "evidence": "Thanewasna: the quartz layer shows no skill at the lodes (AUC 0.51) and some at boreholes (0.68). "
50
+ "The ASTER pixels (90 m) are coarse for a 2 km block.",
51
+ },
52
+ "targets.w_gossan": {
53
+ "what": "How much gossan / iron oxide counts in the hydrothermal score H.",
54
+ "how": "H = w_musc·muscovite + w_qtz·quartz + w_goss·gossan + w_chl·chlorite, each layer 0-1. "
55
+ "The four weights must add up to 1.",
56
+ "up": "Iron-stained, oxidised ground rises in the ranking.",
57
+ "down": "Iron oxide counts less. Useful where laterite or ferruginous soil (not gossan) is widespread.",
58
+ "why": "Expert-assigned. A gossan is the weathered cap of sulphides.",
59
+ "evidence": "Thanewasna: the strongest single layer (AUC 0.77 at lodes, 0.83 at boreholes). Two independent "
60
+ "satellites (ASTER, Sentinel-2) show the same iron-oxide pattern.",
61
+ },
62
+ "targets.w_chlorite": {
63
+ "what": "How much chlorite / epidote (propylitic alteration) counts in the hydrothermal score H.",
64
+ "how": "H = w_musc·muscovite + w_qtz·quartz + w_goss·gossan + w_chl·chlorite, each layer 0-1. "
65
+ "The four weights must add up to 1.",
66
+ "up": "Chlorite-rich areas rise in the ranking.",
67
+ "down": "Chlorite counts less.",
68
+ "why": "Expert-assigned and smallest, because the propylitic zone is the outer halo, not the ore.",
69
+ "evidence": "Thanewasna: chlorite is lower at lode-intersecting boreholes than at halo boreholes (AUC 0.29). "
70
+ "In this deposit it behaves as a distal indicator, so a lower weight is worth testing.",
71
+ },
72
+ "targets.w_structure": {
73
+ "what": "How much the nearness to a lineament counts in the final score, compared with the minerals.",
74
+ "how": "CPI = (1 − s)·H + s·S_prox, where S_prox = exp(−distance to nearest lineament / λ).",
75
+ "up": "Zones are pulled towards mapped lineaments. Areas with no mapped line score lower.",
76
+ "down": "The minerals decide almost alone.",
77
+ "why": "Expert-assigned: alteration is the main evidence (70 %) and structure supports it (30 %).",
78
+ "evidence": "Thanewasna: structure layer alone AUC 0.77 at lodes, 0.85 at boreholes. Remember that "
79
+ "lineaments come from terrain and miss buried structures.",
80
+ },
81
+ "targets.structure_lambda_m": {
82
+ "what": "How quickly the benefit of being near a lineament fades with distance.",
83
+ "how": "S_prox = exp(−d / λ). At d = λ the structural score is 37 %, at 2λ it is 14 %, at 3λ 5 %.",
84
+ "up": "Lineaments influence a wider strip. The map gets smoother and more ground benefits.",
85
+ "down": "Only ground right next to a lineament benefits.",
86
+ "why": "Assumed width of fracture-controlled alteration around a structure (a few hundred metres).",
87
+ "evidence": "Thanewasna: λ = 400 m gave lode AUC 0.74, against 0.73 with 250 m. The difference is small.",
88
+ },
89
+ "targets.tier1": {
90
+ "what": "Score a zone must reach somewhere inside it to be a Tier 1 (high-priority) zone.",
91
+ "how": "A prospective zone is Tier 1 when its peak CPI >= this value. It is also the highest contour on the "
92
+ "CPI map. CPI is not re-stretched, so these are absolute values.",
93
+ "up": "Fewer zones are called Tier 1.",
94
+ "down": "More zones are called Tier 1.",
95
+ "why": "Expert-assigned class boundary. It changes labels and suggested follow-up, not the zone outlines.",
96
+ "evidence": "Thanewasna: the Tier 1 zone covers 17.5 % of the block and holds 53 % of the known lode area "
97
+ "(3.1 times chance) and 6 of the 8 boreholes that intersect a lode.",
98
+ },
99
+ "targets.tier2": {
100
+ "what": "The outline of a prospective zone: all connected ground with CPI at or above this value.",
101
+ "how": "Zones are the connected areas with CPI >= this value. A zone is Tier 1 if it reaches the Tier 1 "
102
+ "threshold somewhere, otherwise Tier 2. This is the middle contour on the CPI map.",
103
+ "up": "Smaller, tighter zones: less ground to check, but more of the mineralisation is left outside.",
104
+ "down": "Larger zones that hold more of the mineralisation but give less focus. Very low values merge "
105
+ "whole districts into one zone.",
106
+ "why": "Expert-assigned. Tested against the Tier 3 line as the boundary, which was too loose.",
107
+ "evidence": "Thanewasna, share of block covered -> share of lode area inside: CPI >= 28 %: 56 % -> 74 % "
108
+ "(1.3 times chance); CPI >= 50 %: 16.5 % -> 48.5 % (2.9 times); CPI >= 70 %: 7 % -> 35 % (5 times).",
109
+ },
110
+ "targets.tier3": {
111
+ "what": "The outer halo around the zones, and the score an isolated weak anomaly needs to be listed as a Tier 3 zone.",
112
+ "how": "Ground with CPI >= this value around a Tier 1 / 2 zone is its outer halo (lowest contour on the map). "
113
+ "An anomaly that reaches this value but never the Tier 2 threshold, and is separate from every "
114
+ "Tier 1 / 2 zone, is listed as a Tier 3 zone.",
115
+ "up": "Narrower halos and fewer Tier 3 zones.", "down": "Wider halos and more weak Tier 3 zones.",
116
+ "why": "Expert-assigned class boundary.",
117
+ },
118
+ "targets.min_cpi": {
119
+ "what": "No field-check location is suggested where the score is below this value.",
120
+ "how": "Only cells with CPI >= this value are considered when the suggested field-check locations are picked.",
121
+ "up": "Fewer locations, all in strong anomalies.",
122
+ "down": "More locations, reaching into weak anomalies (still at most the maximum number).",
123
+ "why": "Expert-assigned, just below the Tier 3 boundary.",
124
+ },
125
+ "targets.min_spacing_m": {
126
+ "what": "The minimum distance between two suggested field-check locations.",
127
+ "how": "Cells are taken from the highest score down. A cell closer than this to an already chosen location is "
128
+ "skipped: it is the weaker neighbour of that location, part of the same anomaly. The same distance is "
129
+ "given as the 'check within' radius of each location.",
130
+ "up": "Fewer, more spread-out locations: roughly one per anomaly.",
131
+ "down": "Several locations can sit on one large zone.",
132
+ "why": "Assumed: about the size of one anomaly, so one zone does not take all the top ranks. The locations are "
133
+ "starting points for field work. Their exact position is not significant, and they are not drill collars.",
134
+ "evidence": "Thanewasna: only 3 of the top 10 locations lie within 100 m of a lode (1.9 expected by chance). "
135
+ "The method predicts zones far better than points.",
136
+ },
137
+ "targets.n_targets": {
138
+ "what": "The largest number of suggested field-check locations listed.",
139
+ "how": "Picking stops when this many locations are found (or when no cell above the minimum score is left).",
140
+ "up": "A longer list.", "down": "A shorter list.",
141
+ "why": "A practical list length for planning field traverses.",
142
+ },
143
+ # ------------------------------------------------------------------ alteration layers
144
+ "alteration.sigma_core_m": {
145
+ "what": "Size of the sharp 'hot spot' drawn around each EMIT detection.",
146
+ "how": "Every detection (a 60 m pixel) is spread with a Gaussian of this σ. The core keeps the peak where "
147
+ "the mineral was actually seen.",
148
+ "up": "Hot spots are blurred and nearby detections merge.",
149
+ "down": "Hot spots stay pixel-sharp and spotty.",
150
+ "why": "Expert choice: about 1.5 EMIT pixels, enough to remove pixel noise but keep detail.",
151
+ },
152
+ "alteration.sigma_halo_m": {
153
+ "what": "Size of the wider zone drawn around each detection, standing for the alteration halo.",
154
+ "how": "A second, wider Gaussian of this σ is combined with the core. It decides how far a zone reaches "
155
+ "beyond the detected pixels.",
156
+ "up": "Wider, more continuous zones; isolated detections spread further.",
157
+ "down": "Tighter zones that hug the detections.",
158
+ "why": "Expert choice: alteration halos around a mineralised system are typically a few hundred metres wide.",
159
+ },
160
+ "alteration.theta_decay": {
161
+ "what": "How many detections must be close together before a halo is believed.",
162
+ "how": "confidence = 1 − exp(−W / θ), where W is the share of detections within the halo around a pixel.",
163
+ "up": "A single lonely detection fades away (treated as noise); only clusters show strongly.",
164
+ "down": "Even one detection shows at full strength.",
165
+ "why": "Expert choice.",
166
+ },
167
+ "alteration.reference_percentile": {
168
+ "what": "Which value is shown as 100 % on the mineral maps.",
169
+ "how": "Intensity = field / (value of the field at this percentile inside the area), capped at 100 %.",
170
+ "up": "Fewer pixels reach 100 %; the strongest areas stand out more.",
171
+ "down": "More of the map looks saturated at 100 %.",
172
+ "why": "The 98th percentile ignores the most extreme 2 % of pixels. Percentages are relative to "
173
+ "this area, so 80 % here is not comparable with 80 % in another area.",
174
+ },
175
+ "alteration.sericite_min_nm": {
176
+ "what": "Shortest wavelength of the Al-OH absorption that is accepted as white mica (muscovite / sericite).",
177
+ "how": "Muscovite has its main absorption near 2200 nm. Detections whose absorption minimum falls outside "
178
+ "the window are not counted as muscovite.",
179
+ "up": "Excludes sodic micas (paragonite, near 2190 nm).",
180
+ "down": "Accepts more sodic micas.",
181
+ "why": "From spectral-library positions of the muscovite family. Kaolinite and smectite absorb at similar "
182
+ "positions, so the window cannot exclude them completely.",
183
+ },
184
+ "alteration.sericite_max_nm": {
185
+ "what": "Longest wavelength of the Al-OH absorption that is accepted as white mica.",
186
+ "how": "Muscovite has its main absorption near 2200 nm. Detections whose absorption minimum falls outside "
187
+ "the window are not counted as muscovite.",
188
+ "up": "Accepts phengitic (Fe/Mg-rich) micas, which absorb at 2215-2225 nm.",
189
+ "down": "Keeps only classic muscovite / sericite.",
190
+ "why": "From spectral-library positions of the muscovite family.",
191
+ },
192
+ "alteration.quartz_qi_weight": {
193
+ "what": "Balance between the two ASTER thermal indices in the quartz layer.",
194
+ "how": "QSI = w·QI + (1 − w)·SI. QI = B11² / (B10·B12) is the published quartz index (Ninomiya 2003). "
195
+ "SI = B13 / B12 is a silica ratio inherited from earlier work with no verified primary source.",
196
+ "up": "Relies more on the published quartz index.",
197
+ "down": "Relies more on the unverified silica ratio.",
198
+ "why": "Expert choice. This weighting is one of the review points in the methodology.",
199
+ },
200
+ "alteration.quartz_background_percentile": {
201
+ "what": "How much of the weakest quartz signal is treated as ordinary rock (0 %).",
202
+ "how": "Quartz intensity = (QSI − P_background) / (P98 − P_background), clipped to 0-100 %.",
203
+ "up": "Only the strongest quartz shows; more of the area becomes 0 %.",
204
+ "down": "More of the area shows some quartz.",
205
+ "why": "Project assumption: the lowest 15 % of the area is background.",
206
+ },
207
+ # ------------------------------------------------------------------ lineaments
208
+ "structure.min_length_m": {
209
+ "what": "Lineaments shorter than this are dropped.",
210
+ "how": "After the line pieces are joined, any line shorter than this is removed.",
211
+ "up": "Only major structures remain.",
212
+ "down": "More short lines, including drainage and noise.",
213
+ "why": "Expert choice: a structure that guides fluids over a deposit is usually at least ~1 km long.",
214
+ },
215
+ "structure.min_segment_m": {
216
+ "what": "Shortest straight piece the line finder will accept before joining pieces.",
217
+ "how": "A Hough transform finds straight pieces on the edge map; pieces shorter than this are ignored.",
218
+ "up": "Fewer, cleaner pieces.", "down": "More fragments.",
219
+ "why": "Project assumption.",
220
+ },
221
+ "structure.max_gap_m": {
222
+ "what": "Largest break that is bridged inside one straight piece.",
223
+ "how": "Edges along the same line with a gap up to this size are treated as one piece.",
224
+ "up": "Broken lines are joined more easily.", "down": "Lines break at small gaps.",
225
+ "why": "Project assumption (3 pixels at 30 m).",
226
+ },
227
+ "structure.canny_low_quantile": {
228
+ "what": "How weak an edge may be and still extend a line.",
229
+ "how": "Edges in the top (1 − value) of contrast can continue a line that a strong edge started.",
230
+ "up": "Fewer, shorter lines.", "down": "Lines run further through weak edges.",
231
+ "why": "Project assumption (top 20 %).",
232
+ },
233
+ "structure.canny_high_quantile": {
234
+ "what": "How strong an edge must be to start a line.",
235
+ "how": "Only edges in the top (1 − value) of contrast can start a line.",
236
+ "up": "Only the sharpest features start lines.", "down": "More lines start.",
237
+ "why": "Project assumption (top 5 %).",
238
+ },
239
+ "structure.scarp_min_step_m": {
240
+ "what": "When a line is called a scarp (a cliff or plateau edge) instead of a valley or ridge.",
241
+ "how": "The terrain is compared 90 m either side of the line. If one side is consistently higher by at "
242
+ "least this much, the line is a scarp.",
243
+ "up": "Fewer lines are called scarps.", "down": "More lines are called scarps.",
244
+ "why": "Expert choice. Erosional scarps (such as the Deccan plateau edge) are not fluid pathways.",
245
+ },
246
+ "structure.exclude_scarps": {
247
+ "what": "Whether scarps are left out of the CPI.",
248
+ "how": "Scarps always stay in the lineament map and vectors. When this is on, they are not used for the "
249
+ "structural score or density.",
250
+ "up": "On: cliff edges do not attract targets.", "down": "Off: every line counts.",
251
+ "why": "Project assumption, checked at both blocks.",
252
+ },
253
+ # ------------------------------------------------------------------ EMIT detection
254
+ "spectral.detect_sigma": {
255
+ "what": "How sure we must be that an absorption is real before calling it a detection.",
256
+ "how": "Detected when band depth > k × its uncertainty. The uncertainty comes from EMIT's own per-band "
257
+ "error, propagated with 64 Monte-Carlo draws. k = 2 is about 95 % confidence.",
258
+ "up": "Fewer, more certain detections.", "down": "More, less certain detections.",
259
+ "why": "Project assumption: the usual 2σ significance level.",
260
+ },
261
+ "spectral.min_depth": {
262
+ "what": "Weakest absorption that can count as a detection, however precise it is.",
263
+ "how": "Detected only when band depth is also above this value (0.01 = a 1 % dip).",
264
+ "up": "Shallow absorptions are ignored.", "down": "Very shallow dips can count.",
265
+ "why": "Project assumption.",
266
+ },
267
+ "spectral.min_albedo": {
268
+ "what": "Pixels darker than this are skipped.",
269
+ "how": "Pixels with reflectance below this value are masked before band depths are measured.",
270
+ "up": "More dark ground (shadow, water, burnt areas) is skipped.", "down": "Darker pixels are used.",
271
+ "why": "Project assumption: band depths are unreliable on very dark surfaces.",
272
+ },
273
+ }
274
+
275
+ LAYER_INFO = {
276
+ "cpi": "Copper Prospectivity Index: (1 − s) × hydrothermal score + s × nearness to lineaments. "
277
+ "0-100 %; contours at the Tier 3 / 2 / 1 thresholds. The Tier 2 contour is the outline of the "
278
+ "prospective zones.",
279
+ "muscovite": "White mica (sericite, phyllic alteration) from the EMIT Al-OH absorption near 2200 nm. "
280
+ "Relative intensity: 100 % = strongest 2 % of this area.",
281
+ "chlorite": "Chlorite / epidote (propylitic alteration) from the EMIT Fe-OH (2250 nm) and Mg-OH (2330 nm) "
282
+ "absorptions, with carbonate-dominated pixels excluded using EMIT L2B.",
283
+ "gossan": "Iron oxide (Fe³⁺) from the EMIT 900 nm absorption. It cannot separate gossan from laterite or "
284
+ "ferruginous soil.",
285
+ "quartz": "Quartz / silicification from ASTER thermal emissivity (90 m): QSI = w·QI + (1 − w)·SI.",
286
+ "lineament_density": "Length of lineaments (km) per km² within 1 km.",
287
+ }
geoscan/geoscan/webapp/jobs.py CHANGED
@@ -1,168 +1,168 @@
1
- """Background analysis jobs: one at a time (Earth Engine downloads are the bottleneck)."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import logging
7
- import queue
8
- import shutil
9
- import subprocess
10
- import sys
11
- import threading
12
- import traceback
13
- import uuid
14
- from dataclasses import dataclass, field
15
- from datetime import datetime, timezone
16
- from pathlib import Path
17
-
18
- from ..pipeline import STEPS, Pipeline
19
- from . import accounts, catalog
20
-
21
- VALIDATED_BLOCKS = ("kelipur", "thanewasna") # blocks with ground truth handled by scripts/validate.py
22
- STEP_LABELS = {"acquire": "Downloading satellite data (EMIT, DEM, ASTER)", "emit_l2a": "EMIT mineral absorption features",
23
- "emit_l2b": "EMIT L2B mineral identification", "aster": "ASTER thermal indices (quartz)",
24
- "structure": "Lineaments", "alteration": "Alteration layers", "targets": "CPI and drill targets",
25
- "maps": "Contour maps", "validation": "Validation against ground truth"}
26
-
27
-
28
- def _now():
29
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
30
-
31
-
32
- @dataclass
33
- class Job:
34
- job_id: str
35
- block: str
36
- options: dict # all option and parameter values
37
- output_name: str
38
- status: str = "queued" # queued | running | done | failed
39
- step: str = ""
40
- log: list = field(default_factory=list)
41
- error: str = ""
42
- run_id: str = ""
43
- created: str = field(default_factory=_now)
44
- user: str = ""
45
- extra: dict = field(default_factory=dict)
46
-
47
- def to_dict(self):
48
- steps = list(self.extra.get("steps", STEPS)) + (["validation"] if self.block in VALIDATED_BLOCKS else [])
49
- idx = steps.index(self.step) if self.step in steps else -1
50
- return {"job_id": self.job_id, "block": self.block, "label": catalog.option_label(self.options),
51
- "options": {k: v for k, v in self.options.items() if k in catalog.OPTIONS},
52
- "params": {k: v for k, v in self.options.items() if k in catalog.PARAMS}, "status": self.status, "step": self.step,
53
- "step_label": STEP_LABELS.get(self.step, self.step), "progress": (idx + (self.status == "done")) / len(steps)
54
- if idx >= 0 else (1.0 if self.status == "done" else 0.0),
55
- "log": self.log[-200:], "error": self.error, "run_id": self.run_id, "created": self.created, "user": self.user}
56
-
57
-
58
- class _LogHandler(logging.Handler):
59
- def __init__(self, job: Job):
60
- super().__init__(logging.INFO)
61
- self.job = job
62
-
63
- def emit(self, record):
64
- if record.name.startswith("geoscan"):
65
- self.job.log.append(f"{datetime.now():%H:%M:%S} {record.getMessage()}")
66
-
67
-
68
- class JobManager:
69
- def __init__(self):
70
- self.jobs: dict[str, Job] = {}
71
- self.q: queue.Queue = queue.Queue()
72
- threading.Thread(target=self._worker, daemon=True).start()
73
-
74
- def submit(self, block: str, options: dict, user: str = "") -> Job:
75
- for j in self.jobs.values():
76
- if j.block == block and j.options == options and j.status in ("queued", "running"):
77
- return j
78
- vid = catalog.variant_id(options)
79
- out = block if not vid else f"{block}__{vid}"
80
- job = Job(uuid.uuid4().hex[:10], block, options, out, user=user)
81
- accounts.record(user, "run_started", area=block, settings=catalog.option_label(options),
82
- changed_parameters=catalog.changed(options), recompute_from=catalog.first_step(options))
83
- self.jobs[job.job_id] = job
84
- self.q.put(job.job_id)
85
- return job
86
-
87
- def _worker(self):
88
- while True:
89
- job = self.jobs[self.q.get()]
90
- job.status = "running"
91
- h = _LogHandler(job)
92
- logging.getLogger("geoscan").addHandler(h)
93
- logging.getLogger("geoscan").setLevel(logging.INFO)
94
- try:
95
- self._run(job)
96
- self._save(job, results=True)
97
- job.status = "done"
98
- accounts.record(job.user, "run_finished", area=job.block, results=job.run_id)
99
- except Exception as e: # report, keep the worker alive
100
- job.status = "failed"
101
- job.error = f"{type(e).__name__}: {e}"
102
- accounts.record(job.user, "run_failed", area=job.block, step=job.step, error=job.error[:300])
103
- job.log.extend(traceback.format_exc().splitlines()[-16:]) # innermost frames
104
- self._save(job, results=False) # keep the downloaded satellite data at least
105
- finally:
106
- logging.getLogger("geoscan").removeHandler(h)
107
-
108
- def _save(self, job: Job, results: bool):
109
- """Copy new files to permanent storage when the app runs on a host with a temporary disk."""
110
- from . import persist
111
- if not persist.enabled():
112
- return
113
- try:
114
- job.log.append(f"{datetime.now():%H:%M:%S} Saving to permanent storage ...")
115
- persist.save_area(job.block)
116
- if results:
117
- persist.save_run(job.output_name)
118
- job.log.append(f"{datetime.now():%H:%M:%S} Saved.")
119
- except Exception as e: # never fail an analysis because of storage
120
- job.log.append(f"WARNING: could not save to permanent storage: {e}")
121
-
122
- def _run(self, job: Job):
123
- def progress(step, msg):
124
- job.step = step
125
- job.log.append(f"{datetime.now():%H:%M:%S} [{STEP_LABELS.get(step, step)}] {msg}")
126
-
127
- settings = catalog.settings_for(job.options)
128
- aoi = catalog.DATA / job.block / "aoi" / "aoi.geojson"
129
- p = Pipeline(aoi, job.block, catalog.ROOT, settings, progress=progress, output_name=job.output_name)
130
- start = catalog.first_step(job.options)
131
- base = catalog.base_run(job.block, job.options)
132
- out = catalog.OUTPUTS / job.output_name
133
- if base is not None and base.path.resolve() != out.resolve():
134
- # re-use everything computed before the first changed step
135
- steps = STEPS[STEPS.index(start):]
136
- job.extra["steps"] = steps
137
- job.log.append(f"Re-using results up to '{start}' from {base.run_id}; running {', '.join(steps)}")
138
- if out.exists():
139
- shutil.rmtree(out)
140
- shutil.copytree(base.path, out, ignore=shutil.ignore_patterns("_webapp_cache", "maps"))
141
- if STEPS.index(start) <= STEPS.index("structure"):
142
- from ..acquire import gee
143
- src = settings.structure.dem_source
144
- path = gee.dem_path(p.ws.raw(""), src)
145
- if not path.exists():
146
- progress("structure", f"downloading {gee.dem_label(src)} DEM")
147
- gee.initialize(settings.acquire.ee_project)
148
- gee.download_dem(src, p.grid_dem, path, settings.acquire.max_request_mb, settings.acquire.retries)
149
- p.run(tuple(steps), clean=False)
150
- else:
151
- p.run(STEPS, clean=True)
152
- if job.block in VALIDATED_BLOCKS:
153
- progress("validation", "started")
154
- script = Path(__file__).resolve().parents[2] / "scripts" / "validate.py"
155
- args = [sys.executable, str(script), "--name", job.block]
156
- if job.output_name != job.block:
157
- args += ["--output", job.output_name]
158
- r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
159
- if r.returncode != 0:
160
- job.log.append(r.stderr[-2000:])
161
- raise RuntimeError("validation failed")
162
- progress("validation", "done")
163
- (catalog.OUTPUTS / job.output_name / "created_by.json").write_text(
164
- json.dumps({"user": job.user, "time": _now()}), encoding="utf-8")
165
- run = catalog.find_run(job.block, job.options)
166
- if run is None:
167
- raise RuntimeError("run finished but its results were not found")
168
- job.run_id = run.run_id
 
1
+ """Background analysis jobs: one at a time (Earth Engine downloads are the bottleneck)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import queue
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import traceback
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from ..pipeline import STEPS, Pipeline
19
+ from . import accounts, catalog
20
+
21
+ VALIDATED_BLOCKS = ("kelipur", "thanewasna") # blocks with ground truth handled by scripts/validate.py
22
+ STEP_LABELS = {"acquire": "Downloading satellite data (EMIT, DEM, ASTER)", "emit_l2a": "EMIT mineral absorption features",
23
+ "emit_l2b": "EMIT L2B mineral identification", "aster": "ASTER thermal indices (quartz)",
24
+ "structure": "Lineaments", "alteration": "Alteration layers", "targets": "CPI, zones and field-check locations",
25
+ "maps": "Contour maps", "validation": "Validation against ground truth"}
26
+
27
+
28
+ def _now():
29
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
30
+
31
+
32
+ @dataclass
33
+ class Job:
34
+ job_id: str
35
+ block: str
36
+ options: dict # all option and parameter values
37
+ output_name: str
38
+ status: str = "queued" # queued | running | done | failed
39
+ step: str = ""
40
+ log: list = field(default_factory=list)
41
+ error: str = ""
42
+ run_id: str = ""
43
+ created: str = field(default_factory=_now)
44
+ user: str = ""
45
+ extra: dict = field(default_factory=dict)
46
+
47
+ def to_dict(self):
48
+ steps = list(self.extra.get("steps", STEPS)) + (["validation"] if self.block in VALIDATED_BLOCKS else [])
49
+ idx = steps.index(self.step) if self.step in steps else -1
50
+ return {"job_id": self.job_id, "block": self.block, "label": catalog.option_label(self.options),
51
+ "options": {k: v for k, v in self.options.items() if k in catalog.OPTIONS},
52
+ "params": {k: v for k, v in self.options.items() if k in catalog.PARAMS}, "status": self.status, "step": self.step,
53
+ "step_label": STEP_LABELS.get(self.step, self.step), "progress": (idx + (self.status == "done")) / len(steps)
54
+ if idx >= 0 else (1.0 if self.status == "done" else 0.0),
55
+ "log": self.log[-200:], "error": self.error, "run_id": self.run_id, "created": self.created, "user": self.user}
56
+
57
+
58
+ class _LogHandler(logging.Handler):
59
+ def __init__(self, job: Job):
60
+ super().__init__(logging.INFO)
61
+ self.job = job
62
+
63
+ def emit(self, record):
64
+ if record.name.startswith("geoscan"):
65
+ self.job.log.append(f"{datetime.now():%H:%M:%S} {record.getMessage()}")
66
+
67
+
68
+ class JobManager:
69
+ def __init__(self):
70
+ self.jobs: dict[str, Job] = {}
71
+ self.q: queue.Queue = queue.Queue()
72
+ threading.Thread(target=self._worker, daemon=True).start()
73
+
74
+ def submit(self, block: str, options: dict, user: str = "") -> Job:
75
+ for j in self.jobs.values():
76
+ if j.block == block and j.options == options and j.status in ("queued", "running"):
77
+ return j
78
+ vid = catalog.variant_id(options)
79
+ out = block if not vid else f"{block}__{vid}"
80
+ job = Job(uuid.uuid4().hex[:10], block, options, out, user=user)
81
+ accounts.record(user, "run_started", area=block, settings=catalog.option_label(options),
82
+ changed_parameters=catalog.changed(options), recompute_from=catalog.first_step(options))
83
+ self.jobs[job.job_id] = job
84
+ self.q.put(job.job_id)
85
+ return job
86
+
87
+ def _worker(self):
88
+ while True:
89
+ job = self.jobs[self.q.get()]
90
+ job.status = "running"
91
+ h = _LogHandler(job)
92
+ logging.getLogger("geoscan").addHandler(h)
93
+ logging.getLogger("geoscan").setLevel(logging.INFO)
94
+ try:
95
+ self._run(job)
96
+ self._save(job, results=True)
97
+ job.status = "done"
98
+ accounts.record(job.user, "run_finished", area=job.block, results=job.run_id)
99
+ except Exception as e: # report, keep the worker alive
100
+ job.status = "failed"
101
+ job.error = f"{type(e).__name__}: {e}"
102
+ accounts.record(job.user, "run_failed", area=job.block, step=job.step, error=job.error[:300])
103
+ job.log.extend(traceback.format_exc().splitlines()[-16:]) # innermost frames
104
+ self._save(job, results=False) # keep the downloaded satellite data at least
105
+ finally:
106
+ logging.getLogger("geoscan").removeHandler(h)
107
+
108
+ def _save(self, job: Job, results: bool):
109
+ """Copy new files to permanent storage when the app runs on a host with a temporary disk."""
110
+ from . import persist
111
+ if not persist.enabled():
112
+ return
113
+ try:
114
+ job.log.append(f"{datetime.now():%H:%M:%S} Saving to permanent storage ...")
115
+ persist.save_area(job.block)
116
+ if results:
117
+ persist.save_run(job.output_name)
118
+ job.log.append(f"{datetime.now():%H:%M:%S} Saved.")
119
+ except Exception as e: # never fail an analysis because of storage
120
+ job.log.append(f"WARNING: could not save to permanent storage: {e}")
121
+
122
+ def _run(self, job: Job):
123
+ def progress(step, msg):
124
+ job.step = step
125
+ job.log.append(f"{datetime.now():%H:%M:%S} [{STEP_LABELS.get(step, step)}] {msg}")
126
+
127
+ settings = catalog.settings_for(job.options)
128
+ aoi = catalog.DATA / job.block / "aoi" / "aoi.geojson"
129
+ p = Pipeline(aoi, job.block, catalog.ROOT, settings, progress=progress, output_name=job.output_name)
130
+ start = catalog.first_step(job.options)
131
+ base = catalog.base_run(job.block, job.options)
132
+ out = catalog.OUTPUTS / job.output_name
133
+ if base is not None and base.path.resolve() != out.resolve():
134
+ # re-use everything computed before the first changed step
135
+ steps = STEPS[STEPS.index(start):]
136
+ job.extra["steps"] = steps
137
+ job.log.append(f"Re-using results up to '{start}' from {base.run_id}; running {', '.join(steps)}")
138
+ if out.exists():
139
+ shutil.rmtree(out)
140
+ shutil.copytree(base.path, out, ignore=shutil.ignore_patterns("_webapp_cache", "maps"))
141
+ if STEPS.index(start) <= STEPS.index("structure"):
142
+ from ..acquire import gee
143
+ src = settings.structure.dem_source
144
+ path = gee.dem_path(p.ws.raw(""), src)
145
+ if not path.exists():
146
+ progress("structure", f"downloading {gee.dem_label(src)} DEM")
147
+ gee.initialize(settings.acquire.ee_project)
148
+ gee.download_dem(src, p.grid_dem, path, settings.acquire.max_request_mb, settings.acquire.retries)
149
+ p.run(tuple(steps), clean=False)
150
+ else:
151
+ p.run(STEPS, clean=True)
152
+ if job.block in VALIDATED_BLOCKS:
153
+ progress("validation", "started")
154
+ script = Path(__file__).resolve().parents[2] / "scripts" / "validate.py"
155
+ args = [sys.executable, str(script), "--name", job.block]
156
+ if job.output_name != job.block:
157
+ args += ["--output", job.output_name]
158
+ r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
159
+ if r.returncode != 0:
160
+ job.log.append(r.stderr[-2000:])
161
+ raise RuntimeError("validation failed")
162
+ progress("validation", "done")
163
+ (catalog.OUTPUTS / job.output_name / "created_by.json").write_text(
164
+ json.dumps({"user": job.user, "time": _now()}), encoding="utf-8")
165
+ run = catalog.find_run(job.block, job.options)
166
+ if run is None:
167
+ raise RuntimeError("run finished but its results were not found")
168
+ job.run_id = run.run_id
geoscan/geoscan/webapp/layers.py CHANGED
@@ -31,8 +31,9 @@ RASTER_LAYERS = {
31
  "cmap": "viridis", "range": None},
32
  }
33
  DROP_COLUMNS = {"EMIT_Data_Support"}
34
- VECTORS = {"targets": "top50_targets.geojson", "lineaments": "lineaments.geojson",
35
- "cpi_zones": "cpi_target_zones.geojson", "aoi": "aoi.geojson"}
 
36
 
37
 
38
  def _cache(run_dir: Path) -> Path:
@@ -160,7 +161,10 @@ def contours(run_dir: Path, key: str) -> gpd.GeoDataFrame:
160
 
161
  def vector(run_dir: Path, key: str) -> gpd.GeoDataFrame:
162
  name = VECTORS.get(key) or f"{key}.geojson"
163
- g = gpd.read_file(run_dir / "vectors" / name)
 
 
 
164
  return g.drop(columns=[c for c in g.columns if c in DROP_COLUMNS])
165
 
166
 
 
31
  "cmap": "viridis", "range": None},
32
  }
33
  DROP_COLUMNS = {"EMIT_Data_Support"}
34
+ VECTORS = {"targets": "field_check_locations.geojson", "zones": "prospective_zones.geojson",
35
+ "lineaments": "lineaments.geojson", "cpi_zones": "cpi_target_zones.geojson", "aoi": "aoi.geojson"}
36
+ OLD_NAMES = {"field_check_locations.geojson": "top50_targets.geojson"} # results made before zones existed
37
 
38
 
39
  def _cache(run_dir: Path) -> Path:
 
161
 
162
  def vector(run_dir: Path, key: str) -> gpd.GeoDataFrame:
163
  name = VECTORS.get(key) or f"{key}.geojson"
164
+ path = run_dir / "vectors" / name
165
+ if not path.exists() and name in OLD_NAMES:
166
+ path = run_dir / "vectors" / OLD_NAMES[name]
167
+ g = gpd.read_file(path)
168
  return g.drop(columns=[c for c in g.columns if c in DROP_COLUMNS])
169
 
170
 
geoscan/geoscan/webapp/persist.py CHANGED
@@ -51,13 +51,15 @@ def restore() -> int:
51
  return n
52
 
53
 
54
- def _upload(folder, message: str) -> bool:
 
55
  if not enabled() or not folder.exists():
56
  return False
57
  rel = folder.relative_to(catalog.ROOT).as_posix()
58
  with _lock:
59
  _api().upload_folder(repo_id=repo(), repo_type="dataset", folder_path=str(folder), path_in_repo=rel,
60
- commit_message=message, ignore_patterns=IGNORE)
 
61
  log.info("Saved %s to %s", rel, repo())
62
  return True
63
 
@@ -67,8 +69,8 @@ def save_area(area: str) -> bool:
67
 
68
 
69
  def save_run(output_name: str) -> bool:
70
- ok = _upload(catalog.OUTPUTS / output_name, f"results: {output_name}")
71
- _upload(catalog.OUTPUTS / f"{output_name}_validation", f"validation: {output_name}")
72
  return ok
73
 
74
 
 
51
  return n
52
 
53
 
54
+ def _upload(folder, message: str, mirror: bool = False) -> bool:
55
+ """mirror=True also deletes stored files that no longer exist locally (results folders are rebuilt whole)."""
56
  if not enabled() or not folder.exists():
57
  return False
58
  rel = folder.relative_to(catalog.ROOT).as_posix()
59
  with _lock:
60
  _api().upload_folder(repo_id=repo(), repo_type="dataset", folder_path=str(folder), path_in_repo=rel,
61
+ commit_message=message, ignore_patterns=IGNORE,
62
+ delete_patterns=["*"] if mirror else None)
63
  log.info("Saved %s to %s", rel, repo())
64
  return True
65
 
 
69
 
70
 
71
  def save_run(output_name: str) -> bool:
72
+ ok = _upload(catalog.OUTPUTS / output_name, f"results: {output_name}", mirror=True)
73
+ _upload(catalog.OUTPUTS / f"{output_name}_validation", f"validation: {output_name}", mirror=True)
74
  return ok
75
 
76
 
geoscan/geoscan/webapp/server.py CHANGED
@@ -371,9 +371,21 @@ def map_png(run_id: str, name: str):
371
  @app.get("/api/runs/{run_id}/targets")
372
  def targets(run_id: str):
373
  r = _run(run_id)
374
- df = pd.read_csv(r.path / "tables" / "top50_targets.csv")
 
375
  df = df.drop(columns=[c for c in df.columns if c in layers.DROP_COLUMNS])
376
- return df.where(pd.notna(df), None).to_dict(orient="records")
 
 
 
 
 
 
 
 
 
 
 
377
 
378
 
379
  @app.get("/api/runs/{run_id}/validation")
 
371
  @app.get("/api/runs/{run_id}/targets")
372
  def targets(run_id: str):
373
  r = _run(run_id)
374
+ path = r.path / "tables" / "field_check_locations.csv"
375
+ df = pd.read_csv(path if path.exists() else r.path / "tables" / "top50_targets.csv")
376
  df = df.drop(columns=[c for c in df.columns if c in layers.DROP_COLUMNS])
377
+ return df.astype(object).where(pd.notna(df), None).to_dict(orient="records")
378
+
379
+
380
+ @app.get("/api/runs/{run_id}/zones")
381
+ def zones(run_id: str):
382
+ """Ranked prospective zones (empty for results made before zones existed)."""
383
+ r = _run(run_id)
384
+ path = r.path / "tables" / "prospective_zones.csv"
385
+ if not path.exists():
386
+ return []
387
+ df = pd.read_csv(path)
388
+ return df.astype(object).where(pd.notna(df), None).to_dict(orient="records")
389
 
390
 
391
  @app.get("/api/runs/{run_id}/validation")
geoscan/geoscan/webapp/static/app.js CHANGED
@@ -1,774 +1,816 @@
1
- "use strict";
2
- const $ = (s) => document.querySelector(s);
3
- const api = async (url, opts) => {
4
- const r = await fetch(url, opts);
5
- if (!r.ok) {
6
- let msg = r.statusText;
7
- try { msg = (await r.json()).detail || msg; } catch (e) { /* not JSON */ }
8
- throw new Error(msg);
9
- }
10
- return r;
11
- };
12
- const getJSON = async (url) => (await api(url)).json();
13
- const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
14
-
15
- const S = { config: null, blocks: [], block: null, options: {}, params: {}, run: null, layer: "cpi", jobTimer: null, fitted: null };
16
- const CONTOUR_COLORS = ["#ffffff", "#ffeb3b", "#ff1744"];
17
- const TIER_COLORS = { "1": "#d50000", "2": "#ff9100", "3": "#9e9e9e" };
18
-
19
- /* ---------------- map ---------------- */
20
- const map = L.map("map", { zoomControl: true, preferCanvas: true }).setView([21, 79], 6);
21
- const bases = {
22
- sat: L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
23
- { maxZoom: 19, attribution: "Imagery © Esri, Maxar, Earthstar Geographics" }),
24
- topo: L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
25
- { maxZoom: 17, attribution: "© OpenStreetMap contributors, SRTM | © OpenTopoMap (CC-BY-SA)" }),
26
- };
27
- bases.sat.addTo(map);
28
- L.control.scale({ imperial: false }).addTo(map);
29
- const G = { overlay: null, na: null, contours: null, stations: null, targets: null, lineaments: null, zones: null, aoi: null, targetMarkers: {} };
30
-
31
- function clearLayer(k) { if (G[k]) { map.removeLayer(G[k]); G[k] = null; } }
32
-
33
- async function drawMap() {
34
- const run = S.run;
35
- ["overlay", "na", "contours", "stations", "targets", "lineaments", "zones", "aoi"].forEach(clearLayer);
36
- if (!run) { renderLegend(null); return; }
37
- const id = run.run_id;
38
- const aoi = await getJSON(`/api/runs/${id}/vector/aoi.geojson`);
39
- G.aoi = L.geoJSON(aoi, { style: { color: "#00e5ff", weight: 2, fill: false }, interactive: false }).addTo(map);
40
- if (S.fitted !== run.block) { map.fitBounds(G.aoi.getBounds(), { padding: [30, 30] }); S.fitted = run.block; }
41
- await drawRaster();
42
- await drawVectors();
43
- }
44
-
45
- const refOf = (layer) => layer.startsWith("ref:") ? S.run.reference.find((r) => r.key === layer.slice(4)) : null;
46
-
47
- function rampColour(colors, t) {
48
- return colors[Math.max(0, Math.min(colors.length - 1, Math.round(t * (colors.length - 1))))];
49
- }
50
-
51
- /* reference geophysics: surface (if any), black contours, stations coloured by value; never part of the CPI */
52
- async function drawReference(ref) {
53
- const base = `/api/blocks/${S.run.block}/reference/${ref.key}`;
54
- const info = await getJSON(`${base}/overlay`);
55
- const lg = info.legend;
56
- if (!info.none) {
57
- G.overlay = L.imageOverlay(info.url, info.bounds, { opacity: +$("#opacity").value, interactive: false }).addTo(map);
58
- if ($("#show-contours").checked) {
59
- G.contours = L.geoJSON(await getJSON(`${base}/contours.geojson`), {
60
- style: { color: "#111", weight: 1.2, opacity: 0.85 },
61
- onEachFeature: (f, l) => l.bindTooltip(`${f.properties.level} ${f.properties.unit}`, { sticky: true }),
62
- }).addTo(map);
63
- }
64
- }
65
- G.stations = L.geoJSON(await getJSON(`${base}/stations.geojson`), {
66
- // diamonds, so stations are not mistaken for drill targets (circles)
67
- pointToLayer: (f, ll) => L.marker(ll, { icon: L.divIcon({ className: "", iconSize: [14, 14], iconAnchor: [7, 7],
68
- html: `<span class="stn" style="background:${rampColour(lg.colors, (f.properties.value - lg.min) / (lg.max - lg.min || 1))}"></span>` }) }),
69
- onEachFeature: (f, l) => l.bindTooltip(`<b>${f.properties.value} ${esc(f.properties.unit)}</b><br>station ${f.properties.station}` +
70
- (f.properties.elevation_m != null ? `<br>elevation ${f.properties.elevation_m} m` : "")),
71
- }).addTo(map);
72
- return { ...lg, stations: ref.stations, spacing_m: ref.spacing_m, has_surface: ref.has_surface };
73
- }
74
-
75
- async function drawRaster() {
76
- clearLayer("overlay"); clearLayer("contours"); clearLayer("na"); clearLayer("stations");
77
- const run = S.run; if (!run) return;
78
- let legend = null;
79
- const ref = refOf(S.layer);
80
- if (ref) {
81
- legend = await drawReference(ref);
82
- S.hatchShown = false;
83
- S.legend = legend;
84
- renderLegend(legend);
85
- G.aoi && G.aoi.bringToFront();
86
- G.targets && G.targets.bringToFront();
87
- return;
88
- }
89
- if (S.layer !== "none") {
90
- const info = await getJSON(`/api/runs/${run.run_id}/overlay/${S.layer}`);
91
- G.overlay = L.imageOverlay(info.url, info.bounds, { opacity: +$("#opacity").value, interactive: false }).addTo(map);
92
- legend = info.legend;
93
- if ($("#show-contours").checked && info.legend.levels && info.legend.levels.length) {
94
- const gj = await getJSON(`/api/runs/${run.run_id}/contours/${S.layer}.geojson`);
95
- const lv = info.legend.levels;
96
- G.contours = L.geoJSON(gj, {
97
- style: (f) => ({ color: CONTOUR_COLORS[lv.indexOf(f.properties.level)] || "#fff", weight: 2, opacity: 0.95 }),
98
- onEachFeature: (f, l) => l.bindTooltip(`${f.properties.level} ${f.properties.unit}`, { sticky: true }),
99
- }).addTo(map);
100
- }
101
- }
102
- S.hatchShown = false;
103
- if ($("#show-na").checked && S.layer !== "none") {
104
- // per layer: only EMIT-based layers can be "not assessed", and only where nothing was detected
105
- const na = await getJSON(`/api/runs/${run.run_id}/overlay/na_${S.layer}`);
106
- if (!na.none) {
107
- G.na = L.imageOverlay(na.url, na.bounds, { opacity: 1, interactive: false }).addTo(map);
108
- S.hatchShown = true;
109
- }
110
- }
111
- S.legend = legend;
112
- renderLegend(legend);
113
- G.aoi && G.aoi.bringToFront();
114
- }
115
-
116
- async function drawVectors() {
117
- ["targets", "lineaments", "zones"].forEach(clearLayer);
118
- const id = S.run.run_id;
119
- if ($("#show-zones").checked) {
120
- const gj = await getJSON(`/api/runs/${id}/vector/cpi_zones.geojson`);
121
- G.zones = L.geoJSON(gj, { style: (f) => ({ color: TIER_COLORS[(f.properties.class || "").match(/Tier (\d)/)?.[1]] || "#999",
122
- weight: 1, fillOpacity: 0.25 }), onEachFeature: (f, l) => l.bindTooltip(f.properties.class) }).addTo(map);
123
- }
124
- if ($("#show-lineaments").checked) {
125
- const gj = await getJSON(`/api/runs/${id}/vector/lineaments.geojson`);
126
- G.lineaments = L.geoJSON(gj, {
127
- style: (f) => f.properties.morphology === "scarp" ? { color: "#bdbdbd", weight: 1.5, dashArray: "5 4" }
128
- : { color: "#00e676", weight: 2 },
129
- onEachFeature: (f, l) => l.bindTooltip(`${f.properties.line_id || ""} · strike ${f.properties.strike_deg}° · ` +
130
- `${Math.round(f.properties.length_m)} m · ${f.properties.morphology}` +
131
- (f.properties.morphology === "scarp" ? " (not used for CPI)" : "")),
132
- }).addTo(map);
133
- }
134
- if ($("#show-targets").checked) {
135
- const gj = await getJSON(`/api/runs/${id}/vector/targets.geojson`);
136
- G.targetMarkers = {};
137
- G.targets = L.geoJSON(gj, {
138
- pointToLayer: (f, ll) => {
139
- const t = String(f.properties.Priority_Tier || "").match(/(\d)/)?.[1];
140
- const m = L.circleMarker(ll, { radius: f.properties.Rank <= 10 ? 8 : 6, color: "#000", weight: 1,
141
- fillColor: TIER_COLORS[t] || "#999", fillOpacity: 0.95 });
142
- G.targetMarkers[f.properties.Rank] = m;
143
- return m;
144
- },
145
- onEachFeature: (f, l) => l.bindPopup(targetPopup(f.properties)),
146
- }).addTo(map);
147
- }
148
- G.aoi && G.aoi.bringToFront();
149
- }
150
-
151
- function targetPopup(p) {
152
- const rows = [["CPI", `${(+p.CPI_Percent).toFixed(1)} %`], ["Tier", p.Priority_Tier], ["Muscovite", `${p.Muscovite_Pct} %`],
153
- ["Quartz", `${p.Quartz_Pct} %`], ["Gossan", `${p.Gossan_Pct} %`], ["Chlorite", `${p.Chlorite_Pct} %`],
154
- ["To lineament", `${p.Distance_to_Lineament_m} m`], ["Lat / Lon", `${(+p.Latitude).toFixed(5)}, ${(+p.Longitude).toFixed(5)}`]];
155
- return `<b>#${p.Rank} ${esc(p.Target_Name || p.Target_ID)}</b><table>${rows.map(([k, v]) =>
156
- `<tr><td>${k}</td><td><b>${esc(v)}</b></td></tr>`).join("")}</table>` +
157
- (p.Exploration_Recommendation ? `<div class="small muted">${esc(p.Exploration_Recommendation)}</div>` : "");
158
- }
159
-
160
- function renderLegend(lg) {
161
- const el = $("#legend");
162
- let h = "";
163
- if (lg && lg.kind === "ramp") {
164
- h += `<b>${esc(lg.label)}</b><div class="ramp" style="background:linear-gradient(90deg,${lg.colors.join(",")})"></div>
165
- <div class="ticks"><span>${lg.min}</span><span>${lg.unit}</span><span>${lg.max}</span></div>`;
166
- if (lg.reference) {
167
- h += `<div class="row"><span class="stn" style="background:#fff;margin:0 3px"></span>
168
- ${lg.stations} stations${lg.spacing_m ? `, about ${(lg.spacing_m / 1000).toFixed(1)} km apart` : ""}</div>` +
169
- (lg.has_surface ? ($("#show-contours").checked && lg.contour_step ? `<div class="row"><span class="sw" style="background:#111"></span>contours every ${lg.contour_step} ${lg.unit}</div>` : "") +
170
- `<div class="small muted">Surface interpolated between stations.</div>`
171
- : `<div class="small muted">Too few stations for a surface: points only.</div>`) +
172
- `<div class="small" style="color:#b5532a"><b>Reference only</b>: not used in the CPI.</div>`;
173
- }
174
- if ($("#show-contours").checked && lg.levels.length)
175
- h += lg.levels.map((v, i) => `<div class="row"><span class="sw" style="background:${CONTOUR_COLORS[i]};outline:1px solid #888"></span>${v} ${lg.unit} contour</div>`).join("");
176
- }
177
- if ($("#show-targets").checked)
178
- h += `<div class="row"><span class="sw" style="background:${TIER_COLORS[1]};height:10px;width:10px;border-radius:50%"></span>Tier 1
179
- <span class="sw" style="background:${TIER_COLORS[2]};height:10px;width:10px;border-radius:50%"></span>Tier 2
180
- <span class="sw" style="background:${TIER_COLORS[3]};height:10px;width:10px;border-radius:50%"></span>Tier 3 targets</div>`;
181
- if ($("#show-lineaments").checked)
182
- h += `<div class="row"><span class="sw" style="background:#00e676"></span>Lineament
183
- <span class="sw" style="background:repeating-linear-gradient(90deg,#bdbdbd 0 5px,transparent 5px 9px)"></span>Scarp</div>`;
184
- if ($("#show-na").checked && S.hatchShown) h += `<div class="row"><span class="hatch"></span>Not assessed (no usable EMIT data)</div>`;
185
- el.innerHTML = h;
186
- el.hidden = !h;
187
- }
188
-
189
- /* ---------------- info pop-ups ---------------- */
190
- const TAG_TEXT = { EXP: "Expert choice (no external source)", ASM: "Project assumption", LIB: "Spectral library",
191
- LIT: "Published literature", TEST: "Tested against ground truth" };
192
-
193
- function showInfo(key) {
194
- const p = S.config.params[key], o = S.config.options[key];
195
- const d = (p || o || {}).info || {};
196
- const meta = [];
197
- if (p) {
198
- const dflt = p.kind === "bool" ? (p.default ? "yes" : "no") : `${p.default}${p.unit ? " " + p.unit : ""}`;
199
- meta.push(`Default: <b>${esc(dflt)}</b>`);
200
- if (p.kind !== "bool") meta.push(`Allowed: ${p.min} – ${p.max}${p.unit ? " " + esc(p.unit) : ""}`);
201
- meta.push(`Source: <b>${p.tag}</b> · ${esc(TAG_TEXT[p.tag] || "")}`);
202
- meta.push(`Group: ${esc(p.group)}`);
203
- } else if (o) {
204
- const c = o.choices.find((x) => String(x.value) === String(o.default));
205
- meta.push(`Recommended: <b>${esc(c ? c.label : o.default)}</b>`);
206
- }
207
- const sec = (title, text, cls = "") => text ? `<div class="info-sec ${cls}"><h4>${title}</h4><p>${esc(text)}</p></div>` : "";
208
- let body = `<div class="info-meta">${meta.map((m) => `<span>${m}</span>`).join("")}</div>`;
209
- body += sec("What it is", d.what);
210
- body += sec("How the method uses it", d.how, "formula");
211
- if (d.choices) body += sec("The choices", d.choices);
212
- if (d.up || d.down) {
213
- const upLabel = p && p.kind === "bool" ? "When on" : o ? "Effect" : "If you raise it";
214
- const downLabel = p && p.kind === "bool" ? "When off" : o ? "Otherwise" : "If you lower it";
215
- body += `<div class="info-sec"><div class="updown"><div><b>${upLabel}</b>${esc(d.up || "")}</div>
216
- <div><b>${downLabel}</b>${esc(d.down || "")}</div></div></div>`;
217
- }
218
- body += sec("Why this default", d.why);
219
- body += sec("What our tests show", d.evidence, "evidence");
220
- if (p && p.help && !d.how) body += sec("Note", p.help);
221
- $("#info-title").textContent = (p || o).label;
222
- $("#info-body").innerHTML = body;
223
- $("#info").showModal();
224
- }
225
-
226
- function showLayerInfo(key) {
227
- const ref = S.run && key.startsWith("ref:") ? refOf(key) : null;
228
- if (ref) {
229
- $("#info-title").textContent = ref.label;
230
- $("#info-body").innerHTML = `<div class="info-meta"><span><b>${ref.stations}</b> stations</span>
231
- ${ref.spacing_m ? `<span>about <b>${(ref.spacing_m / 1000).toFixed(1)} km</b> apart</span>` : ""}
232
- <span>range <b>${ref.min} to ${ref.max} ${esc(ref.unit)}</b></span></div>
233
- <div class="info-sec"><h4>What it is</h4><p>${esc(ref.what)}</p></div>
234
- <div class="info-sec"><h4>What you see on the map</h4><p>${ref.has_surface
235
- ? "The dots are the measured stations (hover for the value). The coloured surface and the contour lines are interpolated between them."
236
- : "Only the measured stations are shown: there are too few to draw a surface."}</p></div>
237
- <div class="info-sec evidence"><h4>How to read it</h4><p>${esc(ref.caveat)} A drill target is about 250 m across, so a pattern drawn from stations ${ref.spacing_m ? "about " + (ref.spacing_m / 1000).toFixed(1) + " km" : "this far"} apart cannot confirm or reject a single target.</p></div>
238
- <div class="info-sec"><h4>Source</h4><p>${esc(ref.source)}</p></div>`;
239
- $("#info").showModal();
240
- return;
241
- }
242
- $("#info-title").textContent = S.config.layers[key];
243
- $("#info-body").innerHTML = `<div class="info-sec"><p>${esc(S.config.layer_info[key] || "")}</p></div>`;
244
- $("#info").showModal();
245
- }
246
-
247
- function infoButton(key, kind = "param") {
248
- return `<button type="button" class="ibtn" data-info="${esc(key)}" data-kind="${kind}" title="What is this?" aria-label="Explain">i</button>`;
249
- }
250
-
251
- document.addEventListener("click", (ev) => {
252
- const b = ev.target.closest(".ibtn[data-info]");
253
- if (!b) return;
254
- ev.preventDefault();
255
- ev.stopPropagation();
256
- if (b.dataset.kind === "layer") showLayerInfo(b.dataset.info);
257
- else showInfo(b.dataset.info);
258
- }, true);
259
- $("#info-close").onclick = () => $("#info").close();
260
- $("#info").addEventListener("click", (ev) => { if (ev.target.id === "info") $("#info").close(); });
261
-
262
- /* ---------------- sidebar ---------------- */
263
- function renderOptions() {
264
- const box = $("#options");
265
- box.innerHTML = "";
266
- for (const [key, o] of Object.entries(S.config.options)) {
267
- const id = "opt-" + key.replace(/\./g, "-");
268
- const d = document.createElement("div");
269
- d.className = "opt";
270
- d.innerHTML = `<label for="${id}">${esc(o.label)}${infoButton(key, "option")}</label><select id="${id}">${o.choices.map((c) =>
271
- `<option value="${esc(c.value)}">${esc(c.label)}${c.value === o.default ? " ★" : ""}</option>`).join("")}</select>`;
272
- box.appendChild(d);
273
- const sel = d.querySelector("select");
274
- sel.value = String(S.options[key]);
275
- sel.onchange = () => {
276
- const c = o.choices.find((x) => String(x.value) === sel.value);
277
- S.options[key] = c.value;
278
- selectRun();
279
- };
280
- }
281
- }
282
-
283
- function sameOptions(a, b) { return Object.keys(S.config.options).every((k) => String(a[k]) === String(b[k])); }
284
- function sameParams(a, b) { return Object.keys(S.config.params).every((k) => Number(a[k]) === Number(b[k])); }
285
- function sameRun(r) { return sameOptions(r.options, S.options) && sameParams(r.params, S.params); }
286
- function defaults(obj) { return Object.fromEntries(Object.entries(obj).map(([k, o]) => [k, o.default])); }
287
-
288
- const STEPS = ["acquire", "emit_l2a", "emit_l2b", "aster", "structure", "alteration", "targets", "maps"];
289
- const RERUN_TEXT = {
290
- targets: "Only the CPI, targets and maps are recomputed (seconds).",
291
- alteration: "Alteration layers, CPI, targets and maps are recomputed (about a minute).",
292
- structure: "Lineaments, alteration, CPI, targets and maps are recomputed (a few minutes).",
293
- emit_l2a: "EMIT detection is redone from the downloaded scenes (several minutes to an hour, depending on area size).",
294
- acquire: "Full analysis including data download.",
295
- };
296
-
297
- function firstStep() {
298
- const steps = [];
299
- for (const [k, o] of Object.entries(S.config.options)) if (String(S.options[k]) !== String(o.default)) steps.push(o.step);
300
- for (const [k, p] of Object.entries(S.config.params)) if (Number(S.params[k]) !== Number(p.default)) steps.push(p.step);
301
- return steps.sort((a, b) => STEPS.indexOf(a) - STEPS.indexOf(b))[0] || null;
302
- }
303
-
304
- function weightSum() {
305
- return ["muscovite", "quartz", "gossan", "chlorite"].reduce((a, m) => a + Number(S.params["targets.w_" + m]), 0);
306
- }
307
-
308
- function ruleProblems() {
309
- const v = S.params, out = [];
310
- const w = weightSum();
311
- if (Math.abs(w - 1) > 0.005) out.push(`Mineral weights add up to ${w.toFixed(2)} (must be 1.00).`);
312
- if (!(v["targets.tier1"] > v["targets.tier2"] && v["targets.tier2"] > v["targets.tier3"])) out.push("Tiers must decrease: Tier 1 > Tier 2 > Tier 3.");
313
- if (!(v["alteration.sigma_halo_m"] > v["alteration.sigma_core_m"])) out.push("Halo σ must be larger than core σ.");
314
- if (!(v["alteration.sericite_max_nm"] > v["alteration.sericite_min_nm"])) out.push("White-mica window: 'to' must be above 'from'.");
315
- if (!(v["structure.canny_high_quantile"] > v["structure.canny_low_quantile"])) out.push("High edge quantile must be above the low one.");
316
- if (!(v["structure.min_length_m"] >= v["structure.min_segment_m"])) out.push("Minimum lineament length must be at least the minimum Hough segment.");
317
- for (const [k, p] of Object.entries(S.config.params)) {
318
- if (p.kind !== "bool" && !(Number(v[k]) >= p.min && Number(v[k]) <= p.max)) out.push(`${p.label} must be between ${p.min} and ${p.max}.`);
319
- }
320
- return out;
321
- }
322
-
323
- function renderParams() {
324
- const box = $("#params");
325
- const groups = {};
326
- for (const [k, p] of Object.entries(S.config.params)) (groups[p.group] = groups[p.group] || []).push([k, p]);
327
- const first = !box.querySelector("details");
328
- const open = new Set([...box.querySelectorAll("details[open]")].map((d) => d.dataset.group));
329
- if (first) open.add(Object.keys(groups)[0]);
330
- box.innerHTML = Object.entries(groups).map(([g, ps]) => {
331
- const rows = ps.map(([k, p]) => {
332
- const val = S.params[k], ch = Number(val) !== Number(p.default);
333
- const input = p.kind === "bool" ? `<input type="checkbox" data-k="${k}"${val ? " checked" : ""}>`
334
- : `<input type="number" data-k="${k}" value="${val}" min="${p.min}" max="${p.max}" step="${p.step_size}">`;
335
- const dflt = p.kind === "bool" ? (p.default ? "yes" : "no") : p.default;
336
- return `<div class="prow${ch ? " changed" : ""}" title="${esc(p.help || "")}">
337
- <div class="pl">${esc(p.label)}<span class="tag">${p.tag}</span>${infoButton(k)}<span class="def">default ${dflt}${p.unit ? " " + esc(p.unit) : ""}</span></div>
338
- ${input}${ch ? `<button type="button" class="reset" data-reset="${k}">reset</button>` : ""}</div>`;
339
- }).join("");
340
- const sum = g.startsWith("CPI") ? '<div id="wsum" class="psum"></div>' : "";
341
- return `<details class="pgroup" data-group="${esc(g)}"${open.has(g) ? " open" : ""}><summary>${esc(g)}</summary>${sum}${rows}</details>`;
342
- }).join("");
343
- box.querySelectorAll("input[data-k]").forEach((i) => i.onchange = () => {
344
- const p = S.config.params[i.dataset.k];
345
- S.params[i.dataset.k] = p.kind === "bool" ? i.checked : Number(i.value);
346
- renderParams();
347
- selectRun();
348
- });
349
- box.querySelectorAll("[data-reset]").forEach((b) => b.onclick = () => {
350
- S.params[b.dataset.reset] = S.config.params[b.dataset.reset].default;
351
- renderParams();
352
- selectRun();
353
- });
354
- const w = weightSum(), ws = $("#wsum");
355
- if (ws) { ws.className = "psum " + (Math.abs(w - 1) <= 0.005 ? "ok" : "bad"); ws.textContent = `Mineral weights sum: ${w.toFixed(2)} (must be 1.00)`; }
356
- const n = Object.entries(S.config.params).filter(([k, p]) => Number(S.params[k]) !== Number(p.default)).length;
357
- $("#params-changed").hidden = !n;
358
- $("#params-changed").textContent = `${n} changed`;
359
- }
360
-
361
- async function refreshBlocks(selectName) {
362
- S.blocks = await getJSON("/api/blocks");
363
- const sel = $("#block-select");
364
- sel.innerHTML = S.blocks.map((b) => `<option value="${esc(b.block)}">${esc(b.block)}${b.area_km2 ? ` · ${b.area_km2} km²` : ""}</option>`).join("");
365
- const name = selectName || (S.block && S.block.block) || (S.blocks[0] && S.blocks[0].block);
366
- if (name) sel.value = name;
367
- S.block = S.blocks.find((b) => b.block === sel.value) || null;
368
- }
369
-
370
- function renderBlockInfo() {
371
- const b = S.block;
372
- $("#block-info").textContent = b ? `${b.runs.length} result set(s)` + (b.has_ground_truth ? " · ground truth available" : "") +
373
- (b.uploaded_by ? ` · uploaded by ${b.uploaded_by}` : "") : "";
374
- const box = $("#computed");
375
- if (!b || !b.runs.length) { box.innerHTML = ""; return; }
376
- box.innerHTML = `<div class="title">Already computed for this area</div>` + b.runs.map((r) =>
377
- `<button class="chip${S.run && S.run.run_id === r.run_id ? " active" : ""}" data-run="${esc(r.run_id)}" title="${esc(r.label)}">
378
- ${r.recommended ? '<span class="rec">Recommended · </span>' : ""}${esc(Object.entries(r.options).map(([k, v]) =>
379
- (S.config.options[k].choices.find((c) => String(c.value) === String(v)) || { label: v }).label).join(" · "))}
380
- ${r.custom ? `<br><span class="small muted">Custom: ${esc(r.label.split("Custom parameters: ")[1] || "")}</span>` : ""}
381
- ${r.created_by ? `<br><span class="small muted">by ${esc(r.created_by)}</span>` : ""}</button>`).join("");
382
- box.querySelectorAll(".chip").forEach((c) => c.onclick = () => {
383
- const r = b.runs.find((x) => x.run_id === c.dataset.run);
384
- Object.assign(S.options, r.options);
385
- Object.assign(S.params, r.params);
386
- renderOptions();
387
- renderParams();
388
- selectRun();
389
- });
390
- }
391
-
392
- async function selectRun() {
393
- const b = S.block;
394
- const st = $("#run-status"), btn = $("#run-btn"), bar = $("#run-bar");
395
- const match = b && b.runs.find(sameRun);
396
- const job = await activeJob();
397
- if (match) {
398
- st.className = "status ready";
399
- st.textContent = match.recommended ? "Results ready (recommended method)." : match.custom ? "Results ready (custom parameters)." : "Results ready.";
400
- bar.hidden = true;
401
- // remember what is being viewed, so "Discard changes" can come back to it
402
- S.last = { block: b.block, options: { ...S.options }, params: { ...S.params } };
403
- if (!S.run || S.run.run_id !== match.run_id) await loadRun(match.run_id);
404
- } else {
405
- // keep showing the results that were on screen while settings are being edited
406
- const keep = S.run && S.run.block === b.block;
407
- if (!keep) S.run = null;
408
- const running = job && job.block === b.block && sameOptions(job.options, S.options) && sameParams(job.params, S.params);
409
- const probs = ruleProblems();
410
- const fs = firstStep();
411
- const note = b.runs.length && fs ? (RERUN_TEXT[fs] || RERUN_TEXT.acquire) : RERUN_TEXT.acquire;
412
- st.className = "status " + (running ? "running" : "missing");
413
- st.textContent = running ? "These settings are being computed now." : "Not computed yet for these settings. Use the bar at the bottom to run them.";
414
- // the run bar stays fixed at the bottom of the side panel, wherever the panel is scrolled
415
- const last = S.last && S.last.block === b.block ? S.last : null;
416
- const n = last ? Object.keys(S.config.options).filter((k) => String(S.options[k]) !== String(last.options[k])).length +
417
- Object.keys(S.config.params).filter((k) => Number(S.params[k]) !== Number(last.params[k])).length : 0;
418
- $("#run-bar-text").innerHTML = (n ? `<b>${n} setting${n > 1 ? "s" : ""} changed.</b> ` : "<b>Not computed yet.</b> ") +
419
- (probs.length ? `<div class="error">${probs.map(esc).join("<br>")}</div>` : esc(note)) +
420
- (keep ? `<div class="muted">The map and tabs still show the previous results until you run.</div>` : "");
421
- $("#run-discard").hidden = !last;
422
- bar.hidden = !!running;
423
- btn.disabled = probs.length > 0;
424
- if (!keep) clearViews();
425
- }
426
- renderBlockInfo();
427
- }
428
-
429
- function clearViews() {
430
- drawMap();
431
- $("#gallery").innerHTML = `<div class="empty">No results for these options yet.</div>`;
432
- $("#targets-table").innerHTML = "";
433
- $("#validation").innerHTML = `<div class="empty">No results for these options yet.</div>`;
434
- $("#download-groups").innerHTML = `<div class="empty">No results for these options yet.</div>`;
435
- }
436
-
437
- async function loadRun(runId) {
438
- S.run = await getJSON(`/api/runs/${runId}`);
439
- const known = [...S.run.layers, ...S.run.reference.map((r) => "ref:" + r.key), "none"];
440
- if (!known.includes(S.layer)) S.layer = S.run.layers[0] || "none";
441
- renderLayerRadios();
442
- await Promise.all([drawMap(), renderGallery(), renderTargets(), renderValidation()]);
443
- renderDownloads();
444
- }
445
-
446
- function renderLayerRadios() {
447
- const box = $("#layer-radios");
448
- const radio = (k, text) => `<label><input type="radio" name="layer" value="${k}"${k === S.layer ? " checked" : ""}> ${text}</label>`;
449
- box.innerHTML = S.run.layers.map((k) => radio(k, esc(S.config.layers[k]) + infoButton(k, "layer"))).join("") +
450
- (S.run.reference.length ? `<div class="ref-head">Reference geophysics <span>view only · not in the CPI</span></div>` +
451
- S.run.reference.map((r) => radio("ref:" + r.key, esc(r.label) + infoButton("ref:" + r.key, "layer"))).join("") : "") +
452
- radio("none", "None");
453
- box.querySelectorAll("input").forEach((i) => i.onchange = () => { S.layer = i.value; drawRaster(); });
454
- }
455
-
456
- /* ---------------- tabs ---------------- */
457
- function renderGallery() {
458
- const run = S.run;
459
- const maps = [...run.maps.map((m) => ({ url: `/api/runs/${run.run_id}/map/${m.file}`, label: m.label, file: m.file })),
460
- ...run.reference.map((r) => ({ url: `/api/blocks/${run.block}/reference/${r.key}/map.png`, file: `reference_${r.key}_map.png`,
461
- label: `Reference (not in the CPI): ${r.label}` }))];
462
- $("#gallery").innerHTML = maps.map((m) => `<figure><img src="${m.url}" alt="${esc(m.label)}" loading="lazy" data-full="${m.url}">
463
- <figcaption><span>${esc(m.label)}</span><a href="${m.url}" download="${run.block}_${m.file}">Download PNG</a></figcaption></figure>`).join("");
464
- $("#gallery").querySelectorAll("img").forEach((i) => i.onclick = () => { $("#lightbox-img").src = i.dataset.full; $("#lightbox").showModal(); });
465
- }
466
-
467
- async function renderTargets() {
468
- const rows = await getJSON(`/api/runs/${S.run.run_id}/targets`);
469
- const cols = [["Rank", "#"], ["Target_Name", "Target"], ["Priority_Tier", "Tier"], ["CPI_Percent", "CPI %"], ["Muscovite_Pct", "Musc %"],
470
- ["Quartz_Pct", "Qtz %"], ["Gossan_Pct", "Goss %"], ["Chlorite_Pct", "Chl %"], ["Distance_to_Lineament_m", "To lineament (m)"],
471
- ["Latitude", "Lat"], ["Longitude", "Lon"], ["Exploration_Recommendation", "Recommendation"]].filter(([k]) => rows.length && k in rows[0]);
472
- const fmt = (k, v) => k === "Latitude" || k === "Longitude" ? (+v).toFixed(5) : k === "CPI_Percent" ? (+v).toFixed(1) : v;
473
- $("#targets-table").innerHTML = `<thead><tr>${cols.map(([, h]) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${rows.map((r) =>
474
- `<tr data-rank="${r.Rank}" data-lat="${r.Latitude}" data-lon="${r.Longitude}">${cols.map(([k]) => {
475
- const t = k === "Priority_Tier" ? ` class="tier${String(r[k]).match(/(\d)/)?.[1]}"` : typeof r[k] === "number" ? ' class="num"' : "";
476
- return `<td${t}>${esc(fmt(k, r[k]))}</td>`;
477
- }).join("")}</tr>`).join("")}</tbody>`;
478
- $("#targets-table").querySelectorAll("tbody tr").forEach((tr) => tr.onclick = () => {
479
- showTab("map");
480
- map.setView([+tr.dataset.lat, +tr.dataset.lon], 16);
481
- const m = G.targetMarkers[tr.dataset.rank];
482
- if (m) m.openPopup();
483
- });
484
- }
485
-
486
- async function renderValidation() {
487
- const run = S.run, el = $("#validation");
488
- if (!run.has_validation) {
489
- el.innerHTML = `<div class="empty">No ground truth (boreholes, lodes or explored blocks) is available for this area,
490
- so there is no validation. The maps and targets are still produced.</div>`;
491
- return;
492
- }
493
- const v = await getJSON(`/api/runs/${run.run_id}/validation`);
494
- el.innerHTML = marked.parse(v.summary) + run.validation_maps.map((m) =>
495
- `<img src="/api/runs/${run.run_id}/validation/${m}" alt="">`).join("");
496
- }
497
-
498
- function renderDownloads() {
499
- const run = S.run;
500
- const groups = {};
501
- run.downloads.forEach((d) => (groups[d.group] = groups[d.group] || []).push(d));
502
- $("#download-groups").innerHTML = Object.entries(groups).map(([g, items]) => `<div class="dgroup"><h3>${esc(g)}
503
- <button type="button" data-all>Select all</button></h3>${items.map((d) =>
504
- `<label><input type="checkbox" value="${esc(d.id)}"${/^(map|vector):/.test(d.id) || d.id === "contour:cpi" ? " checked" : ""}> ${esc(d.label)}</label>`).join("")}</div>`).join("");
505
- $("#download-groups").querySelectorAll("[data-all]").forEach((b) => b.onclick = () => {
506
- const boxes = b.closest(".dgroup").querySelectorAll("input");
507
- const all = [...boxes].every((x) => x.checked);
508
- boxes.forEach((x) => (x.checked = !all));
509
- });
510
- }
511
-
512
- function renderFormats() {
513
- $("#formats").innerHTML = "Vector format:" + Object.entries(S.config.formats).map(([k, v]) =>
514
- `<label><input type="checkbox" value="${k}"${k === "shp" ? " checked" : ""}> ${v}</label>`).join("");
515
- }
516
-
517
- $("#download-btn").onclick = async () => {
518
- if (!S.run) return;
519
- const items = [...document.querySelectorAll("#download-groups input:checked")].map((i) => i.value);
520
- const formats = [...document.querySelectorAll("#formats input:checked")].map((i) => i.value);
521
- if (!items.length) { alert("Select at least one item."); return; }
522
- const btn = $("#download-btn");
523
- btn.disabled = true; btn.textContent = "Preparing…";
524
- try {
525
- const r = await api(`/api/runs/${S.run.run_id}/download`, { method: "POST", headers: { "Content-Type": "application/json" },
526
- body: JSON.stringify({ items, formats }) });
527
- const blob = await r.blob();
528
- const name = (r.headers.get("Content-Disposition") || "").match(/filename="(.+)"/)?.[1] || "geoscan.zip";
529
- const a = document.createElement("a");
530
- a.href = URL.createObjectURL(blob); a.download = name; a.click();
531
- setTimeout(() => URL.revokeObjectURL(a.href), 5000);
532
- } catch (e) { alert("Download failed: " + e.message); }
533
- btn.disabled = false; btn.textContent = "Download selected (.zip)";
534
- };
535
-
536
- function showTab(name) {
537
- document.querySelectorAll("#tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === name));
538
- document.querySelectorAll(".tab-panel").forEach((p) => p.classList.toggle("active", p.id === "tab-" + name));
539
- if (name === "map") setTimeout(() => map.invalidateSize(), 50);
540
- if (name === "admin") renderAdmin();
541
- }
542
- document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => { showTab(b.dataset.tab); history.replaceState(null, "", "#" + b.dataset.tab); });
543
-
544
- /* ---------------- jobs ---------------- */
545
- async function activeJob() {
546
- const js = await getJSON("/api/jobs");
547
- return js.reverse().find((j) => j.status === "running" || j.status === "queued") || null;
548
- }
549
-
550
- function showJob(j) {
551
- $("#job-card").hidden = false;
552
- $("#job-title").textContent = `${j.block} · ${j.label}`;
553
- $("#job-bar").style.width = `${Math.round(100 * j.progress)}%`;
554
- $("#job-step").textContent = j.status === "failed" ? `Failed: ${j.error}` : j.status === "done" ? "Finished." :
555
- j.status === "queued" ? "Waiting for another analysis to finish…" : j.step_label || "Starting…";
556
- $("#job-log").textContent = j.log.join("\n");
557
- }
558
-
559
- function pollJob(jobId) {
560
- clearInterval(S.jobTimer);
561
- S.jobTimer = setInterval(async () => {
562
- const j = await getJSON(`/api/jobs/${jobId}`);
563
- showJob(j);
564
- if (j.status === "done" || j.status === "failed") {
565
- clearInterval(S.jobTimer);
566
- if (j.status === "done") {
567
- await refreshBlocks(j.block);
568
- S.block = S.blocks.find((b) => b.block === j.block);
569
- $("#block-select").value = j.block;
570
- Object.assign(S.options, j.options);
571
- Object.assign(S.params, j.params);
572
- renderOptions();
573
- renderParams();
574
- await selectRun();
575
- }
576
- }
577
- }, 3000);
578
- }
579
-
580
- $("#run-discard").onclick = () => {
581
- if (!S.last) return;
582
- S.options = { ...S.last.options };
583
- S.params = { ...S.last.params };
584
- renderOptions();
585
- renderParams();
586
- selectRun();
587
- };
588
-
589
- $("#run-btn").onclick = async () => {
590
- const btn = $("#run-btn");
591
- if (ruleProblems().length) return;
592
- btn.disabled = true;
593
- try {
594
- const opts = {}, params = {};
595
- Object.keys(S.config.options).forEach((k) => (opts[k] = S.options[k]));
596
- Object.keys(S.config.params).forEach((k) => (params[k] = S.params[k]));
597
- const r = await (await api("/api/runs", { method: "POST", headers: { "Content-Type": "application/json" },
598
- body: JSON.stringify({ block: S.block.block, options: opts, params }) })).json();
599
- if (r.run) { await refreshBlocks(); await selectRun(); }
600
- else { showJob(r.job); pollJob(r.job.job_id); await selectRun(); }
601
- } catch (e) { alert(e.message); }
602
- btn.disabled = false;
603
- };
604
-
605
- /* ---------------- new area ---------------- */
606
- $("#new-area-btn").onclick = () => { $("#area-error").textContent = ""; $("#new-area").showModal(); };
607
- $("#area-cancel").onclick = () => $("#new-area").close();
608
- $("#new-area-form").onsubmit = async (ev) => {
609
- ev.preventDefault();
610
- const f = $("#area-file").files[0];
611
- if (!f) return;
612
- const fd = new FormData();
613
- fd.append("file", f);
614
- fd.append("name", $("#area-name").value);
615
- $("#area-upload").disabled = true;
616
- try {
617
- const r = await (await api("/api/blocks", { method: "POST", body: fd })).json();
618
- $("#new-area").close();
619
- await refreshBlocks(r.block);
620
- S.options = defaults(S.config.options);
621
- S.params = defaults(S.config.params);
622
- renderOptions();
623
- renderParams();
624
- S.run = null;
625
- await selectRun();
626
- if (!S.block.runs.length) $("#run-status").textContent = `New area uploaded (${r.area_km2} km²). Choose options and press Run.`;
627
- } catch (e) { $("#area-error").textContent = e.message; }
628
- $("#area-upload").disabled = false;
629
- };
630
-
631
- $("#block-select").onchange = async () => {
632
- S.block = S.blocks.find((b) => b.block === $("#block-select").value);
633
- S.run = null;
634
- S.fitted = null;
635
- await selectRun();
636
- };
637
- $("#opacity").oninput = () => G.overlay && G.overlay.setOpacity(+$("#opacity").value);
638
- ["#show-contours", "#show-na"].forEach((s) => $(s).onchange = drawRaster);
639
- ["#show-targets", "#show-lineaments", "#show-zones"].forEach((s) => $(s).onchange = async () => { await drawVectors(); renderLegend(S.legend || null); });
640
- document.querySelectorAll('input[name="base"]').forEach((r) => r.onchange = () => {
641
- Object.values(bases).forEach((b) => map.removeLayer(b));
642
- bases[r.value].addTo(map).bringToBack();
643
- });
644
- $("#lightbox-close").onclick = () => $("#lightbox").close();
645
-
646
- /* ---------------- accounts and admin ---------------- */
647
- const ACTION_TEXT = { session_start: "Signed in", upload_area: "Uploaded area", run_started: "Started analysis",
648
- run_finished: "Analysis finished", run_failed: "Analysis failed", open_results: "Opened results", download: "Downloaded",
649
- signed_out: "Signed out", user_added: "Added user", user_removed: "Removed user" };
650
-
651
- function logDetails(e) {
652
- const d = [];
653
- if (e.file) d.push(`file <b>${esc(e.file)}</b>${e.bytes ? ` (${(e.bytes / 1024).toFixed(1)} kB)` : ""}`);
654
- if (e.area) d.push(`area <b>${esc(e.area)}</b>${e.area_km2 ? ` (${e.area_km2} km²)` : ""}`);
655
- if (e.centre_lat) d.push(`centre ${e.centre_lat}, ${e.centre_lon}`);
656
- if (e.result) d.push(esc(e.result));
657
- if (e.settings) d.push(esc(e.settings));
658
- if (e.changed_parameters) d.push("changed: " + Object.entries(e.changed_parameters).map(([k, v]) =>
659
- `${esc((S.config.params[k] || { label: k }).label)} = ${esc(v)}`).join(", "));
660
- if (e.recompute_from && e.action === "run_started") d.push(`recomputed from: ${esc(e.recompute_from)}`);
661
- if (e.results && e.action !== "open_results") d.push(`results: ${esc(e.results)}`);
662
- if (e.items) d.push(`${e.items.length} item(s): ${esc(e.items.join(", "))}${e.megabytes ? ` · ${e.megabytes} MB` : ""}`);
663
- if (e.formats && e.action === "download") d.push(`formats: ${esc(e.formats.join(", "))}`);
664
- if (e.error) d.push(`<span class="error">${esc(e.error)}</span>`);
665
- if (e.account) d.push(`account <b>${esc(e.account)}</b>${e.note ? ` (${esc(e.note)})` : ""}`);
666
- if (e.ip) d.push(`from ${esc(e.ip)}`);
667
- if (e.browser) d.push(`<span class="muted">${esc(e.browser)}</span>`);
668
- return d.join(" · ");
669
- }
670
-
671
- const localTime = (t) => t ? new Date(t).toLocaleString() : "";
672
-
673
- async function renderAdmin() {
674
- const panel = $("#tab-admin");
675
- if (!panel.firstElementChild) {
676
- panel.appendChild($("#admin-template").content.cloneNode(true));
677
- $("#user-form").onsubmit = async (ev) => {
678
- ev.preventDefault();
679
- $("#user-error").textContent = "";
680
- try {
681
- await api("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" },
682
- body: JSON.stringify({ user: $("#new-user").value, password: $("#new-pass").value, note: $("#new-note").value }) });
683
- $("#user-form").reset();
684
- renderAdmin();
685
- } catch (e) { $("#user-error").textContent = e.message; }
686
- };
687
- $("#log-refresh").onclick = renderAdmin;
688
- $("#log-user").onchange = () => drawLog();
689
- $("#log-action").onchange = () => drawLog();
690
- }
691
- const [users, log] = await Promise.all([getJSON("/api/admin/users"), getJSON("/api/admin/log?limit=2000")]);
692
- S.log = log;
693
- $("#users-table").innerHTML = `<thead><tr><th>User</th><th>Role</th><th>Note</th><th>Created</th><th>Last active</th><th></th></tr></thead><tbody>` +
694
- users.map((u) => `<tr><td><b>${esc(u.user)}</b></td><td>${esc(u.role)}</td><td>${esc(u.note)}</td>
695
- <td class="nowrap">${esc(localTime(u.created))}</td><td class="nowrap">${esc(localTime(u.last_active))}</td>
696
- <td>${u.role === "user" ? `<button class="linkbtn" data-remove="${esc(u.user)}">Remove</button>` : ""}</td></tr>`).join("") + "</tbody>";
697
- $("#users-table").querySelectorAll("[data-remove]").forEach((b) => b.onclick = async () => {
698
- if (!confirm(`Remove user "${b.dataset.remove}"? They will no longer be able to sign in.`)) return;
699
- await api(`/api/admin/users/${encodeURIComponent(b.dataset.remove)}`, { method: "DELETE" });
700
- renderAdmin();
701
- });
702
- const keep = (sel, values, all, label) => {
703
- const cur = sel.value;
704
- sel.innerHTML = `<option value="">${all}</option>` + values.map((v) => `<option value="${esc(v)}">${esc(label(v))}</option>`).join("");
705
- sel.value = values.includes(cur) ? cur : "";
706
- };
707
- keep($("#log-user"), [...new Set(log.map((e) => e.user))].sort(), "All users", (v) => v);
708
- keep($("#log-action"), [...new Set(log.map((e) => e.action))].sort(), "All actions", (v) => ACTION_TEXT[v] || v);
709
- drawLog();
710
- }
711
-
712
- function drawLog() {
713
- const u = $("#log-user").value, a = $("#log-action").value;
714
- const rows = (S.log || []).filter((e) => (!u || e.user === u) && (!a || e.action === a));
715
- $("#log-table").innerHTML = `<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead><tbody>` +
716
- (rows.length ? rows.map((e) => `<tr style="cursor:default"><td class="nowrap">${esc(localTime(e.time))}</td><td><b>${esc(e.user)}</b></td>
717
- <td class="nowrap"><span class="act ${esc(e.action)}">${esc(ACTION_TEXT[e.action] || e.action)}</span></td><td>${logDetails(e)}</td></tr>`).join("")
718
- : `<tr><td colspan="4" class="muted">No activity recorded yet.</td></tr>`) + "</tbody>";
719
- }
720
-
721
- async function signOut(ev) {
722
- ev.preventDefault();
723
- try { await api("/api/signout", { method: "POST" }); } catch (e) { /* still sign out */ }
724
- // Browsers keep a Basic-auth password until told otherwise: send wrong credentials on purpose so the
725
- // stored ones are replaced, then show a signed-out page.
726
- await new Promise((done) => {
727
- const x = new XMLHttpRequest();
728
- x.open("GET", "/api/logout", true, "signed-out", "signed-out");
729
- x.onloadend = done;
730
- x.send();
731
- });
732
- document.body.innerHTML = `<div class="signed-out"><h1>You are signed out</h1>
733
- <p>Close this tab, or <a href="/">sign in again</a>.</p>
734
- <p class="muted small">If the app opens without asking for a password, close all browser windows to finish signing out.</p></div>`;
735
- }
736
-
737
- async function initAccount() {
738
- S.me = await getJSON("/api/me");
739
- if (S.me.login) {
740
- $("#whoami").hidden = false;
741
- $("#whoami").innerHTML = `Signed in as <b>${esc(S.me.user)}</b> · activity is recorded · <a href="#" id="signout">Sign out</a>`;
742
- $("#signout").onclick = signOut;
743
- }
744
- if (S.me.admin) $("#admin-tab-btn").hidden = false;
745
- }
746
-
747
- /* ---------------- start ---------------- */
748
- (async function init() {
749
- S.config = await getJSON("/api/config");
750
- await initAccount();
751
- S.options = defaults(S.config.options);
752
- S.params = defaults(S.config.params);
753
- renderOptions();
754
- renderParams();
755
- renderFormats();
756
- $("#params-reset").onclick = () => { S.params = defaults(S.config.params); renderParams(); selectRun(); };
757
- await refreshBlocks();
758
- await selectRun();
759
- const j = await activeJob();
760
- if (j) { showJob(j); pollJob(j.job_id); }
761
- if (location.hash.includes("params")) $("#params-box").open = true;
762
- // links can pre-set parameters, e.g. #params&set=targets.min_spacing_m:400
763
- let preset = false;
764
- for (const m of location.hash.matchAll(/set=([\w.]+):([\d.]+)/g)) {
765
- if (S.config.params[m[1]]) { S.params[m[1]] = Number(m[2]); preset = true; }
766
- }
767
- if (preset) { renderParams(); await selectRun(); }
768
- const lm = location.hash.match(/layer=([\w:]+)/);
769
- if (lm && S.run && (S.run.layers.includes(lm[1]) || refOf(lm[1]))) { S.layer = lm[1]; renderLayerRadios(); await drawRaster(); }
770
- const im = location.hash.match(/info=([\w.]+)/);
771
- if (im && (S.config.params[im[1]] || S.config.options[im[1]])) showInfo(im[1]);
772
- const tab = location.hash.slice(1).split("&")[0];
773
- if (["map", "contours", "targets", "validation", "download", "guide", "admin"].includes(tab)) showTab(tab);
774
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+ const $ = (s) => document.querySelector(s);
3
+ const api = async (url, opts) => {
4
+ const r = await fetch(url, opts);
5
+ if (!r.ok) {
6
+ let msg = r.statusText;
7
+ try { msg = (await r.json()).detail || msg; } catch (e) { /* not JSON */ }
8
+ throw new Error(msg);
9
+ }
10
+ return r;
11
+ };
12
+ const getJSON = async (url) => (await api(url)).json();
13
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
14
+
15
+ const S = { config: null, blocks: [], block: null, options: {}, params: {}, run: null, layer: "cpi", jobTimer: null, fitted: null };
16
+ const CONTOUR_COLORS = ["#ffffff", "#ffeb3b", "#ff1744"];
17
+ const TIER_COLORS = { "1": "#d50000", "2": "#ff9100", "3": "#9e9e9e" };
18
+
19
+ /* ---------------- map ---------------- */
20
+ const map = L.map("map", { zoomControl: true, preferCanvas: true }).setView([21, 79], 6);
21
+ const bases = {
22
+ sat: L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
23
+ { maxZoom: 19, attribution: "Imagery © Esri, Maxar, Earthstar Geographics" }),
24
+ topo: L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
25
+ { maxZoom: 17, attribution: "© OpenStreetMap contributors, SRTM | © OpenTopoMap (CC-BY-SA)" }),
26
+ };
27
+ bases.sat.addTo(map);
28
+ L.control.scale({ imperial: false }).addTo(map);
29
+ const G = { overlay: null, na: null, contours: null, stations: null, targets: null, lineaments: null, zones: null, aoi: null, targetMarkers: {} };
30
+
31
+ function clearLayer(k) { if (G[k]) { map.removeLayer(G[k]); G[k] = null; } }
32
+
33
+ async function drawMap() {
34
+ const run = S.run;
35
+ ["overlay", "na", "contours", "stations", "targets", "lineaments", "zones", "aoi"].forEach(clearLayer);
36
+ if (!run) { renderLegend(null); return; }
37
+ const id = run.run_id;
38
+ const aoi = await getJSON(`/api/runs/${id}/vector/aoi.geojson`);
39
+ G.aoi = L.geoJSON(aoi, { style: { color: "#00e5ff", weight: 2, fill: false }, interactive: false }).addTo(map);
40
+ if (S.fitted !== run.block) { map.fitBounds(G.aoi.getBounds(), { padding: [30, 30] }); S.fitted = run.block; }
41
+ await drawRaster();
42
+ await drawVectors();
43
+ }
44
+
45
+ const refOf = (layer) => layer.startsWith("ref:") ? S.run.reference.find((r) => r.key === layer.slice(4)) : null;
46
+
47
+ function rampColour(colors, t) {
48
+ return colors[Math.max(0, Math.min(colors.length - 1, Math.round(t * (colors.length - 1))))];
49
+ }
50
+
51
+ /* reference geophysics: surface (if any), black contours, stations coloured by value; never part of the CPI */
52
+ async function drawReference(ref) {
53
+ const base = `/api/blocks/${S.run.block}/reference/${ref.key}`;
54
+ const info = await getJSON(`${base}/overlay`);
55
+ const lg = info.legend;
56
+ if (!info.none) {
57
+ G.overlay = L.imageOverlay(info.url, info.bounds, { opacity: +$("#opacity").value, interactive: false }).addTo(map);
58
+ if ($("#show-contours").checked) {
59
+ G.contours = L.geoJSON(await getJSON(`${base}/contours.geojson`), {
60
+ style: { color: "#111", weight: 1.2, opacity: 0.85 },
61
+ onEachFeature: (f, l) => l.bindTooltip(`${f.properties.level} ${f.properties.unit}`, { sticky: true }),
62
+ }).addTo(map);
63
+ }
64
+ }
65
+ G.stations = L.geoJSON(await getJSON(`${base}/stations.geojson`), {
66
+ // diamonds, so stations are not mistaken for field-check locations (circles)
67
+ pointToLayer: (f, ll) => L.marker(ll, { icon: L.divIcon({ className: "", iconSize: [14, 14], iconAnchor: [7, 7],
68
+ html: `<span class="stn" style="background:${rampColour(lg.colors, (f.properties.value - lg.min) / (lg.max - lg.min || 1))}"></span>` }) }),
69
+ onEachFeature: (f, l) => l.bindTooltip(`<b>${f.properties.value} ${esc(f.properties.unit)}</b><br>station ${f.properties.station}` +
70
+ (f.properties.elevation_m != null ? `<br>elevation ${f.properties.elevation_m} m` : "")),
71
+ }).addTo(map);
72
+ return { ...lg, stations: ref.stations, spacing_m: ref.spacing_m, has_surface: ref.has_surface };
73
+ }
74
+
75
+ async function drawRaster() {
76
+ clearLayer("overlay"); clearLayer("contours"); clearLayer("na"); clearLayer("stations");
77
+ const run = S.run; if (!run) return;
78
+ let legend = null;
79
+ const ref = refOf(S.layer);
80
+ if (ref) {
81
+ legend = await drawReference(ref);
82
+ S.hatchShown = false;
83
+ S.legend = legend;
84
+ renderLegend(legend);
85
+ G.aoi && G.aoi.bringToFront();
86
+ G.targets && G.targets.bringToFront();
87
+ return;
88
+ }
89
+ if (S.layer !== "none") {
90
+ const info = await getJSON(`/api/runs/${run.run_id}/overlay/${S.layer}`);
91
+ G.overlay = L.imageOverlay(info.url, info.bounds, { opacity: +$("#opacity").value, interactive: false }).addTo(map);
92
+ legend = info.legend;
93
+ if ($("#show-contours").checked && info.legend.levels && info.legend.levels.length) {
94
+ const gj = await getJSON(`/api/runs/${run.run_id}/contours/${S.layer}.geojson`);
95
+ const lv = info.legend.levels;
96
+ G.contours = L.geoJSON(gj, {
97
+ style: (f) => ({ color: CONTOUR_COLORS[lv.indexOf(f.properties.level)] || "#fff", weight: 2, opacity: 0.95 }),
98
+ onEachFeature: (f, l) => l.bindTooltip(`${f.properties.level} ${f.properties.unit}`, { sticky: true }),
99
+ }).addTo(map);
100
+ }
101
+ }
102
+ S.hatchShown = false;
103
+ if ($("#show-na").checked && S.layer !== "none") {
104
+ // per layer: only EMIT-based layers can be "not assessed", and only where nothing was detected
105
+ const na = await getJSON(`/api/runs/${run.run_id}/overlay/na_${S.layer}`);
106
+ if (!na.none) {
107
+ G.na = L.imageOverlay(na.url, na.bounds, { opacity: 1, interactive: false }).addTo(map);
108
+ S.hatchShown = true;
109
+ }
110
+ }
111
+ S.legend = legend;
112
+ renderLegend(legend);
113
+ G.aoi && G.aoi.bringToFront();
114
+ }
115
+
116
+ async function drawVectors() {
117
+ ["targets", "lineaments", "zones"].forEach(clearLayer);
118
+ const id = S.run.run_id;
119
+ G.zoneLayers = {};
120
+ if ($("#show-zones").checked) {
121
+ try {
122
+ const gj = await getJSON(`/api/runs/${id}/vector/zones.geojson`);
123
+ G.zones = L.geoJSON(gj, {
124
+ style: (f) => {
125
+ const t = String(f.properties.Priority_Tier || "").match(/(\d)/)?.[1];
126
+ return { color: TIER_COLORS[t] || "#999", weight: t === "3" ? 1.5 : 2.5, dashArray: t === "3" ? "5 4" : null,
127
+ fillColor: TIER_COLORS[t] || "#999", fillOpacity: 0.08 };
128
+ },
129
+ onEachFeature: (f, l) => {
130
+ G.zoneLayers[f.properties.Zone_ID] = l;
131
+ l.bindPopup(zonePopup(f.properties));
132
+ if (f.properties.Zone <= 12) l.bindTooltip(f.properties.Zone_ID, { permanent: true, direction: "center", className: "zone-label" });
133
+ },
134
+ }).addTo(map);
135
+ } catch (e) { /* results made before zones existed have no zone file */ }
136
+ }
137
+ if ($("#show-lineaments").checked) {
138
+ const gj = await getJSON(`/api/runs/${id}/vector/lineaments.geojson`);
139
+ G.lineaments = L.geoJSON(gj, {
140
+ style: (f) => f.properties.morphology === "scarp" ? { color: "#bdbdbd", weight: 1.5, dashArray: "5 4" }
141
+ : { color: "#00e676", weight: 2 },
142
+ onEachFeature: (f, l) => l.bindTooltip(`${f.properties.line_id || ""} · strike ${f.properties.strike_deg}° · ` +
143
+ `${Math.round(f.properties.length_m)} m · ${f.properties.morphology}` +
144
+ (f.properties.morphology === "scarp" ? " (not used for CPI)" : "")),
145
+ }).addTo(map);
146
+ }
147
+ if ($("#show-targets").checked) {
148
+ const gj = await getJSON(`/api/runs/${id}/vector/targets.geojson`);
149
+ G.targetMarkers = {};
150
+ G.targets = L.geoJSON(gj, {
151
+ pointToLayer: (f, ll) => {
152
+ const t = String(f.properties.Priority_Tier || "").match(/(\d)/)?.[1];
153
+ const m = L.circleMarker(ll, { radius: f.properties.Rank <= 10 ? 8 : 6, color: "#000", weight: 1,
154
+ fillColor: TIER_COLORS[t] || "#999", fillOpacity: 0.95 });
155
+ G.targetMarkers[f.properties.Rank] = m;
156
+ return m;
157
+ },
158
+ onEachFeature: (f, l) => l.bindPopup(targetPopup(f.properties)),
159
+ }).addTo(map);
160
+ }
161
+ G.aoi && G.aoi.bringToFront();
162
+ }
163
+
164
+ function zonePopup(p) {
165
+ const rows = [["Tier", p.Priority_Tier], ["Area", `${p.Area_km2} km²`], ["Peak / mean CPI", `${p.CPI_Max_Pct} % / ${p.CPI_Mean_Pct} %`],
166
+ ["Main evidence", p.Main_Evidence], ["Muscovite · Quartz", `${p.Muscovite_Pct} % · ${p.Quartz_Pct} %`],
167
+ ["Gossan · Chlorite", `${p.Gossan_Pct} % · ${p.Chlorite_Pct} %`], ["Lineaments inside", `${p.Lineaments_km} km`],
168
+ ["Field-check locations", p.Field_Check_Locations], ["Based on", p.Evidence_Basis]];
169
+ return `<b>Zone ${esc(p.Zone_ID)}</b><table>${rows.map(([k, v]) => `<tr><td>${k}</td><td><b>${esc(v)}</b></td></tr>`).join("")}</table>
170
+ <div class="small muted">Suggested follow-up: ${esc(p.Suggested_Follow_Up)}</div>`;
171
+ }
172
+
173
+ function targetPopup(p) {
174
+ const rows = [["Zone", p.Zone], ["CPI", `${(+p.CPI_Percent).toFixed(1)} %`], ["Tier", p.Priority_Tier], ["Muscovite", `${p.Muscovite_Pct} %`],
175
+ ["Quartz", `${p.Quartz_Pct} %`], ["Gossan", `${p.Gossan_Pct} %`], ["Chlorite", `${p.Chlorite_Pct} %`],
176
+ ["To lineament", `${p.Distance_to_Lineament_m} m`], ["Lat / Lon", `${(+p.Latitude).toFixed(4)}, ${(+p.Longitude).toFixed(4)}`]]
177
+ .filter(([, v]) => v != null && v !== "undefined %" && v !== "");
178
+ const follow = p.Suggested_Follow_Up || "";
179
+ return `<b>#${p.Rank} ${esc(p.Location_Name || p.Target_Name || p.Location_ID || p.Target_ID)}</b>
180
+ <div class="small muted">Suggested field-check location${p.Check_Within_m ? `: look within about ${p.Check_Within_m} m` : ""}. Not a drill collar.</div>
181
+ <table>${rows.map(([k, v]) => `<tr><td>${k}</td><td><b>${esc(v)}</b></td></tr>`).join("")}</table>` +
182
+ (follow ? `<div class="small muted">Suggested follow-up: ${esc(follow)}</div>` : "");
183
+ }
184
+
185
+ function renderLegend(lg) {
186
+ const el = $("#legend");
187
+ let h = "";
188
+ if (lg && lg.kind === "ramp") {
189
+ h += `<b>${esc(lg.label)}</b><div class="ramp" style="background:linear-gradient(90deg,${lg.colors.join(",")})"></div>
190
+ <div class="ticks"><span>${lg.min}</span><span>${lg.unit}</span><span>${lg.max}</span></div>`;
191
+ if (lg.reference) {
192
+ h += `<div class="row"><span class="stn" style="background:#fff;margin:0 3px"></span>
193
+ ${lg.stations} stations${lg.spacing_m ? `, about ${(lg.spacing_m / 1000).toFixed(1)} km apart` : ""}</div>` +
194
+ (lg.has_surface ? ($("#show-contours").checked && lg.contour_step ? `<div class="row"><span class="sw" style="background:#111"></span>contours every ${lg.contour_step} ${lg.unit}</div>` : "") +
195
+ `<div class="small muted">Surface interpolated between stations.</div>`
196
+ : `<div class="small muted">Too few stations for a surface: points only.</div>`) +
197
+ `<div class="small" style="color:#b5532a"><b>Reference only</b>: not used in the CPI.</div>`;
198
+ }
199
+ if ($("#show-contours").checked && lg.levels.length)
200
+ h += lg.levels.map((v, i) => `<div class="row"><span class="sw" style="background:${CONTOUR_COLORS[i]};outline:1px solid #888"></span>${v} ${lg.unit} contour</div>`).join("");
201
+ }
202
+ if ($("#show-targets").checked)
203
+ h += `<div class="row"><span class="sw" style="background:${TIER_COLORS[1]};height:10px;width:10px;border-radius:50%"></span>Tier 1
204
+ <span class="sw" style="background:${TIER_COLORS[2]};height:10px;width:10px;border-radius:50%"></span>Tier 2
205
+ <span class="sw" style="background:${TIER_COLORS[3]};height:10px;width:10px;border-radius:50%"></span>Tier 3 field-check locations</div>`;
206
+ if ($("#show-zones").checked && G.zones)
207
+ h += `<div class="row"><span class="sw" style="height:10px;width:16px;background:rgba(213,0,0,.1);outline:2px solid ${TIER_COLORS[1]}"></span>Prospective zone, coloured by tier</div>`;
208
+ if ($("#show-lineaments").checked)
209
+ h += `<div class="row"><span class="sw" style="background:#00e676"></span>Lineament
210
+ <span class="sw" style="background:repeating-linear-gradient(90deg,#bdbdbd 0 5px,transparent 5px 9px)"></span>Scarp</div>`;
211
+ if ($("#show-na").checked && S.hatchShown) h += `<div class="row"><span class="hatch"></span>Not assessed (no usable EMIT data)</div>`;
212
+ el.innerHTML = h;
213
+ el.hidden = !h;
214
+ }
215
+
216
+ /* ---------------- info pop-ups ---------------- */
217
+ const TAG_TEXT = { EXP: "Expert choice (no external source)", ASM: "Project assumption", LIB: "Spectral library",
218
+ LIT: "Published literature", TEST: "Tested against ground truth" };
219
+
220
+ function showInfo(key) {
221
+ const p = S.config.params[key], o = S.config.options[key];
222
+ const d = (p || o || {}).info || {};
223
+ const meta = [];
224
+ if (p) {
225
+ const dflt = p.kind === "bool" ? (p.default ? "yes" : "no") : `${p.default}${p.unit ? " " + p.unit : ""}`;
226
+ meta.push(`Default: <b>${esc(dflt)}</b>`);
227
+ if (p.kind !== "bool") meta.push(`Allowed: ${p.min} – ${p.max}${p.unit ? " " + esc(p.unit) : ""}`);
228
+ meta.push(`Source: <b>${p.tag}</b> · ${esc(TAG_TEXT[p.tag] || "")}`);
229
+ meta.push(`Group: ${esc(p.group)}`);
230
+ } else if (o) {
231
+ const c = o.choices.find((x) => String(x.value) === String(o.default));
232
+ meta.push(`Recommended: <b>${esc(c ? c.label : o.default)}</b>`);
233
+ }
234
+ const sec = (title, text, cls = "") => text ? `<div class="info-sec ${cls}"><h4>${title}</h4><p>${esc(text)}</p></div>` : "";
235
+ let body = `<div class="info-meta">${meta.map((m) => `<span>${m}</span>`).join("")}</div>`;
236
+ body += sec("What it is", d.what);
237
+ body += sec("How the method uses it", d.how, "formula");
238
+ if (d.choices) body += sec("The choices", d.choices);
239
+ if (d.up || d.down) {
240
+ const upLabel = p && p.kind === "bool" ? "When on" : o ? "Effect" : "If you raise it";
241
+ const downLabel = p && p.kind === "bool" ? "When off" : o ? "Otherwise" : "If you lower it";
242
+ body += `<div class="info-sec"><div class="updown"><div><b>${upLabel}</b>${esc(d.up || "")}</div>
243
+ <div><b>${downLabel}</b>${esc(d.down || "")}</div></div></div>`;
244
+ }
245
+ body += sec("Why this default", d.why);
246
+ body += sec("What our tests show", d.evidence, "evidence");
247
+ if (p && p.help && !d.how) body += sec("Note", p.help);
248
+ $("#info-title").textContent = (p || o).label;
249
+ $("#info-body").innerHTML = body;
250
+ $("#info").showModal();
251
+ }
252
+
253
+ function showLayerInfo(key) {
254
+ const ref = S.run && key.startsWith("ref:") ? refOf(key) : null;
255
+ if (ref) {
256
+ $("#info-title").textContent = ref.label;
257
+ $("#info-body").innerHTML = `<div class="info-meta"><span><b>${ref.stations}</b> stations</span>
258
+ ${ref.spacing_m ? `<span>about <b>${(ref.spacing_m / 1000).toFixed(1)} km</b> apart</span>` : ""}
259
+ <span>range <b>${ref.min} to ${ref.max} ${esc(ref.unit)}</b></span></div>
260
+ <div class="info-sec"><h4>What it is</h4><p>${esc(ref.what)}</p></div>
261
+ <div class="info-sec"><h4>What you see on the map</h4><p>${ref.has_surface
262
+ ? "The dots are the measured stations (hover for the value). The coloured surface and the contour lines are interpolated between them."
263
+ : "Only the measured stations are shown: there are too few to draw a surface."}</p></div>
264
+ <div class="info-sec evidence"><h4>How to read it</h4><p>${esc(ref.caveat)} A zone is a few hundred metres across, so a pattern drawn from stations ${ref.spacing_m ? "about " + (ref.spacing_m / 1000).toFixed(1) + " km" : "this far"} apart cannot confirm or reject a single zone.</p></div>
265
+ <div class="info-sec"><h4>Source</h4><p>${esc(ref.source)}</p></div>`;
266
+ $("#info").showModal();
267
+ return;
268
+ }
269
+ $("#info-title").textContent = S.config.layers[key];
270
+ $("#info-body").innerHTML = `<div class="info-sec"><p>${esc(S.config.layer_info[key] || "")}</p></div>`;
271
+ $("#info").showModal();
272
+ }
273
+
274
+ function infoButton(key, kind = "param") {
275
+ return `<button type="button" class="ibtn" data-info="${esc(key)}" data-kind="${kind}" title="What is this?" aria-label="Explain">i</button>`;
276
+ }
277
+
278
+ document.addEventListener("click", (ev) => {
279
+ const b = ev.target.closest(".ibtn[data-info]");
280
+ if (!b) return;
281
+ ev.preventDefault();
282
+ ev.stopPropagation();
283
+ if (b.dataset.kind === "layer") showLayerInfo(b.dataset.info);
284
+ else showInfo(b.dataset.info);
285
+ }, true);
286
+ $("#info-close").onclick = () => $("#info").close();
287
+ $("#info").addEventListener("click", (ev) => { if (ev.target.id === "info") $("#info").close(); });
288
+
289
+ /* ---------------- sidebar ---------------- */
290
+ function renderOptions() {
291
+ const box = $("#options");
292
+ box.innerHTML = "";
293
+ for (const [key, o] of Object.entries(S.config.options)) {
294
+ const id = "opt-" + key.replace(/\./g, "-");
295
+ const d = document.createElement("div");
296
+ d.className = "opt";
297
+ d.innerHTML = `<label for="${id}">${esc(o.label)}${infoButton(key, "option")}</label><select id="${id}">${o.choices.map((c) =>
298
+ `<option value="${esc(c.value)}">${esc(c.label)}${c.value === o.default ? " ★" : ""}</option>`).join("")}</select>`;
299
+ box.appendChild(d);
300
+ const sel = d.querySelector("select");
301
+ sel.value = String(S.options[key]);
302
+ sel.onchange = () => {
303
+ const c = o.choices.find((x) => String(x.value) === sel.value);
304
+ S.options[key] = c.value;
305
+ selectRun();
306
+ };
307
+ }
308
+ }
309
+
310
+ function sameOptions(a, b) { return Object.keys(S.config.options).every((k) => String(a[k]) === String(b[k])); }
311
+ function sameParams(a, b) { return Object.keys(S.config.params).every((k) => Number(a[k]) === Number(b[k])); }
312
+ function sameRun(r) { return sameOptions(r.options, S.options) && sameParams(r.params, S.params); }
313
+ function defaults(obj) { return Object.fromEntries(Object.entries(obj).map(([k, o]) => [k, o.default])); }
314
+
315
+ const STEPS = ["acquire", "emit_l2a", "emit_l2b", "aster", "structure", "alteration", "targets", "maps"];
316
+ const RERUN_TEXT = {
317
+ targets: "Only the CPI, zones, field-check locations and maps are recomputed (seconds).",
318
+ alteration: "Alteration layers, CPI, zones and maps are recomputed (about a minute).",
319
+ structure: "Lineaments, alteration, CPI, zones and maps are recomputed (a few minutes).",
320
+ emit_l2a: "EMIT detection is redone from the downloaded scenes (several minutes to an hour, depending on area size).",
321
+ acquire: "Full analysis including data download.",
322
+ };
323
+
324
+ function firstStep() {
325
+ const steps = [];
326
+ for (const [k, o] of Object.entries(S.config.options)) if (String(S.options[k]) !== String(o.default)) steps.push(o.step);
327
+ for (const [k, p] of Object.entries(S.config.params)) if (Number(S.params[k]) !== Number(p.default)) steps.push(p.step);
328
+ return steps.sort((a, b) => STEPS.indexOf(a) - STEPS.indexOf(b))[0] || null;
329
+ }
330
+
331
+ function weightSum() {
332
+ return ["muscovite", "quartz", "gossan", "chlorite"].reduce((a, m) => a + Number(S.params["targets.w_" + m]), 0);
333
+ }
334
+
335
+ function ruleProblems() {
336
+ const v = S.params, out = [];
337
+ const w = weightSum();
338
+ if (Math.abs(w - 1) > 0.005) out.push(`Mineral weights add up to ${w.toFixed(2)} (must be 1.00).`);
339
+ if (!(v["targets.tier1"] > v["targets.tier2"] && v["targets.tier2"] > v["targets.tier3"])) out.push("Tiers must decrease: Tier 1 > Tier 2 > Tier 3.");
340
+ if (!(v["alteration.sigma_halo_m"] > v["alteration.sigma_core_m"])) out.push("Halo σ must be larger than core σ.");
341
+ if (!(v["alteration.sericite_max_nm"] > v["alteration.sericite_min_nm"])) out.push("White-mica window: 'to' must be above 'from'.");
342
+ if (!(v["structure.canny_high_quantile"] > v["structure.canny_low_quantile"])) out.push("High edge quantile must be above the low one.");
343
+ if (!(v["structure.min_length_m"] >= v["structure.min_segment_m"])) out.push("Minimum lineament length must be at least the minimum Hough segment.");
344
+ for (const [k, p] of Object.entries(S.config.params)) {
345
+ if (p.kind !== "bool" && !(Number(v[k]) >= p.min && Number(v[k]) <= p.max)) out.push(`${p.label} must be between ${p.min} and ${p.max}.`);
346
+ }
347
+ return out;
348
+ }
349
+
350
+ function renderParams() {
351
+ const box = $("#params");
352
+ const groups = {};
353
+ for (const [k, p] of Object.entries(S.config.params)) (groups[p.group] = groups[p.group] || []).push([k, p]);
354
+ const first = !box.querySelector("details");
355
+ const open = new Set([...box.querySelectorAll("details[open]")].map((d) => d.dataset.group));
356
+ if (first) open.add(Object.keys(groups)[0]);
357
+ box.innerHTML = Object.entries(groups).map(([g, ps]) => {
358
+ const rows = ps.map(([k, p]) => {
359
+ const val = S.params[k], ch = Number(val) !== Number(p.default);
360
+ const input = p.kind === "bool" ? `<input type="checkbox" data-k="${k}"${val ? " checked" : ""}>`
361
+ : `<input type="number" data-k="${k}" value="${val}" min="${p.min}" max="${p.max}" step="${p.step_size}">`;
362
+ const dflt = p.kind === "bool" ? (p.default ? "yes" : "no") : p.default;
363
+ return `<div class="prow${ch ? " changed" : ""}" title="${esc(p.help || "")}">
364
+ <div class="pl">${esc(p.label)}<span class="tag">${p.tag}</span>${infoButton(k)}<span class="def">default ${dflt}${p.unit ? " " + esc(p.unit) : ""}</span></div>
365
+ ${input}${ch ? `<button type="button" class="reset" data-reset="${k}">reset</button>` : ""}</div>`;
366
+ }).join("");
367
+ const sum = g.startsWith("CPI") ? '<div id="wsum" class="psum"></div>' : "";
368
+ return `<details class="pgroup" data-group="${esc(g)}"${open.has(g) ? " open" : ""}><summary>${esc(g)}</summary>${sum}${rows}</details>`;
369
+ }).join("");
370
+ box.querySelectorAll("input[data-k]").forEach((i) => i.onchange = () => {
371
+ const p = S.config.params[i.dataset.k];
372
+ S.params[i.dataset.k] = p.kind === "bool" ? i.checked : Number(i.value);
373
+ renderParams();
374
+ selectRun();
375
+ });
376
+ box.querySelectorAll("[data-reset]").forEach((b) => b.onclick = () => {
377
+ S.params[b.dataset.reset] = S.config.params[b.dataset.reset].default;
378
+ renderParams();
379
+ selectRun();
380
+ });
381
+ const w = weightSum(), ws = $("#wsum");
382
+ if (ws) { ws.className = "psum " + (Math.abs(w - 1) <= 0.005 ? "ok" : "bad"); ws.textContent = `Mineral weights sum: ${w.toFixed(2)} (must be 1.00)`; }
383
+ const n = Object.entries(S.config.params).filter(([k, p]) => Number(S.params[k]) !== Number(p.default)).length;
384
+ $("#params-changed").hidden = !n;
385
+ $("#params-changed").textContent = `${n} changed`;
386
+ }
387
+
388
+ async function refreshBlocks(selectName) {
389
+ S.blocks = await getJSON("/api/blocks");
390
+ const sel = $("#block-select");
391
+ sel.innerHTML = S.blocks.map((b) => `<option value="${esc(b.block)}">${esc(b.block)}${b.area_km2 ? ` · ${b.area_km2} km²` : ""}</option>`).join("");
392
+ const name = selectName || (S.block && S.block.block) || (S.blocks[0] && S.blocks[0].block);
393
+ if (name) sel.value = name;
394
+ S.block = S.blocks.find((b) => b.block === sel.value) || null;
395
+ }
396
+
397
+ function renderBlockInfo() {
398
+ const b = S.block;
399
+ $("#block-info").textContent = b ? `${b.runs.length} result set(s)` + (b.has_ground_truth ? " · ground truth available" : "") +
400
+ (b.uploaded_by ? ` · uploaded by ${b.uploaded_by}` : "") : "";
401
+ const box = $("#computed");
402
+ if (!b || !b.runs.length) { box.innerHTML = ""; return; }
403
+ box.innerHTML = `<div class="title">Already computed for this area</div>` + b.runs.map((r) =>
404
+ `<button class="chip${S.run && S.run.run_id === r.run_id ? " active" : ""}" data-run="${esc(r.run_id)}" title="${esc(r.label)}">
405
+ ${r.recommended ? '<span class="rec">Recommended · </span>' : ""}${esc(Object.entries(r.options).map(([k, v]) =>
406
+ (S.config.options[k].choices.find((c) => String(c.value) === String(v)) || { label: v }).label).join(" · "))}
407
+ ${r.custom ? `<br><span class="small muted">Custom: ${esc(r.label.split("Custom parameters: ")[1] || "")}</span>` : ""}
408
+ ${r.created_by ? `<br><span class="small muted">by ${esc(r.created_by)}</span>` : ""}</button>`).join("");
409
+ box.querySelectorAll(".chip").forEach((c) => c.onclick = () => {
410
+ const r = b.runs.find((x) => x.run_id === c.dataset.run);
411
+ Object.assign(S.options, r.options);
412
+ Object.assign(S.params, r.params);
413
+ renderOptions();
414
+ renderParams();
415
+ selectRun();
416
+ });
417
+ }
418
+
419
+ async function selectRun() {
420
+ const b = S.block;
421
+ const st = $("#run-status"), btn = $("#run-btn"), bar = $("#run-bar");
422
+ const match = b && b.runs.find(sameRun);
423
+ const job = await activeJob();
424
+ if (match) {
425
+ st.className = "status ready";
426
+ st.textContent = match.recommended ? "Results ready (recommended method)." : match.custom ? "Results ready (custom parameters)." : "Results ready.";
427
+ bar.hidden = true;
428
+ // remember what is being viewed, so "Discard changes" can come back to it
429
+ S.last = { block: b.block, options: { ...S.options }, params: { ...S.params } };
430
+ if (!S.run || S.run.run_id !== match.run_id) await loadRun(match.run_id);
431
+ } else {
432
+ // keep showing the results that were on screen while settings are being edited
433
+ const keep = S.run && S.run.block === b.block;
434
+ if (!keep) S.run = null;
435
+ const running = job && job.block === b.block && sameOptions(job.options, S.options) && sameParams(job.params, S.params);
436
+ const probs = ruleProblems();
437
+ const fs = firstStep();
438
+ const note = b.runs.length && fs ? (RERUN_TEXT[fs] || RERUN_TEXT.acquire) : RERUN_TEXT.acquire;
439
+ st.className = "status " + (running ? "running" : "missing");
440
+ st.textContent = running ? "These settings are being computed now." : "Not computed yet for these settings. Use the bar at the bottom to run them.";
441
+ // the run bar stays fixed at the bottom of the side panel, wherever the panel is scrolled
442
+ const last = S.last && S.last.block === b.block ? S.last : null;
443
+ const n = last ? Object.keys(S.config.options).filter((k) => String(S.options[k]) !== String(last.options[k])).length +
444
+ Object.keys(S.config.params).filter((k) => Number(S.params[k]) !== Number(last.params[k])).length : 0;
445
+ $("#run-bar-text").innerHTML = (n ? `<b>${n} setting${n > 1 ? "s" : ""} changed.</b> ` : "<b>Not computed yet.</b> ") +
446
+ (probs.length ? `<div class="error">${probs.map(esc).join("<br>")}</div>` : esc(note)) +
447
+ (keep ? `<div class="muted">The map and tabs still show the previous results until you run.</div>` : "");
448
+ $("#run-discard").hidden = !last;
449
+ bar.hidden = !!running;
450
+ btn.disabled = probs.length > 0;
451
+ if (!keep) clearViews();
452
+ }
453
+ renderBlockInfo();
454
+ }
455
+
456
+ function clearViews() {
457
+ drawMap();
458
+ $("#gallery").innerHTML = `<div class="empty">No results for these options yet.</div>`;
459
+ $("#targets-table").innerHTML = "";
460
+ $("#validation").innerHTML = `<div class="empty">No results for these options yet.</div>`;
461
+ $("#download-groups").innerHTML = `<div class="empty">No results for these options yet.</div>`;
462
+ }
463
+
464
+ async function loadRun(runId) {
465
+ S.run = await getJSON(`/api/runs/${runId}`);
466
+ const known = [...S.run.layers, ...S.run.reference.map((r) => "ref:" + r.key), "none"];
467
+ if (!known.includes(S.layer)) S.layer = S.run.layers[0] || "none";
468
+ renderLayerRadios();
469
+ await Promise.all([drawMap(), renderGallery(), renderTargets(), renderValidation()]);
470
+ renderDownloads();
471
+ }
472
+
473
+ function renderLayerRadios() {
474
+ const box = $("#layer-radios");
475
+ const radio = (k, text) => `<label><input type="radio" name="layer" value="${k}"${k === S.layer ? " checked" : ""}> ${text}</label>`;
476
+ box.innerHTML = S.run.layers.map((k) => radio(k, esc(S.config.layers[k]) + infoButton(k, "layer"))).join("") +
477
+ (S.run.reference.length ? `<div class="ref-head">Reference geophysics <span>view only · not in the CPI</span></div>` +
478
+ S.run.reference.map((r) => radio("ref:" + r.key, esc(r.label) + infoButton("ref:" + r.key, "layer"))).join("") : "") +
479
+ radio("none", "None");
480
+ box.querySelectorAll("input").forEach((i) => i.onchange = () => { S.layer = i.value; drawRaster(); });
481
+ }
482
+
483
+ /* ---------------- tabs ---------------- */
484
+ function renderGallery() {
485
+ const run = S.run;
486
+ const maps = [...run.maps.map((m) => ({ url: `/api/runs/${run.run_id}/map/${m.file}`, label: m.label, file: m.file })),
487
+ ...run.reference.map((r) => ({ url: `/api/blocks/${run.block}/reference/${r.key}/map.png`, file: `reference_${r.key}_map.png`,
488
+ label: `Reference (not in the CPI): ${r.label}` }))];
489
+ $("#gallery").innerHTML = maps.map((m) => `<figure><img src="${m.url}" alt="${esc(m.label)}" loading="lazy" data-full="${m.url}">
490
+ <figcaption><span>${esc(m.label)}</span><a href="${m.url}" download="${run.block}_${m.file}">Download PNG</a></figcaption></figure>`).join("");
491
+ $("#gallery").querySelectorAll("img").forEach((i) => i.onclick = () => { $("#lightbox-img").src = i.dataset.full; $("#lightbox").showModal(); });
492
+ }
493
+
494
+ async function renderTargets() {
495
+ const [zones, rows] = await Promise.all([getJSON(`/api/runs/${S.run.run_id}/zones`), getJSON(`/api/runs/${S.run.run_id}/targets`)]);
496
+ const table = (el, cols, data, attrs) => {
497
+ cols = cols.filter(([k]) => data.length && k in data[0]);
498
+ const fmt = (k, v) => k === "Latitude" || k === "Longitude" ? (+v).toFixed(4) : k === "CPI_Percent" ? (+v).toFixed(1) : v;
499
+ el.innerHTML = data.length ? `<thead><tr>${cols.map(([, h]) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${data.map((r) =>
500
+ `<tr ${attrs(r)}>${cols.map(([k]) => {
501
+ const t = k === "Priority_Tier" ? ` class="tier${String(r[k]).match(/(\d)/)?.[1]}"` : typeof r[k] === "number" ? ' class="num"' : "";
502
+ return `<td${t}>${esc(fmt(k, r[k]))}</td>`;
503
+ }).join("")}</tr>`).join("")}</tbody>` : "";
504
+ };
505
+ $("#zones-note").hidden = zones.length > 0;
506
+ table($("#zones-table"), [["Zone_ID", "Zone"], ["Priority_Tier", "Tier"], ["Area_km2", "Area km²"], ["CPI_Max_Pct", "Peak CPI %"],
507
+ ["CPI_Mean_Pct", "Mean CPI %"], ["Main_Evidence", "Main evidence"], ["Muscovite_Pct", "Musc %"], ["Quartz_Pct", "Qtz %"],
508
+ ["Gossan_Pct", "Goss %"], ["Chlorite_Pct", "Chl %"], ["Lineaments_km", "Lineaments km"], ["Field_Check_Locations", "Field-check locations"],
509
+ ["Evidence_Basis", "Based on"], ["Suggested_Follow_Up", "Suggested follow-up"]], zones, (r) => `data-zone="${esc(r.Zone_ID)}"`);
510
+ $("#zones-table").querySelectorAll("tbody tr").forEach((tr) => tr.onclick = async () => {
511
+ showTab("map");
512
+ if (!$("#show-zones").checked) { $("#show-zones").checked = true; await drawVectors(); }
513
+ const l = (G.zoneLayers || {})[tr.dataset.zone];
514
+ if (l) { map.fitBounds(l.getBounds(), { padding: [80, 80], maxZoom: 16 }); l.openPopup(); }
515
+ });
516
+ table($("#targets-table"), [["Rank", "#"], ["Location_Name", "Location"], ["Target_Name", "Location"], ["Zone", "Zone"], ["Priority_Tier", "Tier"],
517
+ ["CPI_Percent", "CPI %"], ["Muscovite_Pct", "Musc %"], ["Quartz_Pct", "Qtz %"], ["Gossan_Pct", "Goss %"], ["Chlorite_Pct", "Chl %"],
518
+ ["Distance_to_Lineament_m", "To lineament (m)"], ["Latitude", "Lat"], ["Longitude", "Lon"], ["Check_Within_m", "Check within (m)"],
519
+ ["Suggested_Follow_Up", "Suggested follow-up"]], rows, (r) => `data-rank="${r.Rank}" data-lat="${r.Latitude}" data-lon="${r.Longitude}"`);
520
+ $("#targets-table").querySelectorAll("tbody tr").forEach((tr) => tr.onclick = () => {
521
+ showTab("map");
522
+ map.setView([+tr.dataset.lat, +tr.dataset.lon], 16);
523
+ const m = G.targetMarkers[tr.dataset.rank];
524
+ if (m) m.openPopup();
525
+ });
526
+ }
527
+
528
+ async function renderValidation() {
529
+ const run = S.run, el = $("#validation");
530
+ if (!run.has_validation) {
531
+ el.innerHTML = `<div class="empty">No ground truth (boreholes, lodes or explored blocks) is available for this area,
532
+ so there is no validation. The maps, zones and field-check locations are still produced.</div>`;
533
+ return;
534
+ }
535
+ const v = await getJSON(`/api/runs/${run.run_id}/validation`);
536
+ el.innerHTML = marked.parse(v.summary) + run.validation_maps.map((m) =>
537
+ `<img src="/api/runs/${run.run_id}/validation/${m}" alt="">`).join("");
538
+ }
539
+
540
+ function renderDownloads() {
541
+ const run = S.run;
542
+ const groups = {};
543
+ run.downloads.forEach((d) => (groups[d.group] = groups[d.group] || []).push(d));
544
+ $("#download-groups").innerHTML = Object.entries(groups).map(([g, items]) => `<div class="dgroup"><h3>${esc(g)}
545
+ <button type="button" data-all>Select all</button></h3>${items.map((d) =>
546
+ `<label><input type="checkbox" value="${esc(d.id)}"${/^(map|vector):/.test(d.id) || d.id === "contour:cpi" ? " checked" : ""}> ${esc(d.label)}</label>`).join("")}</div>`).join("");
547
+ $("#download-groups").querySelectorAll("[data-all]").forEach((b) => b.onclick = () => {
548
+ const boxes = b.closest(".dgroup").querySelectorAll("input");
549
+ const all = [...boxes].every((x) => x.checked);
550
+ boxes.forEach((x) => (x.checked = !all));
551
+ });
552
+ }
553
+
554
+ function renderFormats() {
555
+ $("#formats").innerHTML = "Vector format:" + Object.entries(S.config.formats).map(([k, v]) =>
556
+ `<label><input type="checkbox" value="${k}"${k === "shp" ? " checked" : ""}> ${v}</label>`).join("");
557
+ }
558
+
559
+ $("#download-btn").onclick = async () => {
560
+ if (!S.run) return;
561
+ const items = [...document.querySelectorAll("#download-groups input:checked")].map((i) => i.value);
562
+ const formats = [...document.querySelectorAll("#formats input:checked")].map((i) => i.value);
563
+ if (!items.length) { alert("Select at least one item."); return; }
564
+ const btn = $("#download-btn");
565
+ btn.disabled = true; btn.textContent = "Preparing…";
566
+ try {
567
+ const r = await api(`/api/runs/${S.run.run_id}/download`, { method: "POST", headers: { "Content-Type": "application/json" },
568
+ body: JSON.stringify({ items, formats }) });
569
+ const blob = await r.blob();
570
+ const name = (r.headers.get("Content-Disposition") || "").match(/filename="(.+)"/)?.[1] || "geoscan.zip";
571
+ const a = document.createElement("a");
572
+ a.href = URL.createObjectURL(blob); a.download = name; a.click();
573
+ setTimeout(() => URL.revokeObjectURL(a.href), 5000);
574
+ } catch (e) { alert("Download failed: " + e.message); }
575
+ btn.disabled = false; btn.textContent = "Download selected (.zip)";
576
+ };
577
+
578
+ function showTab(name) {
579
+ document.querySelectorAll("#tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === name));
580
+ document.querySelectorAll(".tab-panel").forEach((p) => p.classList.toggle("active", p.id === "tab-" + name));
581
+ if (name === "map") setTimeout(() => map.invalidateSize(), 50);
582
+ if (name === "admin") renderAdmin();
583
+ }
584
+ document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => { showTab(b.dataset.tab); history.replaceState(null, "", "#" + b.dataset.tab); });
585
+
586
+ /* ---------------- jobs ---------------- */
587
+ async function activeJob() {
588
+ const js = await getJSON("/api/jobs");
589
+ return js.reverse().find((j) => j.status === "running" || j.status === "queued") || null;
590
+ }
591
+
592
+ function showJob(j) {
593
+ $("#job-card").hidden = false;
594
+ $("#job-title").textContent = `${j.block} · ${j.label}`;
595
+ $("#job-bar").style.width = `${Math.round(100 * j.progress)}%`;
596
+ $("#job-step").textContent = j.status === "failed" ? `Failed: ${j.error}` : j.status === "done" ? "Finished." :
597
+ j.status === "queued" ? "Waiting for another analysis to finish…" : j.step_label || "Starting…";
598
+ $("#job-log").textContent = j.log.join("\n");
599
+ }
600
+
601
+ function pollJob(jobId) {
602
+ clearInterval(S.jobTimer);
603
+ S.jobTimer = setInterval(async () => {
604
+ const j = await getJSON(`/api/jobs/${jobId}`);
605
+ showJob(j);
606
+ if (j.status === "done" || j.status === "failed") {
607
+ clearInterval(S.jobTimer);
608
+ if (j.status === "done") {
609
+ await refreshBlocks(j.block);
610
+ S.block = S.blocks.find((b) => b.block === j.block);
611
+ $("#block-select").value = j.block;
612
+ Object.assign(S.options, j.options);
613
+ Object.assign(S.params, j.params);
614
+ renderOptions();
615
+ renderParams();
616
+ await selectRun();
617
+ }
618
+ }
619
+ }, 3000);
620
+ }
621
+
622
+ $("#run-discard").onclick = () => {
623
+ if (!S.last) return;
624
+ S.options = { ...S.last.options };
625
+ S.params = { ...S.last.params };
626
+ renderOptions();
627
+ renderParams();
628
+ selectRun();
629
+ };
630
+
631
+ $("#run-btn").onclick = async () => {
632
+ const btn = $("#run-btn");
633
+ if (ruleProblems().length) return;
634
+ btn.disabled = true;
635
+ try {
636
+ const opts = {}, params = {};
637
+ Object.keys(S.config.options).forEach((k) => (opts[k] = S.options[k]));
638
+ Object.keys(S.config.params).forEach((k) => (params[k] = S.params[k]));
639
+ const r = await (await api("/api/runs", { method: "POST", headers: { "Content-Type": "application/json" },
640
+ body: JSON.stringify({ block: S.block.block, options: opts, params }) })).json();
641
+ if (r.run) { await refreshBlocks(); await selectRun(); }
642
+ else { showJob(r.job); pollJob(r.job.job_id); await selectRun(); }
643
+ } catch (e) { alert(e.message); }
644
+ btn.disabled = false;
645
+ };
646
+
647
+ /* ---------------- new area ---------------- */
648
+ $("#new-area-btn").onclick = () => { $("#area-error").textContent = ""; $("#new-area").showModal(); };
649
+ $("#area-cancel").onclick = () => $("#new-area").close();
650
+ $("#new-area-form").onsubmit = async (ev) => {
651
+ ev.preventDefault();
652
+ const f = $("#area-file").files[0];
653
+ if (!f) return;
654
+ const fd = new FormData();
655
+ fd.append("file", f);
656
+ fd.append("name", $("#area-name").value);
657
+ $("#area-upload").disabled = true;
658
+ try {
659
+ const r = await (await api("/api/blocks", { method: "POST", body: fd })).json();
660
+ $("#new-area").close();
661
+ await refreshBlocks(r.block);
662
+ S.options = defaults(S.config.options);
663
+ S.params = defaults(S.config.params);
664
+ renderOptions();
665
+ renderParams();
666
+ S.run = null;
667
+ await selectRun();
668
+ if (!S.block.runs.length) $("#run-status").textContent = `New area uploaded (${r.area_km2} km²). Choose options and press Run.`;
669
+ } catch (e) { $("#area-error").textContent = e.message; }
670
+ $("#area-upload").disabled = false;
671
+ };
672
+
673
+ $("#block-select").onchange = async () => {
674
+ S.block = S.blocks.find((b) => b.block === $("#block-select").value);
675
+ S.run = null;
676
+ S.fitted = null;
677
+ await selectRun();
678
+ };
679
+ $("#opacity").oninput = () => G.overlay && G.overlay.setOpacity(+$("#opacity").value);
680
+ ["#show-contours", "#show-na"].forEach((s) => $(s).onchange = drawRaster);
681
+ ["#show-targets", "#show-lineaments", "#show-zones"].forEach((s) => $(s).onchange = async () => { await drawVectors(); renderLegend(S.legend || null); });
682
+ document.querySelectorAll('input[name="base"]').forEach((r) => r.onchange = () => {
683
+ Object.values(bases).forEach((b) => map.removeLayer(b));
684
+ bases[r.value].addTo(map).bringToBack();
685
+ });
686
+ $("#lightbox-close").onclick = () => $("#lightbox").close();
687
+
688
+ /* ---------------- accounts and admin ---------------- */
689
+ const ACTION_TEXT = { session_start: "Signed in", upload_area: "Uploaded area", run_started: "Started analysis",
690
+ run_finished: "Analysis finished", run_failed: "Analysis failed", open_results: "Opened results", download: "Downloaded",
691
+ signed_out: "Signed out", user_added: "Added user", user_removed: "Removed user" };
692
+
693
+ function logDetails(e) {
694
+ const d = [];
695
+ if (e.file) d.push(`file <b>${esc(e.file)}</b>${e.bytes ? ` (${(e.bytes / 1024).toFixed(1)} kB)` : ""}`);
696
+ if (e.area) d.push(`area <b>${esc(e.area)}</b>${e.area_km2 ? ` (${e.area_km2} km²)` : ""}`);
697
+ if (e.centre_lat) d.push(`centre ${e.centre_lat}, ${e.centre_lon}`);
698
+ if (e.result) d.push(esc(e.result));
699
+ if (e.settings) d.push(esc(e.settings));
700
+ if (e.changed_parameters) d.push("changed: " + Object.entries(e.changed_parameters).map(([k, v]) =>
701
+ `${esc((S.config.params[k] || { label: k }).label)} = ${esc(v)}`).join(", "));
702
+ if (e.recompute_from && e.action === "run_started") d.push(`recomputed from: ${esc(e.recompute_from)}`);
703
+ if (e.results && e.action !== "open_results") d.push(`results: ${esc(e.results)}`);
704
+ if (e.items) d.push(`${e.items.length} item(s): ${esc(e.items.join(", "))}${e.megabytes ? ` · ${e.megabytes} MB` : ""}`);
705
+ if (e.formats && e.action === "download") d.push(`formats: ${esc(e.formats.join(", "))}`);
706
+ if (e.error) d.push(`<span class="error">${esc(e.error)}</span>`);
707
+ if (e.account) d.push(`account <b>${esc(e.account)}</b>${e.note ? ` (${esc(e.note)})` : ""}`);
708
+ if (e.ip) d.push(`from ${esc(e.ip)}`);
709
+ if (e.browser) d.push(`<span class="muted">${esc(e.browser)}</span>`);
710
+ return d.join(" · ");
711
+ }
712
+
713
+ const localTime = (t) => t ? new Date(t).toLocaleString() : "";
714
+
715
+ async function renderAdmin() {
716
+ const panel = $("#tab-admin");
717
+ if (!panel.firstElementChild) {
718
+ panel.appendChild($("#admin-template").content.cloneNode(true));
719
+ $("#user-form").onsubmit = async (ev) => {
720
+ ev.preventDefault();
721
+ $("#user-error").textContent = "";
722
+ try {
723
+ await api("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" },
724
+ body: JSON.stringify({ user: $("#new-user").value, password: $("#new-pass").value, note: $("#new-note").value }) });
725
+ $("#user-form").reset();
726
+ renderAdmin();
727
+ } catch (e) { $("#user-error").textContent = e.message; }
728
+ };
729
+ $("#log-refresh").onclick = renderAdmin;
730
+ $("#log-user").onchange = () => drawLog();
731
+ $("#log-action").onchange = () => drawLog();
732
+ }
733
+ const [users, log] = await Promise.all([getJSON("/api/admin/users"), getJSON("/api/admin/log?limit=2000")]);
734
+ S.log = log;
735
+ $("#users-table").innerHTML = `<thead><tr><th>User</th><th>Role</th><th>Note</th><th>Created</th><th>Last active</th><th></th></tr></thead><tbody>` +
736
+ users.map((u) => `<tr><td><b>${esc(u.user)}</b></td><td>${esc(u.role)}</td><td>${esc(u.note)}</td>
737
+ <td class="nowrap">${esc(localTime(u.created))}</td><td class="nowrap">${esc(localTime(u.last_active))}</td>
738
+ <td>${u.role === "user" ? `<button class="linkbtn" data-remove="${esc(u.user)}">Remove</button>` : ""}</td></tr>`).join("") + "</tbody>";
739
+ $("#users-table").querySelectorAll("[data-remove]").forEach((b) => b.onclick = async () => {
740
+ if (!confirm(`Remove user "${b.dataset.remove}"? They will no longer be able to sign in.`)) return;
741
+ await api(`/api/admin/users/${encodeURIComponent(b.dataset.remove)}`, { method: "DELETE" });
742
+ renderAdmin();
743
+ });
744
+ const keep = (sel, values, all, label) => {
745
+ const cur = sel.value;
746
+ sel.innerHTML = `<option value="">${all}</option>` + values.map((v) => `<option value="${esc(v)}">${esc(label(v))}</option>`).join("");
747
+ sel.value = values.includes(cur) ? cur : "";
748
+ };
749
+ keep($("#log-user"), [...new Set(log.map((e) => e.user))].sort(), "All users", (v) => v);
750
+ keep($("#log-action"), [...new Set(log.map((e) => e.action))].sort(), "All actions", (v) => ACTION_TEXT[v] || v);
751
+ drawLog();
752
+ }
753
+
754
+ function drawLog() {
755
+ const u = $("#log-user").value, a = $("#log-action").value;
756
+ const rows = (S.log || []).filter((e) => (!u || e.user === u) && (!a || e.action === a));
757
+ $("#log-table").innerHTML = `<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead><tbody>` +
758
+ (rows.length ? rows.map((e) => `<tr style="cursor:default"><td class="nowrap">${esc(localTime(e.time))}</td><td><b>${esc(e.user)}</b></td>
759
+ <td class="nowrap"><span class="act ${esc(e.action)}">${esc(ACTION_TEXT[e.action] || e.action)}</span></td><td>${logDetails(e)}</td></tr>`).join("")
760
+ : `<tr><td colspan="4" class="muted">No activity recorded yet.</td></tr>`) + "</tbody>";
761
+ }
762
+
763
+ async function signOut(ev) {
764
+ ev.preventDefault();
765
+ try { await api("/api/signout", { method: "POST" }); } catch (e) { /* still sign out */ }
766
+ // Browsers keep a Basic-auth password until told otherwise: send wrong credentials on purpose so the
767
+ // stored ones are replaced, then show a signed-out page.
768
+ await new Promise((done) => {
769
+ const x = new XMLHttpRequest();
770
+ x.open("GET", "/api/logout", true, "signed-out", "signed-out");
771
+ x.onloadend = done;
772
+ x.send();
773
+ });
774
+ document.body.innerHTML = `<div class="signed-out"><h1>You are signed out</h1>
775
+ <p>Close this tab, or <a href="/">sign in again</a>.</p>
776
+ <p class="muted small">If the app opens without asking for a password, close all browser windows to finish signing out.</p></div>`;
777
+ }
778
+
779
+ async function initAccount() {
780
+ S.me = await getJSON("/api/me");
781
+ if (S.me.login) {
782
+ $("#whoami").hidden = false;
783
+ $("#whoami").innerHTML = `Signed in as <b>${esc(S.me.user)}</b> · activity is recorded · <a href="#" id="signout">Sign out</a>`;
784
+ $("#signout").onclick = signOut;
785
+ }
786
+ if (S.me.admin) $("#admin-tab-btn").hidden = false;
787
+ }
788
+
789
+ /* ---------------- start ---------------- */
790
+ (async function init() {
791
+ S.config = await getJSON("/api/config");
792
+ await initAccount();
793
+ S.options = defaults(S.config.options);
794
+ S.params = defaults(S.config.params);
795
+ renderOptions();
796
+ renderParams();
797
+ renderFormats();
798
+ $("#params-reset").onclick = () => { S.params = defaults(S.config.params); renderParams(); selectRun(); };
799
+ await refreshBlocks();
800
+ await selectRun();
801
+ const j = await activeJob();
802
+ if (j) { showJob(j); pollJob(j.job_id); }
803
+ if (location.hash.includes("params")) $("#params-box").open = true;
804
+ // links can pre-set parameters, e.g. #params&set=targets.min_spacing_m:400
805
+ let preset = false;
806
+ for (const m of location.hash.matchAll(/set=([\w.]+):([\d.]+)/g)) {
807
+ if (S.config.params[m[1]]) { S.params[m[1]] = Number(m[2]); preset = true; }
808
+ }
809
+ if (preset) { renderParams(); await selectRun(); }
810
+ const lm = location.hash.match(/layer=([\w:]+)/);
811
+ if (lm && S.run && (S.run.layers.includes(lm[1]) || refOf(lm[1]))) { S.layer = lm[1]; renderLayerRadios(); await drawRaster(); }
812
+ const im = location.hash.match(/info=([\w.]+)/);
813
+ if (im && (S.config.params[im[1]] || S.config.options[im[1]])) showInfo(im[1]);
814
+ const tab = location.hash.slice(1).split("&")[0];
815
+ if (["map", "contours", "targets", "validation", "download", "guide", "admin"].includes(tab)) showTab(tab);
816
+ })();
geoscan/geoscan/webapp/static/index.html CHANGED
@@ -1,266 +1,284 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>GeoScan Copper Prospectivity</title>
7
- <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
8
- <link rel="stylesheet" href="/static/style.css">
9
- </head>
10
- <body>
11
- <header class="topbar">
12
- <div class="brand"><span class="logo">◆</span> GeoScan <span class="sub">Copper prospectivity from satellite data</span></div>
13
- <nav class="tabs" id="tabs">
14
- <button data-tab="map" class="active">Map</button>
15
- <button data-tab="contours">Contour maps</button>
16
- <button data-tab="targets">Targets</button>
17
- <button data-tab="validation">Validation</button>
18
- <button data-tab="download">Download</button>
19
- <button data-tab="guide">Guide</button>
20
- <button data-tab="admin" id="admin-tab-btn" hidden>Admin</button>
21
- </nav>
22
- <div id="whoami" class="whoami" hidden></div>
23
- </header>
24
-
25
- <div class="layout">
26
- <aside class="sidebar">
27
- <div class="side-scroll">
28
- <section class="card">
29
- <h2>Area</h2>
30
- <select id="block-select"></select>
31
- <div id="block-info" class="muted small"></div>
32
- <button id="new-area-btn" class="btn ghost full">+ Upload a new area (KML / KMZ / GeoJSON)</button>
33
- </section>
34
-
35
- <section class="card">
36
- <h2>Method options</h2>
37
- <div id="options"></div>
38
- <div class="muted small">★ = recommended (tested best on both blocks)</div>
39
- <div id="run-status" class="status"></div>
40
- <div id="computed" class="computed"></div>
41
- </section>
42
-
43
- <section class="card">
44
- <details id="params-box">
45
- <summary><h2 class="inline">Adjust parameters</h2> <span id="params-changed" class="badge" hidden></span></summary>
46
- <p class="muted small">Values the method chose by hand. Tags: <b>EXP</b> expert choice, <b>ASM</b> project assumption,
47
- <b>LIB</b> spectral library. Change any value, then press <i>Run analysis</i> in the bar at the bottom; only the affected steps are recomputed.</p>
48
- <div id="params"></div>
49
- <button type="button" id="params-reset" class="btn ghost full">Reset all to defaults</button>
50
- </details>
51
- </section>
52
-
53
- </div>
54
-
55
- <div class="side-footer">
56
- <section id="job-card" class="footer-block" hidden>
57
- <h2>Analysis in progress</h2>
58
- <div id="job-title" class="small"></div>
59
- <div class="bar"><div id="job-bar"></div></div>
60
- <div id="job-step" class="small"></div>
61
- <details><summary class="small">Log</summary><pre id="job-log"></pre></details>
62
- </section>
63
- <div id="run-bar" class="footer-block run-bar" hidden>
64
- <div id="run-bar-text" class="small"></div>
65
- <div class="run-bar-actions">
66
- <button id="run-discard" class="btn ghost" type="button" title="Go back to the results you were viewing">Discard changes</button>
67
- <button id="run-btn" class="btn primary">Run analysis</button>
68
- </div>
69
- </div>
70
- </div>
71
- </aside>
72
-
73
- <main class="content">
74
- <section class="tab-panel active" id="tab-map">
75
- <div id="map"></div>
76
- <div class="map-panel" id="map-panel">
77
- <h3>Layer</h3>
78
- <div id="layer-radios"></div>
79
- <label class="slider">Opacity <input type="range" id="opacity" min="0" max="1" step="0.05" value="0.8"></label>
80
- <h3>Show</h3>
81
- <label><input type="checkbox" id="show-contours" checked> Contour lines</label>
82
- <label><input type="checkbox" id="show-targets" checked> Drill targets</label>
83
- <label><input type="checkbox" id="show-lineaments" checked> Lineaments</label>
84
- <label><input type="checkbox" id="show-zones"> CPI target zones</label>
85
- <label><input type="checkbox" id="show-na" checked> Not assessed (no usable EMIT data)</label>
86
- <h3>Base map</h3>
87
- <label><input type="radio" name="base" value="sat" checked> Satellite</label>
88
- <label><input type="radio" name="base" value="topo"> Topographic</label>
89
- </div>
90
- <div class="legend" id="legend"></div>
91
- </section>
92
-
93
- <section class="tab-panel" id="tab-contours">
94
- <p class="muted">Publication-style contour maps for the selected area and method options. Click a map to enlarge.</p>
95
- <div class="gallery" id="gallery"></div>
96
- </section>
97
-
98
- <section class="tab-panel" id="tab-targets">
99
- <p class="muted">Ranked drill targets (CPI ≥ 0.25, at least 250 m apart). Click a row to show it on the map.</p>
100
- <div class="table-wrap"><table id="targets-table"></table></div>
101
- </section>
102
-
103
- <section class="tab-panel" id="tab-validation">
104
- <div id="validation"></div>
105
- </section>
106
-
107
- <section class="tab-panel" id="tab-download">
108
- <div class="download-head">
109
- <div>
110
- <h2>Download results</h2>
111
- <p class="muted small">Rasters, Shapefiles and GeoPackages are in the area's UTM zone; GeoJSON is in WGS84.</p>
112
- </div>
113
- <div class="formats" id="formats"></div>
114
- <button id="download-btn" class="btn primary">Download selected (.zip)</button>
115
- </div>
116
- <div id="download-groups" class="download-groups"></div>
117
- </section>
118
-
119
- <section class="tab-panel" id="tab-admin"></section>
120
-
121
- <section class="tab-panel" id="tab-guide">
122
- <article class="guide">
123
- <h1>How GeoScan works</h1>
124
- <p class="lead">GeoScan ranks ground by how likely it is to host copper, using only satellite data.
125
- It finds the surface signs that copper-bearing fluids leave behind and the structures those fluids used.
126
- It does not see copper itself. Every result is a surface-evidence ranking to guide field checks,
127
- geophysics and drilling.</p>
128
-
129
- <h2>1. The four alteration minerals</h2>
130
- <p>Hot fluids that carry copper change the rocks they pass through. Four signs are mapped:</p>
131
- <table>
132
- <tr><th>Layer</th><th>What it is</th><th>Satellite and signal</th></tr>
133
- <tr><td><b>Muscovite / sericite</b></td><td>White mica (phyllic alteration), usually closest to the ore</td><td>EMIT, Al-OH absorption near 2200 nm</td></tr>
134
- <tr><td><b>Chlorite / epidote</b></td><td>Green minerals (propylitic alteration), usually an outer halo</td><td>EMIT, Fe-OH (2250 nm) and Mg-OH (2330 nm) absorptions</td></tr>
135
- <tr><td><b>Gossan / iron oxide</b></td><td>Rusty iron staining, the weathered cap of sulphides</td><td>EMIT, Fe³⁺ absorption near 900 nm</td></tr>
136
- <tr><td><b>Quartz / silicification</b></td><td>Silica added by the fluids</td><td>ASTER thermal emissivity (90 m)</td></tr>
137
- </table>
138
- <p>For each EMIT pixel the depth of the absorption and its uncertainty are measured on every usable date.
139
- A pixel counts as a detection only when the absorption is clearly larger than its uncertainty.
140
- Detections are then spread into smooth zones (a sharp core plus a wider halo) and scaled so that
141
- <b>100 % = the strongest 2 % of this area</b>. Percentages are relative within one area.</p>
142
-
143
- <h2>2. Structures (lineaments)</h2>
144
- <p>Fluids rise along faults, fractures and shear zones. Straight features are traced from an elevation
145
- model: hillshades from 8 sun directions plus valley and ridge axes. Lines shorter than 800 m are dropped.
146
- Cliff and plateau edges (<i>scarps</i>) are kept on the map but not used for scoring.
147
- Lineaments come from terrain, so <b>buried structures under flat ground can be missed</b>. Aeromagnetic data is
148
- the standard tool for those.</p>
149
-
150
- <h2>3. The Copper Prospectivity Index (CPI)</h2>
151
- <p class="formula">H = 0.30·muscovite + 0.28·quartz + 0.22·gossan + 0.20·chlorite<br>
152
- CPI = 0.70·H + 0.30·exp(−distance to lineament / 250 m)</p>
153
- <p>These are the default weights. All of them can be changed under <i>Adjust parameters</i>.
154
- CPI is shown 0-100 %, with contours at 28 % (Tier 3), 50 % (Tier 2) and 70 % (Tier 1).</p>
155
-
156
- <h2>4. Drill targets</h2>
157
- <p>Every spot is ranked by CPI. The best is Target A. Each next target must be at least 250 m from every
158
- target already chosen, so each anomaly gets about one target instead of the top ranks all sitting on one hot spot.
159
- Up to 50 targets with CPI ≥ 25 % are listed. Tier 1 → diamond core drilling; Tier 2 → RC drilling or trenching;
160
- Tier 3 → field mapping and rock-chip sampling.</p>
161
-
162
- <h2>5. Reading the maps</h2>
163
- <ul>
164
- <li><b>Hatched grey areas = "Not assessed".</b> EMIT had no usable view of the ground there. This does
165
- <b>not</b> mean there is no alteration. In those areas the CPI uses quartz and structure only.</li>
166
- <li><b>Contour lines</b> mark the zone boundaries shown in each map's legend.</li>
167
- <li>Click any target on the map for its scores. Click a row in <i>Targets</i> to jump to it.</li>
168
- <li><b>Reference geophysics</b> (magnetic anomaly, Bouguer gravity) is listed under the map layers where ground
169
- stations exist for the area. Diamonds are the measured stations; the coloured surface and contours are
170
- interpolated between them. These layers are for viewing and download only: <b>they are not used in the CPI
171
- or the target ranking</b>. The stations are typically about 1 km apart, so they show the regional pattern
172
- and cannot confirm or reject a single target.</li>
173
- </ul>
174
-
175
- <h2>6. Using the app</h2>
176
- <ol>
177
- <li><b>Area:</b> pick an area or upload a polygon (KML, KMZ, GeoJSON). New areas download their data from Google Earth Engine on the first run.</li>
178
- <li><b>Method options:</b> ★ marks the settings that tested best. Results already computed open at once.</li>
179
- <li><b>Adjust parameters:</b> every hand-chosen number, with its default and an <span class="ibtn-demo">i</span> explanation.
180
- Change values and press <i>Run analysis</i>. Only the affected steps are recomputed, and the result is saved
181
- and listed so you can compare it with the others.</li>
182
- <li><b>Download:</b> choose maps, rasters, contour lines, vectors (Shapefile / GeoJSON / GeoPackage) and tables. You get one zip.</li>
183
- </ol>
184
-
185
- <h2>7. Where each number comes from</h2>
186
- <table>
187
- <tr><td><span class="tag">LIT</span></td><td>Published literature</td></tr>
188
- <tr><td><span class="tag">LIB</span></td><td>Spectral library or product documentation</td></tr>
189
- <tr><td><span class="tag">EXP</span></td><td>Expert choice with no external source. Most worth reviewing.</td></tr>
190
- <tr><td><span class="tag">ASM</span></td><td>Project assumption</td></tr>
191
- <tr><td><span class="tag">TEST</span></td><td>Checked against ground truth</td></tr>
192
- </table>
193
-
194
- <h2>8. What the validation numbers mean</h2>
195
- <p><b>AUC</b> is the chance that a known mineralised spot scores higher than a random non-mineralised one.
196
- 0.5 = no better than chance, 1.0 = perfect. At Thanewasna the CPI is tested against 30 mapped Cu lodes
197
- and 47 boreholes. Kelipur has no lode or borehole coordinates, so its checks are consistency tests against
198
- NGDR explored blocks and the GSI geology map.</p>
199
-
200
- <h2>9. Known limits</h2>
201
- <ul>
202
- <li>An Al-OH absorption at 2190-2215 nm is read as white mica, but kaolinite and smectite absorb at similar positions.</li>
203
- <li>Iron oxide from satellites cannot separate a true gossan from laterite or ferruginous soil.</li>
204
- <li>The weights follow porphyry-style zoning. The deposit type (for example VMS at Kelipur) may call for different weights.</li>
205
- <li>The quartz layer is coarse (90 m) and the silica ratio SI has no verified primary source.</li>
206
- </ul>
207
- </article>
208
- </section>
209
- </main>
210
- </div>
211
-
212
- <template id="admin-template">
213
- <div class="admin">
214
- <div class="admin-card">
215
- <h2>Users</h2>
216
- <p class="muted small">Each person signs in with their own user name and password. Passwords are stored scrambled;
217
- they cannot be read back, only replaced. Tell users that their activity on this site is recorded.</p>
218
- <div class="table-wrap"><table id="users-table"></table></div>
219
- <form id="user-form" class="user-form">
220
- <input type="text" id="new-user" placeholder="user name" autocomplete="off" required>
221
- <input type="password" id="new-pass" placeholder="password (8+ characters)" autocomplete="new-password" required>
222
- <input type="text" id="new-note" placeholder="note, e.g. name / organisation">
223
- <button class="btn primary" type="submit">Add or replace user</button>
224
- </form>
225
- <div id="user-error" class="error"></div>
226
- </div>
227
- <div class="admin-card">
228
- <div class="admin-head">
229
- <h2>Activity log</h2>
230
- <select id="log-user"><option value="">All users</option></select>
231
- <select id="log-action"><option value="">All actions</option></select>
232
- <button class="btn ghost" id="log-refresh" type="button">Refresh</button>
233
- <a class="btn ghost" href="/api/admin/log.csv" download>Download CSV</a>
234
- </div>
235
- <div class="table-wrap"><table id="log-table"></table></div>
236
- </div>
237
- </div>
238
- </template>
239
-
240
- <dialog id="new-area">
241
- <form method="dialog" id="new-area-form">
242
- <h2>Upload a new area</h2>
243
- <p class="muted small">A polygon as KML, KMZ or GeoJSON. Satellite data are fetched from Google Earth Engine when you run the analysis
244
- (a 100 km² area takes roughly 20–40 minutes the first time).</p>
245
- <label>File <input type="file" id="area-file" accept=".kml,.kmz,.geojson,.json" required></label>
246
- <label>Name <input type="text" id="area-name" placeholder="e.g. betul_north" maxlength="40"></label>
247
- <div id="area-error" class="error"></div>
248
- <div class="dialog-actions">
249
- <button type="button" class="btn ghost" id="area-cancel">Cancel</button>
250
- <button type="submit" class="btn primary" id="area-upload">Upload</button>
251
- </div>
252
- </form>
253
- </dialog>
254
-
255
- <dialog id="info" class="info-dialog">
256
- <div class="info-head"><h2 id="info-title"></h2><button class="btn ghost" id="info-close" aria-label="Close">✕</button></div>
257
- <div id="info-body"></div>
258
- </dialog>
259
-
260
- <dialog id="lightbox"><img id="lightbox-img" alt=""><button class="btn ghost" id="lightbox-close">Close</button></dialog>
261
-
262
- <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
263
- <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
264
- <script src="/static/app.js"></script>
265
- </body>
266
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>GeoScan Copper Prospectivity</title>
7
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
8
+ <link rel="stylesheet" href="/static/style.css">
9
+ </head>
10
+ <body>
11
+ <header class="topbar">
12
+ <div class="brand"><span class="logo">◆</span> GeoScan <span class="sub">Copper prospectivity from satellite data</span></div>
13
+ <nav class="tabs" id="tabs">
14
+ <button data-tab="map" class="active">Map</button>
15
+ <button data-tab="contours">Contour maps</button>
16
+ <button data-tab="targets">Zones</button>
17
+ <button data-tab="validation">Validation</button>
18
+ <button data-tab="download">Download</button>
19
+ <button data-tab="guide">Guide</button>
20
+ <button data-tab="admin" id="admin-tab-btn" hidden>Admin</button>
21
+ </nav>
22
+ <div id="whoami" class="whoami" hidden></div>
23
+ </header>
24
+
25
+ <div class="layout">
26
+ <aside class="sidebar">
27
+ <div class="side-scroll">
28
+ <section class="card">
29
+ <h2>Area</h2>
30
+ <select id="block-select"></select>
31
+ <div id="block-info" class="muted small"></div>
32
+ <button id="new-area-btn" class="btn ghost full">+ Upload a new area (KML / KMZ / GeoJSON)</button>
33
+ </section>
34
+
35
+ <section class="card">
36
+ <h2>Method options</h2>
37
+ <div id="options"></div>
38
+ <div class="muted small">★ = recommended (tested best on both blocks)</div>
39
+ <div id="run-status" class="status"></div>
40
+ <div id="computed" class="computed"></div>
41
+ </section>
42
+
43
+ <section class="card">
44
+ <details id="params-box">
45
+ <summary><h2 class="inline">Adjust parameters</h2> <span id="params-changed" class="badge" hidden></span></summary>
46
+ <p class="muted small">Values the method chose by hand. Tags: <b>EXP</b> expert choice, <b>ASM</b> project assumption,
47
+ <b>LIB</b> spectral library. Change any value, then press <i>Run analysis</i> in the bar at the bottom; only the affected steps are recomputed.</p>
48
+ <div id="params"></div>
49
+ <button type="button" id="params-reset" class="btn ghost full">Reset all to defaults</button>
50
+ </details>
51
+ </section>
52
+
53
+ </div>
54
+
55
+ <div class="side-footer">
56
+ <section id="job-card" class="footer-block" hidden>
57
+ <h2>Analysis in progress</h2>
58
+ <div id="job-title" class="small"></div>
59
+ <div class="bar"><div id="job-bar"></div></div>
60
+ <div id="job-step" class="small"></div>
61
+ <details><summary class="small">Log</summary><pre id="job-log"></pre></details>
62
+ </section>
63
+ <div id="run-bar" class="footer-block run-bar" hidden>
64
+ <div id="run-bar-text" class="small"></div>
65
+ <div class="run-bar-actions">
66
+ <button id="run-discard" class="btn ghost" type="button" title="Go back to the results you were viewing">Discard changes</button>
67
+ <button id="run-btn" class="btn primary">Run analysis</button>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </aside>
72
+
73
+ <main class="content">
74
+ <section class="tab-panel active" id="tab-map">
75
+ <div id="map"></div>
76
+ <div class="map-panel" id="map-panel">
77
+ <h3>Layer</h3>
78
+ <div id="layer-radios"></div>
79
+ <label class="slider">Opacity <input type="range" id="opacity" min="0" max="1" step="0.05" value="0.8"></label>
80
+ <h3>Show</h3>
81
+ <label><input type="checkbox" id="show-contours" checked> Contour lines</label>
82
+ <label><input type="checkbox" id="show-zones" checked> Prospective zones</label>
83
+ <label><input type="checkbox" id="show-targets" checked> Field-check locations</label>
84
+ <label><input type="checkbox" id="show-lineaments" checked> Lineaments</label>
85
+ <label><input type="checkbox" id="show-na" checked> Not assessed (no usable EMIT data)</label>
86
+ <h3>Base map</h3>
87
+ <label><input type="radio" name="base" value="sat" checked> Satellite</label>
88
+ <label><input type="radio" name="base" value="topo"> Topographic</label>
89
+ </div>
90
+ <div class="legend" id="legend"></div>
91
+ </section>
92
+
93
+ <section class="tab-panel" id="tab-contours">
94
+ <p class="muted">Publication-style contour maps for the selected area and method options. Click a map to enlarge.</p>
95
+ <div class="gallery" id="gallery"></div>
96
+ </section>
97
+
98
+ <section class="tab-panel" id="tab-targets">
99
+ <h2 class="tab-h">Prospective zones</h2>
100
+ <p class="muted">The main result: ranked regions where the evidence is strongest. The method predicts regions, not exact
101
+ points. Click a row to show the zone on the map.</p>
102
+ <div id="zones-note" class="empty" hidden>These results were made before zones were introduced. Run the analysis again to get zones.</div>
103
+ <div class="table-wrap"><table id="zones-table"></table></div>
104
+ <h2 class="tab-h">Suggested field-check locations</h2>
105
+ <p class="muted">Starting points for field work inside the zones (highest CPI, at least 250 m apart). Their exact
106
+ position is not significant; they are not drill collars. Click a row to show it on the map.</p>
107
+ <div class="table-wrap"><table id="targets-table"></table></div>
108
+ </section>
109
+
110
+ <section class="tab-panel" id="tab-validation">
111
+ <div id="validation"></div>
112
+ </section>
113
+
114
+ <section class="tab-panel" id="tab-download">
115
+ <div class="download-head">
116
+ <div>
117
+ <h2>Download results</h2>
118
+ <p class="muted small">Rasters, Shapefiles and GeoPackages are in the area's UTM zone; GeoJSON is in WGS84.</p>
119
+ </div>
120
+ <div class="formats" id="formats"></div>
121
+ <button id="download-btn" class="btn primary">Download selected (.zip)</button>
122
+ </div>
123
+ <div id="download-groups" class="download-groups"></div>
124
+ </section>
125
+
126
+ <section class="tab-panel" id="tab-admin"></section>
127
+
128
+ <section class="tab-panel" id="tab-guide">
129
+ <article class="guide">
130
+ <h1>How GeoScan works</h1>
131
+ <p class="lead">GeoScan ranks ground by how likely it is to host copper, using only satellite data.
132
+ It finds the surface signs that copper-bearing fluids leave behind and the structures those fluids used.
133
+ It does not see copper itself. Every result is a surface-evidence ranking to guide field checks,
134
+ geophysics and drilling.</p>
135
+
136
+ <h2>1. The four alteration minerals</h2>
137
+ <p>Hot fluids that carry copper change the rocks they pass through. Four signs are mapped:</p>
138
+ <table>
139
+ <tr><th>Layer</th><th>What it is</th><th>Satellite and signal</th></tr>
140
+ <tr><td><b>Muscovite / sericite</b></td><td>White mica (phyllic alteration), usually closest to the ore</td><td>EMIT, Al-OH absorption near 2200 nm</td></tr>
141
+ <tr><td><b>Chlorite / epidote</b></td><td>Green minerals (propylitic alteration), usually an outer halo</td><td>EMIT, Fe-OH (2250 nm) and Mg-OH (2330 nm) absorptions</td></tr>
142
+ <tr><td><b>Gossan / iron oxide</b></td><td>Rusty iron staining, the weathered cap of sulphides</td><td>EMIT, Fe³⁺ absorption near 900 nm</td></tr>
143
+ <tr><td><b>Quartz / silicification</b></td><td>Silica added by the fluids</td><td>ASTER thermal emissivity (90 m)</td></tr>
144
+ </table>
145
+ <p>For each EMIT pixel the depth of the absorption and its uncertainty are measured on every usable date.
146
+ A pixel counts as a detection only when the absorption is clearly larger than its uncertainty.
147
+ Detections are then spread into smooth zones (a sharp core plus a wider halo) and scaled so that
148
+ <b>100 % = the strongest 2 % of this area</b>. Percentages are relative within one area.</p>
149
+
150
+ <h2>2. Structures (lineaments)</h2>
151
+ <p>Fluids rise along faults, fractures and shear zones. Straight features are traced from an elevation
152
+ model: hillshades from 8 sun directions plus valley and ridge axes. Lines shorter than 800 m are dropped.
153
+ Cliff and plateau edges (<i>scarps</i>) are kept on the map but not used for scoring.
154
+ Lineaments come from terrain, so <b>buried structures under flat ground can be missed</b>. Aeromagnetic data is
155
+ the standard tool for those.</p>
156
+
157
+ <h2>3. The Copper Prospectivity Index (CPI)</h2>
158
+ <p class="formula">H = 0.30·muscovite + 0.28·quartz + 0.22·gossan + 0.20·chlorite<br>
159
+ CPI = 0.70·H + 0.30·exp(−distance to lineament / 250 m)</p>
160
+ <p>These are the default weights. All of them can be changed under <i>Adjust parameters</i>.
161
+ CPI is shown 0-100 %, with contours at 28 % (Tier 3), 50 % (Tier 2) and 70 % (Tier 1).</p>
162
+
163
+ <h2>4. Prospective zones and field-check locations</h2>
164
+ <p><b>The result of this method is a region, not a point.</b> The satellite pixels are 60-90 m, each detection is
165
+ spread over a few hundred metres, and the data describe the surface while the ore is underground. So the method
166
+ can say "the mineralisation is probably somewhere in this zone", but it cannot say "drill exactly here".</p>
167
+ <p><b>Prospective zones</b> are the main result: connected ground with CPI at or above the Tier 2 threshold (50 %).
168
+ A zone is Tier 1 if it reaches 70 % somewhere, otherwise Tier 2. Separate weak anomalies that only reach 28 % are
169
+ listed as Tier 3 zones. The 28 % contour around a zone is its outer halo. Zones are ranked by tier, then by peak CPI.</p>
170
+ <p><b>Suggested field-check locations</b> are starting points for field work inside the zones: the highest-CPI
171
+ cells, kept at least 250 m apart. Their exact position is not significant, and <b>they are not drill collars</b>.
172
+ Follow-up is staged: Tier 1 → detailed mapping, systematic sampling and ground geophysics; Tier 2 → reconnaissance
173
+ mapping and sampling; Tier 3 → check during regional traverses. Drilling decisions come only after that work.</p>
174
+ <p>Validation supports this: at Thanewasna the Tier 1 zone covers 17.5 % of the block and holds 53 % of the known
175
+ lode area and 6 of the 8 boreholes that intersect a lode, while only 3 of the top 10 point locations lie within
176
+ 100 m of a lode.</p>
177
+
178
+ <h2>5. Reading the maps</h2>
179
+ <ul>
180
+ <li><b>Hatched grey areas = "Not assessed".</b> EMIT had no usable view of the ground there. This does
181
+ <b>not</b> mean there is no alteration. In those areas the CPI uses quartz and structure only.</li>
182
+ <li><b>Contour lines</b> mark the zone boundaries shown in each map's legend.</li>
183
+ <li>Click a zone or a field-check location on the map for its details. Click a row in <i>Zones</i> to jump to it.</li>
184
+ <li><b>Reference geophysics</b> (magnetic anomaly, Bouguer gravity) is listed under the map layers where ground
185
+ stations exist for the area. Diamonds are the measured stations; the coloured surface and contours are
186
+ interpolated between them. These layers are for viewing and download only: <b>they are not used in the CPI
187
+ or the zone ranking</b>. The stations are typically about 1 km apart, so they show the regional pattern
188
+ and cannot confirm or reject a single zone.</li>
189
+ </ul>
190
+
191
+ <h2>6. Using the app</h2>
192
+ <ol>
193
+ <li><b>Area:</b> pick an area or upload a polygon (KML, KMZ, GeoJSON). New areas download their data from Google Earth Engine on the first run.</li>
194
+ <li><b>Method options:</b> ★ marks the settings that tested best. Results already computed open at once.</li>
195
+ <li><b>Adjust parameters:</b> every hand-chosen number, with its default and an <span class="ibtn-demo">i</span> explanation.
196
+ Change values and press <i>Run analysis</i>. Only the affected steps are recomputed, and the result is saved
197
+ and listed so you can compare it with the others.</li>
198
+ <li><b>Download:</b> choose maps, rasters, contour lines, vectors (Shapefile / GeoJSON / GeoPackage) and tables. You get one zip.</li>
199
+ </ol>
200
+
201
+ <h2>7. Where each number comes from</h2>
202
+ <table>
203
+ <tr><td><span class="tag">LIT</span></td><td>Published literature</td></tr>
204
+ <tr><td><span class="tag">LIB</span></td><td>Spectral library or product documentation</td></tr>
205
+ <tr><td><span class="tag">EXP</span></td><td>Expert choice with no external source. Most worth reviewing.</td></tr>
206
+ <tr><td><span class="tag">ASM</span></td><td>Project assumption</td></tr>
207
+ <tr><td><span class="tag">TEST</span></td><td>Checked against ground truth</td></tr>
208
+ </table>
209
+
210
+ <h2>8. What the validation numbers mean</h2>
211
+ <p><b>AUC</b> is the chance that a known mineralised spot scores higher than a random non-mineralised one.
212
+ 0.5 = no better than chance, 1.0 = perfect. At Thanewasna the CPI is tested against 30 mapped Cu lodes
213
+ and 47 boreholes. Kelipur has no lode or borehole coordinates, so its checks are consistency tests against
214
+ NGDR explored blocks and the GSI geology map.</p>
215
+
216
+ <h2>9. Known limits</h2>
217
+ <ul>
218
+ <li><b>The method predicts regions, not exact points.</b> Use the zones to decide where to work, and treat the
219
+ point locations only as places to start.</li>
220
+ <li>An Al-OH absorption at 2190-2215 nm is read as white mica, but kaolinite and smectite absorb at similar positions.</li>
221
+ <li>Iron oxide from satellites cannot separate a true gossan from laterite or ferruginous soil.</li>
222
+ <li>The weights follow porphyry-style zoning. The deposit type (for example VMS at Kelipur) may call for different weights.</li>
223
+ <li>The quartz layer is coarse (90 m) and the silica ratio SI has no verified primary source.</li>
224
+ </ul>
225
+ </article>
226
+ </section>
227
+ </main>
228
+ </div>
229
+
230
+ <template id="admin-template">
231
+ <div class="admin">
232
+ <div class="admin-card">
233
+ <h2>Users</h2>
234
+ <p class="muted small">Each person signs in with their own user name and password. Passwords are stored scrambled;
235
+ they cannot be read back, only replaced. Tell users that their activity on this site is recorded.</p>
236
+ <div class="table-wrap"><table id="users-table"></table></div>
237
+ <form id="user-form" class="user-form">
238
+ <input type="text" id="new-user" placeholder="user name" autocomplete="off" required>
239
+ <input type="password" id="new-pass" placeholder="password (8+ characters)" autocomplete="new-password" required>
240
+ <input type="text" id="new-note" placeholder="note, e.g. name / organisation">
241
+ <button class="btn primary" type="submit">Add or replace user</button>
242
+ </form>
243
+ <div id="user-error" class="error"></div>
244
+ </div>
245
+ <div class="admin-card">
246
+ <div class="admin-head">
247
+ <h2>Activity log</h2>
248
+ <select id="log-user"><option value="">All users</option></select>
249
+ <select id="log-action"><option value="">All actions</option></select>
250
+ <button class="btn ghost" id="log-refresh" type="button">Refresh</button>
251
+ <a class="btn ghost" href="/api/admin/log.csv" download>Download CSV</a>
252
+ </div>
253
+ <div class="table-wrap"><table id="log-table"></table></div>
254
+ </div>
255
+ </div>
256
+ </template>
257
+
258
+ <dialog id="new-area">
259
+ <form method="dialog" id="new-area-form">
260
+ <h2>Upload a new area</h2>
261
+ <p class="muted small">A polygon as KML, KMZ or GeoJSON. Satellite data are fetched from Google Earth Engine when you run the analysis
262
+ (a 100 km² area takes roughly 20–40 minutes the first time).</p>
263
+ <label>File <input type="file" id="area-file" accept=".kml,.kmz,.geojson,.json" required></label>
264
+ <label>Name <input type="text" id="area-name" placeholder="e.g. betul_north" maxlength="40"></label>
265
+ <div id="area-error" class="error"></div>
266
+ <div class="dialog-actions">
267
+ <button type="button" class="btn ghost" id="area-cancel">Cancel</button>
268
+ <button type="submit" class="btn primary" id="area-upload">Upload</button>
269
+ </div>
270
+ </form>
271
+ </dialog>
272
+
273
+ <dialog id="info" class="info-dialog">
274
+ <div class="info-head"><h2 id="info-title"></h2><button class="btn ghost" id="info-close" aria-label="Close">✕</button></div>
275
+ <div id="info-body"></div>
276
+ </dialog>
277
+
278
+ <dialog id="lightbox"><img id="lightbox-img" alt=""><button class="btn ghost" id="lightbox-close">Close</button></dialog>
279
+
280
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
281
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
282
+ <script src="/static/app.js"></script>
283
+ </body>
284
+ </html>
geoscan/geoscan/webapp/static/style.css CHANGED
@@ -198,3 +198,9 @@ h2.inline { display: inline; margin: 0; }
198
 
199
  .stn { display: inline-block; width: 11px; height: 11px; transform: rotate(45deg); border: 1.5px solid #000;
200
  box-shadow: 0 0 0 1px rgba(255,255,255,.7); flex: none; }
 
 
 
 
 
 
 
198
 
199
  .stn { display: inline-block; width: 11px; height: 11px; transform: rotate(45deg); border: 1.5px solid #000;
200
  box-shadow: 0 0 0 1px rgba(255,255,255,.7); flex: none; }
201
+
202
+ .tab-h { font-size: 17px; margin: 4px 0 4px; }
203
+ .tab-h:not(:first-child) { margin-top: 26px; }
204
+ .zone-label { background: #fff; border: 1px solid #000; border-radius: 4px; padding: 0 4px; font-size: 11px; font-weight: 700;
205
+ color: #000; box-shadow: none; }
206
+ .zone-label::before { display: none; }
geoscan/scripts/validate.py CHANGED
@@ -16,6 +16,7 @@ Kelipur no borehole / lode coordinates are available, so: CPI on Precambrian
16
  from __future__ import annotations
17
 
18
  import argparse
 
19
  import os
20
  import sys
21
  from pathlib import Path
@@ -39,7 +40,7 @@ from compare_methods import geoscan_layers # noqa: E402
39
  from geoscan.aoi import load_aoi # noqa: E402
40
  from geoscan.grid import grid_for_aoi # noqa: E402
41
  from geoscan.raster_io import aoi_mask # noqa: E402
42
- from geoscan.validation import bootstrap_auc, captured_at, success_rate, target_hits # noqa: E402
43
 
44
  ROOT = Path(os.environ.get("GEOSCAN_ROOT") or HERE.parents[1])
45
  LAYERS = ("cpi", "gossan", "muscovite", "chlorite", "quartz", "structure")
@@ -61,6 +62,21 @@ def _auc(arr, pos, neg, valid, cap=3000, n=1000):
61
  return bootstrap_auc(p, q, n=n)
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def _setup(name):
65
  aoi = load_aoi(next(p for p in (ROOT / "data" / name / "aoi").glob("*.geojson") if p.name != "aoi.geojson"), name)
66
  grid = grid_for_aoi(aoi, 15.0, 180.0)
@@ -68,8 +84,12 @@ def _setup(name):
68
  inside = aoi_mask(grid, aoi_utm)
69
  src = ROOT / "outputs" / (OUTPUT or name)
70
  G = geoscan_layers(src, grid, inside)
71
- targets = pd.read_csv(src / "tables/top50_targets.csv")
72
- return aoi, grid, aoi_utm, inside, G, targets
 
 
 
 
73
 
74
 
75
  def _panel(ax, grid, arr, inside, aoi, title):
@@ -94,7 +114,7 @@ def _panel(ax, grid, arr, inside, aoi, title):
94
 
95
  def thanewasna(out: Path):
96
  name = "thanewasna"
97
- aoi, grid, aoi_utm, inside, G, targets = _setup(name)
98
  gt = ROOT / "data" / name / "ground_truth"
99
  lodes = gpd.read_file(gt / "cu_lodes_mineral_zone.geojson").to_crs(grid.crs)
100
  lode = rasterize([(g, 1) for g in lodes.geometry], out_shape=grid.shape, transform=grid.transform,
@@ -119,7 +139,13 @@ def thanewasna(out: Path):
119
  xy = targets[["Easting", "Northing"]].to_numpy()
120
  hits = pd.DataFrame([{"subset": "top 10", **target_hits(xy[:10], lodes.geometry, aoi_utm)},
121
  {"subset": "all", **target_hits(xy, lodes.geometry, aoi_utm)}])
122
- hits.to_csv(out / "tables/target_hits.csv", index=False)
 
 
 
 
 
 
123
  curve = success_rate(G["cpi"], lode, inside & np.isfinite(G["cpi"]))
124
  curve.iloc[::5].to_csv(out / "tables/success_rate_curve.csv", index=False)
125
 
@@ -130,8 +156,11 @@ def thanewasna(out: Path):
130
  linewidths=0.4, zorder=6, label="Borehole: halo only")
131
  ax.scatter(bh.loc[bh.lode, "BH_Lon"], bh.loc[bh.lode, "BH_Lat"], s=34, c="#ff1744", edgecolors="black",
132
  marker="D", zorder=7, label="Borehole: intersects Cu lode")
133
- ax.scatter(targets.Longitude[:10], targets.Latitude[:10], s=110, marker="*", c="white", edgecolors="#d50000",
134
- zorder=8, label="Top-10 targets")
 
 
 
135
  ax.plot([], [], color="#00e676", lw=2, label="Cu lode (MECL Mineral_Zone_DM)")
136
  ax.legend(loc="lower left", fontsize=7.5, framealpha=0.9)
137
  fig.colorbar(cf, ax=ax, orientation="horizontal", pad=0.08, shrink=0.8, label="CPI (%)")
@@ -161,11 +190,21 @@ def thanewasna(out: Path):
161
  f"by CPI contains {100 * c.captured_top10pct:.0f} / {100 * c.captured_top20pct:.0f} / "
162
  f"{100 * c.captured_top30pct:.0f}% of the lode area.",
163
  f"- **CPI at borehole collars:** AUC {c.borehole_auc:.2f} (95% CI {c.borehole_ci}), permutation p = {c.borehole_p:.3f}.",
164
- f"- **Targets:** {h.loc['top 10', 'hits_within_100m']} of the top 10 lie within 100 m of a lode "
 
165
  f"(expected at random: {h.loc['top 10', 'expected_random_100m']}); "
166
  f"{h.loc['all', 'hits_within_100m']} of all {h.loc['all', 'n_targets']} "
167
- f"(expected {h.loc['all', 'expected_random_100m']}).\n",
168
- "| Layer | Lode AUC (95% CI) | Borehole AUC (95% CI) |", "|---|---|---|"]
 
 
 
 
 
 
 
 
 
169
  lines += [f"| {r.layer} | {r.lode_auc:.2f} ({r.lode_ci}) | {r.borehole_auc:.2f} ({r.borehole_ci}) |"
170
  for r in res.itertuples()]
171
  lines += ["\nAUC = probability that a mineralised location scores higher than a non-mineralised one; 0.5 = no skill. "
@@ -176,7 +215,7 @@ def thanewasna(out: Path):
176
 
177
  def kelipur(out: Path):
178
  name = "kelipur"
179
- aoi, grid, aoi_utm, inside, G, targets = _setup(name)
180
  gt = ROOT / "data" / name / "ground_truth"
181
  lith = gpd.read_file(gt / "lithology_gsi_cro19966.geojson").to_crs(grid.crs)
182
  lith["group"] = lith["LITHOLOGICAL_UNIT"].map(lith_group)
@@ -204,20 +243,31 @@ def kelipur(out: Path):
204
  a = _auc(G["cpi"], m, ~m, inside)
205
  n_in = int(sum(poly.contains(Point(x, y)) for x, y in xy))
206
  brow.append({"report": rid, "area": label, "area_km2": round(poly.area / 1e6, 2), "cpi_auc": a["auc"],
207
- "ci": f"{a['ci_low']:.2f}–{a['ci_high']:.2f}", "targets_inside": n_in,
208
- "targets_expected_at_random": round(len(xy) * poly.area / aoi_utm.area, 1)})
 
 
209
  blocks = pd.DataFrame(brow)
210
  blocks.to_csv(out / "tables/ngdr_exploration_blocks.csv", index=False)
 
 
 
 
 
 
 
 
 
211
 
212
  tc = np.clip(((xy[:, 0] - grid.x0) / grid.res).astype(int), 0, grid.width - 1)
213
  tr = np.clip(((grid.y0 - xy[:, 1]) / grid.res).astype(int), 0, grid.height - 1)
214
  on = cls[tr, tc]
215
  share = {g: (cls == g).sum() / inside.sum() for g in (COVER, NONHOST, HOST)}
216
- tl = pd.DataFrame([{"lithology_group": n, "targets": int((on == g).sum()),
217
  "expected_by_area": round(share[g] * len(xy), 1)}
218
  for g, n in ((HOST, "granitic / metapelite"), (NONHOST, "amphibolite / mafic–ultramafic"),
219
  (COVER, "Deccan trap / infra-trap cover"))])
220
- tl.to_csv(out / "tables/targets_by_lithology.csv", index=False)
221
 
222
  fig, ax = plt.subplots(figsize=(11, 7.5), dpi=170)
223
  cf = _panel(ax, grid, G["cpi"], inside, aoi, "Kelipur — CPI with NGDR exploration blocks and GSI Deccan cover")
@@ -227,8 +277,11 @@ def kelipur(out: Path):
227
  ax.plot([], [], color=col, lw=2, label=f"{rid}: {label}")
228
  lith[lith["group"] == COVER].to_crs(4326).boundary.plot(ax=ax, color="white", linewidth=1.0, ls="--", zorder=5)
229
  ax.plot([], [], color="white", lw=1.5, ls="--", label="Deccan trap / infra-trap (GSI)")
230
- ax.scatter(targets.Longitude[:10], targets.Latitude[:10], s=110, marker="*", c="white", edgecolors="#d50000",
231
- zorder=8, label="Top-10 targets")
 
 
 
232
  ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1), fontsize=7.5, framealpha=0.9)
233
  fig.colorbar(cf, ax=ax, orientation="horizontal", pad=0.08, shrink=0.6, label="CPI (%)")
234
  fig.tight_layout()
@@ -241,11 +294,21 @@ def kelipur(out: Path):
241
  "block. The checks below ask whether the results are consistent with independent GSI information.\n",
242
  f"- **CPI on Precambrian basement vs Deccan / infra-trap cover** (GSI CRO-19966-1987; the cover is younger "
243
  f"than the mineralisation): AUC {c.basement_vs_cover_auc:.2f} (95% CI {c.ci}). "
244
- f"{int(tl.loc[2, 'targets'])} of {len(xy)} targets fall on cover (expected at random: {tl.loc[2, 'expected_by_area']}).",
 
245
  "- **CPI inside NGDR exploration-block footprints vs the rest of the block:**\n",
246
- "| NGDR report | Area | km² | CPI AUC (95% CI) | Targets inside (random) |", "|---|---|---|---|---|"]
247
- lines += [f"| {r.report} | {r.area} | {r.area_km2} | {r.cpi_auc:.2f} ({r.ci}) | {r.targets_inside} "
248
- f"({r.targets_expected_at_random}) |" for r in blocks.itertuples()]
 
 
 
 
 
 
 
 
 
249
  lines += ["\nNGDR footprints are areas that were explored, not ore outlines, and they were chosen partly because of "
250
  "surface signs (e.g. gossans) that this method also detects. AUC 0.5 = no difference from the rest of the block."]
251
  return lines
 
16
  from __future__ import annotations
17
 
18
  import argparse
19
+ import json
20
  import os
21
  import sys
22
  from pathlib import Path
 
40
  from geoscan.aoi import load_aoi # noqa: E402
41
  from geoscan.grid import grid_for_aoi # noqa: E402
42
  from geoscan.raster_io import aoi_mask # noqa: E402
43
+ from geoscan.validation import bootstrap_auc, captured_at, success_rate, target_hits, zone_capture # noqa: E402
44
 
45
  ROOT = Path(os.environ.get("GEOSCAN_ROOT") or HERE.parents[1])
46
  LAYERS = ("cpi", "gossan", "muscovite", "chlorite", "quartz", "structure")
 
62
  return bootstrap_auc(p, q, n=n)
63
 
64
 
65
+ def _zone_masks(zones, G, grid, inside, tier3):
66
+ """Boolean rasters for the nested zone sets (None when the run has no zone file)."""
67
+ if zones is None:
68
+ return None
69
+
70
+ def burn(sub):
71
+ return rasterize([(g, 1) for g in sub.geometry], out_shape=grid.shape, transform=grid.transform,
72
+ all_touched=True, dtype="uint8").astype(bool) & inside if len(sub) else np.zeros(grid.shape, bool)
73
+
74
+ t = zones["Priority_Tier"].str.extract(r"Tier (\d)")[0].astype(int)
75
+ return {"Tier 1 zones": burn(zones[t == 1]), "Tier 1 + 2 zones": burn(zones[t <= 2]),
76
+ "All zones (Tier 1-3)": burn(zones),
77
+ f"Outer halo (all ground with CPI >= {100 * tier3:.0f} %)": inside & (np.nan_to_num(G["cpi"]) >= tier3)}
78
+
79
+
80
  def _setup(name):
81
  aoi = load_aoi(next(p for p in (ROOT / "data" / name / "aoi").glob("*.geojson") if p.name != "aoi.geojson"), name)
82
  grid = grid_for_aoi(aoi, 15.0, 180.0)
 
84
  inside = aoi_mask(grid, aoi_utm)
85
  src = ROOT / "outputs" / (OUTPUT or name)
86
  G = geoscan_layers(src, grid, inside)
87
+ loc = src / "tables/field_check_locations.csv"
88
+ targets = pd.read_csv(loc if loc.exists() else src / "tables/top50_targets.csv")
89
+ zp = src / "vectors/prospective_zones.geojson"
90
+ zones = gpd.read_file(zp).to_crs(grid.crs) if zp.exists() else None
91
+ tier3 = json.loads((src / "manifest.json").read_text(encoding="utf-8"))["settings"]["targets"]["tier3"]
92
+ return aoi, grid, aoi_utm, inside, G, targets, zones, _zone_masks(zones, G, grid, inside, tier3)
93
 
94
 
95
  def _panel(ax, grid, arr, inside, aoi, title):
 
114
 
115
  def thanewasna(out: Path):
116
  name = "thanewasna"
117
+ aoi, grid, aoi_utm, inside, G, targets, zones, zmask = _setup(name)
118
  gt = ROOT / "data" / name / "ground_truth"
119
  lodes = gpd.read_file(gt / "cu_lodes_mineral_zone.geojson").to_crs(grid.crs)
120
  lode = rasterize([(g, 1) for g in lodes.geometry], out_shape=grid.shape, transform=grid.transform,
 
139
  xy = targets[["Easting", "Northing"]].to_numpy()
140
  hits = pd.DataFrame([{"subset": "top 10", **target_hits(xy[:10], lodes.geometry, aoi_utm)},
141
  {"subset": "all", **target_hits(xy, lodes.geometry, aoi_utm)}])
142
+ hits.to_csv(out / "tables/field_check_location_hits.csv", index=False)
143
+ zcap = None
144
+ if zmask:
145
+ zcap = zone_capture(zmask, lode, inside)
146
+ zcap["lode_boreholes_inside"] = [f"{int(m[br, bc][bh.lode.values].sum())} of {int(bh.lode.sum())}" for m in zmask.values()]
147
+ zcap["halo_boreholes_inside"] = [f"{int(m[br, bc][~bh.lode.values].sum())} of {int((~bh.lode).sum())}" for m in zmask.values()]
148
+ zcap.to_csv(out / "tables/zone_capture.csv", index=False)
149
  curve = success_rate(G["cpi"], lode, inside & np.isfinite(G["cpi"]))
150
  curve.iloc[::5].to_csv(out / "tables/success_rate_curve.csv", index=False)
151
 
 
156
  linewidths=0.4, zorder=6, label="Borehole: halo only")
157
  ax.scatter(bh.loc[bh.lode, "BH_Lon"], bh.loc[bh.lode, "BH_Lat"], s=34, c="#ff1744", edgecolors="black",
158
  marker="D", zorder=7, label="Borehole: intersects Cu lode")
159
+ if zones is not None and len(zones):
160
+ zones.to_crs(4326).boundary.plot(ax=ax, color="#00e5ff", linewidth=1.8, zorder=7)
161
+ ax.plot([], [], color="#00e5ff", lw=2, label="Prospective zone")
162
+ ax.scatter(targets.Longitude[:10], targets.Latitude[:10], s=45, marker="*", c="#ffd600", edgecolors="black",
163
+ linewidths=0.6, zorder=8, label="Suggested field-check locations (top 10)")
164
  ax.plot([], [], color="#00e676", lw=2, label="Cu lode (MECL Mineral_Zone_DM)")
165
  ax.legend(loc="lower left", fontsize=7.5, framealpha=0.9)
166
  fig.colorbar(cf, ax=ax, orientation="horizontal", pad=0.08, shrink=0.8, label="CPI (%)")
 
190
  f"by CPI contains {100 * c.captured_top10pct:.0f} / {100 * c.captured_top20pct:.0f} / "
191
  f"{100 * c.captured_top30pct:.0f}% of the lode area.",
192
  f"- **CPI at borehole collars:** AUC {c.borehole_auc:.2f} (95% CI {c.borehole_ci}), permutation p = {c.borehole_p:.3f}.",
193
+ f"- **Suggested field-check locations** (points; their exact position is not significant): "
194
+ f"{h.loc['top 10', 'hits_within_100m']} of the top 10 lie within 100 m of a lode "
195
  f"(expected at random: {h.loc['top 10', 'expected_random_100m']}); "
196
  f"{h.loc['all', 'hits_within_100m']} of all {h.loc['all', 'n_targets']} "
197
+ f"(expected {h.loc['all', 'expected_random_100m']}). The method predicts regions far better than points.\n",
198
+ ]
199
+ if zcap is not None:
200
+ lines += ["**Prospective zones against the known lodes.** Enrichment = share of lode area inside the zones divided "
201
+ "by the share of the block they cover (1.0 = no better than chance).\n",
202
+ "| Zones | % of block | % of lode area inside | Enrichment | Lode-intersecting boreholes inside | Halo-only boreholes inside |",
203
+ "|---|---|---|---|---|---|"]
204
+ lines += [f"| {r.zones} | {r.pct_of_block} | {r.pct_of_truth_captured} | {r.enrichment}× | {r.lode_boreholes_inside} | "
205
+ f"{r.halo_boreholes_inside} |" for r in zcap.itertuples()]
206
+ lines += [""]
207
+ lines += ["| Layer | Lode AUC (95% CI) | Borehole AUC (95% CI) |", "|---|---|---|"]
208
  lines += [f"| {r.layer} | {r.lode_auc:.2f} ({r.lode_ci}) | {r.borehole_auc:.2f} ({r.borehole_ci}) |"
209
  for r in res.itertuples()]
210
  lines += ["\nAUC = probability that a mineralised location scores higher than a non-mineralised one; 0.5 = no skill. "
 
215
 
216
  def kelipur(out: Path):
217
  name = "kelipur"
218
+ aoi, grid, aoi_utm, inside, G, targets, zones, zmask = _setup(name)
219
  gt = ROOT / "data" / name / "ground_truth"
220
  lith = gpd.read_file(gt / "lithology_gsi_cro19966.geojson").to_crs(grid.crs)
221
  lith["group"] = lith["LITHOLOGICAL_UNIT"].map(lith_group)
 
243
  a = _auc(G["cpi"], m, ~m, inside)
244
  n_in = int(sum(poly.contains(Point(x, y)) for x, y in xy))
245
  brow.append({"report": rid, "area": label, "area_km2": round(poly.area / 1e6, 2), "cpi_auc": a["auc"],
246
+ "ci": f"{a['ci_low']:.2f}–{a['ci_high']:.2f}", "locations_inside": n_in,
247
+ "locations_expected_at_random": round(len(xy) * poly.area / aoi_utm.area, 1),
248
+ "priority_zones_touching": int(zones[~zones["Priority_Tier"].str.contains("Tier 3")].intersects(poly).sum())
249
+ if zones is not None else None})
250
  blocks = pd.DataFrame(brow)
251
  blocks.to_csv(out / "tables/ngdr_exploration_blocks.csv", index=False)
252
+ zcap = None
253
+ if zmask:
254
+ key = ["CRO-20859-1986", "CRO-23431-2018", "CRO-22666-2009"]
255
+ explored = rasterize([(g, 1) for g in idx[idx.exp_upid.isin(key)].geometry], out_shape=grid.shape,
256
+ transform=grid.transform).astype(bool) & inside
257
+ zcap = zone_capture(zmask, explored, inside)
258
+ zcap["pct_of_zone_area_on_cover"] = [round(100 * float((m & (cls == COVER)).sum() / max(m.sum(), 1)), 1)
259
+ for m in zmask.values()]
260
+ zcap.to_csv(out / "tables/zone_capture.csv", index=False)
261
 
262
  tc = np.clip(((xy[:, 0] - grid.x0) / grid.res).astype(int), 0, grid.width - 1)
263
  tr = np.clip(((grid.y0 - xy[:, 1]) / grid.res).astype(int), 0, grid.height - 1)
264
  on = cls[tr, tc]
265
  share = {g: (cls == g).sum() / inside.sum() for g in (COVER, NONHOST, HOST)}
266
+ tl = pd.DataFrame([{"lithology_group": n, "locations": int((on == g).sum()),
267
  "expected_by_area": round(share[g] * len(xy), 1)}
268
  for g, n in ((HOST, "granitic / metapelite"), (NONHOST, "amphibolite / mafic–ultramafic"),
269
  (COVER, "Deccan trap / infra-trap cover"))])
270
+ tl.to_csv(out / "tables/field_check_locations_by_lithology.csv", index=False)
271
 
272
  fig, ax = plt.subplots(figsize=(11, 7.5), dpi=170)
273
  cf = _panel(ax, grid, G["cpi"], inside, aoi, "Kelipur — CPI with NGDR exploration blocks and GSI Deccan cover")
 
277
  ax.plot([], [], color=col, lw=2, label=f"{rid}: {label}")
278
  lith[lith["group"] == COVER].to_crs(4326).boundary.plot(ax=ax, color="white", linewidth=1.0, ls="--", zorder=5)
279
  ax.plot([], [], color="white", lw=1.5, ls="--", label="Deccan trap / infra-trap (GSI)")
280
+ if zones is not None and len(zones):
281
+ zones[~zones["Priority_Tier"].str.contains("Tier 3")].to_crs(4326).boundary.plot(ax=ax, color="#d500f9", linewidth=1.4, zorder=7)
282
+ ax.plot([], [], color="#d500f9", lw=2, label="Prospective zone (Tier 1 / 2)")
283
+ ax.scatter(targets.Longitude[:10], targets.Latitude[:10], s=45, marker="*", c="#ffd600", edgecolors="black",
284
+ linewidths=0.6, zorder=8, label="Suggested field-check locations (top 10)")
285
  ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1), fontsize=7.5, framealpha=0.9)
286
  fig.colorbar(cf, ax=ax, orientation="horizontal", pad=0.08, shrink=0.6, label="CPI (%)")
287
  fig.tight_layout()
 
294
  "block. The checks below ask whether the results are consistent with independent GSI information.\n",
295
  f"- **CPI on Precambrian basement vs Deccan / infra-trap cover** (GSI CRO-19966-1987; the cover is younger "
296
  f"than the mineralisation): AUC {c.basement_vs_cover_auc:.2f} (95% CI {c.ci}). "
297
+ f"{int(tl.loc[2, 'locations'])} of {len(xy)} suggested field-check locations fall on cover "
298
+ f"(expected at random: {tl.loc[2, 'expected_by_area']}).",
299
  "- **CPI inside NGDR exploration-block footprints vs the rest of the block:**\n",
300
+ "| NGDR report | Area | km² | CPI AUC (95% CI) | Priority zones touching it | Field-check locations inside (random) |",
301
+ "|---|---|---|---|---|---|"]
302
+ lines += [f"| {r.report} | {r.area} | {r.area_km2} | {r.cpi_auc:.2f} ({r.ci}) | {r.priority_zones_touching} | "
303
+ f"{r.locations_inside} ({r.locations_expected_at_random}) |" for r in blocks.itertuples()]
304
+ if zcap is not None:
305
+ lines += ["\n- **Prospective zones against the three explored / mineralised NGDR blocks** (Bhawratekra and the two "
306
+ "Banskhapa-Pipariya areas). Enrichment = share of explored ground inside the zones divided by the share of "
307
+ "the block they cover (1.0 = no better than chance).\n",
308
+ "| Zones | % of block | % of explored ground inside | Enrichment | % of zone area on Deccan cover |",
309
+ "|---|---|---|---|---|"]
310
+ lines += [f"| {r.zones} | {r.pct_of_block} | {r.pct_of_truth_captured} | {r.enrichment}× | {r.pct_of_zone_area_on_cover} |"
311
+ for r in zcap.itertuples()]
312
  lines += ["\nNGDR footprints are areas that were explored, not ore outlines, and they were chosen partly because of "
313
  "surface signs (e.g. gossans) that this method also detects. AUC 0.5 = no difference from the rest of the block."]
314
  return lines