safraeli commited on
Commit
15be6bb
·
verified ·
1 Parent(s): d9ee5b0

Vectorize Farquhar, DI ControlLoop, gate pipeline, budget audit, chatbot Hebrew

Browse files
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
- - Use plain language; explain jargon when you first use it
186
- - Be concise but thorough
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\nIMPORTANT: This data is {data_age:.0f} minutes old. "
895
- "Tell the user the data may be stale and conditions may have changed."
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"Explain this result to the farmer in plain language. "
903
- f"When quoting numbers, mention that they come from {source_label}."
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
- # Lazy-init components
135
- self._arbiter = None
136
- self._dispatcher = None
137
- self._astro = None
138
- self._hub = None
139
- self._modes = None
140
- self._fleet = None
 
 
 
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. Uses CWSI from Tleaf/Tair/VPD with empirical bounds.
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
- # Empirical CWSI bounds from (Tleaf - Tair) percentiles if enough data
313
- dT = df[tleaf_col] - df[tair_col]
314
- dTmin = float(dT.quantile(0.05)) if len(dT.dropna()) > 10 else -2.0
315
- dTmax = float(dT.quantile(0.95)) if len(dT.dropna()) > 10 else 8.0
316
- out = []
317
- for _, row in df.iterrows():
318
- try:
319
- par, tleaf, co2, vpd, tair = row[par_col], row[tleaf_col], row[co2_col], row[vpd_col], row[tair_col]
320
- if pd.isna([par, tleaf, co2, vpd, tair]).any():
321
- out.append(np.nan)
322
- continue
323
- cwsi = self.calc_CWSI(float(tleaf), float(tair), float(vpd), dTmin, dTmax)
324
- a = self.calc_photosynthesis(
325
- float(par), float(tleaf), float(co2), float(vpd), float(tair), CWSI=cwsi
326
- )
327
- out.append(a)
328
- except (TypeError, ZeroDivisionError, ValueError):
329
- out.append(np.nan)
330
- return pd.Series(out, index=df.index)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def evaluate(
116
- self,
117
- tleaf_c: Optional[float],
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
- Parameters
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
- # 2. Leaf temperature below Rubisco transition — vine is light-limited,
154
- # reducing PAR would hurt photosynthesis
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
- # 3. Water stress not confirmed — vine is well-watered, no urgent need
 
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
- # 4. Radiation load not high enough to cause meaningful heat build-up
 
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
- # 5. Biology confirms shading would help — FvCB model is Rubisco-limited
182
- # and A would increase if PAR on the fruiting zone drops
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 — pass to TradeoffEngine for geometry check
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