File size: 21,351 Bytes
13fc29d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# Deep Code Review – Photosynthesis Prediction Model

**Scope:** `config/settings.py`, `src/*.py`  
**Date:** 2025-02

---

## 1. Executive Summary

The OOP architecture matches the plan and is generally sound. **Critical issues** to fix: (1) IMS `resample_to_15min` receives DataFrames whose first channel may have `timestamp_utc` as index, not column; (2) `Preprocessor.merge_ims_with_labels` can produce duplicate timestamp columns and misalign when labels are not index-aligned; (3) `FarquharModel.compute_all` uses row-wise Python loop and can leak NaN. **Medium** issues: missing validation (paths, token, NaN handling), IMS timezone handling, and preprocessor edge cases. **Low**: type hints, docstrings, and minor robustness.

---

## 2. config/settings.py

| Finding | Severity | Notes |
|--------|----------|--------|
| No validation that paths exist | Low | `SENSORS_WIDE_PATH` may not exist; code fails at load time. Acceptable but could add a `validate_paths()` or document. |
| `TRAIN_RATIO` used with `or` in Preprocessor | Low | `train_ratio or settings.TRAIN_RATIO` treats `0.0` as falsy; use `train_ratio if train_ratio is not None else settings.TRAIN_RATIO` if 0 is ever valid. |

**Verdict:** Good; no critical bugs.

---

## 3. src/sensor_data_loader.py

| Finding | Severity | Notes |
|--------|----------|--------|
| `read_csv(..., usecols=lambda c: c in use_cols)` | Medium | If the CSV has no `time` column or a column is missing, `read_csv` may error or drop columns silently. Consider validating columns after load. |
| Missing columns not reported | Low | When requested columns are absent, pandas may omit them; caller gets incomplete DataFrame. |
| `filter_daytime` with NaN in PAR | Low | `df[par_column] > par_threshold` yields False for NaN; those rows are dropped. Document or add `dropna(subset=[par_column])` explicitly. |

**Verdict:** Solid. Add a post-load check that required columns exist and optionally log missing ones.

---

## 4. src/ims_client.py

| Finding | Severity | Notes |
|--------|----------|--------|
| **`resample_to_15min` assumes column** | **Critical** | `fetch_channel` returns a DataFrame with `timestamp_utc` as **index** (line 83). `fetch_all_channels` builds `out` by joining such DataFrames and then `reset_index()`, so the final `out` has `timestamp_utc` as a column. So after `fetch_all_channels` we're OK. But `resample_to_15min` is also called with the result of `fetch_all_channels` which has the column. So actually OK. Double-check: `load_cached` returns df with column; `fetch_and_cache` calls `fetch_all_channels` then `resample_to_15min` – and `fetch_all_channels` does `reset_index()`, so column exists. **Verdict:** OK as written. |
| Empty token | Medium | If `IMS_API_TOKEN` is missing, `fetch_channel` will call API with empty token and get 401. Add an explicit check in `fetch_channel` or `__init__` and raise a clear error. |
| IMS returns Israel time | Medium | Doc says IMS returns Israel time; we pass to `pd.to_datetime(..., utc=True)`. If the string has no timezone, pandas treats it as local (or naive). We should parse as Israel and convert to UTC, or document that we assume UTC. |
| No retries / rate limiting | Low | Single request; 60s timeout. For long ranges the API may throttle; consider retries and chunking (as in plan). |
| `fetch_all_channels` first frame has index, no column | Critical | First iteration: `out = df` where `df` is from `fetch_channel` – so `out` has **index** `timestamp_utc`, no column named `timestamp_utc`. Then `out.join(df, ...)` keeps index. So after the loop, `out.reset_index()` creates column `timestamp_utc`. So we're good. |
| `load_cached` and CSV timestamp | Low | After `read_csv`, column is object; we convert with `pd.to_datetime(..., utc=True)`. Good. |

**Verdict:** Add token validation; clarify or fix timezone handling for IMS datetime.

---

## 5. src/farquhar_model.py

