Download src/proxy_a_score.py from SolarWine/api: direct link, hf CLI and curl.
- Browser
- Download file 5.01 kB
-
https://huggingface.co/spaces/SolarWine/api/resolve/main/src/proxy_a_score.py
- Command line
-
hf download hf://spaces/SolarWine/api/src/proxy_a_score.py
-
curl -L -o proxy_a_score.py https://huggingface.co/spaces/SolarWine/api/resolve/main/src/proxy_a_score.py
5.01 kB
| """proxy_a_score — composite photosynthesis-health metric, 0..1. | |
| LiCor (direct A measurement) is not available this season, so the control | |
| loop and chatbot need a single scalar that says "how healthy is the canopy | |
| right now" from the sensors we already collect. This module produces that | |
| scalar by combining 5 already-streamed metrics on Crop_2Soil devices: | |
| - PRI (Photochemical Reflectance Index — light-use efficiency) | |
| - PSRI (Plant Senescence Reflectance Index — inverted) | |
| - leaf-air ΔT (transpiration cooling — inverted) | |
| - shallow soil moisture (water availability) | |
| - NDVI (greenness baseline) | |
| Each metric is mapped linearly from its `stress`→`healthy` bound into | |
| [0, 1] (clipped), then combined as a weighted average. Weights and bounds | |
| live in `config/settings.py` (`PROXY_A_WEIGHTS`, `PROXY_A_BOUNDS`) so they | |
| can be tuned after the null-intervention baseline period without code change. | |
| Higher score = healthier / more productive canopy. The control loop should | |
| prefer interventions that *don't* drop the score by more than ε. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from typing import Dict, Optional | |
| from config import settings | |
| class ProxyAScore: | |
| """Composite photosynthesis-health metric, plus per-term breakdown. | |
| Attributes | |
| ---------- | |
| score : float | |
| Weighted average of the per-metric sub-scores, in [0, 1]. | |
| NaN if no metrics were available. | |
| contributions : dict[str, float] | |
| Per-metric sub-score in [0, 1] (only metrics that had data). | |
| weights_used : dict[str, float] | |
| Per-metric weight actually applied (renormalised over available | |
| metrics so missing inputs don't bias the score downward). | |
| """ | |
| score: float | |
| contributions: Dict[str, float] | |
| weights_used: Dict[str, float] | |
| def _linear_score(value: float, stress: float, healthy: float) -> float: | |
| """Map a raw measurement into [0, 1] given its stress/healthy bounds. | |
| Works whether `healthy > stress` (PRI, NDVI, soil — higher is better) | |
| or `healthy < stress` (PSRI, ΔT — lower is better). | |
| """ | |
| if stress == healthy: | |
| return 0.5 # degenerate config, neutral | |
| raw = (value - stress) / (healthy - stress) | |
| return max(0.0, min(1.0, raw)) | |
| def compute_proxy_a( | |
| pri: Optional[float] = None, | |
| psri: Optional[float] = None, | |
| delta_t: Optional[float] = None, | |
| soil_moisture_pct: Optional[float] = None, | |
| ndvi: Optional[float] = None, | |
| ) -> ProxyAScore: | |
| """Compute the proxy-A score from any subset of the 5 input metrics. | |
| Missing metrics are dropped and the weights for the remaining metrics | |
| are renormalised so they sum to 1. If all metrics are missing, the | |
| score is NaN. | |
| """ | |
| raw_inputs: Dict[str, Optional[float]] = { | |
| "pri": pri, | |
| "psri": psri, | |
| "delta_t": delta_t, | |
| "soil": soil_moisture_pct, | |
| "ndvi": ndvi, | |
| } | |
| contributions: Dict[str, float] = {} | |
| weights_used: Dict[str, float] = {} | |
| for key, val in raw_inputs.items(): | |
| if val is None: | |
| continue | |
| b = settings.PROXY_A_BOUNDS.get(key) | |
| w = settings.PROXY_A_WEIGHTS.get(key, 0.0) | |
| if b is None or w <= 0: | |
| continue | |
| contributions[key] = _linear_score(val, b["stress"], b["healthy"]) | |
| weights_used[key] = w | |
| if not weights_used: | |
| return ProxyAScore(score=float("nan"), contributions={}, weights_used={}) | |
| total_w = sum(weights_used.values()) | |
| renormalised = {k: w / total_w for k, w in weights_used.items()} | |
| score = sum(contributions[k] * renormalised[k] for k in contributions) | |
| return ProxyAScore( | |
| score=round(score, 3), | |
| contributions={k: round(v, 3) for k, v in contributions.items()}, | |
| weights_used=renormalised, | |
| ) | |
| def compute_from_snapshot(snapshot: dict) -> ProxyAScore: | |
| """Convenience: pull the 5 inputs from a VineSnapshot.to_dict().""" | |
| return compute_proxy_a( | |
| pri=snapshot.get("treatment_crop_pri"), | |
| psri=snapshot.get("treatment_crop_psri"), | |
| delta_t=snapshot.get("treatment_air_leaf_delta_t"), | |
| soil_moisture_pct=snapshot.get("treatment_soil_moisture_pct"), | |
| ndvi=snapshot.get("treatment_crop_ndvi"), | |
| ) | |
| def slot_cost( | |
| *, | |
| energy_value_ils: float, | |
| proxy_a: ProxyAScore, | |
| proxy_a_floor: float = 0.5, | |
| proxy_a_weight: float = 5.0, | |
| ) -> float: | |
| """Slot-level cost for the tracker control loop. | |
| Combines instantaneous energy revenue with a penalty for any drop of | |
| `proxy_a.score` below `proxy_a_floor`. Higher = better. | |
| cost = energy_value_ils − proxy_a_weight × max(0, floor − score) | |
| Tune `proxy_a_weight` from the calibration period so that the penalty | |
| at the 95th-percentile stress reading equals roughly half a mid-day | |
| hour of energy revenue. | |
| """ | |
| shortfall = max(0.0, proxy_a_floor - proxy_a.score) | |
| return round(energy_value_ils - proxy_a_weight * shortfall, 3) | |