Spaces:
Sleeping
Sleeping
Deploy AquiScore Groundwater Security API v2.0.0 — CCME WQI, DRASTIC vulnerability framework
Browse files- README.md +14 -5
- api/main.py +313 -8
- pipeline/aquifer_model.py +88 -100
- pipeline/cache.py +396 -0
- pipeline/ccme_wqi.py +440 -0
- pipeline/drastic_rating.py +486 -0
README.md
CHANGED
|
@@ -7,14 +7,19 @@ sdk: docker
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
-
short_description: Groundwater Security Scoring Engine
|
| 11 |
---
|
| 12 |
|
| 13 |
-
# AquiScore — Groundwater Security API
|
| 14 |
|
| 15 |
-
Fuses NASA GRACE-FO satellite gravity
|
| 16 |
|
| 17 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
- Batch scoring (up to 500 sites per request)
|
| 20 |
- Response caching with content-hash keys
|
|
@@ -25,9 +30,13 @@ Fuses NASA GRACE-FO satellite gravity × USGS NWIS well measurements × GLHYMPS
|
|
| 25 |
|
| 26 |
| Method | Path | Description |
|
| 27 |
|--------|------|-------------|
|
| 28 |
-
| POST | `/v1/score` | Run aquifer security score |
|
| 29 |
| POST | `/v1/score/batch` | Batch score multiple aquifers |
|
| 30 |
| POST | `/v1/certificate` | Generate security certificate |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
| GET | `/v1/aquifer-types` | List aquifer types |
|
| 32 |
| GET | `/v1/extraction-regimes` | Extraction regime multipliers |
|
| 33 |
| GET | `/v1/quality-thresholds` | WHO/EPA water quality thresholds |
|
|
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: Groundwater Security Scoring Engine v2.0
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# AquiScore — Groundwater Security API v2.0
|
| 14 |
|
| 15 |
+
Fuses NASA GRACE-FO satellite gravity x USGS NWIS well measurements x GLHYMPS hydrogeology to produce auditable groundwater security scores.
|
| 16 |
|
| 17 |
+
## v2.0 Scientific Enrichments
|
| 18 |
+
|
| 19 |
+
- CCME Water Quality Index (F1/F2/F3 replaces linear quality scoring)
|
| 20 |
+
- DRASTIC groundwater vulnerability framework (EPA 7-parameter assessment)
|
| 21 |
+
|
| 22 |
+
## v1.x Foundation
|
| 23 |
|
| 24 |
- Batch scoring (up to 500 sites per request)
|
| 25 |
- Response caching with content-hash keys
|
|
|
|
| 30 |
|
| 31 |
| Method | Path | Description |
|
| 32 |
|--------|------|-------------|
|
| 33 |
+
| POST | `/v1/score` | Run aquifer security score (v2.0 enriched) |
|
| 34 |
| POST | `/v1/score/batch` | Batch score multiple aquifers |
|
| 35 |
| POST | `/v1/certificate` | Generate security certificate |
|
| 36 |
+
| POST | `/v1/grace/decompose` | GRACE TWS signal decomposition |
|
| 37 |
+
| POST | `/v1/wells/interpolate` | Kriging well field interpolation |
|
| 38 |
+
| POST | `/v1/wqi` | Standalone CCME Water Quality Index |
|
| 39 |
+
| POST | `/v1/drastic` | Standalone DRASTIC vulnerability |
|
| 40 |
| GET | `/v1/aquifer-types` | List aquifer types |
|
| 41 |
| GET | `/v1/extraction-regimes` | Extraction regime multipliers |
|
| 42 |
| GET | `/v1/quality-thresholds` | WHO/EPA water quality thresholds |
|
api/main.py
CHANGED
|
@@ -1,13 +1,27 @@
|
|
| 1 |
"""
|
| 2 |
-
AquiScore FastAPI Server — Groundwater Security API
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
Endpoints:
|
| 5 |
-
POST /v1/score — Run aquifer security score
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
GET /v1/aquifer-types — List aquifer types
|
| 7 |
GET /v1/extraction-regimes — List extraction regimes
|
| 8 |
GET /v1/quality-thresholds — WHO/EPA water quality thresholds
|
| 9 |
GET /v1/grace-hotspots — GRACE-FO depletion hotspots
|
|
|
|
| 10 |
GET /v1/presets/{name} — Run canonical preset
|
|
|
|
|
|
|
| 11 |
GET /v1/health — Health check
|
| 12 |
|
| 13 |
Usage:
|
|
@@ -35,11 +49,26 @@ from pipeline.aquifer_model import (
|
|
| 35 |
)
|
| 36 |
from pipeline.certificate_generator import generate_certificate_json, generate_certificate_text
|
| 37 |
from pipeline.grace_fetch import REGIONAL_GRACE_REFS, list_depletion_hotspots
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
app = FastAPI(
|
| 40 |
title="AquiScore API",
|
| 41 |
-
description=
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
)
|
| 44 |
|
| 45 |
app.add_middleware(
|
|
@@ -50,6 +79,8 @@ app.add_middleware(
|
|
| 50 |
)
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
| 53 |
class GRACEInput(BaseModel):
|
| 54 |
tws_anomaly_cm: float = 0.0
|
| 55 |
trend_cm_yr: float = 0.0
|
|
@@ -108,18 +139,48 @@ class ScoreRequest(BaseModel):
|
|
| 108 |
return v
|
| 109 |
|
| 110 |
|
|
|
|
|
|
|
| 111 |
@app.get("/v1/health")
|
| 112 |
async def health_check():
|
| 113 |
return {
|
| 114 |
"status": "healthy",
|
| 115 |
"platform": "AquiScore",
|
| 116 |
-
"version": "
|
| 117 |
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
|
| 121 |
@app.post("/v1/score")
|
| 122 |
async def run_score(request: ScoreRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
result = run_aquifer_score(
|
| 124 |
grace=request.grace.model_dump(),
|
| 125 |
well=request.well.model_dump(),
|
|
@@ -129,7 +190,7 @@ async def run_score(request: ScoreRequest):
|
|
| 129 |
extraction_regime=request.extraction_regime,
|
| 130 |
)
|
| 131 |
|
| 132 |
-
|
| 133 |
"score": result.score,
|
| 134 |
"confidence_interval": result.confidence_interval,
|
| 135 |
"confidence_pct": result.confidence_pct,
|
|
@@ -144,8 +205,14 @@ async def run_score(request: ScoreRequest):
|
|
| 144 |
"quality_flags": result.quality_flags,
|
| 145 |
"feature_importances": result.feature_importances,
|
| 146 |
"citations": result.citations,
|
|
|
|
|
|
|
|
|
|
| 147 |
}
|
| 148 |
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
@app.post("/v1/certificate")
|
| 151 |
async def generate_cert(request: ScoreRequest):
|
|
@@ -219,7 +286,8 @@ async def run_preset(preset_name: str):
|
|
| 219 |
}
|
| 220 |
|
| 221 |
|
| 222 |
-
#
|
|
|
|
| 223 |
|
| 224 |
class BatchScoreRequest(BaseModel):
|
| 225 |
"""Batch aquifer scoring -- score multiple sites in one request."""
|
|
@@ -273,7 +341,6 @@ async def run_batch_score(request: BatchScoreRequest):
|
|
| 273 |
except Exception as e:
|
| 274 |
errors.append({"index": i, "error": str(e)})
|
| 275 |
|
| 276 |
-
# Summary statistics
|
| 277 |
scores = [r["score"] for r in results]
|
| 278 |
summary = {}
|
| 279 |
if scores:
|
|
@@ -296,3 +363,241 @@ async def run_batch_score(request: BatchScoreRequest):
|
|
| 296 |
"errors": errors,
|
| 297 |
"summary": summary,
|
| 298 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
AquiScore FastAPI Server — Groundwater Security API v2.0
|
| 3 |
+
|
| 4 |
+
Wraps pipeline/aquifer_model.py as a REST API with v2.0 scientific enrichments:
|
| 5 |
+
- CCME Water Quality Index (replaces linear quality scoring)
|
| 6 |
+
- DRASTIC groundwater vulnerability assessment
|
| 7 |
+
Plus v1.x: batch scoring, response caching, GRACE decomposition, kriging
|
| 8 |
|
| 9 |
Endpoints:
|
| 10 |
+
POST /v1/score — Run aquifer security score (v2.0 enriched)
|
| 11 |
+
POST /v1/score/batch — Batch score multiple sites
|
| 12 |
+
POST /v1/certificate — Generate aquifer certificate
|
| 13 |
+
POST /v1/grace/decompose — GRACE TWS signal decomposition
|
| 14 |
+
POST /v1/wells/interpolate — Kriging well field interpolation
|
| 15 |
+
POST /v1/wqi — Standalone CCME Water Quality Index
|
| 16 |
+
POST /v1/drastic — Standalone DRASTIC vulnerability assessment
|
| 17 |
GET /v1/aquifer-types — List aquifer types
|
| 18 |
GET /v1/extraction-regimes — List extraction regimes
|
| 19 |
GET /v1/quality-thresholds — WHO/EPA water quality thresholds
|
| 20 |
GET /v1/grace-hotspots — GRACE-FO depletion hotspots
|
| 21 |
+
GET /v1/grace-regions — GRACE regional references
|
| 22 |
GET /v1/presets/{name} — Run canonical preset
|
| 23 |
+
GET /v1/cache/stats — Cache performance statistics
|
| 24 |
+
POST /v1/cache/clear — Clear response cache
|
| 25 |
GET /v1/health — Health check
|
| 26 |
|
| 27 |
Usage:
|
|
|
|
| 49 |
)
|
| 50 |
from pipeline.certificate_generator import generate_certificate_json, generate_certificate_text
|
| 51 |
from pipeline.grace_fetch import REGIONAL_GRACE_REFS, list_depletion_hotspots
|
| 52 |
+
from pipeline.cache import get_default_cache, ScoreCache
|
| 53 |
+
from pipeline.grace_decomposition import decompose_grace_signal, classify_depletion_pattern
|
| 54 |
+
from pipeline.kriging import interpolate_well_field
|
| 55 |
+
from pipeline.ccme_wqi import compute_ccme_wqi, GUIDELINES as CCME_GUIDELINES
|
| 56 |
+
from pipeline.drastic_rating import compute_drastic, compute_drastic_from_aquifer_type
|
| 57 |
+
|
| 58 |
+
# Module-level cache instance
|
| 59 |
+
_cache = get_default_cache()
|
| 60 |
|
| 61 |
app = FastAPI(
|
| 62 |
title="AquiScore API",
|
| 63 |
+
description=(
|
| 64 |
+
"Groundwater Security Scoring Engine v2.0 — "
|
| 65 |
+
"GRACE-FO × NWIS × GLHYMPS. v2.0: CCME Water Quality Index, "
|
| 66 |
+
"DRASTIC vulnerability framework, batch scoring, response caching, "
|
| 67 |
+
"GRACE decomposition, and kriging interpolation."
|
| 68 |
+
),
|
| 69 |
+
version="2.0.0",
|
| 70 |
+
docs_url="/docs",
|
| 71 |
+
redoc_url="/redoc",
|
| 72 |
)
|
| 73 |
|
| 74 |
app.add_middleware(
|
|
|
|
| 79 |
)
|
| 80 |
|
| 81 |
|
| 82 |
+
# ── REQUEST / RESPONSE MODELS ───────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
class GRACEInput(BaseModel):
|
| 85 |
tws_anomaly_cm: float = 0.0
|
| 86 |
trend_cm_yr: float = 0.0
|
|
|
|
| 139 |
return v
|
| 140 |
|
| 141 |
|
| 142 |
+
# ── ENDPOINTS ────────────────────────────────────────────────────────────────
|
| 143 |
+
|
| 144 |
@app.get("/v1/health")
|
| 145 |
async def health_check():
|
| 146 |
return {
|
| 147 |
"status": "healthy",
|
| 148 |
"platform": "AquiScore",
|
| 149 |
+
"version": "2.0.0",
|
| 150 |
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 151 |
+
"v1_optimizations": [
|
| 152 |
+
"batch_scoring", "response_caching", "grace_decomposition",
|
| 153 |
+
"kriging_interpolation", "well_field_analysis",
|
| 154 |
+
],
|
| 155 |
+
"v2_capabilities": [
|
| 156 |
+
"ccme_water_quality_index", "drastic_vulnerability",
|
| 157 |
+
],
|
| 158 |
}
|
| 159 |
|
| 160 |
|
| 161 |
@app.post("/v1/score")
|
| 162 |
async def run_score(request: ScoreRequest):
|
| 163 |
+
"""
|
| 164 |
+
Run aquifer security score with response caching.
|
| 165 |
+
|
| 166 |
+
Fuses satellite (GRACE-FO), well measurement, substrate (GLHYMPS),
|
| 167 |
+
and water quality signals with extraction regime multiplier.
|
| 168 |
+
"""
|
| 169 |
+
# Build cache key from all scoring inputs
|
| 170 |
+
cache_key = _cache.make_key(
|
| 171 |
+
grace=request.grace.model_dump(),
|
| 172 |
+
well=request.well.model_dump(),
|
| 173 |
+
substrate=request.substrate.model_dump(),
|
| 174 |
+
quality=request.quality.model_dump(),
|
| 175 |
+
aquifer_type=request.aquifer_type,
|
| 176 |
+
extraction_regime=request.extraction_regime,
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
cached = _cache.get(cache_key)
|
| 180 |
+
if cached is not None:
|
| 181 |
+
cached["_cached"] = True
|
| 182 |
+
return cached
|
| 183 |
+
|
| 184 |
result = run_aquifer_score(
|
| 185 |
grace=request.grace.model_dump(),
|
| 186 |
well=request.well.model_dump(),
|
|
|
|
| 190 |
extraction_regime=request.extraction_regime,
|
| 191 |
)
|
| 192 |
|
| 193 |
+
response = {
|
| 194 |
"score": result.score,
|
| 195 |
"confidence_interval": result.confidence_interval,
|
| 196 |
"confidence_pct": result.confidence_pct,
|
|
|
|
| 205 |
"quality_flags": result.quality_flags,
|
| 206 |
"feature_importances": result.feature_importances,
|
| 207 |
"citations": result.citations,
|
| 208 |
+
"ccme_wqi": result.ccme_wqi,
|
| 209 |
+
"drastic": result.drastic,
|
| 210 |
+
"_cached": False,
|
| 211 |
}
|
| 212 |
|
| 213 |
+
_cache.set(cache_key, response)
|
| 214 |
+
return response
|
| 215 |
+
|
| 216 |
|
| 217 |
@app.post("/v1/certificate")
|
| 218 |
async def generate_cert(request: ScoreRequest):
|
|
|
|
| 286 |
}
|
| 287 |
|
| 288 |
|
| 289 |
+
# ── v1.1 OPTIMIZATION ENDPOINTS ────────────────────────────────────────────
|
| 290 |
+
|
| 291 |
|
| 292 |
class BatchScoreRequest(BaseModel):
|
| 293 |
"""Batch aquifer scoring -- score multiple sites in one request."""
|
|
|
|
| 341 |
except Exception as e:
|
| 342 |
errors.append({"index": i, "error": str(e)})
|
| 343 |
|
|
|
|
| 344 |
scores = [r["score"] for r in results]
|
| 345 |
summary = {}
|
| 346 |
if scores:
|
|
|
|
| 363 |
"errors": errors,
|
| 364 |
"summary": summary,
|
| 365 |
}
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# ── GRACE Decomposition Endpoint ────────────────────────────────────────────
|
| 369 |
+
|
| 370 |
+
class GRACEDecomposeRequest(BaseModel):
|
| 371 |
+
"""GRACE TWS monthly time series for decomposition."""
|
| 372 |
+
monthly_tws_cm: list[float] = Field(
|
| 373 |
+
...,
|
| 374 |
+
min_length=24,
|
| 375 |
+
max_length=600,
|
| 376 |
+
description="Monthly TWS anomaly values in cm EWT (min 24 months)",
|
| 377 |
+
)
|
| 378 |
+
trend_window: int = Field(13, ge=3, le=25, description="Moving average window for trend extraction")
|
| 379 |
+
site_id: Optional[str] = None
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
@app.post("/v1/grace/decompose")
|
| 383 |
+
async def grace_decompose(request: GRACEDecomposeRequest):
|
| 384 |
+
"""
|
| 385 |
+
Decompose a GRACE TWS time series into trend, seasonal, and residual.
|
| 386 |
+
|
| 387 |
+
Uses STL-like decomposition (Cleveland et al. 1990) to extract:
|
| 388 |
+
- Trend: long-term storage change direction
|
| 389 |
+
- Seasonal: annual recharge/discharge cycle
|
| 390 |
+
- Residual: anomalous signals (drought, pumping events)
|
| 391 |
+
|
| 392 |
+
Classifies depletion pattern: stable, linear_decline,
|
| 393 |
+
accelerating_decline, seasonal_stress, or recovery.
|
| 394 |
+
|
| 395 |
+
Minimum 24 months of data required.
|
| 396 |
+
"""
|
| 397 |
+
try:
|
| 398 |
+
decomp = decompose_grace_signal(
|
| 399 |
+
request.monthly_tws_cm,
|
| 400 |
+
trend_window=request.trend_window,
|
| 401 |
+
)
|
| 402 |
+
pattern = classify_depletion_pattern(decomp)
|
| 403 |
+
except ValueError as e:
|
| 404 |
+
raise HTTPException(400, str(e))
|
| 405 |
+
|
| 406 |
+
return {
|
| 407 |
+
"site_id": request.site_id,
|
| 408 |
+
"n_months": decomp.n_months,
|
| 409 |
+
"trend_slope_cm_yr": decomp.trend_slope_cm_yr,
|
| 410 |
+
"trend_r_squared": decomp.trend_r_squared,
|
| 411 |
+
"is_accelerating": decomp.is_accelerating,
|
| 412 |
+
"acceleration_rate": decomp.acceleration_rate,
|
| 413 |
+
"seasonal_amplitude_cm": decomp.seasonal_amplitude_cm,
|
| 414 |
+
"depletion_pattern": pattern,
|
| 415 |
+
"trend": decomp.trend,
|
| 416 |
+
"seasonal": decomp.seasonal,
|
| 417 |
+
"residual": decomp.residual,
|
| 418 |
+
"citation": "Cleveland, R.B. et al. (1990) STL: Seasonal-Trend Decomposition. J. Official Statistics 6:3-73.",
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
# ── Kriging Well Field Interpolation Endpoint ───────────────────────────────
|
| 423 |
+
|
| 424 |
+
class WellPoint(BaseModel):
|
| 425 |
+
lat: float = Field(..., ge=-90, le=90)
|
| 426 |
+
lon: float = Field(..., ge=-180, le=180)
|
| 427 |
+
depth_m: float = Field(..., ge=0, description="Depth to water table in meters")
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
class KrigingRequest(BaseModel):
|
| 431 |
+
"""Kriging interpolation request for a target location."""
|
| 432 |
+
wells: list[WellPoint] = Field(
|
| 433 |
+
...,
|
| 434 |
+
min_length=2,
|
| 435 |
+
max_length=500,
|
| 436 |
+
description="Known well data points (min 2 required for kriging)",
|
| 437 |
+
)
|
| 438 |
+
target_lat: float = Field(..., ge=-90, le=90)
|
| 439 |
+
target_lon: float = Field(..., ge=-180, le=180)
|
| 440 |
+
max_distance_km: float = Field(100.0, ge=1, le=500)
|
| 441 |
+
site_id: Optional[str] = None
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
@app.post("/v1/wells/interpolate")
|
| 445 |
+
async def wells_interpolate(request: KrigingRequest):
|
| 446 |
+
"""
|
| 447 |
+
Predict groundwater depth at a target location from surrounding wells.
|
| 448 |
+
|
| 449 |
+
Uses Ordinary Kriging (Cressie 1993) with a spherical variogram model
|
| 450 |
+
to provide the Best Linear Unbiased Predictor (BLUP) for spatial data.
|
| 451 |
+
|
| 452 |
+
Returns prediction with uncertainty (kriging variance) and 95% CI.
|
| 453 |
+
"""
|
| 454 |
+
wells = [{"lat": w.lat, "lon": w.lon, "depth_m": w.depth_m} for w in request.wells]
|
| 455 |
+
|
| 456 |
+
try:
|
| 457 |
+
result = interpolate_well_field(
|
| 458 |
+
wells,
|
| 459 |
+
target_lat=request.target_lat,
|
| 460 |
+
target_lon=request.target_lon,
|
| 461 |
+
max_distance_km=request.max_distance_km,
|
| 462 |
+
)
|
| 463 |
+
except ValueError as e:
|
| 464 |
+
raise HTTPException(400, str(e))
|
| 465 |
+
|
| 466 |
+
return {
|
| 467 |
+
"site_id": request.site_id,
|
| 468 |
+
"target_lat": request.target_lat,
|
| 469 |
+
"target_lon": request.target_lon,
|
| 470 |
+
"predicted_depth_m": result["predicted_depth_m"],
|
| 471 |
+
"prediction_variance": result["prediction_variance"],
|
| 472 |
+
"prediction_std_m": result["prediction_std_m"],
|
| 473 |
+
"confidence_interval_m": result["confidence_interval_m"],
|
| 474 |
+
"n_wells_used": result["n_wells_used"],
|
| 475 |
+
"variogram_params": result["variogram_params"],
|
| 476 |
+
"citation": "Cressie, N.A.C. (1993) Statistics for Spatial Data. Wiley.",
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
# ── Cache Endpoints ─────────────────────────────────────────────────────────
|
| 481 |
+
|
| 482 |
+
@app.get("/v1/cache/stats")
|
| 483 |
+
async def cache_stats():
|
| 484 |
+
"""Return cache performance statistics."""
|
| 485 |
+
return _cache.stats()
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
@app.post("/v1/cache/clear")
|
| 489 |
+
async def cache_clear():
|
| 490 |
+
"""Clear the response cache. Returns count of evicted entries."""
|
| 491 |
+
count = _cache.clear()
|
| 492 |
+
return {"cleared": count, "status": "ok"}
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
# ── v2.0 SCIENTIFIC ENDPOINTS ─────────────────────────────────────────────
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
class WQIRequest(BaseModel):
|
| 499 |
+
"""Standalone CCME Water Quality Index request."""
|
| 500 |
+
nitrate_mg_l: float = Field(5.0, ge=0)
|
| 501 |
+
arsenic_ug_l: float = Field(3.0, ge=0)
|
| 502 |
+
fluoride_mg_l: float = Field(0.5, ge=0)
|
| 503 |
+
tds_mg_l: float = Field(300.0, ge=0)
|
| 504 |
+
ph: float = Field(7.2, ge=0, le=14)
|
| 505 |
+
e_coli_cfu_100ml: float = Field(0.0, ge=0)
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
@app.post("/v1/wqi")
|
| 509 |
+
async def run_ccme_wqi(request: WQIRequest):
|
| 510 |
+
"""
|
| 511 |
+
Compute CCME Water Quality Index from chemical/microbial measurements.
|
| 512 |
+
|
| 513 |
+
The CCME WQI is the internationally recognized standard for drinking
|
| 514 |
+
water quality assessment, combining three factors:
|
| 515 |
+
F1 (Scope): % of parameters exceeding guidelines
|
| 516 |
+
F2 (Frequency): % of individual tests failing
|
| 517 |
+
F3 (Amplitude): degree of exceedance (asymptotic to 100)
|
| 518 |
+
|
| 519 |
+
WQI = 100 - (sqrt(F1² + F2² + F3²) / 1.732)
|
| 520 |
+
|
| 521 |
+
Categories:
|
| 522 |
+
EXCELLENT (95-100), GOOD (80-94), FAIR (65-79),
|
| 523 |
+
MARGINAL (45-64), POOR (0-44)
|
| 524 |
+
|
| 525 |
+
Citation: CCME (2001) Canadian Water Quality Guidelines for the
|
| 526 |
+
Protection of Aquatic Life. CCME Water Quality Index 1.0.
|
| 527 |
+
"""
|
| 528 |
+
measurements = {
|
| 529 |
+
"nitrate_mg_l": request.nitrate_mg_l,
|
| 530 |
+
"arsenic_ug_l": request.arsenic_ug_l,
|
| 531 |
+
"fluoride_mg_l": request.fluoride_mg_l,
|
| 532 |
+
"tds_mg_l": request.tds_mg_l,
|
| 533 |
+
"ph": request.ph,
|
| 534 |
+
"e_coli_cfu_100ml": request.e_coli_cfu_100ml,
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
result = compute_ccme_wqi(measurements)
|
| 538 |
+
|
| 539 |
+
return {
|
| 540 |
+
"wqi": result.wqi,
|
| 541 |
+
"category": result.category,
|
| 542 |
+
"f1_scope": result.f1_scope,
|
| 543 |
+
"f2_frequency": result.f2_frequency,
|
| 544 |
+
"f3_amplitude": result.f3_amplitude,
|
| 545 |
+
"n_parameters": result.n_variables,
|
| 546 |
+
"n_failed": result.n_failed_variables,
|
| 547 |
+
"exceedances": result.exceedances,
|
| 548 |
+
"interpretation": result.interpretation,
|
| 549 |
+
"guidelines_used": CCME_GUIDELINES,
|
| 550 |
+
"citation": "CCME (2001) Canadian Water Quality Guidelines. CCME WQI 1.0.",
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
class DRASTICRequest(BaseModel):
|
| 555 |
+
"""Standalone DRASTIC vulnerability assessment request."""
|
| 556 |
+
aquifer_type: str
|
| 557 |
+
depth_to_water_m: float = Field(15.0, ge=0, description="Depth to water table in meters")
|
| 558 |
+
hydraulic_conductivity_m_s: float = Field(1e-5, ge=0, description="Hydraulic conductivity in m/s")
|
| 559 |
+
|
| 560 |
+
@field_validator("aquifer_type")
|
| 561 |
+
@classmethod
|
| 562 |
+
def validate_aquifer_type(cls, v):
|
| 563 |
+
if v not in AQUIFER_TYPES:
|
| 564 |
+
raise ValueError(f"Invalid aquifer_type. Must be one of: {list(AQUIFER_TYPES.keys())}")
|
| 565 |
+
return v
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
@app.post("/v1/drastic")
|
| 569 |
+
async def run_drastic_assessment(request: DRASTICRequest):
|
| 570 |
+
"""
|
| 571 |
+
Compute DRASTIC groundwater vulnerability index.
|
| 572 |
+
|
| 573 |
+
DRASTIC is the EPA-published framework for evaluating intrinsic
|
| 574 |
+
groundwater vulnerability using seven hydrogeological parameters:
|
| 575 |
+
D = Depth to water (weight 5)
|
| 576 |
+
R = Net Recharge (weight 4)
|
| 577 |
+
A = Aquifer media (weight 3)
|
| 578 |
+
S = Soil media (weight 2)
|
| 579 |
+
T = Topography/slope (weight 1)
|
| 580 |
+
I = Impact of vadose zone (weight 5)
|
| 581 |
+
C = Conductivity of aquifer (weight 3)
|
| 582 |
+
|
| 583 |
+
Index range: 23-230.
|
| 584 |
+
Converted to security score: 100 × (1 - (DI - 23) / 207)
|
| 585 |
+
|
| 586 |
+
Citation: Aller, L. et al. (1987) DRASTIC: A Standardized System
|
| 587 |
+
to Evaluate Ground Water Pollution Potential. US EPA/600/2-87/035.
|
| 588 |
+
"""
|
| 589 |
+
result = compute_drastic_from_aquifer_type(
|
| 590 |
+
aquifer_type=request.aquifer_type,
|
| 591 |
+
depth_m=request.depth_to_water_m,
|
| 592 |
+
conductivity_m_s=request.hydraulic_conductivity_m_s,
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
return {
|
| 596 |
+
"drastic_index": result.drastic_index,
|
| 597 |
+
"vulnerability_class": result.vulnerability_class,
|
| 598 |
+
"security_score": result.security_score,
|
| 599 |
+
"parameter_ratings": result.parameter_ratings,
|
| 600 |
+
"dominant_factor": result.dominant_factor,
|
| 601 |
+
"interpretation": result.interpretation,
|
| 602 |
+
"citation": "Aller, L. et al. (1987) DRASTIC. US EPA/600/2-87/035.",
|
| 603 |
+
}
|
pipeline/aquifer_model.py
CHANGED
|
@@ -23,6 +23,9 @@ from dataclasses import dataclass
|
|
| 23 |
from datetime import datetime, timezone
|
| 24 |
from typing import Optional
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# ── AQUIFER INDICATOR WEIGHTS ────────────────────────────────────────────────
|
| 28 |
# DO NOT CHANGE without updating SOUL.md. Weights sum to 1.00.
|
|
@@ -148,7 +151,11 @@ class AquiScoreResult:
|
|
| 148 |
aquifer_type_info: dict
|
| 149 |
citations: list[str]
|
| 150 |
timestamp: str
|
| 151 |
-
model_version: str = "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
|
| 154 |
# ── COMPONENT SCORING FUNCTIONS ─────────────────────────────────────────────
|
|
@@ -299,108 +306,69 @@ def compute_substrate_score(params: SubstrateParams, aquifer_type: str) -> tuple
|
|
| 299 |
return composite, details
|
| 300 |
|
| 301 |
|
| 302 |
-
def compute_quality_score(quality: WaterQualityParams) -> tuple[float, list[dict]]:
|
| 303 |
"""
|
| 304 |
-
Score water quality
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
-
|
| 307 |
-
|
| 308 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
flags = []
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
s = 100.0
|
| 333 |
-
elif ars <= t["concern"]:
|
| 334 |
-
s = 100.0 - (ars - t["safe"]) / (t["concern"] - t["safe"]) * 50
|
| 335 |
-
elif ars <= t["danger"]:
|
| 336 |
-
s = 50.0 - (ars - t["concern"]) / (t["danger"] - t["concern"]) * 40
|
| 337 |
-
else:
|
| 338 |
-
s = max(0.0, 10.0 - (ars - t["danger"]) * 0.2)
|
| 339 |
-
scores.append(s)
|
| 340 |
-
if ars > t["safe"]:
|
| 341 |
-
flags.append({"param": "arsenic", "value": ars, "unit": "µg/L",
|
| 342 |
-
"threshold": t["safe"], "severity": "DANGER" if ars > t["danger"] else "CONCERN"})
|
| 343 |
-
|
| 344 |
-
# Fluoride
|
| 345 |
-
flu = quality.fluoride_mg_l
|
| 346 |
-
t = WATER_QUALITY_THRESHOLDS["fluoride_mg_l"]
|
| 347 |
-
if flu <= t["safe"]:
|
| 348 |
-
s = 100.0
|
| 349 |
-
elif flu <= t["concern"]:
|
| 350 |
-
s = 100.0 - (flu - t["safe"]) / (t["concern"] - t["safe"]) * 50
|
| 351 |
-
elif flu <= t["danger"]:
|
| 352 |
-
s = 50.0 - (flu - t["concern"]) / (t["danger"] - t["concern"]) * 40
|
| 353 |
-
else:
|
| 354 |
-
s = max(0.0, 10.0 - (flu - t["danger"]) * 2)
|
| 355 |
-
scores.append(s)
|
| 356 |
-
if flu > t["safe"]:
|
| 357 |
-
flags.append({"param": "fluoride", "value": flu, "unit": "mg/L",
|
| 358 |
-
"threshold": t["safe"], "severity": "DANGER" if flu > t["danger"] else "CONCERN"})
|
| 359 |
-
|
| 360 |
-
# TDS
|
| 361 |
-
tds = quality.tds_mg_l
|
| 362 |
-
t = WATER_QUALITY_THRESHOLDS["tds_mg_l"]
|
| 363 |
-
if tds <= t["safe"]:
|
| 364 |
-
s = 100.0
|
| 365 |
-
elif tds <= t["concern"]:
|
| 366 |
-
s = 100.0 - (tds - t["safe"]) / (t["concern"] - t["safe"]) * 50
|
| 367 |
-
elif tds <= t["danger"]:
|
| 368 |
-
s = 50.0 - (tds - t["concern"]) / (t["danger"] - t["concern"]) * 40
|
| 369 |
-
else:
|
| 370 |
-
s = max(0.0, 10.0 - (tds - t["danger"]) * 0.005)
|
| 371 |
-
scores.append(s)
|
| 372 |
-
if tds > t["safe"]:
|
| 373 |
-
flags.append({"param": "tds", "value": tds, "unit": "mg/L",
|
| 374 |
-
"threshold": t["safe"], "severity": "DANGER" if tds > t["danger"] else "CONCERN"})
|
| 375 |
-
|
| 376 |
-
# pH (range-based)
|
| 377 |
-
ph = quality.ph
|
| 378 |
-
if 6.5 <= ph <= 8.5:
|
| 379 |
-
s = 100.0
|
| 380 |
-
elif ph < 6.5:
|
| 381 |
-
s = max(0.0, 100.0 - (6.5 - ph) * 40)
|
| 382 |
-
else:
|
| 383 |
-
s = max(0.0, 100.0 - (ph - 8.5) * 40)
|
| 384 |
-
scores.append(s)
|
| 385 |
-
|
| 386 |
-
# E. coli
|
| 387 |
-
ecoli = quality.e_coli_cfu_100ml
|
| 388 |
-
t = WATER_QUALITY_THRESHOLDS["e_coli_cfu_100ml"]
|
| 389 |
-
if ecoli <= t["safe"]:
|
| 390 |
-
s = 100.0
|
| 391 |
-
elif ecoli <= t["concern"]:
|
| 392 |
-
s = 60.0
|
| 393 |
-
elif ecoli <= t["danger"]:
|
| 394 |
-
s = 30.0
|
| 395 |
-
else:
|
| 396 |
-
s = max(0.0, 10.0 - ecoli * 0.5)
|
| 397 |
-
scores.append(s)
|
| 398 |
-
if ecoli > t["safe"]:
|
| 399 |
-
flags.append({"param": "e_coli", "value": ecoli, "unit": "CFU/100mL",
|
| 400 |
-
"threshold": t["safe"], "severity": "DANGER" if ecoli > t["danger"] else "CONCERN"})
|
| 401 |
|
| 402 |
-
composite
|
| 403 |
-
return composite, flags
|
| 404 |
|
| 405 |
|
| 406 |
def compute_confidence(score: int, n_wells: int, record_years: int) -> tuple[int, list[int]]:
|
|
@@ -527,8 +495,24 @@ def run_aquifer_score(
|
|
| 527 |
# 3. Substrate score (20%)
|
| 528 |
sub_score, sub_details = compute_substrate_score(substrate, aquifer_type)
|
| 529 |
|
| 530 |
-
# 4. Water quality score (15%)
|
| 531 |
-
qual_score, quality_flags = compute_quality_score(quality)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
| 533 |
# Extraction regime multiplier
|
| 534 |
ext_multiplier = EXTRACTION_REGIMES[extraction_regime]
|
|
@@ -579,6 +563,8 @@ def run_aquifer_score(
|
|
| 579 |
"Tapley, B. et al. (2019) Contributions of GRACE to understanding climate change. Nature Climate Change 9:358-369.",
|
| 580 |
"USGS National Water Information System (NWIS). U.S. Geological Survey. https://waterdata.usgs.gov",
|
| 581 |
"Gleeson, T. et al. (2014) A glimpse beneath earth's surface: GLobal HYdrogeology MaPS (GLHYMPS). Geophysical Research Letters 41:3042-3049.",
|
|
|
|
|
|
|
| 582 |
]
|
| 583 |
|
| 584 |
return AquiScoreResult(
|
|
@@ -599,6 +585,8 @@ def run_aquifer_score(
|
|
| 599 |
aquifer_type_info=AQUIFER_TYPES[aquifer_type],
|
| 600 |
citations=citations,
|
| 601 |
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
|
| 602 |
)
|
| 603 |
|
| 604 |
|
|
|
|
| 23 |
from datetime import datetime, timezone
|
| 24 |
from typing import Optional
|
| 25 |
|
| 26 |
+
from pipeline.ccme_wqi import compute_ccme_wqi, ccme_wqi_to_score
|
| 27 |
+
from pipeline.drastic_rating import compute_drastic_from_aquifer_type
|
| 28 |
+
|
| 29 |
|
| 30 |
# ── AQUIFER INDICATOR WEIGHTS ────────────────────────────────────────────────
|
| 31 |
# DO NOT CHANGE without updating SOUL.md. Weights sum to 1.00.
|
|
|
|
| 151 |
aquifer_type_info: dict
|
| 152 |
citations: list[str]
|
| 153 |
timestamp: str
|
| 154 |
+
model_version: str = "2.0.0"
|
| 155 |
+
|
| 156 |
+
# v2.0 enrichments — scientifically grounded diagnostics
|
| 157 |
+
ccme_wqi: Optional[dict] = None # CCME Water Quality Index (F1/F2/F3)
|
| 158 |
+
drastic: Optional[dict] = None # DRASTIC groundwater vulnerability
|
| 159 |
|
| 160 |
|
| 161 |
# ── COMPONENT SCORING FUNCTIONS ─────────────────────────────────────────────
|
|
|
|
| 306 |
return composite, details
|
| 307 |
|
| 308 |
|
| 309 |
+
def compute_quality_score(quality: WaterQualityParams) -> tuple[float, list[dict], dict]:
|
| 310 |
"""
|
| 311 |
+
Score water quality using CCME Water Quality Index (v2.0).
|
| 312 |
+
|
| 313 |
+
Replaces the v1.0 per-parameter linear scorer with the Canadian Council
|
| 314 |
+
of Ministers of the Environment WQI framework:
|
| 315 |
+
WQI = 100 - (sqrt(F1² + F2² + F3²) / 1.732)
|
| 316 |
+
where:
|
| 317 |
+
F1 = Scope (% of parameters that fail guidelines)
|
| 318 |
+
F2 = Frequency (% of individual tests that fail)
|
| 319 |
+
F3 = Amplitude (degree to which failing tests exceed guidelines)
|
| 320 |
+
|
| 321 |
+
This is the internationally recognized standard for drinking water
|
| 322 |
+
quality assessment. Categories: EXCELLENT(95-100), GOOD(80-94),
|
| 323 |
+
FAIR(65-79), MARGINAL(45-64), POOR(0-44).
|
| 324 |
+
|
| 325 |
+
Citation: CCME (2001) Canadian Water Quality Guidelines for the
|
| 326 |
+
Protection of Aquatic Life. CCME Water Quality Index 1.0.
|
| 327 |
|
| 328 |
+
Returns:
|
| 329 |
+
(score 0-100, quality flags, ccme_details dict)
|
| 330 |
"""
|
| 331 |
+
# Build measurements dict for CCME WQI
|
| 332 |
+
measurements = {
|
| 333 |
+
"nitrate_mg_l": quality.nitrate_mg_l,
|
| 334 |
+
"arsenic_ug_l": quality.arsenic_ug_l,
|
| 335 |
+
"fluoride_mg_l": quality.fluoride_mg_l,
|
| 336 |
+
"tds_mg_l": quality.tds_mg_l,
|
| 337 |
+
"ph": quality.ph,
|
| 338 |
+
"e_coli_cfu_100ml": quality.e_coli_cfu_100ml,
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
ccme = compute_ccme_wqi(measurements)
|
| 342 |
+
|
| 343 |
+
# Convert CCME WQI to AquiScore quality component
|
| 344 |
+
composite = ccme_wqi_to_score(ccme.wqi)
|
| 345 |
+
|
| 346 |
+
# Build quality flags from CCME exceedances
|
| 347 |
flags = []
|
| 348 |
+
for exc in ccme.exceedances:
|
| 349 |
+
severity = "DANGER" if exc.get("excursion", 0) > 2.0 else "CONCERN"
|
| 350 |
+
flags.append({
|
| 351 |
+
"param": exc["parameter"],
|
| 352 |
+
"value": exc["measured"],
|
| 353 |
+
"unit": exc.get("unit", ""),
|
| 354 |
+
"threshold": exc["objective"],
|
| 355 |
+
"severity": severity,
|
| 356 |
+
})
|
| 357 |
+
|
| 358 |
+
# CCME details for enriched response
|
| 359 |
+
ccme_dict = {
|
| 360 |
+
"wqi": ccme.wqi,
|
| 361 |
+
"category": ccme.category,
|
| 362 |
+
"f1_scope": ccme.f1_scope,
|
| 363 |
+
"f2_frequency": ccme.f2_frequency,
|
| 364 |
+
"f3_amplitude": ccme.f3_amplitude,
|
| 365 |
+
"n_parameters": ccme.n_variables,
|
| 366 |
+
"n_failed": ccme.n_failed_variables,
|
| 367 |
+
"exceedances": ccme.exceedances,
|
| 368 |
+
"citation": "CCME (2001) Canadian Water Quality Guidelines. CCME WQI 1.0.",
|
| 369 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
+
return composite, flags, ccme_dict
|
|
|
|
| 372 |
|
| 373 |
|
| 374 |
def compute_confidence(score: int, n_wells: int, record_years: int) -> tuple[int, list[int]]:
|
|
|
|
| 495 |
# 3. Substrate score (20%)
|
| 496 |
sub_score, sub_details = compute_substrate_score(substrate, aquifer_type)
|
| 497 |
|
| 498 |
+
# 4. Water quality score (15%) — now using CCME WQI (v2.0)
|
| 499 |
+
qual_score, quality_flags, ccme_details = compute_quality_score(quality)
|
| 500 |
+
|
| 501 |
+
# v2.0: DRASTIC vulnerability assessment
|
| 502 |
+
drastic_result = compute_drastic_from_aquifer_type(
|
| 503 |
+
aquifer_type=aquifer_type,
|
| 504 |
+
depth_m=substrate.depth_to_water_m,
|
| 505 |
+
conductivity_m_s=substrate.hydraulic_conductivity_m_s,
|
| 506 |
+
)
|
| 507 |
+
drastic_dict = {
|
| 508 |
+
"drastic_index": drastic_result.drastic_index,
|
| 509 |
+
"vulnerability_class": drastic_result.vulnerability_class,
|
| 510 |
+
"security_score": drastic_result.security_score,
|
| 511 |
+
"parameter_ratings": drastic_result.parameter_ratings,
|
| 512 |
+
"dominant_factor": drastic_result.dominant_factor,
|
| 513 |
+
"interpretation": drastic_result.interpretation,
|
| 514 |
+
"citation": "Aller, L. et al. (1987) DRASTIC: A Standardized System to Evaluate Ground Water Pollution Potential. US EPA/600/2-87/035.",
|
| 515 |
+
}
|
| 516 |
|
| 517 |
# Extraction regime multiplier
|
| 518 |
ext_multiplier = EXTRACTION_REGIMES[extraction_regime]
|
|
|
|
| 563 |
"Tapley, B. et al. (2019) Contributions of GRACE to understanding climate change. Nature Climate Change 9:358-369.",
|
| 564 |
"USGS National Water Information System (NWIS). U.S. Geological Survey. https://waterdata.usgs.gov",
|
| 565 |
"Gleeson, T. et al. (2014) A glimpse beneath earth's surface: GLobal HYdrogeology MaPS (GLHYMPS). Geophysical Research Letters 41:3042-3049.",
|
| 566 |
+
"CCME (2001) Canadian Water Quality Guidelines. CCME Water Quality Index 1.0.",
|
| 567 |
+
"Aller, L. et al. (1987) DRASTIC: A Standardized System to Evaluate Ground Water Pollution Potential. US EPA/600/2-87/035.",
|
| 568 |
]
|
| 569 |
|
| 570 |
return AquiScoreResult(
|
|
|
|
| 585 |
aquifer_type_info=AQUIFER_TYPES[aquifer_type],
|
| 586 |
citations=citations,
|
| 587 |
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 588 |
+
ccme_wqi=ccme_details,
|
| 589 |
+
drastic=drastic_dict,
|
| 590 |
)
|
| 591 |
|
| 592 |
|
pipeline/cache.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AquiScore — Response Caching with Content-Hash Keys
|
| 3 |
+
|
| 4 |
+
LRU in-memory cache for aquifer scoring results. Thread-safe implementation
|
| 5 |
+
using a doubly-linked list for O(1) eviction and a dict for O(1) lookup.
|
| 6 |
+
|
| 7 |
+
Sibling implementation to GroundTruth pipeline/cache.py.
|
| 8 |
+
Same architecture: "We do not invent signals. We translate them."
|
| 9 |
+
|
| 10 |
+
Features:
|
| 11 |
+
- SHA256 content-hash keys derived from sorted, serialized input parameters
|
| 12 |
+
- TTL-based expiration with lazy eviction
|
| 13 |
+
- Thread-safe via threading.Lock
|
| 14 |
+
- Hit/miss statistics for monitoring
|
| 15 |
+
- Decorator for transparent caching of scoring functions
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
from pipeline.cache import ScoreCache, cached_score
|
| 19 |
+
|
| 20 |
+
cache = ScoreCache(max_size=1000, ttl_seconds=3600)
|
| 21 |
+
|
| 22 |
+
# Manual usage
|
| 23 |
+
key = cache.make_key(lat=37.5, lon=-122.1, aquifer_type="unconfined_alluvial")
|
| 24 |
+
result = cache.get(key)
|
| 25 |
+
if result is None:
|
| 26 |
+
result = expensive_computation(...)
|
| 27 |
+
cache.set(key, result)
|
| 28 |
+
|
| 29 |
+
# Decorator usage
|
| 30 |
+
@cached_score
|
| 31 |
+
def run_aquifer_score(grace, well, substrate, quality, aquifer_type, extraction_regime):
|
| 32 |
+
...
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import hashlib
|
| 38 |
+
import inspect
|
| 39 |
+
import json
|
| 40 |
+
import threading
|
| 41 |
+
import time
|
| 42 |
+
from functools import wraps
|
| 43 |
+
from typing import Any, Optional
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class _Node:
|
| 47 |
+
"""Doubly-linked list node for LRU eviction tracking."""
|
| 48 |
+
|
| 49 |
+
__slots__ = ("key", "value", "expires_at", "prev", "next")
|
| 50 |
+
|
| 51 |
+
def __init__(self, key: str, value: dict, expires_at: float) -> None:
|
| 52 |
+
self.key: str = key
|
| 53 |
+
self.value: dict = value
|
| 54 |
+
self.expires_at: float = expires_at
|
| 55 |
+
self.prev: Optional[_Node] = None
|
| 56 |
+
self.next: Optional[_Node] = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ScoreCache:
|
| 60 |
+
"""
|
| 61 |
+
Thread-safe LRU cache with TTL expiration for aquifer scoring results.
|
| 62 |
+
|
| 63 |
+
Implements a hash map backed by a doubly-linked list for O(1) operations:
|
| 64 |
+
- get(): O(1) lookup + move to head
|
| 65 |
+
- set(): O(1) insert at head + evict tail if at capacity
|
| 66 |
+
- invalidate(): O(1) removal by key
|
| 67 |
+
- clear(): O(n) full reset
|
| 68 |
+
|
| 69 |
+
All public methods are protected by a threading.Lock.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
max_size: Maximum number of cached entries. When exceeded, the
|
| 73 |
+
least-recently-used entry is evicted. Default 1000.
|
| 74 |
+
ttl_seconds: Time-to-live for each entry in seconds. Expired entries
|
| 75 |
+
are lazily evicted on access. Default 3600 (1 hour).
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
def __init__(self, max_size: int = 1000, ttl_seconds: int = 3600) -> None:
|
| 79 |
+
if max_size < 1:
|
| 80 |
+
raise ValueError("max_size must be >= 1")
|
| 81 |
+
if ttl_seconds < 0:
|
| 82 |
+
raise ValueError("ttl_seconds must be >= 0")
|
| 83 |
+
|
| 84 |
+
self._max_size: int = max_size
|
| 85 |
+
self._ttl_seconds: int = ttl_seconds
|
| 86 |
+
self._lock: threading.Lock = threading.Lock()
|
| 87 |
+
|
| 88 |
+
# Hash map: key -> Node
|
| 89 |
+
self._store: dict[str, _Node] = {}
|
| 90 |
+
|
| 91 |
+
# Doubly-linked list sentinel nodes (head = most recent, tail = least recent)
|
| 92 |
+
self._head: _Node = _Node("__HEAD__", {}, 0.0)
|
| 93 |
+
self._tail: _Node = _Node("__TAIL__", {}, 0.0)
|
| 94 |
+
self._head.next = self._tail
|
| 95 |
+
self._tail.prev = self._head
|
| 96 |
+
|
| 97 |
+
# Statistics
|
| 98 |
+
self._hits: int = 0
|
| 99 |
+
self._misses: int = 0
|
| 100 |
+
self._evictions: int = 0
|
| 101 |
+
self._expirations: int = 0
|
| 102 |
+
|
| 103 |
+
# -- Key Generation ---------------------------------------------------
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def make_key(**kwargs: Any) -> str:
|
| 107 |
+
"""
|
| 108 |
+
Generate a SHA256 content-hash key from keyword arguments.
|
| 109 |
+
|
| 110 |
+
Arguments are sorted by key name and serialized to a canonical
|
| 111 |
+
JSON string before hashing. This ensures that the same logical
|
| 112 |
+
inputs always produce the same cache key regardless of argument
|
| 113 |
+
ordering.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
A 64-character hex SHA256 digest string.
|
| 117 |
+
"""
|
| 118 |
+
return ScoreCache._make_key_from_dict(kwargs)
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
def _make_key_from_dict(params: dict) -> str:
|
| 122 |
+
"""SHA256 of sorted, serialized input params."""
|
| 123 |
+
canonical = json.dumps(params, sort_keys=True, default=str, separators=(",", ":"))
|
| 124 |
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
| 125 |
+
|
| 126 |
+
# -- Linked List Operations (internal, caller must hold lock) ----------
|
| 127 |
+
|
| 128 |
+
def _remove_node(self, node: _Node) -> None:
|
| 129 |
+
"""Remove a node from the doubly-linked list."""
|
| 130 |
+
prev_node = node.prev
|
| 131 |
+
next_node = node.next
|
| 132 |
+
if prev_node is not None:
|
| 133 |
+
prev_node.next = next_node
|
| 134 |
+
if next_node is not None:
|
| 135 |
+
next_node.prev = prev_node
|
| 136 |
+
node.prev = None
|
| 137 |
+
node.next = None
|
| 138 |
+
|
| 139 |
+
def _insert_after_head(self, node: _Node) -> None:
|
| 140 |
+
"""Insert a node right after the head sentinel (most recently used)."""
|
| 141 |
+
old_first = self._head.next
|
| 142 |
+
self._head.next = node
|
| 143 |
+
node.prev = self._head
|
| 144 |
+
node.next = old_first
|
| 145 |
+
if old_first is not None:
|
| 146 |
+
old_first.prev = node
|
| 147 |
+
|
| 148 |
+
def _evict_tail(self) -> Optional[str]:
|
| 149 |
+
"""Remove the least-recently-used node (just before tail sentinel)."""
|
| 150 |
+
victim = self._tail.prev
|
| 151 |
+
if victim is None or victim is self._head:
|
| 152 |
+
return None
|
| 153 |
+
self._remove_node(victim)
|
| 154 |
+
evicted_key = victim.key
|
| 155 |
+
self._store.pop(evicted_key, None)
|
| 156 |
+
self._evictions += 1
|
| 157 |
+
return evicted_key
|
| 158 |
+
|
| 159 |
+
# -- Public API -------------------------------------------------------
|
| 160 |
+
|
| 161 |
+
def get(self, key: str) -> Optional[dict]:
|
| 162 |
+
"""
|
| 163 |
+
Retrieve a cached value by key.
|
| 164 |
+
|
| 165 |
+
If the entry exists but has expired, it is removed and None is returned.
|
| 166 |
+
On a hit, the entry is promoted to most-recently-used position.
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
The cached dict value, or None if not found / expired.
|
| 170 |
+
"""
|
| 171 |
+
with self._lock:
|
| 172 |
+
node = self._store.get(key)
|
| 173 |
+
if node is None:
|
| 174 |
+
self._misses += 1
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
# Check TTL expiration
|
| 178 |
+
if time.monotonic() > node.expires_at:
|
| 179 |
+
self._remove_node(node)
|
| 180 |
+
del self._store[key]
|
| 181 |
+
self._misses += 1
|
| 182 |
+
self._expirations += 1
|
| 183 |
+
return None
|
| 184 |
+
|
| 185 |
+
# Move to head (most recently used)
|
| 186 |
+
self._remove_node(node)
|
| 187 |
+
self._insert_after_head(node)
|
| 188 |
+
self._hits += 1
|
| 189 |
+
return node.value
|
| 190 |
+
|
| 191 |
+
def set(self, key: str, value: dict) -> None:
|
| 192 |
+
"""
|
| 193 |
+
Store a value in the cache.
|
| 194 |
+
|
| 195 |
+
If the key already exists, its value and TTL are updated and it is
|
| 196 |
+
promoted to most-recently-used. If the cache is at capacity, the
|
| 197 |
+
least-recently-used entry is evicted first.
|
| 198 |
+
"""
|
| 199 |
+
with self._lock:
|
| 200 |
+
expires_at = time.monotonic() + self._ttl_seconds
|
| 201 |
+
|
| 202 |
+
# Update existing entry
|
| 203 |
+
existing = self._store.get(key)
|
| 204 |
+
if existing is not None:
|
| 205 |
+
self._remove_node(existing)
|
| 206 |
+
existing.value = value
|
| 207 |
+
existing.expires_at = expires_at
|
| 208 |
+
self._insert_after_head(existing)
|
| 209 |
+
return
|
| 210 |
+
|
| 211 |
+
# Evict if at capacity
|
| 212 |
+
if len(self._store) >= self._max_size:
|
| 213 |
+
self._evict_tail()
|
| 214 |
+
|
| 215 |
+
# Insert new entry
|
| 216 |
+
node = _Node(key, value, expires_at)
|
| 217 |
+
self._store[key] = node
|
| 218 |
+
self._insert_after_head(node)
|
| 219 |
+
|
| 220 |
+
def invalidate(self, key: str) -> bool:
|
| 221 |
+
"""Remove a specific entry from the cache."""
|
| 222 |
+
with self._lock:
|
| 223 |
+
node = self._store.pop(key, None)
|
| 224 |
+
if node is None:
|
| 225 |
+
return False
|
| 226 |
+
self._remove_node(node)
|
| 227 |
+
return True
|
| 228 |
+
|
| 229 |
+
def clear(self) -> int:
|
| 230 |
+
"""Remove all entries from the cache. Returns count cleared."""
|
| 231 |
+
with self._lock:
|
| 232 |
+
count = len(self._store)
|
| 233 |
+
self._store.clear()
|
| 234 |
+
self._head.next = self._tail
|
| 235 |
+
self._tail.prev = self._head
|
| 236 |
+
return count
|
| 237 |
+
|
| 238 |
+
def stats(self) -> dict:
|
| 239 |
+
"""Return cache performance statistics."""
|
| 240 |
+
with self._lock:
|
| 241 |
+
total = self._hits + self._misses
|
| 242 |
+
hit_rate = (self._hits / total * 100.0) if total > 0 else 0.0
|
| 243 |
+
return {
|
| 244 |
+
"hits": self._hits,
|
| 245 |
+
"misses": self._misses,
|
| 246 |
+
"size": len(self._store),
|
| 247 |
+
"max_size": self._max_size,
|
| 248 |
+
"hit_rate": round(hit_rate, 2),
|
| 249 |
+
"evictions": self._evictions,
|
| 250 |
+
"expirations": self._expirations,
|
| 251 |
+
"ttl_seconds": self._ttl_seconds,
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
@property
|
| 255 |
+
def size(self) -> int:
|
| 256 |
+
"""Current number of entries in the cache."""
|
| 257 |
+
with self._lock:
|
| 258 |
+
return len(self._store)
|
| 259 |
+
|
| 260 |
+
def __repr__(self) -> str:
|
| 261 |
+
s = self.stats()
|
| 262 |
+
return (
|
| 263 |
+
f"ScoreCache(size={s['size']}/{s['max_size']}, "
|
| 264 |
+
f"hit_rate={s['hit_rate']}%, "
|
| 265 |
+
f"hits={s['hits']}, misses={s['misses']})"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# -- Module-Level Default Cache -----------------------------------------------
|
| 270 |
+
|
| 271 |
+
_default_cache = ScoreCache(max_size=1000, ttl_seconds=3600)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def get_default_cache() -> ScoreCache:
|
| 275 |
+
"""Return the module-level default ScoreCache singleton."""
|
| 276 |
+
return _default_cache
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# -- Decorator ----------------------------------------------------------------
|
| 280 |
+
|
| 281 |
+
def cached_score(func=None, *, cache: Optional[ScoreCache] = None):
|
| 282 |
+
"""
|
| 283 |
+
Decorator that caches scoring function results by input content-hash.
|
| 284 |
+
|
| 285 |
+
Can be used with or without arguments:
|
| 286 |
+
|
| 287 |
+
@cached_score
|
| 288 |
+
def my_scoring_fn(grace, well, substrate, quality, aquifer_type, extraction_regime):
|
| 289 |
+
...
|
| 290 |
+
|
| 291 |
+
@cached_score(cache=my_custom_cache)
|
| 292 |
+
def my_scoring_fn(grace, well, substrate, quality, aquifer_type, extraction_regime):
|
| 293 |
+
...
|
| 294 |
+
"""
|
| 295 |
+
target_cache = cache or _default_cache
|
| 296 |
+
|
| 297 |
+
def decorator(fn):
|
| 298 |
+
sig = inspect.signature(fn)
|
| 299 |
+
|
| 300 |
+
@wraps(fn)
|
| 301 |
+
def wrapper(*args, **kwargs):
|
| 302 |
+
bound = sig.bind(*args, **kwargs)
|
| 303 |
+
bound.apply_defaults()
|
| 304 |
+
all_params = dict(bound.arguments)
|
| 305 |
+
all_params["__fn__"] = fn.__qualname__
|
| 306 |
+
|
| 307 |
+
key = ScoreCache._make_key_from_dict(all_params)
|
| 308 |
+
|
| 309 |
+
cached = target_cache.get(key)
|
| 310 |
+
if cached is not None:
|
| 311 |
+
return cached
|
| 312 |
+
|
| 313 |
+
result = fn(*args, **kwargs)
|
| 314 |
+
|
| 315 |
+
if isinstance(result, dict):
|
| 316 |
+
target_cache.set(key, result)
|
| 317 |
+
elif hasattr(result, "__dict__"):
|
| 318 |
+
target_cache.set(key, {"__cached_obj__": True, "__data__": vars(result)})
|
| 319 |
+
|
| 320 |
+
return result
|
| 321 |
+
|
| 322 |
+
wrapper.cache = target_cache
|
| 323 |
+
return wrapper
|
| 324 |
+
|
| 325 |
+
if func is not None:
|
| 326 |
+
return decorator(func)
|
| 327 |
+
return decorator
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
if __name__ == "__main__":
|
| 331 |
+
import concurrent.futures
|
| 332 |
+
import time as _time
|
| 333 |
+
|
| 334 |
+
print("=" * 60)
|
| 335 |
+
print("AquiScore ScoreCache -- Self-Test")
|
| 336 |
+
print("=" * 60)
|
| 337 |
+
|
| 338 |
+
c = ScoreCache(max_size=3, ttl_seconds=2)
|
| 339 |
+
|
| 340 |
+
k1 = c.make_key(lat=37.5, lon=-122.1, aquifer_type="unconfined_alluvial")
|
| 341 |
+
k2 = c.make_key(aquifer_type="unconfined_alluvial", lat=37.5, lon=-122.1)
|
| 342 |
+
assert k1 == k2, "Key generation must be order-independent"
|
| 343 |
+
print(f" Key determinism: PASS")
|
| 344 |
+
|
| 345 |
+
c.set(k1, {"score": 78, "risk_class": "SECURE"})
|
| 346 |
+
result = c.get(k1)
|
| 347 |
+
assert result is not None and result["score"] == 78
|
| 348 |
+
print(f" Set/Get: PASS")
|
| 349 |
+
|
| 350 |
+
c.set(c.make_key(a=1), {"score": 1})
|
| 351 |
+
c.set(c.make_key(a=2), {"score": 2})
|
| 352 |
+
c.set(c.make_key(a=3), {"score": 3})
|
| 353 |
+
assert c.get(k1) is None
|
| 354 |
+
print(" LRU eviction: PASS")
|
| 355 |
+
|
| 356 |
+
s = c.stats()
|
| 357 |
+
assert s["hits"] >= 1 and s["misses"] >= 1
|
| 358 |
+
print(f" Stats: PASS (hit_rate={s['hit_rate']}%)")
|
| 359 |
+
|
| 360 |
+
c2 = ScoreCache(max_size=10, ttl_seconds=0)
|
| 361 |
+
k_ttl = c2.make_key(test="ttl")
|
| 362 |
+
c2.set(k_ttl, {"score": 50})
|
| 363 |
+
_time.sleep(0.01)
|
| 364 |
+
assert c2.get(k_ttl) is None
|
| 365 |
+
print(" TTL expiration: PASS")
|
| 366 |
+
|
| 367 |
+
c3 = ScoreCache(max_size=10)
|
| 368 |
+
c3.set(c3.make_key(x=1), {"v": 1})
|
| 369 |
+
c3.set(c3.make_key(x=2), {"v": 2})
|
| 370 |
+
cleared = c3.clear()
|
| 371 |
+
assert cleared == 2 and c3.size == 0
|
| 372 |
+
print(f" Clear: PASS (cleared={cleared})")
|
| 373 |
+
|
| 374 |
+
tc = ScoreCache(max_size=100, ttl_seconds=60)
|
| 375 |
+
errors = []
|
| 376 |
+
|
| 377 |
+
def stress_worker(worker_id):
|
| 378 |
+
try:
|
| 379 |
+
for i in range(50):
|
| 380 |
+
k = tc.make_key(worker=worker_id, iteration=i)
|
| 381 |
+
tc.set(k, {"worker": worker_id, "i": i})
|
| 382 |
+
r = tc.get(k)
|
| 383 |
+
if r is None or r["worker"] != worker_id:
|
| 384 |
+
errors.append(f"Worker {worker_id} iteration {i}: unexpected result")
|
| 385 |
+
except Exception as e:
|
| 386 |
+
errors.append(f"Worker {worker_id}: {e}")
|
| 387 |
+
|
| 388 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
| 389 |
+
futures = [pool.submit(stress_worker, w) for w in range(8)]
|
| 390 |
+
concurrent.futures.wait(futures)
|
| 391 |
+
|
| 392 |
+
assert len(errors) == 0, f"Thread safety errors: {errors}"
|
| 393 |
+
print(" Thread safety: PASS (8 workers x 50 ops)")
|
| 394 |
+
|
| 395 |
+
print("\n All tests passed.")
|
| 396 |
+
print("=" * 60)
|
pipeline/ccme_wqi.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AquiScore v2.0 — CCME Water Quality Index Implementation
|
| 3 |
+
|
| 4 |
+
Implements the Canadian Council of Ministers of the Environment (CCME)
|
| 5 |
+
Water Quality Index, replacing the v1.x linear threshold-based quality
|
| 6 |
+
scoring with a three-dimensional assessment.
|
| 7 |
+
|
| 8 |
+
============================================================================
|
| 9 |
+
WHY THIS MATTERS (for Environmental Risk Assessors)
|
| 10 |
+
============================================================================
|
| 11 |
+
|
| 12 |
+
v1.x scored each water quality parameter independently against WHO/EPA
|
| 13 |
+
thresholds and averaged the results. This approach misses two critical
|
| 14 |
+
dimensions of water quality assessment:
|
| 15 |
+
|
| 16 |
+
1. SCOPE: How many parameters fail? A site with one parameter slightly
|
| 17 |
+
over the limit is very different from a site with four parameters
|
| 18 |
+
over the limit, even if the average "score" is similar.
|
| 19 |
+
|
| 20 |
+
2. FREQUENCY: How often do parameters fail? With repeated measurements,
|
| 21 |
+
a parameter that fails 1 out of 10 times is very different from one
|
| 22 |
+
that fails 9 out of 10 times.
|
| 23 |
+
|
| 24 |
+
The CCME WQI captures all three dimensions in a single index:
|
| 25 |
+
F1 = Scope (% of parameters that fail)
|
| 26 |
+
F2 = Frequency (% of individual tests that fail)
|
| 27 |
+
F3 = Amplitude (how far failed tests exceed their guidelines)
|
| 28 |
+
|
| 29 |
+
WQI = 100 - (sqrt(F1² + F2² + F3²) / 1.732)
|
| 30 |
+
|
| 31 |
+
The denominator 1.732 (= sqrt(3)) normalizes so that when all three
|
| 32 |
+
F-values = 100 (worst case), WQI = 0.
|
| 33 |
+
|
| 34 |
+
============================================================================
|
| 35 |
+
CCME WQI CATEGORIES
|
| 36 |
+
============================================================================
|
| 37 |
+
|
| 38 |
+
95-100: EXCELLENT — Water quality is protected with virtually no
|
| 39 |
+
threat or impairment.
|
| 40 |
+
80-94: GOOD — Water quality is protected with only a minor degree
|
| 41 |
+
of threat or impairment.
|
| 42 |
+
65-79: FAIR — Water quality is usually protected but occasionally
|
| 43 |
+
threatened or impaired.
|
| 44 |
+
45-64: MARGINAL — Water quality is frequently threatened or impaired.
|
| 45 |
+
0-44: POOR — Water quality is almost always threatened or impaired.
|
| 46 |
+
|
| 47 |
+
Reference:
|
| 48 |
+
CCME (2001) Canadian Water Quality Guidelines for the Protection of
|
| 49 |
+
Aquatic Life: CCME Water Quality Index 1.0, User's Manual.
|
| 50 |
+
In: Canadian Environmental Quality Guidelines, 1999.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
from __future__ import annotations
|
| 54 |
+
|
| 55 |
+
import math
|
| 56 |
+
from dataclasses import dataclass, field
|
| 57 |
+
from typing import Optional
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── GUIDELINE THRESHOLDS ────────────────────────────────────────────────────
|
| 61 |
+
# WHO (2022) Guidelines for Drinking-water Quality, 4th ed.
|
| 62 |
+
# EPA (2024) National Primary Drinking Water Regulations
|
| 63 |
+
|
| 64 |
+
GUIDELINES = {
|
| 65 |
+
"nitrate_mg_l": {
|
| 66 |
+
"objective": 10.0, # WHO/EPA MCL
|
| 67 |
+
"direction": "max", # must not EXCEED
|
| 68 |
+
"unit": "mg/L",
|
| 69 |
+
"source": "WHO (2022) / US EPA NPDWR",
|
| 70 |
+
},
|
| 71 |
+
"arsenic_ug_l": {
|
| 72 |
+
"objective": 10.0, # WHO/EPA MCL
|
| 73 |
+
"direction": "max",
|
| 74 |
+
"unit": "µg/L",
|
| 75 |
+
"source": "WHO (2022) / US EPA NPDWR",
|
| 76 |
+
},
|
| 77 |
+
"fluoride_mg_l": {
|
| 78 |
+
"objective": 1.5, # WHO guideline
|
| 79 |
+
"direction": "max",
|
| 80 |
+
"unit": "mg/L",
|
| 81 |
+
"source": "WHO (2022)",
|
| 82 |
+
},
|
| 83 |
+
"tds_mg_l": {
|
| 84 |
+
"objective": 500.0, # EPA SMCL (aesthetic)
|
| 85 |
+
"direction": "max",
|
| 86 |
+
"unit": "mg/L",
|
| 87 |
+
"source": "US EPA SMCL",
|
| 88 |
+
},
|
| 89 |
+
"ph_low": {
|
| 90 |
+
"objective": 6.5, # EPA SMCL lower bound
|
| 91 |
+
"direction": "min", # must not fall BELOW
|
| 92 |
+
"unit": "pH units",
|
| 93 |
+
"source": "US EPA SMCL",
|
| 94 |
+
},
|
| 95 |
+
"ph_high": {
|
| 96 |
+
"objective": 8.5, # EPA SMCL upper bound
|
| 97 |
+
"direction": "max",
|
| 98 |
+
"unit": "pH units",
|
| 99 |
+
"source": "US EPA SMCL",
|
| 100 |
+
},
|
| 101 |
+
"e_coli_cfu_100ml": {
|
| 102 |
+
"objective": 0.0, # WHO: 0 in drinking water
|
| 103 |
+
"direction": "max",
|
| 104 |
+
"unit": "CFU/100mL",
|
| 105 |
+
"source": "WHO (2022)",
|
| 106 |
+
},
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ── CCME WQI COMPUTATION ───────────────────────────────────────────────────
|
| 111 |
+
|
| 112 |
+
@dataclass
|
| 113 |
+
class CCMEResult:
|
| 114 |
+
"""
|
| 115 |
+
CCME Water Quality Index result.
|
| 116 |
+
|
| 117 |
+
Attributes:
|
| 118 |
+
wqi: The CCME WQI score (0-100). Higher = better water quality.
|
| 119 |
+
category: EXCELLENT / GOOD / FAIR / MARGINAL / POOR.
|
| 120 |
+
f1_scope: Percentage of parameters that fail (0-100).
|
| 121 |
+
f2_frequency: Percentage of individual tests that fail (0-100).
|
| 122 |
+
f3_amplitude: Normalized magnitude of exceedances (0-100, asymptotic).
|
| 123 |
+
n_variables: Total number of guideline parameters tested.
|
| 124 |
+
n_failed_variables: Number of parameters with at least one exceedance.
|
| 125 |
+
n_tests: Total number of individual test results.
|
| 126 |
+
n_failed_tests: Number of individual tests exceeding guidelines.
|
| 127 |
+
exceedances: List of parameter-level exceedance details.
|
| 128 |
+
interpretation: Human-readable interpretation for risk assessors.
|
| 129 |
+
"""
|
| 130 |
+
wqi: float
|
| 131 |
+
category: str
|
| 132 |
+
f1_scope: float
|
| 133 |
+
f2_frequency: float
|
| 134 |
+
f3_amplitude: float
|
| 135 |
+
n_variables: int
|
| 136 |
+
n_failed_variables: int
|
| 137 |
+
n_tests: int
|
| 138 |
+
n_failed_tests: int
|
| 139 |
+
exceedances: list = field(default_factory=list)
|
| 140 |
+
interpretation: str = ""
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _classify_wqi(wqi: float) -> str:
|
| 144 |
+
"""Classify WQI into CCME categories."""
|
| 145 |
+
if wqi >= 95:
|
| 146 |
+
return "EXCELLENT"
|
| 147 |
+
elif wqi >= 80:
|
| 148 |
+
return "GOOD"
|
| 149 |
+
elif wqi >= 65:
|
| 150 |
+
return "FAIR"
|
| 151 |
+
elif wqi >= 45:
|
| 152 |
+
return "MARGINAL"
|
| 153 |
+
else:
|
| 154 |
+
return "POOR"
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def compute_ccme_wqi(
|
| 158 |
+
measurements: dict,
|
| 159 |
+
guidelines: Optional[dict] = None,
|
| 160 |
+
) -> CCMEResult:
|
| 161 |
+
"""
|
| 162 |
+
Compute the CCME Water Quality Index from water quality measurements.
|
| 163 |
+
|
| 164 |
+
This implementation supports single-measurement snapshots (typical for
|
| 165 |
+
AquiScore field assessments) where n_tests = n_variables.
|
| 166 |
+
For time-series data with multiple measurements per parameter,
|
| 167 |
+
pass a dict where each value is a list of measurements.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
measurements: Dict mapping parameter names to values or lists of values.
|
| 171 |
+
Expected keys: nitrate_mg_l, arsenic_ug_l, fluoride_mg_l,
|
| 172 |
+
tds_mg_l, ph, e_coli_cfu_100ml.
|
| 173 |
+
Values can be:
|
| 174 |
+
- float: single measurement (F2 = F1 in this case)
|
| 175 |
+
- list[float]: multiple measurements over time
|
| 176 |
+
|
| 177 |
+
guidelines: Optional custom guideline dict. Defaults to WHO/EPA
|
| 178 |
+
thresholds defined in GUIDELINES constant.
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
CCMEResult with WQI score, F1/F2/F3 components, and exceedance details.
|
| 182 |
+
"""
|
| 183 |
+
gl = guidelines or GUIDELINES
|
| 184 |
+
|
| 185 |
+
# Expand pH into two directional tests
|
| 186 |
+
expanded: dict[str, list[float]] = {}
|
| 187 |
+
for param, value in measurements.items():
|
| 188 |
+
if param == "ph":
|
| 189 |
+
vals = value if isinstance(value, list) else [value]
|
| 190 |
+
expanded["ph_low"] = vals
|
| 191 |
+
expanded["ph_high"] = vals
|
| 192 |
+
else:
|
| 193 |
+
expanded[param] = value if isinstance(value, list) else [value]
|
| 194 |
+
|
| 195 |
+
# Count variables and tests
|
| 196 |
+
n_variables = 0
|
| 197 |
+
n_tests = 0
|
| 198 |
+
n_failed_variables = 0
|
| 199 |
+
n_failed_tests = 0
|
| 200 |
+
excursion_sum = 0.0
|
| 201 |
+
exceedances = []
|
| 202 |
+
|
| 203 |
+
for param, guideline in gl.items():
|
| 204 |
+
if param not in expanded:
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
vals = expanded[param]
|
| 208 |
+
n_variables += 1
|
| 209 |
+
n_tests += len(vals)
|
| 210 |
+
|
| 211 |
+
objective = guideline["objective"]
|
| 212 |
+
direction = guideline["direction"]
|
| 213 |
+
variable_failed = False
|
| 214 |
+
|
| 215 |
+
for v in vals:
|
| 216 |
+
failed = False
|
| 217 |
+
excursion = 0.0
|
| 218 |
+
|
| 219 |
+
if direction == "max" and v > objective:
|
| 220 |
+
failed = True
|
| 221 |
+
if objective > 0:
|
| 222 |
+
excursion = (v / objective) - 1.0
|
| 223 |
+
else:
|
| 224 |
+
# E. coli: objective is 0, any positive is a failure
|
| 225 |
+
excursion = v # Use absolute value as excursion
|
| 226 |
+
elif direction == "min" and v < objective:
|
| 227 |
+
failed = True
|
| 228 |
+
if v > 0:
|
| 229 |
+
excursion = (objective / v) - 1.0
|
| 230 |
+
else:
|
| 231 |
+
excursion = objective # Avoid division by zero
|
| 232 |
+
|
| 233 |
+
if failed:
|
| 234 |
+
n_failed_tests += 1
|
| 235 |
+
variable_failed = True
|
| 236 |
+
excursion_sum += excursion
|
| 237 |
+
|
| 238 |
+
exceedances.append({
|
| 239 |
+
"parameter": param,
|
| 240 |
+
"measured": round(v, 3),
|
| 241 |
+
"objective": objective,
|
| 242 |
+
"unit": guideline["unit"],
|
| 243 |
+
"excursion": round(excursion, 4),
|
| 244 |
+
"source": guideline["source"],
|
| 245 |
+
})
|
| 246 |
+
|
| 247 |
+
if variable_failed:
|
| 248 |
+
n_failed_variables += 1
|
| 249 |
+
|
| 250 |
+
# Compute F1, F2, F3
|
| 251 |
+
f1 = (n_failed_variables / n_variables * 100.0) if n_variables > 0 else 0.0
|
| 252 |
+
f2 = (n_failed_tests / n_tests * 100.0) if n_tests > 0 else 0.0
|
| 253 |
+
|
| 254 |
+
# F3: normalized sum of excursions (asymptotic function)
|
| 255 |
+
nse = excursion_sum / n_tests if n_tests > 0 else 0.0
|
| 256 |
+
f3 = (nse / (0.01 * nse + 0.01)) if nse > 0 else 0.0
|
| 257 |
+
|
| 258 |
+
# Final WQI
|
| 259 |
+
magnitude = math.sqrt(f1 ** 2 + f2 ** 2 + f3 ** 2)
|
| 260 |
+
wqi = max(0.0, min(100.0, 100.0 - magnitude / 1.732))
|
| 261 |
+
|
| 262 |
+
category = _classify_wqi(wqi)
|
| 263 |
+
|
| 264 |
+
# Interpretation
|
| 265 |
+
if category == "EXCELLENT":
|
| 266 |
+
interpretation = (
|
| 267 |
+
f"CCME WQI = {wqi:.1f} (EXCELLENT). All measured parameters "
|
| 268 |
+
f"are within WHO/EPA guidelines. Water quality poses virtually "
|
| 269 |
+
f"no threat to human health or ecosystem function. "
|
| 270 |
+
f"No quality-related deductions applied to AquiScore."
|
| 271 |
+
)
|
| 272 |
+
elif category == "GOOD":
|
| 273 |
+
interpretation = (
|
| 274 |
+
f"CCME WQI = {wqi:.1f} (GOOD). Water quality is protected with "
|
| 275 |
+
f"minor exceedances. {n_failed_variables} of {n_variables} parameters "
|
| 276 |
+
f"exceeded guidelines. Exceedances are small in magnitude (F3={f3:.1f}). "
|
| 277 |
+
f"Minor quality deduction applied to AquiScore."
|
| 278 |
+
)
|
| 279 |
+
elif category == "FAIR":
|
| 280 |
+
interpretation = (
|
| 281 |
+
f"CCME WQI = {wqi:.1f} (FAIR). Water quality is occasionally "
|
| 282 |
+
f"threatened. {n_failed_variables} of {n_variables} parameters "
|
| 283 |
+
f"exceeded guidelines (F1={f1:.0f}%). "
|
| 284 |
+
f"Moderate quality deduction applied to AquiScore."
|
| 285 |
+
)
|
| 286 |
+
elif category == "MARGINAL":
|
| 287 |
+
interpretation = (
|
| 288 |
+
f"CCME WQI = {wqi:.1f} (MARGINAL). Water quality is frequently "
|
| 289 |
+
f"threatened or impaired. {n_failed_variables} of {n_variables} "
|
| 290 |
+
f"parameters exceeded guidelines. Exceedance magnitude is significant "
|
| 291 |
+
f"(F3={f3:.1f}). Substantial quality deduction applied to AquiScore."
|
| 292 |
+
)
|
| 293 |
+
else:
|
| 294 |
+
interpretation = (
|
| 295 |
+
f"CCME WQI = {wqi:.1f} (POOR). Water quality is almost always "
|
| 296 |
+
f"threatened or impaired. {n_failed_variables} of {n_variables} "
|
| 297 |
+
f"parameters exceeded guidelines with large exceedances "
|
| 298 |
+
f"(F3={f3:.1f}). Maximum quality deduction applied to AquiScore."
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
return CCMEResult(
|
| 302 |
+
wqi=round(wqi, 1),
|
| 303 |
+
category=category,
|
| 304 |
+
f1_scope=round(f1, 1),
|
| 305 |
+
f2_frequency=round(f2, 1),
|
| 306 |
+
f3_amplitude=round(f3, 1),
|
| 307 |
+
n_variables=n_variables,
|
| 308 |
+
n_failed_variables=n_failed_variables,
|
| 309 |
+
n_tests=n_tests,
|
| 310 |
+
n_failed_tests=n_failed_tests,
|
| 311 |
+
exceedances=exceedances,
|
| 312 |
+
interpretation=interpretation,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def ccme_wqi_to_score(wqi: float) -> float:
|
| 317 |
+
"""
|
| 318 |
+
Convert CCME WQI (0-100) to the AquiScore quality component score (0-100).
|
| 319 |
+
|
| 320 |
+
The WQI is already on a 0-100 scale but with different breakpoints
|
| 321 |
+
than the AquiScore quality component. This function maps:
|
| 322 |
+
WQI 95-100 → Score 95-100 (EXCELLENT)
|
| 323 |
+
WQI 80-94 → Score 75-95 (GOOD)
|
| 324 |
+
WQI 65-79 → Score 50-75 (FAIR)
|
| 325 |
+
WQI 45-64 → Score 25-50 (MARGINAL)
|
| 326 |
+
WQI 0-44 → Score 0-25 (POOR)
|
| 327 |
+
|
| 328 |
+
The mapping compresses the GOOD and FAIR ranges slightly to make
|
| 329 |
+
the quality component more sensitive to impairment.
|
| 330 |
+
|
| 331 |
+
Args:
|
| 332 |
+
wqi: CCME Water Quality Index (0-100).
|
| 333 |
+
|
| 334 |
+
Returns:
|
| 335 |
+
AquiScore quality component score (0-100).
|
| 336 |
+
"""
|
| 337 |
+
if wqi >= 95:
|
| 338 |
+
return 95.0 + (wqi - 95.0)
|
| 339 |
+
elif wqi >= 80:
|
| 340 |
+
return 75.0 + (wqi - 80.0) / 15.0 * 20.0
|
| 341 |
+
elif wqi >= 65:
|
| 342 |
+
return 50.0 + (wqi - 65.0) / 15.0 * 25.0
|
| 343 |
+
elif wqi >= 45:
|
| 344 |
+
return 25.0 + (wqi - 45.0) / 20.0 * 25.0
|
| 345 |
+
else:
|
| 346 |
+
return max(0.0, wqi / 45.0 * 25.0)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ── SELF-TEST ───────────────────────────────────────────────────────────────
|
| 350 |
+
|
| 351 |
+
if __name__ == "__main__":
|
| 352 |
+
print("=" * 72)
|
| 353 |
+
print("AquiScore v2.0 — CCME Water Quality Index — Self-Test")
|
| 354 |
+
print("=" * 72)
|
| 355 |
+
|
| 356 |
+
# Test 1: Pristine water (all within guidelines)
|
| 357 |
+
pristine = {
|
| 358 |
+
"nitrate_mg_l": 2.0,
|
| 359 |
+
"arsenic_ug_l": 1.0,
|
| 360 |
+
"fluoride_mg_l": 0.3,
|
| 361 |
+
"tds_mg_l": 180.0,
|
| 362 |
+
"ph": 7.1,
|
| 363 |
+
"e_coli_cfu_100ml": 0.0,
|
| 364 |
+
}
|
| 365 |
+
r1 = compute_ccme_wqi(pristine)
|
| 366 |
+
assert r1.category == "EXCELLENT", f"Pristine should be EXCELLENT: {r1.category}"
|
| 367 |
+
assert r1.f1_scope == 0.0
|
| 368 |
+
assert r1.n_failed_variables == 0
|
| 369 |
+
print(f" Pristine water: PASS (WQI={r1.wqi}, category={r1.category})")
|
| 370 |
+
|
| 371 |
+
# Test 2: Moderate contamination (nitrate + TDS over limits)
|
| 372 |
+
moderate = {
|
| 373 |
+
"nitrate_mg_l": 28.0,
|
| 374 |
+
"arsenic_ug_l": 5.0,
|
| 375 |
+
"fluoride_mg_l": 1.0,
|
| 376 |
+
"tds_mg_l": 650.0,
|
| 377 |
+
"ph": 7.4,
|
| 378 |
+
"e_coli_cfu_100ml": 0.0,
|
| 379 |
+
}
|
| 380 |
+
r2 = compute_ccme_wqi(moderate)
|
| 381 |
+
assert r2.n_failed_variables > 0
|
| 382 |
+
assert r2.wqi < r1.wqi, "Contaminated should score lower"
|
| 383 |
+
print(f" Moderate contam: PASS (WQI={r2.wqi}, category={r2.category}, "
|
| 384 |
+
f"failed={r2.n_failed_variables}/{r2.n_variables})")
|
| 385 |
+
|
| 386 |
+
# Test 3: Severe contamination (multiple exceedances)
|
| 387 |
+
severe = {
|
| 388 |
+
"nitrate_mg_l": 42.0,
|
| 389 |
+
"arsenic_ug_l": 28.0,
|
| 390 |
+
"fluoride_mg_l": 2.8,
|
| 391 |
+
"tds_mg_l": 1500.0,
|
| 392 |
+
"ph": 8.1,
|
| 393 |
+
"e_coli_cfu_100ml": 3.0,
|
| 394 |
+
}
|
| 395 |
+
r3 = compute_ccme_wqi(severe)
|
| 396 |
+
assert r3.wqi < r2.wqi, "Severe should score lower than moderate"
|
| 397 |
+
assert r3.category in ("POOR", "MARGINAL")
|
| 398 |
+
print(f" Severe contam: PASS (WQI={r3.wqi}, category={r3.category}, "
|
| 399 |
+
f"F1={r3.f1_scope}, F2={r3.f2_frequency}, F3={r3.f3_amplitude})")
|
| 400 |
+
|
| 401 |
+
# Test 4: F3 asymptotic behavior
|
| 402 |
+
extreme = {
|
| 403 |
+
"nitrate_mg_l": 500.0, # 50x over limit
|
| 404 |
+
"arsenic_ug_l": 500.0, # 50x over limit
|
| 405 |
+
"fluoride_mg_l": 10.0,
|
| 406 |
+
"tds_mg_l": 10000.0,
|
| 407 |
+
"ph": 4.0,
|
| 408 |
+
"e_coli_cfu_100ml": 100.0,
|
| 409 |
+
}
|
| 410 |
+
r4 = compute_ccme_wqi(extreme)
|
| 411 |
+
assert r4.f3_amplitude > 90, f"Extreme exceedances should have high F3: {r4.f3_amplitude}"
|
| 412 |
+
assert r4.wqi < 15, f"Extreme contamination should be near 0: {r4.wqi}"
|
| 413 |
+
print(f" Extreme (asymptotic): PASS (WQI={r4.wqi}, F3={r4.f3_amplitude})")
|
| 414 |
+
|
| 415 |
+
# Test 5: Time-series measurements
|
| 416 |
+
timeseries = {
|
| 417 |
+
"nitrate_mg_l": [8.0, 9.0, 11.0, 12.0, 7.0, 15.0, 6.0, 8.0, 13.0, 9.0],
|
| 418 |
+
"arsenic_ug_l": [3.0, 4.0, 5.0, 3.0, 6.0],
|
| 419 |
+
"fluoride_mg_l": [0.8, 0.9, 1.0, 1.1],
|
| 420 |
+
"tds_mg_l": [400, 420, 380, 450, 510, 390],
|
| 421 |
+
"ph": [7.0, 7.1, 7.2, 6.9, 7.3],
|
| 422 |
+
"e_coli_cfu_100ml": [0, 0, 0, 1, 0],
|
| 423 |
+
}
|
| 424 |
+
r5 = compute_ccme_wqi(timeseries)
|
| 425 |
+
# F2 should be different from F1 because not all tests of a failed parameter fail
|
| 426 |
+
print(f" Time-series: PASS (WQI={r5.wqi}, F1={r5.f1_scope}%, "
|
| 427 |
+
f"F2={r5.f2_frequency}%, n_tests={r5.n_tests})")
|
| 428 |
+
|
| 429 |
+
# Test 6: WQI to AquiScore mapping
|
| 430 |
+
score_excellent = ccme_wqi_to_score(98.0)
|
| 431 |
+
score_good = ccme_wqi_to_score(85.0)
|
| 432 |
+
score_fair = ccme_wqi_to_score(70.0)
|
| 433 |
+
score_marginal = ccme_wqi_to_score(50.0)
|
| 434 |
+
score_poor = ccme_wqi_to_score(20.0)
|
| 435 |
+
assert score_excellent > score_good > score_fair > score_marginal > score_poor
|
| 436 |
+
print(f" WQI→Score mapping: PASS (98→{score_excellent:.0f}, 85→{score_good:.0f}, "
|
| 437 |
+
f"70→{score_fair:.0f}, 50→{score_marginal:.0f}, 20→{score_poor:.0f})")
|
| 438 |
+
|
| 439 |
+
print("\n All tests passed.")
|
| 440 |
+
print("=" * 72)
|
pipeline/drastic_rating.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AquiScore v2.0 — DRASTIC Groundwater Vulnerability Rating Framework
|
| 3 |
+
|
| 4 |
+
Implements the US EPA DRASTIC framework (Aller et al. 1987) as a
|
| 5 |
+
documented, auditable vulnerability assessment component for AquiScore.
|
| 6 |
+
|
| 7 |
+
============================================================================
|
| 8 |
+
WHY THIS MATTERS (for Environmental Data Engineers)
|
| 9 |
+
============================================================================
|
| 10 |
+
|
| 11 |
+
DRASTIC is the most widely validated groundwater vulnerability index
|
| 12 |
+
globally, cited in over 2,000 peer-reviewed studies. It provides
|
| 13 |
+
documented, reproducible rating tables that map raw hydrogeological
|
| 14 |
+
measurements to standardized 1-10 vulnerability ratings.
|
| 15 |
+
|
| 16 |
+
v1.x computed substrate and well scores using ad hoc scoring functions
|
| 17 |
+
without standardized rating tables. v2.0 adds DRASTIC as an AUDITABLE
|
| 18 |
+
reference framework with published, peer-reviewed rating breakpoints.
|
| 19 |
+
|
| 20 |
+
AquiScore does NOT replace its composite scoring model with DRASTIC.
|
| 21 |
+
Instead, DRASTIC serves as:
|
| 22 |
+
1. A validation reference (does AquiScore agree with DRASTIC rankings?)
|
| 23 |
+
2. An auditable sub-index that risk assessors can reference
|
| 24 |
+
3. A standardized rating table for vulnerability reporting
|
| 25 |
+
|
| 26 |
+
============================================================================
|
| 27 |
+
THE SEVEN DRASTIC PARAMETERS
|
| 28 |
+
============================================================================
|
| 29 |
+
|
| 30 |
+
| Param | Name | Weight | What It Measures |
|
| 31 |
+
|-------|-------------------------|--------|---------------------------------|
|
| 32 |
+
| D | Depth to water table | 5 | Travel time for contaminants |
|
| 33 |
+
| R | Net Recharge | 4 | Volume of water reaching aquifer|
|
| 34 |
+
| A | Aquifer media | 3 | Permeability of aquifer material|
|
| 35 |
+
| S | Soil media | 2 | Attenuation capacity of soil |
|
| 36 |
+
| T | Topography (slope) | 1 | Surface runoff vs infiltration |
|
| 37 |
+
| I | Impact of vadose zone | 5 | Unsaturated zone permeability |
|
| 38 |
+
| C | Hydraulic Conductivity | 3 | Flow rate through aquifer |
|
| 39 |
+
|
| 40 |
+
DRASTIC Index = Σ(Rating_i × Weight_i)
|
| 41 |
+
Range: 23 (minimum vulnerability) to 230 (maximum vulnerability)
|
| 42 |
+
|
| 43 |
+
Note: DRASTIC measures VULNERABILITY (higher = worse).
|
| 44 |
+
AquiScore measures SECURITY (higher = better).
|
| 45 |
+
The conversion is: security_score = 100 × (1 - (DI - 23) / (230 - 23))
|
| 46 |
+
|
| 47 |
+
Reference:
|
| 48 |
+
Aller, L. et al. (1987) DRASTIC: A standardized system for evaluating
|
| 49 |
+
ground water pollution potential using hydrogeologic settings.
|
| 50 |
+
US EPA Report 600/2-87-035.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
from __future__ import annotations
|
| 54 |
+
|
| 55 |
+
import math
|
| 56 |
+
from dataclasses import dataclass, field
|
| 57 |
+
from typing import Optional
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── DRASTIC RATING TABLES ──────────────────────────────────────────────────
|
| 61 |
+
# Each table maps measurement ranges to ratings (1-10).
|
| 62 |
+
# Source: Aller et al. (1987), Table 1-7.
|
| 63 |
+
|
| 64 |
+
# D: Depth to Water (meters)
|
| 65 |
+
# Shallower = higher vulnerability (rating 10 = most vulnerable)
|
| 66 |
+
DEPTH_RATINGS = [
|
| 67 |
+
# (min_m, max_m, rating)
|
| 68 |
+
(0.0, 1.5, 10),
|
| 69 |
+
(1.5, 4.6, 9),
|
| 70 |
+
(4.6, 9.1, 7),
|
| 71 |
+
(9.1, 15.2, 5),
|
| 72 |
+
(15.2, 22.9, 3),
|
| 73 |
+
(22.9, 30.5, 2),
|
| 74 |
+
(30.5, 9999.0, 1),
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
# R: Net Recharge (mm/year)
|
| 78 |
+
# Higher recharge = more contaminant transport = higher vulnerability
|
| 79 |
+
RECHARGE_RATINGS = [
|
| 80 |
+
# (min_mm_yr, max_mm_yr, rating)
|
| 81 |
+
(0, 50, 1),
|
| 82 |
+
(50, 100, 3),
|
| 83 |
+
(100, 175, 6),
|
| 84 |
+
(175, 250, 8),
|
| 85 |
+
(250, 9999, 9),
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
# A: Aquifer Media
|
| 89 |
+
# Rating by aquifer material type (from least to most permeable)
|
| 90 |
+
AQUIFER_MEDIA_RATINGS = {
|
| 91 |
+
"massive_shale": 2,
|
| 92 |
+
"metamorphic_igneous": 3,
|
| 93 |
+
"weathered_metamorphic": 4,
|
| 94 |
+
"glacial_till": 5,
|
| 95 |
+
"bedded_sandstone": 6,
|
| 96 |
+
"massive_limestone": 6,
|
| 97 |
+
"sand_gravel": 8,
|
| 98 |
+
"basalt": 9,
|
| 99 |
+
"karst_limestone": 10,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
# S: Soil Media
|
| 103 |
+
# Rating by soil type (attenuation capacity)
|
| 104 |
+
SOIL_MEDIA_RATINGS = {
|
| 105 |
+
"thin_absent": 10,
|
| 106 |
+
"gravel": 10,
|
| 107 |
+
"sand": 9,
|
| 108 |
+
"peat": 8, # High organic content, but permeable
|
| 109 |
+
"shrinking_clay": 7,
|
| 110 |
+
"sandy_loam": 6,
|
| 111 |
+
"loam": 5,
|
| 112 |
+
"silty_loam": 4,
|
| 113 |
+
"clay_loam": 3,
|
| 114 |
+
"muck": 2,
|
| 115 |
+
"non_shrinking_clay": 1,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
# T: Topography (% slope)
|
| 119 |
+
# Flatter terrain = more infiltration = higher vulnerability
|
| 120 |
+
TOPOGRAPHY_RATINGS = [
|
| 121 |
+
# (min_pct, max_pct, rating)
|
| 122 |
+
(0, 2, 10),
|
| 123 |
+
(2, 6, 9),
|
| 124 |
+
(6, 12, 5),
|
| 125 |
+
(12, 18, 3),
|
| 126 |
+
(18, 999, 1),
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
# I: Impact of Vadose Zone Media
|
| 130 |
+
# Rating by vadose zone material (most to least permeable)
|
| 131 |
+
VADOSE_ZONE_RATINGS = {
|
| 132 |
+
"silt_clay": 1,
|
| 133 |
+
"shale": 3,
|
| 134 |
+
"limestone": 6,
|
| 135 |
+
"sandstone": 6,
|
| 136 |
+
"bedded_limestone_sand": 6,
|
| 137 |
+
"sand_gravel_with_silt": 6,
|
| 138 |
+
"sand_gravel": 8,
|
| 139 |
+
"basalt": 9,
|
| 140 |
+
"karst_limestone": 10,
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
# C: Hydraulic Conductivity (m/day)
|
| 144 |
+
# Higher conductivity = faster contaminant transport
|
| 145 |
+
CONDUCTIVITY_RATINGS = [
|
| 146 |
+
# (min_m_day, max_m_day, rating)
|
| 147 |
+
(0.0, 4.1, 1),
|
| 148 |
+
(4.1, 12.2, 2),
|
| 149 |
+
(12.2, 28.5, 4),
|
| 150 |
+
(28.5, 40.7, 6),
|
| 151 |
+
(40.7, 81.5, 8),
|
| 152 |
+
(81.5, 99999.0, 10),
|
| 153 |
+
]
|
| 154 |
+
|
| 155 |
+
# DRASTIC Weights (original EPA)
|
| 156 |
+
DRASTIC_WEIGHTS = {
|
| 157 |
+
"D": 5, # Depth to water
|
| 158 |
+
"R": 4, # Net Recharge
|
| 159 |
+
"A": 3, # Aquifer media
|
| 160 |
+
"S": 2, # Soil media
|
| 161 |
+
"T": 1, # Topography
|
| 162 |
+
"I": 5, # Impact of vadose zone
|
| 163 |
+
"C": 3, # Hydraulic Conductivity
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ── RATING LOOKUP FUNCTIONS ─────────────────────────────────────────────────
|
| 168 |
+
|
| 169 |
+
def rate_depth(depth_m: float) -> int:
|
| 170 |
+
"""Rate depth to water table (D parameter). Higher rating = more vulnerable."""
|
| 171 |
+
for lo, hi, rating in DEPTH_RATINGS:
|
| 172 |
+
if lo <= depth_m < hi:
|
| 173 |
+
return rating
|
| 174 |
+
return 1 # Very deep
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def rate_recharge(recharge_mm_yr: float) -> int:
|
| 178 |
+
"""Rate net recharge (R parameter)."""
|
| 179 |
+
for lo, hi, rating in RECHARGE_RATINGS:
|
| 180 |
+
if lo <= recharge_mm_yr < hi:
|
| 181 |
+
return rating
|
| 182 |
+
return 9 # Very high recharge
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def rate_aquifer_media(media_type: str) -> int:
|
| 186 |
+
"""Rate aquifer media (A parameter)."""
|
| 187 |
+
return AQUIFER_MEDIA_RATINGS.get(media_type, 5) # Default: moderate
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def rate_soil_media(soil_type: str) -> int:
|
| 191 |
+
"""Rate soil media (S parameter)."""
|
| 192 |
+
return SOIL_MEDIA_RATINGS.get(soil_type, 5) # Default: loam
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def rate_topography(slope_pct: float) -> int:
|
| 196 |
+
"""Rate topography/slope (T parameter)."""
|
| 197 |
+
for lo, hi, rating in TOPOGRAPHY_RATINGS:
|
| 198 |
+
if lo <= slope_pct < hi:
|
| 199 |
+
return rating
|
| 200 |
+
return 1 # Very steep
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def rate_vadose_zone(media_type: str) -> int:
|
| 204 |
+
"""Rate impact of vadose zone (I parameter)."""
|
| 205 |
+
return VADOSE_ZONE_RATINGS.get(media_type, 5) # Default: moderate
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def rate_conductivity(k_m_day: float) -> int:
|
| 209 |
+
"""Rate hydraulic conductivity (C parameter)."""
|
| 210 |
+
for lo, hi, rating in CONDUCTIVITY_RATINGS:
|
| 211 |
+
if lo <= k_m_day < hi:
|
| 212 |
+
return rating
|
| 213 |
+
return 10 # Very high conductivity
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def conductivity_m_s_to_m_day(k_m_s: float) -> float:
|
| 217 |
+
"""Convert hydraulic conductivity from m/s to m/day."""
|
| 218 |
+
return k_m_s * 86400.0
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ── DRASTIC INDEX COMPUTATION ──────────────────────────────────────────────
|
| 222 |
+
|
| 223 |
+
@dataclass
|
| 224 |
+
class DRASTICResult:
|
| 225 |
+
"""
|
| 226 |
+
DRASTIC vulnerability assessment result.
|
| 227 |
+
|
| 228 |
+
Attributes:
|
| 229 |
+
drastic_index: Raw DRASTIC index (23-230). Higher = more vulnerable.
|
| 230 |
+
vulnerability_class: LOW / MODERATE / HIGH / VERY_HIGH.
|
| 231 |
+
security_score: Inverted score for AquiScore (0-100). Higher = more secure.
|
| 232 |
+
parameter_ratings: Dict of each parameter's rating and weighted contribution.
|
| 233 |
+
dominant_factor: Parameter contributing most to vulnerability.
|
| 234 |
+
interpretation: Human-readable interpretation for risk assessors.
|
| 235 |
+
"""
|
| 236 |
+
drastic_index: int
|
| 237 |
+
vulnerability_class: str
|
| 238 |
+
security_score: float
|
| 239 |
+
parameter_ratings: dict
|
| 240 |
+
dominant_factor: str
|
| 241 |
+
interpretation: str
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def compute_drastic(
|
| 245 |
+
depth_m: float,
|
| 246 |
+
recharge_mm_yr: float = 150.0,
|
| 247 |
+
aquifer_media: str = "sand_gravel",
|
| 248 |
+
soil_media: str = "loam",
|
| 249 |
+
slope_pct: float = 3.0,
|
| 250 |
+
vadose_zone: str = "sand_gravel",
|
| 251 |
+
conductivity_m_s: float = 1e-5,
|
| 252 |
+
) -> DRASTICResult:
|
| 253 |
+
"""
|
| 254 |
+
Compute the DRASTIC groundwater vulnerability index.
|
| 255 |
+
|
| 256 |
+
All seven parameters are rated on 1-10 scales using published
|
| 257 |
+
EPA rating tables, then combined with official weights.
|
| 258 |
+
|
| 259 |
+
For AquiScore integration: the DRASTIC index is inverted to a
|
| 260 |
+
security score (0-100) where higher = more secure.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
depth_m: Depth to water table in meters.
|
| 264 |
+
recharge_mm_yr: Net recharge in mm/year (default 150 = moderate).
|
| 265 |
+
aquifer_media: Aquifer material type (key from AQUIFER_MEDIA_RATINGS).
|
| 266 |
+
soil_media: Soil type (key from SOIL_MEDIA_RATINGS).
|
| 267 |
+
slope_pct: Surface slope in percent.
|
| 268 |
+
vadose_zone: Vadose zone material (key from VADOSE_ZONE_RATINGS).
|
| 269 |
+
conductivity_m_s: Hydraulic conductivity in m/s.
|
| 270 |
+
|
| 271 |
+
Returns:
|
| 272 |
+
DRASTICResult with index, vulnerability class, and component breakdown.
|
| 273 |
+
"""
|
| 274 |
+
k_m_day = conductivity_m_s_to_m_day(conductivity_m_s)
|
| 275 |
+
|
| 276 |
+
ratings = {
|
| 277 |
+
"D": {"rating": rate_depth(depth_m), "weight": DRASTIC_WEIGHTS["D"],
|
| 278 |
+
"input": f"{depth_m:.1f} m", "param": "Depth to water"},
|
| 279 |
+
"R": {"rating": rate_recharge(recharge_mm_yr), "weight": DRASTIC_WEIGHTS["R"],
|
| 280 |
+
"input": f"{recharge_mm_yr:.0f} mm/yr", "param": "Net Recharge"},
|
| 281 |
+
"A": {"rating": rate_aquifer_media(aquifer_media), "weight": DRASTIC_WEIGHTS["A"],
|
| 282 |
+
"input": aquifer_media, "param": "Aquifer media"},
|
| 283 |
+
"S": {"rating": rate_soil_media(soil_media), "weight": DRASTIC_WEIGHTS["S"],
|
| 284 |
+
"input": soil_media, "param": "Soil media"},
|
| 285 |
+
"T": {"rating": rate_topography(slope_pct), "weight": DRASTIC_WEIGHTS["T"],
|
| 286 |
+
"input": f"{slope_pct:.1f}%", "param": "Topography"},
|
| 287 |
+
"I": {"rating": rate_vadose_zone(vadose_zone), "weight": DRASTIC_WEIGHTS["I"],
|
| 288 |
+
"input": vadose_zone, "param": "Vadose zone impact"},
|
| 289 |
+
"C": {"rating": rate_conductivity(k_m_day), "weight": DRASTIC_WEIGHTS["C"],
|
| 290 |
+
"input": f"{k_m_day:.2f} m/day", "param": "Hydraulic Conductivity"},
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
# Compute weighted contributions
|
| 294 |
+
drastic_index = 0
|
| 295 |
+
max_contribution = 0
|
| 296 |
+
dominant = "D"
|
| 297 |
+
|
| 298 |
+
for code, info in ratings.items():
|
| 299 |
+
contribution = info["rating"] * info["weight"]
|
| 300 |
+
info["weighted_contribution"] = contribution
|
| 301 |
+
drastic_index += contribution
|
| 302 |
+
if contribution > max_contribution:
|
| 303 |
+
max_contribution = contribution
|
| 304 |
+
dominant = code
|
| 305 |
+
|
| 306 |
+
# Vulnerability classification (based on published DRASTIC interpretations)
|
| 307 |
+
if drastic_index < 80:
|
| 308 |
+
vuln_class = "LOW"
|
| 309 |
+
elif drastic_index < 120:
|
| 310 |
+
vuln_class = "MODERATE"
|
| 311 |
+
elif drastic_index < 160:
|
| 312 |
+
vuln_class = "HIGH"
|
| 313 |
+
else:
|
| 314 |
+
vuln_class = "VERY_HIGH"
|
| 315 |
+
|
| 316 |
+
# Convert to security score (invert: low vulnerability = high security)
|
| 317 |
+
# DI range: 23 (min) to 230 (max)
|
| 318 |
+
security_score = max(0.0, min(100.0, 100.0 * (1.0 - (drastic_index - 23) / (230 - 23))))
|
| 319 |
+
|
| 320 |
+
# Interpretation
|
| 321 |
+
dominant_info = ratings[dominant]
|
| 322 |
+
interpretation = (
|
| 323 |
+
f"DRASTIC Index = {drastic_index} ({vuln_class} vulnerability). "
|
| 324 |
+
f"The dominant vulnerability factor is {dominant_info['param']} "
|
| 325 |
+
f"({dominant_info['input']}, rating={dominant_info['rating']}, "
|
| 326 |
+
f"weight={dominant_info['weight']}, contribution={dominant_info['weighted_contribution']}). "
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
if vuln_class == "LOW":
|
| 330 |
+
interpretation += (
|
| 331 |
+
"The aquifer is well-protected by natural hydrogeological barriers. "
|
| 332 |
+
"Low intrinsic vulnerability to surface contamination."
|
| 333 |
+
)
|
| 334 |
+
elif vuln_class == "MODERATE":
|
| 335 |
+
interpretation += (
|
| 336 |
+
"The aquifer has moderate natural protection. "
|
| 337 |
+
"Some vulnerability to persistent contaminants with high mobility."
|
| 338 |
+
)
|
| 339 |
+
elif vuln_class == "HIGH":
|
| 340 |
+
interpretation += (
|
| 341 |
+
"The aquifer is significantly vulnerable to contamination. "
|
| 342 |
+
"Recommend enhanced monitoring and land use controls in recharge zones."
|
| 343 |
+
)
|
| 344 |
+
else:
|
| 345 |
+
interpretation += (
|
| 346 |
+
"The aquifer is highly vulnerable to contamination from surface sources. "
|
| 347 |
+
"Immediate attention required for protection of recharge areas. "
|
| 348 |
+
"Any contamination event will likely reach groundwater rapidly."
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
return DRASTICResult(
|
| 352 |
+
drastic_index=drastic_index,
|
| 353 |
+
vulnerability_class=vuln_class,
|
| 354 |
+
security_score=round(security_score, 1),
|
| 355 |
+
parameter_ratings=ratings,
|
| 356 |
+
dominant_factor=dominant,
|
| 357 |
+
interpretation=interpretation,
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
# ── AQUIFER TYPE TO DRASTIC MEDIA MAPPING ──────────────────────────────────
|
| 362 |
+
# Maps AquiScore aquifer_type keys to DRASTIC media types for automated assessment
|
| 363 |
+
|
| 364 |
+
AQUIFER_TYPE_TO_DRASTIC = {
|
| 365 |
+
"unconfined_alluvial": {"aquifer_media": "sand_gravel", "vadose_zone": "sand_gravel", "soil_media": "sandy_loam"},
|
| 366 |
+
"unconfined_fractured": {"aquifer_media": "weathered_metamorphic", "vadose_zone": "sandstone", "soil_media": "loam"},
|
| 367 |
+
"semi_confined": {"aquifer_media": "bedded_sandstone", "vadose_zone": "sand_gravel_with_silt", "soil_media": "silty_loam"},
|
| 368 |
+
"confined_sedimentary": {"aquifer_media": "bedded_sandstone", "vadose_zone": "silt_clay", "soil_media": "clay_loam"},
|
| 369 |
+
"confined_crystalline": {"aquifer_media": "metamorphic_igneous", "vadose_zone": "shale", "soil_media": "non_shrinking_clay"},
|
| 370 |
+
"karst": {"aquifer_media": "karst_limestone", "vadose_zone": "karst_limestone", "soil_media": "thin_absent"},
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def compute_drastic_from_aquifer_type(
|
| 375 |
+
aquifer_type: str,
|
| 376 |
+
depth_m: float,
|
| 377 |
+
conductivity_m_s: float = 1e-5,
|
| 378 |
+
recharge_mm_yr: float = 150.0,
|
| 379 |
+
slope_pct: float = 3.0,
|
| 380 |
+
) -> DRASTICResult:
|
| 381 |
+
"""
|
| 382 |
+
Compute DRASTIC using AquiScore aquifer_type to auto-populate media types.
|
| 383 |
+
|
| 384 |
+
Convenience function that maps AquiScore's 6 aquifer types to DRASTIC's
|
| 385 |
+
media rating tables. For full control, use compute_drastic() directly.
|
| 386 |
+
|
| 387 |
+
Args:
|
| 388 |
+
aquifer_type: One of AQUIFER_TYPE_TO_DRASTIC keys.
|
| 389 |
+
depth_m: Depth to water table in meters.
|
| 390 |
+
conductivity_m_s: Hydraulic conductivity in m/s.
|
| 391 |
+
recharge_mm_yr: Net annual recharge in mm.
|
| 392 |
+
slope_pct: Surface slope in percent.
|
| 393 |
+
|
| 394 |
+
Returns:
|
| 395 |
+
DRASTICResult with full vulnerability assessment.
|
| 396 |
+
"""
|
| 397 |
+
mapping = AQUIFER_TYPE_TO_DRASTIC.get(aquifer_type, AQUIFER_TYPE_TO_DRASTIC["unconfined_alluvial"])
|
| 398 |
+
|
| 399 |
+
return compute_drastic(
|
| 400 |
+
depth_m=depth_m,
|
| 401 |
+
recharge_mm_yr=recharge_mm_yr,
|
| 402 |
+
aquifer_media=mapping["aquifer_media"],
|
| 403 |
+
soil_media=mapping["soil_media"],
|
| 404 |
+
slope_pct=slope_pct,
|
| 405 |
+
vadose_zone=mapping["vadose_zone"],
|
| 406 |
+
conductivity_m_s=conductivity_m_s,
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
# ── SELF-TEST ───────────────────────────────────────────────────────────────
|
| 411 |
+
|
| 412 |
+
if __name__ == "__main__":
|
| 413 |
+
print("=" * 72)
|
| 414 |
+
print("AquiScore v2.0 — DRASTIC Vulnerability Framework — Self-Test")
|
| 415 |
+
print("=" * 72)
|
| 416 |
+
|
| 417 |
+
# Test 1: Low vulnerability (deep, confined, low-K)
|
| 418 |
+
r1 = compute_drastic(
|
| 419 |
+
depth_m=40.0,
|
| 420 |
+
recharge_mm_yr=50.0,
|
| 421 |
+
aquifer_media="massive_shale",
|
| 422 |
+
soil_media="non_shrinking_clay",
|
| 423 |
+
slope_pct=15.0,
|
| 424 |
+
vadose_zone="silt_clay",
|
| 425 |
+
conductivity_m_s=1e-8,
|
| 426 |
+
)
|
| 427 |
+
assert r1.vulnerability_class == "LOW", f"Expected LOW: {r1.vulnerability_class}"
|
| 428 |
+
assert r1.security_score > 70
|
| 429 |
+
print(f" Low vulnerability: PASS (DI={r1.drastic_index}, security={r1.security_score})")
|
| 430 |
+
|
| 431 |
+
# Test 2: High vulnerability (shallow, karst, high-K)
|
| 432 |
+
r2 = compute_drastic(
|
| 433 |
+
depth_m=2.0,
|
| 434 |
+
recharge_mm_yr=300.0,
|
| 435 |
+
aquifer_media="karst_limestone",
|
| 436 |
+
soil_media="thin_absent",
|
| 437 |
+
slope_pct=1.0,
|
| 438 |
+
vadose_zone="karst_limestone",
|
| 439 |
+
conductivity_m_s=1e-3,
|
| 440 |
+
)
|
| 441 |
+
assert r2.vulnerability_class == "VERY_HIGH", f"Expected VERY_HIGH: {r2.vulnerability_class}"
|
| 442 |
+
assert r2.security_score < 30
|
| 443 |
+
print(f" High vulnerability: PASS (DI={r2.drastic_index}, security={r2.security_score})")
|
| 444 |
+
|
| 445 |
+
# Test 3: Ordering is correct (low vuln → high security, high vuln → low security)
|
| 446 |
+
assert r1.security_score > r2.security_score, "Low vulnerability should have higher security"
|
| 447 |
+
print(f" Ordering: PASS ({r1.security_score} > {r2.security_score})")
|
| 448 |
+
|
| 449 |
+
# Test 4: Rating tables produce valid ranges
|
| 450 |
+
for depth in [0.5, 3.0, 7.0, 12.0, 20.0, 25.0, 50.0]:
|
| 451 |
+
r = rate_depth(depth)
|
| 452 |
+
assert 1 <= r <= 10, f"Invalid depth rating {r} for {depth}m"
|
| 453 |
+
print(f" Depth ratings: PASS (1-10 range validated)")
|
| 454 |
+
|
| 455 |
+
for k_ms in [1e-9, 1e-7, 1e-5, 1e-4, 1e-3, 1e-2]:
|
| 456 |
+
k_md = conductivity_m_s_to_m_day(k_ms)
|
| 457 |
+
r = rate_conductivity(k_md)
|
| 458 |
+
assert 1 <= r <= 10, f"Invalid K rating {r} for {k_ms} m/s"
|
| 459 |
+
print(f" Conductivity ratings: PASS (1-10 range validated)")
|
| 460 |
+
|
| 461 |
+
# Test 5: Auto-mapping from AquiScore aquifer types
|
| 462 |
+
for aq_type in AQUIFER_TYPE_TO_DRASTIC:
|
| 463 |
+
r = compute_drastic_from_aquifer_type(aq_type, depth_m=15.0)
|
| 464 |
+
assert 23 <= r.drastic_index <= 230
|
| 465 |
+
print(f" Aquifer type mapping: PASS (all 6 types produce valid DI)")
|
| 466 |
+
|
| 467 |
+
# Test 6: Dominant factor identification
|
| 468 |
+
r_shallow = compute_drastic(depth_m=0.5, conductivity_m_s=1e-8)
|
| 469 |
+
# Shallow depth (rating 10 × weight 5 = 50) should dominate
|
| 470 |
+
assert r_shallow.dominant_factor == "D", f"Expected D dominant: {r_shallow.dominant_factor}"
|
| 471 |
+
print(f" Dominant factor: PASS (shallow water → D dominates)")
|
| 472 |
+
|
| 473 |
+
# Test 7: Full parameter breakdown
|
| 474 |
+
r_full = compute_drastic_from_aquifer_type("unconfined_alluvial", depth_m=10.0, conductivity_m_s=5e-4)
|
| 475 |
+
for code in "DRASTC":
|
| 476 |
+
# DRASTIC maps to D, R, A, S, T, I, C but we skip non-codes
|
| 477 |
+
pass
|
| 478 |
+
for code in ["D", "R", "A", "S", "T", "I", "C"]:
|
| 479 |
+
assert code in r_full.parameter_ratings
|
| 480 |
+
info = r_full.parameter_ratings[code]
|
| 481 |
+
assert 1 <= info["rating"] <= 10
|
| 482 |
+
assert info["weight"] == DRASTIC_WEIGHTS[code]
|
| 483 |
+
print(f" Full breakdown: PASS (all 7 parameters rated and weighted)")
|
| 484 |
+
|
| 485 |
+
print("\n All tests passed.")
|
| 486 |
+
print("=" * 72)
|