GeoScan web app
Browse files- geoscan/geoscan/config.py +173 -172
- geoscan/geoscan/pipeline.py +38 -10
- geoscan/geoscan/prospectivity/targets.py +183 -78
- geoscan/geoscan/report/contour_maps.py +253 -242
- geoscan/geoscan/report/render.py +10 -7
- geoscan/geoscan/validation.py +91 -74
- geoscan/geoscan/webapp/catalog.py +12 -12
- geoscan/geoscan/webapp/exports.py +8 -4
- geoscan/geoscan/webapp/geophysics.py +295 -295
- geoscan/geoscan/webapp/help_text.py +287 -275
- geoscan/geoscan/webapp/jobs.py +168 -168
- geoscan/geoscan/webapp/layers.py +7 -3
- geoscan/geoscan/webapp/persist.py +6 -4
- geoscan/geoscan/webapp/server.py +14 -2
- geoscan/geoscan/webapp/static/app.js +816 -774
- geoscan/geoscan/webapp/static/index.html +284 -266
- geoscan/geoscan/webapp/static/style.css +6 -0
- geoscan/scripts/validate.py +84 -21
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
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
| 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
|
| 459 |
import geopandas as gpd
|
| 460 |
from shapely.geometry import Point
|
| 461 |
-
from .prospectivity.targets import cpi,
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
if len(df):
|
| 492 |
gdf = gpd.GeoDataFrame(df, geometry=[Point(xy) for xy in zip(df["Longitude"], df["Latitude"])], crs=4326)
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
|
|
|
| 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
|
| 499 |
-
tiers =
|
| 500 |
-
return {"
|
|
|
|
| 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)
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
from
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 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 |
-
|
|
|
|
| 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 |
-
"
|
| 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"{
|
| 119 |
-
|
| 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
|
| 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
|
| 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
|
| 51 |
-
_p("targets.w_gossan", "Weight: gossan", "CPI and
|
| 52 |
-
_p("targets.w_chlorite", "Weight: chlorite", "CPI and
|
| 53 |
-
_p("targets.w_structure", "Structure share of CPI", "CPI and
|
| 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
|
| 56 |
50, 2000, 10, "m", "Distance over which the structural score falls to 37 %."),
|
| 57 |
-
_p("targets.tier1", "Tier 1 threshold (CPI)", "CPI and
|
| 58 |
-
_p("targets.tier2", "Tier 2 threshold (CPI)", "CPI and
|
| 59 |
-
_p("targets.tier3", "Tier 3 threshold (CPI)", "CPI and
|
| 60 |
-
_p("targets.min_cpi", "Minimum CPI for a
|
| 61 |
-
_p("targets.min_spacing_m", "Minimum spacing between
|
| 62 |
30, 2000, 10, "m"),
|
| 63 |
-
_p("targets.n_targets", "Maximum number of
|
| 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
|
| 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 = {"
|
|
|
|
|
|
|
| 40 |
"lineaments.csv": "Lineament attributes", "lineament_rose.csv": "Lineament strike histogram",
|
| 41 |
"aster_ged_index_summary.csv": "ASTER GED index statistics"}
|
| 42 |
-
VECTOR_LABELS = {"
|
| 43 |
-
"
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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": "
|
| 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
|
| 91 |
-
"how": "
|
| 92 |
-
"CPI is not re-stretched, so these are absolute values.",
|
| 93 |
-
"up": "Fewer
|
| 94 |
-
"down": "More
|
| 95 |
-
"why": "Expert-assigned class
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
"
|
| 101 |
-
"
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
"
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
"
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
"
|
| 116 |
-
"why": "Expert-assigned
|
| 117 |
-
},
|
| 118 |
-
"targets.
|
| 119 |
-
"what": "
|
| 120 |
-
"how": "
|
| 121 |
-
|
| 122 |
-
"
|
| 123 |
-
"
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
"
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
"
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
"
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
"
|
| 139 |
-
"
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
"
|
| 146 |
-
"
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
"
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
"
|
| 154 |
-
"
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
"
|
| 158 |
-
"
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
"
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
"
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
"
|
| 169 |
-
"
|
| 170 |
-
"
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
"
|
| 178 |
-
|
| 179 |
-
"
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
"
|
| 186 |
-
"
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
"
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
"
|
| 194 |
-
"
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
"
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
"
|
| 202 |
-
"
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
"
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
"
|
| 212 |
-
"
|
| 213 |
-
"
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
"
|
| 218 |
-
"
|
| 219 |
-
"
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
"
|
| 224 |
-
"
|
| 225 |
-
"
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
"
|
| 230 |
-
"
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
"
|
| 237 |
-
"
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
"
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
"
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
"
|
| 248 |
-
"
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
"
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
"
|
| 259 |
-
"
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
"
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 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
|
| 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": "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
| 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 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
el
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
if (
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
$(
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
$
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
const
|
| 326 |
-
for (const [k,
|
| 327 |
-
const
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
const
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
}
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
(
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
box.
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
const
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
}
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
}
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
$("#
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
}
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
}
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
}
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
if (
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
};
|
| 630 |
-
|
| 631 |
-
$("#
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
};
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
});
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
const
|
| 653 |
-
if (
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
}
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
};
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
}
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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">
|
| 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-
|
| 83 |
-
<label><input type="checkbox" id="show-
|
| 84 |
-
<label><input type="checkbox" id="show-
|
| 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 |
-
<
|
| 100 |
-
<
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
<
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
<
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
<
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
<
|
| 131 |
-
<
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
</
|
| 138 |
-
<
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
<b>
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
<
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
the
|
| 149 |
-
|
| 150 |
-
<h2>
|
| 151 |
-
<p
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
<
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
<
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
<li><b>
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
<
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
<
|
| 202 |
-
|
| 203 |
-
<
|
| 204 |
-
<
|
| 205 |
-
<
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
</
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
<
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
<
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
<
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
</
|
| 254 |
-
|
| 255 |
-
<
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
<
|
| 259 |
-
|
| 260 |
-
<
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
<
|
| 264 |
-
<
|
| 265 |
-
</
|
| 266 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 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"- **
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}", "
|
| 208 |
-
"
|
|
|
|
|
|
|
| 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, "
|
| 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/
|
| 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 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
| 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, '
|
|
|
|
| 245 |
"- **CPI inside NGDR exploration-block footprints vs the rest of the block:**\n",
|
| 246 |
-
"| NGDR report | Area | km² | CPI AUC (95% CI) |
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|