Download src/advisor/safety_rails.py from SolarWine/api: direct link, hf CLI and curl.
- Browser
- Download file 6.33 kB
-
https://huggingface.co/spaces/SolarWine/api/resolve/main/src/advisor/safety_rails.py
- Command line
-
hf download hf://spaces/SolarWine/api/src/advisor/safety_rails.py
-
curl -L -o safety_rails.py https://huggingface.co/spaces/SolarWine/api/resolve/main/src/advisor/safety_rails.py
6.33 kB
| """ | |
| SafetyRails: FvCB vs ML divergence guard for the SolarWine 2.0 control loop. | |
| Position in the control loop (Phase 3, Step 7): | |
| After TradeoffEngine selects a minimum dose, SafetyRails validates that | |
| the FvCB and ML photosynthesis predictions are sufficiently consistent. | |
| If the two models disagree by more than DIVERGENCE_THRESHOLD (12%), the | |
| system cannot confidently predict that shading will help, so it falls back | |
| to full astronomical tracking (zero energy sacrifice, zero risk). | |
| Rationale | |
| --------- | |
| The FvCB mechanistic model and ML ensemble are calibrated on different | |
| assumptions: | |
| - FvCB is reliable in standard conditions (T < 30°C, moderate VPD). | |
| - ML handles non-linear stress regimes better. | |
| When both agree → high confidence → proceed with intervention. | |
| When they disagree significantly → sensor fault, regime change, or edge | |
| case not covered by calibration. The safe default is no intervention. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from typing import Optional | |
| from config.settings import DIVERGENCE_THRESHOLD | |
| # --------------------------------------------------------------------------- | |
| # Result dataclass | |
| # --------------------------------------------------------------------------- | |
| class SafetyCheckResult: | |
| """Outcome of a single FvCB vs ML divergence check.""" | |
| passed: bool | |
| fvcb_a: float | |
| ml_a: float | |
| divergence_pct: float # |fvcb_a - ml_a| / max(fvcb_a, ml_a) × 100 | |
| fallback_needed: bool # True when divergence > threshold | |
| reason: str # human-readable explanation | |
| def __str__(self) -> str: | |
| status = "PASS" if self.passed else "FAIL → fallback to θ_astro" | |
| return ( | |
| f"SafetyRails [{status}] " | |
| f"FvCB={self.fvcb_a:.2f} ML={self.ml_a:.2f} " | |
| f"divergence={self.divergence_pct:.1f}% " | |
| f"(threshold={DIVERGENCE_THRESHOLD * 100:.0f}%)" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # SafetyRails | |
| # --------------------------------------------------------------------------- | |
| class SafetyRails: | |
| """ | |
| Validates that FvCB and ML model outputs are consistent before any | |
| shading command is issued. | |
| Usage | |
| ----- | |
| rails = SafetyRails() | |
| result = rails.check(fvcb_a=14.3, ml_a=14.8) | |
| if result.fallback_needed: | |
| # stay at θ_astro, log result | |
| """ | |
| def __init__(self, threshold: Optional[float] = None) -> None: | |
| """ | |
| Parameters | |
| ---------- | |
| threshold : divergence fraction (0–1) that triggers fallback. | |
| Defaults to DIVERGENCE_THRESHOLD (0.12) from settings. | |
| """ | |
| self.threshold = threshold if threshold is not None else DIVERGENCE_THRESHOLD | |
| def check( | |
| self, | |
| fvcb_a: float, | |
| ml_a: float, | |
| context: Optional[str] = None, | |
| ) -> SafetyCheckResult: | |
| """ | |
| Compare FvCB and ML photosynthesis outputs. | |
| Parameters | |
| ---------- | |
| fvcb_a : net A from FarquharModel (µmol CO₂ m⁻² s⁻¹) | |
| ml_a : net A from ML ensemble (µmol CO₂ m⁻² s⁻¹) | |
| context : optional string for logging (e.g. "2025-07-15 13:00") | |
| Returns | |
| ------- | |
| SafetyCheckResult | |
| """ | |
| denominator = max(abs(fvcb_a), abs(ml_a), 1e-6) | |
| divergence = abs(fvcb_a - ml_a) / denominator | |
| divergence_pct = divergence * 100.0 | |
| fallback_needed = divergence > self.threshold | |
| if fallback_needed: | |
| reason = ( | |
| f"Models diverge by {divergence_pct:.1f}% " | |
| f"(FvCB={fvcb_a:.2f}, ML={ml_a:.2f}) — " | |
| f"exceeds {self.threshold * 100:.0f}% threshold. " | |
| f"Falling back to full astronomical tracking." | |
| ) | |
| elif fvcb_a < 0 and ml_a < 0: | |
| reason = "Both models predict carbon loss (dark/night); no shading beneficial." | |
| fallback_needed = True | |
| else: | |
| reason = ( | |
| f"Models agree within {self.threshold * 100:.0f}% threshold " | |
| f"(FvCB={fvcb_a:.2f}, ML={ml_a:.2f}, " | |
| f"divergence={divergence_pct:.1f}%). Proceeding." | |
| ) | |
| return SafetyCheckResult( | |
| passed=not fallback_needed, | |
| fvcb_a=fvcb_a, | |
| ml_a=ml_a, | |
| divergence_pct=round(divergence_pct, 2), | |
| fallback_needed=fallback_needed, | |
| reason=reason, | |
| ) | |
| def check_from_log(self, fvcb_a: Optional[float], ml_a: Optional[float]) -> SafetyCheckResult: | |
| """ | |
| Variant that handles None inputs gracefully (e.g. ML model not loaded). | |
| If either value is None, defaults to passing with a warning — the | |
| calling code should use whichever model is available. | |
| """ | |
| if fvcb_a is None or ml_a is None: | |
| available = fvcb_a if fvcb_a is not None else ml_a | |
| return SafetyCheckResult( | |
| passed=True, | |
| fvcb_a=fvcb_a or 0.0, | |
| ml_a=ml_a or 0.0, | |
| divergence_pct=0.0, | |
| fallback_needed=False, | |
| reason=( | |
| f"Only one model available (value={available:.2f}). " | |
| "Cannot check divergence; proceeding with available model." | |
| ), | |
| ) | |
| return self.check(fvcb_a, ml_a) | |
| # --------------------------------------------------------------------------- | |
| # CLI smoke test | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| rails = SafetyRails() | |
| cases = [ | |
| (14.3, 14.8, "Normal agreement (3.4%)"), | |
| (14.3, 16.5, "Borderline (15.4% — over threshold)"), | |
| (14.3, 12.0, "Below threshold (17.6% — over)"), | |
| (14.3, 14.3, "Perfect agreement"), | |
| (14.3, None, "ML unavailable"), | |
| (-2.0, -1.8, "Carbon loss (night)"), | |
| ] | |
| print(f"SafetyRails — threshold={rails.threshold * 100:.0f}%\n") | |
| for fvcb, ml, label in cases: | |
| result = rails.check_from_log(fvcb, ml) | |
| status = "FALLBACK" if result.fallback_needed else "OK " | |
| print(f" [{status}] {label}") | |
| print(f" {result.reason}") | |
| print() | |