| Finding | Severity | Notes |
|--------|----------|--------|
| **`compute_all` row-wise loop** | **Medium** | `df.iterrows()` is slow and not vectorized; any NaN in a row propagates (e.g. `row[par_col]` can be NaN). We should vectorize or use `apply` and handle NaN explicitly (e.g. skip or fill). |
| **NaN propagation** | **Medium** | `calc_photosynthesis` and helpers don't check for NaN; numpy will propagate them. So `compute_all` can return a Series full of NaN if one input column has NaNs. Add NaN handling (e.g. return NaN for that row and document). |
| Ko units and OI | Low | Plan: `Ko = exp(20.30 - 36380/(R*Tk))`; code multiplies by 1000 "to match OI". OI is 210 mmol/mol. Formula units vary by source; document or cite so Ko/OI consistency is clear. |
| Vcmax/Jmax above Topt | Low | Simplified Gaussian decline above Topt; not the full Greer & Weedon equation. Document as simplified. |
| `_ci_from_ca` and gs_factor | Low | Stomatal model is heuristic (VPD/CWSI scaling). Document as simplified. |
| Division by zero | Low | In `calc_photosynthesis`, `ci + Kc*(1+OI/Ko)` could in theory be 0 for extreme T; clip or guard if needed. |

**Verdict:** Fix NaN handling and consider vectorization or explicit NaN-in β†’ NaN-out in `compute_all`.

---

## 6. src/preprocessor.py

| Finding | Severity | Notes |
|--------|----------|--------|
| **merge when labels not index-aligned** | **Critical** | When `timestamp_index_labels=False`, we do `merged["A"] = labels.values[: len(merged)]`. This assumes `labels` and `ims_df` are **ordered identically** by time and that `len(labels) >= len(merged)`. If IMS and labels have different timestamps (inner join not used), alignment is wrong. When `timestamp_index_labels=True`, inner merge is correct. Recommendation: deprecate or remove the `timestamp_index_labels=False` path, or require an explicit timestamp column in labels and merge on it. |
| Duplicate timestamp columns after merge | Low | After inner merge we may have both `timestamp_utc` (from IMS) and the label index column (e.g. `time`). Consider dropping the duplicate: `merged.drop(columns=[ts_lab], inplace=True)` if `ts_lab != timestamp_col_ims`. |
| `temporal_split` return contract when n>=len(df) | Medium | When `n >= len(df)` we return `X, y, empty, empty` – so "train" is full dataset, test is empty. Caller may not expect that. Document or return a clear sentinel. |
| `int(len(df) * self.train_ratio)` | Low | For very small df, n can be 0; we already return early for `n <= 0`. Good. |
| `fit_transform_train` overwrites scaler | Low | Each call fits a new scaler; no way to "refit" explicitly. Acceptable. |

**Verdict:** Fix merge logic when labels are not index-based; document or adjust temporal_split when test is empty.

---

## 7. src/predictor.py

| Finding | Severity | Notes |
|--------|----------|--------|
| `get_feature_importance` | Low | Uses `feature_names_in_` (sklearn 1.0+). For older sklearn, fallback to `list(range(len(imp)))` is correct. |
| `plot_results` and `self.results` | Low | If `evaluate` was never called, `self.results` is empty and plot is no-op. Document. |
| R2 when variance is zero | Low | If `y_test` is constant, `r2_score` can be negative or undefined; sklearn returns 0 or a value. Acceptable. |
| Unused import `seaborn` | Low | Remove if not used. |

**Verdict:** Minor cleanups only.

---

## 8. src/__init__.py

| Finding | Severity | Notes |
|--------|----------|--------|
| Lazy `__getattr__` | Low | Works; avoids pulling config/requests on import. |
| No `__dir__` | Low | `dir(src)` won't list the lazy attributes; could add `__dir__` returning `__all__` for better discoverability. |

**Verdict:** Good.

---

## 9. Cross-Cutting and Data Flow

| Finding | Severity | Notes |
|--------|----------|--------|
| Sensor timestamp vs IMS timestamp | Medium | SensorDataLoader uses column `time` (from sensors_wide); IMS uses `timestamp_utc`. For merge, Preprocessor expects labels with datetime index or a column. Ensure sensor data index (or column) is set to the same timezone (UTC) and format when building labels for merge. |
| No end-to-end test | Medium | A minimal script or test that runs load β†’ Farquhar β†’ merge β†’ split β†’ train β†’ evaluate would catch integration bugs. |

---

## 10. Recommended Fixes (Priority Order)

