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.