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.
|