api / src /proxy_a_score.py
safraeli's picture
Deploy: 2026 sensor migration + redesign + bucket B endpoints
13fc29d verified
"""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
@dataclass
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)