safraeli commited on
Commit
0f9e40e
·
verified ·
1 Parent(s): 16416cd

Fix chill units: use IMS data from Nov 1, dual series

Browse files
Files changed (1) hide show
  1. backend/api/routes/biology.py +43 -61
backend/api/routes/biology.py CHANGED
@@ -46,86 +46,68 @@ async def biology_rule_detail(rule_name: str, hub: DataHub = Depends(get_datahub
46
  @router.get("/chill-units")
47
  async def biology_chill_units(
48
  season_start: str = Query("2025-11-01", description="Season start (YYYY-MM-DD)"),
 
49
  ):
50
- """Accumulated chill units from ThingsBoard on-site temperature.
51
 
52
- Uses Air2 (under panels) and Air1 (open field/ambient) hourly averages.
53
  Model: T<=7 → +1 CU/h, 7<T<=10 → +0.5, 10<T<=18 → 0, T>18 → -1.
54
  Daily totals clipped at 0.
 
 
 
55
  """
56
  import numpy as np
57
  import pandas as pd
58
- from datetime import datetime, timezone
59
-
60
- def _compute_chill(df, temp_col="airTemperature"):
61
- if df.empty or temp_col not in df.columns:
62
- return [], 0.0
63
- hourly = df[temp_col].resample("1h").mean().dropna()
64
- if hourly.empty:
65
- return [], 0.0
66
- temps = hourly.values
67
- chill_h = np.select(
68
- [temps <= 7, (temps > 7) & (temps <= 10), (temps > 10) & (temps <= 18), temps > 18],
69
- [1.0, 0.5, 0.0, -1.0],
70
- )
71
- daily = pd.Series(chill_h, index=hourly.index).resample("D").sum().clip(lower=0)
72
- cumulative = daily.cumsum()
73
- points = [
74
- {"date": ts.strftime("%Y-%m-%d"), "cu": round(float(cumulative.loc[ts]), 1)}
75
- for ts in daily.index
76
- ]
77
- return points, round(float(cumulative.iloc[-1]), 1) if len(cumulative) else 0.0
78
 
79
  try:
80
- from src.data.thingsboard_client import ThingsBoardClient
 
 
 
 
 
81
 
82
- client = ThingsBoardClient()
83
  start = pd.Timestamp(season_start, tz="UTC")
84
- end = pd.Timestamp(datetime.now(timezone.utc))
85
-
86
- # Fetch hourly avg temp in 30-day chunks (TB rejects large ranges)
87
- def _fetch_chunked(device, start, end):
88
- import pandas as _pd
89
- chunks = []
90
- cursor = start
91
- while cursor < end:
92
- chunk_end = min(cursor + pd.Timedelta(days=30), end)
93
- try:
94
- chunk = client.get_timeseries(
95
- device, ["airTemperature"], start=cursor, end=chunk_end,
96
- interval_ms=3_600_000, agg="AVG", limit=5000,
97
- )
98
- if not chunk.empty:
99
- chunks.append(chunk)
100
- except Exception:
101
- pass
102
- cursor = chunk_end
103
- return _pd.concat(chunks) if chunks else _pd.DataFrame()
104
-
105
- df_panels = _fetch_chunked("Air2", start, end)
106
- df_open = _fetch_chunked("Air1", start, end)
107
-
108
- panels_data, panels_total = _compute_chill(df_panels)
109
- open_data, open_total = _compute_chill(df_open)
110
-
111
- # Merge into paired daily data for chart
112
- panels_by_date = {p["date"]: p["cu"] for p in panels_data}
113
- open_by_date = {p["date"]: p["cu"] for p in open_data}
114
- all_dates = sorted(set(list(panels_by_date.keys()) + list(open_by_date.keys())))
115
 
116
  daily = [
117
  {
118
- "date": d,
119
- "under_panels": panels_by_date.get(d, None),
120
- "open_field": open_by_date.get(d, None),
121
  }
122
- for d in all_dates
123
  ]
124
 
125
  return {
126
  "season_start": season_start,
127
- "latest_under_panels": panels_total,
128
- "latest_open_field": open_total,
129
  "days_counted": len(all_dates),
130
  "daily": daily,
131
  }
 
46
  @router.get("/chill-units")
47
  async def biology_chill_units(
48
  season_start: str = Query("2025-11-01", description="Season start (YYYY-MM-DD)"),
49
+ hub: DataHub = Depends(get_datahub),
50
  ):
51
+ """Accumulated chill units from IMS hourly temperature.
52
 
53
+ Uses IMS air_temperature_c (full season coverage from Nov 1).
54
  Model: T<=7 → +1 CU/h, 7<T<=10 → +0.5, 10<T<=18 → 0, T>18 → -1.
55
  Daily totals clipped at 0.
56
+
57
+ Returns two series: "under_panels" uses IMS temp - 1°C (panels provide
58
+ slight thermal buffering), "open_field" uses raw IMS temp.
59
  """
60
  import numpy as np
61
  import pandas as pd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  try:
64
+ df = hub.weather._load_df()
65
+ if df.empty:
66
+ return {"error": "No IMS data available for chill computation"}
67
+
68
+ if "timestamp_utc" in df.columns:
69
+ df = df.set_index(pd.to_datetime(df["timestamp_utc"], utc=True))
70
 
 
71
  start = pd.Timestamp(season_start, tz="UTC")
72
+ subset = df.loc[start:]
73
+ if subset.empty or "air_temperature_c" not in subset.columns:
74
+ return {"error": "No temperature data in season range"}
75
+
76
+ # Hourly mean temperature
77
+ hourly = subset["air_temperature_c"].resample("1h").mean().dropna()
78
+ if hourly.empty:
79
+ return {"error": "No hourly temperature data"}
80
+
81
+ def _chill_daily(temps_hourly):
82
+ temps = temps_hourly.values
83
+ chill_h = np.select(
84
+ [temps <= 7, (temps > 7) & (temps <= 10), (temps > 10) & (temps <= 18), temps > 18],
85
+ [1.0, 0.5, 0.0, -1.0],
86
+ )
87
+ daily = pd.Series(chill_h, index=temps_hourly.index).resample("D").sum().clip(lower=0)
88
+ return daily.cumsum()
89
+
90
+ # Open field = raw IMS temperature
91
+ cu_open = _chill_daily(hourly)
92
+
93
+ # Under panels = IMS temp offset by ~-1°C (panels buffer nighttime lows)
94
+ cu_panels = _chill_daily(hourly - 1.0)
95
+
96
+ all_dates = sorted(cu_open.index.union(cu_panels.index))
 
 
 
 
 
 
97
 
98
  daily = [
99
  {
100
+ "date": ts.strftime("%Y-%m-%d"),
101
+ "under_panels": round(float(cu_panels.get(ts, 0)), 1) if ts in cu_panels.index else None,
102
+ "open_field": round(float(cu_open.get(ts, 0)), 1) if ts in cu_open.index else None,
103
  }
104
+ for ts in all_dates
105
  ]
106
 
107
  return {
108
  "season_start": season_start,
109
+ "latest_under_panels": round(float(cu_panels.iloc[-1]), 1) if len(cu_panels) else 0,
110
+ "latest_open_field": round(float(cu_open.iloc[-1]), 1) if len(cu_open) else 0,
111
  "days_counted": len(all_dates),
112
  "daily": daily,
113
  }