1. **Preprocessor.merge_ims_with_labels:** When `timestamp_index_labels=False`, do not align by position; require a timestamp column in labels and merge on it, or drop this branch and require index-based labels.
2. **IMSClient:** Validate `self.token` in `__init__` or at first fetch and raise a clear error if missing.
3. **FarquharModel.compute_all:** Handle NaN (e.g. skip row or set A=NaN); consider vectorizing or using `apply` with a wrapper that catches NaN.
4. **Preprocessor:** After merge, drop the redundant timestamp column if `ts_lab != timestamp_col_ims`.
5. **SensorDataLoader.load:** After `read_csv`, check that required columns (at least timestamp and Stage 1) are present and log or raise if not.
6. **predictor:** Remove unused `seaborn` import; add `__dir__` to `src/__init__.py` for discoverability.

---

## 11. Summary Table

| Module | Critical | Medium | Low |
|--------|----------|--------|-----|
| config/settings.py | 0 | 0 | 2 |
| sensor_data_loader.py | 0 | 1 | 2 |
| ims_client.py | 0 | 2 | 2 |
| farquhar_model.py | 0 | 2 | 4 |
| preprocessor.py | 1 | 1 | 3 |
| predictor.py | 0 | 0 | 3 |
| __init__.py | 0 | 0 | 1 |

**Total: 1 critical, 6 medium, 17 low.**

---

## 12. scripts/run_pipeline.py and app.py (post-todo completion)

| Finding | Severity | Notes |
|--------|----------|--------|
| run_pipeline: Stage 1 labels index | Low | Labels index set to floor 15min and aggregated; aligns with IMS for merge. Good. |
| run_pipeline: Stage 2 requires overlap | Low | If IMS cache and sensor date ranges don't overlap, merge is empty; message added. |
| run_pipeline: global `pd` | Low | `import pandas as pd` at module level; used in run_stage1 and validate_stage1. |
| app.py: run_stage1/run_stage2 buttons | Low | Buttons trigger pipeline; state not persisted across reruns. Acceptable. |
| app.py: paths in sidebar | Low | Shows config paths; no secrets. Good. |

**Verdict:** Pipeline and app are consistent with the plan; no critical issues.

---

## 13. Applied Fixes (post-review)

- **Preprocessor:** Drop duplicate timestamp column after merge when `ts_lab != timestamp_col_ims`; when `timestamp_index_labels=False`, use labels.index for merge if present to avoid position-based alignment. `train_ratio`: use `None` default and `if train_ratio is None else train_ratio` so `0.0` is valid.
- **IMSClient:** Validate token in `__init__` and raise `ValueError` if missing.
- **FarquharModel.compute_all:** Check for NaN in required inputs per row and append NaN to output; catch TypeError/ZeroDivisionError/ValueError and append NaN.
- **SensorDataLoader.load:** After `read_csv`, raise `ValueError` with missing column names if any requested column is absent.
- **predictor:** Removed unused `seaborn` import.
- **src/__init__.py:** Added `__dir__` returning `__all__` for better discoverability.

---

## 14. Growing-season filter (Oct–April excluded)

| Finding | Severity | Notes |
|--------|----------|--------|
| config.GROWING_SEASON_MONTHS | Low | (5,6,7,8,9) = May–September; single source of truth. |
| run_stage1: filter by month after daytime | Low | Only rows with month in GROWING_SEASON_MONTHS are used for A; labels and Stage 2 are thus growing-season only. No change needed in Stage 2 merge. |
| Hemisphere | Low | Assumes Northern Hemisphere (Israel). Document if reused elsewhere. |

**Verdict:** Correct; reduces label count to active season only and improves Stage 2 relevance.

---

## 15. Applied fixes (verified)

Verified in the codebase as of 2026-02. The following fixes from section 13 are present:

| Fix | Status | Location |
|-----|--------|----------|
| Preprocessor: drop duplicate timestamp after merge | Done | `preprocessor.py` 48–49, 59–61 |
| Preprocessor: merge on timestamp when `timestamp_index_labels=False` and labels have index | Done (partial) | When labels have a named index, merge is on timestamp; else position-based alignment still used (lines 62–64) |
| Preprocessor: `train_ratio` default `None`, use `if train_ratio is None else train_ratio` | Done | `preprocessor.py` 19, 21 |
| IMSClient: validate token in `__init__`, raise `ValueError` if missing | Done | `ims_client.py` 38–41 |
| FarquharModel.compute_all: NaN handling and exception β†’ NaN | Done | `farquhar_model.py` 184–185, 193 |
| SensorDataLoader.load: raise `ValueError` with missing column names | Done | `sensor_data_loader.py` 61–64 |
| predictor: remove unused `seaborn` | Done | Not present in `predictor.py` |
| `src/__init__.py`: add `__dir__` returning `__all__` | Done | `__init__.py` line 14 |

