Vectorize Farquhar, DI ControlLoop, gate pipeline, budget audit, chatbot Hebrew
Browse files- src/budget_audit.py +184 -0
- src/chatbot/guardrails.py +9 -0
- src/chatbot/vineyard_chatbot.py +60 -23
- src/control_loop.py +32 -10
- src/models/farquhar_model.py +67 -20
- src/shading/tradeoff_engine.py +61 -36
src/budget_audit.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Budget audit pipeline — logs every slot spend and provides rollup reports.
|
| 3 |
+
|
| 4 |
+
Appends one row per control tick to a parquet file. The weekly rollup
|
| 5 |
+
verifies cumulative sacrifice stays within the 5% annual ceiling.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
# In control_loop.py tick():
|
| 9 |
+
from src.budget_audit import BudgetAuditLog
|
| 10 |
+
audit = BudgetAuditLog()
|
| 11 |
+
audit.log_slot(tick_result)
|
| 12 |
+
|
| 13 |
+
# Weekly report:
|
| 14 |
+
python -m src.budget_audit --report
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from datetime import date, datetime, timezone
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
import pandas as pd
|
| 26 |
+
|
| 27 |
+
from config.settings import (
|
| 28 |
+
DATA_DIR,
|
| 29 |
+
MAX_ENERGY_REDUCTION_PCT,
|
| 30 |
+
SYSTEM_CAPACITY_KW,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
AUDIT_DIR = DATA_DIR / "budget_audit"
|
| 36 |
+
AUDIT_PATH = AUDIT_DIR / "slot_log.parquet"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class SlotRecord:
|
| 41 |
+
"""One row in the audit log."""
|
| 42 |
+
timestamp: datetime
|
| 43 |
+
date: date
|
| 44 |
+
slot_index: int
|
| 45 |
+
planned_offset_deg: float
|
| 46 |
+
actual_offset_deg: float
|
| 47 |
+
energy_cost_kwh: float
|
| 48 |
+
budget_spent_kwh: float
|
| 49 |
+
budget_remaining_kwh: float
|
| 50 |
+
gate_passed: bool
|
| 51 |
+
source: str
|
| 52 |
+
stage_id: str
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class BudgetAuditLog:
|
| 56 |
+
"""Append-only parquet log for budget slot spends."""
|
| 57 |
+
|
| 58 |
+
def __init__(self, path: Path = AUDIT_PATH):
|
| 59 |
+
self.path = path
|
| 60 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
|
| 62 |
+
def log_slot(self, tick_result) -> None:
|
| 63 |
+
"""Append a tick result to the audit log."""
|
| 64 |
+
try:
|
| 65 |
+
record = {
|
| 66 |
+
"timestamp": getattr(tick_result, "timestamp", datetime.now(timezone.utc)),
|
| 67 |
+
"date": str(getattr(tick_result, "timestamp", datetime.now(timezone.utc)).date()
|
| 68 |
+
if hasattr(getattr(tick_result, "timestamp", None), "date")
|
| 69 |
+
else date.today()),
|
| 70 |
+
"slot_index": getattr(tick_result, "slot_index", -1),
|
| 71 |
+
"planned_offset_deg": getattr(tick_result, "plan_offset_deg", 0.0),
|
| 72 |
+
"actual_offset_deg": getattr(tick_result, "target_angle", 0.0),
|
| 73 |
+
"energy_cost_kwh": getattr(tick_result, "energy_cost_kwh", 0.0),
|
| 74 |
+
"budget_spent_kwh": getattr(tick_result, "budget_spent_kwh", 0.0),
|
| 75 |
+
"budget_remaining_kwh": getattr(tick_result, "budget_remaining_kwh", 0.0),
|
| 76 |
+
"gate_passed": getattr(tick_result, "live_gate_passed", False),
|
| 77 |
+
"source": getattr(tick_result, "source", ""),
|
| 78 |
+
"stage_id": getattr(tick_result, "stage_id", "unknown"),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
new_row = pd.DataFrame([record])
|
| 82 |
+
|
| 83 |
+
if self.path.exists():
|
| 84 |
+
existing = pd.read_parquet(self.path)
|
| 85 |
+
combined = pd.concat([existing, new_row], ignore_index=True)
|
| 86 |
+
else:
|
| 87 |
+
combined = new_row
|
| 88 |
+
|
| 89 |
+
combined.to_parquet(self.path, index=False)
|
| 90 |
+
logger.debug("Audit log: slot %d, cost=%.4f kWh", record["slot_index"], record["energy_cost_kwh"])
|
| 91 |
+
|
| 92 |
+
except Exception as exc:
|
| 93 |
+
logger.warning("Budget audit log failed: %s", exc)
|
| 94 |
+
|
| 95 |
+
def load(self) -> pd.DataFrame:
|
| 96 |
+
"""Load the full audit log."""
|
| 97 |
+
if self.path.exists():
|
| 98 |
+
return pd.read_parquet(self.path)
|
| 99 |
+
return pd.DataFrame()
|
| 100 |
+
|
| 101 |
+
def daily_summary(self, target_date: Optional[date] = None) -> dict:
|
| 102 |
+
"""Summarize a single day's budget usage."""
|
| 103 |
+
df = self.load()
|
| 104 |
+
if df.empty:
|
| 105 |
+
return {"error": "No audit data"}
|
| 106 |
+
|
| 107 |
+
if target_date is None:
|
| 108 |
+
target_date = date.today()
|
| 109 |
+
|
| 110 |
+
day = df[df["date"] == str(target_date)]
|
| 111 |
+
if day.empty:
|
| 112 |
+
return {"date": str(target_date), "slots": 0, "total_cost_kwh": 0.0}
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
"date": str(target_date),
|
| 116 |
+
"slots": len(day),
|
| 117 |
+
"total_cost_kwh": round(float(day["energy_cost_kwh"].sum()), 4),
|
| 118 |
+
"interventions": int(day["gate_passed"].sum()),
|
| 119 |
+
"max_offset_deg": round(float(day["actual_offset_deg"].abs().max()), 1),
|
| 120 |
+
"budget_remaining_kwh": round(float(day["budget_remaining_kwh"].iloc[-1]), 4),
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
def weekly_report(self) -> dict:
|
| 124 |
+
"""Generate a weekly rollup report for budget compliance."""
|
| 125 |
+
df = self.load()
|
| 126 |
+
if df.empty:
|
| 127 |
+
return {"error": "No audit data"}
|
| 128 |
+
|
| 129 |
+
total_cost = float(df["energy_cost_kwh"].sum())
|
| 130 |
+
days = df["date"].nunique()
|
| 131 |
+
daily_potential_kwh = SYSTEM_CAPACITY_KW * 6.0 # ~6 peak sun hours
|
| 132 |
+
annual_potential_kwh = daily_potential_kwh * 365
|
| 133 |
+
ceiling_kwh = annual_potential_kwh * MAX_ENERGY_REDUCTION_PCT / 100.0
|
| 134 |
+
|
| 135 |
+
# Project annual rate from observed data
|
| 136 |
+
if days > 0:
|
| 137 |
+
daily_rate = total_cost / days
|
| 138 |
+
projected_annual = daily_rate * 365
|
| 139 |
+
else:
|
| 140 |
+
daily_rate = 0
|
| 141 |
+
projected_annual = 0
|
| 142 |
+
|
| 143 |
+
compliant = projected_annual <= ceiling_kwh
|
| 144 |
+
|
| 145 |
+
return {
|
| 146 |
+
"period_days": days,
|
| 147 |
+
"total_cost_kwh": round(total_cost, 3),
|
| 148 |
+
"daily_avg_kwh": round(daily_rate, 4),
|
| 149 |
+
"projected_annual_kwh": round(projected_annual, 1),
|
| 150 |
+
"ceiling_kwh": round(ceiling_kwh, 1),
|
| 151 |
+
"ceiling_pct": MAX_ENERGY_REDUCTION_PCT,
|
| 152 |
+
"utilization_pct": round(projected_annual / ceiling_kwh * 100, 1) if ceiling_kwh > 0 else 0,
|
| 153 |
+
"compliant": compliant,
|
| 154 |
+
"total_interventions": int(df["gate_passed"].sum()),
|
| 155 |
+
"intervention_rate_pct": round(float(df["gate_passed"].mean()) * 100, 1),
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
# CLI
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
import argparse
|
| 165 |
+
import json
|
| 166 |
+
|
| 167 |
+
parser = argparse.ArgumentParser(description="Budget audit report")
|
| 168 |
+
parser.add_argument("--report", action="store_true", help="Weekly rollup report")
|
| 169 |
+
parser.add_argument("--daily", type=str, help="Daily summary for YYYY-MM-DD")
|
| 170 |
+
args = parser.parse_args()
|
| 171 |
+
|
| 172 |
+
audit = BudgetAuditLog()
|
| 173 |
+
|
| 174 |
+
if args.report:
|
| 175 |
+
print(json.dumps(audit.weekly_report(), indent=2))
|
| 176 |
+
elif args.daily:
|
| 177 |
+
print(json.dumps(audit.daily_summary(date.fromisoformat(args.daily)), indent=2))
|
| 178 |
+
else:
|
| 179 |
+
df = audit.load()
|
| 180 |
+
if df.empty:
|
| 181 |
+
print("No audit data yet.")
|
| 182 |
+
else:
|
| 183 |
+
print(f"Audit log: {len(df)} slots, {df['date'].nunique()} days")
|
| 184 |
+
print(json.dumps(audit.weekly_report(), indent=2))
|
src/chatbot/guardrails.py
CHANGED
|
@@ -53,6 +53,10 @@ _DATA_KEYWORDS = [
|
|
| 53 |
# Direct data ask
|
| 54 |
r"\bshow me\b", r"\bwhat is\b", r"\bwhat are\b", r"\bhow much\b",
|
| 55 |
r"\bcheck\b", r"\bstatus\b", r"\bstate\b",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
]
|
| 57 |
|
| 58 |
# Compile once
|
|
@@ -283,12 +287,17 @@ def estimate_confidence(
|
|
| 283 |
tool_succeeded: bool,
|
| 284 |
data_age_minutes: Optional[float],
|
| 285 |
tool_name: Optional[str] = None,
|
|
|
|
| 286 |
) -> str:
|
| 287 |
"""
|
| 288 |
Estimate response confidence based on data grounding.
|
| 289 |
|
| 290 |
Returns one of: "high", "medium", "low", "insufficient_data".
|
| 291 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
# No tool called at all
|
| 293 |
if not tool_called:
|
| 294 |
return "low" # answering from system prompt / training data only
|
|
|
|
| 53 |
# Direct data ask
|
| 54 |
r"\bshow me\b", r"\bwhat is\b", r"\bwhat are\b", r"\bhow much\b",
|
| 55 |
r"\bcheck\b", r"\bstatus\b", r"\bstate\b",
|
| 56 |
+
# Hebrew keywords (common farmer queries)
|
| 57 |
+
r"להצליל", r"הצללה", r"טמפרטורה", r"מזג אוויר", r"גשם", r"רוח",
|
| 58 |
+
r"לחות", r"קרינה", r"השקיה", r"מים", r"אנרגיה", r"חשמל",
|
| 59 |
+
r"עכשיו", r"היום", r"מחר", r"אתמול", r"מה המצב", r"כמה",
|
| 60 |
]
|
| 61 |
|
| 62 |
# Compile once
|
|
|
|
| 287 |
tool_succeeded: bool,
|
| 288 |
data_age_minutes: Optional[float],
|
| 289 |
tool_name: Optional[str] = None,
|
| 290 |
+
rule_override: bool = False,
|
| 291 |
) -> str:
|
| 292 |
"""
|
| 293 |
Estimate response confidence based on data grounding.
|
| 294 |
|
| 295 |
Returns one of: "high", "medium", "low", "insufficient_data".
|
| 296 |
"""
|
| 297 |
+
# Rule-based override (e.g. dormancy, biology rules) — always high
|
| 298 |
+
if rule_override:
|
| 299 |
+
return "high"
|
| 300 |
+
|
| 301 |
# No tool called at all
|
| 302 |
if not tool_called:
|
| 303 |
return "low" # answering from system prompt / training data only
|
src/chatbot/vineyard_chatbot.py
CHANGED
|
@@ -169,6 +169,11 @@ You help the farmer decide when and how much to shade their Semillon grapevines
|
|
| 169 |
(VSP trellis, 1.2 m canopy) under single-axis solar trackers (1.13 m panel at \
|
| 170 |
2.05 m height, 3.0 m row spacing).
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
CONTROL OBJECTIVE:
|
| 173 |
- Primary goal: maximise annual PV energy production.
|
| 174 |
- Secondary goal: protect vines from heat, water stress, and sunburn using a \
|
|
@@ -180,12 +185,17 @@ CALENDAR & STAGE HANDLING:
|
|
| 180 |
- Do NOT guess the current calendar month. If the user does not supply a \
|
| 181 |
date and you do not have a phenology tool result, talk in terms of stages \
|
| 182 |
(budburst, flowering, veraison, etc.) rather than asserting a specific month.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
COMMUNICATION STYLE:
|
| 185 |
-
-
|
| 186 |
-
-
|
| 187 |
- Always explain WHY a recommendation makes sense biologically
|
| 188 |
- When uncertain, say so and suggest what data would help
|
|
|
|
| 189 |
|
| 190 |
BIOLOGICAL GUIDELINES (strong constraints; balance them with the energy objective):
|
| 191 |
|
|
@@ -729,6 +739,17 @@ class VineyardChatbot:
|
|
| 729 |
now = datetime.now(tz=tz)
|
| 730 |
lines.append(f"CURRENT STATUS ({now.strftime('%Y-%m-%d %H:%M')} IST):")
|
| 731 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
# Weather
|
| 733 |
try:
|
| 734 |
wx = self.hub.weather.get_current()
|
|
@@ -790,15 +811,6 @@ class VineyardChatbot:
|
|
| 790 |
except Exception:
|
| 791 |
pass
|
| 792 |
|
| 793 |
-
# Phenology
|
| 794 |
-
try:
|
| 795 |
-
from src.phenology import estimate_stage_for_date
|
| 796 |
-
from datetime import date
|
| 797 |
-
stage = estimate_stage_for_date(date.today())
|
| 798 |
-
lines.append(f" Phenology: {stage.name} ({stage.id})")
|
| 799 |
-
except Exception:
|
| 800 |
-
pass
|
| 801 |
-
|
| 802 |
# Control status (from Redis via hub — no direct Redis import)
|
| 803 |
try:
|
| 804 |
ctrl = self.hub.advisory.get_status()
|
|
@@ -886,22 +898,39 @@ class VineyardChatbot:
|
|
| 886 |
tagged_result = tag_tool_result(tool_name, tool_result)
|
| 887 |
data_age = tagged_result.get("_data_age_minutes")
|
| 888 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 889 |
# Build Pass 2 prompt with source citation instructions
|
| 890 |
source_label = get_source_label(tool_name)
|
| 891 |
freshness_note = ""
|
| 892 |
if data_age is not None and data_age > 60:
|
| 893 |
freshness_note = (
|
| 894 |
-
f"\n\
|
| 895 |
-
"
|
| 896 |
)
|
| 897 |
|
| 898 |
tool_result_text = (
|
| 899 |
f"Tool result for {tool_name} "
|
| 900 |
f"(source: {source_label}):\n"
|
| 901 |
f"```json\n{json.dumps(tagged_result, indent=2, default=str)}\n```\n\n"
|
| 902 |
-
f"
|
| 903 |
-
f"
|
| 904 |
-
f"{freshness_note}"
|
| 905 |
)
|
| 906 |
|
| 907 |
messages.append({"role": "model", "parts": [{"text": response_text}]})
|
|
@@ -913,19 +942,27 @@ class VineyardChatbot:
|
|
| 913 |
else:
|
| 914 |
final_response = response_text
|
| 915 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
# Step 4: Estimate confidence
|
| 917 |
confidence = estimate_confidence(
|
| 918 |
tool_called=tool_call is not None,
|
| 919 |
tool_succeeded=tool_succeeded,
|
| 920 |
data_age_minutes=data_age,
|
| 921 |
tool_name=tool_name,
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
# Step 5: Post-response rule validation
|
| 925 |
-
validation_ctx = self._get_validation_context()
|
| 926 |
-
violations = validate_response(
|
| 927 |
-
response_text=final_response,
|
| 928 |
-
context=validation_ctx,
|
| 929 |
)
|
| 930 |
|
| 931 |
caveats: list[str] = []
|
|
|
|
| 169 |
(VSP trellis, 1.2 m canopy) under single-axis solar trackers (1.13 m panel at \
|
| 170 |
2.05 m height, 3.0 m row spacing).
|
| 171 |
|
| 172 |
+
LANGUAGE:
|
| 173 |
+
- ALWAYS reply in the same language the user writes in. If they write in \
|
| 174 |
+
Hebrew, reply in Hebrew. If English, reply in English. Match their language \
|
| 175 |
+
exactly — do not switch languages mid-conversation.
|
| 176 |
+
|
| 177 |
CONTROL OBJECTIVE:
|
| 178 |
- Primary goal: maximise annual PV energy production.
|
| 179 |
- Secondary goal: protect vines from heat, water stress, and sunburn using a \
|
|
|
|
| 185 |
- Do NOT guess the current calendar month. If the user does not supply a \
|
| 186 |
date and you do not have a phenology tool result, talk in terms of stages \
|
| 187 |
(budburst, flowering, veraison, etc.) rather than asserting a specific month.
|
| 188 |
+
- IMPORTANT: For "should I shade?" questions, ALWAYS consider phenological \
|
| 189 |
+
stage FIRST. If the vine is dormant (no leaves), shading is irrelevant — \
|
| 190 |
+
say so briefly and recommend full tracking for energy. Do not waste the \
|
| 191 |
+
user's time with weather analysis when the vine has no leaves.
|
| 192 |
|
| 193 |
COMMUNICATION STYLE:
|
| 194 |
+
- Be CONCISE: 2-4 sentences for simple questions, not 15 lines
|
| 195 |
+
- Lead with the answer, then give a brief reason
|
| 196 |
- Always explain WHY a recommendation makes sense biologically
|
| 197 |
- When uncertain, say so and suggest what data would help
|
| 198 |
+
- Do NOT repeat that data is stale multiple times — mention it once
|
| 199 |
|
| 200 |
BIOLOGICAL GUIDELINES (strong constraints; balance them with the energy objective):
|
| 201 |
|
|
|
|
| 739 |
now = datetime.now(tz=tz)
|
| 740 |
lines.append(f"CURRENT STATUS ({now.strftime('%Y-%m-%d %H:%M')} IST):")
|
| 741 |
|
| 742 |
+
# Phenology FIRST — most important context for shading decisions
|
| 743 |
+
try:
|
| 744 |
+
from src.models.phenology import estimate_stage_for_date
|
| 745 |
+
from datetime import date
|
| 746 |
+
stage = estimate_stage_for_date(date.today())
|
| 747 |
+
dormant = stage.id in ("winter_dormancy", "dormant", "pre_budburst")
|
| 748 |
+
lines.append(f" Phenology: {stage.name} ({stage.id})"
|
| 749 |
+
+ (" — DORMANT, no leaves, shading irrelevant" if dormant else ""))
|
| 750 |
+
except Exception:
|
| 751 |
+
pass
|
| 752 |
+
|
| 753 |
# Weather
|
| 754 |
try:
|
| 755 |
wx = self.hub.weather.get_current()
|
|
|
|
| 811 |
except Exception:
|
| 812 |
pass
|
| 813 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
# Control status (from Redis via hub — no direct Redis import)
|
| 815 |
try:
|
| 816 |
ctrl = self.hub.advisory.get_status()
|
|
|
|
| 898 |
tagged_result = tag_tool_result(tool_name, tool_result)
|
| 899 |
data_age = tagged_result.get("_data_age_minutes")
|
| 900 |
|
| 901 |
+
# Auto-supplement: when IMS is stale, also fetch TB sensors
|
| 902 |
+
supplement_text = ""
|
| 903 |
+
if tool_name == "get_current_weather" and data_age is not None and data_age > 120:
|
| 904 |
+
try:
|
| 905 |
+
snap = self.hub.vine_sensors.get_snapshot(light=True)
|
| 906 |
+
if snap and "error" not in snap:
|
| 907 |
+
snap_tagged = tag_tool_result("get_vine_state", snap)
|
| 908 |
+
supplement_text = (
|
| 909 |
+
f"\n\nADDITIONAL: IMS weather is stale ({data_age:.0f} min old). "
|
| 910 |
+
f"Here are FRESH on-site sensor readings from ThingsBoard:\n"
|
| 911 |
+
f"```json\n{json.dumps(snap_tagged, indent=2, default=str)}\n```\n"
|
| 912 |
+
f"Use these fresh readings instead of the stale IMS data for "
|
| 913 |
+
f"current conditions."
|
| 914 |
+
)
|
| 915 |
+
except Exception:
|
| 916 |
+
pass
|
| 917 |
+
|
| 918 |
# Build Pass 2 prompt with source citation instructions
|
| 919 |
source_label = get_source_label(tool_name)
|
| 920 |
freshness_note = ""
|
| 921 |
if data_age is not None and data_age > 60:
|
| 922 |
freshness_note = (
|
| 923 |
+
f"\n\nNote: IMS data is {data_age:.0f} minutes old — "
|
| 924 |
+
"mention this once, briefly."
|
| 925 |
)
|
| 926 |
|
| 927 |
tool_result_text = (
|
| 928 |
f"Tool result for {tool_name} "
|
| 929 |
f"(source: {source_label}):\n"
|
| 930 |
f"```json\n{json.dumps(tagged_result, indent=2, default=str)}\n```\n\n"
|
| 931 |
+
f"Answer the farmer's question concisely (2-4 sentences). "
|
| 932 |
+
f"Lead with the answer, then explain briefly."
|
| 933 |
+
f"{freshness_note}{supplement_text}"
|
| 934 |
)
|
| 935 |
|
| 936 |
messages.append({"role": "model", "parts": [{"text": response_text}]})
|
|
|
|
| 942 |
else:
|
| 943 |
final_response = response_text
|
| 944 |
|
| 945 |
+
# Step 5: Post-response rule validation
|
| 946 |
+
validation_ctx = self._get_validation_context()
|
| 947 |
+
violations = validate_response(
|
| 948 |
+
response_text=final_response,
|
| 949 |
+
context=validation_ctx,
|
| 950 |
+
)
|
| 951 |
+
|
| 952 |
+
# Detect rule-based overrides (dormancy, blocked rules) for confidence
|
| 953 |
+
has_rule_override = any(
|
| 954 |
+
v.rule_name in ("no_leaves_no_shade_problem", "no_shade_before_10", "no_shade_in_may")
|
| 955 |
+
and v.severity == "block"
|
| 956 |
+
for v in violations
|
| 957 |
+
)
|
| 958 |
+
|
| 959 |
# Step 4: Estimate confidence
|
| 960 |
confidence = estimate_confidence(
|
| 961 |
tool_called=tool_call is not None,
|
| 962 |
tool_succeeded=tool_succeeded,
|
| 963 |
data_age_minutes=data_age,
|
| 964 |
tool_name=tool_name,
|
| 965 |
+
rule_override=has_rule_override,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
)
|
| 967 |
|
| 968 |
caveats: list[str] = []
|
src/control_loop.py
CHANGED
|
@@ -111,6 +111,9 @@ class TickResult:
|
|
| 111 |
class ControlLoop:
|
| 112 |
"""15-minute agrivoltaic control loop.
|
| 113 |
|
|
|
|
|
|
|
|
|
|
| 114 |
Parameters
|
| 115 |
----------
|
| 116 |
dry_run : bool
|
|
@@ -119,6 +122,8 @@ class ControlLoop:
|
|
| 119 |
Path to the day-ahead plan JSON file.
|
| 120 |
log_path : Path
|
| 121 |
Path for simulation log output.
|
|
|
|
|
|
|
| 122 |
"""
|
| 123 |
|
| 124 |
def __init__(
|
|
@@ -126,21 +131,31 @@ class ControlLoop:
|
|
| 126 |
dry_run: bool = True,
|
| 127 |
plan_path: Path = DAILY_PLAN_PATH,
|
| 128 |
log_path: Path = SIMULATION_LOG_PATH,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
):
|
| 130 |
self.dry_run = dry_run
|
| 131 |
self.plan_path = plan_path
|
| 132 |
self.log_path = log_path
|
| 133 |
|
| 134 |
-
#
|
| 135 |
-
self._arbiter =
|
| 136 |
-
self._dispatcher =
|
| 137 |
-
self._astro =
|
| 138 |
-
self._hub =
|
| 139 |
-
self._modes =
|
| 140 |
-
self._fleet =
|
|
|
|
|
|
|
|
|
|
| 141 |
self._schedulers: Dict[str, object] = {}
|
| 142 |
-
self._budget_planner = None
|
| 143 |
-
self._router = None
|
| 144 |
self._current_plan: Optional[dict] = None
|
| 145 |
self._tick_log: List[dict] = []
|
| 146 |
|
|
@@ -155,7 +170,7 @@ class ControlLoop:
|
|
| 155 |
self._replan_count: int = 0
|
| 156 |
|
| 157 |
# ------------------------------------------------------------------
|
| 158 |
-
# Lazy component init
|
| 159 |
# ------------------------------------------------------------------
|
| 160 |
|
| 161 |
@property
|
|
@@ -725,6 +740,13 @@ class ControlLoop:
|
|
| 725 |
f" [OVERRIDE: {result.override_reason}]" if result.live_override else "",
|
| 726 |
)
|
| 727 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
return result
|
| 729 |
|
| 730 |
# ------------------------------------------------------------------
|
|
|
|
| 111 |
class ControlLoop:
|
| 112 |
"""15-minute agrivoltaic control loop.
|
| 113 |
|
| 114 |
+
All dependencies are injected via ``__init__()`` with sensible defaults.
|
| 115 |
+
Pass explicit instances for testing (e.g. mock dispatcher).
|
| 116 |
+
|
| 117 |
Parameters
|
| 118 |
----------
|
| 119 |
dry_run : bool
|
|
|
|
| 122 |
Path to the day-ahead plan JSON file.
|
| 123 |
log_path : Path
|
| 124 |
Path for simulation log output.
|
| 125 |
+
arbiter, dispatcher, astro, hub, modes, fleet, budget_planner, router
|
| 126 |
+
Injectable dependencies — pass None (default) to auto-create.
|
| 127 |
"""
|
| 128 |
|
| 129 |
def __init__(
|
|
|
|
| 131 |
dry_run: bool = True,
|
| 132 |
plan_path: Path = DAILY_PLAN_PATH,
|
| 133 |
log_path: Path = SIMULATION_LOG_PATH,
|
| 134 |
+
*,
|
| 135 |
+
arbiter=None,
|
| 136 |
+
dispatcher=None,
|
| 137 |
+
astro=None,
|
| 138 |
+
hub=None,
|
| 139 |
+
modes=None,
|
| 140 |
+
fleet=None,
|
| 141 |
+
budget_planner=None,
|
| 142 |
+
router=None,
|
| 143 |
):
|
| 144 |
self.dry_run = dry_run
|
| 145 |
self.plan_path = plan_path
|
| 146 |
self.log_path = log_path
|
| 147 |
|
| 148 |
+
# Dependencies — lazy-create if not injected
|
| 149 |
+
self._arbiter = arbiter
|
| 150 |
+
self._dispatcher = dispatcher
|
| 151 |
+
self._astro = astro
|
| 152 |
+
self._hub = hub
|
| 153 |
+
self._modes = modes
|
| 154 |
+
self._fleet = fleet
|
| 155 |
+
self._budget_planner = budget_planner
|
| 156 |
+
self._router = router
|
| 157 |
+
|
| 158 |
self._schedulers: Dict[str, object] = {}
|
|
|
|
|
|
|
| 159 |
self._current_plan: Optional[dict] = None
|
| 160 |
self._tick_log: List[dict] = []
|
| 161 |
|
|
|
|
| 170 |
self._replan_count: int = 0
|
| 171 |
|
| 172 |
# ------------------------------------------------------------------
|
| 173 |
+
# Lazy component init (auto-create when not injected)
|
| 174 |
# ------------------------------------------------------------------
|
| 175 |
|
| 176 |
@property
|
|
|
|
| 740 |
f" [OVERRIDE: {result.override_reason}]" if result.live_override else "",
|
| 741 |
)
|
| 742 |
|
| 743 |
+
# 12. Budget audit — append slot to parquet log
|
| 744 |
+
try:
|
| 745 |
+
from src.budget_audit import BudgetAuditLog
|
| 746 |
+
BudgetAuditLog().log_slot(result)
|
| 747 |
+
except Exception as exc:
|
| 748 |
+
logger.debug("Budget audit log skipped: %s", exc)
|
| 749 |
+
|
| 750 |
return result
|
| 751 |
|
| 752 |
# ------------------------------------------------------------------
|
src/models/farquhar_model.py
CHANGED
|
@@ -302,29 +302,76 @@ class FarquharModel:
|
|
| 302 |
humidity_col: Optional[str] = "Air1_airHumidity_ref",
|
| 303 |
) -> pd.Series:
|
| 304 |
"""
|
| 305 |
-
Compute A for each row
|
| 306 |
Returns Series of A (umol CO2 m-2 s-1), index aligned to df.
|
| 307 |
"""
|
| 308 |
required = [par_col, tleaf_col, co2_col, vpd_col, tair_col]
|
| 309 |
for c in required:
|
| 310 |
if c not in df.columns:
|
| 311 |
return pd.Series(np.nan, index=df.index)
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
humidity_col: Optional[str] = "Air1_airHumidity_ref",
|
| 303 |
) -> pd.Series:
|
| 304 |
"""
|
| 305 |
+
Compute A for each row using vectorized pandas operations (~100x faster).
|
| 306 |
Returns Series of A (umol CO2 m-2 s-1), index aligned to df.
|
| 307 |
"""
|
| 308 |
required = [par_col, tleaf_col, co2_col, vpd_col, tair_col]
|
| 309 |
for c in required:
|
| 310 |
if c not in df.columns:
|
| 311 |
return pd.Series(np.nan, index=df.index)
|
| 312 |
+
|
| 313 |
+
# Extract columns as float arrays
|
| 314 |
+
par = df[par_col].astype(float)
|
| 315 |
+
tleaf = df[tleaf_col].astype(float)
|
| 316 |
+
co2 = df[co2_col].astype(float)
|
| 317 |
+
vpd = df[vpd_col].astype(float)
|
| 318 |
+
tair = df[tair_col].astype(float)
|
| 319 |
+
|
| 320 |
+
# Vectorized CWSI from (Tleaf - Tair) with empirical bounds
|
| 321 |
+
dT = tleaf - tair
|
| 322 |
+
n_valid = dT.notna().sum()
|
| 323 |
+
dTmin = float(dT.quantile(0.05)) if n_valid > 10 else -2.0
|
| 324 |
+
dTmax = float(dT.quantile(0.95)) if n_valid > 10 else 8.0
|
| 325 |
+
if dTmax <= dTmin:
|
| 326 |
+
cwsi = pd.Series(0.0, index=df.index)
|
| 327 |
+
else:
|
| 328 |
+
cwsi = ((dT - dTmin) / (dTmax - dTmin)).clip(0.0, 1.0)
|
| 329 |
+
|
| 330 |
+
# Vectorized FvCB computation
|
| 331 |
+
Tk = tleaf + 273.15
|
| 332 |
+
|
| 333 |
+
# Michaelis constants (Bernacchi et al. 2001) — vectorized
|
| 334 |
+
Kc = np.exp(38.05 - 79430.0 / (R * Tk))
|
| 335 |
+
Ko = np.exp(20.30 - 36380.0 / (R * Tk)) * 1000.0
|
| 336 |
+
gamma_star = np.exp(19.02 - 37830.0 / (R * Tk))
|
| 337 |
+
|
| 338 |
+
# Vcmax and Jmax (modified Arrhenius) — vectorized
|
| 339 |
+
Vcmax = _modified_arrhenius(Tk, self.params["k25_vcmax"], self.params["Ha_vcmax"],
|
| 340 |
+
self.params["Hd_vcmax"], self.params["S_vcmax"])
|
| 341 |
+
Jmax = _modified_arrhenius(Tk, self.params["k25_jmax"], self.params["Ha_jmax"],
|
| 342 |
+
self.params["Hd_jmax"], self.params["S_jmax"])
|
| 343 |
+
|
| 344 |
+
# Electron transport J — vectorized quadratic solve
|
| 345 |
+
alpha = self.params["alpha"]
|
| 346 |
+
theta = self.params["theta"]
|
| 347 |
+
b = alpha * par + Jmax
|
| 348 |
+
c_val = alpha * par * Jmax
|
| 349 |
+
disc = b * b - 4 * theta * c_val
|
| 350 |
+
disc_safe = disc.clip(lower=0)
|
| 351 |
+
J = ((b - np.sqrt(disc_safe)) / (2 * theta)).clip(lower=0)
|
| 352 |
+
J = np.minimum(J, Jmax)
|
| 353 |
+
J = J.where(par > 0, 0.0)
|
| 354 |
+
|
| 355 |
+
# Dark respiration
|
| 356 |
+
Rd = self.params["rd_frac"] * Vcmax
|
| 357 |
+
|
| 358 |
+
# Intercellular CO2 — vectorized ci/ca
|
| 359 |
+
vpd_scale = np.exp(-0.3 * (vpd - 1.0).clip(lower=0))
|
| 360 |
+
stress = 1.0 - 0.5 * cwsi.fillna(0)
|
| 361 |
+
gs_factor = 2.1 * vpd_scale * stress
|
| 362 |
+
ci = co2 * (1.0 - 1.0 / (1.6 * gs_factor.clip(lower=0.01)))
|
| 363 |
+
ci = ci.clip(lower=co2 * 0.3, upper=co2)
|
| 364 |
+
|
| 365 |
+
# Rubisco-limited (Ac) and RuBP-limited (Aj) rates
|
| 366 |
+
Ac = Vcmax * (ci - gamma_star) / (ci + Kc * (1.0 + OI / Ko))
|
| 367 |
+
Aj = J * (ci - gamma_star) / (4.0 * ci + 8.0 * gamma_star)
|
| 368 |
+
|
| 369 |
+
# Net assimilation
|
| 370 |
+
An = np.minimum(Ac, Aj) - Rd
|
| 371 |
+
An = An.clip(lower=0.0)
|
| 372 |
+
|
| 373 |
+
# NaN where any input was NaN
|
| 374 |
+
valid = par.notna() & tleaf.notna() & co2.notna() & vpd.notna() & tair.notna()
|
| 375 |
+
An = An.where(valid, np.nan)
|
| 376 |
+
|
| 377 |
+
return An
|
src/shading/tradeoff_engine.py
CHANGED
|
@@ -112,36 +112,12 @@ class InterventionGate:
|
|
| 112 |
self.shade_eligible_cwsi_above = shade_eligible_cwsi_above
|
| 113 |
self.shade_eligible_ghi_above = shade_eligible_ghi_above
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
ghi_w_m2: Optional[float],
|
| 119 |
-
cwsi: Optional[float],
|
| 120 |
-
shading_helps: Optional[bool],
|
| 121 |
-
dt: Optional[datetime] = None, # accepted but not used; preserved for logging
|
| 122 |
-
) -> GateDecision:
|
| 123 |
-
"""
|
| 124 |
-
Evaluate whether the vine is significantly stressed.
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
tleaf_c : leaf temperature (°C) — from SensorRaw or forecast
|
| 129 |
-
ghi_w_m2 : global horizontal irradiance (W/m²) — from IMS or TB
|
| 130 |
-
cwsi : Crop Water Stress Index [0–1] — from TB or computed
|
| 131 |
-
shading_helps : output of FarquharModel — True only when Rubisco-limited
|
| 132 |
-
AND reducing PAR would increase net A
|
| 133 |
-
dt : slot datetime (optional; used only for logging tags)
|
| 134 |
-
|
| 135 |
-
Returns
|
| 136 |
-
-------
|
| 137 |
-
GateDecision
|
| 138 |
-
passed=True only when all physiological stress conditions are met.
|
| 139 |
-
The caller then passes to TradeoffEngine.find_minimum_dose() to
|
| 140 |
-
determine whether the current sun geometry allows effective shading.
|
| 141 |
-
"""
|
| 142 |
-
dec = GateDecision(passed=False)
|
| 143 |
-
|
| 144 |
-
# 1. Night / deep overcast guard — no useful sun, skip shadow computation
|
| 145 |
if ghi_w_m2 is not None and ghi_w_m2 < self.min_meaningful_ghi:
|
| 146 |
dec.no_meaningful_sun = True
|
| 147 |
dec.rejection_reason = (
|
|
@@ -149,9 +125,10 @@ class InterventionGate:
|
|
| 149 |
f"< {self.min_meaningful_ghi:.0f}"
|
| 150 |
)
|
| 151 |
return dec
|
|
|
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
| 155 |
if tleaf_c is not None and tleaf_c < self.shade_eligible_tleaf_above:
|
| 156 |
dec.tleaf_below_threshold = True
|
| 157 |
dec.rejection_reason = (
|
|
@@ -159,8 +136,10 @@ class InterventionGate:
|
|
| 159 |
f"< {self.shade_eligible_tleaf_above:.0f}°C (Rubisco transition)"
|
| 160 |
)
|
| 161 |
return dec
|
|
|
|
| 162 |
|
| 163 |
-
|
|
|
|
| 164 |
if cwsi is not None and cwsi < self.shade_eligible_cwsi_above:
|
| 165 |
dec.cwsi_below_threshold = True
|
| 166 |
dec.rejection_reason = (
|
|
@@ -168,8 +147,10 @@ class InterventionGate:
|
|
| 168 |
f"< {self.shade_eligible_cwsi_above:.2f}"
|
| 169 |
)
|
| 170 |
return dec
|
|
|
|
| 171 |
|
| 172 |
-
|
|
|
|
| 173 |
if ghi_w_m2 is not None and ghi_w_m2 < self.shade_eligible_ghi_above:
|
| 174 |
dec.ghi_below_threshold = True
|
| 175 |
dec.rejection_reason = (
|
|
@@ -177,9 +158,10 @@ class InterventionGate:
|
|
| 177 |
f"< {self.shade_eligible_ghi_above:.0f} W/m²"
|
| 178 |
)
|
| 179 |
return dec
|
|
|
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
dec.biology_says_shade_helps = bool(shading_helps)
|
| 184 |
if not shading_helps:
|
| 185 |
dec.rejection_reason = (
|
|
@@ -187,8 +169,51 @@ class InterventionGate:
|
|
| 187 |
"possibly declining afternoon PAR or unusual conditions"
|
| 188 |
)
|
| 189 |
return dec
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
-
# All stress conditions met
|
| 192 |
dec.passed = True
|
| 193 |
return dec
|
| 194 |
|
|
|
|
| 112 |
self.shade_eligible_cwsi_above = shade_eligible_cwsi_above
|
| 113 |
self.shade_eligible_ghi_above = shade_eligible_ghi_above
|
| 114 |
|
| 115 |
+
# ------------------------------------------------------------------
|
| 116 |
+
# Individual gate checks (pipeline pattern)
|
| 117 |
+
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
+
def _check_meaningful_sun(self, ghi_w_m2: Optional[float], dec: GateDecision) -> Optional[GateDecision]:
|
| 120 |
+
"""Block if GHI too low (night / deep overcast)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
if ghi_w_m2 is not None and ghi_w_m2 < self.min_meaningful_ghi:
|
| 122 |
dec.no_meaningful_sun = True
|
| 123 |
dec.rejection_reason = (
|
|
|
|
| 125 |
f"< {self.min_meaningful_ghi:.0f}"
|
| 126 |
)
|
| 127 |
return dec
|
| 128 |
+
return None
|
| 129 |
|
| 130 |
+
def _check_heat_stress(self, tleaf_c: Optional[float], dec: GateDecision) -> Optional[GateDecision]:
|
| 131 |
+
"""Block if leaf temperature below Rubisco transition (vine is light-limited)."""
|
| 132 |
if tleaf_c is not None and tleaf_c < self.shade_eligible_tleaf_above:
|
| 133 |
dec.tleaf_below_threshold = True
|
| 134 |
dec.rejection_reason = (
|
|
|
|
| 136 |
f"< {self.shade_eligible_tleaf_above:.0f}°C (Rubisco transition)"
|
| 137 |
)
|
| 138 |
return dec
|
| 139 |
+
return None
|
| 140 |
|
| 141 |
+
def _check_water_stress(self, cwsi: Optional[float], dec: GateDecision) -> Optional[GateDecision]:
|
| 142 |
+
"""Block if CWSI below threshold (vine not water-stressed)."""
|
| 143 |
if cwsi is not None and cwsi < self.shade_eligible_cwsi_above:
|
| 144 |
dec.cwsi_below_threshold = True
|
| 145 |
dec.rejection_reason = (
|
|
|
|
| 147 |
f"< {self.shade_eligible_cwsi_above:.2f}"
|
| 148 |
)
|
| 149 |
return dec
|
| 150 |
+
return None
|
| 151 |
|
| 152 |
+
def _check_radiation_load(self, ghi_w_m2: Optional[float], dec: GateDecision) -> Optional[GateDecision]:
|
| 153 |
+
"""Block if radiation too low for meaningful heat build-up."""
|
| 154 |
if ghi_w_m2 is not None and ghi_w_m2 < self.shade_eligible_ghi_above:
|
| 155 |
dec.ghi_below_threshold = True
|
| 156 |
dec.rejection_reason = (
|
|
|
|
| 158 |
f"< {self.shade_eligible_ghi_above:.0f} W/m²"
|
| 159 |
)
|
| 160 |
return dec
|
| 161 |
+
return None
|
| 162 |
|
| 163 |
+
def _check_biology(self, shading_helps: Optional[bool], dec: GateDecision) -> Optional[GateDecision]:
|
| 164 |
+
"""Block if FvCB model says shading would hurt (RuBP-limited)."""
|
| 165 |
dec.biology_says_shade_helps = bool(shading_helps)
|
| 166 |
if not shading_helps:
|
| 167 |
dec.rejection_reason = (
|
|
|
|
| 169 |
"possibly declining afternoon PAR or unusual conditions"
|
| 170 |
)
|
| 171 |
return dec
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
# ------------------------------------------------------------------
|
| 175 |
+
# Main evaluate (pipeline composition)
|
| 176 |
+
# ------------------------------------------------------------------
|
| 177 |
+
|
| 178 |
+
def evaluate(
|
| 179 |
+
self,
|
| 180 |
+
tleaf_c: Optional[float],
|
| 181 |
+
ghi_w_m2: Optional[float],
|
| 182 |
+
cwsi: Optional[float],
|
| 183 |
+
shading_helps: Optional[bool],
|
| 184 |
+
dt: Optional[datetime] = None, # accepted but not used; preserved for logging
|
| 185 |
+
) -> GateDecision:
|
| 186 |
+
"""
|
| 187 |
+
Evaluate whether the vine is significantly stressed.
|
| 188 |
+
|
| 189 |
+
Runs a pipeline of 5 checks in order. First rejection stops the pipeline.
|
| 190 |
+
Gate passes only when ALL stress conditions are simultaneously met.
|
| 191 |
+
|
| 192 |
+
Parameters
|
| 193 |
+
----------
|
| 194 |
+
tleaf_c : leaf temperature (°C)
|
| 195 |
+
ghi_w_m2 : global horizontal irradiance (W/m²)
|
| 196 |
+
cwsi : Crop Water Stress Index [0–1]
|
| 197 |
+
shading_helps : output of FarquharModel
|
| 198 |
+
dt : slot datetime (optional; for logging only)
|
| 199 |
+
"""
|
| 200 |
+
dec = GateDecision(passed=False)
|
| 201 |
+
|
| 202 |
+
# Run checks as a pipeline — first rejection short-circuits
|
| 203 |
+
checks = [
|
| 204 |
+
lambda d: self._check_meaningful_sun(ghi_w_m2, d),
|
| 205 |
+
lambda d: self._check_heat_stress(tleaf_c, d),
|
| 206 |
+
lambda d: self._check_water_stress(cwsi, d),
|
| 207 |
+
lambda d: self._check_radiation_load(ghi_w_m2, d),
|
| 208 |
+
lambda d: self._check_biology(shading_helps, d),
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
for check in checks:
|
| 212 |
+
rejection = check(dec)
|
| 213 |
+
if rejection is not None:
|
| 214 |
+
return rejection
|
| 215 |
|
| 216 |
+
# All stress conditions met
|
| 217 |
dec.passed = True
|
| 218 |
return dec
|
| 219 |
|