api / context /CODE_REVIEW.md
safraeli's picture
Deploy: 2026 sensor migration + redesign + bucket B endpoints
13fc29d verified
|
Raw
History Blame Contribute Delete
21.4 kB
# 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.