CryptoThaler commited on
Commit
c45cbeb
·
verified ·
1 Parent(s): 33cc3f1

Deploy AquiScore Groundwater Security API v2.0.0 — CCME WQI, DRASTIC vulnerability framework

Browse files
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 v1.1
11
  ---
12
 
13
- # AquiScore — Groundwater Security API v1.1
14
 
15
- Fuses NASA GRACE-FO satellite gravity × USGS NWIS well measurements × GLHYMPS hydrogeology to produce auditable groundwater security scores.
16
 
17
- ## v1.1 Optimizations
 
 
 
 
 
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="Groundwater Security Scoring Engine — GRACE-FO × NWIS × GLHYMPS",
42
- version="1.0.0",
 
 
 
 
 
 
 
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": "1.0.0",
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
- return {
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
- # -- Batch Scoring Endpoint ---------------------------------------------------
 
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 = "1.0.0"
 
 
 
 
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 against WHO/EPA thresholds.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
- Each parameter scored independently, then averaged.
307
- Contamination flags raised for any exceedance.
308
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  flags = []
310
- scores = []
311
-
312
- # Nitrate
313
- nit = quality.nitrate_mg_l
314
- t = WATER_QUALITY_THRESHOLDS["nitrate_mg_l"]
315
- if nit <= t["safe"]:
316
- s = 100.0
317
- elif nit <= t["concern"]:
318
- s = 100.0 - (nit - t["safe"]) / (t["concern"] - t["safe"]) * 50
319
- elif nit <= t["danger"]:
320
- s = 50.0 - (nit - t["concern"]) / (t["danger"] - t["concern"]) * 40
321
- else:
322
- s = max(0.0, 10.0 - (nit - t["danger"]) * 0.2)
323
- scores.append(s)
324
- if nit > t["safe"]:
325
- flags.append({"param": "nitrate", "value": nit, "unit": "mg/L",
326
- "threshold": t["safe"], "severity": "DANGER" if nit > t["danger"] else "CONCERN"})
327
-
328
- # Arsenic
329
- ars = quality.arsenic_ug_l
330
- t = WATER_QUALITY_THRESHOLDS["arsenic_ug_l"]
331
- if ars <= t["safe"]:
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 = sum(scores) / len(scores) if scores else 50.0
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)