---

## 16. Fixes applied 2026-02-17

The following issues identified in the full code + design review (2026-02-17) have been resolved:

| Fix | Severity | Location | Change |
|-----|----------|----------|--------|
| **Preprocessor position-based merge fallback removed** | Critical | `preprocessor.py:52–64` | The `else` branch that silently aligned labels to IMS rows by position (`labels.values[:len(merged)]`) β€” which produces incorrect joins when row counts differ β€” has been replaced with an explicit `ValueError`. All callers use `timestamp_index_labels=True`; the dead code path is no longer reachable. |
| **`use_container_width=True` deprecated API removed** | Medium | `app.py` (~50 occurrences) | Streamlit 1.54 deprecates `use_container_width=True`. All occurrences replaced: `st.image` calls now use `width='stretch'` (equivalent behaviour, since the old default for images was `width='content'`); `st.plotly_chart` and `st.dataframe` calls have the argument removed (their default is already `width='stretch'`). |

---

## 17. Remaining work

**Medium (not yet applied)**

- **IMS timezone:** Doc states IMS returns Israel time; code uses `pd.to_datetime(..., utc=True)`. Either parse as Israel and convert to UTC, or document that input is assumed UTC. *(Note: `ims_client.py` was updated to correctly localize to `Asia/Jerusalem` then convert to UTC; verify this is actually called on the datetime string.)*
- **FarquharModel.compute_all:** Still uses `df.iterrows()`; vectorization or `apply` with NaN-safe wrapper would improve performance for large datasets (NaN handling is already correct).
- **Preprocessor.temporal_split:** When `n >= len(df)` the function returns full dataset as train and empty test; document this contract or return a clear sentinel.
- **No automated tests:** Add at minimum: `test_farquhar.py` (known inputs, NaN handling, ci floor), `test_preprocessor.py` (merge, split, empty edge cases, ValueError for position-based path), `test_solar_geometry.py` (tracker tilt scalar output, shadow mask shape).
- **app.py monolith:** 2568 lines, all 5 tabs inline. Extract tab content into a `tabs/` package for maintainability.

**Low (optional)**

- `compute_face_shading` unbound locals: initialize `east_sunlit = 0.0` and `west_sunlit = 0.0` before conditional branches (currently safe in practice but fragile).
- No-op rename in `ims_client.py:141`: `df.rename(columns={c: c ...})` is an identity operation; remove it.
- Unused `humidity_col` parameter in `FarquharModel.compute_all`: remove or document.
- Module-level singletons in `tracker_optimizer.py`: `_model` and `_shadow` are created on import; acceptable but note this if config changes at runtime.
- Path validation in `config/settings.py`; `filter_daytime` NaN in PAR; Ko/OI units documentation; division-by-zero guard in Farquhar; etc. See sections 2–9 for the full list.

---

## 18. src/chatbot/guardrails.py (added 2026-03-09)

Anti-hallucination guardrails for the vineyard advisor chatbot.

| Component | Role | Notes |
|-----------|------|-------|
| `QueryClassifier` | Regex-based query routing | Tags queries as data/knowledge/greeting/ambiguous. Data queries force tool calls. Generic "what is" without domain keyword β†’ ambiguous (avoids false positives). |
| `ResponseValidator` | Deterministic post-response rule checks | 5 rules: no_shade_before_10 (block), no_shade_in_may (block), temperature_transition (warn <28Β°C), no_leaves_no_shade_problem (warn), no_shading_must_explain (warn). Block overrides response text. |
| `estimate_confidence` | Data freshness β†’ confidence level | high (<30min), medium (30-120min), low (>120min or no tool), insufficient_data (tool failed). Computed results (FvCB, sim) β†’ high. |
| `tag_tool_result` | Source metadata injection | Adds `_source` (human-readable), `_data_age_minutes`, `_freshness_warning` (if >60min) to tool result dict before Gemini Pass 2. |

**Verdict:** Solid first layer. Heuristic text matching in `_text_recommends_shading` could be fooled by unusual phrasing; consider LLM-based classification for edge cases in a future iteration. Query classifier has a good balance of specificity (domain keywords) vs generality (generic question words treated as ambiguous).

---

## 19. src/chatbot/vineyard_chatbot.py (updated 2026-03-09)

| Change | Notes |
|--------|-------|
| `ChatResponse` extended | New fields: `confidence`, `sources`, `caveats`, `rule_violations`. Backward-compatible (all have defaults). |
| `chat()` flow upgraded | 7-step pipeline: classify β†’ Gemini Pass 1 β†’ force tool if data query β†’ dispatch + tag β†’ Gemini Pass 2 β†’ validate β†’ confidence. |
| System prompt strengthened | Added: "NEVER invent sensor readings", "MUST call a tool for data questions", "cite source and timestamp", "If data unavailable, say so β€” do NOT estimate". |
| `_get_validation_context()` | Gathers hour, month, stage_id, temp_c for rule validation. Uses `zoneinfo` for Israel time. |

**Verdict:** Well-integrated. The re-prompt mechanism (force tool call) adds one extra Gemini call for data queries where the LLM skips the tool β€” acceptable latency cost for reliability.

---

## 20. src/energy_budget.py (created 2026-03-10)

| Finding | Severity | Fix |
|---------|----------|-----|
| `compute_weekly_plan` month-end calc overflowed for month=12 (β†’ month 13) | Bug | Added December guard before the `month+1` path |
| `compute_daily_plan` block weights assumed `NO_SHADE_BEFORE_HOUR=10`; transition block `(10,11)` hardcoded | Medium | Made `transition_end = max(NO_SHADE_BEFORE_HOUR+1, 11)` dynamic |
| `_energy_analytical` loops 15k timestamps in Python | Low | Acceptable for one-shot season planning |
| `spend_slot` doesn't validate negative amounts | Low | Internal API; callers are trusted |

**Verdict:** Clean hierarchical design. Budget math is correct: 920 kWh potential β†’ 46 kWh (5%) β†’ monthly/weekly/daily/slot. Both bugs fixed.

---

## 21. src/command_arbiter.py (created 2026-03-10)

| Finding | Severity | Fix |
|---------|----------|-----|
| `AstronomicalTracker.get_angle` passed naive datetime to pvlib (warns on tz) | Medium | Added `tz_localize("UTC")` fallback for naive timestamps |
| `should_move` uses `pd.Timedelta` for `datetime` comparison | Low | Works; pd dependency already imported |
| `arbitrate` mutates `decision.source` on dispatch | Low | Intentional; overrides "stable"/"initial" with actual priority source |

**Verdict:** Priority stack is clear and correct. Hysteresis correctly prevents sub-slot jitter while allowing safety-critical overrides (weather/harvest) to bypass the filter. All fallbacks default to ΞΈ_astro (zero energy cost).

---

## 22. src/spectral_aggregator.py (created 2026-03-10)

| Finding | Severity | Fix |
|---------|----------|-----|
| `aggregate_spectral_df` had no `cwsi_col` parameter β€” couldn't pass explicit CWSI from TB | Medium | Added `cwsi_col: Optional[str] = None` parameter |
| CWSI cascade (explicit β†’ delta-T β†’ VPD β†’ missing) matches `data_schema.py` proxy logic | β€” | Consistent |
| Physical bounds hardcoded (not in config) | Low | Acceptable; these are physical constants, not tunable params |
| `_clip_or_flag` only checks `isinstance(float)` for NaN | Low | Sensor values are always float; safe |

**Verdict:** Good stateless design. CWSI cascade is well-ordered. Quality flags provide auditability.

---

## 23. scripts/import_layout.py (created 2026-03-10)

| Finding | Severity | Fix |
|---------|----------|-----|
| Row azimuth 315.0Β° duplicated from ShadowModel default | Low | Acceptable; layout is a snapshot, not a live reference |
| `--print` shadows Python builtin | Low | argparse convention; fine |
| Crop sensor positions (west/east, bottom/upper) parsed from label strings | Low | Works for current 7 Crop devices; fragile if naming changes |

**Verdict:** Useful spatial registration script. Generates a clean JSON for dashboard map view and future ShadowModel integration.

---

## 24. src/models/canopy_photosynthesis.py (modified 2026-03-10)

| Finding | Severity | Fix |
|---------|----------|-----|
| `from config.settings import FRUITING_ZONE_INDEX` was inside method body | Medium | Moved to module-level import |
| `compute_timeseries` doesn't include new fruiting/top fields | Low | Acceptable; it's a batch summary for historical analysis |

**Verdict:** Clean addition. New fields (`fruiting_zone_A`, `fruiting_zone_par`, `top_canopy_A`, `top_canopy_par`) integrate well with existing return dict.