safraeli commited on
Commit
13fc29d
·
verified ·
1 Parent(s): 15be6bb

Deploy: 2026 sensor migration + redesign + bucket B endpoints

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/settings.local.json +113 -0
  2. .devcontainer/devcontainer.json +33 -0
  3. .env.example +31 -0
  4. .gitattributes +2 -0
  5. .github/workflows/control-tick.yml +27 -0
  6. .github/workflows/daily-planner.yml +28 -0
  7. .gitignore +38 -0
  8. .streamlit/config.toml +10 -0
  9. CLAUDE.md +205 -0
  10. DEPLOY.md +79 -0
  11. Data/2026/manual_observations.csv +1 -0
  12. Data/2026/sensor_history.parquet +3 -0
  13. Data/energy_predictor_model.pkl +3 -0
  14. Data/energy_weather_merged.csv +0 -0
  15. Data/ims/ims_merged_15min.csv +672 -672
  16. Data/layout.json +536 -0
  17. Data/processed/stage1_labels.csv +0 -0
  18. README.md +117 -82
  19. app.py +201 -0
  20. assets/logo.png +0 -0
  21. assets/vineyard_closeup.png +3 -0
  22. assets/vineyard_panels.png +3 -0
  23. backend/api/routes/control.py +101 -0
  24. backend/api/routes/energy.py +80 -1
  25. backend/api/routes/events.py +85 -1
  26. backend/api/routes/photosynthesis.py +76 -0
  27. backend/workers/control_tick.py +11 -0
  28. config/settings.py +31 -0
  29. context/.gitkeep +0 -0
  30. context/1_purpose.md +20 -0
  31. context/2_plan.md +1534 -0
  32. context/3_todo.md +555 -0
  33. context/4_production.md +564 -0
  34. context/CODE_REVIEW.md +311 -0
  35. context/refactor_todo.md +83 -0
  36. docker-compose.yml +17 -0
  37. ims_api_documentation.md +287 -0
  38. scripts/__init__.py +1 -0
  39. scripts/collect_2026_training_data.py +411 -0
  40. scripts/create_pptx.py +774 -0
  41. scripts/create_sample_data.py +54 -0
  42. scripts/download_ims_data.py +123 -0
  43. scripts/eda.py +91 -0
  44. scripts/html_to_docx.py +602 -0
  45. scripts/import_layout.py +224 -0
  46. scripts/load_test.py +101 -0
  47. scripts/refresh_energy_data.py +242 -0
  48. scripts/run_chatbot_qa.py +48 -0
  49. scripts/run_chronos_long_training.py +186 -0
  50. scripts/run_control_simulation.py +236 -0
.claude/settings.local.json ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(.venv/bin/python:*)",
5
+ "Bash(python -m scripts.create_sample_data:*)",
6
+ "Bash(python:*)",
7
+ "Bash(git add:*)",
8
+ "Bash(gh repo view:*)",
9
+ "Bash(git config:*)",
10
+ "Bash(git commit:*)",
11
+ "Bash(git push:*)",
12
+ "Bash(git pull:*)",
13
+ "WebFetch(domain:www.solarwine.ai)",
14
+ "WebFetch(domain:cdn.prod.website-files.com)",
15
+ "Bash(curl:*)",
16
+ "Bash(export IMS_API_TOKEN=\"1a901e45-9028-44ff-bd2c-35e82407fb9b\")",
17
+ "Bash(streamlit run:*)",
18
+ "Bash(pip install:*)",
19
+ "Bash(git checkout:*)",
20
+ "Bash(lsof:*)",
21
+ "Bash(kill:*)",
22
+ "WebSearch",
23
+ "Bash(python3:*)",
24
+ "WebFetch(domain:doi.org)",
25
+ "WebFetch(domain:link.springer.com)",
26
+ "WebFetch(domain:localhost)",
27
+ "Bash(git status:*)",
28
+ "Bash(git merge:*)",
29
+ "Bash(git -C /Users/elisafra/Documents/GitHub/Baseline log --oneline --all)",
30
+ "Bash(wc:*)",
31
+ "Bash(pip show:*)",
32
+ "Bash(head:*)",
33
+ "Bash(tail:*)",
34
+ "Bash(conda run:*)",
35
+ "Bash(pkill:*)",
36
+ "Bash(git:*)",
37
+ "Bash(sed:*)",
38
+ "Bash(ls:*)",
39
+ "Bash(GOOGLE_API_KEY=\"AIzaSyBpuIjLdpcMpThsNSJoXnHFzn5qEGy3gEI\" conda run:*)",
40
+ "Bash(sleep 55 && GOOGLE_API_KEY=\"AIzaSyBpuIjLdpcMpThsNSJoXnHFzn5qEGy3gEI\" conda run -n solarwine python3 -m src.routing_agent 2>&1)",
41
+ "Bash(sleep 35 && GOOGLE_API_KEY=\"AIzaSyBpuIjLdpcMpThsNSJoXnHFzn5qEGy3gEI\" conda run -n solarwine python3 -c \"\nfrom google import genai\nclient = genai.Client\\(api_key='AIzaSyBpuIjLdpcMpThsNSJoXnHFzn5qEGy3gEI'\\)\n# Try gemini-2.0-flash-lite\nresponse = client.models.generate_content\\(\n model='gemini-2.0-flash-lite',\n contents='Reply only MODEL_A or MODEL_B. Temp=38C, GHI=950, CWSI=0.72',\n config={'system_instruction': 'Reply only MODEL_A or MODEL_B.'},\n\\)\nprint\\('gemini-2.0-flash-lite response:', response.text\\)\n\" 2>&1)",
42
+ "Bash(grep:*)",
43
+ "Bash(THINGSBOARD_HOST=\"https://web.seymouragri.com/\" THINGSBOARD_USERNAME=\"eli@solarwine.ai\" THINGSBOARD_PASSWORD=\"Xcs4007255%\" python:*)",
44
+ "WebFetch(domain:knowledge-center.solaredge.com)",
45
+ "WebFetch(domain:solar.ece.ksu.edu)",
46
+ "WebFetch(domain:github.com)",
47
+ "WebFetch(domain:solaredge-interface.readthedocs.io)",
48
+ "Bash(pip list:*)",
49
+ "Bash(conda:*)",
50
+ "Bash(cp:*)",
51
+ "Bash(npm install:*)",
52
+ "Bash(brew list:*)",
53
+ "Bash(rm:*)",
54
+ "Bash(find:*)",
55
+ "Read(//private/tmp/hf-api/**)",
56
+ "Bash(sed -i '' 's/slowapi>=0.2.0/slowapi>=0.1.9/' backend/requirements.txt)",
57
+ "Bash(npx vercel:*)",
58
+ "Bash(node:*)",
59
+ "Bash(echo $PATH)",
60
+ "Bash(/opt/homebrew/bin/brew list:*)",
61
+ "Bash(/opt/homebrew/bin/brew --prefix)",
62
+ "Read(//opt/homebrew/bin/**)",
63
+ "Bash(/opt/homebrew/bin/brew install:*)",
64
+ "Bash(/opt/homebrew/bin/node --version)",
65
+ "Bash(/opt/homebrew/bin/npm install:*)",
66
+ "Bash(/opt/homebrew/bin/npm --version)",
67
+ "Bash(/opt/homebrew/bin/wrangler --version)",
68
+ "Bash(wrangler whoami:*)",
69
+ "Bash(wrangler pages:*)",
70
+ "Bash(VITE_API_URL=https://solarwine-solarwine-api.hf.space /opt/homebrew/bin/npm run build)",
71
+ "Bash(VITE_API_URL=https://solarwine-api.hf.space /opt/homebrew/bin/npm run build)",
72
+ "Read(//Users/elisafra/Documents/GitHub/Research/**)",
73
+ "Read(//Users/elisafra/Documents/GitHub/**)",
74
+ "Bash(python -c \"import sys,json;d=json.load\\(sys.stdin\\);print\\(len\\(d\\), ''''rows''''\\)\" echo \"=== Temp/Humidity ===\")",
75
+ "Bash(python -c \"import sys,json;d=json.load\\(sys.stdin\\);print\\(len\\(d\\), ''''rows''''\\)\" echo \"=== NDVI ===\")",
76
+ "Bash(python -c \"import sys,json;d=json.load\\(sys.stdin\\);print\\(len\\(d\\), ''''rows''''\\)\" echo \"=== VPD ===\")",
77
+ "Bash(python -c \":*)",
78
+ "Bash(python -c \"import sys,json;d=json.load\\(sys.stdin\\);print\\(len\\(d\\),''''rows''''\\);print\\(d[0] if d else ''''empty''''\\)\")",
79
+ "Bash(VITE_API_URL=https://solarwine-api.hf.space VITE_GOOGLE_MAPS_KEY=AIzaSyCrGbjxN613vCo_g0ppRgZZjCE0d5xzojg /opt/homebrew/bin/npm run build)",
80
+ "Bash(pdftoppm -v)",
81
+ "Bash(/opt/homebrew/bin/pdftoppm -png -r 150 -f 1 -l 1 \"/Users/elisafra/Downloads/SolarWine Dashboard-2026-03-20_11_14_02.pdf\" /tmp/tb_dash_1)",
82
+ "Read(//private/tmp/**)",
83
+ "Bash(npx tsc:*)",
84
+ "Bash(PYTHONPATH=. python -c \"from backend.api.events import event_bus; print\\(''EventBus OK:'', event_bus.versions\\)\")",
85
+ "Bash(PYTHONPATH=. python -c \"from backend.api.routes.events import router; print\\(''SSE route OK:'', [r.path for r in router.routes]\\)\")",
86
+ "Bash(npm run:*)",
87
+ "Read(//Library/Frameworks/Python.framework/Versions/3.12/bin/**)",
88
+ "Bash(/Users/elisafra/Documents/GitHub/Baseline/.venv/bin/python --version)",
89
+ "Bash(.venv/bin/pip install:*)",
90
+ "Bash(.venv/bin/pip show:*)",
91
+ "Bash(.venv/bin/huggingface-cli whoami:*)",
92
+ "Bash(GIT_LFS_SKIP_SMUDGE=1 git fetch hf 2>&1)",
93
+ "Bash(rm -rf solarwine-hf)",
94
+ "Bash(GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 https://huggingface.co/spaces/SolarWine/api solarwine-hf)",
95
+ "Read(//tmp/**)",
96
+ "Bash(cd:*)",
97
+ "Bash(.venv/bin/streamlit run:*)",
98
+ "Bash(npx vite:*)",
99
+ "Bash(awk '{print $9, $5}')",
100
+ "Bash(open http://127.0.0.1:8501)",
101
+ "Bash(unzip -l \"sw research.zip\")",
102
+ "Bash(unzip -o \"sw research.zip\" -d design_handoff/)",
103
+ "Bash(npx --prefix /Users/elisafra/Documents/GitHub/Baseline/frontend tsc --noEmit -p /Users/elisafra/Documents/GitHub/Baseline/frontend)",
104
+ "Bash(echo \"---tsc exit=$?\")",
105
+ "Bash(echo \"---exit=$?\")",
106
+ "Bash(open http://localhost:8080/)",
107
+ "Bash(./node_modules/.bin/tsc --noEmit -p tsconfig.app.json)",
108
+ "Bash(open http://127.0.0.1:8501/)",
109
+ "Bash(xargs -r kill -9)",
110
+ "Bash(open http://127.0.0.1:8080/)"
111
+ ]
112
+ }
113
+ }
.devcontainer/devcontainer.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Python 3",
3
+ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
4
+ "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
5
+ "customizations": {
6
+ "codespaces": {
7
+ "openFiles": [
8
+ "README.md",
9
+ "app.py"
10
+ ]
11
+ },
12
+ "vscode": {
13
+ "settings": {},
14
+ "extensions": [
15
+ "ms-python.python",
16
+ "ms-python.vscode-pylance"
17
+ ]
18
+ }
19
+ },
20
+ "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y <packages.txt; [ -f requirements.txt ] && pip3 install --user -r requirements.txt; pip3 install --user streamlit; echo '✅ Packages installed and Requirements met'",
21
+ "postAttachCommand": {
22
+ "server": "streamlit run app.py --server.enableCORS false --server.enableXsrfProtection false"
23
+ },
24
+ "portsAttributes": {
25
+ "8501": {
26
+ "label": "Application",
27
+ "onAutoForward": "openPreview"
28
+ }
29
+ },
30
+ "forwardPorts": [
31
+ 8501
32
+ ]
33
+ }
.env.example ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolarWine Environment Variables
2
+ # Copy to .env and fill in values
3
+
4
+ # --- ThingsBoard ---
5
+ THINGSBOARD_HOST=https://your-thingsboard-instance.com/
6
+ THINGSBOARD_USERNAME=
7
+ THINGSBOARD_PASSWORD=
8
+
9
+ # --- IMS Weather API ---
10
+ IMS_API_TOKEN=
11
+
12
+ # --- Google Gemini ---
13
+ GOOGLE_API_KEY=
14
+
15
+ # --- Upstash Redis (optional, falls back to in-memory cache) ---
16
+ UPSTASH_REDIS_URL=
17
+ UPSTASH_REDIS_TOKEN=
18
+
19
+ # --- JWT Auth (required for production, guest mode when unset) ---
20
+ JWT_SECRET=
21
+ ADMIN_USERNAME=admin
22
+ ADMIN_PASSWORD=
23
+
24
+ # --- Sentry (optional) ---
25
+ SENTRY_DSN=
26
+
27
+ # --- CORS (comma-separated origins) ---
28
+ ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
29
+
30
+ # --- Budget Alerts (optional webhook URL) ---
31
+ BUDGET_ALERT_WEBHOOK=
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/vineyard_closeup.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/vineyard_panels.png filter=lfs diff=lfs merge=lfs -text
.github/workflows/control-tick.yml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Control Loop Tick
2
+ on:
3
+ schedule:
4
+ - cron: "*/15 * * * *"
5
+ workflow_dispatch:
6
+
7
+ jobs:
8
+ tick:
9
+ runs-on: ubuntu-latest
10
+ timeout-minutes: 10
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ cache: pip
17
+ - name: Install dependencies
18
+ run: pip install -r requirements.txt -r backend/requirements.txt
19
+ - name: Run control tick
20
+ run: python -m backend.workers.control_tick
21
+ env:
22
+ THINGSBOARD_HOST: ${{ secrets.THINGSBOARD_HOST }}
23
+ THINGSBOARD_USERNAME: ${{ secrets.THINGSBOARD_USERNAME }}
24
+ THINGSBOARD_PASSWORD: ${{ secrets.THINGSBOARD_PASSWORD }}
25
+ IMS_API_TOKEN: ${{ secrets.IMS_API_TOKEN }}
26
+ UPSTASH_REDIS_URL: ${{ secrets.UPSTASH_REDIS_URL }}
27
+ UPSTASH_REDIS_TOKEN: ${{ secrets.UPSTASH_REDIS_TOKEN }}
.github/workflows/daily-planner.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Day-Ahead Planner
2
+ on:
3
+ schedule:
4
+ - cron: "0 2 * * *" # 02:00 UTC = 05:00 IST
5
+ workflow_dispatch:
6
+
7
+ jobs:
8
+ plan:
9
+ runs-on: ubuntu-latest
10
+ timeout-minutes: 15
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ cache: pip
17
+ - name: Install dependencies
18
+ run: pip install -r requirements.txt -r backend/requirements.txt
19
+ - name: Run day-ahead planner
20
+ run: python -m backend.workers.daily_planner
21
+ env:
22
+ THINGSBOARD_HOST: ${{ secrets.THINGSBOARD_HOST }}
23
+ THINGSBOARD_USERNAME: ${{ secrets.THINGSBOARD_USERNAME }}
24
+ THINGSBOARD_PASSWORD: ${{ secrets.THINGSBOARD_PASSWORD }}
25
+ IMS_API_TOKEN: ${{ secrets.IMS_API_TOKEN }}
26
+ GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
27
+ UPSTASH_REDIS_URL: ${{ secrets.UPSTASH_REDIS_URL }}
28
+ UPSTASH_REDIS_TOKEN: ${{ secrets.UPSTASH_REDIS_TOKEN }}
.gitignore CHANGED
@@ -1 +1,39 @@
 
 
 
1
  __pycache__/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+
3
+ # Python
4
  __pycache__/
5
+ *.py[cod]
6
+ *.pyo
7
+ *.pyd
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Virtual environments
15
+ .venv/
16
+ venv/
17
+ ENV/
18
+
19
+ # Env / secrets
20
+ .env
21
+ .streamlit/secrets.toml
22
+
23
+ # Frontend
24
+ frontend/node_modules/
25
+ frontend/dist/
26
+ frontend/.env.local
27
+
28
+ # Notebooks
29
+ .ipynb_checkpoints/
30
+
31
+ # Project outputs / caches — allow pre-trained results for deployment
32
+ # outputs/
33
+ # Data/ims/
34
+ # Data/processed/
35
+
36
+ # Large sensor data (keep metadata; data can be restored elsewhere)
37
+ Data/Seymour/sensors_wide.csv
38
+ # Allow the trimmed sample used for deployment
39
+ !Data/Seymour/sensors_wide_sample.csv
.streamlit/config.toml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [server]
2
+ headless = true
3
+
4
+ [theme]
5
+ base = "light"
6
+ primaryColor = "#00BD3E"
7
+ backgroundColor = "#FFFFFF"
8
+ secondaryBackgroundColor = "#F0F7F1"
9
+ textColor = "#1A1A1A"
10
+ font = "sans serif"
CLAUDE.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ ## Project Overview
4
+
5
+ **SolarWine** — Photosynthesis prediction and tracker control system for an agrivoltaic vineyard (*Vitis vinifera*) in Sde Boker, Israel.
6
+
7
+ Two-stage pipeline:
8
+ 1. **Stage 1 (Mechanistic)**: Farquhar et al. (1980) model driven by on-site sensors (PAR, leaf temp, CO₂, VPD) → net photosynthesis rate *A* (µmol CO₂/m²/s)
9
+ 2. **Stage 2 (ML)**: IMS weather station data → predict *A* without on-site sensors, enabling day-ahead forecasting
10
+
11
+ ## Repository Layout
12
+
13
+ ```
14
+ backend/ FastAPI REST API (routes, auth, workers)
15
+ config/ settings.py (paths, IMS channels, panel geometry)
16
+ context/ Project docs, todo, code review notes
17
+ frontend/ React + TypeScript + Vite SPA
18
+ scripts/ CLI tools (data download, pipeline, training)
19
+ src/ Core Python modules
20
+ advisor/ Day-ahead advisory engine
21
+ chatbot/ LLM integration (Google Gemini)
22
+ data/ IMS client, ThingsBoard client, DataHub
23
+ forecasting/ ML predictors, time-series, Chronos wrapper
24
+ models/ Farquhar model, phenology
25
+ shading/ Solar geometry, tracker optimizer
26
+ tracker/ Tracker optimization module
27
+ ui/ Streamlit UI utilities
28
+ app.py Legacy Streamlit dashboard
29
+ ```
30
+
31
+ ## Tech Stack
32
+
33
+ **Backend**: Python 3.12, FastAPI, uvicorn, pydantic, PyJWT, slowapi, sentry-sdk
34
+ **ML/Science**: pandas, numpy, scikit-learn, xgboost, pvlib, scipy, torch, chronos-forecasting (HuggingFace)
35
+ **LLM**: google-genai (Gemini)
36
+ **Frontend**: React 18, TypeScript 5, Vite 5, Tailwind CSS, shadcn/ui, Recharts, TanStack React Query, React Router 6
37
+ **Data sources**: IMS API (station 43, Sde Boker), ThingsBoard (MQTT/REST), on-site sensors
38
+ **Deploy**: HuggingFace Spaces (backend, port 7860), Cloudflare Pages (frontend), Streamlit Community Cloud (legacy)
39
+
40
+ ## Environment Setup
41
+
42
+ ```bash
43
+ # Python (from repo root)
44
+ python -m venv .venv
45
+ source .venv/bin/activate
46
+ pip install -r requirements.txt
47
+ pip install -r backend/requirements.txt
48
+
49
+ # Frontend
50
+ cd frontend && npm install
51
+ ```
52
+
53
+ Required `.env` variables:
54
+ ```
55
+ IMS_API_TOKEN=<token>
56
+ JWT_SECRET=<secret>
57
+ ADMIN_PASSWORD=<password>
58
+ # Optional
59
+ SENTRY_DSN=<dsn>
60
+ ALLOWED_ORIGINS=http://localhost:5173,https://solarwine.pages.dev
61
+ ```
62
+
63
+ ## Common Commands
64
+
65
+ ### Backend
66
+
67
+ ```bash
68
+ # Dev server (auto-reload)
69
+ uvicorn backend.api.main:app --reload --port 7860
70
+
71
+ # Production
72
+ uvicorn backend.api.main:app --host 0.0.0.0 --port 7860
73
+ ```
74
+
75
+ ### Frontend
76
+
77
+ ```bash
78
+ cd frontend
79
+ npm run dev # Vite dev server → http://localhost:5173
80
+ npm run build # Production build → dist/
81
+ npm run lint # ESLint
82
+ npm run test # Vitest
83
+ npm run test:watch # Watch mode
84
+ ```
85
+
86
+ ### Legacy Streamlit
87
+
88
+ ```bash
89
+ streamlit run app.py
90
+ ```
91
+
92
+ ### Data & Pipelines
93
+
94
+ ```bash
95
+ # Download IMS weather data
96
+ python -m scripts.download_ims_data --from 2024-01-01 --to 2025-12-31
97
+
98
+ # Full Stage 1 → Stage 2 pipeline
99
+ python -m scripts.run_pipeline
100
+
101
+ # Fine-tune Chronos (long-context forecasting)
102
+ python scripts/run_chronos_long_training.py --device cpu --context-days 28 --num-steps 4000
103
+
104
+ # Generate trimmed sample CSV for cloud deploy
105
+ python -m scripts.create_sample_data
106
+
107
+ # Control system simulation / validation
108
+ python -m scripts.run_control_simulation
109
+ python -m scripts.verify_control_system
110
+
111
+ # Budget audit (compliance report)
112
+ python -m src.budget_audit --report # weekly rollup
113
+ python -m src.budget_audit --daily 2026-03-29 # single day
114
+
115
+ # Load test (requires: pip install locust)
116
+ locust -f scripts/load_test.py --host https://solarwine-api.hf.space \
117
+ --users 10 --spawn-rate 2 --run-time 5m --headless
118
+ ```
119
+
120
+ ## Architecture Notes
121
+
122
+ ### Backend (FastAPI)
123
+
124
+ - **`DataHub`** (`src/data/data_providers.py`) — singleton facade over all providers; injected via `backend/api/deps.py`
125
+ - **Routes** (`backend/api/routes/`): `health`, `weather`, `sensors`, `energy`, `photosynthesis`, `control`, `chatbot`, `biology`, `login`, `events` (~27 endpoints)
126
+ - **Auth**: JWT issued at `/api/auth/login`; guest read-only mode when `JWT_SECRET` unset
127
+ - **Middleware**: CORS, rate-limiting (60 req/min via slowapi), request logging, global exception handler → JSON errors
128
+ - **Workers** (`backend/workers/`): async background tasks for daily planner and control ticks
129
+ - **Services** (`backend/services/`): `DataFlowMonitor` (per-source health), `EmailAlerter` (SMTP alerts on red status)
130
+ - **Background loops**: IMS refresh (2h), sensor poll (2min), data flow alert check (5min) — all push SSE events
131
+
132
+ ### Core Python Modules
133
+
134
+ | Module | Purpose |
135
+ |---|---|
136
+ | `src/models/farquhar_model.py` | Leaf photosynthesis (FvCB mechanistic, Semillon sigmoid 28–32°C transition, vectorized `compute_all()`) |
137
+ | `src/data/ims_client.py` | IMS REST API client |
138
+ | `src/data/data_providers.py` | DataHub facade (6 services: Weather, VineSensor, PS, Energy, Advisory, Biology) |
139
+ | `src/data/thingsboard_client.py` | ThingsBoard REST client (22 devices, 4 trackers, plant asset) |
140
+ | `src/forecasting/predictor.py` | Multi-model ML regressor (LR/DT/RF/GBR/XGB) |
141
+ | `src/forecasting/ts_predictor.py` | Multi-horizon lag-based time-series |
142
+ | `src/forecasting/chronos_forecaster.py` | HuggingFace Chronos wrapper |
143
+ | `src/shading/tracker_optimizer.py` | Tilt-angle simulation & stress scheduling |
144
+ | `src/shading/solar_geometry.py` | Sun position, shadow projection, canopy PAR |
145
+ | `src/shading/tradeoff_engine.py` | InterventionGate (pipeline pattern) + minimum-dose offset search |
146
+ | `src/control_loop.py` | Real-time 15-min control orchestration (DI via `__init__()`) |
147
+ | `src/budget_audit.py` | Slot-level budget audit log (parquet) + weekly compliance report |
148
+ | `src/energy_budget.py` | Hierarchical Year→Month→Week→Day→Slot budget planner |
149
+ | `src/command_arbiter.py` | 5-level priority stack + hysteresis filter |
150
+ | `src/tracker_dispatcher.py` | Send tilt commands to TB trackers, verify execution |
151
+ | `src/advisor/day_ahead_advisor.py` | Gemini-powered daily planning engine |
152
+ | `src/day_ahead_planner.py` | DP trajectory optimizer for daily slot plans |
153
+ | `src/chatbot/vineyard_chatbot.py` | Gemini chatbot with tool dispatch + guardrails |
154
+ | `src/chatbot/guardrails.py` | Query classifier (EN+HE), response validator, confidence estimation, range validation, cross-source consistency |
155
+ | `backend/services/data_flow_monitor.py` | Per-source data freshness monitor (green/yellow/red) |
156
+ | `backend/services/email_alerter.py` | SMTP email alerts with per-source cooldown |
157
+
158
+ ### Frontend
159
+
160
+ - **Pages**: Home, Agro, Trackers, Power, Monitoring (alarms + system health), Advisor, Control, Photosynthesis, Shading, Docs, Research
161
+ - **Custom hooks** (`src/hooks/`): `useWeather`, `useEnergy`, `useSensors`, `useChatbot`, `useControl`, `useAuth`, `useDataFlowStatus`, `useEventStream`
162
+ - **API client**: `src/lib/` — typed wrappers around backend endpoints
163
+ - **Auth**: JWT stored in `localStorage`; TanStack React Query for server state
164
+
165
+ ### Data Flow
166
+
167
+ ```
168
+ IMS API ──→ [Redis/CSV cache] ──→ Backend ──→ API Routes ──→ React Frontend
169
+ ThingsBoard sensors ────────────→ DataHub ──→ API Routes
170
+ Farquhar + ML models ───────────→ PhotosynthesisService ──→ /photosynthesis/*
171
+ TrackerOptimizer + EnergyBudget → ControlSystem ──→ /control/*
172
+ DataFlowMonitor ────────────────→ /health/data-sources ──→ DataFlowStatus component
173
+ → EmailAlerter ──→ SMTP alerts
174
+ Backend events ─────────────────→ SSE /events/stream ──→ auto-invalidate React Query
175
+ ```
176
+
177
+ ## Key Files to Know
178
+
179
+ - `config/settings.py` — central config (site coordinates, IMS channel IDs, panel geometry, budget params)
180
+ - `backend/api/main.py` — FastAPI app entry point (middleware, router registration)
181
+ - `backend/api/routes/` — all API endpoint implementations (~27 endpoints)
182
+ - `backend/workers/control_tick.py` — 15-min control loop cron entry point
183
+ - `backend/workers/daily_planner.py` — daily plan generation cron entry point
184
+ - `src/models/farquhar_model.py` — core mechanistic model
185
+ - `src/control_loop.py` — main control loop (sensors → plan → gate → arbiter → dispatch)
186
+ - `src/energy_budget.py` — hierarchical energy budget planner
187
+ - `src/chatbot/guardrails.py` — anti-hallucination guardrails (query classifier, range validation, cross-source checks)
188
+ - `backend/services/data_flow_monitor.py` — data source health monitoring
189
+ - `backend/services/email_alerter.py` — SMTP email alerts for data outages
190
+ - `backend/api/routes/events.py` — SSE event stream for live frontend updates
191
+ - `frontend/src/lib/api.ts` — API client with JWT auth
192
+ - `frontend/src/hooks/` — TanStack Query hooks for all API endpoints
193
+ - `scripts/load_test.py` — Locust load test (16 weighted endpoints)
194
+ - `context/3_todo.md` — current work items and backlog
195
+ - `ims_api_documentation.md` — IMS API reference (Hebrew + English)
196
+
197
+ ## Deployment
198
+
199
+ | Platform | Target | Notes |
200
+ |---|---|---|
201
+ | HuggingFace Spaces | Backend | Docker, port 7860, see `backend/HF_README.md` |
202
+ | Cloudflare Pages | Frontend | `VITE_API_URL` env var points to HF backend |
203
+ | Streamlit Cloud | Legacy app | `IMS_API_TOKEN` secret, falls back to `sensors_wide_sample.csv` |
204
+
205
+ Large files (`Data/Seymour/sensors_wide.csv` ~982 MB, `outputs/`) are gitignored. Cloud deployments use `Data/Seymour/sensors_wide_sample.csv`.
DEPLOY.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ש# Deploy Streamlit app (free – Streamlit Community Cloud)
2
+
3
+ ## 1. Prerequisites
4
+
5
+ - App code in a **public** GitHub repo (e.g. `solarwine-ai/Baseline`).
6
+ - A [Streamlit Community Cloud](https://share.streamlit.io) account (sign in with GitHub).
7
+ - `sensors_wide_sample.csv` committed under `Data/Seymour/` (already in the repo).
8
+
9
+ ## 2. Deploy steps
10
+
11
+ 1. Go to **https://share.streamlit.io** and sign in with GitHub.
12
+ 2. Click **"New app"**.
13
+ 3. Set:
14
+ - **Repository:** `solarwine-ai/Baseline` (or your fork).
15
+ - **Branch:** `main`.
16
+ - **Main file path:** `app.py`.
17
+ 4. Click **"Advanced settings"** and set:
18
+ - **Python version:** 3.11 (or match your local).
19
+ - Leave **Requirements file** as `requirements.txt` (repo root).
20
+ 5. Under **Secrets**, add your IMS API token so the app can fetch IMS data:
21
+
22
+ ```toml
23
+ IMS_API_TOKEN = "your-ims-api-token-here"
24
+ ```
25
+
26
+ Streamlit Cloud injects secrets as environment variables; the app reads `IMS_API_TOKEN` from the environment via `os.environ`.
27
+
28
+ 6. Click **"Deploy"**. The first build may take a few minutes.
29
+
30
+ ## 3. How data works on Community Cloud
31
+
32
+ - **Sensor data (Stage 1):**
33
+ The full `sensors_wide.csv` (982 MB) is gitignored. Instead, a trimmed
34
+ `sensors_wide_sample.csv` (~2.7 MB, Stage 1 columns only, growing season May-Sep)
35
+ is committed. The app automatically falls back to the sample when the full file
36
+ is absent — no code changes needed.
37
+
38
+ - **IMS data (Stage 2):**
39
+ Click **Download IMS 2024–2025** in the sidebar (requires `IMS_API_TOKEN` in
40
+ Secrets), then **Run Stage 2**. IMS data is fetched at runtime; free-tier memory
41
+ and CPU limits apply to very large or long-running fetches.
42
+
43
+ - **Secrets:**
44
+ Never commit `.env` or real tokens. Use only the **Secrets** field in the
45
+ Streamlit Cloud app settings.
46
+
47
+ ## 4. Regenerating the sample CSV locally
48
+
49
+ If you update the full sensor data and need to refresh the sample:
50
+
51
+ ```bash
52
+ python -m scripts.create_sample_data
53
+ ```
54
+
55
+ This reads `sensors_wide.csv`, extracts Stage 1 columns for May-Sep, and writes
56
+ `sensors_wide_sample.csv`. Commit the updated sample.
57
+
58
+ ## 5. Configuration files
59
+
60
+ - `.streamlit/config.toml` — sets `headless = true` and light theme for Cloud.
61
+ - `requirements.txt` — Python dependencies (already in repo root).
62
+ - No `packages.txt` needed (no OS-level dependencies).
63
+
64
+ ## 6. Chronos-2 long training (local)
65
+
66
+ To run LoRA fine-tuning with a large context window until results converge (single run, tuned for 32 GB RAM / 10 CPU cores):
67
+
68
+ ```bash
69
+ cd /path/to/Baseline
70
+ PYTHONPATH=. conda run -n solarwine python scripts/run_chronos_long_training.py \
71
+ --device cpu \
72
+ --context-days 28 \
73
+ --num-steps 4000 \
74
+ --batch-size 16
75
+ ```
76
+
77
+ - **Output:** Checkpoints in `outputs/chronos_finetuned_long/`; benchmark row `lora / all` appended to `outputs/chronos_benchmark.csv`; sample plot `outputs/chronos_forecast_sample.png`.
78
+ - **Convergence:** Chronos does not support resuming LoRA across multiple `fit()` calls; use one large `--num-steps` (e.g. 4000–6000). Training uses built-in validation; adjust `--learning-rate` (default `1e-5`) if needed.
79
+ - **Resources:** Script sets `OMP_NUM_THREADS` from CPU count; use `--batch-size 8` on lower memory.
Data/2026/manual_observations.csv ADDED
@@ -0,0 +1 @@
 
 
1
+ date,row,position,phenology,observer,spad,brix,ta_g_l,ph,yield_kg,berry_count,berry_weight_g,cluster_count,anthocyanin_mg_g,phenolics_mg_g,sunburn_pct,pruning_weight_kg,lai,notes,photo_url,recorded_at
Data/2026/sensor_history.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5797b2bf7aaab71ac9de1beb9ba6b93f7e3520f9a897768bc62ea67c93bbed7b
3
+ size 151423
Data/energy_predictor_model.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f6631097b6219c92f9adf18dca080f2e386cb969c2002aa52d881a2c3b76b312
3
+ size 2505097
Data/energy_weather_merged.csv ADDED
The diff for this file is too large to render. See raw diff
 
Data/ims/ims_merged_15min.csv CHANGED
@@ -1,673 +1,673 @@
1
  timestamp_utc,air_temperature_c,tdmax_c,tdmin_c,ghi_w_m2,rh_percent,rain_mm,wind_speed_ms
2
- 2026-03-14 22:00:00+00:00,16.15,16.25,16.05,0.0,79.5,0.0,2.95
3
- 2026-03-14 22:15:00+00:00,16.0,16.1,15.9,0.0,80.0,0.0,2.4
4
- 2026-03-14 22:30:00+00:00,15.8,15.850000000000001,15.7,0.0,81.0,0.0,1.8
5
- 2026-03-14 22:45:00+00:00,15.7,15.8,15.7,0.0,81.0,0.0,2.3
6
- 2026-03-14 23:00:00+00:00,15.7,15.75,15.6,0.0,80.5,0.0,1.7999999999999998
7
- 2026-03-14 23:15:00+00:00,15.7,15.8,15.7,0.0,80.0,0.0,1.8
8
- 2026-03-14 23:30:00+00:00,15.6,15.649999999999999,15.5,0.0,79.5,0.0,2.0
9
- 2026-03-14 23:45:00+00:00,15.6,15.6,15.5,0.0,80.0,0.0,1.9
10
- 2026-03-15 00:00:00+00:00,15.45,15.55,15.45,0.0,79.5,0.0,1.65
11
- 2026-03-15 00:15:00+00:00,15.4,15.4,15.3,0.0,80.0,0.0,1.1
12
- 2026-03-15 00:30:00+00:00,15.25,15.3,15.149999999999999,0.0,81.0,0.0,2.05
13
- 2026-03-15 00:45:00+00:00,15.1,15.3,15.0,0.0,80.0,0.0,1.3
14
- 2026-03-15 01:00:00+00:00,15.0,15.1,15.0,0.0,80.5,0.0,2.25
15
- 2026-03-15 01:15:00+00:00,15.1,15.1,15.1,0.0,80.0,0.0,2.5
16
- 2026-03-15 01:30:00+00:00,14.95,15.05,14.850000000000001,0.0,80.0,0.0,1.65
17
- 2026-03-15 01:45:00+00:00,14.8,14.9,14.8,0.0,80.0,0.0,2.0
18
- 2026-03-15 02:00:00+00:00,14.9,14.9,14.8,0.0,81.0,0.0,1.6
19
- 2026-03-15 02:15:00+00:00,14.9,14.9,14.8,0.0,82.0,0.5,1.5
20
- 2026-03-15 02:30:00+00:00,14.4,14.65,14.25,0.0,88.5,0.1,1.7999999999999998
21
- 2026-03-15 02:45:00+00:00,14.1,14.2,13.9,0.0,90.0,0.0,2.0
22
- 2026-03-15 03:00:00+00:00,13.9,13.95,13.8,0.0,90.5,0.0,1.4
23
- 2026-03-15 03:15:00+00:00,13.9,14.0,13.8,0.0,91.0,0.1,2.0
24
- 2026-03-15 03:30:00+00:00,14.0,14.05,13.9,0.0,91.5,0.1,2.8499999999999996
25
- 2026-03-15 03:45:00+00:00,14.0,14.1,13.9,1.0,91.0,0.0,2.4
26
- 2026-03-15 04:00:00+00:00,13.850000000000001,13.9,13.850000000000001,5.5,84.5,0.0,2.95
27
- 2026-03-15 04:15:00+00:00,13.9,14.0,13.9,15.0,82.0,0.0,4.4
28
- 2026-03-15 04:30:00+00:00,14.05,14.1,13.9,27.5,81.5,0.0,2.6
29
- 2026-03-15 04:45:00+00:00,14.0,14.1,13.9,37.0,81.0,0.0,2.3
30
- 2026-03-15 05:00:00+00:00,14.35,14.55,14.15,196.0,79.0,0.0,1.9
31
- 2026-03-15 05:15:00+00:00,15.1,15.2,14.8,242.0,76.0,0.0,2.0
32
- 2026-03-15 05:30:00+00:00,15.05,15.1,14.850000000000001,125.0,77.0,0.0,2.7
33
- 2026-03-15 05:45:00+00:00,14.7,14.9,14.6,79.0,77.0,0.0,3.3
34
- 2026-03-15 06:00:00+00:00,14.6,14.7,14.55,210.0,73.0,0.0,5.25
35
- 2026-03-15 06:15:00+00:00,15.0,15.2,14.7,483.0,73.0,0.0,5.9
36
- 2026-03-15 06:30:00+00:00,15.7,15.850000000000001,15.399999999999999,486.0,70.0,0.0,4.95
37
- 2026-03-15 06:45:00+00:00,15.7,16.0,15.1,277.0,70.0,0.0,5.6
38
- 2026-03-15 07:00:00+00:00,14.0,14.45,13.6,173.5,84.0,0.15,3.6
39
- 2026-03-15 07:15:00+00:00,13.8,13.9,13.6,372.0,88.0,0.0,3.4
40
- 2026-03-15 07:30:00+00:00,13.85,14.0,13.649999999999999,261.0,84.0,0.0,3.1500000000000004
41
- 2026-03-15 07:45:00+00:00,14.1,14.2,14.0,160.0,83.0,0.0,2.2
42
- 2026-03-15 08:00:00+00:00,14.399999999999999,14.55,14.15,342.5,84.0,0.0,2.15
43
- 2026-03-15 08:15:00+00:00,15.0,15.0,14.8,327.0,82.0,0.0,2.6
44
- 2026-03-15 08:30:00+00:00,14.7,14.85,14.6,216.5,85.0,0.15,4.25
45
- 2026-03-15 08:45:00+00:00,14.9,15.1,14.7,469.0,83.0,0.0,6.4
46
- 2026-03-15 09:00:00+00:00,15.350000000000001,15.5,15.1,279.0,80.5,0.0,5.2
47
- 2026-03-15 09:15:00+00:00,15.5,15.5,15.4,170.0,78.0,0.0,4.6
48
- 2026-03-15 09:30:00+00:00,15.6,15.75,15.4,353.0,79.5,0.05,5.9
49
- 2026-03-15 09:45:00+00:00,15.6,15.7,15.4,320.0,79.0,0.0,6.4
50
- 2026-03-15 10:00:00+00:00,15.8,15.9,15.6,470.0,78.0,0.0,5.75
51
- 2026-03-15 10:15:00+00:00,15.3,15.9,14.1,183.0,82.0,3.3,6.2
52
- 2026-03-15 10:30:00+00:00,14.35,14.55,14.15,490.5,86.0,0.0,5.25
53
- 2026-03-15 10:45:00+00:00,15.2,15.5,14.8,627.0,81.0,0.0,6.4
54
- 2026-03-15 11:00:00+00:00,15.2,15.5,14.7,302.0,78.0,0.15,6.300000000000001
55
- 2026-03-15 11:15:00+00:00,14.0,14.2,13.8,259.0,80.0,0.0,3.4
56
- 2026-03-15 11:30:00+00:00,14.95,15.1,14.6,334.5,79.0,0.05,5.65
57
- 2026-03-15 11:45:00+00:00,15.3,15.4,15.1,332.0,76.0,0.0,5.5
58
- 2026-03-15 12:00:00+00:00,15.45,15.6,15.350000000000001,236.5,74.0,0.0,5.1
59
- 2026-03-15 12:15:00+00:00,15.3,15.5,15.2,154.0,75.0,0.0,4.9
60
- 2026-03-15 12:30:00+00:00,15.149999999999999,15.2,15.0,143.0,75.5,0.0,6.0
61
- 2026-03-15 12:45:00+00:00,14.8,14.9,14.7,247.0,70.0,0.0,5.0
62
- 2026-03-15 13:00:00+00:00,14.7,14.95,14.5,408.0,64.5,0.0,4.5
63
- 2026-03-15 13:15:00+00:00,14.8,15.0,14.6,164.0,66.0,0.0,4.4
64
- 2026-03-15 13:30:00+00:00,14.55,14.649999999999999,14.45,129.0,73.0,0.0,4.35
65
- 2026-03-15 13:45:00+00:00,14.7,14.8,14.5,88.0,76.0,0.0,2.4
66
- 2026-03-15 14:00:00+00:00,15.100000000000001,15.2,14.85,137.0,76.5,0.0,4.0
67
- 2026-03-15 14:15:00+00:00,15.1,15.3,15.0,227.0,66.0,0.0,4.3
68
- 2026-03-15 14:30:00+00:00,15.3,15.45,15.2,132.0,67.0,0.0,4.45
69
- 2026-03-15 14:45:00+00:00,15.0,15.1,15.0,42.0,72.0,0.0,2.2
70
- 2026-03-15 15:00:00+00:00,15.149999999999999,15.25,15.05,48.0,74.0,0.0,3.5
71
- 2026-03-15 15:15:00+00:00,15.3,15.3,15.3,27.0,76.0,0.0,3.6
72
- 2026-03-15 15:30:00+00:00,15.25,15.3,15.149999999999999,9.0,75.5,0.0,4.15
73
- 2026-03-15 15:45:00+00:00,15.1,15.2,15.1,3.0,77.0,0.0,4.4
74
- 2026-03-15 16:00:00+00:00,15.1,15.2,15.0,0.0,75.5,0.0,3.6
75
- 2026-03-15 16:15:00+00:00,15.1,15.2,15.0,0.0,75.0,0.0,3.4
76
- 2026-03-15 16:30:00+00:00,15.1,15.1,15.0,0.0,74.5,0.0,3.3499999999999996
77
- 2026-03-15 16:45:00+00:00,15.0,15.1,15.0,0.0,76.0,0.0,4.1
78
- 2026-03-15 17:00:00+00:00,14.95,15.05,14.9,0.0,75.0,0.0,3.45
79
- 2026-03-15 17:15:00+00:00,15.0,15.1,14.9,0.0,75.0,0.0,3.4
80
- 2026-03-15 17:30:00+00:00,15.0,15.1,15.0,0.0,76.0,0.0,3.4000000000000004
81
- 2026-03-15 17:45:00+00:00,14.9,15.0,14.9,0.0,76.0,0.0,3.6
82
- 2026-03-15 18:00:00+00:00,14.850000000000001,14.9,14.8,0.0,75.5,0.0,2.95
83
- 2026-03-15 18:15:00+00:00,14.8,14.9,14.8,0.0,74.0,0.0,2.1
84
- 2026-03-15 18:30:00+00:00,14.75,14.8,14.7,0.0,75.5,0.0,2.7
85
- 2026-03-15 18:45:00+00:00,14.7,14.8,14.6,0.0,75.0,0.0,2.1
86
- 2026-03-15 19:00:00+00:00,14.6,14.7,14.55,0.0,74.0,0.0,2.4000000000000004
87
- 2026-03-15 19:15:00+00:00,14.6,14.6,14.5,0.0,74.0,0.0,3.0
88
- 2026-03-15 19:30:00+00:00,14.6,14.649999999999999,14.55,0.0,74.0,0.0,2.45
89
- 2026-03-15 19:45:00+00:00,14.6,14.7,14.5,0.0,76.0,0.0,2.0
90
- 2026-03-15 20:00:00+00:00,14.649999999999999,14.7,14.55,0.0,76.0,0.0,2.8
91
- 2026-03-15 20:15:00+00:00,14.7,14.8,14.6,0.0,73.0,0.0,4.7
92
- 2026-03-15 20:30:00+00:00,14.8,14.9,14.8,0.0,74.5,0.0,4.6
93
- 2026-03-15 20:45:00+00:00,14.8,14.9,14.7,0.0,75.0,0.0,3.7
94
- 2026-03-15 21:00:00+00:00,14.7,14.75,14.6,0.0,73.5,0.0,2.85
95
- 2026-03-15 21:15:00+00:00,14.6,14.7,14.6,0.0,73.0,0.0,3.2
96
- 2026-03-15 21:30:00+00:00,14.55,14.6,14.4,0.0,73.0,0.0,3.3
97
- 2026-03-15 21:45:00+00:00,14.6,14.8,14.6,0.0,74.0,0.0,4.0
98
- 2026-03-15 22:00:00+00:00,14.55,14.649999999999999,14.5,0.0,73.0,0.0,4.85
99
- 2026-03-15 22:15:00+00:00,14.4,14.5,14.3,0.0,70.0,0.0,4.3
100
- 2026-03-15 22:30:00+00:00,14.45,14.5,14.4,0.0,72.5,0.0,3.75
101
- 2026-03-15 22:45:00+00:00,14.6,14.7,14.5,0.0,76.0,0.0,2.7
102
- 2026-03-15 23:00:00+00:00,14.8,14.850000000000001,14.7,0.0,76.5,0.0,5.0
103
- 2026-03-15 23:15:00+00:00,14.8,14.9,14.8,0.0,75.0,0.0,4.9
104
- 2026-03-15 23:30:00+00:00,14.8,14.850000000000001,14.7,0.0,74.0,0.0,5.15
105
- 2026-03-15 23:45:00+00:00,14.7,14.7,14.6,0.0,72.0,0.0,4.7
106
- 2026-03-16 00:00:00+00:00,14.649999999999999,14.7,14.6,0.0,72.5,0.0,5.75
107
- 2026-03-16 00:15:00+00:00,14.6,14.6,14.6,0.0,72.0,0.0,5.0
108
- 2026-03-16 00:30:00+00:00,14.5,14.55,14.4,0.0,71.5,0.0,4.25
109
- 2026-03-16 00:45:00+00:00,14.5,14.5,14.4,0.0,71.0,0.0,3.5
110
- 2026-03-16 01:00:00+00:00,14.45,14.5,14.4,0.0,72.5,0.0,4.1
111
- 2026-03-16 01:15:00+00:00,14.4,14.4,14.4,0.0,74.0,0.0,3.6
112
- 2026-03-16 01:30:00+00:00,14.4,14.5,14.3,0.0,76.5,0.0,3.2
113
- 2026-03-16 01:45:00+00:00,14.5,14.5,14.4,0.0,76.0,0.0,2.9
114
- 2026-03-16 02:00:00+00:00,14.5,14.55,14.4,0.0,75.5,0.0,3.3
115
- 2026-03-16 02:15:00+00:00,14.5,14.6,14.5,0.0,71.0,0.0,3.8
116
- 2026-03-16 02:30:00+00:00,14.45,14.55,14.3,0.0,66.5,0.0,3.8499999999999996
117
- 2026-03-16 02:45:00+00:00,14.3,14.3,14.2,0.0,67.0,0.0,3.4
118
- 2026-03-16 03:00:00+00:00,14.2,14.25,14.149999999999999,0.0,65.5,0.0,3.05
119
- 2026-03-16 03:15:00+00:00,14.1,14.1,13.9,0.0,63.0,0.0,2.0
120
- 2026-03-16 03:30:00+00:00,13.95,14.05,13.95,0.0,64.5,0.0,3.0
121
- 2026-03-16 03:45:00+00:00,14.0,14.2,14.0,0.0,63.0,0.0,3.9
122
- 2026-03-16 04:00:00+00:00,14.1,14.2,13.95,13.5,61.5,0.0,3.55
123
- 2026-03-16 04:15:00+00:00,14.3,14.3,14.2,49.0,62.0,0.0,2.7
124
- 2026-03-16 04:30:00+00:00,14.6,14.75,14.5,111.5,61.5,0.0,3.5
125
- 2026-03-16 04:45:00+00:00,15.0,15.1,14.8,170.0,61.0,0.0,2.7
126
- 2026-03-16 05:00:00+00:00,15.399999999999999,15.55,15.25,228.5,62.0,0.0,3.1
127
- 2026-03-16 05:15:00+00:00,15.7,15.8,15.6,235.0,62.0,0.0,2.8
128
- 2026-03-16 05:30:00+00:00,15.5,15.65,15.4,140.5,62.0,0.0,2.35
129
- 2026-03-16 05:45:00+00:00,15.3,15.4,15.2,164.0,64.0,0.0,2.2
130
- 2026-03-16 06:00:00+00:00,15.45,15.55,15.350000000000001,178.5,64.0,0.0,2.1
131
- 2026-03-16 06:15:00+00:00,15.8,16.1,15.4,375.0,64.0,0.0,2.5
132
- 2026-03-16 06:30:00+00:00,16.1,16.35,15.95,432.0,63.5,0.0,3.1500000000000004
133
- 2026-03-16 06:45:00+00:00,16.0,16.3,15.8,300.0,63.0,0.0,4.2
134
- 2026-03-16 07:00:00+00:00,16.25,16.55,16.05,575.5,64.5,0.0,3.5999999999999996
135
- 2026-03-16 07:15:00+00:00,16.2,16.8,16.0,679.0,65.0,0.0,4.7
136
- 2026-03-16 07:30:00+00:00,16.25,16.45,16.15,548.5,65.5,0.0,4.4
137
- 2026-03-16 07:45:00+00:00,16.5,17.0,16.3,781.0,64.0,0.0,4.4
138
- 2026-03-16 08:00:00+00:00,16.95,17.15,16.75,513.0,61.5,0.0,3.35
139
- 2026-03-16 08:15:00+00:00,17.0,17.3,16.6,561.0,60.0,0.0,4.7
140
- 2026-03-16 08:30:00+00:00,16.799999999999997,17.1,16.45,616.0,62.5,0.0,4.15
141
- 2026-03-16 08:45:00+00:00,17.1,17.3,17.0,834.0,58.0,0.0,4.2
142
- 2026-03-16 09:00:00+00:00,17.3,17.65,17.0,679.0,58.0,0.0,4.25
143
- 2026-03-16 09:15:00+00:00,16.9,18.0,16.5,775.0,60.0,0.0,4.3
144
- 2026-03-16 09:30:00+00:00,17.55,17.700000000000003,17.25,743.5,58.5,0.0,4.05
145
- 2026-03-16 09:45:00+00:00,17.3,17.6,17.1,829.0,60.0,0.0,5.0
146
- 2026-03-16 10:00:00+00:00,17.75,18.15,17.200000000000003,889.5,58.5,0.0,3.8
147
- 2026-03-16 10:15:00+00:00,17.7,18.2,17.5,929.0,59.0,0.0,4.0
148
- 2026-03-16 10:30:00+00:00,17.9,18.35,17.5,716.0,59.0,0.0,3.25
149
- 2026-03-16 10:45:00+00:00,17.8,18.1,17.4,826.0,60.0,0.0,3.8
150
- 2026-03-16 11:00:00+00:00,17.3,17.75,16.9,658.5,61.0,0.0,4.75
151
- 2026-03-16 11:15:00+00:00,17.2,17.4,17.1,592.0,62.0,0.0,3.2
152
- 2026-03-16 11:30:00+00:00,17.75,18.15,17.200000000000003,680.0,61.0,0.0,3.9499999999999997
153
- 2026-03-16 11:45:00+00:00,17.2,17.4,16.9,621.0,64.0,0.0,3.4
154
- 2026-03-16 12:00:00+00:00,16.95,17.3,16.7,308.5,66.0,0.0,3.85
155
- 2026-03-16 12:15:00+00:00,16.6,16.9,16.4,326.0,68.0,0.0,2.6
156
- 2026-03-16 12:30:00+00:00,16.9,17.1,16.65,319.0,67.5,0.0,3.8499999999999996
157
- 2026-03-16 12:45:00+00:00,16.8,16.9,16.7,245.0,69.0,0.0,2.9
158
- 2026-03-16 13:00:00+00:00,17.049999999999997,17.35,16.85,354.5,67.0,0.0,3.2
159
- 2026-03-16 13:15:00+00:00,17.1,17.5,16.8,554.0,64.0,0.0,4.4
160
- 2026-03-16 13:30:00+00:00,16.9,17.1,16.700000000000003,245.0,64.5,0.0,3.95
161
- 2026-03-16 13:45:00+00:00,16.4,16.6,16.3,153.0,67.0,0.0,3.6
162
- 2026-03-16 14:00:00+00:00,16.45,16.6,16.35,269.0,63.5,0.0,4.25
163
- 2026-03-16 14:15:00+00:00,16.5,16.6,16.4,286.0,63.0,0.0,3.7
164
- 2026-03-16 14:30:00+00:00,16.3,16.450000000000003,16.15,160.5,63.5,0.0,3.3499999999999996
165
- 2026-03-16 14:45:00+00:00,16.1,16.2,16.0,180.0,65.0,0.0,2.7
166
- 2026-03-16 15:00:00+00:00,16.1,16.15,15.95,102.0,64.0,0.0,2.35
167
- 2026-03-16 15:15:00+00:00,15.8,15.9,15.6,64.0,66.0,0.0,2.5
168
- 2026-03-16 15:30:00+00:00,15.6,15.7,15.5,19.5,65.5,0.0,1.65
169
- 2026-03-16 15:45:00+00:00,15.2,15.4,15.1,3.0,64.0,0.0,2.0
170
- 2026-03-16 16:00:00+00:00,14.65,14.85,14.45,0.0,64.5,0.0,1.4
171
- 2026-03-16 16:15:00+00:00,14.1,14.3,14.0,0.0,67.0,0.0,1.3
172
- 2026-03-16 16:30:00+00:00,13.8,13.95,13.649999999999999,0.0,68.0,0.0,1.45
173
- 2026-03-16 16:45:00+00:00,13.6,13.6,13.5,0.0,70.0,0.0,1.5
174
- 2026-03-16 17:00:00+00:00,13.6,13.649999999999999,13.5,0.0,68.0,0.0,1.7000000000000002
175
- 2026-03-16 17:15:00+00:00,13.5,13.7,13.4,0.0,68.0,0.0,1.4
176
- 2026-03-16 17:30:00+00:00,13.2,13.4,12.95,0.0,71.0,0.0,1.4
177
- 2026-03-16 17:45:00+00:00,12.8,12.9,12.7,0.0,74.0,0.0,1.4
178
- 2026-03-16 18:00:00+00:00,12.649999999999999,12.75,12.5,0.0,75.5,0.0,1.3
179
- 2026-03-16 18:15:00+00:00,12.4,12.5,12.4,0.0,77.0,0.0,1.2
180
- 2026-03-16 18:30:00+00:00,12.25,12.3,12.149999999999999,0.0,78.5,0.0,1.2
181
- 2026-03-16 18:45:00+00:00,12.2,12.2,12.1,0.0,78.0,0.0,1.3
182
- 2026-03-16 19:00:00+00:00,12.149999999999999,12.25,12.1,0.0,78.5,0.0,1.25
183
- 2026-03-16 19:15:00+00:00,12.2,12.3,12.2,0.0,79.0,0.0,1.2
184
- 2026-03-16 19:30:00+00:00,12.05,12.149999999999999,12.0,0.0,80.0,0.0,1.2
185
- 2026-03-16 19:45:00+00:00,12.0,12.1,12.0,0.0,80.0,0.0,1.5
186
- 2026-03-16 20:00:00+00:00,12.05,12.05,11.95,0.0,80.5,0.0,1.35
187
- 2026-03-16 20:15:00+00:00,11.9,12.0,11.7,0.0,81.0,0.0,1.0
188
- 2026-03-16 20:30:00+00:00,11.75,11.850000000000001,11.649999999999999,0.0,81.5,0.0,1.0
189
- 2026-03-16 20:45:00+00:00,11.6,11.8,11.5,0.0,83.0,0.0,1.1
190
- 2026-03-16 21:00:00+00:00,11.649999999999999,11.75,11.5,0.0,82.0,0.0,1.15
191
- 2026-03-16 21:15:00+00:00,11.6,11.6,11.5,0.0,82.0,0.0,1.1
192
- 2026-03-16 21:30:00+00:00,11.5,11.6,11.45,0.0,83.0,0.0,1.05
193
- 2026-03-16 21:45:00+00:00,11.6,11.7,11.5,0.0,83.0,0.0,1.1
194
- 2026-03-16 22:00:00+00:00,11.4,11.5,11.3,0.0,83.5,0.0,1.1
195
- 2026-03-16 22:15:00+00:00,11.1,11.3,11.1,0.0,85.0,0.0,1.4
196
- 2026-03-16 22:30:00+00:00,11.3,11.4,11.149999999999999,0.0,84.0,0.0,0.95
197
- 2026-03-16 22:45:00+00:00,11.3,11.4,11.2,0.0,84.0,0.0,0.4
198
- 2026-03-16 23:00:00+00:00,11.15,11.35,10.850000000000001,0.0,84.0,0.0,1.45
199
- 2026-03-16 23:15:00+00:00,11.5,11.6,11.4,0.0,82.0,0.0,1.7
200
- 2026-03-16 23:30:00+00:00,11.0,11.25,10.850000000000001,0.0,85.0,0.0,1.45
201
- 2026-03-16 23:45:00+00:00,10.9,11.0,10.8,0.0,86.0,0.0,1.3
202
- 2026-03-17 00:00:00+00:00,10.8,10.95,10.649999999999999,0.0,86.0,0.0,1.05
203
- 2026-03-17 00:15:00+00:00,11.2,11.6,10.6,0.0,84.0,0.0,2.2
204
- 2026-03-17 00:30:00+00:00,12.05,12.2,11.75,0.0,77.5,0.0,2.05
205
- 2026-03-17 00:45:00+00:00,12.3,12.4,12.2,0.0,76.0,0.0,2.3
206
- 2026-03-17 01:00:00+00:00,12.2,12.350000000000001,12.0,0.0,75.5,0.0,2.1
207
- 2026-03-17 01:15:00+00:00,11.8,12.0,11.5,0.0,78.0,0.0,2.0
208
- 2026-03-17 01:30:00+00:00,11.899999999999999,12.1,11.65,0.0,77.0,0.0,2.0
209
- 2026-03-17 01:45:00+00:00,11.5,11.6,11.5,0.0,79.0,0.0,1.8
210
- 2026-03-17 02:00:00+00:00,10.85,11.149999999999999,10.55,0.0,82.5,0.0,1.45
211
- 2026-03-17 02:15:00+00:00,10.5,10.7,10.5,0.0,84.0,0.0,1.4
212
- 2026-03-17 02:30:00+00:00,10.850000000000001,11.05,10.55,0.0,82.0,0.0,1.65
213
- 2026-03-17 02:45:00+00:00,11.5,12.0,10.6,0.0,78.0,0.0,2.4
214
- 2026-03-17 03:00:00+00:00,11.85,12.05,11.7,0.0,76.5,0.0,2.15
215
- 2026-03-17 03:15:00+00:00,12.2,12.3,12.0,0.0,75.0,0.0,2.8
216
- 2026-03-17 03:30:00+00:00,12.1,12.2,12.05,0.0,74.5,0.0,2.55
217
- 2026-03-17 03:45:00+00:00,11.9,12.0,11.8,1.0,75.0,0.0,2.4
218
- 2026-03-17 04:00:00+00:00,12.149999999999999,12.2,12.1,16.5,72.5,0.0,2.1
219
- 2026-03-17 04:15:00+00:00,12.2,12.3,12.1,49.0,72.0,0.0,1.9
220
- 2026-03-17 04:30:00+00:00,12.4,12.6,12.25,97.0,71.5,0.0,1.2000000000000002
221
- 2026-03-17 04:45:00+00:00,12.9,13.1,12.5,146.0,70.0,0.0,1.6
222
- 2026-03-17 05:00:00+00:00,13.3,13.45,13.2,201.5,69.5,0.0,1.75
223
- 2026-03-17 05:15:00+00:00,13.6,13.8,13.5,261.0,68.0,0.0,2.0
224
- 2026-03-17 05:30:00+00:00,14.05,14.25,13.95,322.0,67.5,0.0,2.05
225
- 2026-03-17 05:45:00+00:00,14.3,14.4,14.1,383.0,67.0,0.0,2.2
226
- 2026-03-17 06:00:00+00:00,14.350000000000001,14.55,14.2,441.5,65.5,0.0,2.5
227
- 2026-03-17 06:15:00+00:00,14.7,14.9,14.5,495.0,63.0,0.0,2.4
228
- 2026-03-17 06:30:00+00:00,15.149999999999999,15.350000000000001,14.95,547.0,62.0,0.0,2.7
229
- 2026-03-17 06:45:00+00:00,15.3,15.6,15.0,598.0,62.0,0.0,2.6
230
- 2026-03-17 07:00:00+00:00,15.75,16.0,15.5,643.5,61.5,0.0,2.8
231
- 2026-03-17 07:15:00+00:00,16.2,16.4,15.9,687.0,61.0,0.0,2.8
232
- 2026-03-17 07:30:00+00:00,16.45,16.8,16.3,723.0,59.5,0.0,2.7
233
- 2026-03-17 07:45:00+00:00,17.1,17.5,16.9,756.0,56.0,0.0,3.2
234
- 2026-03-17 08:00:00+00:00,17.45,17.65,17.2,792.5,53.5,0.0,3.1500000000000004
235
- 2026-03-17 08:15:00+00:00,17.9,18.0,17.7,817.0,51.0,0.0,3.7
236
- 2026-03-17 08:30:00+00:00,17.9,18.2,17.700000000000003,841.5,51.5,0.0,3.25
237
- 2026-03-17 08:45:00+00:00,18.3,18.6,18.3,865.0,50.0,0.0,3.1
238
- 2026-03-17 09:00:00+00:00,18.55,18.75,18.35,883.5,48.5,0.0,3.3
239
- 2026-03-17 09:15:00+00:00,18.8,19.2,18.4,898.0,47.0,0.0,2.6
240
- 2026-03-17 09:30:00+00:00,19.7,20.049999999999997,19.299999999999997,907.5,40.0,0.0,3.3
241
- 2026-03-17 09:45:00+00:00,19.9,20.2,19.7,918.0,33.0,0.0,3.3
242
- 2026-03-17 10:00:00+00:00,20.4,20.6,20.15,912.5,35.0,0.0,2.95
243
- 2026-03-17 10:15:00+00:00,20.5,20.8,20.2,908.0,35.0,0.0,3.6
244
- 2026-03-17 10:30:00+00:00,21.05,21.25,20.7,895.0,32.0,0.0,2.8499999999999996
245
- 2026-03-17 10:45:00+00:00,21.3,21.4,21.2,877.0,30.0,0.0,2.4
246
- 2026-03-17 11:00:00+00:00,20.1,20.65,19.25,855.0,41.5,0.0,3.95
247
- 2026-03-17 11:15:00+00:00,19.3,19.5,18.9,828.0,49.0,0.0,2.6
248
- 2026-03-17 11:30:00+00:00,18.799999999999997,19.3,18.35,800.0,51.5,0.0,2.8
249
- 2026-03-17 11:45:00+00:00,18.4,18.6,18.1,771.0,54.0,0.0,2.4
250
- 2026-03-17 12:00:00+00:00,18.1,18.35,17.95,734.5,54.5,0.0,2.8499999999999996
251
- 2026-03-17 12:15:00+00:00,18.2,18.8,17.9,699.0,55.0,0.0,2.8
252
- 2026-03-17 12:30:00+00:00,18.5,18.8,18.15,654.5,55.0,0.0,2.75
253
- 2026-03-17 12:45:00+00:00,17.8,18.0,17.6,611.0,58.0,0.0,2.8
254
- 2026-03-17 13:00:00+00:00,17.700000000000003,18.1,17.4,562.0,58.5,0.0,3.0
255
- 2026-03-17 13:15:00+00:00,17.8,18.3,17.5,509.0,58.0,0.0,2.6
256
- 2026-03-17 13:30:00+00:00,17.45,17.65,17.1,456.0,59.5,0.0,2.75
257
- 2026-03-17 13:45:00+00:00,17.7,18.1,17.3,402.0,59.0,0.0,2.8
258
- 2026-03-17 14:00:00+00:00,17.450000000000003,17.8,17.1,344.5,61.0,0.0,2.5
259
- 2026-03-17 14:15:00+00:00,17.2,17.3,17.0,286.0,63.0,0.0,2.4
260
- 2026-03-17 14:30:00+00:00,17.15,17.299999999999997,17.0,227.0,63.5,0.0,2.45
261
- 2026-03-17 14:45:00+00:00,16.8,17.0,16.6,170.0,65.0,0.0,2.4
262
- 2026-03-17 15:00:00+00:00,16.55,16.6,16.45,116.5,68.0,0.0,2.2
263
- 2026-03-17 15:15:00+00:00,16.3,16.5,16.3,49.0,69.0,0.0,1.9
264
- 2026-03-17 15:30:00+00:00,16.25,16.35,16.25,15.5,70.5,0.0,1.6
265
- 2026-03-17 15:45:00+00:00,16.1,16.2,15.9,3.0,73.0,0.0,1.5
266
- 2026-03-17 16:00:00+00:00,15.8,15.850000000000001,15.7,0.0,74.5,0.0,1.55
267
- 2026-03-17 16:15:00+00:00,15.6,15.6,15.5,0.0,77.0,0.0,1.3
268
- 2026-03-17 16:30:00+00:00,15.4,15.55,15.3,0.0,77.5,0.0,1.65
269
- 2026-03-17 16:45:00+00:00,15.1,15.2,15.0,0.0,79.0,0.0,1.8
270
- 2026-03-17 17:00:00+00:00,14.850000000000001,14.9,14.75,0.0,80.0,0.0,1.55
271
- 2026-03-17 17:15:00+00:00,14.9,14.9,14.7,0.0,79.0,0.0,2.1
272
- 2026-03-17 17:30:00+00:00,14.9,15.0,14.8,0.0,80.0,0.0,1.9
273
- 2026-03-17 17:45:00+00:00,14.9,15.0,14.8,0.0,80.0,0.0,2.4
274
- 2026-03-17 18:00:00+00:00,14.9,14.9,14.8,0.0,80.0,0.0,2.5999999999999996
275
- 2026-03-17 18:15:00+00:00,14.9,14.9,14.8,0.0,80.0,0.0,2.8
276
- 2026-03-17 18:30:00+00:00,14.9,14.9,14.8,0.0,80.5,0.0,2.75
277
- 2026-03-17 18:45:00+00:00,14.8,14.8,14.7,0.0,82.0,0.0,2.5
278
- 2026-03-17 19:00:00+00:00,14.7,14.7,14.6,0.0,84.0,0.0,2.6
279
- 2026-03-17 19:15:00+00:00,14.7,14.7,14.6,0.0,84.0,0.0,2.1
280
- 2026-03-17 19:30:00+00:00,14.5,14.649999999999999,14.3,0.0,83.0,0.0,2.15
281
- 2026-03-17 19:45:00+00:00,13.5,14.0,13.3,0.0,82.0,0.0,1.7
282
- 2026-03-17 20:00:00+00:00,13.5,13.7,13.4,0.0,77.0,0.0,1.45
283
- 2026-03-17 20:15:00+00:00,14.0,14.6,13.7,0.0,73.0,0.0,2.0
284
- 2026-03-17 20:30:00+00:00,16.15,16.6,15.45,0.0,57.5,0.0,1.95
285
- 2026-03-17 20:45:00+00:00,16.7,16.9,16.6,0.0,53.0,0.0,1.9
286
- 2026-03-17 21:00:00+00:00,17.05,17.200000000000003,16.85,0.0,50.0,0.0,1.85
287
- 2026-03-17 21:15:00+00:00,16.9,17.0,16.5,0.0,51.0,0.0,1.6
288
- 2026-03-17 21:30:00+00:00,16.950000000000003,17.3,16.7,0.0,49.0,0.0,2.05
289
- 2026-03-17 21:45:00+00:00,17.4,17.6,17.2,0.0,47.0,0.0,2.3
290
- 2026-03-17 22:00:00+00:00,17.65,17.8,17.45,0.0,45.0,0.0,2.4
291
- 2026-03-17 22:15:00+00:00,17.6,17.8,17.5,0.0,44.0,0.0,2.5
292
- 2026-03-17 22:30:00+00:00,17.35,17.6,17.15,0.0,45.0,0.0,2.5999999999999996
293
- 2026-03-17 22:45:00+00:00,17.6,17.7,17.6,0.0,43.0,0.0,2.4
294
- 2026-03-17 23:00:00+00:00,17.8,17.9,17.700000000000003,0.0,42.0,0.0,3.1500000000000004
295
- 2026-03-17 23:15:00+00:00,17.7,17.9,17.6,0.0,42.0,0.0,2.9
296
- 2026-03-17 23:30:00+00:00,17.7,17.75,17.6,0.0,41.5,0.0,3.45
297
- 2026-03-17 23:45:00+00:00,17.8,17.9,17.7,0.0,39.0,0.0,3.5
298
- 2026-03-18 00:00:00+00:00,18.2,18.3,17.95,0.0,37.0,0.0,3.75
299
- 2026-03-18 00:15:00+00:00,18.3,18.4,18.2,0.0,36.0,0.0,4.1
300
- 2026-03-18 00:30:00+00:00,18.3,18.35,18.2,0.0,35.5,0.0,4.0
301
- 2026-03-18 00:45:00+00:00,18.2,18.3,18.1,0.0,36.0,0.0,4.2
302
- 2026-03-18 01:00:00+00:00,18.3,18.5,18.25,0.0,34.5,0.0,4.85
303
- 2026-03-18 01:15:00+00:00,18.5,18.6,18.4,0.0,33.0,0.0,4.8
304
- 2026-03-18 01:30:00+00:00,18.4,18.549999999999997,18.35,0.0,33.0,0.0,4.55
305
- 2026-03-18 01:45:00+00:00,18.2,18.3,18.1,0.0,33.0,0.0,4.4
306
- 2026-03-18 02:00:00+00:00,18.299999999999997,18.4,18.200000000000003,0.0,32.5,0.0,4.449999999999999
307
- 2026-03-18 02:15:00+00:00,18.3,18.4,18.3,0.0,31.0,0.0,3.8
308
- 2026-03-18 02:30:00+00:00,18.4,18.5,18.3,0.0,31.0,0.0,4.4
309
- 2026-03-18 02:45:00+00:00,18.5,18.5,18.4,0.0,30.0,0.0,5.2
310
- 2026-03-18 03:00:00+00:00,18.25,18.45,18.1,0.0,31.0,0.0,4.65
311
- 2026-03-18 03:15:00+00:00,18.2,18.3,18.1,0.0,31.0,0.0,4.7
312
- 2026-03-18 03:30:00+00:00,18.05,18.15,17.9,0.0,31.5,0.0,4.8
313
- 2026-03-18 03:45:00+00:00,18.0,18.1,18.0,1.0,32.0,0.0,4.8
314
- 2026-03-18 04:00:00+00:00,17.75,17.9,17.65,16.5,32.5,0.0,4.449999999999999
315
- 2026-03-18 04:15:00+00:00,17.7,17.8,17.6,45.0,33.0,0.0,4.2
316
- 2026-03-18 04:30:00+00:00,17.85,18.05,17.700000000000003,88.0,33.0,0.0,5.0
317
- 2026-03-18 04:45:00+00:00,18.1,18.1,18.0,127.0,33.0,0.0,5.5
318
- 2026-03-18 05:00:00+00:00,18.1,18.15,18.0,181.5,33.5,0.0,4.6
319
- 2026-03-18 05:15:00+00:00,18.2,18.2,18.1,243.0,34.0,0.0,5.0
320
- 2026-03-18 05:30:00+00:00,18.299999999999997,18.4,18.200000000000003,305.0,34.5,0.0,5.2
321
- 2026-03-18 05:45:00+00:00,18.5,18.7,18.4,362.0,35.0,0.0,4.9
322
- 2026-03-18 06:00:00+00:00,18.85,19.0,18.700000000000003,418.5,35.0,0.0,4.7
323
- 2026-03-18 06:15:00+00:00,19.2,19.2,19.1,471.0,35.0,0.0,4.7
324
- 2026-03-18 06:30:00+00:00,19.4,19.549999999999997,19.25,521.0,34.5,0.0,4.85
325
- 2026-03-18 06:45:00+00:00,19.7,19.8,19.6,570.0,34.0,0.0,5.6
326
- 2026-03-18 07:00:00+00:00,20.25,20.4,20.05,615.5,34.0,0.0,4.95
327
- 2026-03-18 07:15:00+00:00,20.7,20.9,20.5,655.0,33.0,0.0,4.8
328
- 2026-03-18 07:30:00+00:00,21.1,21.200000000000003,20.85,690.0,31.5,0.0,5.55
329
- 2026-03-18 07:45:00+00:00,21.6,21.8,21.3,732.0,30.0,0.0,5.6
330
- 2026-03-18 08:00:00+00:00,21.9,22.1,21.700000000000003,761.5,30.0,0.0,6.050000000000001
331
- 2026-03-18 08:15:00+00:00,22.4,22.6,22.1,791.0,29.0,0.0,5.1
332
- 2026-03-18 08:30:00+00:00,23.0,23.15,22.75,811.5,28.5,0.0,5.35
333
- 2026-03-18 08:45:00+00:00,23.0,23.1,22.9,690.0,28.0,0.0,6.9
334
- 2026-03-18 09:00:00+00:00,23.3,23.55,23.1,614.0,27.0,0.0,6.4
335
- 2026-03-18 09:15:00+00:00,24.0,24.4,23.5,682.0,27.0,0.0,4.9
336
- 2026-03-18 09:30:00+00:00,24.5,24.65,24.35,812.0,26.0,0.0,6.35
337
- 2026-03-18 09:45:00+00:00,24.7,24.8,24.5,859.0,27.0,0.0,6.6
338
- 2026-03-18 10:00:00+00:00,25.15,25.4,24.95,881.0,26.0,0.0,6.45
339
- 2026-03-18 10:15:00+00:00,25.9,26.0,25.6,865.0,23.0,0.0,6.2
340
- 2026-03-18 10:30:00+00:00,25.7,26.0,25.450000000000003,492.0,23.5,0.0,5.8
341
- 2026-03-18 10:45:00+00:00,25.2,25.4,25.1,437.0,25.0,0.0,5.5
342
- 2026-03-18 11:00:00+00:00,25.6,25.75,25.45,644.0,23.5,0.0,6.05
343
- 2026-03-18 11:15:00+00:00,25.6,25.7,25.4,339.0,24.0,0.0,4.8
344
- 2026-03-18 11:30:00+00:00,25.55,25.75,25.4,476.0,25.0,0.0,4.95
345
- 2026-03-18 11:45:00+00:00,25.4,25.8,25.3,607.0,25.0,0.0,5.1
346
- 2026-03-18 12:00:00+00:00,25.85,26.0,25.75,511.0,24.5,0.0,4.6
347
- 2026-03-18 12:15:00+00:00,25.6,25.8,25.6,271.0,24.0,0.0,5.4
348
- 2026-03-18 12:30:00+00:00,25.55,25.65,25.45,127.0,24.0,0.0,4.05
349
- 2026-03-18 12:45:00+00:00,25.5,25.5,25.4,43.0,23.0,0.0,4.0
350
- 2026-03-18 13:00:00+00:00,25.4,25.5,25.3,70.5,22.5,0.0,2.3
351
- 2026-03-18 13:15:00+00:00,25.4,25.5,25.3,103.0,23.0,0.0,2.4
352
- 2026-03-18 13:30:00+00:00,25.3,25.4,25.2,163.5,24.0,0.0,3.0
353
- 2026-03-18 13:45:00+00:00,25.2,25.3,25.0,130.0,25.0,0.0,2.8
354
- 2026-03-18 14:00:00+00:00,25.0,25.0,24.9,40.5,25.5,0.0,1.6
355
- 2026-03-18 14:15:00+00:00,25.0,25.1,24.8,18.0,25.0,0.0,1.6
356
- 2026-03-18 14:30:00+00:00,23.8,24.35,23.5,133.5,33.5,0.0,0.7
357
- 2026-03-18 14:45:00+00:00,23.6,23.9,23.4,66.0,36.0,0.0,1.3
358
- 2026-03-18 15:00:00+00:00,23.75,23.9,23.6,28.0,30.0,0.0,2.05
359
- 2026-03-18 15:15:00+00:00,24.1,24.1,23.9,31.0,28.0,0.0,2.5
360
- 2026-03-18 15:30:00+00:00,24.299999999999997,24.4,24.200000000000003,7.5,27.5,0.0,2.8499999999999996
361
- 2026-03-18 15:45:00+00:00,24.1,24.3,23.9,0.0,28.0,0.0,1.9
362
- 2026-03-18 16:00:00+00:00,23.65,23.85,23.450000000000003,0.0,30.0,0.0,1.75
363
- 2026-03-18 16:15:00+00:00,23.6,23.7,23.5,0.0,30.0,0.0,2.0
364
- 2026-03-18 16:30:00+00:00,23.7,23.799999999999997,23.6,0.0,29.5,0.0,2.05
365
- 2026-03-18 16:45:00+00:00,24.3,24.9,23.9,0.0,27.0,0.0,3.1
366
- 2026-03-18 17:00:00+00:00,21.8,23.5,20.1,0.0,46.0,0.9,4.3
367
- 2026-03-18 17:15:00+00:00,17.7,18.1,17.6,0.0,70.0,0.5,3.2
368
- 2026-03-18 17:30:00+00:00,17.15,17.4,16.950000000000003,0.0,74.0,0.05,2.55
369
- 2026-03-18 17:45:00+00:00,16.9,17.2,16.5,0.0,78.0,0.0,3.0
370
- 2026-03-18 18:00:00+00:00,16.25,16.45,16.15,0.0,80.5,0.0,2.55
371
- 2026-03-18 18:15:00+00:00,16.4,16.6,16.2,0.0,77.0,0.0,2.0
372
- 2026-03-18 18:30:00+00:00,16.35,16.700000000000003,16.15,0.0,77.5,0.0,1.3
373
- 2026-03-18 18:45:00+00:00,15.9,16.3,15.5,0.0,80.0,0.0,0.8
374
- 2026-03-18 19:00:00+00:00,17.35,17.950000000000003,16.65,0.5,63.0,0.0,3.2
375
- 2026-03-18 19:15:00+00:00,18.3,18.3,18.1,1.0,58.0,0.0,2.5
376
- 2026-03-18 19:30:00+00:00,18.35,18.45,18.3,0.5,59.0,0.0,2.6
377
- 2026-03-18 19:45:00+00:00,18.4,18.5,18.3,0.0,61.0,0.0,2.1
378
- 2026-03-18 20:00:00+00:00,18.5,18.55,18.4,0.0,63.0,0.0,2.4
379
- 2026-03-18 20:15:00+00:00,18.5,18.5,18.4,0.0,65.0,0.0,2.5
380
- 2026-03-18 20:30:00+00:00,18.45,18.55,18.4,0.0,67.0,0.0,4.15
381
- 2026-03-18 20:45:00+00:00,18.2,18.3,18.2,0.0,70.0,0.0,5.1
382
- 2026-03-18 21:00:00+00:00,18.1,18.200000000000003,18.0,0.0,71.5,0.0,4.6
383
- 2026-03-18 21:15:00+00:00,17.8,17.9,17.7,0.0,73.0,0.0,3.0
384
- 2026-03-18 21:30:00+00:00,17.75,17.85,17.6,0.0,72.5,0.0,2.85
385
- 2026-03-18 21:45:00+00:00,18.2,18.5,18.0,0.0,69.0,0.0,3.9
386
- 2026-03-18 22:00:00+00:00,18.4,18.55,18.25,0.0,67.0,0.0,4.3
387
- 2026-03-18 22:15:00+00:00,18.0,18.1,17.9,0.0,69.0,0.0,4.4
388
- 2026-03-18 22:30:00+00:00,17.8,17.85,17.7,0.0,70.0,0.0,4.550000000000001
389
- 2026-03-18 22:45:00+00:00,17.9,18.0,17.8,0.0,70.0,0.0,5.9
390
- 2026-03-18 23:00:00+00:00,17.55,17.700000000000003,17.5,0.0,72.0,0.0,5.75
391
- 2026-03-18 23:15:00+00:00,17.5,17.5,17.4,0.0,72.0,0.0,5.0
392
- 2026-03-18 23:30:00+00:00,17.35,17.35,17.3,0.0,72.5,0.0,3.2
393
- 2026-03-18 23:45:00+00:00,17.3,17.3,17.2,0.0,73.0,0.0,2.8
394
- 2026-03-19 00:00:00+00:00,17.2,17.25,17.1,0.0,73.0,0.0,2.75
395
- 2026-03-19 00:15:00+00:00,17.3,17.4,17.2,0.0,72.0,0.0,4.1
396
- 2026-03-19 00:30:00+00:00,17.3,17.35,17.2,0.0,72.5,0.0,3.0
397
- 2026-03-19 00:45:00+00:00,17.2,17.3,17.2,0.0,73.0,0.0,2.5
398
- 2026-03-19 01:00:00+00:00,17.1,17.25,17.0,0.0,73.5,0.05,2.25
399
- 2026-03-19 01:15:00+00:00,16.8,16.9,16.8,0.0,76.0,0.0,1.8
400
- 2026-03-19 01:30:00+00:00,16.6,16.700000000000003,16.5,0.0,78.0,0.0,2.8
401
- 2026-03-19 01:45:00+00:00,16.5,16.5,16.4,0.0,78.0,0.0,2.4
402
- 2026-03-19 02:00:00+00:00,16.3,16.45,16.2,0.0,79.5,0.15,2.45
403
- 2026-03-19 02:15:00+00:00,15.9,16.0,15.9,0.0,81.0,0.0,1.9
404
- 2026-03-19 02:30:00+00:00,16.200000000000003,16.299999999999997,16.05,0.0,79.5,0.0,1.65
405
- 2026-03-19 02:45:00+00:00,16.5,16.5,16.4,0.0,77.0,0.0,1.7
406
- 2026-03-19 03:00:00+00:00,16.5,16.7,16.4,0.0,76.5,0.0,2.0
407
- 2026-03-19 03:15:00+00:00,16.4,16.5,16.3,0.0,78.0,0.0,1.8
408
- 2026-03-19 03:30:00+00:00,16.200000000000003,16.299999999999997,16.05,0.0,79.0,0.0,1.5
409
- 2026-03-19 03:45:00+00:00,15.9,16.0,15.9,1.0,81.0,0.0,1.3
410
- 2026-03-19 04:00:00+00:00,15.75,15.8,15.649999999999999,6.0,82.5,0.0,1.5499999999999998
411
- 2026-03-19 04:15:00+00:00,15.7,15.7,15.6,28.0,82.0,0.0,1.7
412
- 2026-03-19 04:30:00+00:00,15.850000000000001,15.95,15.7,72.5,81.5,0.0,2.1500000000000004
413
- 2026-03-19 04:45:00+00:00,15.8,16.0,15.7,126.0,84.0,0.0,2.1
414
- 2026-03-19 05:00:00+00:00,15.95,16.0,15.9,155.5,84.0,0.0,2.4000000000000004
415
- 2026-03-19 05:15:00+00:00,16.0,16.1,15.9,192.0,85.0,0.0,2.5
416
- 2026-03-19 05:30:00+00:00,15.850000000000001,15.95,15.8,178.0,86.5,0.0,2.55
417
- 2026-03-19 05:45:00+00:00,15.8,15.9,15.8,178.0,88.0,0.0,1.5
418
- 2026-03-19 06:00:00+00:00,16.0,16.1,15.9,158.5,87.5,0.0,1.4
419
- 2026-03-19 06:15:00+00:00,16.5,17.0,16.1,360.0,85.0,0.0,1.5
420
- 2026-03-19 06:30:00+00:00,17.4,17.5,17.15,275.0,76.5,0.0,2.7
421
- 2026-03-19 06:45:00+00:00,17.5,17.6,17.4,230.0,77.0,0.0,2.7
422
- 2026-03-19 07:00:00+00:00,18.15,18.65,17.9,545.0,72.5,0.0,1.85
423
- 2026-03-19 07:15:00+00:00,19.3,19.7,19.0,630.0,65.0,0.0,1.3
424
- 2026-03-19 07:30:00+00:00,19.549999999999997,19.75,19.35,657.5,61.5,0.0,1.85
425
- 2026-03-19 07:45:00+00:00,19.9,20.2,19.7,701.0,59.0,0.0,1.9
426
- 2026-03-19 08:00:00+00:00,19.75,19.950000000000003,19.450000000000003,739.0,57.0,0.0,2.55
427
- 2026-03-19 08:15:00+00:00,19.8,20.1,19.6,762.0,58.0,0.0,2.4
428
- 2026-03-19 08:30:00+00:00,19.85,20.5,19.45,767.5,60.0,0.0,3.2
429
- 2026-03-19 08:45:00+00:00,19.4,19.8,19.1,701.0,65.0,0.0,4.3
430
- 2026-03-19 09:00:00+00:00,19.1,19.2,18.9,525.0,69.0,0.0,2.95
431
- 2026-03-19 09:15:00+00:00,18.5,19.1,18.3,424.0,73.0,0.0,3.9
432
- 2026-03-19 09:30:00+00:00,18.15,18.45,18.0,347.0,75.5,0.0,3.65
433
- 2026-03-19 09:45:00+00:00,17.8,17.9,17.8,336.0,78.0,0.0,3.5
434
- 2026-03-19 10:00:00+00:00,17.450000000000003,17.6,17.25,292.5,80.5,0.0,4.4
435
- 2026-03-19 10:15:00+00:00,17.2,17.3,17.1,282.0,81.0,0.0,5.0
436
- 2026-03-19 10:30:00+00:00,17.15,17.25,17.0,256.5,80.0,0.0,4.949999999999999
437
- 2026-03-19 10:45:00+00:00,17.0,17.1,16.8,231.0,82.0,0.0,5.2
438
- 2026-03-19 11:00:00+00:00,16.65,16.75,16.5,286.0,83.0,0.0,5.550000000000001
439
- 2026-03-19 11:15:00+00:00,16.5,16.6,16.4,333.0,82.0,0.0,4.8
440
- 2026-03-19 11:30:00+00:00,16.8,16.95,16.55,306.0,81.0,0.0,4.55
441
- 2026-03-19 11:45:00+00:00,16.8,17.0,16.6,262.0,79.0,0.0,4.2
442
- 2026-03-19 12:00:00+00:00,16.45,16.55,16.3,188.5,81.5,0.0,4.699999999999999
443
- 2026-03-19 12:15:00+00:00,16.4,16.4,16.3,165.0,82.0,0.0,4.5
444
- 2026-03-19 12:30:00+00:00,16.45,16.55,16.35,188.0,81.0,0.0,4.55
445
- 2026-03-19 12:45:00+00:00,16.5,16.7,16.4,315.0,81.0,0.0,3.9
446
- 2026-03-19 13:00:00+00:00,16.7,16.75,16.65,251.0,78.5,0.0,4.5
447
- 2026-03-19 13:15:00+00:00,16.7,16.7,16.6,235.0,80.0,0.0,4.0
448
- 2026-03-19 13:30:00+00:00,16.95,17.2,16.75,240.5,78.5,0.0,4.35
449
- 2026-03-19 13:45:00+00:00,16.5,16.8,16.3,132.0,82.0,0.0,5.7
450
- 2026-03-19 14:00:00+00:00,16.25,16.3,16.15,106.5,83.5,0.0,4.9
451
- 2026-03-19 14:15:00+00:00,16.1,16.1,16.0,34.0,86.0,0.0,4.0
452
- 2026-03-19 14:30:00+00:00,16.0,16.1,15.9,64.5,86.5,0.0,4.55
453
- 2026-03-19 14:45:00+00:00,16.1,16.1,16.0,34.0,87.0,0.0,4.3
454
- 2026-03-19 15:00:00+00:00,16.15,16.25,16.05,55.5,87.5,0.0,4.4
455
- 2026-03-19 15:15:00+00:00,16.2,16.3,16.1,45.0,89.0,0.0,4.9
456
- 2026-03-19 15:30:00+00:00,16.1,16.15,16.05,10.5,88.0,0.0,4.6
457
- 2026-03-19 15:45:00+00:00,16.0,16.0,15.9,1.0,89.0,0.0,4.5
458
- 2026-03-19 16:00:00+00:00,15.65,16.0,14.8,0.0,89.0,2.0,3.8499999999999996
459
- 2026-03-19 16:15:00+00:00,13.3,13.7,13.2,0.0,88.0,0.0,2.4
460
- 2026-03-19 16:30:00+00:00,13.3,13.4,13.2,0.0,88.5,0.0,1.75
461
- 2026-03-19 16:45:00+00:00,13.2,13.5,13.0,0.0,90.0,0.0,1.5
462
- 2026-03-19 17:00:00+00:00,13.4,13.45,13.25,0.0,87.5,0.0,2.0
463
- 2026-03-19 17:15:00+00:00,13.5,13.6,13.4,0.0,87.0,0.0,1.3
464
- 2026-03-19 17:30:00+00:00,13.3,13.4,13.2,0.0,90.5,0.0,1.5
465
- 2026-03-19 17:45:00+00:00,13.0,13.2,12.9,0.0,91.0,0.0,1.7
466
- 2026-03-19 18:00:00+00:00,13.149999999999999,13.25,13.05,0.0,89.5,0.0,1.85
467
- 2026-03-19 18:15:00+00:00,13.4,13.5,13.2,0.0,87.0,0.0,1.4
468
- 2026-03-19 18:30:00+00:00,13.45,13.55,13.350000000000001,0.0,88.0,0.0,1.7
469
- 2026-03-19 18:45:00+00:00,13.8,14.0,13.6,0.0,85.0,0.0,2.3
470
- 2026-03-19 19:00:00+00:00,14.149999999999999,14.25,14.0,0.0,83.0,0.0,1.85
471
- 2026-03-19 19:15:00+00:00,14.4,14.7,14.2,0.0,83.0,0.0,3.0
472
- 2026-03-19 19:30:00+00:00,15.1,15.2,14.899999999999999,1.0,86.5,0.0,3.75
473
- 2026-03-19 19:45:00+00:00,15.4,15.4,15.3,1.0,88.0,0.0,3.7
474
- 2026-03-19 20:00:00+00:00,15.149999999999999,15.3,14.9,0.5,87.0,0.0,4.95
475
- 2026-03-19 20:15:00+00:00,15.3,15.4,15.2,0.0,87.0,0.0,5.5
476
- 2026-03-19 20:30:00+00:00,15.2,15.35,15.1,0.0,89.0,0.1,4.9
477
- 2026-03-19 20:45:00+00:00,15.0,15.2,14.7,0.0,88.0,0.0,7.0
478
- 2026-03-19 21:00:00+00:00,14.85,14.95,14.75,0.0,87.0,0.0,5.85
479
- 2026-03-19 21:15:00+00:00,15.0,15.1,14.9,0.0,89.0,0.0,5.1
480
- 2026-03-19 21:30:00+00:00,14.9,15.0,14.850000000000001,0.0,87.0,0.0,4.35
481
- 2026-03-19 21:45:00+00:00,14.9,15.0,14.8,0.0,86.0,0.0,4.8
482
- 2026-03-19 22:00:00+00:00,14.9,14.95,14.8,0.0,84.0,0.0,5.35
483
- 2026-03-19 22:15:00+00:00,14.9,15.0,14.8,0.0,83.0,0.0,4.9
484
- 2026-03-19 22:30:00+00:00,14.9,15.0,14.850000000000001,0.0,82.0,0.0,4.35
485
- 2026-03-19 22:45:00+00:00,15.0,15.1,14.9,0.0,83.0,0.0,4.5
486
- 2026-03-19 23:00:00+00:00,15.0,15.05,14.9,0.0,81.5,0.0,4.1
487
- 2026-03-19 23:15:00+00:00,15.0,15.1,14.9,0.0,81.0,0.0,5.6
488
- 2026-03-19 23:30:00+00:00,15.0,15.1,14.95,0.0,81.0,0.0,4.25
489
- 2026-03-19 23:45:00+00:00,15.0,15.1,15.0,0.0,82.0,0.0,4.2
490
- 2026-03-20 00:00:00+00:00,14.95,15.05,14.9,0.0,80.0,0.0,4.95
491
- 2026-03-20 00:15:00+00:00,15.0,15.1,14.9,0.0,83.0,0.0,3.1
492
- 2026-03-20 00:30:00+00:00,14.9,14.9,14.8,0.0,79.0,0.0,3.35
493
- 2026-03-20 00:45:00+00:00,14.8,14.8,14.8,0.0,78.0,0.0,3.0
494
- 2026-03-20 01:00:00+00:00,14.850000000000001,14.9,14.8,0.0,79.0,0.0,3.65
495
- 2026-03-20 01:15:00+00:00,14.8,14.9,14.7,0.0,80.0,0.0,3.2
496
- 2026-03-20 01:30:00+00:00,14.8,14.9,14.75,0.0,78.5,0.0,4.1
497
- 2026-03-20 01:45:00+00:00,14.9,14.9,14.8,0.0,74.0,0.0,5.9
498
- 2026-03-20 02:00:00+00:00,14.5,14.75,14.149999999999999,0.0,77.5,0.0,4.4
499
- 2026-03-20 02:15:00+00:00,13.6,13.7,13.6,0.0,77.0,0.0,5.2
500
- 2026-03-20 02:30:00+00:00,13.55,13.6,13.5,0.0,79.5,0.0,4.2
501
- 2026-03-20 02:45:00+00:00,13.8,13.9,13.6,0.0,84.0,0.0,5.5
502
- 2026-03-20 03:00:00+00:00,13.95,14.0,13.9,0.0,85.5,0.0,3.25
503
- 2026-03-20 03:15:00+00:00,13.9,14.1,13.6,0.0,87.0,1.4,3.6
504
- 2026-03-20 03:30:00+00:00,13.649999999999999,13.75,13.649999999999999,0.0,88.5,0.0,4.550000000000001
505
- 2026-03-20 03:45:00+00:00,13.6,13.8,13.6,3.0,89.0,0.0,4.0
506
- 2026-03-20 04:00:00+00:00,13.649999999999999,13.7,13.6,11.5,89.0,0.0,3.0
507
- 2026-03-20 04:15:00+00:00,13.8,13.9,13.7,17.0,89.0,0.0,2.6
508
- 2026-03-20 04:30:00+00:00,13.25,13.55,12.899999999999999,11.5,92.0,0.95,4.4
509
- 2026-03-20 04:45:00+00:00,12.9,13.0,12.8,24.0,95.0,0.0,4.9
510
- 2026-03-20 05:00:00+00:00,12.95,13.0,12.850000000000001,50.0,95.0,0.30000000000000004,4.2
511
- 2026-03-20 05:15:00+00:00,13.1,13.2,13.1,70.0,94.0,0.2,3.7
512
- 2026-03-20 05:30:00+00:00,13.05,13.1,12.9,50.5,94.0,1.05,4.55
513
- 2026-03-20 05:45:00+00:00,12.5,12.8,12.4,24.0,95.0,1.5,3.4
514
- 2026-03-20 06:00:00+00:00,12.55,12.7,12.350000000000001,98.5,95.5,0.25,1.35
515
- 2026-03-20 06:15:00+00:00,13.2,13.4,12.9,134.0,95.0,0.2,2.4
516
- 2026-03-20 06:30:00+00:00,13.5,13.7,13.3,88.0,95.0,0.65,2.4000000000000004
517
- 2026-03-20 06:45:00+00:00,14.1,14.2,13.9,218.0,94.0,0.0,5.9
518
- 2026-03-20 07:00:00+00:00,14.45,14.55,14.3,320.5,91.0,0.0,5.949999999999999
519
- 2026-03-20 07:15:00+00:00,14.7,14.8,14.7,250.0,90.0,0.0,5.4
520
- 2026-03-20 07:30:00+00:00,14.8,14.9,14.65,466.5,88.5,0.0,5.1
521
- 2026-03-20 07:45:00+00:00,14.9,15.0,14.9,255.0,89.0,0.0,5.0
522
- 2026-03-20 08:00:00+00:00,15.1,15.149999999999999,14.9,357.0,88.5,0.0,5.449999999999999
523
- 2026-03-20 08:15:00+00:00,15.2,15.4,15.1,527.0,87.0,0.0,5.5
524
- 2026-03-20 08:30:00+00:00,15.45,15.5,15.3,445.5,85.0,0.0,5.3
525
- 2026-03-20 08:45:00+00:00,15.1,15.3,14.9,366.0,86.0,0.0,5.4
526
- 2026-03-20 09:00:00+00:00,14.9,15.05,14.75,432.5,87.0,0.0,4.55
527
- 2026-03-20 09:15:00+00:00,15.3,15.5,14.8,433.0,86.0,0.0,3.8
528
- 2026-03-20 09:30:00+00:00,15.4,15.5,15.25,396.0,82.0,0.0,5.5
529
- 2026-03-20 09:45:00+00:00,15.6,15.6,15.5,192.0,78.0,0.0,4.2
530
- 2026-03-20 10:00:00+00:00,14.8,15.2,14.55,141.5,86.5,0.35,4.4
531
- 2026-03-20 10:15:00+00:00,14.2,14.5,14.1,319.0,89.0,0.0,4.9
532
- 2026-03-20 10:30:00+00:00,15.05,15.35,14.75,389.5,88.5,0.0,4.1
533
- 2026-03-20 10:45:00+00:00,15.7,15.8,15.6,314.0,85.0,0.0,5.6
534
- 2026-03-20 11:00:00+00:00,15.75,16.05,15.55,427.0,82.5,0.0,4.95
535
- 2026-03-20 11:15:00+00:00,16.1,16.3,15.9,325.0,80.0,0.0,3.9
536
- 2026-03-20 11:30:00+00:00,16.1,16.15,16.0,332.5,78.5,0.0,4.1
537
- 2026-03-20 11:45:00+00:00,16.4,16.5,16.1,468.0,76.0,0.0,4.1
538
- 2026-03-20 12:00:00+00:00,16.3,16.6,16.15,418.0,75.5,0.0,4.15
539
- 2026-03-20 12:15:00+00:00,16.8,16.8,16.6,486.0,71.0,0.0,4.3
540
- 2026-03-20 12:30:00+00:00,16.95,17.2,16.75,531.5,70.0,0.0,2.5
541
- 2026-03-20 12:45:00+00:00,17.2,17.5,17.0,551.0,68.0,0.0,2.1
542
- 2026-03-20 13:00:00+00:00,17.4,17.55,17.15,551.5,65.0,0.0,2.4
543
- 2026-03-20 13:15:00+00:00,17.6,17.9,17.4,505.0,64.0,0.0,1.7
544
- 2026-03-20 13:30:00+00:00,17.95,18.1,17.75,429.5,59.0,0.0,1.75
545
- 2026-03-20 13:45:00+00:00,18.1,18.3,17.9,394.0,57.0,0.0,1.7
546
- 2026-03-20 14:00:00+00:00,18.299999999999997,18.4,18.1,321.0,56.5,0.0,0.9500000000000001
547
- 2026-03-20 14:15:00+00:00,18.1,18.5,18.0,280.0,54.0,0.0,1.1
548
- 2026-03-20 14:30:00+00:00,18.0,18.200000000000003,17.85,118.0,55.0,0.0,1.2
549
- 2026-03-20 14:45:00+00:00,17.6,17.7,17.5,192.0,55.0,0.0,1.0
550
- 2026-03-20 15:00:00+00:00,17.4,17.65,17.05,116.5,60.0,0.0,1.1
551
- 2026-03-20 15:15:00+00:00,16.8,17.2,16.5,58.0,62.0,0.0,1.1
552
- 2026-03-20 15:30:00+00:00,15.850000000000001,16.75,15.149999999999999,15.0,67.5,0.0,0.65
553
- 2026-03-20 15:45:00+00:00,14.3,14.7,14.1,3.0,75.0,0.0,0.9
554
- 2026-03-20 16:00:00+00:00,14.1,14.35,13.8,0.0,74.0,0.0,1.15
555
- 2026-03-20 16:15:00+00:00,13.9,14.3,13.7,0.0,75.0,0.0,1.1
556
- 2026-03-20 16:30:00+00:00,14.4,14.85,14.0,0.0,68.5,0.0,1.65
557
- 2026-03-20 16:45:00+00:00,14.6,15.0,14.2,0.0,68.0,0.0,1.5
558
- 2026-03-20 17:00:00+00:00,15.5,16.0,14.65,0.0,60.5,0.0,1.95
559
- 2026-03-20 17:15:00+00:00,16.2,16.8,15.2,0.0,53.0,0.0,2.9
560
- 2026-03-20 17:30:00+00:00,15.9,16.55,15.2,0.0,57.5,0.0,1.65
561
- 2026-03-20 17:45:00+00:00,15.2,15.5,15.0,0.0,61.0,0.0,0.7
562
- 2026-03-20 18:00:00+00:00,15.2,15.600000000000001,14.3,0.0,61.0,0.0,1.25
563
- 2026-03-20 18:15:00+00:00,16.0,16.7,15.2,0.0,56.0,0.0,1.7
564
- 2026-03-20 18:30:00+00:00,16.35,16.7,16.0,0.0,56.0,0.0,1.15
565
- 2026-03-20 18:45:00+00:00,16.4,16.7,16.2,0.0,61.0,0.0,2.0
566
- 2026-03-20 19:00:00+00:00,16.200000000000003,16.299999999999997,16.1,0.0,73.5,0.0,2.75
567
- 2026-03-20 19:15:00+00:00,15.9,16.0,15.9,0.0,77.0,0.0,2.5
568
- 2026-03-20 19:30:00+00:00,15.9,15.95,15.9,0.0,76.0,0.0,2.35
569
- 2026-03-20 19:45:00+00:00,15.9,15.9,15.8,0.0,73.0,0.0,2.1
570
- 2026-03-20 20:00:00+00:00,15.75,15.850000000000001,15.75,0.0,69.0,0.0,2.75
571
- 2026-03-20 20:15:00+00:00,15.7,15.8,15.7,0.0,67.0,0.0,4.2
572
- 2026-03-20 20:30:00+00:00,15.649999999999999,15.7,15.55,0.0,69.0,0.0,3.35
573
- 2026-03-20 20:45:00+00:00,15.5,15.5,15.5,0.0,68.0,0.0,4.1
574
- 2026-03-20 21:00:00+00:00,15.45,15.5,15.4,0.0,65.5,0.0,3.95
575
- 2026-03-20 21:15:00+00:00,15.4,15.5,15.3,0.0,66.0,0.0,2.8
576
- 2026-03-20 21:30:00+00:00,15.25,15.350000000000001,15.25,0.0,68.0,0.0,3.5
577
- 2026-03-20 21:45:00+00:00,15.2,15.2,15.1,0.0,69.0,0.0,3.1
578
- 2026-03-20 22:00:00+00:00,15.1,15.2,15.1,0.0,69.0,0.0,3.1500000000000004
579
- 2026-03-20 22:15:00+00:00,15.1,15.1,15.0,0.0,71.0,0.0,3.5
580
- 2026-03-20 22:30:00+00:00,15.05,15.1,15.0,0.0,69.5,0.0,3.4000000000000004
581
- 2026-03-20 22:45:00+00:00,15.0,15.1,15.0,0.0,70.0,0.0,3.8
582
- 2026-03-20 23:00:00+00:00,14.9,14.95,14.850000000000001,0.0,69.5,0.0,4.0
583
- 2026-03-20 23:15:00+00:00,14.8,14.9,14.7,0.0,70.0,0.0,3.4
584
- 2026-03-20 23:30:00+00:00,14.649999999999999,14.75,14.6,0.0,70.5,0.0,2.0
585
- 2026-03-20 23:45:00+00:00,14.5,14.6,14.4,0.0,69.0,0.0,2.3
586
- 2026-03-21 00:00:00+00:00,14.45,14.5,14.4,0.0,68.0,0.0,3.4
587
- 2026-03-21 00:15:00+00:00,14.4,14.4,14.3,0.0,69.0,0.0,2.6
588
- 2026-03-21 00:30:00+00:00,14.3,14.3,14.25,0.0,70.0,0.0,1.9
589
- 2026-03-21 00:45:00+00:00,14.2,14.3,14.2,0.0,71.0,0.0,1.9
590
- 2026-03-21 01:00:00+00:00,14.149999999999999,14.2,14.0,0.0,71.0,0.0,1.2999999999999998
591
- 2026-03-21 01:15:00+00:00,14.2,14.3,14.1,0.0,70.0,0.0,1.5
592
- 2026-03-21 01:30:00+00:00,14.1,14.2,13.9,0.0,71.0,0.0,1.65
593
- 2026-03-21 01:45:00+00:00,14.1,14.1,14.0,0.0,71.0,0.0,1.9
594
- 2026-03-21 02:00:00+00:00,14.1,14.2,14.05,0.0,71.0,0.0,1.5
595
- 2026-03-21 02:15:00+00:00,14.0,14.2,14.0,0.0,72.0,0.0,1.3
596
- 2026-03-21 02:30:00+00:00,14.05,14.05,14.0,0.0,72.0,0.0,1.7999999999999998
597
- 2026-03-21 02:45:00+00:00,14.0,14.1,13.9,0.0,71.0,0.0,2.0
598
- 2026-03-21 03:00:00+00:00,14.0,14.1,13.8,0.0,71.0,0.0,2.55
599
- 2026-03-21 03:15:00+00:00,14.0,14.0,13.9,0.0,72.0,0.0,2.5
600
- 2026-03-21 03:30:00+00:00,13.9,14.0,13.75,0.0,72.5,0.0,1.85
601
- 2026-03-21 03:45:00+00:00,13.7,13.8,13.6,6.0,74.0,0.0,1.3
602
- 2026-03-21 04:00:00+00:00,13.7,13.95,13.5,26.0,74.0,0.0,1.75
603
- 2026-03-21 04:15:00+00:00,13.4,13.5,13.3,84.0,77.0,0.0,1.8
604
- 2026-03-21 04:30:00+00:00,13.35,13.7,13.15,89.5,78.0,0.0,1.7999999999999998
605
- 2026-03-21 04:45:00+00:00,13.2,13.3,13.0,129.0,78.0,0.5,2.6
606
- 2026-03-21 05:00:00+00:00,13.25,13.4,13.149999999999999,153.5,79.0,0.0,2.05
607
- 2026-03-21 05:15:00+00:00,13.5,13.6,13.4,135.0,78.0,0.0,1.7
608
- 2026-03-21 05:30:00+00:00,13.45,13.55,13.4,27.0,76.0,0.0,1.9500000000000002
609
- 2026-03-21 05:45:00+00:00,13.0,13.3,12.9,27.0,77.0,0.0,3.4
610
- 2026-03-21 06:00:00+00:00,13.05,13.2,12.9,223.0,77.0,0.0,1.7000000000000002
611
- 2026-03-21 06:15:00+00:00,13.7,13.9,13.5,105.0,74.0,0.0,1.5
612
- 2026-03-21 06:30:00+00:00,13.850000000000001,13.95,13.7,62.5,74.0,0.0,1.7000000000000002
613
- 2026-03-21 06:45:00+00:00,13.6,13.9,13.5,67.0,76.0,0.1,1.4
614
- 2026-03-21 07:00:00+00:00,13.45,13.5,13.4,52.5,76.5,0.2,1.5
615
- 2026-03-21 07:15:00+00:00,13.0,13.5,12.8,40.0,83.0,1.1,1.4
616
- 2026-03-21 07:30:00+00:00,12.649999999999999,12.8,12.6,44.5,88.0,0.7,1.5
617
- 2026-03-21 07:45:00+00:00,12.3,12.7,11.7,19.0,90.0,1.9,2.7
618
- 2026-03-21 08:00:00+00:00,12.15,12.5,11.899999999999999,101.0,90.5,0.1,1.8
619
- 2026-03-21 08:15:00+00:00,12.9,13.2,12.7,195.0,89.0,0.0,2.4
620
- 2026-03-21 08:30:00+00:00,13.15,13.25,13.0,74.5,88.0,0.2,2.15
621
- 2026-03-21 08:45:00+00:00,13.0,13.2,12.8,272.0,90.0,0.1,2.1
622
- 2026-03-21 09:00:00+00:00,13.7,13.850000000000001,13.45,149.0,85.0,0.0,3.7
623
- 2026-03-21 09:15:00+00:00,12.7,13.7,12.2,24.0,84.0,0.2,2.4
624
- 2026-03-21 09:30:00+00:00,12.25,12.350000000000001,12.149999999999999,24.0,88.5,0.8999999999999999,1.85
625
- 2026-03-21 09:45:00+00:00,12.5,12.7,12.4,122.0,90.0,0.4,1.6
626
- 2026-03-21 10:00:00+00:00,13.8,14.25,13.3,452.0,86.0,0.0,2.9
627
- 2026-03-21 10:15:00+00:00,14.6,14.7,14.6,302.0,79.0,0.0,3.0
628
- 2026-03-21 10:30:00+00:00,14.7,14.7,14.6,268.0,77.0,0.0,3.65
629
- 2026-03-21 10:45:00+00:00,14.7,14.8,14.7,260.0,77.0,0.0,3.3
630
- 2026-03-21 11:00:00+00:00,13.95,14.399999999999999,13.649999999999999,191.5,82.5,0.25,3.35
631
- 2026-03-21 11:15:00+00:00,13.5,13.8,13.2,607.0,82.0,0.0,3.2
632
- 2026-03-21 11:30:00+00:00,14.5,14.75,14.15,544.5,78.5,0.0,3.25
633
- 2026-03-21 11:45:00+00:00,14.8,14.8,14.5,341.0,78.0,0.0,4.5
634
- 2026-03-21 12:00:00+00:00,14.9,15.1,14.45,238.5,72.0,0.0,4.15
635
- 2026-03-21 12:15:00+00:00,13.5,14.1,13.3,154.0,76.0,0.0,2.9
636
- 2026-03-21 12:30:00+00:00,13.45,13.55,13.350000000000001,158.5,78.0,0.0,3.3499999999999996
637
- 2026-03-21 12:45:00+00:00,13.8,13.9,13.7,237.0,79.0,0.0,3.3
638
- 2026-03-21 13:00:00+00:00,14.25,14.350000000000001,14.0,146.5,80.0,0.15,2.4000000000000004
639
- 2026-03-21 13:15:00+00:00,14.1,14.1,14.0,81.0,83.0,0.0,2.2
640
- 2026-03-21 13:30:00+00:00,14.1,14.2,14.05,80.5,78.5,0.0,4.45
641
- 2026-03-21 13:45:00+00:00,13.8,14.0,13.7,36.0,79.0,0.3,4.4
642
- 2026-03-21 14:00:00+00:00,13.25,13.5,13.15,54.0,82.0,0.25,3.95
643
- 2026-03-21 14:15:00+00:00,12.8,13.0,12.6,29.0,81.0,0.3,5.2
644
- 2026-03-21 14:30:00+00:00,12.35,12.5,12.3,16.0,86.0,0.5,1.95
645
- 2026-03-21 14:45:00+00:00,12.2,12.3,11.8,6.0,91.0,3.3,3.3
646
- 2026-03-21 15:00:00+00:00,10.6,11.25,10.350000000000001,4.0,96.0,3.35,2.4
647
- 2026-03-21 15:15:00+00:00,10.2,10.6,10.1,5.0,96.0,0.1,1.7
648
- 2026-03-21 15:30:00+00:00,10.0,10.15,9.95,4.5,96.0,0.35,1.95
649
- 2026-03-21 15:45:00+00:00,9.8,10.0,9.7,0.0,95.0,1.0,2.5
650
- 2026-03-21 16:00:00+00:00,9.9,10.05,9.8,0.0,96.0,1.15,2.2
651
- 2026-03-21 16:15:00+00:00,10.1,10.2,10.0,0.0,97.0,0.8,3.3
652
- 2026-03-21 16:30:00+00:00,10.3,10.3,10.25,0.0,97.0,1.05,4.0
653
- 2026-03-21 16:45:00+00:00,10.3,10.4,10.3,0.0,97.0,0.7,3.5
654
- 2026-03-21 17:00:00+00:00,10.4,10.4,10.3,0.0,97.0,0.35,3.65
655
- 2026-03-21 17:15:00+00:00,10.3,10.4,10.3,0.0,96.0,0.2,3.4
656
- 2026-03-21 17:30:00+00:00,10.45,10.55,10.4,0.0,93.0,0.1,2.95
657
- 2026-03-21 17:45:00+00:00,10.5,10.5,10.4,0.0,91.0,0.0,2.4
658
- 2026-03-21 18:00:00+00:00,10.3,10.4,10.25,0.0,92.5,0.05,2.6
659
- 2026-03-21 18:15:00+00:00,10.5,10.6,10.3,0.0,87.0,0.0,3.2
660
- 2026-03-21 18:30:00+00:00,10.5,10.65,10.4,0.5,87.0,0.0,3.0999999999999996
661
- 2026-03-21 18:45:00+00:00,10.4,10.5,10.3,1.0,88.0,0.0,3.3
662
- 2026-03-21 19:00:00+00:00,10.65,10.9,10.5,1.0,84.5,0.0,3.8
663
- 2026-03-21 19:15:00+00:00,10.9,11.0,10.8,1.0,82.0,0.0,3.7
664
- 2026-03-21 19:30:00+00:00,10.9,11.0,10.75,0.5,81.5,0.0,3.3499999999999996
665
- 2026-03-21 19:45:00+00:00,10.8,10.9,10.8,0.0,85.0,0.0,2.0
666
- 2026-03-21 20:00:00+00:00,10.75,10.8,10.7,0.0,86.0,0.0,1.9500000000000002
667
- 2026-03-21 20:15:00+00:00,10.7,10.7,10.7,0.0,86.0,0.0,1.9
668
- 2026-03-21 20:30:00+00:00,10.8,10.95,10.7,0.0,85.0,0.0,1.5499999999999998
669
- 2026-03-21 20:45:00+00:00,10.8,11.0,10.6,0.0,86.0,0.0,1.4
670
- 2026-03-21 21:00:00+00:00,10.8,10.9,10.7,0.0,84.5,0.0,1.75
671
- 2026-03-21 21:15:00+00:00,10.8,10.8,10.7,0.0,85.0,0.1,2.1
672
- 2026-03-21 21:30:00+00:00,10.649999999999999,10.75,10.5,0.0,87.0,0.15,2.45
673
- 2026-03-21 21:45:00+00:00,10.5,10.5,10.4,0.0,89.0,0.2,3.0
 
1
  timestamp_utc,air_temperature_c,tdmax_c,tdmin_c,ghi_w_m2,rh_percent,rain_mm,wind_speed_ms
2
+ 2026-05-16 21:00:00+00:00,17.15,17.25,17.05,0.0,88.5,0.0,0.95
3
+ 2026-05-16 21:15:00+00:00,16.9,17.1,16.7,0.0,89.0,0.0,0.1
4
+ 2026-05-16 21:30:00+00:00,16.55,16.65,16.55,0.0,89.5,0.0,0.6499999999999999
5
+ 2026-05-16 21:45:00+00:00,16.4,16.5,16.4,0.0,90.0,0.0,1.0
6
+ 2026-05-16 22:00:00+00:00,16.5,16.55,16.35,0.0,90.5,0.0,0.3
7
+ 2026-05-16 22:15:00+00:00,16.2,16.3,16.1,0.0,91.0,0.0,0.4
8
+ 2026-05-16 22:30:00+00:00,16.15,16.25,16.1,0.0,91.0,0.0,0.2
9
+ 2026-05-16 22:45:00+00:00,16.1,16.2,16.0,0.0,92.0,0.0,0.9
10
+ 2026-05-16 23:00:00+00:00,15.9,16.0,15.8,0.0,92.0,0.0,1.4500000000000002
11
+ 2026-05-16 23:15:00+00:00,16.2,16.3,16.0,0.0,91.0,0.0,1.4
12
+ 2026-05-16 23:30:00+00:00,16.7,16.9,16.5,0.0,89.5,0.0,2.25
13
+ 2026-05-16 23:45:00+00:00,17.0,17.0,16.9,0.0,88.0,0.0,1.4
14
+ 2026-05-17 00:00:00+00:00,16.95,17.05,16.9,0.0,88.0,0.0,3.5999999999999996
15
+ 2026-05-17 00:15:00+00:00,16.9,17.0,16.8,0.0,88.0,0.0,4.4
16
+ 2026-05-17 00:30:00+00:00,16.9,17.0,16.9,0.0,87.0,0.0,3.9000000000000004
17
+ 2026-05-17 00:45:00+00:00,16.9,17.0,16.9,0.0,87.0,0.0,4.2
18
+ 2026-05-17 01:00:00+00:00,16.9,16.95,16.8,0.0,87.0,0.0,2.45
19
+ 2026-05-17 01:15:00+00:00,16.9,17.0,16.9,0.0,86.0,0.0,2.5
20
+ 2026-05-17 01:30:00+00:00,17.05,17.2,17.0,0.0,85.5,0.0,3.3
21
+ 2026-05-17 01:45:00+00:00,17.0,17.0,16.9,5.0,87.0,0.0,1.8
22
+ 2026-05-17 02:00:00+00:00,16.95,17.0,16.85,23.5,87.5,0.0,2.2
23
+ 2026-05-17 02:15:00+00:00,17.1,17.2,17.0,51.0,87.0,0.0,2.6
24
+ 2026-05-17 02:30:00+00:00,17.5,17.6,17.35,86.0,84.5,0.0,2.4
25
+ 2026-05-17 02:45:00+00:00,17.8,17.9,17.7,129.0,82.0,0.0,3.5
26
+ 2026-05-17 03:00:00+00:00,18.05,18.3,17.85,178.5,79.5,0.0,1.55
27
+ 2026-05-17 03:15:00+00:00,17.6,17.9,17.6,230.0,84.0,0.0,1.4
28
+ 2026-05-17 03:30:00+00:00,18.2,18.450000000000003,17.95,283.5,81.5,0.0,0.9
29
+ 2026-05-17 03:45:00+00:00,18.4,18.6,18.2,330.0,80.0,0.0,1.7
30
+ 2026-05-17 04:00:00+00:00,18.75,19.0,18.55,382.5,78.0,0.0,1.9
31
+ 2026-05-17 04:15:00+00:00,19.4,19.6,19.1,436.0,75.0,0.0,2.4
32
+ 2026-05-17 04:30:00+00:00,19.55,19.700000000000003,19.45,477.0,74.0,0.0,2.75
33
+ 2026-05-17 04:45:00+00:00,20.0,20.1,19.8,519.0,72.0,0.0,2.9
34
+ 2026-05-17 05:00:00+00:00,20.6,20.9,20.3,563.0,68.0,0.0,2.6
35
+ 2026-05-17 05:15:00+00:00,21.3,21.5,21.2,600.0,63.0,0.0,3.1
36
+ 2026-05-17 05:30:00+00:00,21.75,21.9,21.55,637.5,59.5,0.0,3.1500000000000004
37
+ 2026-05-17 05:45:00+00:00,22.0,22.1,21.8,667.0,55.0,0.0,3.4
38
+ 2026-05-17 06:00:00+00:00,22.65,22.9,22.25,710.5,51.0,0.0,3.3
39
+ 2026-05-17 06:15:00+00:00,23.2,23.4,23.0,751.0,47.0,0.0,4.0
40
+ 2026-05-17 06:30:00+00:00,23.700000000000003,23.85,23.4,781.5,41.5,0.0,3.75
41
+ 2026-05-17 06:45:00+00:00,24.2,24.4,23.9,811.0,39.0,0.0,3.9
42
+ 2026-05-17 07:00:00+00:00,24.65,24.9,24.2,848.0,37.0,0.0,4.15
43
+ 2026-05-17 07:15:00+00:00,24.9,25.1,24.7,884.0,35.0,0.0,4.0
44
+ 2026-05-17 07:30:00+00:00,25.75,26.0,25.4,910.5,33.5,0.0,3.4
45
+ 2026-05-17 07:45:00+00:00,26.2,26.3,26.0,934.0,33.0,0.0,3.3
46
+ 2026-05-17 08:00:00+00:00,26.3,26.7,26.049999999999997,949.5,33.5,0.0,3.65
47
+ 2026-05-17 08:15:00+00:00,27.1,27.3,27.0,960.0,33.0,0.0,3.2
48
+ 2026-05-17 08:30:00+00:00,27.6,28.0,27.200000000000003,961.0,34.0,0.0,2.2
49
+ 2026-05-17 08:45:00+00:00,27.9,28.4,27.2,952.0,37.0,0.0,2.1
50
+ 2026-05-17 09:00:00+00:00,27.75,28.299999999999997,27.1,948.0,38.5,0.0,1.95
51
+ 2026-05-17 09:15:00+00:00,27.0,27.2,26.9,940.0,43.0,0.0,3.9
52
+ 2026-05-17 09:30:00+00:00,26.9,27.1,26.75,934.5,42.0,0.0,3.8499999999999996
53
+ 2026-05-17 09:45:00+00:00,26.7,27.0,26.4,926.0,39.0,0.0,4.5
54
+ 2026-05-17 10:00:00+00:00,27.45,27.700000000000003,27.1,907.5,33.0,0.0,3.65
55
+ 2026-05-17 10:15:00+00:00,27.8,27.9,27.5,883.0,31.0,0.0,3.3
56
+ 2026-05-17 10:30:00+00:00,28.5,28.85,28.2,858.5,28.0,0.0,2.7
57
+ 2026-05-17 10:45:00+00:00,28.8,29.1,28.4,830.0,27.0,0.0,2.7
58
+ 2026-05-17 11:00:00+00:00,29.1,29.35,28.799999999999997,796.0,26.0,0.0,2.6500000000000004
59
+ 2026-05-17 11:15:00+00:00,28.8,29.1,28.6,761.0,25.0,0.0,3.3
60
+ 2026-05-17 11:30:00+00:00,28.85,29.15,28.6,720.0,23.5,0.0,3.35
61
+ 2026-05-17 11:45:00+00:00,29.3,29.6,29.0,673.0,23.0,0.0,2.4
62
+ 2026-05-17 12:00:00+00:00,29.5,29.75,29.35,631.0,22.5,0.0,2.8
63
+ 2026-05-17 12:15:00+00:00,29.3,29.5,29.2,583.0,22.0,0.0,3.5
64
+ 2026-05-17 12:30:00+00:00,29.6,29.7,29.45,527.0,22.5,0.0,2.5
65
+ 2026-05-17 12:45:00+00:00,29.6,29.7,29.5,475.0,23.0,0.0,2.6
66
+ 2026-05-17 13:00:00+00:00,29.95,30.15,29.6,415.5,22.5,0.0,1.85
67
+ 2026-05-17 13:15:00+00:00,30.0,30.3,29.8,347.0,22.0,0.0,2.2
68
+ 2026-05-17 13:30:00+00:00,30.1,30.2,29.9,315.0,23.0,0.0,1.7000000000000002
69
+ 2026-05-17 13:45:00+00:00,30.3,30.4,30.1,253.0,23.0,0.0,1.2
70
+ 2026-05-17 14:00:00+00:00,30.05,30.15,29.95,205.5,23.0,0.0,1.15
71
+ 2026-05-17 14:15:00+00:00,30.0,30.2,29.9,152.0,24.0,0.0,1.0
72
+ 2026-05-17 14:30:00+00:00,29.549999999999997,29.85,29.15,105.5,27.0,0.0,0.7
73
+ 2026-05-17 14:45:00+00:00,28.5,28.7,28.4,62.0,31.0,0.0,0.6
74
+ 2026-05-17 15:00:00+00:00,28.200000000000003,28.299999999999997,28.0,36.0,30.5,0.0,0.45
75
+ 2026-05-17 15:15:00+00:00,27.5,27.9,27.3,15.0,31.0,0.0,0.5
76
+ 2026-05-17 15:30:00+00:00,26.65,27.0,26.25,1.5,32.0,0.0,0.65
77
+ 2026-05-17 15:45:00+00:00,26.2,26.4,25.9,0.0,32.0,0.0,1.4
78
+ 2026-05-17 16:00:00+00:00,26.75,27.200000000000003,26.25,0.0,29.5,0.0,2.0999999999999996
79
+ 2026-05-17 16:15:00+00:00,27.8,28.0,27.3,0.0,27.0,0.0,2.5
80
+ 2026-05-17 16:30:00+00:00,28.3,28.55,27.950000000000003,0.0,26.5,0.0,3.35
81
+ 2026-05-17 16:45:00+00:00,28.9,29.1,28.7,0.0,26.0,0.0,3.5
82
+ 2026-05-17 17:00:00+00:00,28.85,29.0,28.6,0.0,26.5,0.0,3.4000000000000004
83
+ 2026-05-17 17:15:00+00:00,29.1,29.2,28.9,0.0,27.0,0.0,3.1
84
+ 2026-05-17 17:30:00+00:00,29.1,29.25,29.0,0.0,27.0,0.0,3.3
85
+ 2026-05-17 17:45:00+00:00,29.3,29.4,29.1,0.0,27.0,0.0,3.6
86
+ 2026-05-17 18:00:00+00:00,29.45,29.5,29.35,0.0,27.0,0.0,3.8499999999999996
87
+ 2026-05-17 18:15:00+00:00,29.1,29.3,29.0,0.0,28.0,0.0,3.9
88
+ 2026-05-17 18:30:00+00:00,28.700000000000003,28.85,28.55,0.0,29.0,0.0,3.65
89
+ 2026-05-17 18:45:00+00:00,28.4,28.6,28.3,0.0,29.0,0.0,3.0
90
+ 2026-05-17 19:00:00+00:00,28.4,28.55,28.3,0.0,29.0,0.0,3.8499999999999996
91
+ 2026-05-17 19:15:00+00:00,28.2,28.4,28.0,0.0,29.0,0.0,4.6
92
+ 2026-05-17 19:30:00+00:00,27.799999999999997,27.9,27.65,0.0,30.5,0.0,5.0
93
+ 2026-05-17 19:45:00+00:00,27.4,27.6,27.3,0.0,32.0,0.0,5.1
94
+ 2026-05-17 20:00:00+00:00,26.950000000000003,27.05,26.700000000000003,0.0,34.5,0.0,5.15
95
+ 2026-05-17 20:15:00+00:00,26.5,26.7,26.3,0.0,35.0,0.0,5.0
96
+ 2026-05-17 20:30:00+00:00,26.45,26.5,26.35,0.0,35.0,0.0,5.1
97
+ 2026-05-17 20:45:00+00:00,26.5,26.6,26.4,0.0,35.0,0.0,5.5
98
+ 2026-05-17 21:00:00+00:00,26.6,26.65,26.5,0.0,35.0,0.0,4.5
99
+ 2026-05-17 21:15:00+00:00,26.9,27.1,26.6,0.0,34.0,0.0,4.3
100
+ 2026-05-17 21:30:00+00:00,26.85,27.05,26.7,0.0,34.0,0.0,3.5999999999999996
101
+ 2026-05-17 21:45:00+00:00,26.8,26.9,26.6,0.0,34.0,0.0,3.6
102
+ 2026-05-17 22:00:00+00:00,27.05,27.15,26.9,0.0,32.0,0.0,4.9
103
+ 2026-05-17 22:15:00+00:00,26.9,27.0,26.8,0.0,31.0,0.0,4.4
104
+ 2026-05-17 22:30:00+00:00,27.1,27.25,26.9,0.0,29.5,0.0,5.5
105
+ 2026-05-17 22:45:00+00:00,27.3,27.4,27.1,0.0,29.0,0.0,6.3
106
+ 2026-05-17 23:00:00+00:00,27.25,27.4,27.1,0.0,27.5,0.0,5.0
107
+ 2026-05-17 23:15:00+00:00,27.0,27.3,26.9,0.0,29.0,0.0,3.2
108
+ 2026-05-17 23:30:00+00:00,26.6,26.75,26.55,0.0,33.0,0.0,4.75
109
+ 2026-05-17 23:45:00+00:00,26.3,26.5,26.0,0.0,36.0,0.0,5.9
110
+ 2026-05-18 00:00:00+00:00,25.8,25.950000000000003,25.7,0.0,38.0,0.0,6.0
111
+ 2026-05-18 00:15:00+00:00,25.7,25.8,25.6,0.0,39.0,0.0,5.8
112
+ 2026-05-18 00:30:00+00:00,25.450000000000003,25.6,25.299999999999997,0.0,39.5,0.0,4.15
113
+ 2026-05-18 00:45:00+00:00,24.9,25.2,24.6,0.0,42.0,0.0,3.8
114
+ 2026-05-18 01:00:00+00:00,23.9,24.25,23.6,0.0,48.0,0.0,4.199999999999999
115
+ 2026-05-18 01:15:00+00:00,23.3,23.4,23.2,0.0,52.0,0.0,3.6
116
+ 2026-05-18 01:30:00+00:00,22.85,23.1,22.6,0.0,59.5,0.0,3.3
117
+ 2026-05-18 01:45:00+00:00,22.4,22.5,22.3,1.0,65.0,0.0,3.3
118
+ 2026-05-18 02:00:00+00:00,22.2,22.3,22.2,10.0,64.0,0.0,2.2
119
+ 2026-05-18 02:15:00+00:00,22.4,22.5,22.3,20.0,61.0,0.0,1.3
120
+ 2026-05-18 02:30:00+00:00,22.25,22.450000000000003,22.2,14.5,63.0,0.0,1.35
121
+ 2026-05-18 02:45:00+00:00,22.5,22.7,22.2,30.0,56.0,0.0,1.8
122
+ 2026-05-18 03:00:00+00:00,23.35,23.6,23.049999999999997,74.0,43.0,0.0,1.6
123
+ 2026-05-18 03:15:00+00:00,24.0,24.2,23.8,113.0,34.0,0.0,1.7
124
+ 2026-05-18 03:30:00+00:00,24.1,24.299999999999997,23.8,111.5,43.5,0.0,1.2000000000000002
125
+ 2026-05-18 03:45:00+00:00,23.6,23.8,23.4,163.0,56.0,0.0,0.0
126
+ 2026-05-18 04:00:00+00:00,24.25,24.450000000000003,24.05,196.0,49.5,0.0,0.44999999999999996
127
+ 2026-05-18 04:15:00+00:00,24.6,24.8,24.2,335.0,50.0,0.0,1.1
128
+ 2026-05-18 04:30:00+00:00,24.9,25.049999999999997,24.549999999999997,291.0,40.0,0.0,1.35
129
+ 2026-05-18 04:45:00+00:00,25.3,25.5,25.1,474.0,38.0,0.0,1.4
130
+ 2026-05-18 05:00:00+00:00,25.2,25.35,25.0,300.5,37.5,0.0,1.75
131
+ 2026-05-18 05:15:00+00:00,25.2,25.6,25.0,517.0,37.0,0.0,1.6
132
+ 2026-05-18 05:30:00+00:00,25.5,25.65,25.35,622.0,39.0,0.0,1.75
133
+ 2026-05-18 05:45:00+00:00,26.4,27.1,25.5,713.0,36.0,0.0,0.5
134
+ 2026-05-18 06:00:00+00:00,27.4,27.7,27.0,576.5,32.5,0.0,1.05
135
+ 2026-05-18 06:15:00+00:00,26.8,27.0,26.6,479.0,29.0,0.0,0.9
136
+ 2026-05-18 06:30:00+00:00,27.0,27.450000000000003,26.15,650.5,31.5,0.0,1.45
137
+ 2026-05-18 06:45:00+00:00,26.0,26.9,25.4,547.0,47.0,0.0,2.4
138
+ 2026-05-18 07:00:00+00:00,25.8,26.15,25.299999999999997,431.0,46.5,0.0,2.1500000000000004
139
+ 2026-05-18 07:15:00+00:00,25.5,25.9,25.1,463.0,48.0,0.0,2.4
140
+ 2026-05-18 07:30:00+00:00,26.6,26.950000000000003,25.950000000000003,480.5,33.0,0.0,1.4
141
+ 2026-05-18 07:45:00+00:00,27.7,27.9,27.4,769.0,23.0,0.0,1.2
142
+ 2026-05-18 08:00:00+00:00,28.2,28.450000000000003,27.95,890.0,20.5,0.0,1.8
143
+ 2026-05-18 08:15:00+00:00,28.9,29.4,28.5,960.0,20.0,0.0,1.5
144
+ 2026-05-18 08:30:00+00:00,28.8,29.299999999999997,28.5,963.5,20.5,0.0,2.15
145
+ 2026-05-18 08:45:00+00:00,28.3,29.0,27.2,984.0,28.0,0.0,2.3
146
+ 2026-05-18 09:00:00+00:00,27.85,28.1,27.45,981.5,27.0,0.0,2.0
147
+ 2026-05-18 09:15:00+00:00,27.8,28.3,27.5,945.0,26.0,0.0,1.8
148
+ 2026-05-18 09:30:00+00:00,27.950000000000003,28.45,27.35,968.0,27.0,0.0,2.8
149
+ 2026-05-18 09:45:00+00:00,28.0,28.3,27.6,961.0,27.0,0.0,2.4
150
+ 2026-05-18 10:00:00+00:00,27.85,28.299999999999997,27.55,907.5,31.0,0.0,2.75
151
+ 2026-05-18 10:15:00+00:00,26.8,27.3,26.7,839.0,34.0,0.0,3.7
152
+ 2026-05-18 10:30:00+00:00,26.85,27.1,26.65,864.0,36.5,0.0,3.25
153
+ 2026-05-18 10:45:00+00:00,27.1,27.3,26.6,814.0,35.0,0.0,3.2
154
+ 2026-05-18 11:00:00+00:00,26.200000000000003,26.700000000000003,25.85,527.0,40.0,0.0,3.0999999999999996
155
+ 2026-05-18 11:15:00+00:00,26.0,26.3,25.8,700.0,39.0,0.0,3.1
156
+ 2026-05-18 11:30:00+00:00,26.2,26.45,25.85,470.5,40.0,0.0,2.4
157
+ 2026-05-18 11:45:00+00:00,25.9,26.6,25.2,357.0,44.0,0.0,2.3
158
+ 2026-05-18 12:00:00+00:00,25.3,25.95,24.75,271.5,48.5,0.0,2.7
159
+ 2026-05-18 12:15:00+00:00,24.4,24.6,24.2,219.0,57.0,0.0,2.1
160
+ 2026-05-18 12:30:00+00:00,23.6,24.1,23.0,232.0,62.5,0.0,3.0
161
+ 2026-05-18 12:45:00+00:00,23.2,23.5,23.0,241.0,66.0,0.0,2.9
162
+ 2026-05-18 13:00:00+00:00,22.6,22.85,22.4,184.0,65.5,0.0,3.0
163
+ 2026-05-18 13:15:00+00:00,22.6,22.8,22.4,167.0,68.0,0.0,2.6
164
+ 2026-05-18 13:30:00+00:00,22.35,22.5,22.2,155.5,70.5,0.0,2.85
165
+ 2026-05-18 13:45:00+00:00,21.7,22.0,21.6,78.0,71.0,0.0,3.2
166
+ 2026-05-18 14:00:00+00:00,21.75,21.9,21.65,78.5,69.5,0.0,2.1500000000000004
167
+ 2026-05-18 14:15:00+00:00,21.5,22.1,21.2,69.0,72.0,0.0,1.5
168
+ 2026-05-18 14:30:00+00:00,21.4,21.5,21.35,51.5,72.0,0.0,2.5
169
+ 2026-05-18 14:45:00+00:00,21.2,21.3,21.1,33.0,74.0,0.0,3.0
170
+ 2026-05-18 15:00:00+00:00,21.05,21.200000000000003,20.9,14.5,75.5,0.0,2.2
171
+ 2026-05-18 15:15:00+00:00,20.9,21.1,20.7,2.0,77.0,0.0,2.2
172
+ 2026-05-18 15:30:00+00:00,20.700000000000003,20.8,20.65,0.0,78.5,0.0,1.95
173
+ 2026-05-18 15:45:00+00:00,20.6,20.8,20.5,0.0,81.0,0.0,2.1
174
+ 2026-05-18 16:00:00+00:00,20.55,20.55,20.45,0.0,80.5,0.0,1.4500000000000002
175
+ 2026-05-18 16:15:00+00:00,20.7,20.8,20.6,0.0,81.0,0.0,0.5
176
+ 2026-05-18 16:30:00+00:00,20.5,20.700000000000003,20.3,0.0,82.5,0.0,2.25
177
+ 2026-05-18 16:45:00+00:00,20.4,20.6,20.3,0.0,82.0,0.0,1.4
178
+ 2026-05-18 17:00:00+00:00,20.299999999999997,20.45,20.2,0.0,84.0,0.0,2.6
179
+ 2026-05-18 17:15:00+00:00,20.3,20.4,20.2,0.0,85.0,0.0,2.5
180
+ 2026-05-18 17:30:00+00:00,20.1,20.15,20.0,0.0,86.5,0.0,3.8
181
+ 2026-05-18 17:45:00+00:00,20.0,20.0,20.0,0.0,86.0,0.0,2.8
182
+ 2026-05-18 18:00:00+00:00,20.0,20.1,20.0,0.0,85.5,0.0,2.6500000000000004
183
+ 2026-05-18 18:15:00+00:00,20.0,20.1,19.8,0.0,86.0,0.0,1.3
184
+ 2026-05-18 18:30:00+00:00,19.85,19.95,19.75,0.0,85.5,0.0,1.9
185
+ 2026-05-18 18:45:00+00:00,19.7,19.8,19.6,0.0,87.0,0.0,1.4
186
+ 2026-05-18 19:00:00+00:00,19.55,19.6,19.45,0.0,86.0,0.0,1.25
187
+ 2026-05-18 19:15:00+00:00,19.5,19.5,19.4,0.0,86.0,0.0,1.8
188
+ 2026-05-18 19:30:00+00:00,19.6,19.65,19.5,0.0,86.5,0.0,1.95
189
+ 2026-05-18 19:45:00+00:00,19.5,19.6,19.5,0.0,89.0,0.0,2.9
190
+ 2026-05-18 20:00:00+00:00,19.4,19.45,19.3,0.0,87.5,0.0,4.05
191
+ 2026-05-18 20:15:00+00:00,19.3,19.5,19.2,0.0,87.0,0.0,5.2
192
+ 2026-05-18 20:30:00+00:00,19.25,19.35,19.2,0.0,85.5,0.0,3.75
193
+ 2026-05-18 20:45:00+00:00,19.2,19.3,19.1,0.0,85.0,0.0,3.1
194
+ 2026-05-18 21:00:00+00:00,19.2,19.3,19.15,0.0,85.5,0.0,2.6500000000000004
195
+ 2026-05-18 21:15:00+00:00,19.1,19.2,19.0,0.0,86.0,0.0,3.1
196
+ 2026-05-18 21:30:00+00:00,19.05,19.15,18.95,0.0,86.0,0.0,3.4000000000000004
197
+ 2026-05-18 21:45:00+00:00,19.0,19.1,19.0,0.0,87.0,0.0,4.6
198
+ 2026-05-18 22:00:00+00:00,18.95,19.05,18.95,0.0,88.0,0.0,4.7
199
+ 2026-05-18 22:15:00+00:00,18.8,19.0,18.7,0.0,89.0,0.0,4.9
200
+ 2026-05-18 22:30:00+00:00,18.85,18.95,18.75,0.0,86.5,0.0,5.15
201
+ 2026-05-18 22:45:00+00:00,18.8,18.9,18.7,0.0,87.0,0.0,4.9
202
+ 2026-05-18 23:00:00+00:00,18.8,18.85,18.7,0.0,87.5,0.0,4.85
203
+ 2026-05-18 23:15:00+00:00,18.6,18.8,18.6,0.0,90.0,0.0,4.0
204
+ 2026-05-18 23:30:00+00:00,18.45,18.55,18.4,0.0,90.0,0.0,4.55
205
+ 2026-05-18 23:45:00+00:00,18.4,18.5,18.3,0.0,90.0,0.0,4.4
206
+ 2026-05-19 00:00:00+00:00,18.35,18.4,18.25,0.0,89.5,0.0,2.55
207
+ 2026-05-19 00:15:00+00:00,18.3,18.4,18.2,0.0,90.0,0.0,0.7
208
+ 2026-05-19 00:30:00+00:00,18.4,18.45,18.35,0.0,90.0,0.0,2.3
209
+ 2026-05-19 00:45:00+00:00,18.4,18.5,18.3,0.0,89.0,0.0,3.4
210
+ 2026-05-19 01:00:00+00:00,18.5,18.5,18.35,0.0,88.5,0.0,3.0999999999999996
211
+ 2026-05-19 01:15:00+00:00,18.4,18.5,18.4,0.0,89.0,0.0,2.7
212
+ 2026-05-19 01:30:00+00:00,18.35,18.4,18.3,2.5,88.0,0.0,2.8
213
+ 2026-05-19 01:45:00+00:00,18.3,18.4,18.2,8.0,87.0,0.0,2.0
214
+ 2026-05-19 02:00:00+00:00,18.3,18.3,18.2,21.5,85.5,0.0,2.45
215
+ 2026-05-19 02:15:00+00:00,18.3,18.5,18.2,54.0,84.0,0.0,3.8
216
+ 2026-05-19 02:30:00+00:00,18.55,18.6,18.4,73.0,85.5,0.0,2.65
217
+ 2026-05-19 02:45:00+00:00,18.6,18.7,18.6,96.0,87.0,0.0,0.7
218
+ 2026-05-19 03:00:00+00:00,18.8,18.9,18.7,117.5,84.5,0.0,2.15
219
+ 2026-05-19 03:15:00+00:00,18.9,19.0,18.8,134.0,82.0,0.0,2.4
220
+ 2026-05-19 03:30:00+00:00,19.35,19.5,19.1,256.0,77.5,0.0,1.9
221
+ 2026-05-19 03:45:00+00:00,19.9,20.0,19.8,238.0,74.0,0.0,1.7
222
+ 2026-05-19 04:00:00+00:00,19.950000000000003,20.25,19.75,333.5,73.0,0.0,2.1
223
+ 2026-05-19 04:15:00+00:00,20.0,20.3,19.8,329.0,71.0,0.0,2.3
224
+ 2026-05-19 04:30:00+00:00,20.55,20.7,20.3,426.0,71.0,0.0,1.75
225
+ 2026-05-19 04:45:00+00:00,20.4,20.9,20.1,460.0,71.0,0.0,2.8
226
+ 2026-05-19 05:00:00+00:00,20.45,20.65,20.2,419.0,68.5,0.0,1.9
227
+ 2026-05-19 05:15:00+00:00,20.8,20.9,20.7,340.0,68.0,0.0,2.1
228
+ 2026-05-19 05:30:00+00:00,21.25,21.45,20.9,524.5,67.0,0.0,2.0
229
+ 2026-05-19 05:45:00+00:00,21.0,21.6,20.8,751.0,68.0,0.0,2.2
230
+ 2026-05-19 06:00:00+00:00,21.25,21.75,20.799999999999997,815.0,66.0,0.0,2.8
231
+ 2026-05-19 06:15:00+00:00,21.6,21.9,21.2,747.0,66.0,0.0,3.3
232
+ 2026-05-19 06:30:00+00:00,21.3,21.5,21.049999999999997,819.5,66.0,0.0,3.2
233
+ 2026-05-19 06:45:00+00:00,21.2,21.5,21.0,623.0,64.0,0.0,3.0
234
+ 2026-05-19 07:00:00+00:00,22.0,22.35,21.65,805.0,64.5,0.0,3.1
235
+ 2026-05-19 07:15:00+00:00,22.4,22.8,22.2,744.0,64.0,0.0,3.5
236
+ 2026-05-19 07:30:00+00:00,21.75,22.1,21.45,968.0,64.5,0.0,4.3
237
+ 2026-05-19 07:45:00+00:00,21.7,22.3,21.2,736.0,65.0,0.0,3.7
238
+ 2026-05-19 08:00:00+00:00,21.9,22.200000000000003,21.7,952.5,66.5,0.0,4.7
239
+ 2026-05-19 08:15:00+00:00,22.0,22.1,21.9,1040.0,65.0,0.0,3.9
240
+ 2026-05-19 08:30:00+00:00,22.1,22.25,21.9,1032.5,66.0,0.0,4.05
241
+ 2026-05-19 08:45:00+00:00,22.2,22.5,22.0,1044.0,62.0,0.0,4.9
242
+ 2026-05-19 09:00:00+00:00,22.200000000000003,22.450000000000003,22.0,1018.0,65.0,0.0,4.85
243
+ 2026-05-19 09:15:00+00:00,21.9,22.2,21.7,1066.0,67.0,0.0,4.1
244
+ 2026-05-19 09:30:00+00:00,21.9,22.2,21.6,759.5,65.5,0.0,4.449999999999999
245
+ 2026-05-19 09:45:00+00:00,22.0,22.4,21.4,1081.0,66.0,0.0,3.2
246
+ 2026-05-19 10:00:00+00:00,22.2,22.65,21.8,908.0,63.0,0.0,3.9000000000000004
247
+ 2026-05-19 10:15:00+00:00,21.7,21.9,21.5,969.0,64.0,0.0,6.3
248
+ 2026-05-19 10:30:00+00:00,22.0,22.4,21.700000000000003,887.5,63.0,0.0,5.6
249
+ 2026-05-19 10:45:00+00:00,22.4,22.6,22.1,919.0,61.0,0.0,4.9
250
+ 2026-05-19 11:00:00+00:00,22.0,22.15,21.85,861.5,61.5,0.0,5.2
251
+ 2026-05-19 11:15:00+00:00,22.2,22.5,21.8,835.0,61.0,0.0,4.6
252
+ 2026-05-19 11:30:00+00:00,22.85,23.15,22.549999999999997,798.0,57.0,0.0,3.75
253
+ 2026-05-19 11:45:00+00:00,22.9,23.1,22.7,734.0,55.0,0.0,4.4
254
+ 2026-05-19 12:00:00+00:00,22.2,22.549999999999997,21.95,719.5,59.0,0.0,4.85
255
+ 2026-05-19 12:15:00+00:00,21.7,21.9,21.6,675.0,63.0,0.0,5.3
256
+ 2026-05-19 12:30:00+00:00,22.1,22.299999999999997,21.799999999999997,582.5,63.0,0.0,3.5999999999999996
257
+ 2026-05-19 12:45:00+00:00,22.0,22.1,21.7,541.0,65.0,0.0,4.0
258
+ 2026-05-19 13:00:00+00:00,21.25,21.549999999999997,21.1,498.0,70.5,0.0,4.45
259
+ 2026-05-19 13:15:00+00:00,21.4,21.7,21.0,441.0,69.0,0.0,2.2
260
+ 2026-05-19 13:30:00+00:00,22.05,22.2,21.7,313.0,62.5,0.0,3.0999999999999996
261
+ 2026-05-19 13:45:00+00:00,21.5,21.7,21.1,312.0,67.0,0.0,2.9
262
+ 2026-05-19 14:00:00+00:00,20.7,20.950000000000003,20.450000000000003,238.0,73.5,0.0,4.1
263
+ 2026-05-19 14:15:00+00:00,20.2,20.5,19.9,202.0,77.0,0.0,3.3
264
+ 2026-05-19 14:30:00+00:00,19.95,20.1,19.799999999999997,105.0,79.0,0.0,2.6
265
+ 2026-05-19 14:45:00+00:00,19.8,19.9,19.7,59.0,80.0,0.0,3.2
266
+ 2026-05-19 15:00:00+00:00,19.6,19.700000000000003,19.5,31.0,81.0,0.0,3.55
267
+ 2026-05-19 15:15:00+00:00,19.4,19.4,19.3,16.0,82.0,0.0,3.8
268
+ 2026-05-19 15:30:00+00:00,19.25,19.35,19.15,3.5,82.5,0.0,3.3
269
+ 2026-05-19 15:45:00+00:00,19.0,19.1,19.0,0.0,83.0,0.0,2.3
270
+ 2026-05-19 16:00:00+00:00,18.95,19.0,18.9,0.0,85.0,0.0,2.15
271
+ 2026-05-19 16:15:00+00:00,18.9,18.9,18.8,0.0,85.0,0.0,2.3
272
+ 2026-05-19 16:30:00+00:00,18.7,18.75,18.6,0.0,86.0,0.0,3.0
273
+ 2026-05-19 16:45:00+00:00,18.6,18.7,18.6,0.0,86.0,0.0,2.8
274
+ 2026-05-19 17:00:00+00:00,18.6,18.65,18.55,0.0,86.0,0.0,1.8
275
+ 2026-05-19 17:15:00+00:00,18.6,18.6,18.5,0.0,85.0,0.0,0.8
276
+ 2026-05-19 17:30:00+00:00,18.6,18.6,18.55,0.0,86.0,0.0,0.7
277
+ 2026-05-19 17:45:00+00:00,18.5,18.5,18.4,0.0,86.0,0.0,1.0
278
+ 2026-05-19 18:00:00+00:00,18.45,18.5,18.4,0.0,87.0,0.0,1.8
279
+ 2026-05-19 18:15:00+00:00,18.5,18.5,18.4,0.0,87.0,0.0,2.1
280
+ 2026-05-19 18:30:00+00:00,18.45,18.5,18.4,0.0,87.0,0.0,1.9500000000000002
281
+ 2026-05-19 18:45:00+00:00,18.4,18.5,18.4,0.0,87.0,0.0,1.1
282
+ 2026-05-19 19:00:00+00:00,18.45,18.5,18.35,0.0,86.5,0.0,0.8500000000000001
283
+ 2026-05-19 19:15:00+00:00,18.3,18.4,18.2,0.0,87.0,0.0,0.7
284
+ 2026-05-19 19:30:00+00:00,18.049999999999997,18.15,17.95,0.0,87.0,0.0,0.65
285
+ 2026-05-19 19:45:00+00:00,18.0,18.0,18.0,0.0,88.0,0.0,0.6
286
+ 2026-05-19 20:00:00+00:00,18.05,18.2,18.0,0.0,87.5,0.0,0.4
287
+ 2026-05-19 20:15:00+00:00,18.0,18.1,17.9,0.0,88.0,0.0,0.7
288
+ 2026-05-19 20:30:00+00:00,17.95,18.1,17.85,0.0,88.0,0.0,0.6000000000000001
289
+ 2026-05-19 20:45:00+00:00,18.2,18.2,18.1,0.0,87.0,0.0,1.4
290
+ 2026-05-19 21:00:00+00:00,18.2,18.3,18.15,0.0,87.0,0.0,1.2000000000000002
291
+ 2026-05-19 21:15:00+00:00,18.2,18.3,18.2,0.0,88.0,0.0,1.2
292
+ 2026-05-19 21:30:00+00:00,18.299999999999997,18.35,18.25,0.0,87.0,0.0,2.0
293
+ 2026-05-19 21:45:00+00:00,18.4,18.5,18.3,0.0,86.0,0.0,4.9
294
+ 2026-05-19 22:00:00+00:00,18.45,18.5,18.4,0.0,86.5,0.0,4.85
295
+ 2026-05-19 22:15:00+00:00,18.5,18.5,18.4,0.0,87.0,0.0,4.2
296
+ 2026-05-19 22:30:00+00:00,18.45,18.5,18.4,0.0,87.0,0.0,4.15
297
+ 2026-05-19 22:45:00+00:00,18.5,18.5,18.4,0.0,87.0,0.0,2.5
298
+ 2026-05-19 23:00:00+00:00,18.3,18.4,18.2,0.0,86.0,0.0,1.5499999999999998
299
+ 2026-05-19 23:15:00+00:00,18.2,18.3,18.2,0.0,87.0,0.0,2.2
300
+ 2026-05-19 23:30:00+00:00,18.3,18.35,18.2,0.0,86.0,0.0,0.85
301
+ 2026-05-19 23:45:00+00:00,18.4,18.5,18.3,0.0,86.0,0.0,1.4
302
+ 2026-05-20 00:00:00+00:00,18.5,18.6,18.45,0.0,87.0,0.0,2.2
303
+ 2026-05-20 00:15:00+00:00,18.6,18.6,18.5,0.0,88.0,0.0,2.7
304
+ 2026-05-20 00:30:00+00:00,18.55,18.6,18.45,0.0,86.0,0.0,1.85
305
+ 2026-05-20 00:45:00+00:00,18.4,18.5,18.3,0.0,87.0,0.0,1.0
306
+ 2026-05-20 01:00:00+00:00,18.4,18.5,18.35,0.0,87.5,0.0,1.75
307
+ 2026-05-20 01:15:00+00:00,18.4,18.5,18.4,0.0,88.0,0.0,0.8
308
+ 2026-05-20 01:30:00+00:00,18.35,18.45,18.35,0.0,89.0,0.0,0.65
309
+ 2026-05-20 01:45:00+00:00,18.3,18.3,18.2,5.0,89.0,0.0,1.3
310
+ 2026-05-20 02:00:00+00:00,18.2,18.25,18.15,27.5,88.5,0.0,1.05
311
+ 2026-05-20 02:15:00+00:00,18.3,18.4,18.2,68.0,88.0,0.0,1.2
312
+ 2026-05-20 02:30:00+00:00,18.5,18.6,18.35,108.0,86.5,0.0,1.3
313
+ 2026-05-20 02:45:00+00:00,18.8,18.9,18.7,154.0,85.0,0.0,2.0
314
+ 2026-05-20 03:00:00+00:00,19.25,19.45,19.049999999999997,201.5,83.5,0.0,1.35
315
+ 2026-05-20 03:15:00+00:00,19.8,20.0,19.6,236.0,81.0,0.0,1.4
316
+ 2026-05-20 03:30:00+00:00,19.549999999999997,19.75,19.35,184.0,83.0,0.0,2.85
317
+ 2026-05-20 03:45:00+00:00,19.3,19.4,19.2,208.0,84.0,0.0,5.8
318
+ 2026-05-20 04:00:00+00:00,19.25,19.35,19.1,180.5,82.5,0.0,2.7
319
+ 2026-05-20 04:15:00+00:00,19.5,19.7,19.3,264.0,82.0,0.0,1.4
320
+ 2026-05-20 04:30:00+00:00,19.85,20.0,19.65,165.0,81.0,0.0,3.6500000000000004
321
+ 2026-05-20 04:45:00+00:00,19.9,20.3,19.6,481.0,81.0,0.0,3.4
322
+ 2026-05-20 05:00:00+00:00,20.15,20.35,20.0,317.5,79.0,0.0,5.25
323
+ 2026-05-20 05:15:00+00:00,20.1,20.2,20.0,197.0,80.0,0.0,4.9
324
+ 2026-05-20 05:30:00+00:00,20.05,20.1,20.0,238.0,78.0,0.0,4.6
325
+ 2026-05-20 05:45:00+00:00,20.1,20.2,20.1,242.0,79.0,0.0,4.5
326
+ 2026-05-20 06:00:00+00:00,20.15,20.4,20.0,511.5,76.5,0.0,4.75
327
+ 2026-05-20 06:15:00+00:00,21.1,21.3,20.7,804.0,62.0,0.0,4.2
328
+ 2026-05-20 06:30:00+00:00,21.299999999999997,21.5,21.15,962.5,59.5,0.0,5.5
329
+ 2026-05-20 06:45:00+00:00,21.7,22.0,21.3,934.0,62.0,0.0,3.9
330
+ 2026-05-20 07:00:00+00:00,21.799999999999997,22.15,21.35,940.5,59.5,0.0,4.55
331
+ 2026-05-20 07:15:00+00:00,21.7,22.1,21.4,960.0,62.0,0.0,4.3
332
+ 2026-05-20 07:30:00+00:00,21.6,21.75,21.45,819.5,63.0,0.0,4.55
333
+ 2026-05-20 07:45:00+00:00,21.3,21.7,21.1,711.0,63.0,0.0,5.2
334
+ 2026-05-20 08:00:00+00:00,21.2,21.35,20.9,793.5,65.5,0.0,4.95
335
+ 2026-05-20 08:15:00+00:00,21.3,21.5,21.0,834.0,67.0,0.0,4.8
336
+ 2026-05-20 08:30:00+00:00,21.65,21.85,21.35,764.5,64.0,0.0,4.95
337
+ 2026-05-20 08:45:00+00:00,21.5,21.7,21.3,711.0,65.0,0.0,5.3
338
+ 2026-05-20 09:00:00+00:00,21.35,21.6,21.15,659.0,67.0,0.0,4.45
339
+ 2026-05-20 09:15:00+00:00,21.4,21.6,21.1,576.0,68.0,0.0,4.0
340
+ 2026-05-20 09:30:00+00:00,21.049999999999997,21.45,20.75,482.0,69.0,0.0,5.0
341
+ 2026-05-20 09:45:00+00:00,20.7,20.8,20.5,323.0,74.0,0.0,3.7
342
+ 2026-05-20 10:00:00+00:00,20.95,21.25,20.6,320.5,75.5,0.0,4.699999999999999
343
+ 2026-05-20 10:15:00+00:00,20.3,20.5,20.2,401.0,77.0,0.0,6.0
344
+ 2026-05-20 10:30:00+00:00,20.6,20.9,20.35,762.0,72.5,0.0,5.3
345
+ 2026-05-20 10:45:00+00:00,20.9,21.1,20.7,988.0,65.0,0.0,5.3
346
+ 2026-05-20 11:00:00+00:00,20.95,21.5,20.7,793.0,65.0,0.0,5.5
347
+ 2026-05-20 11:15:00+00:00,20.6,20.9,20.3,463.0,67.0,0.0,4.9
348
+ 2026-05-20 11:30:00+00:00,20.65,20.85,20.35,520.5,67.0,0.0,5.35
349
+ 2026-05-20 11:45:00+00:00,20.2,20.6,19.9,504.0,68.0,0.0,6.2
350
+ 2026-05-20 12:00:00+00:00,20.35,20.5,20.15,555.0,67.0,0.0,6.2
351
+ 2026-05-20 12:15:00+00:00,20.2,20.7,19.9,412.0,67.0,0.0,5.9
352
+ 2026-05-20 12:30:00+00:00,20.1,20.25,19.95,345.5,69.0,0.0,5.15
353
+ 2026-05-20 12:45:00+00:00,20.0,20.3,19.7,252.0,66.0,0.0,5.7
354
+ 2026-05-20 13:00:00+00:00,19.85,20.25,19.5,324.5,66.5,0.0,4.6
355
+ 2026-05-20 13:15:00+00:00,20.1,20.4,19.9,385.0,63.0,0.0,5.2
356
+ 2026-05-20 13:30:00+00:00,19.549999999999997,19.85,19.4,187.5,65.0,0.0,5.5
357
+ 2026-05-20 13:45:00+00:00,19.3,19.5,19.1,299.0,66.0,0.0,6.1
358
+ 2026-05-20 14:00:00+00:00,19.55,19.7,19.4,143.0,62.5,0.0,4.9
359
+ 2026-05-20 14:15:00+00:00,19.3,19.4,19.2,89.0,66.0,0.0,5.4
360
+ 2026-05-20 14:30:00+00:00,19.2,19.3,19.15,133.5,66.0,0.0,5.85
361
+ 2026-05-20 14:45:00+00:00,19.0,19.2,18.8,33.0,65.0,0.0,5.1
362
+ 2026-05-20 15:00:00+00:00,18.85,18.9,18.85,26.0,68.5,0.0,3.5
363
+ 2026-05-20 15:15:00+00:00,18.9,18.9,18.8,7.0,69.0,0.0,3.3
364
+ 2026-05-20 15:30:00+00:00,18.75,18.8,18.65,2.0,71.0,0.0,3.6999999999999997
365
+ 2026-05-20 15:45:00+00:00,18.7,18.9,18.6,0.0,72.0,0.0,4.9
366
+ 2026-05-20 16:00:00+00:00,18.7,18.75,18.6,0.0,73.5,0.0,3.45
367
+ 2026-05-20 16:15:00+00:00,18.5,18.7,18.5,0.0,74.0,0.0,4.8
368
+ 2026-05-20 16:30:00+00:00,18.5,18.5,18.4,0.0,75.0,0.0,4.05
369
+ 2026-05-20 16:45:00+00:00,18.5,18.5,18.4,0.0,75.0,0.0,4.4
370
+ 2026-05-20 17:00:00+00:00,18.55,18.55,18.45,0.0,75.5,0.0,4.35
371
+ 2026-05-20 17:15:00+00:00,18.6,18.6,18.5,0.0,76.0,0.0,3.6
372
+ 2026-05-20 17:30:00+00:00,18.5,18.55,18.4,0.0,75.0,0.0,3.8
373
+ 2026-05-20 17:45:00+00:00,18.3,18.4,18.2,0.0,76.0,0.0,3.0
374
+ 2026-05-20 18:00:00+00:00,18.35,18.4,18.25,0.0,76.5,0.0,3.95
375
+ 2026-05-20 18:15:00+00:00,18.4,18.5,18.4,0.0,76.0,0.0,4.1
376
+ 2026-05-20 18:30:00+00:00,18.4,18.5,18.35,0.0,76.0,0.0,3.4000000000000004
377
+ 2026-05-20 18:45:00+00:00,18.4,18.4,18.3,0.0,76.0,0.0,2.4
378
+ 2026-05-20 19:00:00+00:00,18.200000000000003,18.35,18.15,0.0,77.5,0.0,3.5999999999999996
379
+ 2026-05-20 19:15:00+00:00,18.0,18.1,17.9,0.0,78.0,0.0,3.1
380
+ 2026-05-20 19:30:00+00:00,17.95,18.0,17.9,0.0,78.5,0.0,2.5999999999999996
381
+ 2026-05-20 19:45:00+00:00,18.0,18.0,17.9,0.0,78.0,0.0,2.5
382
+ 2026-05-20 20:00:00+00:00,18.0,18.0,18.0,0.0,77.5,0.0,3.05
383
+ 2026-05-20 20:15:00+00:00,18.0,18.1,18.0,0.0,79.0,0.0,4.0
384
+ 2026-05-20 20:30:00+00:00,18.05,18.15,18.05,0.0,79.0,0.0,3.4000000000000004
385
+ 2026-05-20 20:45:00+00:00,18.1,18.2,18.0,0.0,78.0,0.0,4.9
386
+ 2026-05-20 21:00:00+00:00,18.05,18.15,18.05,0.0,78.0,0.0,3.9
387
+ 2026-05-20 21:15:00+00:00,18.0,18.0,17.9,0.0,80.0,0.0,3.8
388
+ 2026-05-20 21:30:00+00:00,17.9,17.95,17.8,0.0,81.0,0.0,3.6
389
+ 2026-05-20 21:45:00+00:00,17.9,18.0,17.8,0.0,82.0,0.0,3.2
390
+ 2026-05-20 22:00:00+00:00,17.9,17.95,17.8,0.0,80.0,0.0,4.4
391
+ 2026-05-20 22:15:00+00:00,17.9,18.0,17.8,0.0,81.0,0.0,3.0
392
+ 2026-05-20 22:30:00+00:00,17.9,17.95,17.85,0.0,79.5,0.0,2.95
393
+ 2026-05-20 22:45:00+00:00,18.0,18.0,17.9,0.0,79.0,0.0,3.2
394
+ 2026-05-20 23:00:00+00:00,17.9,18.0,17.9,0.0,77.0,0.0,3.25
395
+ 2026-05-20 23:15:00+00:00,17.9,18.0,17.9,0.0,77.0,0.0,3.3
396
+ 2026-05-20 23:30:00+00:00,17.9,18.0,17.85,0.0,77.0,0.0,4.4
397
+ 2026-05-20 23:45:00+00:00,17.9,18.0,17.9,0.0,80.0,0.0,3.2
398
+ 2026-05-21 00:00:00+00:00,17.85,17.95,17.75,0.0,81.0,0.1,2.85
399
+ 2026-05-21 00:15:00+00:00,17.3,17.6,17.2,0.0,87.0,0.2,3.9
400
+ 2026-05-21 00:30:00+00:00,17.15,17.25,17.15,0.0,88.5,0.0,3.55
401
+ 2026-05-21 00:45:00+00:00,17.1,17.2,17.1,0.0,85.0,0.0,3.8
402
+ 2026-05-21 01:00:00+00:00,17.15,17.25,17.1,0.0,84.5,0.0,2.95
403
+ 2026-05-21 01:15:00+00:00,17.2,17.2,17.1,0.0,84.0,0.0,2.6
404
+ 2026-05-21 01:30:00+00:00,17.25,17.3,17.2,0.5,83.0,0.0,2.3
405
+ 2026-05-21 01:45:00+00:00,17.4,17.5,17.3,4.0,82.0,0.0,3.4
406
+ 2026-05-21 02:00:00+00:00,17.5,17.55,17.4,24.5,80.0,0.0,4.1
407
+ 2026-05-21 02:15:00+00:00,17.6,17.8,17.5,108.0,79.0,0.0,3.4
408
+ 2026-05-21 02:30:00+00:00,18.0,18.1,17.85,178.0,79.0,0.0,3.6500000000000004
409
+ 2026-05-21 02:45:00+00:00,18.2,18.3,18.1,215.0,78.0,0.0,4.1
410
+ 2026-05-21 03:00:00+00:00,18.299999999999997,18.4,18.15,247.5,78.0,0.0,4.6
411
+ 2026-05-21 03:15:00+00:00,18.3,18.4,18.2,204.0,78.0,0.0,3.6
412
+ 2026-05-21 03:30:00+00:00,18.15,18.25,18.05,79.0,78.5,0.0,4.35
413
+ 2026-05-21 03:45:00+00:00,18.2,18.3,18.0,148.0,78.0,0.0,4.0
414
+ 2026-05-21 04:00:00+00:00,18.3,18.45,18.25,153.5,78.0,0.0,4.15
415
+ 2026-05-21 04:15:00+00:00,18.7,18.9,18.4,365.0,76.0,0.0,4.4
416
+ 2026-05-21 04:30:00+00:00,18.5,18.65,18.35,197.0,79.0,0.0,4.1
417
+ 2026-05-21 04:45:00+00:00,18.6,18.7,18.5,152.0,77.0,0.0,3.6
418
+ 2026-05-21 05:00:00+00:00,18.700000000000003,18.799999999999997,18.55,221.5,77.0,0.0,3.8
419
+ 2026-05-21 05:15:00+00:00,18.7,18.8,18.6,200.0,78.0,0.0,4.5
420
+ 2026-05-21 05:30:00+00:00,18.799999999999997,19.0,18.7,238.0,77.0,0.0,3.85
421
+ 2026-05-21 05:45:00+00:00,18.9,19.2,18.7,111.0,78.0,0.0,4.1
422
+ 2026-05-21 06:00:00+00:00,18.8,18.9,18.7,185.5,80.0,0.0,4.35
423
+ 2026-05-21 06:15:00+00:00,18.8,19.2,18.6,490.0,78.0,0.0,4.6
424
+ 2026-05-21 06:30:00+00:00,19.5,19.65,19.25,830.0,73.0,0.0,4.15
425
+ 2026-05-21 06:45:00+00:00,20.0,20.2,19.8,769.0,70.0,0.0,5.3
426
+ 2026-05-21 07:00:00+00:00,19.9,20.1,19.75,766.5,72.0,0.0,4.55
427
+ 2026-05-21 07:15:00+00:00,19.9,20.3,19.7,470.0,71.0,0.0,5.4
428
+ 2026-05-21 07:30:00+00:00,19.9,20.15,19.65,555.0,73.0,0.0,4.85
429
+ 2026-05-21 07:45:00+00:00,19.5,19.8,19.4,340.0,75.0,0.0,4.8
430
+ 2026-05-21 08:00:00+00:00,19.35,19.549999999999997,19.15,347.0,75.0,0.0,5.15
431
+ 2026-05-21 08:15:00+00:00,19.9,20.0,19.7,707.0,71.0,0.0,4.9
432
+ 2026-05-21 08:30:00+00:00,20.0,20.2,19.85,725.5,70.0,0.0,5.300000000000001
433
+ 2026-05-21 08:45:00+00:00,20.3,20.5,20.1,801.0,69.0,0.0,4.9
434
+ 2026-05-21 09:00:00+00:00,20.0,20.25,19.75,457.5,72.0,0.0,5.15
435
+ 2026-05-21 09:15:00+00:00,20.2,20.5,19.7,550.0,72.0,0.0,4.5
436
+ 2026-05-21 09:30:00+00:00,20.2,20.4,20.0,615.0,70.0,0.0,5.4
437
+ 2026-05-21 09:45:00+00:00,20.2,20.4,20.0,389.0,72.0,0.0,4.9
438
+ 2026-05-21 10:00:00+00:00,20.0,20.35,19.799999999999997,617.5,72.0,0.0,5.8
439
+ 2026-05-21 10:15:00+00:00,20.2,20.3,19.9,702.0,73.0,0.0,5.1
440
+ 2026-05-21 10:30:00+00:00,20.0,20.15,19.9,253.0,74.5,0.0,4.2
441
+ 2026-05-21 10:45:00+00:00,20.1,20.2,19.9,442.0,74.0,0.0,4.7
442
+ 2026-05-21 11:00:00+00:00,20.200000000000003,20.45,20.0,448.5,73.5,0.0,4.85
443
+ 2026-05-21 11:15:00+00:00,19.8,20.1,19.7,354.0,75.0,0.0,4.7
444
+ 2026-05-21 11:30:00+00:00,19.6,19.75,19.45,310.0,76.5,0.0,5.0
445
+ 2026-05-21 11:45:00+00:00,19.8,19.9,19.7,248.0,77.0,0.0,4.5
446
+ 2026-05-21 12:00:00+00:00,19.65,19.799999999999997,19.55,292.5,76.5,0.0,4.45
447
+ 2026-05-21 12:15:00+00:00,19.7,19.8,19.5,290.0,76.0,0.0,4.8
448
+ 2026-05-21 12:30:00+00:00,19.5,19.65,19.35,293.5,77.0,0.0,5.55
449
+ 2026-05-21 12:45:00+00:00,19.8,19.9,19.6,217.0,76.0,0.0,3.6
450
+ 2026-05-21 13:00:00+00:00,19.5,19.65,19.35,153.0,79.0,0.0,4.800000000000001
451
+ 2026-05-21 13:15:00+00:00,19.3,19.4,19.2,171.0,80.0,0.0,5.5
452
+ 2026-05-21 13:30:00+00:00,19.3,19.4,19.25,145.5,79.0,0.0,4.75
453
+ 2026-05-21 13:45:00+00:00,19.3,19.4,19.2,110.0,80.0,0.0,4.7
454
+ 2026-05-21 14:00:00+00:00,19.3,19.4,19.25,97.5,80.0,0.0,3.8
455
+ 2026-05-21 14:15:00+00:00,19.2,19.3,19.1,89.0,81.0,0.0,3.9
456
+ 2026-05-21 14:30:00+00:00,19.1,19.15,19.05,57.0,81.5,0.0,3.8499999999999996
457
+ 2026-05-21 14:45:00+00:00,19.0,19.1,18.9,64.0,82.0,0.0,3.2
458
+ 2026-05-21 15:00:00+00:00,19.0,19.05,18.9,26.0,82.5,0.0,3.3
459
+ 2026-05-21 15:15:00+00:00,18.9,18.9,18.8,13.0,83.0,0.0,3.1
460
+ 2026-05-21 15:30:00+00:00,18.799999999999997,18.9,18.700000000000003,5.0,83.5,0.0,3.8499999999999996
461
+ 2026-05-21 15:45:00+00:00,18.7,18.8,18.6,0.0,84.0,0.0,3.4
462
+ 2026-05-21 16:00:00+00:00,18.7,18.7,18.6,0.0,84.5,0.0,2.5999999999999996
463
+ 2026-05-21 16:15:00+00:00,18.6,18.7,18.5,0.0,85.0,0.0,3.1
464
+ 2026-05-21 16:30:00+00:00,18.5,18.6,18.45,0.0,85.0,0.0,2.5999999999999996
465
+ 2026-05-21 16:45:00+00:00,18.5,18.5,18.4,0.0,85.0,0.0,2.4
466
+ 2026-05-21 17:00:00+00:00,18.4,18.45,18.35,0.0,85.5,0.0,1.6
467
+ 2026-05-21 17:15:00+00:00,18.4,18.4,18.3,0.0,86.0,0.0,1.5
468
+ 2026-05-21 17:30:00+00:00,18.4,18.5,18.3,0.0,85.5,0.0,1.9000000000000001
469
+ 2026-05-21 17:45:00+00:00,18.4,18.5,18.3,0.0,85.0,0.0,1.4
470
+ 2026-05-21 18:00:00+00:00,18.35,18.45,18.25,0.0,85.0,0.0,1.65
471
+ 2026-05-21 18:15:00+00:00,18.3,18.4,18.2,0.0,86.0,0.0,2.1
472
+ 2026-05-21 18:30:00+00:00,18.25,18.35,18.2,0.0,86.5,0.0,3.3
473
+ 2026-05-21 18:45:00+00:00,18.2,18.2,18.1,0.0,87.0,0.0,3.9
474
+ 2026-05-21 19:00:00+00:00,18.2,18.2,18.1,0.0,87.0,0.0,3.1
475
+ 2026-05-21 19:15:00+00:00,18.1,18.2,18.0,0.0,87.0,0.0,2.6
476
+ 2026-05-21 19:30:00+00:00,18.1,18.15,18.0,0.0,87.0,0.0,1.2000000000000002
477
+ 2026-05-21 19:45:00+00:00,18.1,18.2,18.1,0.0,86.0,0.0,1.1
478
+ 2026-05-21 20:00:00+00:00,18.2,18.25,18.1,0.0,86.0,0.0,1.05
479
+ 2026-05-21 20:15:00+00:00,18.1,18.2,18.1,0.0,86.0,0.0,0.6
480
+ 2026-05-21 20:30:00+00:00,18.15,18.2,18.1,0.0,86.5,0.0,0.55
481
+ 2026-05-21 20:45:00+00:00,18.1,18.2,18.1,0.0,87.0,0.0,0.8
482
+ 2026-05-21 21:00:00+00:00,18.2,18.2,18.1,0.0,87.0,0.0,0.8500000000000001
483
+ 2026-05-21 21:15:00+00:00,18.2,18.2,18.1,0.0,87.0,0.0,1.0
484
+ 2026-05-21 21:30:00+00:00,18.05,18.15,18.0,0.0,87.0,0.0,1.1
485
+ 2026-05-21 21:45:00+00:00,18.0,18.1,17.9,0.0,87.0,0.0,0.8
486
+ 2026-05-21 22:00:00+00:00,17.95,18.0,17.85,0.0,87.5,0.0,0.8500000000000001
487
+ 2026-05-21 22:15:00+00:00,17.9,18.0,17.9,0.0,88.0,0.0,1.4
488
+ 2026-05-21 22:30:00+00:00,17.9,17.95,17.85,0.0,88.0,0.0,1.85
489
+ 2026-05-21 22:45:00+00:00,17.9,17.9,17.8,0.0,87.0,0.0,2.1
490
+ 2026-05-21 23:00:00+00:00,17.9,17.95,17.85,0.0,87.0,0.0,2.5999999999999996
491
+ 2026-05-21 23:15:00+00:00,17.9,17.9,17.8,0.0,87.0,0.0,3.1
492
+ 2026-05-21 23:30:00+00:00,17.75,17.85,17.7,0.0,87.0,0.0,2.9000000000000004
493
+ 2026-05-21 23:45:00+00:00,17.7,17.7,17.6,0.0,87.0,0.0,2.6
494
+ 2026-05-22 00:00:00+00:00,17.55,17.6,17.5,0.0,88.0,0.0,2.25
495
+ 2026-05-22 00:15:00+00:00,17.6,17.6,17.5,0.0,88.0,0.0,2.4
496
+ 2026-05-22 00:30:00+00:00,17.5,17.55,17.4,0.0,88.0,0.0,2.8
497
+ 2026-05-22 00:45:00+00:00,17.4,17.5,17.4,0.0,89.0,0.0,3.3
498
+ 2026-05-22 01:00:00+00:00,17.4,17.5,17.3,0.0,88.5,0.0,3.0999999999999996
499
+ 2026-05-22 01:15:00+00:00,17.6,17.6,17.5,0.0,88.0,0.0,1.8
500
+ 2026-05-22 01:30:00+00:00,17.55,17.6,17.5,0.5,88.0,0.0,2.7
501
+ 2026-05-22 01:45:00+00:00,17.6,17.7,17.5,8.0,86.0,0.0,3.3
502
+ 2026-05-22 02:00:00+00:00,17.55,17.65,17.5,23.0,87.0,0.0,2.25
503
+ 2026-05-22 02:15:00+00:00,17.6,17.6,17.5,70.0,87.0,0.0,3.0
504
+ 2026-05-22 02:30:00+00:00,17.700000000000003,17.799999999999997,17.6,77.5,86.5,0.0,1.35
505
+ 2026-05-22 02:45:00+00:00,17.8,17.8,17.7,113.0,86.0,0.0,1.3
506
+ 2026-05-22 03:00:00+00:00,18.3,18.450000000000003,18.05,269.0,84.5,0.0,1.75
507
+ 2026-05-22 03:15:00+00:00,18.6,18.7,18.6,237.0,82.0,0.0,1.8
508
+ 2026-05-22 03:30:00+00:00,18.7,18.8,18.65,240.0,82.0,0.0,2.05
509
+ 2026-05-22 03:45:00+00:00,18.7,18.8,18.6,349.0,82.0,0.0,2.0
510
+ 2026-05-22 04:00:00+00:00,19.05,19.15,18.85,450.0,79.5,0.0,2.65
511
+ 2026-05-22 04:15:00+00:00,19.3,19.4,19.2,499.0,78.0,0.0,3.3
512
+ 2026-05-22 04:30:00+00:00,19.6,19.85,19.35,555.0,75.5,0.0,2.1
513
+ 2026-05-22 04:45:00+00:00,20.1,20.4,19.8,685.0,74.0,0.0,1.7
514
+ 2026-05-22 05:00:00+00:00,20.5,20.75,20.3,503.5,69.5,0.0,1.55
515
+ 2026-05-22 05:15:00+00:00,20.9,21.0,20.6,609.0,65.0,0.0,2.9
516
+ 2026-05-22 05:30:00+00:00,21.2,21.35,20.9,648.0,62.5,0.0,2.5
517
+ 2026-05-22 05:45:00+00:00,21.5,21.7,21.2,541.0,61.0,0.0,4.0
518
+ 2026-05-22 06:00:00+00:00,21.35,21.6,21.15,810.5,64.5,0.0,3.45
519
+ 2026-05-22 06:15:00+00:00,21.4,21.6,21.2,682.0,66.0,0.0,3.2
520
+ 2026-05-22 06:30:00+00:00,21.5,21.8,21.1,681.0,65.0,0.0,3.9
521
+ 2026-05-22 06:45:00+00:00,21.2,21.4,21.0,395.0,67.0,0.0,4.1
522
+ 2026-05-22 07:00:00+00:00,21.549999999999997,21.8,21.299999999999997,830.5,67.0,0.0,3.9
523
+ 2026-05-22 07:15:00+00:00,21.8,22.2,21.4,983.0,68.0,0.0,4.3
524
+ 2026-05-22 07:30:00+00:00,21.049999999999997,21.35,20.9,795.5,71.0,0.0,5.35
525
+ 2026-05-22 07:45:00+00:00,21.1,21.4,20.7,814.0,69.0,0.0,4.4
526
+ 2026-05-22 08:00:00+00:00,21.15,21.45,20.9,987.5,68.5,0.0,5.2
527
+ 2026-05-22 08:15:00+00:00,21.5,21.9,21.1,978.0,67.0,0.0,4.2
528
+ 2026-05-22 08:30:00+00:00,21.3,21.7,20.8,915.0,70.0,0.0,4.15
529
+ 2026-05-22 08:45:00+00:00,21.1,21.8,20.8,760.0,71.0,0.0,4.9
530
+ 2026-05-22 09:00:00+00:00,21.200000000000003,21.4,20.9,900.0,70.5,0.0,4.8
531
+ 2026-05-22 09:15:00+00:00,21.2,21.3,21.0,1005.0,70.0,0.0,4.6
532
+ 2026-05-22 09:30:00+00:00,21.35,21.7,21.05,1029.5,68.5,0.0,5.9
533
+ 2026-05-22 09:45:00+00:00,21.3,21.5,21.1,1024.0,69.0,0.0,6.4
534
+ 2026-05-22 10:00:00+00:00,21.2,21.35,21.05,994.5,68.0,0.0,6.949999999999999
535
+ 2026-05-22 10:15:00+00:00,21.4,21.8,21.1,972.0,67.0,0.0,6.1
536
+ 2026-05-22 10:30:00+00:00,21.3,21.65,21.15,952.0,70.0,0.0,6.55
537
+ 2026-05-22 10:45:00+00:00,21.3,21.5,21.2,923.0,70.0,0.0,6.0
538
+ 2026-05-22 11:00:00+00:00,21.3,21.5,21.0,890.0,71.0,0.0,6.4
539
+ 2026-05-22 11:15:00+00:00,21.0,21.1,20.8,836.0,71.0,0.0,6.6
540
+ 2026-05-22 11:30:00+00:00,20.950000000000003,21.15,20.75,813.0,70.5,0.0,6.85
541
+ 2026-05-22 11:45:00+00:00,20.8,20.9,20.7,728.0,70.0,0.0,6.7
542
+ 2026-05-22 12:00:00+00:00,20.7,20.85,20.55,680.0,69.5,0.0,6.65
543
+ 2026-05-22 12:15:00+00:00,20.7,21.0,20.5,669.0,70.0,0.0,5.5
544
+ 2026-05-22 12:30:00+00:00,20.7,20.8,20.6,612.5,68.0,0.0,5.65
545
+ 2026-05-22 12:45:00+00:00,20.7,20.8,20.4,552.0,68.0,0.0,5.5
546
+ 2026-05-22 13:00:00+00:00,20.45,20.55,20.3,495.0,72.0,0.0,5.449999999999999
547
+ 2026-05-22 13:15:00+00:00,20.4,20.6,20.2,442.0,70.0,0.0,4.4
548
+ 2026-05-22 13:30:00+00:00,20.65,20.75,20.55,378.5,70.0,0.0,3.45
549
+ 2026-05-22 13:45:00+00:00,20.7,20.8,20.5,320.0,71.0,0.0,3.1
550
+ 2026-05-22 14:00:00+00:00,20.5,20.700000000000003,20.35,234.0,72.0,0.0,2.75
551
+ 2026-05-22 14:15:00+00:00,20.0,20.1,19.9,110.0,75.0,0.0,3.0
552
+ 2026-05-22 14:30:00+00:00,19.95,20.0,19.85,94.0,75.0,0.0,2.25
553
+ 2026-05-22 14:45:00+00:00,19.8,19.8,19.7,55.0,76.0,0.0,1.4
554
+ 2026-05-22 15:00:00+00:00,19.5,19.65,19.4,34.0,77.5,0.0,1.7000000000000002
555
+ 2026-05-22 15:15:00+00:00,19.4,19.5,19.2,12.0,77.0,0.0,1.6
556
+ 2026-05-22 15:30:00+00:00,19.15,19.15,19.0,3.5,79.0,0.0,1.7999999999999998
557
+ 2026-05-22 15:45:00+00:00,18.9,19.0,18.8,0.0,80.0,0.0,1.8
558
+ 2026-05-22 16:00:00+00:00,18.75,18.85,18.7,0.0,81.5,0.0,1.4500000000000002
559
+ 2026-05-22 16:15:00+00:00,18.6,18.7,18.5,0.0,83.0,0.0,0.2
560
+ 2026-05-22 16:30:00+00:00,18.299999999999997,18.45,18.200000000000003,0.0,83.5,0.0,1.0
561
+ 2026-05-22 16:45:00+00:00,18.0,18.1,18.0,0.0,84.0,0.0,1.0
562
+ 2026-05-22 17:00:00+00:00,17.799999999999997,17.9,17.75,0.0,85.5,0.0,1.05
563
+ 2026-05-22 17:15:00+00:00,17.7,17.8,17.7,0.0,87.0,0.0,1.8
564
+ 2026-05-22 17:30:00+00:00,17.75,17.8,17.7,0.0,87.0,0.0,1.7
565
+ 2026-05-22 17:45:00+00:00,17.8,17.9,17.8,0.0,87.0,0.0,1.8
566
+ 2026-05-22 18:00:00+00:00,17.75,17.799999999999997,17.65,0.0,87.0,0.0,1.25
567
+ 2026-05-22 18:15:00+00:00,17.7,17.7,17.7,0.0,87.0,0.0,1.3
568
+ 2026-05-22 18:30:00+00:00,17.85,17.9,17.799999999999997,0.0,87.0,0.0,1.35
569
+ 2026-05-22 18:45:00+00:00,17.9,17.9,17.8,0.0,87.0,0.0,1.0
570
+ 2026-05-22 19:00:00+00:00,17.75,17.85,17.700000000000003,0.0,87.0,0.0,0.7
571
+ 2026-05-22 19:15:00+00:00,17.6,17.7,17.6,0.0,88.0,0.0,0.9
572
+ 2026-05-22 19:30:00+00:00,17.700000000000003,17.75,17.6,0.0,87.5,0.0,0.8
573
+ 2026-05-22 19:45:00+00:00,17.8,17.9,17.8,0.0,87.0,0.0,0.8
574
+ 2026-05-22 20:00:00+00:00,17.8,17.85,17.75,0.0,87.0,0.0,0.75
575
+ 2026-05-22 20:15:00+00:00,17.8,17.8,17.7,0.0,88.0,0.0,1.1
576
+ 2026-05-22 20:30:00+00:00,17.8,17.85,17.75,0.0,88.0,0.0,1.35
577
+ 2026-05-22 20:45:00+00:00,17.7,17.8,17.7,0.0,88.0,0.0,1.5
578
+ 2026-05-22 21:00:00+00:00,17.65,17.75,17.6,0.0,89.0,,
579
+ 2026-05-22 21:15:00+00:00,17.3,17.5,17.2,0.0,89.0,,
580
+ 2026-05-22 21:30:00+00:00,17.0,17.15,16.9,0.0,90.5,,
581
+ 2026-05-22 21:45:00+00:00,16.9,16.9,16.8,0.0,91.0,,
582
+ 2026-05-22 22:00:00+00:00,16.75,16.799999999999997,16.65,0.0,91.5,,
583
+ 2026-05-22 22:15:00+00:00,16.8,16.8,16.7,0.0,92.0,,
584
+ 2026-05-22 22:30:00+00:00,16.7,16.8,16.65,0.0,92.0,,
585
+ 2026-05-22 22:45:00+00:00,16.9,16.9,16.8,0.0,91.0,,
586
+ 2026-05-22 23:00:00+00:00,16.700000000000003,16.85,16.65,0.0,91.0,,
587
+ 2026-05-22 23:15:00+00:00,16.5,16.6,16.4,0.0,92.0,,
588
+ 2026-05-22 23:30:00+00:00,16.4,16.5,16.35,0.0,92.0,,
589
+ 2026-05-22 23:45:00+00:00,16.3,16.4,16.2,0.0,92.0,,
590
+ 2026-05-23 00:00:00+00:00,16.2,16.3,16.1,0.0,92.5,,
591
+ 2026-05-23 00:15:00+00:00,16.1,16.2,16.0,0.0,93.0,,
592
+ 2026-05-23 00:30:00+00:00,16.25,16.3,16.1,0.0,93.0,,
593
+ 2026-05-23 00:45:00+00:00,16.2,16.2,16.1,0.0,93.0,,
594
+ 2026-05-23 01:00:00+00:00,16.0,16.1,15.9,0.0,93.5,,
595
+ 2026-05-23 01:15:00+00:00,16.2,16.5,15.9,0.0,93.0,,
596
+ 2026-05-23 01:30:00+00:00,16.299999999999997,16.450000000000003,16.15,1.0,91.0,,
597
+ 2026-05-23 01:45:00+00:00,16.0,16.1,15.9,8.0,92.0,,
598
+ 2026-05-23 02:00:00+00:00,15.9,16.0,15.850000000000001,26.5,92.0,,
599
+ 2026-05-23 02:15:00+00:00,16.1,16.3,15.9,71.0,92.0,,
600
+ 2026-05-23 02:30:00+00:00,16.85,17.1,16.55,81.0,91.0,,
601
+ 2026-05-23 02:45:00+00:00,17.5,17.6,17.3,111.0,88.0,,
602
+ 2026-05-23 03:00:00+00:00,18.0,18.15,17.8,185.5,84.0,,
603
+ 2026-05-23 03:15:00+00:00,18.4,18.7,18.0,249.0,82.0,,
604
+ 2026-05-23 03:30:00+00:00,18.85,19.05,18.7,314.5,79.0,,
605
+ 2026-05-23 03:45:00+00:00,19.6,19.8,19.1,413.0,77.0,,
606
+ 2026-05-23 04:00:00+00:00,19.4,19.75,19.2,379.5,75.5,,
607
+ 2026-05-23 04:15:00+00:00,19.2,19.3,19.1,453.0,76.0,,
608
+ 2026-05-23 04:30:00+00:00,20.0,20.25,19.700000000000003,444.5,73.5,,
609
+ 2026-05-23 04:45:00+00:00,20.5,20.7,20.2,519.0,71.0,,
610
+ 2026-05-23 05:00:00+00:00,20.5,20.65,20.15,594.5,70.5,,
611
+ 2026-05-23 05:15:00+00:00,20.7,20.9,20.5,601.0,70.0,,
612
+ 2026-05-23 05:30:00+00:00,21.25,21.55,20.950000000000003,706.0,66.0,,
613
+ 2026-05-23 05:45:00+00:00,21.9,22.4,21.3,335.0,65.0,,
614
+ 2026-05-23 06:00:00+00:00,21.6,21.799999999999997,21.45,771.0,67.0,,
615
+ 2026-05-23 06:15:00+00:00,22.1,22.2,21.8,897.0,66.0,,
616
+ 2026-05-23 06:30:00+00:00,21.799999999999997,22.1,21.6,922.0,66.0,,
617
+ 2026-05-23 06:45:00+00:00,21.7,21.9,21.4,945.0,65.0,,
618
+ 2026-05-23 07:00:00+00:00,21.75,21.95,21.549999999999997,967.5,65.5,,
619
+ 2026-05-23 07:15:00+00:00,21.9,22.1,21.7,990.0,64.0,,
620
+ 2026-05-23 07:30:00+00:00,21.9,22.1,21.65,1009.5,64.0,,
621
+ 2026-05-23 07:45:00+00:00,22.2,22.4,22.0,1026.0,60.0,,
622
+ 2026-05-23 08:00:00+00:00,22.2,22.4,21.95,1038.0,59.0,,
623
+ 2026-05-23 08:15:00+00:00,22.1,22.3,22.0,1047.0,58.0,,
624
+ 2026-05-23 08:30:00+00:00,22.35,22.65,22.1,1050.5,58.5,,
625
+ 2026-05-23 08:45:00+00:00,22.7,22.9,22.4,1049.0,59.0,,
626
+ 2026-05-23 09:00:00+00:00,23.0,23.4,22.7,1045.5,56.0,,
627
+ 2026-05-23 09:15:00+00:00,23.0,23.6,22.7,1038.0,54.0,,
628
+ 2026-05-23 09:30:00+00:00,22.450000000000003,22.85,22.200000000000003,1030.0,61.0,,
629
+ 2026-05-23 09:45:00+00:00,22.5,23.0,22.0,1017.0,62.0,,
630
+ 2026-05-23 10:00:00+00:00,22.15,22.450000000000003,21.85,995.0,65.0,,
631
+ 2026-05-23 10:15:00+00:00,22.0,22.3,21.7,969.0,67.0,,
632
+ 2026-05-23 10:30:00+00:00,21.9,22.3,21.4,942.5,66.5,,
633
+ 2026-05-23 10:45:00+00:00,22.5,22.9,22.3,908.0,63.0,,
634
+ 2026-05-23 11:00:00+00:00,22.2,22.45,21.799999999999997,873.5,63.5,,
635
+ 2026-05-23 11:15:00+00:00,21.7,21.9,21.5,835.0,66.0,,
636
+ 2026-05-23 11:30:00+00:00,21.75,22.0,21.3,796.5,65.5,,
637
+ 2026-05-23 11:45:00+00:00,22.0,22.4,21.7,748.0,64.0,,
638
+ 2026-05-23 12:00:00+00:00,22.450000000000003,22.549999999999997,22.299999999999997,702.0,63.5,,
639
+ 2026-05-23 12:15:00+00:00,22.2,22.6,21.8,648.0,66.0,,
640
+ 2026-05-23 12:30:00+00:00,22.2,22.450000000000003,21.85,599.0,63.0,,
641
+ 2026-05-23 12:45:00+00:00,21.8,22.2,21.4,548.0,65.0,,
642
+ 2026-05-23 13:00:00+00:00,21.65,21.85,21.5,487.5,65.5,,
643
+ 2026-05-23 13:15:00+00:00,22.1,22.3,21.9,433.0,65.0,,
644
+ 2026-05-23 13:30:00+00:00,21.9,22.1,21.75,353.5,66.0,,
645
+ 2026-05-23 13:45:00+00:00,21.5,21.8,21.5,301.0,69.0,,
646
+ 2026-05-23 14:00:00+00:00,21.1,21.4,20.9,181.0,70.0,,
647
+ 2026-05-23 14:15:00+00:00,20.5,20.6,20.4,189.0,72.0,,
648
+ 2026-05-23 14:30:00+00:00,20.6,20.7,20.5,134.0,71.5,,
649
+ 2026-05-23 14:45:00+00:00,20.4,20.5,20.2,83.0,74.0,,
650
+ 2026-05-23 15:00:00+00:00,19.950000000000003,20.1,19.85,39.5,76.0,,
651
+ 2026-05-23 15:15:00+00:00,19.7,19.8,19.6,16.0,78.0,,
652
+ 2026-05-23 15:30:00+00:00,19.4,19.549999999999997,19.299999999999997,4.0,78.5,,
653
+ 2026-05-23 15:45:00+00:00,19.2,19.2,19.0,0.0,80.0,,
654
+ 2026-05-23 16:00:00+00:00,18.95,19.0,18.85,0.0,81.5,,
655
+ 2026-05-23 16:15:00+00:00,18.8,18.8,18.7,0.0,82.0,,
656
+ 2026-05-23 16:30:00+00:00,18.7,18.75,18.65,0.0,83.0,,
657
+ 2026-05-23 16:45:00+00:00,18.6,18.7,18.5,0.0,83.0,,
658
+ 2026-05-23 17:00:00+00:00,18.3,18.4,18.1,0.0,83.5,,
659
+ 2026-05-23 17:15:00+00:00,17.9,18.0,17.9,0.0,85.0,,
660
+ 2026-05-23 17:30:00+00:00,17.95,18.05,17.9,0.0,84.5,,
661
+ 2026-05-23 17:45:00+00:00,17.8,17.9,17.7,0.0,85.0,,
662
+ 2026-05-23 18:00:00+00:00,17.75,17.8,17.65,0.0,86.0,,
663
+ 2026-05-23 18:15:00+00:00,17.6,17.7,17.5,0.0,86.0,,
664
+ 2026-05-23 18:30:00+00:00,17.45,17.55,17.35,0.0,86.5,,
665
+ 2026-05-23 18:45:00+00:00,17.1,17.2,17.0,0.0,88.0,,
666
+ 2026-05-23 19:00:00+00:00,16.85,16.95,16.8,0.0,88.0,,
667
+ 2026-05-23 19:15:00+00:00,16.8,16.9,16.8,0.0,88.0,,
668
+ 2026-05-23 19:30:00+00:00,16.75,16.8,16.65,0.0,88.0,,
669
+ 2026-05-23 19:45:00+00:00,16.7,16.7,16.7,0.0,89.0,,
670
+ 2026-05-23 20:00:00+00:00,16.799999999999997,16.85,16.700000000000003,0.0,89.0,,
671
+ 2026-05-23 20:15:00+00:00,16.8,17.0,16.7,0.0,88.0,,
672
+ 2026-05-23 20:30:00+00:00,16.6,16.7,16.6,0.0,89.5,,
673
+ 2026-05-23 20:45:00+00:00,16.5,16.6,16.4,0.0,89.0,,
Data/layout.json ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "site": {
3
+ "latitude": 30.87,
4
+ "longitude": 34.79,
5
+ "altitude_m": 475.0,
6
+ "timezone": "Asia/Jerusalem",
7
+ "name": "Sde Boker Agrivoltaic Research Site"
8
+ },
9
+ "panel_geometry": {
10
+ "width_m": 1.13,
11
+ "height_m": 2.05,
12
+ "row_spacing_m": 3.0,
13
+ "row_azimuth_deg": 315.0,
14
+ "tilt_axis": "single_axis_NS"
15
+ },
16
+ "canopy_geometry": {
17
+ "height_m": 1.2,
18
+ "width_m": 0.6,
19
+ "trellis_type": "VSP",
20
+ "n_vertical_zones": 3,
21
+ "zone_labels": [
22
+ "basal_trunk",
23
+ "fruiting_zone",
24
+ "apical_canopy"
25
+ ],
26
+ "zone_heights_m": [
27
+ 0.2,
28
+ 0.6,
29
+ 1.0
30
+ ],
31
+ "lai_distribution": [
32
+ 0.15,
33
+ 0.35,
34
+ 0.5
35
+ ]
36
+ },
37
+ "rows": [
38
+ {
39
+ "row_id": 501,
40
+ "type": "treatment",
41
+ "panel_center_x_m": 0.0,
42
+ "panel_center_y_m": 0.0,
43
+ "panel_height_m": 2.05,
44
+ "devices": [
45
+ "Crop_2Soil11",
46
+ "Crop_2Soil8",
47
+ "Tracker501"
48
+ ]
49
+ },
50
+ {
51
+ "row_id": 502,
52
+ "type": "treatment",
53
+ "panel_center_x_m": 3.0,
54
+ "panel_center_y_m": 0.0,
55
+ "panel_height_m": 2.05,
56
+ "devices": [
57
+ "Crop_2Soil1",
58
+ "Crop_2Soil17",
59
+ "Crop_2Soil6",
60
+ "Tracker502"
61
+ ]
62
+ },
63
+ {
64
+ "row_id": 503,
65
+ "type": "treatment",
66
+ "panel_center_x_m": 6.0,
67
+ "panel_center_y_m": 0.0,
68
+ "panel_height_m": 2.05,
69
+ "devices": [
70
+ "Crop_2Soil13",
71
+ "Tracker503"
72
+ ]
73
+ },
74
+ {
75
+ "row_id": 504,
76
+ "type": "treatment",
77
+ "panel_center_x_m": 9.0,
78
+ "panel_center_y_m": 0.0,
79
+ "panel_height_m": 2.05,
80
+ "devices": [
81
+ "Crop_2Soil19",
82
+ "Crop_2Soil20",
83
+ "Crop_2Soil7"
84
+ ]
85
+ },
86
+ {
87
+ "row_id": 509,
88
+ "type": "treatment",
89
+ "panel_center_x_m": 12.0,
90
+ "panel_center_y_m": 0.0,
91
+ "panel_height_m": 2.05,
92
+ "devices": [
93
+ "Crop_2Soil9",
94
+ "Tracker509"
95
+ ]
96
+ },
97
+ {
98
+ "row_id": 202,
99
+ "type": "reference",
100
+ "panel_center_x_m": 0.0,
101
+ "panel_center_y_m": -30.0,
102
+ "panel_height_m": 0.0,
103
+ "devices": [
104
+ "Crop_2Soil10",
105
+ "Crop_2Soil14",
106
+ "Crop_2Soil18",
107
+ "Crop_2Soil2",
108
+ "Crop_2Soil3",
109
+ "Crop_2Soil4",
110
+ "Crop_2Soil5"
111
+ ]
112
+ }
113
+ ],
114
+ "devices": {
115
+ "Crop_2Soil11": {
116
+ "uuid": "a4cfc5c0-2b64-11f1-b902-5ff1ea8c4cf9",
117
+ "area": "treatment",
118
+ "row": 501,
119
+ "label": "501 north",
120
+ "position": "north",
121
+ "position_m": [
122
+ 0.0,
123
+ 5.0,
124
+ 0.6
125
+ ]
126
+ },
127
+ "Crop_2Soil8": {
128
+ "uuid": "933b7f70-2b64-11f1-b902-5ff1ea8c4cf9",
129
+ "area": "treatment",
130
+ "row": 501,
131
+ "label": "501 south",
132
+ "position": "south",
133
+ "position_m": [
134
+ 0.0,
135
+ -5.0,
136
+ 0.6
137
+ ]
138
+ },
139
+ "Crop_2Soil1": {
140
+ "uuid": "38437e20-2b63-11f1-b902-5ff1ea8c4cf9",
141
+ "area": "treatment",
142
+ "row": 502,
143
+ "label": "502 north",
144
+ "position": "north",
145
+ "position_m": [
146
+ 3.0,
147
+ 5.0,
148
+ 0.6
149
+ ]
150
+ },
151
+ "Crop_2Soil17": {
152
+ "uuid": "d232bae0-2b64-11f1-b902-5ff1ea8c4cf9",
153
+ "area": "treatment",
154
+ "row": 502,
155
+ "label": "502 south",
156
+ "position": "south",
157
+ "position_m": [
158
+ 3.0,
159
+ -5.0,
160
+ 0.6
161
+ ]
162
+ },
163
+ "Crop_2Soil6": {
164
+ "uuid": "8766eef0-2b64-11f1-b902-5ff1ea8c4cf9",
165
+ "area": "treatment",
166
+ "row": 502,
167
+ "label": "502 south-east",
168
+ "position": "south-east",
169
+ "position_m": [
170
+ 3.5,
171
+ -5.0,
172
+ 0.6
173
+ ]
174
+ },
175
+ "Crop_2Soil13": {
176
+ "uuid": "bafcce10-2b64-11f1-b902-5ff1ea8c4cf9",
177
+ "area": "treatment",
178
+ "row": 503,
179
+ "label": "503",
180
+ "position": null,
181
+ "position_m": [
182
+ 6.0,
183
+ 0.0,
184
+ 0.6
185
+ ]
186
+ },
187
+ "Crop_2Soil7": {
188
+ "uuid": "8d965680-2b64-11f1-b902-5ff1ea8c4cf9",
189
+ "area": "treatment",
190
+ "row": 504,
191
+ "label": "504 north",
192
+ "position": "north",
193
+ "position_m": [
194
+ 9.0,
195
+ 5.0,
196
+ 0.6
197
+ ]
198
+ },
199
+ "Crop_2Soil19": {
200
+ "uuid": "dde80390-2b64-11f1-b902-5ff1ea8c4cf9",
201
+ "area": "treatment",
202
+ "row": 504,
203
+ "label": "504 center",
204
+ "position": "center",
205
+ "position_m": [
206
+ 9.0,
207
+ 0.0,
208
+ 0.6
209
+ ]
210
+ },
211
+ "Crop_2Soil20": {
212
+ "uuid": "e44b2550-2b64-11f1-b902-5ff1ea8c4cf9",
213
+ "area": "treatment",
214
+ "row": 504,
215
+ "label": "504 center-east",
216
+ "position": "center-east",
217
+ "position_m": [
218
+ 9.5,
219
+ 0.0,
220
+ 0.6
221
+ ]
222
+ },
223
+ "Crop_2Soil9": {
224
+ "uuid": "9908c9d0-2b64-11f1-b902-5ff1ea8c4cf9",
225
+ "area": "treatment",
226
+ "row": 509,
227
+ "label": "509 south",
228
+ "position": "south",
229
+ "position_m": [
230
+ 12.0,
231
+ -5.0,
232
+ 0.6
233
+ ]
234
+ },
235
+ "Crop_2Soil4": {
236
+ "uuid": "7bea6980-2b64-11f1-b902-5ff1ea8c4cf9",
237
+ "area": "reference",
238
+ "row": 202,
239
+ "label": "202 north (ref)",
240
+ "position": "north",
241
+ "position_m": [
242
+ 0.0,
243
+ -25.0,
244
+ 0.6
245
+ ]
246
+ },
247
+ "Crop_2Soil3": {
248
+ "uuid": "7362e120-2b64-11f1-b902-5ff1ea8c4cf9",
249
+ "area": "reference",
250
+ "row": 202,
251
+ "label": "202 north",
252
+ "position": "north",
253
+ "position_m": [
254
+ 0.0,
255
+ -25.0,
256
+ 0.6
257
+ ]
258
+ },
259
+ "Crop_2Soil18": {
260
+ "uuid": "d7b8ea20-2b64-11f1-b902-5ff1ea8c4cf9",
261
+ "area": "reference",
262
+ "row": 202,
263
+ "label": "202 north-east",
264
+ "position": "north-east",
265
+ "position_m": [
266
+ 0.5,
267
+ -25.0,
268
+ 0.6
269
+ ]
270
+ },
271
+ "Crop_2Soil2": {
272
+ "uuid": "79a26ac0-2b63-11f1-b902-5ff1ea8c4cf9",
273
+ "area": "reference",
274
+ "row": 202,
275
+ "label": "202 center (ref)",
276
+ "position": "center",
277
+ "position_m": [
278
+ 0.0,
279
+ -30.0,
280
+ 0.6
281
+ ]
282
+ },
283
+ "Crop_2Soil5": {
284
+ "uuid": "81a95c00-2b64-11f1-b902-5ff1ea8c4cf9",
285
+ "area": "reference",
286
+ "row": 202,
287
+ "label": "202 center",
288
+ "position": "center",
289
+ "position_m": [
290
+ 0.0,
291
+ -30.0,
292
+ 0.6
293
+ ]
294
+ },
295
+ "Crop_2Soil14": {
296
+ "uuid": "c05cd7b0-2b64-11f1-b902-5ff1ea8c4cf9",
297
+ "area": "reference",
298
+ "row": 202,
299
+ "label": "202 south",
300
+ "position": "south",
301
+ "position_m": [
302
+ 0.0,
303
+ -35.0,
304
+ 0.6
305
+ ]
306
+ },
307
+ "Crop_2Soil10": {
308
+ "uuid": "9f4047b0-2b64-11f1-b902-5ff1ea8c4cf9",
309
+ "area": "reference",
310
+ "row": 202,
311
+ "label": "202 south (ref)",
312
+ "position": "south",
313
+ "position_m": [
314
+ 0.0,
315
+ -35.0,
316
+ 0.6
317
+ ]
318
+ },
319
+ "Crop_2Soil12": {
320
+ "uuid": "aa114ae0-2b64-11f1-b902-5ff1ea8c4cf9",
321
+ "area": "treatment",
322
+ "row": null,
323
+ "label": "unallocated",
324
+ "position": null,
325
+ "position_m": [
326
+ 0.0,
327
+ 0.0,
328
+ 0.6
329
+ ]
330
+ },
331
+ "Crop_2Soil15": {
332
+ "uuid": "c6574c90-2b64-11f1-b902-5ff1ea8c4cf9",
333
+ "area": "treatment",
334
+ "row": null,
335
+ "label": "unallocated",
336
+ "position": null,
337
+ "position_m": [
338
+ 0.0,
339
+ 0.0,
340
+ 0.6
341
+ ]
342
+ },
343
+ "Thermocouples1": {
344
+ "uuid": "d172ffe0-4fac-11f1-829c-09d61d29d108",
345
+ "area": "treatment",
346
+ "row": null,
347
+ "label": "Panel surface thermocouples 1",
348
+ "position": null,
349
+ "position_m": [
350
+ 0.0,
351
+ 0.0,
352
+ 2.05
353
+ ]
354
+ },
355
+ "Thermocouples2": {
356
+ "uuid": "e0d87f50-4fac-11f1-829c-09d61d29d108",
357
+ "area": "treatment",
358
+ "row": null,
359
+ "label": "Panel surface thermocouples 2",
360
+ "position": null,
361
+ "position_m": [
362
+ 0.0,
363
+ 0.0,
364
+ 2.05
365
+ ]
366
+ },
367
+ "Thermocouples3": {
368
+ "uuid": "e737d080-4fac-11f1-829c-09d61d29d108",
369
+ "area": "treatment",
370
+ "row": null,
371
+ "label": "Panel surface thermocouples 3",
372
+ "position": null,
373
+ "position_m": [
374
+ 0.0,
375
+ 0.0,
376
+ 2.05
377
+ ]
378
+ },
379
+ "Thermocouples4": {
380
+ "uuid": "ed4901b0-4fac-11f1-829c-09d61d29d108",
381
+ "area": "treatment",
382
+ "row": null,
383
+ "label": "Panel surface thermocouples 4",
384
+ "position": null,
385
+ "position_m": [
386
+ 0.0,
387
+ 0.0,
388
+ 2.05
389
+ ]
390
+ },
391
+ "Thermocouples5": {
392
+ "uuid": "f3f07f70-4fac-11f1-829c-09d61d29d108",
393
+ "area": "treatment",
394
+ "row": null,
395
+ "label": "Panel surface thermocouples 5",
396
+ "position": null,
397
+ "position_m": [
398
+ 0.0,
399
+ 0.0,
400
+ 2.05
401
+ ]
402
+ },
403
+ "Thermocouples6": {
404
+ "uuid": "faa591c0-4fac-11f1-829c-09d61d29d108",
405
+ "area": "treatment",
406
+ "row": null,
407
+ "label": "Panel surface thermocouples 6",
408
+ "position": null,
409
+ "position_m": [
410
+ 0.0,
411
+ 0.0,
412
+ 2.05
413
+ ]
414
+ },
415
+ "Thermocouples7": {
416
+ "uuid": "0095a660-4fad-11f1-829c-09d61d29d108",
417
+ "area": "treatment",
418
+ "row": null,
419
+ "label": "Panel surface thermocouples 7",
420
+ "position": null,
421
+ "position_m": [
422
+ 0.0,
423
+ 0.0,
424
+ 2.05
425
+ ]
426
+ },
427
+ "Thermocouples8": {
428
+ "uuid": "07168950-4fad-11f1-829c-09d61d29d108",
429
+ "area": "treatment",
430
+ "row": null,
431
+ "label": "Panel surface thermocouples 8",
432
+ "position": null,
433
+ "position_m": [
434
+ 0.0,
435
+ 0.0,
436
+ 2.05
437
+ ]
438
+ },
439
+ "Thermocouples9": {
440
+ "uuid": "0e69fe80-4fad-11f1-829c-09d61d29d108",
441
+ "area": "treatment",
442
+ "row": null,
443
+ "label": "Panel surface thermocouples 9",
444
+ "position": null,
445
+ "position_m": [
446
+ 0.0,
447
+ 0.0,
448
+ 2.05
449
+ ]
450
+ },
451
+ "Thermocouples10": {
452
+ "uuid": "14e36760-4fad-11f1-829c-09d61d29d108",
453
+ "area": "treatment",
454
+ "row": null,
455
+ "label": "Panel surface thermocouples 10",
456
+ "position": null,
457
+ "position_m": [
458
+ 0.0,
459
+ 0.0,
460
+ 2.05
461
+ ]
462
+ },
463
+ "Thermocouples11": {
464
+ "uuid": "1b513780-4fad-11f1-829c-09d61d29d108",
465
+ "area": "treatment",
466
+ "row": null,
467
+ "label": "Panel surface thermocouples 11",
468
+ "position": null,
469
+ "position_m": [
470
+ 0.0,
471
+ 0.0,
472
+ 2.05
473
+ ]
474
+ },
475
+ "Thermocouples12": {
476
+ "uuid": "2121dd40-4fad-11f1-829c-09d61d29d108",
477
+ "area": "treatment",
478
+ "row": null,
479
+ "label": "Panel surface thermocouples 12",
480
+ "position": null,
481
+ "position_m": [
482
+ 0.0,
483
+ 0.0,
484
+ 2.05
485
+ ]
486
+ },
487
+ "Tracker501": {
488
+ "uuid": "aac06e50-f769-11f0-b902-5ff1ea8c4cf9",
489
+ "area": "treatment",
490
+ "row": 501,
491
+ "label": "Tracker row 501",
492
+ "position": null,
493
+ "position_m": [
494
+ 0.0,
495
+ 0.0,
496
+ 2.05
497
+ ]
498
+ },
499
+ "Tracker502": {
500
+ "uuid": "b99bd630-f769-11f0-b902-5ff1ea8c4cf9",
501
+ "area": "treatment",
502
+ "row": 502,
503
+ "label": "Tracker row 502",
504
+ "position": null,
505
+ "position_m": [
506
+ 3.0,
507
+ 0.0,
508
+ 2.05
509
+ ]
510
+ },
511
+ "Tracker503": {
512
+ "uuid": "caffe4c0-f769-11f0-b902-5ff1ea8c4cf9",
513
+ "area": "treatment",
514
+ "row": 503,
515
+ "label": "Tracker row 503",
516
+ "position": null,
517
+ "position_m": [
518
+ 6.0,
519
+ 0.0,
520
+ 2.05
521
+ ]
522
+ },
523
+ "Tracker509": {
524
+ "uuid": "bacf7c50-fcdc-11f0-b902-5ff1ea8c4cf9",
525
+ "area": "treatment",
526
+ "row": 509,
527
+ "label": "Tracker row 509",
528
+ "position": null,
529
+ "position_m": [
530
+ 12.0,
531
+ 0.0,
532
+ 2.05
533
+ ]
534
+ }
535
+ }
536
+ }
Data/processed/stage1_labels.csv ADDED
The diff for this file is too large to render. See raw diff
 
README.md CHANGED
@@ -1,82 +1,117 @@
1
- ---
2
- title: SolarWine API
3
- emoji: 🌿
4
- colorFrom: green
5
- colorTo: yellow
6
- sdk: docker
7
- app_port: 7860
8
- private: true
9
- ---
10
-
11
- # SolarWine API
12
-
13
- FastAPI backend for the SolarWine agrivoltaic vineyard control system.
14
-
15
- ## Endpoints
16
-
17
- ### Health & Monitoring
18
- - `GET|HEAD /api/health` system health (uptime, Redis, TB, IMS, Gemini status)
19
- - `GET /api/health/data` — data freshness per source (age, ok/degraded)
20
- - `GET /api/health/data-sources` per-source green/yellow/red status with thresholds
21
-
22
- ### Weather (IMS Station 43)
23
- - `GET /api/weather/current` — latest weather readings + data age
24
- - `GET /api/weather/history?start_date&end_date&format` historical weather (hourly)
25
- - `GET /api/weather/forecast` — weather forecast
26
-
27
- ### Vine Sensors (ThingsBoard)
28
- - `GET /api/sensors/snapshot` — latest readings (treatment vs reference)
29
- - `GET /api/sensors/history?type&area&hours` sensor time-series
30
- - `GET /api/sensors/soil-moisture?hours` soil moisture history
31
- - `GET /api/sensors/rain?hours` — rainfall history
32
-
33
- ### Energy
34
- - `GET /api/energy/current` current power output (kW)
35
- - `GET /api/energy/daily/{date}` — daily production + hourly profile
36
- - `GET /api/energy/history` energy generation time-series
37
- - `GET /api/energy/predict/{date}`predicted daily generation
38
-
39
- ### Photosynthesis
40
- - `GET /api/photosynthesis/current?model`current A rate (FvCB or ML)
41
- - `GET /api/photosynthesis/forecast`24h predicted A profile
42
-
43
- ### Control System
44
- - `GET /api/control/status` — last control loop tick
45
- - `GET /api/control/plan` — current day-ahead plan
46
- - `GET /api/control/budget` — energy budget status
47
- - `GET /api/control/trackers` tracker angles and modes
48
-
49
- ### Chatbot (Gemini AI)
50
- - `POST /api/chatbot/message` send message to vineyard advisor
51
- - `POST /api/chatbot/feedback` submit feedback on response
52
- - `GET /api/chatbot/briefing` current system status briefing
53
-
54
- ### Biology
55
- - `GET /api/biology/phenology` current growth stage
56
- - `GET /api/biology/rules` all biology rules
57
- - `GET /api/biology/rules/{name}` — single rule detail
58
- - `GET /api/biology/chill-units?season_start` — accumulated chill units
59
-
60
- ### Auth
61
- - `POST /api/auth/login` JWT token (guest mode when JWT_SECRET unset)
62
-
63
- ### Events
64
- - `GET /api/events/stream` SSE stream for live frontend updates
65
-
66
- Interactive docs at `/docs`.
67
-
68
- ## Environment Variables
69
-
70
- | Variable | Required | Description |
71
- |----------|----------|-------------|
72
- | `IMS_API_TOKEN` | Yes | IMS weather API token |
73
- | `THINGSBOARD_HOST` | Yes | ThingsBoard server URL |
74
- | `THINGSBOARD_USERNAME` | Yes | ThingsBoard login |
75
- | `THINGSBOARD_PASSWORD` | Yes | ThingsBoard password |
76
- | `GOOGLE_API_KEY` | No | Gemini AI for chatbot/advisor |
77
- | `JWT_SECRET` | No | JWT signing key (guest mode if unset) |
78
- | `ADMIN_PASSWORD` | No | Login password |
79
- | `REDIS_URL` | No | Upstash Redis for shared cache |
80
- | `SENTRY_DSN` | No | Sentry error tracking |
81
- | `SMTP_HOST` | No | Email alerts (with ALERT_EMAIL_TO) |
82
- | `ALERT_EMAIL_TO` | No | Alert recipient email(s) |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolarWine — Photosynthesis Prediction for Agrivoltaic Vineyards
2
+
3
+ A two-stage pipeline that models grapevine photosynthesis under agrivoltaic panels (solar panels above vineyard rows) and predicts it from publicly available weather data. Built for the Sde Boker research site (Negev, Israel).
4
+
5
+ ## Status — May 2026
6
+
7
+ - **2026 sensor migration complete.** `DEVICE_REGISTRY` clean-replaced (19 Crop_2Soil with spectrometer + dual soil profile, 12 panel thermocouples, 4 trackers). `VineSnapshot` rewritten with NDVI / PRI / PSRI / SIPI / GCI / LCI / DUVI + dual-soil + position-keyed dicts. Treatment = rows 501/502/503/504/509; Reference = row 202.
8
+ - **Energy predictor refreshed.** New `scripts/refresh_energy_data.py` + `scripts/train_energy_predictor.py`. Hourly MAE 4.5 kWh on the May hold-out; **daily bias +50%** is intervention-regime drift (training tracker-angle avg −15°, hold-out avg −51° due to active shading), not a code bug — fix lands once ≥3 months of 2026 growing-season data accumulate (~Aug 2026).
9
+ - **2026 ML pipeline scaffolded.** `scripts/collect_2026_training_data.py` pulls treatment + reference Crop_2Soil + IMS, applies a tracker-angle + ShadowModel PAR-shading correction (PAR factor 0.10–1.00, median 0.82), then computes Farquhar A as labels. First 10 days: 1,896 rows, daytime mean A ≈ 11.7 µmol CO₂/m²/s — squarely in the Greer–Weedon Semillon range.
10
+ - **Frontend redesigned.** React app at `frontend/` rebuilt to the SolarWine editorial design system (Negev sand surfaces, vine green + sun gold, Source Serif 4 + IBM Plex Sans + IBM Plex Mono). All 11 pages — Home, Agronomy, Photosynthesis, Shading, Power, Trackers, Control loop, Advisor, Monitoring, Documentation, **Research** (new) — use the new chrome and Recharts wrappers.
11
+ - **Streamlit dashboard cleaned of stale data.** Control Replay defaults to a fresh 2026 sim log; `scripts/run_control_simulation.py` defaults to `today − 7 d / today`; Shading tab clarifies the legacy `sensors_wide.csv` is frozen at Feb 2026; freshness warnings flag stale logs.
12
+
13
+ See `context/3_todo.md` § 12 for the rolling work log. `frontend/` is the production UI; `app.py` remains as the legacy Streamlit dashboard.
14
+
15
+ ## What it does
16
+
17
+ **Stage 1 Mechanistic model (sensors → photosynthesis rate A)**
18
+ Uses on-site sensor data (PAR, leaf temperature, CO2, VPD, air temperature) collected at 15-minute intervals to compute net carbon assimilation *A* via the Farquhar et al. (1980) biochemical model, with Greer & Weedon (2012) temperature response curves calibrated for *Vitis vinifera*. Includes Crop Water Stress Index (CWSI) and stomatal conductance adjustments. Only the growing season (May–September) is used; vines are dormant October–April.
19
+
20
+ **Stage 2ML prediction (IMS weather → A)**
21
+ Trains regression models to predict *A* using only Israel Meteorological Service (IMS) station 43 data (air temperature, GHI solar radiation, relative humidity, rainfall, wind speed). This enables photosynthesis forecasting without on-site sensors. Models evaluated: Linear Regression, Decision Tree, Random Forest, Gradient Boosting, and XGBoost.
22
+
23
+ **Time-series forecasting**
24
+ Direct multi-horizon forecasting (15 min, 1 hour, 1 day, 1 week, 1 month ahead) using lag features and daytime-session indexing to handle overnight gaps. Per-horizon XGBoost/GradientBoosting models.
25
+
26
+ **Tracker optimizer**
27
+ Simulates agrivoltaic panel tilt angles to find the optimal tradeoff between solar energy capture and crop photosynthesis. Computes a stress-aware daily schedule that recommends shading during heat stress periods, with season-level summaries of energy yield, photosynthesis impact, and estimated water savings.
28
+
29
+ **Solar geometry & canopy photosynthesis**
30
+ Calculates sun position, panel shadow geometry, and diffuse/direct PAR reaching the vine canopy at configurable panel dimensions and row spacings.
31
+
32
+ ## Interactive Streamlit app
33
+
34
+ A branded dashboard (`app.py`, ~1900 lines) with:
35
+
36
+ - **Sidebar** — Run Stage 1 / Stage 2, download IMS data (chunked 7-day fetches for 2024–2025), data status indicators
37
+ - **Stage 1 tab** Labels distribution, validation plots, Farquhar model outputs
38
+ - **Stage 2 tab** — Model comparison metrics (RMSE, MAE, R²), prediction vs actual plots, feature importance
39
+ - **EDA tab** — Sensor distributions, A over time, IMS weather statistics, merged dataset exploration
40
+ - **Time-Series tab**Multi-horizon forecast results with per-tab explanations
41
+ - **Tracker Optimizer tab** Tilt-angle simulations, stress heatmaps, daily shading schedule, season summary
42
+ - **About tab** — Two-stage rationale, methodology, and project roadmap
43
+
44
+ ## Project structure
45
+
46
+ ```
47
+ config/settings.py Site/panel geometry, IMS channel map, paths, train ratio
48
+ src/
49
+ farquhar_model.py Farquhar–Greer–Weedon mechanistic photosynthesis model
50
+ sensor_data_loader.py Load and filter on-site sensor CSVs
51
+ ims_client.py IMS REST API client with caching
52
+ preprocessor.py Merge IMS + labels, add time features, temporal split
53
+ predictor.py Train/evaluate ML regressors (LR, DT, RF, GBR, XGB)
54
+ ts_predictor.py Lag-based multi-horizon time-series forecasting
55
+ tracker_optimizer.py Tilt-angle simulation and stress-aware scheduling
56
+ solar_geometry.py Sun position, shadow projection, canopy PAR
57
+ canopy_photosynthesis.py Canopy-level photosynthesis integration
58
+ scripts/
59
+ download_ims_data.py CLI: fetch IMS station data (--list-channels, date range)
60
+ run_pipeline.py CLI: execute full Stage 1 → Stage 2 pipeline
61
+ create_sample_data.py Generate trimmed sample CSV for cloud deployment
62
+ eda.py Standalone exploratory data analysis
63
+ app.py Streamlit dashboard
64
+ Data/Seymour/ On-site sensor data (15-min intervals)
65
+ Data/ims/ Cached IMS API responses
66
+ Data/processed/ Intermediate files (labels, merged data)
67
+ outputs/ Metrics CSVs and plots (gitignored)
68
+ assets/ Logo and vineyard imagery
69
+ ```
70
+
71
+ ## Setup
72
+
73
+ ```bash
74
+ python -m venv .venv
75
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
76
+ pip install -r requirements.txt
77
+ ```
78
+
79
+ Create a `.env` file with your IMS API token:
80
+
81
+ ```
82
+ IMS_API_TOKEN=<your-token>
83
+ ```
84
+
85
+ See [ims_api_documentation.md](ims_api_documentation.md) for token registration.
86
+
87
+ ## Usage
88
+
89
+ ### Download IMS weather data
90
+
91
+ ```bash
92
+ python -m scripts.download_ims_data --list-channels # show available channels
93
+ python -m scripts.download_ims_data --from 2024-01-01 --to 2024-12-31
94
+ ```
95
+
96
+ ### Run the pipeline
97
+
98
+ ```bash
99
+ python -m scripts.run_pipeline
100
+ ```
101
+
102
+ Stage 1: load sensors → filter daytime → keep growing season (May–Sep) → compute Farquhar *A* → save labels.
103
+ Stage 2: load IMS + labels → merge on timestamp → add time features → temporal train/test split → train & evaluate models → save metrics and plots to `outputs/`.
104
+
105
+ ### Launch the app
106
+
107
+ ```bash
108
+ streamlit run app.py
109
+ ```
110
+
111
+ ## Key dependencies
112
+
113
+ pandas, numpy, scikit-learn, xgboost, pvlib, matplotlib, seaborn, plotly, streamlit, requests, python-dotenv
114
+
115
+ ## Deployment
116
+
117
+ See [DEPLOY.md](DEPLOY.md) for Streamlit Community Cloud deployment instructions. A trimmed sensor sample (`sensors_wide_sample.csv`) is included for cloud use where the full 982 MB dataset is not available.
app.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streamlit app: SolarWine — Smart Shading for Vineyard Solar Panels.
3
+ 5 tabs: Overview, Photosynthesis & Data, Forecasting, Shading Simulator, Documentation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ PROJECT_ROOT = Path(__file__).resolve().parent
12
+ if str(PROJECT_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(PROJECT_ROOT))
14
+
15
+ try:
16
+ from dotenv import load_dotenv
17
+ load_dotenv(PROJECT_ROOT / ".env")
18
+ except ImportError:
19
+ pass
20
+
21
+ import os
22
+ import streamlit as st
23
+
24
+ # Copy Streamlit secrets into os.environ so downstream clients (ThingsBoard,
25
+ # IMS, etc.) can read them via os.environ without any code changes.
26
+ for key, value in st.secrets.items():
27
+ if isinstance(value, str) and key not in os.environ:
28
+ os.environ[key] = value
29
+
30
+ from ui.bootstrap import img_to_base64, now_israel
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Page config
34
+ # ---------------------------------------------------------------------------
35
+
36
+ st.set_page_config(page_title="SolarWine - Smart Shading for Vineyards", layout="wide")
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Migration banner (shows only when new frontend URL is configured)
40
+ # ---------------------------------------------------------------------------
41
+ _NEW_FRONTEND_URL = os.environ.get("SOLARWINE_FRONTEND_URL", "")
42
+ if _NEW_FRONTEND_URL:
43
+ st.info(
44
+ f"A new version of the SolarWine dashboard is available at "
45
+ f"[{_NEW_FRONTEND_URL}]({_NEW_FRONTEND_URL}). "
46
+ f"This Streamlit app will be retired soon.",
47
+ icon="🔄",
48
+ )
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Brand CSS
52
+ # ---------------------------------------------------------------------------
53
+
54
+ _BRAND_GREEN = "#00BD3E"
55
+ _BRAND_DARK = "#1A1A1A"
56
+
57
+ st.markdown(f"""
58
+ <style>
59
+ /* SolarWine brand overrides — async font load (non-blocking) */
60
+ @font-face {{
61
+ font-family: 'Open Sans';
62
+ font-style: normal;
63
+ font-weight: 300 400 600 700;
64
+ font-display: swap;
65
+ src: url('https://fonts.gstatic.com/s/opensans/v40/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-mu0SC55I.woff2') format('woff2');
66
+ }}
67
+
68
+ html, body, [class*="css"] {{
69
+ font-family: 'Open Sans', sans-serif;
70
+ }}
71
+
72
+ /* Header bar */
73
+ header[data-testid="stHeader"] {{
74
+ background-color: {_BRAND_DARK};
75
+ }}
76
+
77
+ /* Metric value color */
78
+ [data-testid="stMetricValue"] {{
79
+ color: {_BRAND_GREEN};
80
+ }}
81
+
82
+ /* Tab labels */
83
+ button[data-baseweb="tab"] {{
84
+ font-weight: 600;
85
+ }}
86
+
87
+ /* Sidebar background */
88
+ section[data-testid="stSidebar"] {{
89
+ background-color: {_BRAND_DARK};
90
+ }}
91
+ section[data-testid="stSidebar"] * {{
92
+ color: #FFFFFF !important;
93
+ }}
94
+ section[data-testid="stSidebar"] button {{
95
+ background-color: {_BRAND_GREEN} !important;
96
+ color: #FFFFFF !important;
97
+ border: none !important;
98
+ border-radius: 6px !important;
99
+ }}
100
+ section[data-testid="stSidebar"] button:hover {{
101
+ background-color: #00a035 !important;
102
+ }}
103
+
104
+ /* Hero banner */
105
+ .hero-banner {{
106
+ background: linear-gradient(135deg, {_BRAND_DARK} 0%, #2d3a2d 100%);
107
+ padding: 2rem 2.5rem;
108
+ border-radius: 12px;
109
+ margin-bottom: 1.5rem;
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 2rem;
113
+ }}
114
+ .hero-banner img {{
115
+ max-height: 48px;
116
+ }}
117
+ .hero-text {{
118
+ color: #FFFFFF;
119
+ }}
120
+ .hero-text h1 {{
121
+ margin: 0 0 0.3rem 0;
122
+ font-size: 1.8rem;
123
+ font-weight: 700;
124
+ color: #FFFFFF;
125
+ }}
126
+ .hero-text p {{
127
+ margin: 0;
128
+ font-size: 1.05rem;
129
+ color: #B8D4B8;
130
+ }}
131
+ </style>
132
+ """, unsafe_allow_html=True)
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Hero banner with logo
136
+ # ---------------------------------------------------------------------------
137
+
138
+ _ASSETS = PROJECT_ROOT / "assets"
139
+ _logo_path = _ASSETS / "logo.png"
140
+
141
+ if _logo_path.exists():
142
+ _logo_b64 = img_to_base64(_logo_path)
143
+ st.markdown(f"""
144
+ <div class="hero-banner">
145
+ <img src="data:image/png;base64,{_logo_b64}" alt="SolarWine logo">
146
+ <div class="hero-text">
147
+ <h1>Smart Shading for Vineyard Solar Panels</h1>
148
+ <p>Empowering growers · Harvesting sunshine</p>
149
+ </div>
150
+ </div>
151
+ """, unsafe_allow_html=True)
152
+ else:
153
+ st.title("SolarWine — Smart Shading for Vineyard Solar Panels")
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Sidebar
157
+ # ---------------------------------------------------------------------------
158
+
159
+ sidebar = st.sidebar
160
+ if _logo_path.exists():
161
+ sidebar.image(str(_logo_path), width=180)
162
+
163
+ _now = now_israel()
164
+ sidebar.caption("**Site:** Yeruham, Israel")
165
+ sidebar.markdown(f"**Date:** {_now.strftime('%Y-%m-%d')}")
166
+ sidebar.markdown(f"**Time (local):** {_now.strftime('%H:%M')}")
167
+ st.session_state.current_time_israel = _now
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Navigation — only the selected page renders (avoids running all 6 tabs)
171
+ # ---------------------------------------------------------------------------
172
+
173
+ _PAGES = [
174
+ "System Status",
175
+ "Overview",
176
+ "Photosynthesis & Data",
177
+ "Forecasting",
178
+ "Shading Simulator",
179
+ "Documentation",
180
+ ]
181
+
182
+ _selected = sidebar.radio("Navigate", _PAGES, label_visibility="collapsed")
183
+
184
+ if _selected == "System Status":
185
+ from ui.tab_system_status import render_tab_system_status
186
+ render_tab_system_status()
187
+ elif _selected == "Overview":
188
+ from ui.tab_overview import render_tab_overview
189
+ render_tab_overview()
190
+ elif _selected == "Photosynthesis & Data":
191
+ from ui.tab_data import render_tab_data
192
+ render_tab_data()
193
+ elif _selected == "Forecasting":
194
+ from ui.tab_forecast import render_tab_forecast
195
+ render_tab_forecast()
196
+ elif _selected == "Shading Simulator":
197
+ from ui.tab_shading import render_tab_shading
198
+ render_tab_shading()
199
+ elif _selected == "Documentation":
200
+ from ui.tab_docs import render_tab_docs
201
+ render_tab_docs()
assets/logo.png ADDED
assets/vineyard_closeup.png ADDED

Git LFS Details

  • SHA256: 6e60d3120f0b2a85fedfd801a81e2a9a07977714279f148755b38c5b1971a711
  • Pointer size: 131 Bytes
  • Size of remote file: 159 kB
assets/vineyard_panels.png ADDED

Git LFS Details

  • SHA256: 59172bdd4c49c8bee82112de76687c0199038f50d5eaa1179bd6f55268c175e3
  • Pointer size: 131 Bytes
  • Size of remote file: 181 kB
backend/api/routes/control.py CHANGED
@@ -75,3 +75,104 @@ async def control_trackers(hub: DataHub = Depends(get_datahub)):
75
  """Live tracker angles (cached via VineSensorService)."""
76
  result = hub.vine_sensors.get_tracker_details()
77
  return {"trackers": result.get("trackers", []), "source": "ThingsBoard"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  """Live tracker angles (cached via VineSensorService)."""
76
  result = hub.vine_sensors.get_tracker_details()
77
  return {"trackers": result.get("trackers", []), "source": "ThingsBoard"}
78
+
79
+
80
+ @router.get("/trackers/{tracker_id}/history")
81
+ async def tracker_history(
82
+ tracker_id: str,
83
+ hours: int = 24,
84
+ user: dict = Depends(optional_auth),
85
+ ):
86
+ """15-min angle history for one tracker, last N hours.
87
+
88
+ Returns a list of `{ts, angle}` ordered by time asc. Floors timestamps
89
+ to the 15-min bucket so the UI sparkline lands on stable ticks.
90
+ """
91
+ from datetime import datetime, timedelta, timezone
92
+ import pandas as pd
93
+ from src.data.thingsboard_client import ThingsBoardClient, DEVICE_REGISTRY
94
+
95
+ if tracker_id not in DEVICE_REGISTRY or not tracker_id.startswith("Tracker"):
96
+ raise HTTPException(404, detail=f"Unknown tracker: {tracker_id}")
97
+ if not 1 <= hours <= 24 * 14:
98
+ raise HTTPException(400, detail="hours must be 1..336")
99
+
100
+ end = datetime.now(tz=timezone.utc)
101
+ start = end - timedelta(hours=hours)
102
+ try:
103
+ df = ThingsBoardClient().get_timeseries(
104
+ tracker_id, ["angle"],
105
+ start=start, end=end,
106
+ limit=10_000, interval_ms=900_000, agg="AVG",
107
+ )
108
+ except Exception as exc:
109
+ raise HTTPException(status_code=502, detail=f"TB fetch failed: {exc}")
110
+ if df.empty:
111
+ return {"tracker": tracker_id, "hours": hours, "samples": []}
112
+
113
+ df.index = pd.to_datetime(df.index, utc=True).floor("15min")
114
+ df = df[~df.index.duplicated(keep="last")]
115
+ return {
116
+ "tracker": tracker_id,
117
+ "hours": hours,
118
+ "samples": [
119
+ {"ts": ts.isoformat(), "angle": round(float(v), 2)}
120
+ for ts, v in df["angle"].items()
121
+ if pd.notna(v)
122
+ ],
123
+ }
124
+
125
+
126
+ @router.get("/dispatches/summary")
127
+ async def dispatch_summary(user: dict = Depends(optional_auth)):
128
+ """Recent-tick dispatch statistics: count, p50/p95 latency, hard fails.
129
+
130
+ Reads from Redis where the worker writes `control:recent_ticks`
131
+ (rolling window of the last ~96 ticks). Returns empty stats if the
132
+ worker hasn't run yet — UI degrades to "—".
133
+ """
134
+ redis = get_redis_client()
135
+ if not redis:
136
+ return _empty_dispatch_summary()
137
+ ticks = redis.get_json("control:recent_ticks") or []
138
+ if not isinstance(ticks, list) or not ticks:
139
+ # Fall back to the single last tick if available.
140
+ last = redis.get_json("control:last_tick")
141
+ ticks = [last] if isinstance(last, dict) else []
142
+
143
+ if not ticks:
144
+ return _empty_dispatch_summary()
145
+
146
+ latencies = [
147
+ float(t.get("dispatch_latency_ms"))
148
+ for t in ticks
149
+ if isinstance(t, dict) and t.get("dispatch_latency_ms") is not None
150
+ ]
151
+ n_disp = sum(1 for t in ticks if isinstance(t, dict) and t.get("dispatch"))
152
+ n_fail = sum(1 for t in ticks if isinstance(t, dict) and t.get("dispatch_error"))
153
+
154
+ def _pct(xs, p):
155
+ if not xs:
156
+ return None
157
+ xs = sorted(xs)
158
+ k = max(0, min(len(xs) - 1, int(round(p / 100 * (len(xs) - 1)))))
159
+ return round(xs[k], 1)
160
+
161
+ return {
162
+ "tick_count": len(ticks),
163
+ "dispatches": n_disp,
164
+ "hard_fails": n_fail,
165
+ "latency_p50_ms": _pct(latencies, 50),
166
+ "latency_p95_ms": _pct(latencies, 95),
167
+ "latency_mean_ms": round(sum(latencies) / len(latencies), 1) if latencies else None,
168
+ "window_first_ts": ticks[0].get("timestamp") if isinstance(ticks[0], dict) else None,
169
+ "window_last_ts": ticks[-1].get("timestamp") if isinstance(ticks[-1], dict) else None,
170
+ }
171
+
172
+
173
+ def _empty_dispatch_summary() -> dict:
174
+ return {
175
+ "tick_count": 0, "dispatches": 0, "hard_fails": 0,
176
+ "latency_p50_ms": None, "latency_p95_ms": None, "latency_mean_ms": None,
177
+ "window_first_ts": None, "window_last_ts": None,
178
+ }
backend/api/routes/energy.py CHANGED
@@ -49,4 +49,83 @@ async def energy_history(
49
  async def energy_predict(target_date: str, hub: DataHub = Depends(get_datahub)):
50
  _validate_date(target_date)
51
  result = hub.energy.predict(target_date=target_date)
52
- return check_service_error(result, "energy predict")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  async def energy_predict(target_date: str, hub: DataHub = Depends(get_datahub)):
50
  _validate_date(target_date)
51
  result = hub.energy.predict(target_date=target_date)
52
+ result = check_service_error(result, "energy predict")
53
+ # Forecast remaining today — sum hourly_profile after current UTC hour.
54
+ if target_date == str(__import__("datetime").date.today()):
55
+ hp = result.get("hourly_profile") or []
56
+ now_h = __import__("datetime").datetime.now(__import__("datetime").timezone.utc).hour
57
+ remaining = sum(float(h.get("energy_kwh", 0) or 0) for h in hp if int(h.get("hour", -1)) >= now_h)
58
+ result["remaining_kwh_today"] = round(remaining, 2)
59
+ return result
60
+
61
+
62
+ @router.get("/history-daily")
63
+ async def energy_history_daily(
64
+ days: int = Query(7, ge=1, le=365, description="Number of calendar days back from today"),
65
+ hub: DataHub = Depends(get_datahub),
66
+ ):
67
+ """Daily kWh totals for the last N calendar days.
68
+
69
+ Sums TB Plant `production` (Wh) per UTC day. Returns a list ordered by
70
+ date asc, including days with zero/missing production (filled as 0).
71
+ Built for the Power-page weekly bars and the revenue ledger.
72
+ """
73
+ from datetime import datetime, timedelta, timezone
74
+ import pandas as pd
75
+
76
+ from src.data.thingsboard_client import ThingsBoardClient
77
+ end = datetime.now(tz=timezone.utc)
78
+ start = (end - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
79
+ try:
80
+ df = ThingsBoardClient().get_asset_timeseries(
81
+ "Plant", keys=["production"], start=start, end=end,
82
+ limit=10_000, interval_ms=3_600_000, agg="SUM",
83
+ )
84
+ except Exception as exc:
85
+ raise HTTPException(status_code=502, detail=f"TB fetch failed: {exc}")
86
+
87
+ if df.empty:
88
+ return []
89
+
90
+ df.index = pd.to_datetime(df.index, utc=True).floor("h")
91
+ by_day = df.groupby(df.index.date)["production"].sum() / 1000.0
92
+
93
+ # Fill missing days with 0 so the bar chart has stable bucket count.
94
+ out = []
95
+ cursor = start.date()
96
+ today = end.date()
97
+ while cursor <= today:
98
+ out.append({
99
+ "date": cursor.isoformat(),
100
+ "daily_kwh": round(float(by_day.get(cursor, 0.0)), 2),
101
+ })
102
+ cursor += timedelta(days=1)
103
+ return out
104
+
105
+
106
+ @router.get("/inverters")
107
+ async def energy_inverters(hub: DataHub = Depends(get_datahub)):
108
+ """Per-inverter live state.
109
+
110
+ The 2026 fleet exposes only a Plant-level asset on ThingsBoard
111
+ (no per-inverter telemetry). We return a single entry derived from
112
+ that asset so the UI degrades gracefully; when per-inverter
113
+ telemetry is added, extend this endpoint to enumerate them.
114
+ """
115
+ current = hub.energy.get_current()
116
+ p_kw = current.get("power_kw") if isinstance(current, dict) else None
117
+ return {
118
+ "inverters": [
119
+ {
120
+ "id": "Plant",
121
+ "model": "SolarEdge (aggregate)",
122
+ "capacity_kw": 48.0,
123
+ "power_kw": p_kw,
124
+ "status": "live" if p_kw is not None else "offline",
125
+ "ac_freq_hz": None,
126
+ "dc_string_v": None,
127
+ },
128
+ ],
129
+ "note": "Per-inverter telemetry not yet plumbed; single aggregate "
130
+ "Plant asset returned. See todo §12.5.",
131
+ }
backend/api/routes/events.py CHANGED
@@ -13,10 +13,12 @@ from __future__ import annotations
13
  import json
14
  import logging
15
 
16
- from fastapi import APIRouter, Request
17
  from starlette.responses import StreamingResponse
18
 
 
19
  from backend.api.events import event_bus
 
20
 
21
  log = logging.getLogger("solarwine.sse")
22
  router = APIRouter()
@@ -53,3 +55,85 @@ async def event_stream(request: Request):
53
  "X-Accel-Buffering": "no", # disable nginx/proxy buffering
54
  },
55
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  import json
14
  import logging
15
 
16
+ from fastapi import APIRouter, Depends, Query, Request
17
  from starlette.responses import StreamingResponse
18
 
19
+ from backend.api.deps import get_datahub, get_redis_client
20
  from backend.api.events import event_bus
21
+ from src.data.data_providers import DataHub
22
 
23
  log = logging.getLogger("solarwine.sse")
24
  router = APIRouter()
 
55
  "X-Accel-Buffering": "no", # disable nginx/proxy buffering
56
  },
57
  )
58
+
59
+
60
+ @router.get("/list")
61
+ async def events_list(
62
+ limit: int = Query(50, ge=1, le=500),
63
+ hub: DataHub = Depends(get_datahub),
64
+ ):
65
+ """Recent discrete events (alerts + dispatches + data-flow changes).
66
+
67
+ There's no persisted event log yet. This endpoint stitches together
68
+ the freshest sources we *do* have so the UI alert stream and
69
+ dispatch log can render live without fabrication:
70
+
71
+ - data-flow monitor state per source (any non-green → event)
72
+ - Redis `control:recent_ticks` (per-tick dispatch result)
73
+ - Redis `control:last_tick` (single fallback if no rolling window)
74
+ """
75
+ from datetime import datetime, timezone
76
+
77
+ events: list[dict] = []
78
+
79
+ # ---------- Data-flow monitor → 1 event per source not in `green` ----------
80
+ try:
81
+ flow = hub.vine_sensors.get_data_flow_status() # type: ignore[attr-defined]
82
+ except Exception:
83
+ flow = None
84
+ if isinstance(flow, dict):
85
+ checked = flow.get("checked_at") or datetime.now(timezone.utc).isoformat()
86
+ for key, src in (flow.get("sources") or {}).items():
87
+ status = (src or {}).get("status", "unknown")
88
+ if status == "green":
89
+ continue
90
+ events.append({
91
+ "id": f"flow:{key}:{status}",
92
+ "ts": checked,
93
+ "source": key,
94
+ "name": f"{key} {status}",
95
+ "detail": (src or {}).get("message") or "",
96
+ "severity": "warn" if status == "yellow" else "fail",
97
+ "kind": "data_flow",
98
+ "acked": False,
99
+ })
100
+
101
+ # ---------- Recent control ticks → dispatch + plan-change events ----------
102
+ redis = get_redis_client()
103
+ ticks = []
104
+ if redis:
105
+ ticks = redis.get_json("control:recent_ticks") or []
106
+ if not ticks:
107
+ last = redis.get_json("control:last_tick")
108
+ ticks = [last] if isinstance(last, dict) else []
109
+ for t in ticks[-min(len(ticks), limit):]:
110
+ if not isinstance(t, dict):
111
+ continue
112
+ ts = t.get("timestamp")
113
+ if t.get("dispatch_error"):
114
+ events.append({
115
+ "id": f"dispatch_err:{ts}",
116
+ "ts": ts,
117
+ "source": "ControlLoop",
118
+ "name": "dispatch failed",
119
+ "detail": str(t.get("dispatch_error"))[:240],
120
+ "severity": "fail",
121
+ "kind": "dispatch",
122
+ "acked": False,
123
+ })
124
+ elif t.get("dispatch"):
125
+ events.append({
126
+ "id": f"dispatch:{ts}",
127
+ "ts": ts,
128
+ "source": "ControlLoop",
129
+ "name": f"angle → {t.get('target_angle')}°",
130
+ "detail": (f"offset {t.get('plan_offset_deg', 0)}°"
131
+ + (f" · {t.get('mode')}" if t.get('mode') else "")),
132
+ "severity": "info",
133
+ "kind": "dispatch",
134
+ "acked": True, # successful dispatch is auto-acked
135
+ })
136
+
137
+ # Newest first, capped.
138
+ events.sort(key=lambda e: e.get("ts") or "", reverse=True)
139
+ return {"events": events[:limit], "count": len(events[:limit])}
backend/api/routes/photosynthesis.py CHANGED
@@ -54,3 +54,79 @@ async def ps_current(
54
  @router.get("/forecast")
55
  async def ps_forecast(hub: DataHub = Depends(get_datahub)):
56
  return hub.photosynthesis.forecast_day_ahead()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  @router.get("/forecast")
55
  async def ps_forecast(hub: DataHub = Depends(get_datahub)):
56
  return hub.photosynthesis.forecast_day_ahead()
57
+
58
+
59
+ @router.get("/stress-grid")
60
+ async def stress_grid(
61
+ date: str = Query(None, description="Target date YYYY-MM-DD (default: today)"),
62
+ hub: DataHub = Depends(get_datahub),
63
+ ):
64
+ """24-hour × 5-row CWSI matrix for the Home + Shading heatmap.
65
+
66
+ Rows are the 5 vineyard rows that have at least one active sensor:
67
+ 501, 502, 504, 509 (treatment) + 202 (reference). For each (row, hour)
68
+ cell, CWSI = clip((Tleaf − Tair_IRT) / 15, 0, 1) averaged across all
69
+ Crop_2Soil devices in that row that reported in the hour.
70
+
71
+ Sparse rows (no telemetry in an hour) get a null cell; the UI renders
72
+ those as the "low-stress" colour. Returns the requested date if given,
73
+ else the most recent 24-hour rolling window.
74
+ """
75
+ from datetime import datetime, timedelta, timezone
76
+ import pandas as pd
77
+ from src.data.thingsboard_client import (
78
+ ThingsBoardClient, DEVICE_REGISTRY, VineArea,
79
+ )
80
+
81
+ rows_to_report = [501, 502, 504, 509, 202]
82
+ devices_by_row = {
83
+ r: [n for n, d in DEVICE_REGISTRY.items()
84
+ if n.startswith("Crop_2Soil") and d.row == r]
85
+ for r in rows_to_report
86
+ }
87
+
88
+ if date:
89
+ try:
90
+ day = datetime.fromisoformat(date).replace(tzinfo=timezone.utc)
91
+ start = day
92
+ end = day + timedelta(days=1)
93
+ except ValueError:
94
+ raise HTTPException(400, "date must be YYYY-MM-DD")
95
+ else:
96
+ end = datetime.now(tz=timezone.utc)
97
+ start = end - timedelta(hours=24)
98
+
99
+ client = ThingsBoardClient()
100
+ grid: list[list[float | None]] = []
101
+ for row_id in rows_to_report:
102
+ hourly = [None] * 24
103
+ for dev in devices_by_row.get(row_id, []):
104
+ try:
105
+ df = client.get_timeseries(
106
+ dev, ["leafTemperature", "ambientTemperatureIRT"],
107
+ start=start, end=end,
108
+ limit=10_000, interval_ms=3_600_000, agg="AVG",
109
+ )
110
+ except Exception:
111
+ continue
112
+ if df.empty or "leafTemperature" not in df.columns or "ambientTemperatureIRT" not in df.columns:
113
+ continue
114
+ df.index = pd.to_datetime(df.index, utc=True).floor("h")
115
+ df = df[~df.index.duplicated(keep="last")]
116
+ df["cwsi"] = ((df["leafTemperature"] - df["ambientTemperatureIRT"]) / 15.0).clip(0, 1)
117
+ for ts, v in df["cwsi"].items():
118
+ if pd.isna(v):
119
+ continue
120
+ h = ts.hour
121
+ prev = hourly[h]
122
+ hourly[h] = float(v) if prev is None else (prev + float(v)) / 2.0
123
+ grid.append([None if v is None else round(v, 3) for v in hourly])
124
+
125
+ return {
126
+ "rows": [str(r) for r in rows_to_report],
127
+ "hours": list(range(24)),
128
+ "grid": grid,
129
+ "window_start": start.isoformat(),
130
+ "window_end": end.isoformat(),
131
+ "source": "Crop_2Soil leaf-air ΔT, hourly, CWSI = clip((Tleaf − Tair)/15, 0, 1)",
132
+ }
backend/workers/control_tick.py CHANGED
@@ -61,6 +61,17 @@ def main():
61
  redis.set_json("control:last_tick", safe, ttl=1200) # 20 min TTL
62
  log.info("Tick result saved to Redis")
63
 
 
 
 
 
 
 
 
 
 
 
 
64
  # Also persist budget state for the /control/budget endpoint
65
  from config.settings import MAX_ENERGY_REDUCTION_PCT
66
  budget_info = {
 
61
  redis.set_json("control:last_tick", safe, ttl=1200) # 20 min TTL
62
  log.info("Tick result saved to Redis")
63
 
64
+ # Rolling window of recent ticks so the Trackers reliability stats
65
+ # + Monitoring event stream have something to compute over. Keep
66
+ # the last 96 ticks (24 h at 15-min cadence). TTL 48 h so the
67
+ # window survives a full day of downtime.
68
+ recent = redis.get_json("control:recent_ticks") or []
69
+ if not isinstance(recent, list):
70
+ recent = []
71
+ recent.append(safe)
72
+ recent = recent[-96:]
73
+ redis.set_json("control:recent_ticks", recent, ttl=2 * 24 * 3600)
74
+
75
  # Also persist budget state for the /control/budget endpoint
76
  from config.settings import MAX_ENERGY_REDUCTION_PCT
77
  budget_info = {
config/settings.py CHANGED
@@ -217,3 +217,34 @@ ENERGY_STALE_RED_MIN = 60 # Energy telemetry older than this = red
217
 
218
  # Email alerts (activated when SMTP_HOST + ALERT_EMAIL_TO env vars are set)
219
  ALERT_COOLDOWN_MIN = 60 # minimum minutes between repeat alerts for same source
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  # Email alerts (activated when SMTP_HOST + ALERT_EMAIL_TO env vars are set)
219
  ALERT_COOLDOWN_MIN = 60 # minimum minutes between repeat alerts for same source
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Proxy-A score — composite photosynthesis health metric
223
+ # ---------------------------------------------------------------------------
224
+ # Replaces direct LiCor A measurement (not available this season). Combines
225
+ # 5 already-collected sensors into a single 0..1 scalar where 1 = healthy.
226
+ # See src/proxy_a_score.py and src/season_ground_truth.py.
227
+ #
228
+ # Calibrate weights with two weeks of null-intervention baseline data;
229
+ # defaults below are pre-calibration starting points.
230
+
231
+ PROXY_A_WEIGHTS = {
232
+ "pri": 0.30, # Photochemical Reflectance Index — light-use efficiency
233
+ "psri": 0.20, # senescence (inverted: low = good)
234
+ "delta_t": 0.25, # leaf − air ΔT (inverted: low = good)
235
+ "soil": 0.15, # shallow soil moisture, normalised availability
236
+ "ndvi": 0.10, # canopy greenness baseline
237
+ }
238
+
239
+ # Healthy / stress bounds per metric. Score per metric maps [stress → healthy]
240
+ # linearly into [0 → 1]; outside the range clips. Tune from baseline data.
241
+ PROXY_A_BOUNDS = {
242
+ "pri": {"stress": -0.10, "healthy": 0.05}, # higher PRI = better
243
+ "psri": {"stress": 0.30, "healthy": 0.00}, # lower PSRI = better (inverted)
244
+ "delta_t": {"stress": 5.00, "healthy": -2.00}, # lower ΔT = better (inverted)
245
+ "soil": {"stress": 10.0, "healthy": 30.0}, # higher soil moisture % = better
246
+ "ndvi": {"stress": 0.40, "healthy": 0.80}, # higher NDVI = better
247
+ }
248
+
249
+ # Path for manual ground-truth observations (SPAD, brix, yield, pruning, etc.)
250
+ MANUAL_OBSERVATIONS_PATH = DATA_DIR / "2026" / "manual_observations.csv"
context/.gitkeep ADDED
File without changes
context/1_purpose.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Photosynthesis Prediction Model – Purpose & Background
2
+
3
+ This project builds a **two-stage photosynthesis prediction pipeline for grapevines**:
4
+
5
+ - **Stage 1 (Mechanistic)**: Use on-site crop sensor data from the Seymour plot to compute leaf photosynthesis rate \(A\) using the **Farquhar et al. (1980)** model with **Greer & Weedon (2012)** parameterization for *Vitis vinifera*. This includes temperature-dependent Vcmax/Jmax, electron transport, and a Crop Water Stress Index (CWSI) based on leaf–air temperature differences and humidity.
6
+ - **Stage 2 (ML)**: Train simple machine‑learning models to predict \(A\) **using only external IMS weather station data** (station 43, Sde Boker). This lets us estimate grapevine photosynthesis from standard meteorological inputs without needing the full sensor stack.
7
+
8
+ ### Domain context
9
+
10
+ - Site: solar wine‑farm experimental vineyard near Yeruham, Israel (Seymour plot).
11
+ - Crop: grapevine (*Vitis vinifera*).
12
+ - On‑site sensors: PAR, leaf temperature, air temperature, CO₂, VPD, humidity, and crop spectral indices (PRI, NDVI variants).
13
+ - External weather: IMS station 43 (Sde Boker) providing temperature, solar radiation, humidity, rain, wind, and pressure.
14
+
15
+ ### Design principles
16
+
17
+ - **Mechanistic first, then ML**: Ground the target \(A\) in a physiological model before fitting data‑driven models.
18
+ - **Strict separation of data sources**: Stage 1 uses only on‑site sensors; Stage 2 uses only IMS weather features to avoid leakage.
19
+ - **Reproducible, well‑documented pipeline**: Clear directory layout, config in `config/settings.py`, and this `context/` folder as the long‑term project memory.
20
+
context/2_plan.md ADDED
@@ -0,0 +1,1534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolarWine 2.0 — Implementation Plan
2
+
3
+ ## Executive Summary
4
+
5
+ Evolve the existing two-stage photosynthesis prediction pipeline into a biology-driven agrivoltaic control system. The core principle: **full astronomical tracking is the default — the panels follow the sun and generate maximum energy 100% of the time**. Anti-tracking (tilting away from the sun to shade the vine) is a rare, targeted intervention used ONLY when the vine's fruiting zone is at measurable risk of heat or radiation damage, and ONLY at the minimum dose necessary to protect yield.
6
+
7
+ The user sets a hard ceiling on annual energy sacrifice (e.g., 5%). The system distributes that budget across Year → Month → Week → Day → 15-min slots, concentrating almost all of it in July–August midday windows when heat stress actually threatens grape quality. Margins at each level allow the system to respond to unexpected stress events while guaranteeing the energy ceiling is never breached.
8
+
9
+ ---
10
+
11
+ ## Biological Design Principles
12
+
13
+ These principles, drawn from viticultural research for *Vitis vinifera* in hot-arid climates, constrain every decision the algorithm makes:
14
+
15
+ ### Principle 1: Morning light is sacred — never shade mornings
16
+
17
+ Morning radiation (sunrise to ~10:00) is the most valuable light the vine receives:
18
+ - **Stomata are maximally open** in early morning (low VPD, cool air). This is when gas exchange is most efficient and photosynthetic carbon loading peaks.
19
+ - **Spring mornings (May–June)** are critical for flowering, fruit set, and early berry development. Shading during this period directly reduces yield potential.
20
+ - The vine "charges" its carbohydrate reserves in the morning to survive afternoon heat stress. Reducing morning PAR weakens the vine's ability to cope later in the day.
21
+
22
+ **Hard constraint**: Shading is PROHIBITED before 10:00 local time, regardless of any other signal.
23
+
24
+ ### Principle 2: The fruiting zone matters more than the canopy top
25
+
26
+ In a VSP (Vertical Shoot Positioning) trellis, the grape clusters hang in the **mid-canopy zone** (~0.4–0.8m height, zone index 1 in our 3-zone ShadowModel at center height ~0.6m). This zone determines grape quality:
27
+ - **Berry color** (anthocyanins) requires moderate light exposure — but direct midday sun in the Negev causes sunburn (berry surface > 35°C).
28
+ - **Sugar accumulation** (Brix) depends on canopy photosynthesis above, not direct fruit irradiance.
29
+ - **Flavor compounds** (terpenes, phenolics) develop best under filtered/diffuse light in the fruiting zone, not harsh direct radiation.
30
+
31
+ **Optimization target**: When intervention IS needed, choose the minimum tilt angle that shades the **fruiting zone** specifically while keeping the **upper canopy** (zone index 2, where 50% of leaf area lives) in full sun for photosynthesis.
32
+
33
+ ### Principle 3: Shade only when it actually helps — minimum effective dose
34
+
35
+ Shading is not inherently beneficial. Below 30°C (RuBP-limited state), the vine is light-limited and shading HURTS both energy generation AND photosynthesis. The system should only intervene when:
36
+ - The vine crosses into **Rubisco-limited state** (Tleaf > 30°C for Semillon)
37
+ - CWSI indicates real water stress (> 0.4), meaning stomata are closing due to heat/VPD
38
+ - The FvCB model confirms that reducing PAR at the current temperature would actually improve or maintain A (not reduce it)
39
+
40
+ And the intervention must be the **smallest angle offset** that brings the fruiting zone below the damage threshold. Once the stress signal drops, return immediately to full tracking.
41
+
42
+ ### Principle 4: Most of the season needs zero intervention
43
+
44
+ In a typical Sde Boker growing season:
45
+ - **May**: Canopy developing, temps moderate (25–32°C). Almost no stress events. Budget needed: ~0%.
46
+ - **June**: Temps rising, occasional heat spikes. Rare intervention. Budget needed: ~5%.
47
+ - **July**: Peak heat (35–42°C), daily midday stress. This is where 40–45% of the budget goes.
48
+ - **August**: Sustained heat, fruit ripening. Critical sunburn risk. Budget needed: 40–45%.
49
+ - **September**: Declining heat, pre-harvest. Occasional late heat waves. Budget needed: ~10%.
50
+
51
+ The budget is heavily concentrated in ~4 hours/day (11:00–15:00) during ~8 weeks (mid-July to mid-August). Outside those windows, the tracker runs at full astronomical tracking with zero sacrifice.
52
+
53
+ ---
54
+
55
+ ## What Already Exists (Baseline v1)
56
+
57
+ | Component | Module | Status |
58
+ |---|---|---|
59
+ | Farquhar/Greer-Weedon FvCB model | `src/farquhar_model.py` | Done — Vcmax/Jmax temp response, CWSI, stomatal conductance |
60
+ | Shadow/solar geometry | `src/solar_geometry.py` | Done — ShadowModel, 3-zone vertical grid (bottom/mid/top), ray-tracing, PAR distribution |
61
+ | Canopy-level photosynthesis | `src/canopy_photosynthesis.py` | Done — zone-weighted Farquhar, LAI weights [0.15, 0.35, 0.50] bottom→top |
62
+ | Tilt-angle simulation | `src/tracker_optimizer.py` | Done — cosine-factor PAR scaling, stress heatmap |
63
+ | IMS weather client | `src/ims_client.py` | Done — fetch/cache/resample IMS station 43 |
64
+ | ML predictor (Stage 2) | `src/predictor.py` | Done — LR, DT, RF, GBR, XGBoost |
65
+ | Time-series forecasting | `src/ts_predictor.py` | Done — multi-horizon lag-based |
66
+ | Preprocessor | `src/preprocessor.py` | Done — merge, time features, temporal split |
67
+ | Sensor data loader | `src/sensor_data_loader.py` | Done |
68
+ | Streamlit dashboard | `app.py` | Done — 6 tabs, branded UI |
69
+ | Site/panel config | `config/settings.py` | Done — panel 1.13m wide at 2.05m height, canopy 1.2m VSP, 3.0m row spacing |
70
+
71
+ ### Key existing geometry (from ShadowModel)
72
+
73
+ ```
74
+ Vertical zones (n_vertical=3):
75
+ Zone 0 (bottom): center ~0.2m — trunk/cordons — LAI weight 15%
76
+ Zone 1 (mid): center ~0.6m — FRUITING ZONE — LAI weight 35%
77
+ Zone 2 (top): center ~1.0m — main leaf canopy — LAI weight 50%
78
+
79
+ Panel: 1.13m wide, mounted at 2.05m, row spacing 3.0m
80
+ Canopy: 1.2m tall, 0.6m wide, VSP trellis
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Gap Analysis
86
+
87
+ ### New Components
88
+
89
+ 1. **EnergyBudgetPlanner** — Hierarchical budget with no-shade windows and fruiting-zone targeting
90
+ 2. **TradeoffEngine** — Budget-constrained, minimum-dose optimization
91
+ 3. **Command Arbiter** — Fallback logic, hysteresis, mode priority
92
+ 4. **Safety Rails** — FvCB vs ML divergence validation
93
+ 5. **Spectral Aggregator** — CWSI/NDVI/PRI from sensor data
94
+ 6. **ROI Reporting Service** — Budget utilization, LER
95
+ 7. **Weather Protection / Harvest Modes** — Override protocols
96
+ 8. **Data Schema** — Telemetry storage definitions
97
+
98
+ ### Existing Components to Upgrade
99
+
100
+ 1. **FarquharModel** — Add 30°C Semillon transition returning limiting state
101
+ 2. **ShadowModel** — Add fruiting-zone-specific shadow query
102
+ 3. **CanopyPhotosynthesisModel** — Expose per-zone A and fruiting zone metrics
103
+ 4. **Config/Settings** — Add no-shade windows, fruiting zone thresholds, budget parameters
104
+
105
+ ---
106
+
107
+ ## Implementation Phases
108
+
109
+ ### Phase 1: Configuration & Data Infrastructure
110
+
111
+ #### 1.1 Extend `config/settings.py`
112
+
113
+ ```python
114
+ # === ENERGY BUDGET ===
115
+ MAX_ENERGY_REDUCTION_PCT = 5.0 # user's hard ceiling (%)
116
+ ANNUAL_RESERVE_PCT = 15.0 # emergency reserve from total budget
117
+ WEEKLY_RESERVE_PCT = 20.0 # within-week flexibility
118
+ DAILY_MARGIN_PCT = 20.0 # real-time response pool
119
+
120
+ # Monthly budget distribution — concentrated in peak heat months
121
+ MONTHLY_BUDGET_WEIGHTS = {
122
+ 5: 0.00, # May — no intervention, canopy establishing
123
+ 6: 0.05, # June — rare, only extreme heat spikes
124
+ 7: 0.45, # July — peak heat, primary shading window
125
+ 8: 0.40, # August — sustained heat, fruit ripening/sunburn risk
126
+ 9: 0.10, # Sept — occasional late heat waves
127
+ }
128
+
129
+ # === NO-SHADE WINDOWS (hard constraints — shading PROHIBITED) ===
130
+ NO_SHADE_BEFORE_HOUR = 10 # local solar time — morning light is sacred
131
+ NO_SHADE_MONTHS = [5] # May — full spring exposure for fruit set
132
+ NO_SHADE_GHI_BELOW = 300 # W/m2 — overcast, already diffuse
133
+ NO_SHADE_TLEAF_BELOW = 28.0 # C — no heat stress risk
134
+ NO_SHADE_CWSI_BELOW = 0.3 # vine is healthy, wants light
135
+
136
+ # === SHADE-ELIGIBLE CONDITIONS (ALL must be true to allow intervention) ===
137
+ SHADE_ELIGIBLE_TLEAF_ABOVE = 30.0 # C — Semillon Rubisco transition
138
+ SHADE_ELIGIBLE_CWSI_ABOVE = 0.4 # moderate stress confirmed
139
+ SHADE_ELIGIBLE_GHI_ABOVE = 500 # W/m2 — high direct radiation
140
+ SHADE_ELIGIBLE_HOURS = (10, 16) # local solar time window
141
+
142
+ # === FRUITING ZONE ===
143
+ FRUITING_ZONE_INDEX = 1 # mid-canopy zone in 3-zone ShadowModel
144
+ FRUITING_ZONE_HEIGHT_M = 0.6 # center height of grape cluster zone
145
+ BERRY_SUNBURN_TEMP_C = 35.0 # berry surface temperature damage threshold
146
+ FRUITING_ZONE_TARGET_PAR = 400 # umol/m2/s — sufficient for quality, below burn
147
+
148
+ # === TRADEOFF ENGINE ===
149
+ CANDIDATE_OFFSETS = [0, 3, 5, 8, 10, 15, 20] # degrees off astronomical (small steps)
150
+ SIMULATION_TIMEOUT_SEC = 5
151
+
152
+ # === SAFETY RAILS ===
153
+ DIVERGENCE_THRESHOLD = 0.12 # 12% FvCB vs ML mismatch triggers fallback
154
+
155
+ # === SEMILLON FvCB ===
156
+ SEMILLON_TRANSITION_TEMP_C = 30.0
157
+
158
+ # === WEATHER PROTECTION ===
159
+ WIND_STOW_SPEED_MS = 15.0
160
+ HEAT_SHIELD_TEMP_C = 38.0
161
+ HEAT_SHIELD_CWSI = 0.6
162
+
163
+ # === MECHANICAL HARVESTING ===
164
+ HARVEST_PARK_CLEARANCE_CM = 250
165
+ HARVEST_LATERAL_WIDTH_CM = 18
166
+ HARVESTER_RPM_RANGE = (430, 460)
167
+
168
+ # === HYSTERESIS ===
169
+ HYSTERESIS_WINDOW_MIN = 15
170
+ ANGLE_TOLERANCE_DEG = 2.0
171
+
172
+ # === ROI ===
173
+ TARGET_LER = 1.5
174
+ ```
175
+
176
+ #### 1.2 Create `src/data_schema.py`
177
+
178
+ Dataclass definitions for the four telemetry tables (SensorRawTelemetry, BiologicalState, TrackerKinematics, SimulationLog). CSV/Parquet backend for now; schema supports future TimescaleDB migration.
179
+
180
+ #### 1.3 Create `scripts/import_layout.py`
181
+
182
+ Spatial initialization: read site layout, assign 3D coordinates to assets, output `layout.json` for ShadowModel.
183
+
184
+ ---
185
+
186
+ ### Phase 2: Biological Digital Twin Upgrades
187
+
188
+ #### 2.1 Upgrade `src/farquhar_model.py` — Semillon State Transition
189
+
190
+ ```python
191
+ def calc_photosynthesis_semillon(self, PAR, Tleaf, CO2, VPD, Tair, CWSI=None):
192
+ """FvCB with explicit 30C Semillon state transition.
193
+ Returns (A, limiting_state, shading_helps).
194
+
195
+ shading_helps is True ONLY when the vine is Rubisco-limited AND
196
+ reducing PAR would not reduce A (because the bottleneck is heat, not light).
197
+ """
198
+ Tk = Tleaf + 273.15
199
+ Kc, Ko = self.calc_Kc(Tk), self.calc_Ko(Tk)
200
+ gamma_star = self.calc_gamma_star(Tk)
201
+ Vcmax, Jmax = self.calc_Vcmax(Tk), self.calc_Jmax(Tk)
202
+ J = self.calc_electron_transport(PAR, Jmax)
203
+ Rd = self.params["rd_frac"] * Vcmax
204
+ ci = self._ci_from_ca(CO2, VPD, CWSI or 0.0)
205
+
206
+ Ac = Vcmax * (ci - gamma_star) / (ci + Kc * (1.0 + OI / Ko))
207
+ Aj = J * (ci - gamma_star) / (4.0 * ci + 8.0 * gamma_star)
208
+
209
+ if Tleaf < SEMILLON_TRANSITION_TEMP_C:
210
+ # RuBP-limited: light is the bottleneck
211
+ # Shading HURTS — reduces J, reduces Aj, reduces A
212
+ state = "RuBP_Limited"
213
+ shading_helps = False
214
+ An = min(Ac, Aj) - Rd # standard min-based
215
+ else:
216
+ # Rubisco-limited: enzyme/heat is the bottleneck
217
+ # Shading helps IF it cools the leaf without cutting PAR below
218
+ # the point where Aj drops below Ac
219
+ state = "Rubisco_Limited"
220
+ shading_helps = (Aj > Ac) # True when light is abundant relative to Vcmax
221
+ An = min(Ac, Aj) - Rd
222
+
223
+ return max(0.0, An), state, shading_helps
224
+ ```
225
+
226
+ The third return value `shading_helps` is the gate: the TradeoffEngine will NOT shade unless this is True. Even above 30°C, if PAR is already low (cloudy), Aj may be below Ac and shading would still hurt.
227
+
228
+ #### 2.2 Upgrade `src/solar_geometry.py` — Fruiting Zone Shadow Query
229
+
230
+ Add to ShadowModel:
231
+
232
+ ```python
233
+ def fruiting_zone_shadow(self, shadow_mask: np.ndarray,
234
+ fruiting_zone_idx: int = 1) -> dict:
235
+ """Report shading specifically on the fruiting zone (mid-canopy)."""
236
+ fz_row = shadow_mask[fruiting_zone_idx, :] # shape (n_horizontal,)
237
+ fz_shaded_fraction = float(fz_row.mean())
238
+ return {
239
+ "fruiting_zone_shaded_pct": fz_shaded_fraction * 100,
240
+ "fruiting_zone_sunlit_pct": (1 - fz_shaded_fraction) * 100,
241
+ "fruiting_zone_mask": fz_row,
242
+ }
243
+
244
+ def evaluate_candidate_offsets(self, solar_elevation, solar_azimuth,
245
+ theta_astro, offsets, total_par):
246
+ """Evaluate shadow at astronomical angle + each offset.
247
+ Returns dict keyed by offset with shadow mask, PAR distribution,
248
+ and fruiting zone metrics."""
249
+ results = {}
250
+ for offset in offsets:
251
+ theta = theta_astro + offset
252
+ mask = self.project_shadow(solar_elevation, solar_azimuth, theta)
253
+ par_dist = self.compute_par_distribution(total_par, mask)
254
+ fz = self.fruiting_zone_shadow(mask)
255
+ results[offset] = {
256
+ "shadow_mask": mask,
257
+ "par_distribution": par_dist,
258
+ "sunlit_fraction": self.sunlit_fraction(mask),
259
+ "fruiting_zone": fz,
260
+ }
261
+ return results
262
+ ```
263
+
264
+ #### 2.3 Upgrade `src/canopy_photosynthesis.py` — Expose Fruiting Zone Metrics
265
+
266
+ Add to `compute_vine_A()` return dict:
267
+
268
+ ```python
269
+ # After computing A_zones:
270
+ fruiting_zone_A = float(A_zones[FRUITING_ZONE_INDEX, :].mean())
271
+ fruiting_zone_par = float(par_zones[FRUITING_ZONE_INDEX, :].mean())
272
+ top_canopy_A = float(A_zones[2, :].mean()) # zone 2 = top, 50% LAI
273
+
274
+ return {
275
+ "A_vine": A_vine,
276
+ "A_zones": A_zones,
277
+ "sunlit_fraction": sunlit_frac,
278
+ "par_zones": par_zones,
279
+ "fruiting_zone_A": fruiting_zone_A,
280
+ "fruiting_zone_par": fruiting_zone_par,
281
+ "top_canopy_A": top_canopy_A,
282
+ }
283
+ ```
284
+
285
+ #### 2.4 Create `src/safety_rails.py`
286
+
287
+ FvCB vs ML divergence check. 12% threshold triggers fallback to astronomical tracking (zero sacrifice).
288
+
289
+ #### 2.5 Create `src/spectral_aggregator.py`
290
+
291
+ Batch preprocessing of CWSI, NDVI, PRI from sensor columns. Operates as a function, not a service (message bus deferred to production).
292
+
293
+ ---
294
+
295
+ ### Phase 3: The Control Algorithm
296
+
297
+ **Default behavior: FULL ASTRONOMICAL TRACKING. The system generates maximum energy and only deviates when the vine's fruiting zone is genuinely at risk.**
298
+
299
+ ---
300
+
301
+ #### 3.0 Algorithm Overview
302
+
303
+ ```
304
+ ┌──────────────────────────────────────────────────────────────┐
305
+ │ EVERY 15 MINUTES │
306
+ │ │
307
+ │ 1. Compute θ_astro (pvlib single-axis tracking) │
308
+ │ 2. DEFAULT DECISION: θ = θ_astro (full tracking, 0 sacrifice)│
309
+ │ │
310
+ │ 3. CHECK: Is this a no-shade window? │
311
+ │ - Before 10:00 local? → STAY at θ_astro. Done. │
312
+ │ - May? → STAY at θ_astro. Done. │
313
+ │ - Tleaf < 28C? → STAY at θ_astro. Done. │
314
+ │ - CWSI < 0.3? → STAY at θ_astro. Done. │
315
+ │ - GHI < 300 W/m2? → STAY at θ_astro. Done. │
316
+ │ │
317
+ │ 4. CHECK: Are shade-eligible conditions ALL met? │
318
+ │ - Tleaf > 30C (Rubisco-limited)? │
319
+ │ - CWSI > 0.4 (real stress)? │
320
+ │ - FvCB says shading_helps = True? │
321
+ │ - GHI > 500 W/m2 (high direct radiation)? │
322
+ │ - Hour between 10:00 and 16:00? │
323
+ │ → If ANY condition is False: STAY at θ_astro. Done. │
324
+ │ │
325
+ │ 5. All gates passed → INTERVENTION ALLOWED │
326
+ │ - Budget available? (slot_budget + margin > 0) │
327
+ │ → If no budget: STAY at θ_astro. Done. │
328
+ │ │
329
+ │ 6. FIND MINIMUM EFFECTIVE DOSE: │
330
+ │ For offsets [3, 5, 8, 10, 15, 20] degrees: │
331
+ │ - Ray-trace shadow at θ_astro + offset │
332
+ │ - Check: does fruiting zone PAR drop below 400 umol? │
333
+ │ - Check: does top canopy stay mostly sunlit (>70%)? │
334
+ │ - Check: sacrifice(offset) ≤ available budget? │
335
+ │ → Select SMALLEST offset that protects fruiting zone │
336
+ │ while keeping top canopy productive │
337
+ │ │
338
+ │ 7. Record sacrifice, update budget ledger. │
339
+ └──────────────────────────────────────────────────────────────┘
340
+ ```
341
+
342
+ The critical difference from the prior plan: **the utility function does not pick the "best" angle — it picks the SMALLEST angle that achieves the biological target.** Energy sacrifice is minimized, not balanced.
343
+
344
+ ---
345
+
346
+ #### 3.1 Energy Budget Planner — `src/energy_budget.py`
347
+
348
+ ##### 3.1.1 The Budget Hierarchy
349
+
350
+ ```
351
+ USER INPUT: max_energy_reduction_pct = 5%
352
+
353
+ ANNUAL PLAN (computed at season start)
354
+ total_potential = Σ E_astro(slot) for May 1 – Sep 30
355
+ total_budget = potential × 5%
356
+ annual_reserve = budget × 15% (for heat waves)
357
+ distributable = budget × 85%
358
+
359
+ Monthly allocation (where the budget goes):
360
+ May: 0% = 0 kWh ← NO intervention ever in May
361
+ June: 5% = small ← rare extreme events only
362
+ July: 45% = bulk ← peak heat, daily midday shading
363
+ August: 40% = bulk ← sustained heat, sunburn risk
364
+ Sept: 10% = moderate ← late heat waves
365
+
366
+ WEEKLY PLAN (generated Monday or rolling)
367
+ weekly_raw = monthly_remaining / weeks_left + rollover
368
+ weekly_reserve = weekly_raw × 20%
369
+ distributable = weekly_raw × 80%
370
+
371
+ Daily allocation weighted by forecast Tmax:
372
+ weight(day) = max(0, forecast_Tmax - 30)²
373
+ Hot days get quadratically more budget.
374
+ Days with Tmax < 30: zero allocation (no stress expected).
375
+
376
+ DAILY PLAN (generated at sunrise)
377
+ daily_raw = weekly_allocation + yesterday's rollover
378
+ daily_margin = daily_raw × 20% (unexpected afternoon spike)
379
+ planned = daily_raw × 80%
380
+
381
+ Slot allocation:
382
+ Before 10:00: ZERO (no-shade window)
383
+ 10:00-11:00: planned × 0.05 (transition period)
384
+ 11:00-14:00: planned × 0.60 (peak stress window)
385
+ 14:00-16:00: planned × 0.30 (sustained heat)
386
+ After 16:00: planned × 0.05 (rare late stress)
387
+
388
+ SLOT EXECUTION (every 15 min)
389
+ available = slot_budget + margin_share
390
+ → If intervention triggered AND budget > 0: shade
391
+ → If no intervention needed: budget preserved (rolls forward)
392
+ ```
393
+
394
+ ##### 3.1.2 Why the Budget Is Small
395
+
396
+ At 5% annual sacrifice with Sde Boker's solar geometry:
397
+ - Annual energy potential (May–Sep) ≈ 750 kWh/kWp equivalent
398
+ - Total budget ≈ 37.5 kWh sacrifice allowed
399
+ - July gets ≈ 14 kWh → ≈ 0.45 kWh/day → spread across ≈ 3 hours of eligibility
400
+ - Each 15-min slot gets ≈ 0.04 kWh — enough for 3–8° offset during peak
401
+
402
+ This is intentionally tight. The algorithm must be surgical: intervene only when truly necessary, at the minimum dose, and return to full tracking immediately.
403
+
404
+ ##### 3.1.3 Budget Implementation
405
+
406
+ ```python
407
+ class EnergyBudgetPlanner:
408
+ def __init__(self, max_energy_reduction_pct, shadow_model):
409
+ self.max_pct = max_energy_reduction_pct
410
+ self.shadow = shadow_model
411
+
412
+ def compute_annual_plan(self, year: int) -> dict:
413
+ """Compute energy potential under astronomical tracking, allocate budget."""
414
+ season_start = pd.Timestamp(f"{year}-05-01", tz="UTC")
415
+ season_end = pd.Timestamp(f"{year}-09-30 23:45", tz="UTC")
416
+ times = pd.date_range(season_start, season_end, freq="15min")
417
+ solar_pos = self.shadow.get_solar_position(times)
418
+
419
+ energy_per_slot = []
420
+ for _, sp in solar_pos.iterrows():
421
+ if sp["solar_elevation"] <= 0:
422
+ energy_per_slot.append(0.0)
423
+ continue
424
+ tracker = self.shadow.compute_tracker_tilt(
425
+ sp["solar_azimuth"], sp["solar_elevation"])
426
+ e = max(0, np.cos(np.radians(tracker["aoi"]))) * 0.25
427
+ energy_per_slot.append(e)
428
+
429
+ total_potential = sum(energy_per_slot)
430
+ total_budget = total_potential * self.max_pct / 100
431
+ annual_reserve = total_budget * ANNUAL_RESERVE_PCT / 100
432
+ distributable = total_budget - annual_reserve
433
+
434
+ monthly = {}
435
+ for month, weight in MONTHLY_BUDGET_WEIGHTS.items():
436
+ monthly[month] = distributable * weight
437
+
438
+ return {
439
+ "year": year,
440
+ "total_potential_kWh": total_potential,
441
+ "total_budget_kWh": total_budget,
442
+ "annual_reserve_kWh": annual_reserve,
443
+ "monthly_budgets": monthly,
444
+ "budget_spent_kWh": 0.0,
445
+ }
446
+
447
+ def compute_weekly_plan(self, week_start, monthly_remaining,
448
+ forecast_tmax=None, rollover=0.0):
449
+ """Distribute to days weighted by (Tmax - 30)^2. Days < 30C get zero."""
450
+ month = week_start.month
451
+ weeks_left = max(1, (pd.Timestamp(
452
+ f"{week_start.year}-{month:02d}-30") - week_start).days // 7)
453
+ weekly_raw = monthly_remaining / weeks_left + rollover
454
+ weekly_reserve = weekly_raw * WEEKLY_RESERVE_PCT / 100
455
+ distributable = weekly_raw - weekly_reserve
456
+
457
+ if forecast_tmax and len(forecast_tmax) == 7:
458
+ weights = [max(0, t - 30.0) ** 2 for t in forecast_tmax]
459
+ total_w = sum(weights)
460
+ if total_w > 0:
461
+ daily = [distributable * w / total_w for w in weights]
462
+ else:
463
+ daily = [0.0] * 7 # all days < 30C → no budget needed
464
+ else:
465
+ daily = [distributable / 7] * 7
466
+
467
+ return {
468
+ "weekly_total_kWh": weekly_raw,
469
+ "weekly_reserve_kWh": weekly_reserve,
470
+ "daily_budgets_kWh": daily,
471
+ }
472
+
473
+ def compute_daily_plan(self, date, daily_budget, rollover=0.0):
474
+ """Distribute to 15-min slots. Zero before 10:00. Peak at 11-14."""
475
+ daily_raw = daily_budget + rollover
476
+ daily_margin = daily_raw * DAILY_MARGIN_PCT / 100
477
+ planned = daily_raw - daily_margin
478
+
479
+ slot_budgets = {}
480
+ blocks = [
481
+ ((5, 10), 0.00), # NO SHADE — morning is sacred
482
+ ((10, 11), 0.05), # transition
483
+ ((11, 14), 0.60), # peak stress window
484
+ ((14, 16), 0.30), # sustained heat
485
+ ((16, 20), 0.05), # rare late stress
486
+ ]
487
+ for (h_start, h_end), weight in blocks:
488
+ block_budget = planned * weight
489
+ n_slots = (h_end - h_start) * 4
490
+ per_slot = block_budget / n_slots if n_slots > 0 else 0
491
+ for h in range(h_start, h_end):
492
+ for m in (0, 15, 30, 45):
493
+ slot_budgets[f"{h:02d}:{m:02d}"] = per_slot
494
+
495
+ return {
496
+ "date": date,
497
+ "daily_total_kWh": daily_raw,
498
+ "daily_margin_kWh": daily_margin,
499
+ "daily_margin_remaining_kWh": daily_margin,
500
+ "slot_budgets": slot_budgets,
501
+ "cumulative_spent": 0.0,
502
+ }
503
+
504
+ def emergency_draw(self, annual_plan, amount):
505
+ """Draw from annual reserve for extreme heat events."""
506
+ available = annual_plan["annual_reserve_kWh"]
507
+ drawn = min(amount, available)
508
+ annual_plan["annual_reserve_kWh"] -= drawn
509
+ return drawn
510
+ ```
511
+
512
+ ---
513
+
514
+ #### 3.2 The Intervention Gate — `src/tradeoff_engine.py`
515
+
516
+ The gate is a series of hard checks. The engine does NOT run a utility function to decide WHETHER to shade — it runs a strict pass/fail gate. Only after all gates pass does it search for the minimum effective dose.
517
+
518
+ ##### 3.2.1 The Gate Logic
519
+
520
+ ```python
521
+ class InterventionGate:
522
+ """Determines whether shading is allowed at this timestep.
523
+ Returns True only when ALL conditions for intervention are met.
524
+ This is not an optimization — it is a hard pass/fail filter."""
525
+
526
+ def check(self, timestamp, tleaf, tair, cwsi, ghi, par,
527
+ co2, vpd, farquhar_model) -> dict:
528
+
529
+ local_hour = timestamp.hour # approximate (should use solar noon)
530
+
531
+ # --- NO-SHADE WINDOWS (any one blocks intervention) ---
532
+ if local_hour < NO_SHADE_BEFORE_HOUR:
533
+ return {"allowed": False, "reason": "morning_protected"}
534
+
535
+ if timestamp.month in NO_SHADE_MONTHS:
536
+ return {"allowed": False, "reason": "spring_protected"}
537
+
538
+ if tleaf < NO_SHADE_TLEAF_BELOW:
539
+ return {"allowed": False, "reason": "no_heat_stress"}
540
+
541
+ if cwsi < NO_SHADE_CWSI_BELOW:
542
+ return {"allowed": False, "reason": "vine_healthy"}
543
+
544
+ if ghi < NO_SHADE_GHI_BELOW:
545
+ return {"allowed": False, "reason": "overcast"}
546
+
547
+ # --- SHADE-ELIGIBLE CONDITIONS (all must pass) ---
548
+ if tleaf < SHADE_ELIGIBLE_TLEAF_ABOVE:
549
+ return {"allowed": False, "reason": "below_rubisco_threshold"}
550
+
551
+ if cwsi < SHADE_ELIGIBLE_CWSI_ABOVE:
552
+ return {"allowed": False, "reason": "stress_insufficient"}
553
+
554
+ h_start, h_end = SHADE_ELIGIBLE_HOURS
555
+ if not (h_start <= local_hour < h_end):
556
+ return {"allowed": False, "reason": "outside_eligible_hours"}
557
+
558
+ if ghi < SHADE_ELIGIBLE_GHI_ABOVE:
559
+ return {"allowed": False, "reason": "low_radiation"}
560
+
561
+ # --- BIOLOGICAL CONFIRMATION ---
562
+ _, state, shading_helps = farquhar_model.calc_photosynthesis_semillon(
563
+ par, tleaf, co2, vpd, tair
564
+ )
565
+ if not shading_helps:
566
+ return {"allowed": False, "reason": "fvcb_says_shade_hurts",
567
+ "state": state}
568
+
569
+ return {"allowed": True, "reason": "all_gates_passed", "state": state}
570
+ ```
571
+
572
+ ##### 3.2.2 Minimum Effective Dose Selection
573
+
574
+ When the gate passes, the engine does NOT maximize a utility function. Instead, it searches for the **smallest offset** that achieves the biological target: bring fruiting zone PAR below the sunburn threshold while keeping the top canopy productive.
575
+
576
+ ```python
577
+ class TradeoffEngine:
578
+ def __init__(self, shadow_model, canopy_model, farquhar_model):
579
+ self.shadow = shadow_model
580
+ self.canopy = canopy_model
581
+ self.farquhar = farquhar_model
582
+ self.gate = InterventionGate()
583
+
584
+ def evaluate_slot(self, timestamp, par, tleaf, tair, co2, vpd,
585
+ cwsi, ghi, slot_budget_kWh, margin_kWh) -> dict:
586
+
587
+ # 1. Astronomical optimal (always computed — this is the default)
588
+ sp = self.shadow.get_solar_position(pd.DatetimeIndex([timestamp]))
589
+ solar_elev = sp["solar_elevation"].iloc[0]
590
+ solar_az = sp["solar_azimuth"].iloc[0]
591
+
592
+ if solar_elev <= 2.0:
593
+ return {"angle": 0, "offset": 0, "sacrifice": 0, "action": "night"}
594
+
595
+ tracker = self.shadow.compute_tracker_tilt(solar_az, solar_elev)
596
+ theta_astro = tracker["tracker_theta"]
597
+ E_astro = max(0, np.cos(np.radians(tracker["aoi"]))) * 0.25
598
+
599
+ # 2. DEFAULT: full astronomical tracking
600
+ result = {
601
+ "angle": theta_astro,
602
+ "offset": 0,
603
+ "sacrifice_kWh": 0.0,
604
+ "margin_spent_kWh": 0.0,
605
+ "budget_saved_kWh": slot_budget_kWh, # entire slot budget preserved
606
+ "action": "full_tracking",
607
+ "E_kWh": E_astro,
608
+ }
609
+
610
+ # 3. Check intervention gate
611
+ gate = self.gate.check(timestamp, tleaf, tair, cwsi, ghi,
612
+ par, co2, vpd, self.farquhar)
613
+ result["gate"] = gate
614
+
615
+ if not gate["allowed"]:
616
+ return result # stay at full tracking
617
+
618
+ # 4. Budget check
619
+ total_available = slot_budget_kWh + margin_kWh
620
+ if total_available <= 0:
621
+ result["action"] = "gate_passed_but_no_budget"
622
+ return result
623
+
624
+ # 5. MINIMUM EFFECTIVE DOSE — scan offsets smallest to largest
625
+ for offset in CANDIDATE_OFFSETS:
626
+ if offset == 0:
627
+ continue # 0 = full tracking, already the default
628
+
629
+ theta = theta_astro + offset
630
+ E_theta = E_astro * np.cos(np.radians(offset))
631
+ sacrifice = E_astro - E_theta
632
+
633
+ # Budget constraint
634
+ if sacrifice > total_available:
635
+ break # larger offsets will cost even more — stop scanning
636
+
637
+ # Ray-trace at this angle
638
+ mask = self.shadow.project_shadow(solar_elev, solar_az, theta)
639
+
640
+ # Fruiting zone check: is the fruit zone now shaded enough?
641
+ fz = self.shadow.fruiting_zone_shadow(mask)
642
+ fz_par = par * (1.0 - fz["fruiting_zone_shaded_pct"] / 100)
643
+ # Target: reduce fruiting zone PAR below sunburn threshold
644
+ # but not below minimum useful level (~100 umol for diffuse photosynthesis)
645
+ fz_par_with_diffuse = max(fz_par, par * 0.15) # diffuse floor
646
+
647
+ # Top canopy check: is the top canopy still mostly sunlit?
648
+ top_mask = mask[2, :] # zone 2 = top canopy
649
+ top_sunlit_pct = float(1.0 - top_mask.mean()) * 100
650
+
651
+ if top_sunlit_pct < 70:
652
+ continue # this angle shades too much of the productive canopy
653
+
654
+ # Does this offset actually help the fruiting zone?
655
+ if fz_par_with_diffuse > FRUITING_ZONE_TARGET_PAR:
656
+ continue # not enough shade on fruit zone yet, try larger offset
657
+
658
+ # SUCCESS — this is the minimum offset that protects the fruit
659
+ # while keeping the top canopy productive
660
+ vine_result = self.canopy.compute_vine_A(
661
+ par, tleaf, co2, vpd, tair, mask)
662
+
663
+ margin_spent = max(0, sacrifice - slot_budget_kWh)
664
+
665
+ return {
666
+ "angle": theta,
667
+ "offset": offset,
668
+ "sacrifice_kWh": sacrifice,
669
+ "margin_spent_kWh": margin_spent,
670
+ "budget_saved_kWh": max(0, slot_budget_kWh - sacrifice),
671
+ "action": "minimum_dose_shade",
672
+ "E_kWh": E_theta,
673
+ "A_vine": vine_result["A_vine"],
674
+ "fruiting_zone_par": fz_par_with_diffuse,
675
+ "fruiting_zone_shaded_pct": fz["fruiting_zone_shaded_pct"],
676
+ "top_canopy_sunlit_pct": top_sunlit_pct,
677
+ "gate": gate,
678
+ }
679
+
680
+ # No offset achieved the target within budget/constraints
681
+ # → stay at full tracking (do not shade ineffectively)
682
+ result["action"] = "no_effective_dose_found"
683
+ return result
684
+ ```
685
+
686
+ ##### 3.2.3 Why This is NOT a Utility Function
687
+
688
+ The prior plan used `U(θ) = α·E(θ) + β(CWSI)·A(θ)` — a weighted sum that trades energy for photosynthesis. This is wrong for three reasons:
689
+
690
+ 1. **The default must be zero intervention.** A utility function always finds a "best" angle, which may not be zero. The gate-then-minimum-dose approach ensures the system stays at full tracking unless the biological case is clear.
691
+
692
+ 2. **Minimum dose, not maximum benefit.** A utility function would pick the angle with the highest A, which may be a large offset. We want the SMALLEST offset that prevents damage — less energy sacrifice for the same biological protection.
693
+
694
+ 3. **The vine doesn't always benefit from shade.** In RuBP-limited state (< 30°C), shading reduces A. A utility function with high β could still recommend shading if the math works out. The gate prevents this categorically.
695
+
696
+ ##### 3.2.4 The Daily Cycle Under This Algorithm
697
+
698
+ ```
699
+ Typical July day in Sde Boker:
700
+
701
+ 05:00-10:00 θ = θ_astro (FULL TRACKING)
702
+ Gate blocked: morning_protected
703
+ Budget spent: 0. All slot budgets preserved → margin pool.
704
+ Vine: stomata open, photosynthesizing efficiently.
705
+
706
+ 10:00-10:30 θ = θ_astro (FULL TRACKING)
707
+ Gate blocked: below_rubisco_threshold (Tleaf = 29C)
708
+ Budget spent: 0.
709
+
710
+ 10:45 θ = θ_astro (FULL TRACKING)
711
+ Gate blocked: stress_insufficient (CWSI = 0.35)
712
+ Vine is warming but not yet stressed enough.
713
+
714
+ 11:00 θ = θ_astro + 3° (MINIMUM DOSE)
715
+ Gate passed: Tleaf=32C, CWSI=0.45, GHI=850 W/m2,
716
+ FvCB: Rubisco_Limited, shading_helps=True
717
+ Offset 3°: fruiting zone PAR drops to ≈ 380, top canopy ≈ 95% sunlit
718
+ Sacrifice: ≈ 0.03 kWh. Fruiting zone protected.
719
+
720
+ 11:15-13:45 θ = θ_astro + 5° to +8° (ESCALATING AS NEEDED)
721
+ CWSI rising to 0.55, Tleaf 34-37C.
722
+ System uses 5° offset for most slots, 8° for peak.
723
+ Top canopy stays ≈ 80–90% sunlit.
724
+ Sacrifice: ≈ 0.04–0.08 kWh per slot.
725
+
726
+ 14:00 θ = θ_astro + 5° (MAINTAINING)
727
+ CWSI still elevated but no longer rising.
728
+ Minimum dose maintained.
729
+
730
+ 15:00 θ = θ_astro + 3° (DE-ESCALATING)
731
+ CWSI dropping to 0.38.
732
+
733
+ 15:30 θ = θ_astro (BACK TO FULL TRACKING)
734
+ Gate blocked: stress_insufficient (CWSI = 0.28)
735
+ Immediate return to full energy generation.
736
+
737
+ 16:00-19:00 θ = θ_astro (FULL TRACKING)
738
+ Vine recovering, afternoon light for late photosynthesis.
739
+
740
+ Total day sacrifice: ≈ 1.2 kWh out of ≈ 25 kWh potential (≈ 4.8%)
741
+ Shading duration: ≈ 4.5 hours out of ≈ 14 hours of daylight
742
+ Budget used: within daily allocation + small margin draw
743
+ ```
744
+
745
+ ##### 3.2.5 The Seasonal Pattern
746
+
747
+ ```
748
+ MAY: 100% full tracking. Zero sacrifice. Zero intervention.
749
+ Vine establishing canopy, flowering, fruit set.
750
+ Every photon is valuable. Budget allocation: 0%.
751
+
752
+ JUNE: ≈ 99% full tracking. Rare intervention on extreme days (>38C).
753
+ Maybe 2–3 days in the month trigger 1–2 hours of shading.
754
+ Budget allocation: 5%.
755
+
756
+ JULY: ≈ 85% full tracking. Daily midday shading ≈ 3–5 hours.
757
+ This is where the budget is consumed.
758
+ Typical: 3–5° offset, 11:00–15:00.
759
+ Budget allocation: 45%.
760
+
761
+ AUGUST: ≈ 85% full tracking. Similar to July but with fruit ripening.
762
+ Berry sunburn is the primary concern now.
763
+ Fruiting zone targeting is most critical this month.
764
+ Budget allocation: 40%.
765
+
766
+ SEPT: ≈ 95% full tracking. Occasional late heat waves.
767
+ Pre-harvest period: balance sugar/acid/phenolics.
768
+ Budget allocation: 10%.
769
+
770
+ SEASON TOTAL: ≈ 92% of time at full astronomical tracking.
771
+ ≈ 8% of time with minimum-dose shading.
772
+ Energy sacrifice: ≤ 5% (guaranteed by budget ceiling).
773
+ ```
774
+
775
+ ---
776
+
777
+ #### 3.3 Command Arbiter — `src/command_arbiter.py`
778
+
779
+ ##### 3.3.1 Priority Stack
780
+
781
+ ```
782
+ Priority 1 (highest): Weather Protection → stow angle
783
+ Priority 2: Mechanical Harvesting → vertical park (90°)
784
+ Priority 3: Safety Rail Alert → θ_astro (zero sacrifice)
785
+ Priority 4: Simulation Timeout → θ_astro (zero sacrifice)
786
+ Priority 5 (normal): TradeoffEngine output → θ_astro or θ_astro + offset
787
+ ```
788
+
789
+ All fallbacks default to **full astronomical tracking** — the safe, energy-maximizing default. There is no scenario where a fallback costs energy.
790
+
791
+ ##### 3.3.2 Hysteresis — Motor Protection
792
+
793
+ ```python
794
+ class CommandArbiter:
795
+ def __init__(self, hysteresis_window_min=15, angle_tolerance_deg=2.0):
796
+ self.window_min = hysteresis_window_min
797
+ self.tolerance = angle_tolerance_deg
798
+ self.buffer = []
799
+ self.current_angle = 0.0
800
+
801
+ def should_move(self, requested_angle, timestamp):
802
+ self.buffer.append((timestamp, requested_angle))
803
+ cutoff = timestamp - pd.Timedelta(minutes=self.window_min)
804
+ self.buffer = [(t, a) for t, a in self.buffer if t >= cutoff]
805
+
806
+ if len(self.buffer) < 2:
807
+ return {"dispatch": False, "angle": self.current_angle}
808
+
809
+ stable = all(abs(a - requested_angle) <= self.tolerance
810
+ for _, a in self.buffer)
811
+ if stable:
812
+ self.current_angle = requested_angle
813
+ return {"dispatch": True, "angle": requested_angle}
814
+ return {"dispatch": False, "angle": self.current_angle}
815
+
816
+ def select_source(self, engine_result, safety_result,
817
+ sim_time_sec, weather_override, harvest_active):
818
+ if weather_override:
819
+ return {"angle": weather_override["target_angle"],
820
+ "source": "weather"}
821
+ if harvest_active:
822
+ return {"angle": 90, "source": "harvest"}
823
+ if not safety_result["valid"]:
824
+ return {"angle": engine_result.get("angle", 0),
825
+ "source": "safety_fallback"}
826
+ if sim_time_sec > SIMULATION_TIMEOUT_SEC:
827
+ return {"angle": engine_result.get("angle", 0),
828
+ "source": "timeout_fallback"}
829
+ return {"angle": engine_result["angle"],
830
+ "source": "engine"}
831
+ ```
832
+
833
+ ##### 3.3.3 Return-to-Tracking Speed
834
+
835
+ When the gate closes (stress drops below threshold), the system returns to full astronomical tracking **at the next 15-min slot** — no ramp-down, no smoothing. The vine transitions from Rubisco-limited back to RuBP-limited and immediately wants full light. The hysteresis only prevents sub-slot jitter; it does not delay the return-to-tracking decision across slots.
836
+
837
+ ---
838
+
839
+ #### 3.4 Astronomical Tracker (Fallback) — `src/astronomical_tracker.py`
840
+
841
+ ```python
842
+ class AstronomicalTracker:
843
+ """Pure sun-following. The always-safe default."""
844
+ def __init__(self, shadow_model):
845
+ self.shadow = shadow_model
846
+
847
+ def get_angle(self, timestamp):
848
+ sp = self.shadow.get_solar_position(pd.DatetimeIndex([timestamp]))
849
+ result = self.shadow.compute_tracker_tilt(
850
+ sp["solar_azimuth"].iloc[0], sp["solar_elevation"].iloc[0])
851
+ return result["tracker_theta"]
852
+ ```
853
+
854
+ ---
855
+
856
+ #### 3.5 The Complete 15-Minute Control Loop
857
+
858
+ ```
859
+ ┌────────────────────────────────────────────────────────────────┐
860
+ │ STEP 1: READ SENSORS │
861
+ │ PAR, Tleaf, Tair, CO2, VPD (on-site) │
862
+ │ GHI, wind, Tmax (IMS) │
863
+ │ CWSI (from SpectralAggregator) │
864
+ │ │
865
+ │ STEP 2: COMPUTE θ_astro (pvlib) │
866
+ │ This is the default. System starts here. │
867
+ │ │
868
+ │ STEP 3: CHECK WEATHER / HARVEST OVERRIDES │
869
+ │ Weather stow? → dispatch stow angle. END. │
870
+ │ Harvest mode? → dispatch 90°. END. │
871
+ │ │
872
+ │ STEP 4: RUN INTERVENTION GATE │
873
+ │ No-shade window? → θ = θ_astro. END. │
874
+ │ Shade conditions not met? → θ = θ_astro. END. │
875
+ │ FvCB says shade hurts? → θ = θ_astro. END. │
876
+ │ │
877
+ │ STEP 5: BUDGET CHECK │
878
+ │ slot_budget + margin > 0? → proceed │
879
+ │ No budget? → θ = θ_astro. END. │
880
+ │ │
881
+ │ STEP 6: MINIMUM DOSE SEARCH │
882
+ │ Scan offsets 3°, 5°, 8°, 10°, 15°, 20° (smallest first) │
883
+ │ For each: ray-trace → check fruiting zone PAR < 400 │
884
+ │ check top canopy > 70% sunlit │
885
+ │ check sacrifice ≤ available budget │
886
+ │ Select FIRST (smallest) offset that passes all checks. │
887
+ │ No offset works? → θ = θ_astro. END. │
888
+ │ │
889
+ │ STEP 7: SAFETY RAILS │
890
+ │ FvCB vs ML divergence > 12%? → θ = θ_astro. END. │
891
+ │ │
892
+ │ STEP 8: COMMAND ARBITER │
893
+ │ Apply hysteresis (15-min stability, ±2° tolerance) │
894
+ │ Stable? → dispatch move command. │
895
+ │ Not stable? → hold current angle. │
896
+ │ │
897
+ │ STEP 9: BUDGET ACCOUNTING │
898
+ │ Record sacrifice. Update daily margin. │
899
+ │ If zero sacrifice: slot budget rolls forward as savings. │
900
+ └────────────────────────────────────────────────────────────────┘
901
+ ```
902
+
903
+ ---
904
+
905
+ #### 3.6 Edge Cases and Guarantees
906
+
907
+ | Scenario | System Response |
908
+ |---|---|
909
+ | Heat wave, 5 days of 42°C | Daily budgets consumed rapidly. Weekly reserve engaged. If exhausted, annual reserve drawn. If ALL reserves empty → full tracking (energy guarantee holds). |
910
+ | Cool July week (Tmax 28°C) | Weekly Tmax < 30°C → daily budgets = 0. Gate never opens. Full tracking. Entire week's budget rolls forward to next week. |
911
+ | Morning heat spike (09:30, Tleaf 33°C, CWSI 0.5) | Gate BLOCKED: before 10:00. Morning light is protected regardless of stress level. |
912
+ | May heat wave (Tmax 40°C) | Gate BLOCKED: May is a no-shade month. Full tracking. Budget for May = 0. Vine exposed but this is critical for fruit set; spring stress is accepted. |
913
+ | CWSI = 0.45 but FvCB says shading hurts | Gate BLOCKED: `shading_helps = False`. This means Aj < Ac even above 30°C (e.g., late afternoon with declining PAR). Vine is actually light-limited despite stress signals. No intervention. |
914
+ | Offset 3° protects fruit zone sufficiently | Selected immediately (smallest effective dose). Sacrifice minimized. Top canopy stays 95% sunlit. Energy cost: ~0.03 kWh per slot. |
915
+ | No offset protects fruit zone within budget | No intervention. System stays at θ_astro. Action = "no_effective_dose_found". Budget preserved. |
916
+
917
+ ---
918
+
919
+ ### Phase 4: Operational Modes
920
+
921
+ #### 4.1 `src/operational_modes.py`
922
+
923
+ ```python
924
+ class OperationalModes:
925
+ def check_weather_protection(self, weather_state):
926
+ if weather_state.get("hail_alert"):
927
+ return {"mode": "hail_stow", "target_angle": 75}
928
+ if weather_state.get("wind_speed_ms", 0) > WIND_STOW_SPEED_MS:
929
+ return {"mode": "wind_stow", "target_angle": 0}
930
+ if (weather_state.get("air_temp_c", 0) > HEAT_SHIELD_TEMP_C
931
+ and weather_state.get("cwsi", 0) > HEAT_SHIELD_CWSI):
932
+ return {"mode": "heat_shield", "target_angle": "maximize_shade"}
933
+ return None
934
+
935
+ def enter_harvest_mode(self):
936
+ return {"mode": "harvest", "target_angle": 90,
937
+ "clearance_cm": HARVEST_PARK_CLEARANCE_CM,
938
+ "vibration_alarms_disabled": True}
939
+ ```
940
+
941
+ Weather and harvest overrides do NOT consume budget. They either generate full energy (wind stow at 0°, harvest at 90°) or protect hardware (hail stow at 75°). The heat shield mode is the only one that costs energy, and it draws from the budget system.
942
+
943
+ ---
944
+
945
+ ### Phase 5: ROI Reporting
946
+
947
+ #### 5.1 `src/roi_service.py`
948
+
949
+ Reports on budget utilization and biological effectiveness:
950
+ - Budget spent vs allocated (daily, weekly, monthly, annual)
951
+ - Number of intervention slots and total shading hours
952
+ - Fruiting zone temperature during interventions (was it effective?)
953
+ - Energy sacrifice as % of potential (must always be ≤ user ceiling)
954
+ - LER computation
955
+
956
+ ---
957
+
958
+ ### Phase 6: Dashboard v2
959
+
960
+ New tabs in `app.py`:
961
+ - **Budget Planner** — Annual/monthly/weekly/daily budget gauges, rollover visualization, margin utilization
962
+ - **Control Replay** — Select a day, step through 15-min slots, see gate decisions, offset selection, shadow projection on fruiting zone
963
+ - **Fruiting Zone Monitor** — PAR and temperature at mid-canopy over time, sunburn risk indicator
964
+ - **ROI Dashboard** — Cumulative sacrifice vs ceiling, LER, intervention frequency
965
+
966
+ ---
967
+
968
+ ### Phase 7: Integration Testing
969
+
970
+ #### 7.1 `scripts/run_control_simulation.py`
971
+
972
+ Replay historical data through the full control loop.
973
+
974
+ #### 7.2 Validation Criteria
975
+
976
+ - **Hard guarantee**: cumulative sacrifice NEVER exceeds `max_energy_reduction_pct`
977
+ - **Default tracking rate**: > 90% of daytime slots remain at full astronomical tracking
978
+ - **Gate accuracy**: no interventions in no-shade windows (May, mornings, low stress)
979
+ - **Minimum dose**: average intervention offset < 10° (small, surgical corrections)
980
+ - **Fruiting zone effectiveness**: during interventions, mid-canopy PAR reduced below 400 umol target in > 80% of cases
981
+ - **Top canopy preservation**: during interventions, top canopy sunlit fraction > 70% in all cases
982
+ - **Budget utilization**: July–August consume > 80% of total budget; May consumes 0%
983
+
984
+ ---
985
+
986
+ ## Agronomic Value Weighting
987
+
988
+ A unit of carbon assimilated by the vine does not have a fixed value. Its contribution to the final economic yield depends on **which leaf zone** is doing the work and the **phenological stage** of the vine. The control algorithm must account for both dimensions when evaluating whether a candidate angle is worth its energy cost.
989
+
990
+ ### Spatial Weighting: Contribution by Canopy Zone
991
+
992
+ The 3-zone ShadowModel maps directly to the vine's source-sink physiology:
993
+
994
+ #### Zone 0 — Basal Leaves (trunk/cordons, ~0.2m, LAI 15%)
995
+
996
+ Early in the season, these mature leaves provide the primary carbon source for developing inflorescences. As the season progresses they age and photosynthetic efficiency declines. Removing them later (basal defoliation) has minimal negative impact because their efficiency is already low.
997
+
998
+ **Algorithmic implication**: Photons reaching zone 0 have declining marginal value as the season progresses. By veraison, shading zone 0 carries almost no penalty.
999
+
1000
+ #### Zone 1 — Fruiting Zone (grape clusters, ~0.6m, LAI 35%)
1001
+
1002
+ The grape clusters themselves do not photosynthesize, but the leaves interspersed at this height supply carbon directly to the berries via short transport paths. Berry quality (anthocyanins, phenolics, flavor compounds) develops best under filtered/diffuse light — not harsh direct radiation. Berry surface temperatures above 35°C cause sunburn.
1003
+
1004
+ **Algorithmic implication**: This zone's light environment is the primary optimization target. The goal is not maximum PAR but *optimal* PAR — enough for quality development, below the sunburn threshold (400 µmol/m²/s target).
1005
+
1006
+ #### Zone 2 — Apical/Lateral Leaves (upper canopy, ~1.0m, LAI 50%)
1007
+
1008
+ Young developing leaves do not export carbon until they reach ~50% of final size. However, from veraison onwards, these mid-to-apical leaves and lateral shoots become the most efficient photosynthetic engines — they drive sugar accumulation (°Brix) in the berries.
1009
+
1010
+ **Algorithmic implication**: Photons hitting zone 2 during ripening have the highest marginal value. The minimum-dose search already enforces >70% sunlit fraction on zone 2; the spatial weight reinforces this — sacrificing zone 2 light is the most expensive possible intervention.
1011
+
1012
+ #### Zone-Weighted Crop Value
1013
+
1014
+ When the TradeoffEngine evaluates a candidate offset, the crop value of the resulting light distribution is:
1015
+
1016
+ ```python
1017
+ # Spatial value weights by zone (phenology-dependent)
1018
+ ZONE_CROP_WEIGHTS = {
1019
+ "pre_veraison": [0.25, 0.35, 0.40], # zone 0, 1, 2
1020
+ "veraison": [0.10, 0.30, 0.60], # apical leaves dominate sugar loading
1021
+ "post_harvest": [0.15, 0.15, 0.70], # reserve building, top canopy matters most
1022
+ }
1023
+
1024
+ def zone_weighted_crop_value(A_zones, par_zones, stage, base_crop_value):
1025
+ """Compute the economic crop value of a given light distribution."""
1026
+ weights = ZONE_CROP_WEIGHTS[stage]
1027
+ weighted_A = sum(w * A for w, A in zip(weights, A_zones))
1028
+ return base_crop_value * weighted_A
1029
+ ```
1030
+
1031
+ ---
1032
+
1033
+ ### Temporal Weighting: Contribution across Growth Stages
1034
+
1035
+ The vine's carbon demands shift drastically throughout the season. The crop value multiplier must scale dynamically based on phenological stage, tracked via Growing Degree Days (GDD):
1036
+
1037
+ #### Pre-Flowering to Fruit Set (May–early June)
1038
+
1039
+ Carbon reserves from trunk and roots are mobilized to support early shoot growth. Maximum daily temperatures and radiation during this window have the largest statistical impact on determining bunch number and overall yield mass.
1040
+
1041
+ **Value**: High. Maximizing photosynthesis sets the baseline yield capacity.
1042
+ **Budget policy**: NO_SHADE_MONTHS = [5] already enforces this — zero intervention, full astronomical tracking.
1043
+
1044
+ #### Fruit Set to Veraison (June–mid July)
1045
+
1046
+ Rapid berry cell division. Canopy expanding. Carbon demand is high but the vine is generally not yet heat-stressed (Negev temperatures rising but not yet at sustained peaks).
1047
+
1048
+ **Value**: High. Intervention rare; the gate's CWSI and temperature thresholds protect this window naturally.
1049
+ **Seasonal multiplier**: 1.0× (baseline).
1050
+
1051
+ #### Veraison to Ripening (mid July–August)
1052
+
1053
+ The fruit becomes the dominant carbon sink. Vegetative growth slows to allow preferential carbon redistribution into berries. The ratio of leaf area to fruit weight directly dictates berry mass, sugar concentration, and anthocyanin accumulation.
1054
+
1055
+ **Value**: Extremely high, but with a thermal caveat. Above 30°C, carbon assimilation is hampered and organic acids degrade. This is where the intervention system earns its keep — protecting the fruiting zone from sunburn while keeping the top canopy productive for sugar loading.
1056
+ **Seasonal multiplier**: 1.5× (reflects that each unit of photosynthesis during ripening has disproportionate impact on final grape value).
1057
+
1058
+ #### Post-Harvest (September–October)
1059
+
1060
+ Berries are gone. The carbon sink shifts back to roots and trunk for starch storage (next spring's budburst reserves).
1061
+
1062
+ **Value**: Moderate. The system only needs to maintain sufficient photosynthesis to replenish reserves. The tracker can heavily favor energy generation.
1063
+ **Seasonal multiplier**: 0.5× (energy production prioritized).
1064
+
1065
+ #### Phenology Tracker
1066
+
1067
+ ```python
1068
+ # Growing Degree Day thresholds for Semillon (Sde Boker)
1069
+ PHENOLOGY_GDD_THRESHOLDS = {
1070
+ "budburst": 0, # GDD base, ~March
1071
+ "flowering": 350, # ~May
1072
+ "fruit_set": 500, # ~early June
1073
+ "veraison": 1200, # ~mid July
1074
+ "harvest": 1800, # ~late August / September
1075
+ }
1076
+
1077
+ STAGE_CROP_MULTIPLIER = {
1078
+ "pre_flowering": 1.2, # setting yield capacity
1079
+ "fruit_set": 1.0, # baseline
1080
+ "veraison": 1.5, # sugar loading, highest crop value
1081
+ "post_harvest": 0.5, # reserve building only
1082
+ }
1083
+
1084
+ def get_phenological_stage(gdd_cumulative):
1085
+ """Return current growth stage based on accumulated GDD."""
1086
+ if gdd_cumulative < PHENOLOGY_GDD_THRESHOLDS["flowering"]:
1087
+ return "pre_flowering"
1088
+ elif gdd_cumulative < PHENOLOGY_GDD_THRESHOLDS["veraison"]:
1089
+ return "fruit_set"
1090
+ elif gdd_cumulative < PHENOLOGY_GDD_THRESHOLDS["harvest"]:
1091
+ return "veraison"
1092
+ else:
1093
+ return "post_harvest"
1094
+ ```
1095
+
1096
+ ---
1097
+
1098
+ ### Predicting Baseline Photosynthesis
1099
+
1100
+ To assess whether a candidate angle's photosynthesis sacrifice is acceptable, the system needs a baseline prediction — the expected photosynthesis under full astronomical tracking for the day ahead.
1101
+
1102
+ #### Mechanistic Baseline (FvCB)
1103
+
1104
+ The existing Farquhar model computes A at each 15-min slot under full tracking. This serves as the denominator: `A_baseline(t) = FvCB(PAR_astro(t), Tleaf_forecast(t), CO2, VPD_forecast(t))`.
1105
+
1106
+ #### ML-Enhanced Baseline (Auto-regressive)
1107
+
1108
+ The Stage 2 ML predictor (RF/XGBoost) ingests the previous day's metrics (approximated A, CWSI, soil moisture) and day-ahead IMS weather forecast (radiation, temperature, wind) to predict next-day A at each slot. This catches microclimate effects the pure mechanistic model may miss.
1109
+
1110
+ #### Hybrid Consensus
1111
+
1112
+ ```python
1113
+ def predict_baseline_ps(slot, farquhar_model, ml_predictor, weather_forecast):
1114
+ """Predict baseline photosynthesis (full tracking) for a future slot."""
1115
+ A_fvcb = farquhar_model.calc_photosynthesis(
1116
+ weather_forecast["par"], weather_forecast["tleaf"],
1117
+ weather_forecast["co2"], weather_forecast["vpd"],
1118
+ weather_forecast["tair"])
1119
+
1120
+ A_ml = ml_predictor.predict(weather_forecast)
1121
+
1122
+ # Safety rails: if FvCB and ML diverge > 12%, flag uncertainty
1123
+ divergence = abs(A_fvcb - A_ml) / max(A_fvcb, A_ml, 1e-6)
1124
+ if divergence > DIVERGENCE_THRESHOLD:
1125
+ return A_fvcb, {"confident": False, "divergence": divergence}
1126
+
1127
+ # Weighted average (FvCB anchored, ML corrects)
1128
+ A_baseline = 0.6 * A_fvcb + 0.4 * A_ml
1129
+ return A_baseline, {"confident": True, "divergence": divergence}
1130
+ ```
1131
+
1132
+ ---
1133
+
1134
+ ## Day-Ahead Trajectory Optimization
1135
+
1136
+ The real-time control loop (Phase 3) makes slot-by-slot decisions. A greedy per-slot approach can cause excessive motor movement and misallocate the daily budget. A **day-ahead planner** uses Discrete Dynamic Programming to compute the optimal trajectory for the entire day, which the real-time arbiter then executes (or overrides if live sensor data diverges from forecast).
1137
+
1138
+ ### Algorithm: Day-Ahead Agrivoltaic Trajectory Optimization
1139
+
1140
+ #### Step 1: Retrieve Inputs
1141
+
1142
+ For the next 24-hour horizon (96 slots at 15-min intervals):
1143
+
1144
+ | Input | Source |
1145
+ |---|---|
1146
+ | GHI, DHI, ambient temp, wind | IMS station 43 day-ahead forecast |
1147
+ | Energy market price `Price_energy(t)` | Grid tariff schedule (or flat rate for now) |
1148
+ | Crop value `Price_crop(t)` | Phenology stage multiplier × base crop value per hectare |
1149
+ | Baseline PS `A_baseline(t)` | Hybrid FvCB + ML prediction (see above) |
1150
+ | Candidate angles Θ | `CANDIDATE_OFFSETS` mapped to absolute angles: `{θ_astro(t) + offset}` for each slot |
1151
+
1152
+ #### Step 2: Pre-compute State Space
1153
+
1154
+ For every slot `t` and every candidate angle `θ ∈ Θ`, pre-compute:
1155
+
1156
+ - **`E_t(θ)`**: Expected PV energy generation, accounting for sun position and cosine losses
1157
+ - **`A_t(θ)`**: Predicted photosynthesis, from ray-traced PAR distribution at angle θ through the FvCB model
1158
+ - **Gate status**: Whether the intervention gate would allow θ ≠ θ_astro at slot t (no-shade windows, temperature thresholds, etc.)
1159
+
1160
+ Slots where the gate blocks intervention are locked to `θ_astro` — the DP does not consider alternatives for those slots.
1161
+
1162
+ #### Step 3: Utility Function
1163
+
1164
+ For each slot `t` and angle `θ`:
1165
+
1166
+ ```
1167
+ U_t(θ) = Price_energy(t) · E_t(θ) + Price_crop(t) · A_t(θ)
1168
+ ```
1169
+
1170
+ The crop price term incorporates:
1171
+ - The **phenological multiplier** (1.5× during veraison, 0.5× post-harvest)
1172
+ - The **spatial zone weights** (penalizes angles that shade zone 2 more than angles that shade zone 0)
1173
+ - A **heat-stress penalty** when forecasted Tleaf > 30°C and the angle exposes the fruiting zone to direct sun
1174
+
1175
+ #### Step 4: Dynamic Programming with Movement Cost
1176
+
1177
+ A greedy approach (best angle per slot independently) causes excessive motor wear. The DP includes a mechanical movement penalty:
1178
+
1179
+ ```
1180
+ max Σ_{t=0}^{96} [ U_t(θ_t) - Cost_move · |θ_t - θ_{t-1}| ]
1181
+ ```
1182
+
1183
+ - **Forward pass**: For each slot, compute the optimal cumulative utility to reach each candidate angle, considering all possible transitions from the previous slot.
1184
+ - **Backward pass**: Trace back from slot 96 to extract the single optimal angle sequence `[θ_0, θ_1, ..., θ_96]`.
1185
+
1186
+ The movement cost biases the optimizer toward smooth trajectories — holding a consistent offset during the midday stress window rather than oscillating between angles.
1187
+
1188
+ #### Step 5: Budget Constraint Integration
1189
+
1190
+ The DP trajectory must respect the daily energy budget:
1191
+
1192
+ ```
1193
+ Σ_{t=0}^{96} [ E_t(θ_astro) - E_t(θ_t) ] ≤ daily_budget_kWh
1194
+ ```
1195
+
1196
+ This is enforced as a constraint during the forward pass: any path whose cumulative sacrifice exceeds the daily budget is pruned. The DP naturally finds the trajectory that maximizes combined utility while staying within the energy ceiling.
1197
+
1198
+ #### Step 6: Output — `daily_plan.json`
1199
+
1200
+ The optimizer outputs a schedule with explainability tags:
1201
+
1202
+ ```python
1203
+ {
1204
+ "date": "2025-07-15",
1205
+ "slots": [
1206
+ {
1207
+ "time": "05:00", "angle": 45.2, "offset": 0,
1208
+ "action": "full_tracking",
1209
+ "tag": "Morning protected — maximizing photosynthesis"
1210
+ },
1211
+ {
1212
+ "time": "11:15", "angle": 48.7, "offset": 5,
1213
+ "action": "minimum_dose_shade",
1214
+ "tag": "Sacrificing 0.04 kWh to protect fruiting zone (Tleaf=33°C)"
1215
+ },
1216
+ {
1217
+ "time": "15:30", "angle": 32.1, "offset": 0,
1218
+ "action": "full_tracking",
1219
+ "tag": "Stress subsided — maximizing energy revenue"
1220
+ }
1221
+ ],
1222
+ "total_sacrifice_kWh": 1.18,
1223
+ "daily_budget_kWh": 1.35,
1224
+ "budget_remaining_kWh": 0.17
1225
+ }
1226
+ ```
1227
+
1228
+ ### Relationship to Real-Time Control
1229
+
1230
+ The day-ahead plan is a **schedule**, not a command. The real-time arbiter (Phase 3.3) executes the planned trajectory but can override it when:
1231
+
1232
+ - Live sensor data diverges significantly from the forecast (e.g., unexpected cloud cover, sudden heat spike)
1233
+ - Weather protection or harvest mode is triggered (Priority 1–2 overrides)
1234
+ - The safety rails detect FvCB vs ML divergence > 12%
1235
+
1236
+ When the arbiter overrides the plan, it falls back to the real-time gate + minimum-dose logic. The daily plan is recalculated at sunrise each morning using the latest forecast.
1237
+
1238
+ ```
1239
+ DAY-AHEAD PLANNER (runs once at sunrise)
1240
+
1241
+ ▼ daily_plan.json
1242
+ REAL-TIME ARBITER (runs every 15 min)
1243
+ ├── Plan matches live conditions? → Execute planned angle
1244
+ ├── Live conditions diverge? → Fall back to real-time gate + min-dose
1245
+ └── Emergency override? → Weather stow / harvest park
1246
+ ```
1247
+
1248
+ ### New Module: `src/day_ahead_planner.py`
1249
+
1250
+ ```python
1251
+ class DayAheadPlanner:
1252
+ """Discrete DP optimizer for day-ahead tracker trajectory."""
1253
+
1254
+ def __init__(self, shadow_model, canopy_model, farquhar_model,
1255
+ ml_predictor, energy_budget):
1256
+ self.shadow = shadow_model
1257
+ self.canopy = canopy_model
1258
+ self.farquhar = farquhar_model
1259
+ self.ml_predictor = ml_predictor
1260
+ self.budget = energy_budget
1261
+ self.gate = InterventionGate()
1262
+
1263
+ def generate_plan(self, date, weather_forecast, energy_prices,
1264
+ crop_value_base, gdd_cumulative,
1265
+ daily_budget_kWh, cost_move=0.01):
1266
+ """Generate optimal angle trajectory for the next day.
1267
+
1268
+ Args:
1269
+ date: Target date
1270
+ weather_forecast: 96-slot weather forecast DataFrame
1271
+ energy_prices: 96-slot price vector ($/kWh)
1272
+ crop_value_base: Base crop value ($/hectare/season)
1273
+ gdd_cumulative: Growing degree days to date
1274
+ daily_budget_kWh: Max allowed energy sacrifice
1275
+ cost_move: Penalty per degree of motor movement
1276
+
1277
+ Returns:
1278
+ Daily plan dict with angle sequence and explainability tags.
1279
+ """
1280
+ stage = get_phenological_stage(gdd_cumulative)
1281
+ crop_multiplier = STAGE_CROP_MULTIPLIER[stage]
1282
+ zone_weights = ZONE_CROP_WEIGHTS.get(
1283
+ "veraison" if stage == "veraison" else
1284
+ "post_harvest" if stage == "post_harvest" else
1285
+ "pre_veraison")
1286
+
1287
+ # Pre-compute state space
1288
+ n_slots = len(weather_forecast)
1289
+ candidates = self._build_candidate_angles(weather_forecast)
1290
+ utility, energy, gate_locked = self._precompute(
1291
+ weather_forecast, candidates, energy_prices,
1292
+ crop_value_base, crop_multiplier, zone_weights)
1293
+
1294
+ # DP forward pass with budget constraint
1295
+ trajectory = self._dp_optimize(
1296
+ utility, energy, gate_locked, candidates,
1297
+ daily_budget_kWh, cost_move)
1298
+
1299
+ # Generate explainability tags
1300
+ plan = self._build_plan(date, trajectory, weather_forecast,
1301
+ utility, energy, daily_budget_kWh)
1302
+ return plan
1303
+ ```
1304
+
1305
+ ---
1306
+
1307
+ ## File Summary
1308
+
1309
+ | File | Purpose |
1310
+ |---|---|
1311
+ | `src/energy_budget.py` | Hierarchical Year→Month→Week→Day→Slot budget planner |
1312
+ | `src/tradeoff_engine.py` | InterventionGate + minimum-dose offset search |
1313
+ | `src/command_arbiter.py` | Fallback logic, hysteresis, priority stack |
1314
+ | `src/safety_rails.py` | FvCB vs ML divergence validation |
1315
+ | `src/spectral_aggregator.py` | CWSI/NDVI/PRI batch preprocessing |
1316
+ | `src/astronomical_tracker.py` | pvlib single-axis fallback |
1317
+ | `src/operational_modes.py` | Weather protection + harvesting |
1318
+ | `src/roi_service.py` | Budget utilization + LER reporting |
1319
+ | `src/data_schema.py` | Telemetry table definitions |
1320
+ | `src/day_ahead_planner.py` | DP trajectory optimizer + agronomic value weighting |
1321
+ | `scripts/import_layout.py` | Spatial asset registration |
1322
+ | `scripts/run_control_simulation.py` | End-to-end historical replay |
1323
+ | `src/licor_ingest.py` | LiCor data parsing, validation, and storage |
1324
+ | `src/calibration_pipeline.py` | Parameter fitting + model retraining + versioned deployment |
1325
+ | `scripts/upload_licor.py` | CLI script for batch LiCor upload |
1326
+
1327
+ | Modified File | Changes |
1328
+ |---|---|
1329
+ | `src/farquhar_model.py` | `calc_photosynthesis_semillon()` → returns (A, state, shading_helps) |
1330
+ | `src/solar_geometry.py` | `fruiting_zone_shadow()`, `evaluate_candidate_offsets()` |
1331
+ | `src/canopy_photosynthesis.py` | Expose `fruiting_zone_A`, `fruiting_zone_par`, `top_canopy_A` |
1332
+ | `config/settings.py` | No-shade windows, fruiting zone params, budget config, candidate offsets |
1333
+ | `app.py` | Budget Planner, Control Replay, Fruiting Zone Monitor, ROI tabs |
1334
+
1335
+ ---
1336
+
1337
+ ### Phase 8: LiCor Ground-Truth Calibration Pipeline
1338
+
1339
+ #### 8.1 Background
1340
+
1341
+ Manual LiCor measurements (LI-6400/6800 portable gas-exchange systems) provide the highest-fidelity ground-truth photosynthesis data available — direct leaf-level A, gs, Ci, and transpiration measured under controlled chamber conditions. These readings serve as the gold standard for calibrating and validating the Farquhar model and the ML prediction pipeline.
1342
+
1343
+ #### 8.2 Module: `src/licor_ingest.py`
1344
+
1345
+ Dedicated ingestion module for LiCor calibration datasets:
1346
+
1347
+ ```python
1348
+ class LiCorIngest:
1349
+ """Ingest, validate, and store manual LiCor gas-exchange measurements.
1350
+
1351
+ Accepts Excel/CSV exports from LI-6400/6800 instruments.
1352
+ Maps LiCor columns (Photo, Cond, Ci, Trmmol, Tleaf, PARi, etc.)
1353
+ to internal schema and stores as ground-truth reference data.
1354
+ """
1355
+
1356
+ LICOR_COLUMN_MAP = {
1357
+ "Photo": "A_measured", # µmol CO₂ m⁻² s⁻¹
1358
+ "Cond": "gs_measured", # mol H₂O m⁻² s⁻¹
1359
+ "Ci": "Ci_measured", # µmol mol⁻¹
1360
+ "Trmmol": "E_measured", # mmol H₂O m⁻² s⁻¹
1361
+ "Tleaf": "Tleaf_measured", # °C
1362
+ "PARi": "PAR_measured", # µmol m⁻² s⁻¹
1363
+ "VpdL": "VPD_measured", # kPa
1364
+ }
1365
+
1366
+ def ingest(self, file_path: str, metadata: dict) -> pd.DataFrame:
1367
+ """Parse LiCor export, validate ranges, attach metadata
1368
+ (date, vine ID, zone, operator), return clean DataFrame."""
1369
+ ...
1370
+
1371
+ def validate(self, df: pd.DataFrame) -> dict:
1372
+ """Range checks: A in [-5, 35], PAR in [0, 2500], Tleaf in [10, 55].
1373
+ Flag outliers and instrument errors (e.g., negative gs)."""
1374
+ ...
1375
+
1376
+ def store(self, df: pd.DataFrame, dest_dir: str):
1377
+ """Append to ground-truth archive (Parquet partitioned by date)."""
1378
+ ...
1379
+ ```
1380
+
1381
+ #### 8.3 API Endpoint for Calibration Upload
1382
+
1383
+ A dedicated endpoint (FastAPI or Streamlit file-upload widget) that:
1384
+
1385
+ 1. **Accepts** LiCor datasets (CSV/Excel upload with metadata: date, vine ID, canopy zone, operator notes)
1386
+ 2. **Validates** data integrity and physiological plausibility
1387
+ 3. **Triggers** a re-training / fine-tuning pipeline:
1388
+ - Recalibrate Farquhar model parameters (Vcmax25, Jmax25, Rd) by fitting to LiCor A-Ci curves
1389
+ - Fine-tune ML regressors (Stage 2) using LiCor A as ground-truth labels instead of Farquhar-derived A
1390
+ - Optionally fine-tune Chronos-2 LoRA adapter on the updated label set
1391
+ 4. **Versions** the updated model artifacts (model registry with timestamp, LiCor dataset hash, metrics delta)
1392
+ 5. **Promotes** the new model version to production only if validation metrics improve (gated deployment)
1393
+
1394
+ ```python
1395
+ class CalibrationPipeline:
1396
+ """End-to-end: LiCor upload → parameter fitting → model retraining → versioned deployment."""
1397
+
1398
+ def run(self, licor_df: pd.DataFrame, current_model_version: str) -> dict:
1399
+ """
1400
+ 1. Fit Farquhar params to A-Ci curves → updated Vcmax25, Jmax25
1401
+ 2. Regenerate Stage 1 labels using calibrated Farquhar
1402
+ 3. Retrain Stage 2 ML models on calibrated labels
1403
+ 4. Evaluate on held-out LiCor validation set
1404
+ 5. If RMSE improves ≥ 5%: promote new version
1405
+ Else: keep current version, log comparison
1406
+ """
1407
+ ...
1408
+ ```
1409
+
1410
+ #### 8.4 Files
1411
+
1412
+ | File | Purpose |
1413
+ |---|---|
1414
+ | `src/licor_ingest.py` | LiCor data parsing, validation, and storage |
1415
+ | `src/calibration_pipeline.py` | Parameter fitting + model retraining + versioned deployment |
1416
+ | `scripts/upload_licor.py` | CLI script for batch LiCor upload |
1417
+
1418
+ ---
1419
+
1420
+ ---
1421
+
1422
+ ## Phase 10: Reliable Advisor — Anti-Hallucination Guardrails
1423
+
1424
+ ### Problem
1425
+
1426
+ The Gemini chatbot (Phase 9/9.5) hallucinated: it could fabricate sensor readings,
1427
+ recommend shading that violates biology rules, and answer data questions from
1428
+ training knowledge instead of calling tools. No grounding verification existed.
1429
+
1430
+ ### Architecture
1431
+
1432
+ ```
1433
+ User Query
1434
+
1435
+ QueryClassifier (regex) ← Step 2: data / knowledge / greeting
1436
+
1437
+ Gemini Pass 1
1438
+
1439
+ Tool call missing + data query? ← Step 2: re-prompt to force tool use
1440
+
1441
+ _dispatch_tool()
1442
+
1443
+ tag_tool_result() ← Step 4: source label + data age + freshness warning
1444
+
1445
+ Gemini Pass 2 (with source citation instructions)
1446
+
1447
+ validate_response() ← Step 3: deterministic rule checks (block / warn)
1448
+
1449
+ estimate_confidence() ← Step 7: high / medium / low / insufficient_data
1450
+
1451
+ ChatResponse ← Step 1: structured schema with metadata
1452
+
1453
+ UI: render confidence badge, sources, caveats
1454
+ ```
1455
+
1456
+ ### Components
1457
+
1458
+ | Component | File | Role |
1459
+ |-----------|------|------|
1460
+ | `QueryClassifier` | `src/chatbot/guardrails.py` | Regex-based query routing: forces tool calls for data questions |
1461
+ | `ResponseValidator` | `src/chatbot/guardrails.py` | Deterministic post-response check against biology rules |
1462
+ | `estimate_confidence` | `src/chatbot/guardrails.py` | Data freshness → confidence level mapping |
1463
+ | `tag_tool_result` | `src/chatbot/guardrails.py` | Adds `_source`, `_data_age_minutes`, `_freshness_warning` to tool results |
1464
+ | `ChatResponse` | `src/chatbot/vineyard_chatbot.py` | Extended with `confidence`, `sources`, `caveats`, `rule_violations` |
1465
+ | `_render_grounding_metadata` | `ui/tab_advisor.py` | Renders confidence badge, source citations, caveats in UI |
1466
+
1467
+ ### Validation rules enforced
1468
+
1469
+ | Rule | Severity | Trigger |
1470
+ |------|----------|---------|
1471
+ | `no_shade_before_10` | **Block** | Response recommends shading before 10:00 |
1472
+ | `no_shade_in_may` | **Block** | Response recommends shading in May without citing extreme conditions |
1473
+ | `temperature_transition` | Warn | Response recommends shading below 28°C |
1474
+ | `no_leaves_no_shade_problem` | Warn | Response recommends shading during dormancy |
1475
+ | `no_shading_must_explain` | Warn | Response says "don't shade" without giving a biological reason |
1476
+
1477
+ Block = response text overridden; Warn = caveat appended.
1478
+
1479
+ ### Confidence mapping
1480
+
1481
+ | Condition | Confidence |
1482
+ |-----------|------------|
1483
+ | Tool succeeded, data < 30 min | **High** |
1484
+ | Tool succeeded, data 30–120 min | **Medium** |
1485
+ | Tool succeeded, data > 120 min | **Low** |
1486
+ | No tool called | **Low** |
1487
+ | Tool failed | **Insufficient data** |
1488
+ | Computed result (FvCB, sim) | **High** |
1489
+
1490
+ ### Remaining steps (future phases)
1491
+
1492
+ - **Step 5 — RAG knowledge base**: Move biology rules from prompt stuffing to searchable documents; inject only relevant rules per query
1493
+ - **Step 6 — Conversation memory**: Sliding context window with session summary; pin critical context (vine state, active alerts)
1494
+ - **Step 8 — Guardrail test suite**: Automated adversarial prompts run on every prompt/model change (5 QA tests implemented, expand to 20+)
1495
+ - **Step 9 — Dual-channel advisory**: Separate information mode (blue, factual) from advisory mode (amber, recommendations with reasoning chain)
1496
+ - **Step 10 — Feedback loop**: Thumbs up/down, "flag as incorrect" button, weekly failure-mode review
1497
+
1498
+ ---
1499
+
1500
+ ## Dependency Order
1501
+
1502
+ ```
1503
+ Phase 1 (Config + Schema)
1504
+
1505
+
1506
+ Phase 2 (FvCB Semillon + Fruiting Zone Shadow + Safety Rails)
1507
+
1508
+
1509
+ Phase 3 (InterventionGate + MinDose Engine + Budget + Arbiter) ← CORE
1510
+
1511
+
1512
+ Phase 3.5 (Agronomic Weighting + Day-Ahead DP Planner)
1513
+
1514
+
1515
+ Phase 4 (Operational Modes)
1516
+
1517
+
1518
+ Phase 5 (ROI Service)
1519
+
1520
+
1521
+ Phase 6 (Dashboard v2)
1522
+
1523
+
1524
+ Phase 7 (Integration Testing + Budget Guarantee Validation)
1525
+
1526
+
1527
+ Phase 8 (LiCor Ground-Truth Calibration Pipeline)
1528
+
1529
+
1530
+ Phase 9/9.5 (Vineyard Advisor Chatbot + Data Integration)
1531
+
1532
+
1533
+ Phase 10 (Reliable Advisor — Anti-Hallucination Guardrails)
1534
+ ```
context/3_todo.md ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolarWine – Todo Tracker
2
+
3
+ This file mirrors the high-level plan in `2_plan.md` and tracks day-to-day progress.
4
+
5
+ ---
6
+
7
+ ## Baseline v1 (Complete)
8
+
9
+ - [x] **scaffold** – project directory structure, `.env`, `.gitignore`, `requirements.txt`
10
+ - [x] **context-files** – create `context/1_purpose.md`, `context/2_plan.md`, `context/3_todo.md`
11
+ - [x] **git-init** – create git repository and make initial commit
12
+ - [x] **oop-architecture** – implement OOP modules in `src/`
13
+ - [x] **ims-fetch** – implement `IMSClient` and cache IMS data
14
+ - [x] **stage1-farquhar** – implement mechanistic Farquhar/Greer–Weedon model
15
+ - [x] **stage1-validate** – validate Stage 1 A outputs and plots
16
+ - [x] **stage2-preprocess** – preprocessing and temporal split
17
+ - [x] **stage2-models** – train and evaluate ML models
18
+ - [x] **stage2-visualize** – build evaluation visualizations
19
+ - [x] **streamlit-app** – build Streamlit UI (6 tabs)
20
+
21
+ ---
22
+
23
+ ## SolarWine 2.0
24
+
25
+ ### Phase 1: Configuration & Data Infrastructure
26
+
27
+ - [x] **config-extend** – add energy budget, no-shade windows, fruiting zone, and tradeoff params to `config/settings.py` (most params added in earlier session; no-shade windows `NO_SHADE_BEFORE_HOUR`, `NO_SHADE_MONTHS`, `NO_SHADE_GHI_BELOW`, `NO_SHADE_TLEAF_BELOW` and `SHADE_ELIGIBLE_HOURS` added 2026-03-10; guardrails wired to reference settings)
28
+ - [x] **data-schema** – `src/data/data_schema.py` (telemetry dataclasses: SensorRaw, BiologicalState, TrackerKinematics, SimulationLog). Re-exported via `src/data_schema.py`
29
+ - [x] **import-layout** – `scripts/import_layout.py` generates `Data/layout.json` from config geometry + TB device registry (4 rows, 22 devices with 3D positions). Added 2026-03-10
30
+
31
+ ### Phase 2: Biological Digital Twin Upgrades
32
+
33
+ - [x] **fvcb-semillon** – upgrade `src/farquhar_model.py` with `calc_photosynthesis_semillon()` → returns (A, limiting_state, shading_helps). Added 2026-03-10: uses configurable `transition_temp` (default `SEMILLON_TRANSITION_TEMP_C=30°C`); `shading_helps` is True only when Rubisco-limited AND Aj > Ac (light abundant relative to enzyme capacity)
34
+ - [x] **shadow-fruiting** – upgrade `src/solar_geometry.py` with `fruiting_zone_shadow()` and `evaluate_candidate_offsets()` (added 2026-03-10)
35
+ - [x] **canopy-expose** – `compute_vine_A()` now returns `fruiting_zone_A`, `fruiting_zone_par`, `top_canopy_A`, `top_canopy_par` alongside existing fields. Added 2026-03-10
36
+ - [x] **safety-rails** – `src/advisor/safety_rails.py` (FvCB vs ML divergence check, 12% threshold → fallback). Re-exported via `src/safety_rails.py`
37
+ - [x] **spectral-agg** – `src/spectral_aggregator.py` with `aggregate_spectral()` (single-timestep) and `aggregate_spectral_df()` (batch DataFrame). CWSI from explicit value → delta-T proxy → VPD fallback. Physical bounds clipping with quality flags. Added 2026-03-10
38
+
39
+ ### Phase 3: The Control Algorithm (Core)
40
+
41
+ - [x] **energy-budget** – create `src/energy_budget.py` (hierarchical Year→Month→Week→Day→Slot budget planner). Added 2026-03-10: `EnergyBudgetPlanner` with `compute_annual_plan()`, `compute_weekly_plan()`, `compute_daily_plan()`, `spend_slot()`, `emergency_draw()`, `compute_daily_rollover()`. Analytical fallback when no ShadowModel. Budget: ~920 kWh potential → 46 kWh budget (5%), July gets 17.6 kWh (45%)
42
+ - [x] **intervention-gate** – `InterventionGate` in `src/shading/tradeoff_engine.py` (physiology-only pass/fail: GHI, Tleaf, CWSI, shading_helps; no time/month hard-coding — geometry decides). Re-exported via `src/tradeoff_engine.py`
43
+ - [x] **min-dose-engine** – `TradeoffEngine` in `src/shading/tradeoff_engine.py` (smallest offset satisfying: fruiting PAR < 400 µmol, top canopy ≥ 85% baseline, sacrifice ≤ budget; geometric feasibility pre-check; face-PAR sun-side selection)
44
+ - [x] **command-arbiter** – `src/command_arbiter.py` `CommandArbiter` (5-level priority stack: weather→harvest→safety→timeout→engine; hysteresis filter with configurable window/tolerance; `arbitrate()` combines selection + filter; weather/harvest bypass hysteresis). Added 2026-03-10
45
+ - [x] **astro-tracker** – `AstronomicalTracker` in `src/command_arbiter.py` (pure sun-following via ShadowModel; `get_angle(timestamp)` interface). Added 2026-03-10
46
+
47
+ ### Phase 3.5: Agronomic Weighting & Day-Ahead Planner
48
+
49
+ - [x] **zone-crop-weights** – spatial zone weighting implemented in `DayAheadPlanner._get_crop_multiplier()` using `STAGE_CROP_MULTIPLIER` + `ZONE_CROP_WEIGHTS` from settings. Added 2026-03-10
50
+ - [x] **day-ahead-dp** – created `src/day_ahead_planner.py` with `DayAheadPlanner` (DP trajectory optimizer: forward pass over daylight slots × candidate offsets, budget constraint via discretised DP, movement cost penalty for smooth trajectories, simplified InterventionGate using forecast data). Returns `DayAheadPlan` with `SlotPlan` list + explainability tags. Added 2026-03-10
51
+ - [x] **daily-plan-output** – `DayAheadPlan.to_dict()` serialises plan to JSON-ready dict with per-slot offset, energy cost, gate status, and tags. Added 2026-03-10
52
+ - [x] **phenology-tracker** – `src/models/phenology.py` upgraded with three estimation methods: (1) GDD-based via `compute_gdd_from_ims()` + `estimate_stage_by_gdd()` using IMS tdmax/tdmin and `PHENOLOGY_GDD_THRESHOLDS`, (2) camera-based via `detect_stage_from_camera()` using Gemini Vision on live vineyard feed, (3) calendar fallback. `estimate_stage_combined()` merges all three (camera high-confidence > GDD > calendar). Backward-compatible API. Added 2026-03-10
53
+ - [x] **baseline-ps** – created `src/baseline_predictor.py` (hybrid FvCB+ML with RoutingAgent rule-based selection per slot); wired into DayAheadPlanner as optional `baseline_predictor` param, replaces temperature heuristic with actual A predictions for crop value
54
+ - [x] **arbiter-plan-integration** – ControlLoop now tracks plan divergence (cumulative kWh + consecutive slots), triggers re-plan when thresholds exceeded, with cooldown to avoid thrashing
55
+ - [x] **tracker-rpc** – added `send_rpc_command()` and `set_device_attributes()` to ThingsBoardClient (two-way RPC with one-way fallback, shared attribute writes)
56
+ - [x] **tracker-verify** – TrackerDispatcher verifies |actual - target| < tolerance after dispatch via `_verify_all()`
57
+ - [x] **tracker-dispatch** – created `src/tracker_dispatcher.py`: takes `ArbiterDecision` → sends to all 4 trackers via RPC/attribute fallback → verifies execution → logs result
58
+ - [x] **control-loop** – created `src/control_loop.py`: 15-min main loop (fetch sensors → plan lookup → live gate check → budget guard → arbitrate → fleet overrides → dispatch → budget spend → divergence check → re-plan → log). One-shot, continuous, and dry_run modes. TrackerFleet/Scheduler wired for per-tracker overrides (default = all same). EnergyBudgetPlanner wired for real-time budget consumption. Plan divergence tracking with auto re-plan
59
+
60
+ ### Phase 4: Operational Modes
61
+
62
+ - [x] **ops-modes** – created `src/operational_modes.py` (WindStow, HailStow, HeatShield, HarvestMode + composite `OperationalModeChecker`; integrated into ControlLoop)
63
+
64
+ ### Phase 5: ROI Reporting
65
+
66
+ - [x] **roi-service** – created `src/roi_service.py` (BudgetStatus, InterventionStats, LERResult, ROIService with budget tracking + LER computation)
67
+
68
+ ### Phase 6: Dashboard v2
69
+
70
+ - [x] **tab-system-status** – unified System Status tab (`ui/tab_system_status.py`) with 5 sub-sections:
71
+ - **Tracker Status**: live angles from TB, coherence check, bar chart
72
+ - **Energy Budget**: annual/monthly budget allocation, gauge, weekly plan
73
+ - **Control Replay**: load simulation logs, timeline charts, slot-by-slot detail
74
+ - **Fruiting Zone**: live canopy temp/PAR/VPD, sunburn risk assessment, thresholds
75
+ - **ROI Dashboard**: budget utilisation gauge, intervention stats, LER calculator
76
+
77
+ ### Phase 7: Integration Testing
78
+
79
+ - [x] **sim-script** – created `scripts/run_control_simulation.py` (historical replay through full control loop with per-day summary, CLI args, JSON output)
80
+ - [x] **validate-budget** – `scripts/validate_control_system.py` verifies cumulative sacrifice never exceeds `max_energy_reduction_pct` (tested with real sim log + synthetic violations)
81
+ - [x] **validate-gates** – validates no interventions in May, before 10:00, below transition temp, or below GHI threshold (3/3 violation types caught in self-test)
82
+ - [x] **validate-dose** – validates average offset < 10°, max offset < 20°, intervention rate < 30% (catches excessive dose in self-test)
83
+
84
+ ### Phase 8: AI-Enhanced Forecasting & Routing
85
+
86
+ #### 8A — Chronos-2 Multivariate Day-Ahead Forecasting
87
+
88
+ - [x] **install-chronos** – install `chronos-forecasting>=2.0` and verify compatibility with existing env (done: conda env `solarwine` with Python 3.12, chronos 2.2.2, torch 2.10, llvm-openmp for XGBoost)
89
+ - [x] **chronos-data-prep** – build multivariate input pipeline: combine historical A_n (target) with past IMS station 43 measurements + on-site sensors (PAR, T_canopy, VPD, CWSI) as historical context, plus IMS *forecast* data (predicted GHI, air temperature, wind speed, humidity) as known-future covariates for the prediction horizon; IMS forecasts are available at inference time so this is not a data leak — only actual future sensor measurements would be
90
+ - [x] **chronos-predict** – execute zero-shot 96-step (24h @ 15-min) day-ahead A_n prediction using Chronos-2 (`amazon/chronos-t5-small` or `-base`) with multivariate context (past A_n + past sensor measurements as history, IMS forecasts as known-future covariates); integrate into `src/day_ahead_planner.py` baseline prediction
91
+ - [x] **chronos-benchmark** – benchmark Chronos-2 MAE against existing ML baseline (MAE ≈ 2.7) on held-out test set; compare univariate (A_n only) vs multivariate (A_n + IMS covariates) performance; document results and decide on integration path
92
+
93
+ #### 8B — Gemini LLM Data Engineering
94
+
95
+ - [x] **install-gemini** – `google-genai>=1.0` added to `requirements.txt`; already installed in `solarwine` conda env (v1.65.0)
96
+ - [x] **llm-data-cleaning** – `src/llm_data_engineer.py` `LLMDataEngineer.analyze_anomalies()`: sends per-column descriptive stats + domain context (physical ranges, Negev site notes) to Gemini; receives Z-score/IQR/bound thresholds as JSON; `apply_cleaning()` applies clip/nan/drop strategies; graceful fallback to physics-based bounds when API key unavailable
97
+ - [x] **llm-feature-eng** – `LLMDataEngineer.engineer_features()`: adds `hour_sin`, `hour_cos`, `doy_sin`, `doy_cos` (cyclical time encodings) and `stress_risk_score` (normalised weighted VPD + CWSI, weights confirmed by Gemini); `run_pipeline()` orchestrates clean → feature-engineer in one call
98
+ - [x] **dashboard-8b** – Data Explorer: added **AI Data Engineering** as third radio option (Gemini anomaly thresholds, cleaning summary, before/after charts, engineered features, daytime stress profile). Presentation tab: added Gemini LLM data engineering to “What’s Next” completed list, Key takeaways, and “What each tab shows” (Data Explorer bullet). All data precomputed in-tab (no cache loader).
99
+
100
+ #### 8C — Gemini Intelligent Routing Layer
101
+
102
+ - [x] **routing-prompt** – define supervisory system prompt for Gemini model router: given real-time telemetry (Temp, Irradiance, CWSI), route to FvCB (Model A, accurate under standard conditions) or ML (Model B, handles non-linear stress) — reply only `MODEL_A` or `MODEL_B`
103
+ - [x] **routing-agent** – build `RoutingAgent` function: executes Gemini API call at start of 15-min control loop, constructs prompt from live telemetry, parses response to determine model path
104
+ - [x] **arbiter-routing** – RoutingAgent wired into ControlLoop tick (step 2b): routes each slot to FvCB or ML via rule-based fast path (>90% of cases) with Gemini fallback for transition zone; `model_route` field added to TickResult; safety_rails divergence check remains as independent safety layer
105
+
106
+ #### 8E — Gemini Day-Ahead Advisor
107
+
108
+ - [x] **day-ahead-advisor** – `src/day_ahead_advisor.py` DayAheadAdvisor: Gemini analyzes IMS forecast + vine state → structured stress profile, budget recommendations, model routing, Chronos sanity check; dashboard display in Predictions tab
109
+
110
+ #### 8D — Shadow Mode Validation
111
+
112
+ - [x] **shadow-mode-test** – created `scripts/shadow_mode_test.py`: 4 validation tests — (1) LLM data cleaning filters catch anomalies, (2) BaselinePredictor generates valid A profiles (peak=17.0 µmol, daylight mean=6.1), (3) RoutingAgent routes cool→fvcb and hot→ml correctly, (4) full pipeline shadow simulation over date range
113
+
114
+ ### Phase 9: Vineyard Advisor Chatbot
115
+
116
+ - [x] **chatbot-todo** – add Phase 9 to `context/3_todo.md`
117
+ - [x] **chatbot-scaffold** – create `src/vineyard_chatbot.py` scaffold + Gemini client (lazy init, same pattern as DayAheadAdvisor)
118
+ - [x] **chatbot-system-prompt** – write `CHATBOT_SYSTEM_PROMPT` (persona, site facts, 8 biology rules, tool definitions, response rules)
119
+ - [x] **chatbot-tool-defs** – implement `TOOL_DEFINITIONS` list + `_dispatch_tool()` router
120
+ - [x] **chatbot-tools-weather** – implement `_tool_current_weather()`, `_tool_calc_photosynthesis()`, `_tool_explain_rule()`
121
+ - [x] **chatbot-tools-shading** – implement `_tool_simulate_shading()`, `_tool_compare_angles()`, `_tool_daily_schedule()`, `_tool_day_ahead_advisory()`
122
+ - [x] **chatbot-chat-flow** – implement two-pass chat flow (`_build_messages()`, `_call_gemini()`, `chat()`)
123
+ - [x] **chatbot-tab** – add Vineyard Advisor tab to `app.py` (Vineyard Advisor tab, chat UI, session state)
124
+ - [x] **chatbot-quick-actions** – add quick-action buttons + `_render_tool_data()` helper + clear chat
125
+ - [x] **chatbot-test** – syntax check, import check, tabs render, tool call test, fallback test
126
+
127
+ ### Phase 9.5: Chatbot Data Integration
128
+
129
+ Wire all five data sources into the chatbot (IMS weather, TB sensors, PS prediction,
130
+ energy generation, energy prediction) via a loosely-coupled provider layer.
131
+
132
+ #### Architecture (`src/data_providers.py`)
133
+
134
+ ```
135
+ VineyardChatbot ──▶ DataHub ──┬── WeatherService (IMS, 30-min TTL cache)
136
+ (tool dispatch) (registry) ├── VineSensorService (TB, 5-min TTL snapshot)
137
+ ├── PhotosynthesisService (FvCB + ML + forecast)
138
+ ├── EnergyService (TB generation + prediction)
139
+ ├── AdvisoryService (Gemini day-ahead)
140
+ └── BiologyService (rules lookup)
141
+ ```
142
+
143
+ - Chatbot never imports data clients directly — only talks to `self.hub`.
144
+ - Each Service owns its caching (TTLCache), error handling, lazy init.
145
+ - Adding a new source = write a Service subclass + `hub.register()`.
146
+ - `summarise_dataframe()` auto-compresses large results for LLM context.
147
+
148
+ #### Data strategy
149
+
150
+ | Source | Service | Strategy | Detail |
151
+ |---|---|---|---|
152
+ | IMS weather | WeatherService | Cache (preload) | `ims_merged_15min.csv`, 30-min TTL |
153
+ | TB sensor snapshot | VineSensorService | On-demand + 5-min TTL | `get_vine_snapshot()` |
154
+ | TB sensor time-series | VineSensorService | On-demand | `get_timeseries()` → hourly downsample |
155
+ | PS prediction (FvCB/ML) | PhotosynthesisService | On-demand (compute) | Lazy-load models |
156
+ | Energy generation (TB) | EnergyService | On-demand | Fetch from inverter/meter devices |
157
+ | Energy prediction | EnergyService | On-demand (compute) | GHI × panel × efficiency × cos(θ) |
158
+
159
+ #### Steps
160
+
161
+ - [x] **chatbot-provider-scaffold** – Create `src/data_providers.py` with BaseService ABC, TTLCache, summarise_dataframe, DataHub registry, and 6 service classes (Weather, VineSensor, Photosynthesis, Energy, Advisory, Biology)
162
+ - [x] **chatbot-hub-wiring** – Refactored `VineyardChatbot.__init__` to accept a `DataHub` (default: `DataHub.default()`); rewrote `_dispatch_tool()` to delegate to hub services (15 tools); removed all direct imports of IMSClient/ThingsBoardClient/FarquharModel from chatbot
163
+ - [x] **chatbot-ims-history** – Implemented `WeatherService.get_history()` with hourly resample + `summarise_dataframe()`; added `get_weather_history` tool to system prompt and dispatch
164
+ - [x] **chatbot-tb-timeseries** – Implemented `VineSensorService.get_history()` (device_type, area, hours_back → hourly averages via `get_timeseries()`); added `get_sensor_history` tool to system prompt and dispatch
165
+ - [x] **chatbot-ml-predictor** – Implemented `PhotosynthesisService.predict_ml()` with train-once-cache pattern: lazy loads sensor data + IMS, runs Preprocessor + PhotosynthesisPredictor, picks best model by MAE, auto-fills features from latest IMS if not provided. *Note: requires IMS cache with temperature/GHI overlapping sensor data dates*
166
+ - [x] **chatbot-ps-forecast** – Implemented `PhotosynthesisService.forecast_day_ahead()`: FvCB-based projection over IMS hourly weather (PAR~2×GHI, Tleaf~Tair+2°C, VPD from T+RH). Returns hourly A profile + peak/stress summary
167
+ - [x] **chatbot-energy-devices** – Discovered Plant asset "Yeruham Vineyard" (ASSET, not DEVICE) with `power` (W) + `production` (Wh/5min) keys. Added `ASSET_REGISTRY`, `get_asset_timeseries()`, `get_asset_latest()` to TB client. Also registered 4 Tracker devices (501-509) with angle/mode telemetry
168
+ - [x] **chatbot-energy-tool** – `EnergyService` rewritten: past/today → real TB Plant asset data (daily ~270-290 kWh for 48 kW system); future → analytical estimate (persistence forecast × system capacity). Hourly profiles in Israel local time
169
+ - [x] **chatbot-energy-predict** – `EnergyService.predict()` now routes: past/today → `get_daily_production()` (real TB), future → `_predict_analytical()` (IMS GHI × 48 kW)
170
+ - [x] **chatbot-prompt-update** – Updated system prompt with all 15 tools (grouped: weather, vine sensors, photosynthesis, shading, energy, advisory, biology); added energy/prediction/irrigation/fertiliser keywords to fallback matcher
171
+ - [x] **chatbot-integration-test** – All 15 tools dispatch correctly; verified imports + instantiation (no circular imports); tested fallback keyword matching; `summarise_dataframe()` compresses >48-row results. Remaining data gaps: IMS cache lacks GHI/temperature (only wind_speed_ms), no date overlap between IMS and sensors for ML training
172
+
173
+ ### Phase 10: Reliable Advisor — Anti-Hallucination Guardrails
174
+
175
+ Deterministic guardrails to prevent the chatbot from hallucinating data,
176
+ violating biology rules, or answering data questions without tool grounding.
177
+
178
+ #### Step 1: Structured Response Schema
179
+ - [x] **structured-response** – Extended `ChatResponse` with `confidence`, `sources`, `caveats`, `rule_violations` fields; all responses carry grounding metadata
180
+
181
+ #### Step 2: Mandatory Tool Grounding
182
+ - [x] **query-classifier** – `classify_query()` in `src/chatbot/guardrails.py`: regex-based classification (data / knowledge / greeting / ambiguous); data queries force tool calls
183
+ - [x] **tool-enforcement** – If LLM answers a data query without calling a tool, chatbot re-prompts: "You MUST call a tool"
184
+
185
+ #### Step 3: Post-Response Rule Validator
186
+ - [x] **response-validator** – `validate_response()` in `src/chatbot/guardrails.py`: deterministic checks for no_shade_before_10 (block), no_shade_in_may (block), temperature_transition (warn), no_leaves_no_shade_problem (warn), no_shading_must_explain (warn)
187
+ - [x] **validator-integration** – Validator runs on every response in `chat()`; block-severity overrides response text; warn-severity appends caveats
188
+
189
+ #### Step 4: Source-Tagged Context Injection
190
+ - [x] **source-tagging** – `tag_tool_result()` adds `_source`, `_data_age_minutes`, `_freshness_warning` to tool results before Gemini Pass 2
191
+ - [x] **citation-prompt** – Pass 2 prompt instructs Gemini to cite source and timestamp when quoting numbers
192
+
193
+ #### Step 5: RAG Knowledge Base
194
+ - [x] **rag-knowledge-base** – Keyword-indexed rule retrieval with pinned rules (no_shade_before_10, energy_budget, temperature_transition) + top-scoring rules per query; `retrieve_relevant_rules()` + `build_contextual_prompt()` in vineyard_chatbot.py
195
+
196
+ #### Step 6: Conversation Memory
197
+ - [x] **sliding-context** – Summarize older messages into topics, keep recent 6 verbatim, pin critical context; `_summarize_history()` in vineyard_chatbot.py
198
+
199
+ #### Step 7: Confidence-Gated Responses
200
+ - [x] **confidence-estimator** – `estimate_confidence()` maps data freshness to high/medium/low/insufficient_data
201
+ - [x] **confidence-ui** – `_render_grounding_metadata()` in `ui/tab_advisor.py` shows colored confidence badge, sources, caveats, rule warnings
202
+
203
+ #### Step 8: Guardrail Test Suite
204
+ - [x] **qa-tests-5** – 5 adversarial QA tests in `tests/test_advisor_chatbot.py`: shade-before-10 blocked, data-question forces tool, stale-data warns, no-shade-without-reason warns, tool-failure returns insufficient_data
205
+ - [x] **guardrail-unit-tests** – Unit tests for QueryClassifier, ResponseValidator, ConfidenceEstimation, SourceTagging (42 tests total, all passing)
206
+ - [x] **qa-tests-expand** – Expanded to 21 adversarial prompts (QA1-QA21) covering May shading, dormancy, night queries, prompt injection, boundary conditions, multi-rule violations, energy/irrigation classification, ambiguous queries. 58 tests total, all passing
207
+
208
+ #### Step 9: Dual-Channel Advisory
209
+ - [x] **dual-channel** – `classify_response_mode()` separates info mode (blue, factual) from advisory mode (amber, recommendations); `response_mode` field on ChatResponse; UI renders mode indicator
210
+
211
+ #### Step 10: Feedback Loop
212
+ - [x] **feedback-ui** – Thumbs up/down/flag buttons on each response in `ui/tab_advisor.py`; `_render_feedback_buttons()` + `_submit_feedback()`
213
+ - [x] **feedback-storage** – `src/chatbot/feedback.py` logs feedback to JSONL (query, response, tool_calls, rules, confidence, comment)
214
+
215
+ ---
216
+
217
+ ## Phase 11: Production Migration
218
+
219
+ Full architecture documented in `context/4_production.md`.
220
+ 3-layer architecture: Lovable/React frontend (Cloudflare Pages) → FastAPI middleware (HuggingFace Spaces) → existing Python core.
221
+ Cron workers via GitHub Actions (free for private repos).
222
+
223
+ ### Phase 11.1: Redis Caching Layer
224
+
225
+ Replace in-memory TTLCache with Upstash Redis so API and workers share state.
226
+
227
+ - [x] **upstash-setup** – Upstash Redis instance created (many-mammal-76248.upstash.io), credentials in `.env`, connectivity verified with dual-layer TTLCache round-trip
228
+ - [x] **redis-client** – Create `src/data/redis_cache.py`: thin wrapper around Upstash Redis REST API with `get_json()`, `set_json(ttl)`, `delete()`, `exists()`, `ping()`. Singleton via `get_redis()`, returns None when not configured
229
+ - [x] **datahub-redis** – Refactored `TTLCache` in `data_providers.py`: tries Redis first (via `redis_prefix`), falls back to in-memory. WeatherService prefix `weather:`, VineSensorService prefix `vine:`. Streamlit works unchanged (no Redis = pure in-memory)
230
+ - [x] **tick-result-redis** – `backend/workers/control_tick.py` writes TickResult to Redis `control:last_tick` (20-min TTL) after each tick
231
+ - [x] **plan-redis** – `backend/workers/daily_planner.py` writes plan to Redis `control:plan` (24h TTL) + backup `Data/daily_plan.json`
232
+ - [x] **budget-redis** – ControlLoop `_ensure_daily_budget()` restores from Redis `control:budget` on startup; `_persist_budget()` saves after every `spend_slot()`. Falls back to recompute if Redis unavailable
233
+
234
+ ### Phase 11.2: FastAPI Middleware (HuggingFace Spaces)
235
+
236
+ Create the API gateway in `backend/api/`, deploy as HF Space (Docker SDK).
237
+
238
+ - [x] **fastapi-scaffold** – Created `backend/api/main.py` with FastAPI app, CORS (configurable via `ALLOWED_ORIGINS`), lifespan, all route includes
239
+ - [x] **fastapi-deps** – Created `backend/api/deps.py` with `get_datahub()` (lru_cache singleton) and `get_redis_client()`
240
+ - [x] **route-weather** – `backend/api/routes/weather.py`: GET `/api/weather/current`, `/api/weather/history?start_date&end_date`, `/api/weather/forecast`
241
+ - [x] **route-sensors** – `backend/api/routes/sensors.py`: GET `/api/sensors/snapshot`, `/api/sensors/history?type&area&hours`
242
+ - [x] **route-energy** – `backend/api/routes/energy.py`: GET `/api/energy/current`, `/api/energy/daily/{date}`, `/api/energy/history`, `/api/energy/predict/{date}`
243
+ - [x] **route-photosynthesis** – `backend/api/routes/photosynthesis.py`: GET `/api/photosynthesis/current?model`, `/api/photosynthesis/forecast`
244
+ - [x] **route-control** – `backend/api/routes/control.py`: GET `/api/control/status`, `/api/control/plan`, `/api/control/budget`, `/api/control/trackers`
245
+ - [x] **route-chatbot** – `backend/api/routes/chatbot.py`: POST `/api/chatbot/message`, POST `/api/chatbot/feedback` with Pydantic request models
246
+ - [x] **route-biology** – `backend/api/routes/biology.py`: GET `/api/biology/phenology`, `/api/biology/rules`, `/api/biology/rules/{name}`
247
+ - [x] **rate-limiting** – Added `slowapi`: 60 req/min global default, 10 req/min on `/api/chatbot/message`
248
+ - [x] **fastapi-dockerfile** – Created `backend/Dockerfile` (Python 3.12 slim, port 7860, copies src/config/backend/Data)
249
+ - [x] **hf-space-readme** – Created `backend/HF_README.md` (sdk: docker, app_port: 7860, private: true, endpoint summary)
250
+ - [x] **fastapi-test** – Smoke tested: `/api/health` returns OK (uptime + redis status), `/api/weather/current` returns live IMS data
251
+
252
+ ### Phase 11.3: Background Workers (GitHub Actions)
253
+
254
+ Extract ControlLoop and DayAheadPlanner into standalone entry points, run as scheduled GitHub Actions.
255
+
256
+ - [x] **worker-control** – Created `backend/workers/control_tick.py`: loads ControlLoop, runs single tick, writes TickResult to Redis, supports `--dry-run`
257
+ - [x] **worker-planner** – Created `backend/workers/daily_planner.py`: runs DayAheadPlanner, writes plan to Redis + JSON backup
258
+ - [x] **gh-action-tick** – Created `.github/workflows/control-tick.yml`: schedule `*/15 * * * *`, Python 3.12 + pip cache, all secrets
259
+ - [x] **gh-action-planner** – Created `.github/workflows/daily-planner.yml`: schedule `0 2 * * *`, Python 3.12 + pip cache, all secrets
260
+ - [x] **gh-secrets** – All 7 secrets added to GitHub repo Settings → Actions secrets
261
+ - [x] **worker-logging** – Structured JSON logging (asctime + level + name + message) in both workers
262
+ - [x] **worker-test** – Dry-run tested: `python -m backend.workers.control_tick --dry-run` loads plan (43 slots), computes angle (-10.5°), dispatches to 4 trackers (DRY RUN), logs tick result
263
+
264
+ ### Phase 11.4: Lovable Frontend (React + Cloudflare Pages)
265
+
266
+ Generate and customize the production frontend via Lovable.
267
+
268
+ - [x] **lovable-project** – Created via Lovable, exported to `solarwine-ai/solarvine-dashboard` repo
269
+ - [x] **lovable-dashboard** – Dashboard page: weather card, energy (yesterday/today/tomorrow), phenology, quick stats
270
+ - [x] **lovable-advisor** – Advisor page: chat panel with bubbles, advisory cards, confidence/channel badges, feedback
271
+ - [x] **lovable-control** – Control page: tracker bars, energy budget, last tick, plan timeline
272
+ - [x] **lovable-photosynthesis** – Photosynthesis page: FvCB vs ML, forecast chart, sensor snapshot
273
+ - [x] **lovable-shading** – Shading page: offset slider, 3-zone canopy, stress heatmap
274
+ - [x] **lovable-docs** – Docs page: biology rules accordion, architecture cards, API reference (18 endpoints)
275
+ - [x] **lovable-export** – Imported into `frontend/` directory in main repo
276
+ - [x] **api-hooks** – 7 hooks (weather, energy, sensors/PS, control, chatbot, auth) + `api.ts` + `types/api.ts`
277
+ - [x] **api-integration** – All 6 pages wired to real API: mock data replaced with live TanStack Query hooks, field names mapped to backend snake_case responses
278
+ - [x] **chatbot-component** – Real mutations (useSendMessage/useSendFeedback), confidence badges, source citations, caveats, dual-channel (INFO/ADVISORY), auto-scroll
279
+ - [x] **responsive-polish** – Sidebar hamburger already implemented (lg breakpoint). Fixed: Power chart responsive height, Shading heatmap mobile min-width, Home grid breakpoints, camera/map sizing
280
+
281
+ ### Phase 11.5: Deployment & Integration
282
+
283
+ Wire everything together and deploy.
284
+
285
+ - [x] **frontend-hosting** – Switched from Vercel (requires Pro for private org repos) to **Cloudflare Pages** (free, unlimited bandwidth, private org repos supported). Connect GitHub repo → root `frontend/`, build `npm run build`, output `dist`, set `VITE_API_URL` env var
286
+ - [x] **hf-deploy** – HF Space `SolarWine/api` deployed (Docker SDK, public), secrets injected via huggingface_hub, `/api/health` returns OK with Redis connected. 14/17 endpoints passing; 3 fixed (phenology missing arg, photosynthesis missing method, sensors null area)
287
+ - [ ] **upstash-connect** – Verify Redis connectivity from HF Space and GitHub Actions, test cache read/write round-trip
288
+ - [x] **e2e-smoke** – Created `scripts/smoke_test.py`: tests 12 endpoints (health, weather, sensors, energy, PS, control, biology, chatbot). Run: `python scripts/smoke_test.py [url]`
289
+ - [x] **streamlit-deprecate** – Added migration banner to `app.py`; shows only when `SOLARWINE_FRONTEND_URL` env var is set (invisible until new frontend is live)
290
+ - [ ] **dns-custom** – (Optional) Configure custom domain on Cloudflare Pages if available
291
+
292
+ ### Phase 11.6: Security & Monitoring (Post-Launch)
293
+
294
+ - [x] **auth-jwt** – Created `backend/api/auth.py` (create_token, require_auth, optional_auth) + `backend/api/routes/login.py` (POST `/api/auth/login`). Auth returns `role: "guest"` when JWT_SECRET not set (read-only). PyJWT added to backend/requirements.txt
295
+ - [x] **api-hardening** – Standardized error handling (HTTPException everywhere, no JSONResponse for errors), data provider error dict detection, startup path validation, Sentry fail-loud, chatbot session_id validation + blank message guard + init failure circuit breaker, Redis singleton retry-storm fix, CORS defaults include HF Space + Cloudflare Pages URLs
296
+ - [x] **auth-frontend** – Created `useAuth.ts` hook (login/logout/token restore from localStorage) + updated `api.ts` to auto-attach JWT Bearer header + auto-clear on 401
297
+ - [ ] **monitoring-health** – UptimeRobot or similar free monitor on HF Space `/api/health` (5-min interval, email alerts)
298
+ - [x] **monitoring-logs** – Structured logging (asctime/level/name/message) in `main.py` + request logging middleware (method, path, status, duration). Health endpoint extended with ThingsBoard/IMS/Gemini connectivity checks
299
+ - [x] **error-tracking** – Added Sentry integration to `backend/api/main.py` (optional, enabled via `SENTRY_DSN` env var). `sentry-sdk[fastapi]>=2.0` in backend/requirements.txt
300
+ - [x] **budget-alerts** – `control_tick.py` checks budget after each tick; logs warning if >80% spent before 14:00 IST; sends webhook to `BUDGET_ALERT_WEBHOOK` if configured
301
+
302
+ ---
303
+
304
+ ## Phase 12: Robustness & Efficiency
305
+
306
+ ### 12.1: Immediate (Week 1)
307
+
308
+ - [x] **tb-health-fix** – Fixed ThingsBoard health check: `/api/noauth/health` returns 404, switched to root URL. Needs HF Space redeploy
309
+ - [x] **hf-redeploy** – Redeployed HF Space via `huggingface_hub` API (git push blocked by binary file policy). Uploaded 8 files, space auto-rebuilt
310
+ - [x] **redis-verify** – Smoke test against live HF URL: 11/12 pass (control plan 404 expected — no cron run yet). Redis connected, IMS/Gemini configured
311
+ - [x] **weather-fix** – Fixed WeatherService crash: Redis deserialised DataFrame as dict/list → `.empty` AttributeError. Added type guard + skip DataFrame Redis serialisation
312
+ - [x] **uptime-monitor** – UptimeRobot configured on `/api/health` (5-min interval). Added HEAD method support to health endpoint for monitoring compatibility
313
+ - [x] **monitoring-page** – Redesigned Alarms page → Monitoring & Alarms: overall status banner, 3 data source cards (IMS/TB/Energy), backend services panel (Redis/TB/IMS/Gemini), data flow issues surface as system alarms. Sidebar renamed Alarms → Monitoring
314
+ - [x] **code-review-fixes** – Fixed email alerter string formatting bug (CRITICAL), duplicate className in Home.tsx, DataFlowStatus null safety, energy price consistency (0.162 USD/kWh), table accessibility (scope attrs), negative age logging
315
+ - [x] **data-flow-monitor** – New `/api/health/data-sources` endpoint returning per-source green/yellow/red status (IMS weather, TB sensors, TB energy) with age tracking and configurable thresholds. `DataFlowMonitor` in `backend/services/data_flow_monitor.py`. Thresholds in `config/settings.py`: IMS yellow 60m/red 180m, TB yellow 15m/red 60m
316
+ - [x] **data-flow-alerts** – `EmailAlerter` in `backend/services/email_alerter.py`: SMTP email alerts when sources go red, per-source cooldown (60 min). Activated by `SMTP_HOST` + `ALERT_EMAIL_TO` env vars. Background loop in `main.py` checks every 5 min, pushes SSE `health` event
317
+ - [x] **data-flow-frontend** – `DataFlowStatus` component on Home page: colored dots (green/yellow/red) for each source, hover tooltips, auto-refresh via SSE. Hook: `useDataFlowStatus`, types: `DataSourceStatus` / `DataFlowStatusResponse`
318
+ - [x] **chatbot-range-validation** – `validate_numeric_ranges()` in guardrails.py: checks tool results against physical bounds (temp, GHI, PAR, VPD, CO2, power, etc.), flags sensor faults as caveats
319
+ - [x] **chatbot-cross-source** – `check_cross_source_consistency()` in guardrails.py: compares IMS vs TB temperature readings, warns when they diverge by >5°C
320
+ - [x] **chatbot-rag-scoring** – Improved `retrieve_relevant_rules()`: weighted scoring (exact match +2, partial word overlap +0.5), increased max_rules to 6
321
+
322
+ ### 12.2: Short-term (Week 2–4)
323
+
324
+ - [x] **responsive-polish** – Sidebar hamburger already implemented (lg breakpoint). Fixed: Power chart responsive height (280/350/450px), Shading heatmap mobile min-width (400px), Home grid md breakpoint for map/camera/power row, camera/map responsive min-heights
325
+ - [x] **load-test** – `scripts/load_test.py` (Locust): 16 weighted endpoints, 10 users × 1 min = 157 requests, p50=250ms, p95=1700ms, 0 errors. All endpoints healthy except `/api/health/data-sources` (not yet deployed at test time)
326
+ - [x] **circuit-breaker** – Added `CircuitBreaker` class (3 failures in 60s → open for 5 min) to VineSensorService and EnergyService. Prevents retry storms when TB is down
327
+ - [x] **tracker-timeout** – Added exponential backoff retry (3 attempts: 1s, 2s) for TB shared attribute writes in TrackerDispatcher
328
+
329
+ ### 12.2b: Code quality improvements (done 2026-03-29)
330
+
331
+ - [x] **code-review-critical** – Fixed: Redis health try-catch, chatbot init race condition (lock before null check), background task try-finally, daily planner IST timezone, JWT login parse safety, error boundary on Outlet
332
+ - [x] **error-standardization** – Extracted `check_service_error()` to `backend/api/utils.py`, used by energy + sensors routes. Fixed energy route ignoring errors when `daily_kwh` present
333
+ - [x] **control-auth** – Added `optional_auth` to all `/control/*` endpoints (status, plan, budget)
334
+ - [x] **farquhar-sigmoid** – Replaced hard 30°C RuBP→Rubisco cutoff with smooth sigmoid (28–32°C). New "Transition" state. Prevents flip-flopping near threshold
335
+ - [x] **divergence-fix** – Fixed inverted logic in `control_loop.py:704` divergence check
336
+ - [x] **frontend-extract** – Extracted `StatCard` to `/components/StatCard.tsx`, date formatters to `/lib/dateUtils.ts`, energy price to `/config/constants.ts`. Home.tsx reduced by ~50 lines
337
+ - [x] **strict-types** – Added TypeScript interfaces: `ChillUnitResponse`, `SoilMoistureRow`, `RainRow`, `TrackerInfo`, `ControlTrackers`, `ControlBudget`. Replaced inline `any` types
338
+ - [x] **chart-memoize** – `useMemo` on tempData chart transformation in Home.tsx
339
+
340
+ ### 12.2c: Architecture refactors (done 2026-03-29)
341
+
342
+ - [x] **vectorize-farquhar** – `compute_all()` rewritten with vectorized pandas/numpy (no iterrows). 10K rows: 7.5ms (was ~750ms). Numerically identical output (max diff=0)
343
+ - [x] **control-loop-di** – `ControlLoop.__init__()` accepts all 8 dependencies as keyword args. Pass None (default) for lazy auto-create; pass mocks for testing
344
+ - [x] **gate-pipeline** – `InterventionGate.evaluate()` split into 5 methods (`_check_meaningful_sun`, `_check_heat_stress`, `_check_water_stress`, `_check_radiation_load`, `_check_biology`) composed as pipeline. First rejection short-circuits
345
+ - [x] **budget-audit** – `src/budget_audit.py`: `BudgetAuditLog` appends one parquet row per control tick. `daily_summary(date)` and `weekly_report()` for compliance verification. Wired into `control_loop.py` tick step 12. CLI: `python -m src.budget_audit --report`
346
+ - [x] **chatbot-hebrew** – Added 18 Hebrew keywords to query classifier. System prompt: language matching, phenology-first, conciseness. IMS stale → auto-supplement with TB sensors. Confidence rule override for dormancy/blocks
347
+ - [x] **refactor-tests** – 23 new tests in `tests/test_refactors.py`: vectorized vs scalar (4), sigmoid transition (2), gate pipeline (6), ControlLoop DI (3), budget audit (3), Hebrew classification (3), confidence override (2)
348
+
349
+ ### 12.2e: Data + energy-model refresh — done 2026-05-18
350
+
351
+ - [x] **ims-refresh** – Pulled IMS station 43 from 2025-12-01 → 2026-05-10 (10,748 rows, 15-min). API has ~8-day lag; data ends 2026-05-10.
352
+ - [x] **scripts-refresh-energy-data** – New `scripts/refresh_energy_data.py`: joins TB Plant `production` (1h SUM agg) with IMS hourly weather, reindexes to contiguous hours (nighttime = 0 kWh), adds time + solar-geometry features, writes `Data/energy_weather_merged.csv` (3,477 rows × 18 cols).
353
+ - [x] **scripts-train-energy** – New `scripts/train_energy_predictor.py`: temporal hold-out (last 14 days), sklearn `GradientBoostingRegressor` + `LinearRegression` fallback (xgboost requires libomp on macOS; not installed). Bundle slot named `xgb_model` for backward compat with `EnergyPredictor.__init__`.
354
+ - [x] **energy-backtest-fix** – `EnergyPredictor.backtest()` now reads weather from `IMSClient` instead of the dead Air1 device.
355
+ - [x] **energy-model-validation** – 1,343 train rows / 337 hold-out rows. Hourly MAE = 4.5 kWh. **Daily mean signed error = +50%** — model systematically over-predicts in late April/May. Cause: model has no `tracker_angle` feature, so it can't see the agrivoltaic interventions that actively detune trackers as growing season starts. See 12.5 below for the planned fix.
356
+ - [x] **energy-tracker-feature-attempted** – Wired `tracker_angle_mean` + `tracker_angle_std` + `abs_tracker_angle` from TB into the refresh pipeline (`scripts/refresh_energy_data.py:fetch_tb_tracker_angles`). Two variants tried: (a) with imputation of pre-Feb history via hour-of-day medians → bias worsened to **+57%**; (b) without imputation + dropped `sin_elevation` → bias worsened to **+75%**. **Root cause is distribution shift, not features**: training tracker_angle avg = -15° (mild), hold-out avg = -51° (aggressive interventions). The model can't extrapolate operational regimes it didn't see in training. Reverted features to the original best config. Tracker telemetry is still collected into the CSV (columns `tracker_angle_mean`/`_std`) — ready to be consumed once 2026 growing-season data accumulates. See 12.6 / `bigml-tracker-feature` for the proper fix path.
357
+
358
+ ### 12.2d: 2026 sensor migration — done 2026-05-18
359
+
360
+ - [x] **tb-2026-registry** – Clean-replaced `DEVICE_REGISTRY` in `src/data/thingsboard_client.py` with the 2026 fleet (35 entries: 19 Crop_2Soil + 12 Thermocouples + 4 Trackers). Legacy `Air1-4`, `Crop1-7`, `Soil*`, `Irrigation1`, `Thermocouples1-2` removed. Added `DeviceInfo.position` field (north/south/center/etc.). Convenience subsets `TREATMENT_DEVICES`/`REFERENCE_DEVICES`/`CROP_2SOIL_DEVICES`/`THERMOCOUPLE_DEVICES` derived at import. Treatment = rows 501/502/503/504/509; Reference = row 202.
361
+ - [x] **vinesnapshot-2026** – Rewrote `VineSnapshot` with 61 fields. Removed PAR/DLI/VPD/CO2/Irrigation (no 2026 sensor); added NDVI/PRI/PSRI/SIPI/GCI/LCI/DUVI ratios, dual soil profile (shallow + deep moisture/temp/pore-water EC), `panel_temp_active_count`. Legacy field names kept as None-valued compat shims so downstream consumers don't crash. Per-position keys use `{row}-{cardinal}` form (e.g. `"502-south-east"`).
362
+ - [x] **vinesnapshot-aggregation** – `get_vine_snapshot()` no longer hard-codes device names; iterates `DEVICE_REGISTRY` filtered by `area`. Full fleet fetched in 1.1 s via `ThreadPoolExecutor`.
363
+ - [x] **bounds-fixes** – Added physical bounds for PSRI/SIPI/GCI/LCI/DUVI/leaf-wetness in `_BOUNDS`. **Critical fix**: GCI was being clipped to `[-1, 1]` (real range 0..30), causing all daytime values to be discarded. Split `treatment_soil_pore_water_ec` into shallow + `_deep`. De-duped the `treatment_pri` computation.
364
+
365
+ ### 12.3: Medium-term (Month 1–2)
366
+
367
+ - [ ] **chatbot-feedback** – Review first 20 thumbs-down/flag submissions, identify top 3 guardrail failures, tune rules
368
+ - [ ] **model-versioning** – Create `model_registry/` to track Chronos/ML/Farquhar parameter versions, enable A/B testing
369
+ - [ ] **phenology-validation** – Cross-reference GDD-based stage estimates vs camera observations, refine thresholds for Sde Boker
370
+
371
+ ### 12.5: 2026 sensor follow-ups (queued after the foundational registry rewrite)
372
+
373
+ Bugs and design debt surfaced during the May 2026 sensor migration. Foundational
374
+ work landed in 12.2d above; these are the deferred items.
375
+
376
+ **Remaining bugs**
377
+
378
+ - [ ] **panel-tc-semantics** – `panel_temp_active_count` is per-device but the panel-temp avg is per-channel — inconsistent. Pick one. (`src/data/thingsboard_client.py:get_vine_snapshot`)
379
+ - [x] **tab-system-status-2026** – `ui/tab_system_status.py` `_render_fruiting_zone` rewritten to use NDVI / PRI / leaf-air ΔT / PSRI; risk assessment now triggers on ΔT > 3°C and PSRI > 0.2 in addition to canopy temp. Done 2026-05-18.
380
+ - [ ] **frontend-types-2026** – `frontend/src/types/api.ts` and the React hooks (`useSensors`, etc.) still declare `treatment_par_umol: number | null` and similar. After the migration these are always null — type narrows to `null`. Audit `frontend/src/lib/` + `frontend/src/hooks/` and remove or mark deprecated.
381
+
382
+ **Architectural concerns (numbered to match the post-migration code review)**
383
+
384
+ - [ ] **2026-A-auto-sync-registry** – Hardcoded `DEVICE_REGISTRY` is brittle: each TB change requires a code edit + redeploy. Build `scripts/sync_devices.py` that reads the `Crop 2Soil 2026` + `Thermocouples 2026` entity groups, writes `Data/devices.json`, and have `thingsboard_client.py` prefer that file with the hardcoded dict as offline fallback. Position decoding belongs in this sync script (regex over the Hebrew TB labels: `צפון`→north, `דרום`→south, `מזרח`→east, `מערב`→west, `מרכז`→center).
385
+ - [ ] **2026-B-vinesnapshot-nest** – `VineSnapshot` is 61 flat fields and growing. Nest as `snapshot.treatment.{air,canopy,soil_shallow,soil_deep}` and `snapshot.panel`, expose flat `to_dict()` only at the JSON boundary. Defer until you're already breaking the API for another reason.
386
+ - [ ] **2026-C-schema-version** – Compat shims (`treatment_par_umol=None` etc.) silently say "sensor down" when the truth is "this metric no longer exists." Add `schema_version: "2026.1"` to the snapshot and let consumers branch / error explicitly; remove the shims one release later.
387
+ - [ ] **2026-D-split-fetch-aggregate** – `get_vine_snapshot()` mixes ~80 lines of I/O with ~70 lines of transformation. Split into `_fetch_all_latest(plan) → raw_dict` + `_aggregate_snapshot(raw, now) → VineSnapshot`. Then unit-test the aggregate step with fixture dicts (currently no test covers it).
388
+ - [ ] **2026-E-position-source-of-truth** – Position labels were decoded by hand from Hebrew TB labels — not reproducible. After 2026-A lands, decode at sync time. Until then, store the original TB label as `DeviceInfo.tb_label_hebrew` so the human-readable source survives.
389
+ - [ ] **2026-F-per-device-freshness** – `DataFlowMonitor` checks aggregate "newest TB timestamp" — one fast-reporting device makes the fleet look healthy even if 11 thermocouples are silent. Move to per-device staleness with grace per device-type (Crop_2Soil ~15min, Thermocouples up to ~60min due to deep-sleep cycles).
390
+ - [x] **2026-G-layout-regen** – `scripts/import_layout.py` updated to handle 2026 rows (202 reference; 501/502/503/504/509 treatment) and the `DeviceInfo.position` field via `_POSITION_OFFSETS`. `Data/layout.json` regenerated: 6 rows, 35 devices, each with `position_m: [x, y, z]`. Done 2026-05-18.
391
+ - [ ] **2026-H-position-taxonomy** – Position strings (`"south-east"`, `"center-east"`, …) are free-form. Convert to a `CardinalPosition` enum in `src/data/data_schema.py` (or document the canonical form in CLAUDE.md) so future labels match.
392
+ - [ ] **2026-I-aggregation-tests** – No unit test covers the new aggregation logic. Add at least 3 fixture-based tests in `tests/test_thingsboard_client.py`: full fleet, 1 device per area, all-empty. Depends on 2026-D.
393
+
394
+ **Follow-on integration work**
395
+
396
+ - [ ] **2026-J-ims-ambient-bridge** – Wire IMS Sde Boker station 43 → `VineSnapshot.ambient_*` fields so they stop returning None. Currently `Alarms.tsx` and `Home.tsx` fall through to `treatment_air_temp_c`, which is misleading (it's not ambient).
397
+ - [ ] **2026-K-chatbot-spectral-tools** – Add chatbot tools `get_vegetation_indices()` and `get_canopy_health()` exposing the new 2026-only data (NDVI/PRI/PSRI/SIPI per row + position).
398
+ - [ ] **2026-L-spectrometer-page** – New frontend page (or extend `Agro.tsx`): vegetation-index time series, NDVI/SIPI heatmap across the 17 active Crop_2Soil devices, panel surface-temp 4×12 grid from the thermocouples.
399
+
400
+ ### 12.6: "Big" — Stage 2 photosynthesis ML retrain on 2026 schema
401
+
402
+ Plan, not yet executed. The Stage 2 ML predictor (`src/forecasting/predictor.py`)
403
+ forecasts A_n (photosynthesis rate) from weather features. Currently trained on
404
+ `Data/Seymour/sensors_wide.csv` (frozen at 2026-02-01) — features include PAR,
405
+ airTemperature, CO2, VPD, leafTemperature from the legacy Air1/Crop fleet.
406
+ The 2026 sensor refresh deprecated all of those sensors. The new fleet emits
407
+ NDVI/PRI/PSRI/spectrometer bands + dual-soil profile + IRT ambient — a
408
+ fundamentally different feature space. This is a redesign, not a refresh.
409
+
410
+ **Phase 1 — Data collection pipeline (2-3 days work)**
411
+ - [x] **bigml-collection** – `scripts/collect_2026_training_data.py` written
412
+ and smoke-tested 2026-05-18. Pulls 15-min telemetry from the 10 treatment +
413
+ 7 reference Crop_2Soil devices via TB, averages per area per timestep,
414
+ inner-joins with IMS 15-min weather, writes to
415
+ `Data/2026/sensor_history.parquet`. Append-mode dedupes on
416
+ (timestamp, area). May 1–10 sample: **1,896 rows** (948 per area).
417
+ - [x] **bigml-stage1-2026** – Farquhar inputs derived from the 2026 fleet:
418
+ PAR ≈ 2.0 × GHI (Akitsu et al. 2017, ±10 %), VPD via Tetens from IMS
419
+ RH + Tair, CO2 = 420 ppm assumed, CWSI = (Tleaf − Tair)/15 clipped to
420
+ [0, 1]. `compute_farquhar_a()` runs `FarquharModel.calc_photosynthesis()`
421
+ row-by-row; result column `a_farquhar_umol`. Validation: daytime mean
422
+ 13.1 µmol CO₂/m²/s, peak 19.6 µmol — squarely in Greer & Weedon's
423
+ empirical Semillon range for the leaf-temp window seen.
424
+ - [x] **bigml-collection-shading-correction** – `apply_shading_correction()`
425
+ added to `scripts/collect_2026_training_data.py`. For each treatment-area
426
+ timestep: pulls `tracker_angle_mean` (or astronomical fallback when telemetry
427
+ missing), runs `ShadowModel.project_shadow()` + `compute_par_distribution()`,
428
+ takes the fruiting-zone mean PAR, and multiplies the IMS-derived
429
+ `par_umol_derived` by that factor. Reference rows untouched. May 1–10 sample:
430
+ treatment PAR factor distribution is 0.10–1.0 (median 0.82, mean 0.73) —
431
+ panels meaningfully reduce fruiting-zone PAR. Farquhar A label is recomputed
432
+ after the correction. Done 2026-05-18.
433
+ - [ ] **bigml-baseline** – Until enough 2026 data accumulates (≥3 months
434
+ growing-season hours), run Farquhar-only as the photosynthesis estimate
435
+ (skip Stage 2 ML for 2026). The chatbot's photosynthesis tool already
436
+ supports this via `PhotosynthesisService.compute_fvcb()`.
437
+ - [ ] **bigml-collection-cron** – Schedule
438
+ `scripts.collect_2026_training_data` to run weekly via GitHub Actions
439
+ (similar pattern to `control-tick.yml` / `daily-planner.yml`). Window =
440
+ last 8 days (overlaps to be safe against IMS's ~8-day publication lag).
441
+
442
+ **Phase 2 — Feature engineering (1-2 days)**
443
+ - [ ] **bigml-features** – Define the 2026 ML feature set. Candidates:
444
+ IMS GHI/Tair/RH/wind (forecastable), Crop_2Soil NDVI/PRI/PSRI/SIPI (now),
445
+ soilMoisture (shallow + deep), leafTemperature, tracker angle (current),
446
+ hour, doy_sin/cos. Decide whether to keep the per-device dimension (input
447
+ = 17 device readings per timestep) or aggregate to area-level (~5 features).
448
+ - [ ] **bigml-spectral-pca** – With 17 spectrometer bands per device × 17
449
+ devices = 289 columns, dimensionality reduction is needed. Run PCA on the
450
+ spectral subset and keep the top 5-8 components.
451
+
452
+ **Phase 3 — Training + validation (1 day)**
453
+ - [ ] **bigml-train** – Train RF / GBR / Linear regressors (analogous to
454
+ Stage 2 v1). Use temporal hold-out from the latest 14 days of the
455
+ collected 2026 data. Compare MAE vs Farquhar-only baseline.
456
+ - [ ] **bigml-tracker-feature** – Add `tracker_angle` and
457
+ `intervention_active` as features to the **energy predictor too** (fixes
458
+ the +50% over-prediction bias seen in 12.2e). Requires pulling tracker
459
+ telemetry alongside production into the energy refresh script.
460
+ - [ ] **bigml-deploy** – Wire the trained model into
461
+ `PhotosynthesisService.predict_ml()`, replacing the current
462
+ Stage 2 v1 model. Update model_versioning in `model_registry/`
463
+ (Phase 12.3 — `model-versioning`).
464
+
465
+ **Dependencies / open questions**
466
+ - Need ≥3 months of 2026 growing-season data before Phase 3 is statistically
467
+ meaningful. With data collection starting May 2026, retraining is realistic
468
+ for **August–September 2026**.
469
+ - CO2 atmospheric assumption (420 ppm) is conservative but the lack of a
470
+ real sensor is a real degradation vs the legacy fleet. Consider sourcing
471
+ hourly CO2 from a nearby Mauna Loa proxy if precision matters.
472
+ - Tracker-angle feature needs ThingsBoard telemetry alignment — trackers
473
+ emit `angle` as shared-attribute, not telemetry by default. Verify the
474
+ control loop is writing telemetry to TB.
475
+
476
+ ### 12.7: Frontend redesign — Claude Design handoff (done 2026-05-18)
477
+
478
+ - [x] **redesign-foundation** — Replaced `:root` tokens with Negev sand + vine green + sun gold; loaded Source Serif 4 + IBM Plex Sans + IBM Plex Mono; added brand stripe motif + masked-PNG wordmark; re-targeted shadcn aliases. `tailwind.config.ts` extended with serif/mono families and brand color tokens.
479
+ - [x] **redesign-layout** — Rewrote `AppSidebar` to a 240 px sand sidebar with 4-section nav (Vineyard / Energy / Operations / Knowledge), masked logo, brand stripes, vine left-bar on active. New `Topbar.tsx` (breadcrumb · ⌘K search · refresh · bell · avatar). New `PageHeader.tsx` (eyebrow + serif H1 with optional italic emphasis + deck + 3-row meta panel). `AppLayout` adds Topbar + footer hairline strip.
480
+ - [x] **redesign-shared-components** — New `Sparkline`, `CardHead`, `DataFlowRail`, `TrackerArc`, `PlanTimeline` (96-cell ribbon), `StressHeatmap`. `StatCard` rewritten with eyebrow + delta + serif tabular number + caption + Sparkline. New chart wrappers `charts/SwAreaChart.tsx` and `charts/SwLineChart.tsx` with shared SolarWine defaults (hairline grid, mono ticks, brand palette, dashed reference lines).
481
+ - [x] **redesign-home** — Editorial header, data-flow rail, 4-stat row, 96-slot plan ribbon + power chart, tracker fleet with arc dials + status pills, FvCB-vs-ML photosynthesis card, stress heatmap, **dark Advisor card** with sun-gold "Apply at next tick" CTA, 7-day climate w/ 32°C dashed line, chill-accumulation bars.
482
+ - [x] **redesign-pages-all** — All 10 remaining pages rewritten:
483
+ - **Agronomy** — 4-stat row + A1–A4 block table w/ vigor bars + soil profile + agronomy log + water budget
484
+ - **Photosynthesis** — model-comparison big numbers + Chronos forecast + sensor explorer
485
+ - **Shading** — 64 px serif offset slider w/ vine→sun gradient, 3-zone canopy diagram, stress×offset map, 8-rule policy grid
486
+ - **Power** — 4-cell KPI strip + today-vs-yesterday-vs-astro line, inverter cards, weekly bars, revenue ledger
487
+ - **Trackers** — fleet table w/ arc dials + status pills + 24h sparklines, dispatch log, reliability fields
488
+ - **Control loop** — 5-stage pipeline with active highlight, L0..L4 priority stack, hierarchical energy budget, plan ribbon
489
+ - **Advisor** — chat surface w/ confidence pills + citations + feedback, dark day-ahead brief, active recommendations, quick-asks
490
+ - **Monitoring** (was Alarms) — alert stream w/ severity pills + ack, 6-source health column
491
+ - **Documentation** — numbered serif accordion (10 biology rules), API ref table (21 endpoints), 12-term glossary
492
+ - **Research** (new) — vineyard-photo hero band, 3 paper cards, datasets table, 7-person crew strip. `/research` route added.
493
+ - [x] **redesign-build** — TypeScript clean, vite build 2.5 s, 240 KB gzipped JS. Dev server at http://localhost:8080/.
494
+
495
+ **TODO markers inline (would benefit from new backend endpoints)**
496
+ - [ ] **redesign-stress-grid** — `/api/photosynthesis/stress-grid?date=...` for live 4×24 CWSI grid (used by Home + Shading; currently synthetic).
497
+ - [ ] **redesign-inverters** — `/api/energy/inverters` for per-inverter live state on Power.
498
+ - [ ] **redesign-dispatch-stats** — `/api/control/dispatches` summary for p95 ack latency on Trackers.
499
+ - [ ] **redesign-events-dispatch** — surface dispatch + alert events through `/api/events/*` so the Trackers log + Monitoring stream are live, not seeded.
500
+ - [ ] **redesign-frontend-types-purge** — `frontend/src/types/api.ts` and hooks still declare `treatment_par_umol: number | null` etc. After the 2026 fleet migration these are always null; trim or mark deprecated.
501
+
502
+ ### 12.8: Streamlit staleness audit (done 2026-05-18)
503
+
504
+ - [x] **streamlit-control-replay-freshness** — `ui/tab_system_status.py:_render_control_replay`: empty-state message now uses today's date; loaded log shows a freshness warning when older than 30 days; caption surfaces the simulated date range (sim_2026-05-01 → 2026-05-10).
505
+ - [x] **scripts-sim-defaults** — `scripts/run_control_simulation.py` defaults changed from `2025-07-01 / 2025-07-07` to `today − 7 d / today` so future runs are fresh.
506
+ - [x] **fresh-sim-log** — Generated `Data/simulation_logs/sim_2026-05-01_2026-05-10.json` (960 ticks). Required installing `astral` into the venv (was missing; fixed 960/960 errors on the first attempt).
507
+ - [x] **streamlit-shading-caption** — Added "historical analysis · `sensors_wide.csv` frozen at Feb 2026" caption to the Shading tab so the date picker isn't read as live.
508
+ - [x] **camera-caption** — Camera image caption now says "if the view shows only sky, the on-site camera needs realignment". Physical site issue; can't be fixed from code.
509
+
510
+ ### 12.4: Long-term (3+ months)
511
+
512
+ - [ ] **multi-vineyard** – Refactor `config/settings.py` to multi-tenant pattern (vineyard ID → settings), add Postgres (Neon free tier)
513
+ - [ ] **licor-calibration** – Ingest first real LiCor measurements, retrain Farquhar Vcmax/Jmax via `src/calibration_pipeline.py`
514
+ - [ ] **chronos-vs-ml** – 3-month live comparison of Chronos-2 vs ML ensemble, promote winner to primary predictor
515
+
516
+ ---
517
+
518
+ ## Session notes
519
+
520
+ ### 2026-02-27 — Phase 8B: Gemini LLM Data Engineering
521
+ - Created `src/llm_data_engineer.py` with `LLMDataEngineer` class.
522
+ - `analyze_anomalies()`: constructs a structured prompt from per-column descriptive stats + site-specific domain context (physical ranges, Negev notes) → Gemini returns a JSON with `lower_bound`, `upper_bound`, `zscore_threshold`, `iqr_multiplier`, `rationale` per column; falls back to physics-based bounds when API unavailable.
523
+ - `apply_cleaning()`: applies union of bound violations + Z-score + IQR anomaly flags; three strategies: `clip` (default), `nan`, `drop`. Detected 14 PAR anomalies (0.05%), 9 VPD anomalies (0.03%), 168 CO₂ anomalies (0.58%) on `sensors_wide_sample.csv`.
524
+ - `engineer_features()`: adds `hour_sin`, `hour_cos`, `doy_sin`, `doy_cos` (cyclical encodings); `stress_risk_score = vpd_weight × norm(VPD) + cwsi_weight × norm(CWSI)` in [0, 1]; weights sourced from Gemini (default: 0.6/0.4, falls back gracefully without CWSI).
525
+ - `run_pipeline()`: single-call orchestration of anomaly detection → cleaning → feature engineering.
526
+ - Added `google-genai>=1.0` to `requirements.txt`.
527
+
528
+ ### 2026-02-27 — Dashboard & pvlib
529
+ - **Data Explorer:** Intro text now lists all three data sources (Vineyard sensors, Weather station data, **AI Data Engineering**). Third option runs full LLM pipeline in-tab (thresholds, cleaning summary, before/after PAR/VPD, feature spec, stress profile); no cache loader, all precomputed on selection.
530
+ - **Presentation tab:** “What’s Next” completed list includes **Gemini LLM data engineering** with pointer to Data Explorer → AI Data Engineering. “What each tab shows” updated so Data Explorer bullet mentions AI Data Engineering. Key takeaways add Gemini data engineering (anomaly cleaning + stress/time features).
531
+ - **pvlib:** `src/solar_geometry.py` — `compute_tracker_tilt()` now calls `pvlib.tracking.singleaxis()` with first two args positional (zenith, azimuth) so it works with both pvlib &lt; 0.13.1 (`apparent_azimuth`) and ≥ 0.13.1 (`solar_azimuth`). Fixes “Shadow model demo unavailable: singleaxis() got an unexpected keyword argument 'solar_azimuth'”.
532
+
533
+ ### 2026-03-05 — ThingsBoard live telemetry integration (Step 1)
534
+ - Rewrote `src/thingsboard_client.py`: full device registry (22 devices, UUIDs from devices.csv), area classification (treatment rows 501–502 / reference rows 503–504 / ambient Air1), parallel fetch with `ThreadPoolExecutor`, JWT auth with token refresh.
535
+ - Added `VineSnapshot` dataclass: aggregated treatment vs reference readings — Air, Crop (per panel position: west-bottom/east-upper/east-bottom/west-upper), Soil, Irrigation, Thermocouples. Includes `to_advisor_text()` and `to_dict()`.
536
+ - Added `_bounded_avg()` with `_BOUNDS` dict: physical plausibility filter rejects sensor faults (e.g. Soil5 thermocouple reading 653°C). Soil, Air, PAR, NDVI, VPD, CO₂ all filtered by domain bounds.
537
+ - Wired into `src/vineyard_chatbot.py`: new `_tool_get_vine_state()` tool + `get_vine_state` in system prompt + dispatch router; `_tool_day_ahead_advisory()` now passes `VineSnapshot` to advisor.
538
+ - Wired into `src/day_ahead_advisor.py`: optional `vine_snapshot` param in `advise()`; `to_advisor_text()` inserted before weather forecast in Gemini prompt.
539
+ - TB credentials (`TB_USERNAME`, `TB_PASSWORD`) added to `.env`; config also accepts `THINGSBOARD_USERNAME`/`THINGSBOARD_PASSWORD`.
540
+ - Tested live: all 22 devices fetched in 1.1 s, PAR shading ratio 0.33 (67% reduction), soil temp 15.5°C (outlier-filtered).
541
+ - Synced Phase 9 checklist: all chatbot tasks marked done (implemented in a prior session, todo file not updated).
542
+
543
+ ### 2026-03-09 — Phase 10: Anti-Hallucination Guardrails
544
+ - Created `src/chatbot/guardrails.py` with 4 components: QueryClassifier (regex data/knowledge/greeting routing), ResponseValidator (deterministic rule checks with block/warn severity), estimate_confidence (data freshness → confidence level), tag_tool_result (source metadata injection).
545
+ - Rewrote `src/chatbot/vineyard_chatbot.py`: integrated all guardrails into `chat()` flow — query classification → forced tool re-prompting → source-tagged tool results → response validation → confidence estimation. Extended `ChatResponse` with confidence/sources/caveats/rule_violations. Strengthened system prompt: "NEVER invent sensor readings", "MUST call a tool for data questions", "cite source and timestamp".
546
+ - Updated `ui/tab_advisor.py`: new `_render_grounding_metadata()` renders colored confidence badge, source citations, caveats, and rule violation warnings under each chat response. Metadata persisted in chat history.
547
+ - Added 23 new tests to `tests/test_advisor_chatbot.py`: 5 adversarial QA scenarios (shade-before-10 blocked, data-question forces tool call, stale-data warns, no-shade-without-reason warns, tool-failure returns insufficient_data) + unit tests for all guardrail components. Total: 42 tests, all passing.
548
+ - Updated `context/2_plan.md` with Phase 10 architecture, validation rules, confidence mapping, and remaining steps. Updated dependency order.
549
+
550
+ ### 2026-02-17 — Code quality fixes
551
+ - Fixed `Preprocessor.merge_ims_with_labels` position-based fallback: now raises `ValueError` instead of silently misaligning rows when `timestamp_index_labels=False` and labels have no named index.
552
+ - Removed all ~50 deprecated `use_container_width=True` calls in `app.py` (Streamlit 1.54): `st.image` → `width='stretch'`; `st.plotly_chart` / `st.dataframe` → argument removed (default is already `'stretch'`).
553
+ - Fixed follow-up `SyntaxError`: regex removal of `use_container_width=True` in the middle of argument lists left double commas (`,,`); collapsed all `,,` → `,`.
554
+ - Updated `context/CODE_REVIEW.md` sections 16–17 with applied fixes and remaining work list.
555
+ - Updated "What's next" sections in `app.py` (tab_photosynthesis section 6 and About tab) to reflect Chronos-2 and Gemini routing as completed.
context/4_production.md ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolarWine — Production Architecture
2
+
3
+ ## Overview
4
+
5
+ Migrate from a single-process Streamlit prototype to a 3-layer production system
6
+ deployed on free-tier infrastructure. The monorepo stays — no new repositories.
7
+
8
+ ```
9
+ Baseline/ (monorepo)
10
+ ├── frontend/ NEW — Lovable-generated React app
11
+ │ ├── src/
12
+ │ │ ├── pages/
13
+ │ │ │ ├── Dashboard.tsx Overview + energy + weather
14
+ │ │ │ ├── Advisor.tsx Chatbot + advisory cards
15
+ │ │ │ ├── Control.tsx System status + tracker angles
16
+ │ │ │ ├── Photosynthesis.tsx FvCB/ML results + forecasts
17
+ │ │ │ ├── Shading.tsx Interactive simulator
18
+ │ │ │ └── Docs.tsx Documentation
19
+ │ │ ├── components/
20
+ │ │ │ ├── EnergyCard.tsx
21
+ │ │ │ ├── WeatherCard.tsx
22
+ │ │ │ ├── Chatbot.tsx
23
+ │ │ │ ├── TrackerStatus.tsx
24
+ │ │ │ └── Charts.tsx Recharts / Plotly wrappers
25
+ │ │ ├── hooks/
26
+ │ │ │ ├── useWeather.ts fetch /api/weather/*
27
+ │ │ │ ├── useEnergy.ts fetch /api/energy/*
28
+ │ │ │ ├── useSensors.ts fetch /api/sensors/*
29
+ │ │ │ ├── useChatbot.ts POST /api/chatbot/message
30
+ │ │ │ └── useControl.ts fetch /api/control/*
31
+ │ │ ├── lib/
32
+ │ │ │ └── api.ts API base URL + fetch wrapper
33
+ │ │ └── App.tsx Router + layout
34
+ │ ├── package.json
35
+ │ └── tailwind.config.js
36
+
37
+ ├── backend/ NEW — FastAPI middleware + background services
38
+ │ ├── api/
39
+ │ │ ├── __init__.py
40
+ │ │ ├── main.py FastAPI app, CORS, lifespan
41
+ │ │ ├── routes/
42
+ │ │ │ ├── weather.py GET /api/weather/*
43
+ │ │ │ ├── sensors.py GET /api/sensors/*
44
+ │ │ │ ├── energy.py GET /api/energy/*
45
+ │ │ │ ├── photosynthesis.py GET /api/photosynthesis/*
46
+ │ │ │ ├── control.py GET /api/control/*
47
+ │ │ │ ├── chatbot.py POST /api/chatbot/message
48
+ │ │ │ └── health.py GET /api/health
49
+ │ │ ├── deps.py Shared dependencies (DataHub, Redis)
50
+ │ │ └── auth.py JWT middleware (Phase 11.6)
51
+ │ ├── workers/
52
+ │ │ ├── control_tick.py ControlLoop 15-min cron entry point
53
+ │ │ └── daily_planner.py DayAheadPlanner daily cron entry point
54
+ │ ├── Dockerfile
55
+ │ └── requirements.txt
56
+
57
+ ├── .github/
58
+ │ └── workflows/
59
+ │ ├── control-tick.yml GitHub Actions: 15-min cron
60
+ │ └── daily-planner.yml GitHub Actions: daily 05:00 IST cron
61
+
62
+ ├── src/ UNCHANGED — shared Python core
63
+ │ ├── control_loop.py
64
+ │ ├── command_arbiter.py
65
+ │ ├── tracker_dispatcher.py
66
+ │ ├── day_ahead_planner.py
67
+ │ ├── energy_budget.py
68
+ │ ├── data/
69
+ │ │ ├── data_providers.py DataHub + 6 services
70
+ │ │ ├── thingsboard_client.py
71
+ │ │ └── ims_client.py
72
+ │ ├── chatbot/
73
+ │ │ ├── vineyard_chatbot.py
74
+ │ │ ├── guardrails.py
75
+ │ │ └── routing_agent.py
76
+ │ ├── models/
77
+ │ │ ├── farquhar_model.py
78
+ │ │ └── phenology.py
79
+ │ ├── forecasting/
80
+ │ │ ├── predictor.py
81
+ │ │ └── ts_predictor.py
82
+ │ ├── shading/
83
+ │ │ ├── solar_geometry.py
84
+ │ │ └── tradeoff_engine.py
85
+ │ └── ...
86
+
87
+ ├── config/settings.py UNCHANGED — shared configuration
88
+ ├── app.py KEPT temporarily (Streamlit, deprecated)
89
+ ├── ui/ KEPT temporarily (Streamlit tabs, deprecated)
90
+ ├── docker-compose.yml NEW — local dev orchestration
91
+ └── requirements.txt Shared Python deps
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Layer 1 — The Brain (Backend Services)
97
+
98
+ ### What it is
99
+
100
+ All business logic, models, data access, and control algorithms. This layer
101
+ already exists in `src/`. The only change is extracting the runtime entry points
102
+ into `backend/workers/`.
103
+
104
+ ### Components
105
+
106
+ | Component | Module | Runtime | Schedule |
107
+ |-----------|--------|---------|----------|
108
+ | ControlLoop | `src/control_loop.py` | GitHub Actions cron | Every 15 min |
109
+ | DayAheadPlanner | `src/day_ahead_planner.py` | GitHub Actions cron | Daily 05:00 IST + re-plan events |
110
+ | DataHub | `src/data/data_providers.py` | Imported by API + workers | Always available |
111
+ | ThingsBoard client | `src/data/thingsboard_client.py` | Imported by DataHub | On-demand |
112
+ | IMS client | `src/data/ims_client.py` | Imported by DataHub | On-demand (30-min TTL) |
113
+ | FvCB model | `src/models/farquhar_model.py` | Imported by PhotosynthesisService | On-demand (~10ms) |
114
+ | ML ensemble | `src/forecasting/predictor.py` | Imported by PhotosynthesisService | On-demand (~500ms) |
115
+ | Chronos forecaster | `src/forecasting/ts_predictor.py` | Imported by PhotosynthesisService | On-demand (~2-5s) |
116
+ | CommandArbiter | `src/command_arbiter.py` | Imported by ControlLoop | Per tick |
117
+ | TrackerDispatcher | `src/tracker_dispatcher.py` | Imported by ControlLoop | Per tick |
118
+ | EnergyBudget | `src/energy_budget.py` | Imported by ControlLoop | Per tick |
119
+ | Chatbot + guardrails | `src/chatbot/*.py` | Imported by API route | Per message |
120
+
121
+ ### Caching strategy
122
+
123
+ | Data | Current | Production |
124
+ |------|---------|------------|
125
+ | IMS weather | In-memory TTLCache (30 min) | **Upstash Redis** (30 min TTL) |
126
+ | Vine sensors | In-memory TTLCache (5 min) | **Upstash Redis** (5 min TTL) |
127
+ | Energy daily | No cache | **Upstash Redis** (15 min TTL) |
128
+ | ML trained model | Pickle file | Pickle in `/tmp` + Redis metadata |
129
+ | Day-ahead plan | JSON file | Redis hash + JSON file backup |
130
+ | Chatbot history | Streamlit session_state | Redis list per session |
131
+
132
+ ### External dependencies
133
+
134
+ | Service | URL | Auth | Purpose |
135
+ |---------|-----|------|---------|
136
+ | ThingsBoard | `web.seymouragri.com` | Username/password → JWT | 21 sensors, 4 trackers, energy |
137
+ | IMS | `api.ims.gov.il` | API token | Weather station 43 (Sde Boker) |
138
+ | Google Gemini | `generativelanguage.googleapis.com` | API key | Chatbot (2.5-flash), routing |
139
+ | Upstash Redis | `*.upstash.io` | Token | Shared cache |
140
+
141
+ ---
142
+
143
+ ## Layer 2 — Middleware (FastAPI on HuggingFace Spaces)
144
+
145
+ ### What it is
146
+
147
+ A thin Python API layer that wraps `DataHub` services as REST endpoints. Hosted
148
+ on **HuggingFace Spaces** (Docker SDK, free tier) — Python-native, no cold starts
149
+ on the free tier's persistent container.
150
+
151
+ ### Tech stack
152
+
153
+ | Component | Technology | Why |
154
+ |-----------|-----------|-----|
155
+ | Framework | **FastAPI** | Same Python team, async, auto-docs, WebSocket |
156
+ | Serialization | **Pydantic v2** | Already a FastAPI dependency, typed responses |
157
+ | Auth | **JWT** (PyJWT) | Simple, stateless, no external IdP needed initially |
158
+ | Rate limiting | **slowapi** | Token bucket per IP, protects Gemini quota |
159
+ | CORS | FastAPI `CORSMiddleware` | Allow Cloudflare Pages frontend origin |
160
+ | WebSocket | FastAPI WebSocket | Live tracker angles, energy push (future) |
161
+ | Process manager | **uvicorn** | ASGI server, production-ready |
162
+ | Hosting | **HuggingFace Spaces** (Docker SDK) | Free, Python-native, persistent container |
163
+
164
+ ### HuggingFace Space configuration
165
+
166
+ The API Space uses Docker SDK. A `Dockerfile` at `backend/` builds the FastAPI
167
+ server. The Space README metadata:
168
+
169
+ ```yaml
170
+ ---
171
+ title: SolarWine API
172
+ emoji: 🌿
173
+ colorFrom: green
174
+ colorTo: yellow
175
+ sdk: docker
176
+ app_port: 7860
177
+ private: true
178
+ ---
179
+ ```
180
+
181
+ Note: HF Spaces exposes port 7860, so FastAPI binds to `0.0.0.0:7860`.
182
+
183
+ ### API endpoints
184
+
185
+ ```
186
+ Health
187
+ GET /api/health → { status, uptime, redis_ok, tb_ok }
188
+
189
+ Weather
190
+ GET /api/weather/current → WeatherService.get_current()
191
+ GET /api/weather/history?days=7 → WeatherService.get_history()
192
+ GET /api/weather/forecast → WeatherService.get_forecast()
193
+
194
+ Vine Sensors
195
+ GET /api/sensors/snapshot → VineSensorService.get_snapshot()
196
+ GET /api/sensors/history?type=crop&hours=24 → VineSensorService.get_history()
197
+
198
+ Energy
199
+ GET /api/energy/current → EnergyService.get_current()
200
+ GET /api/energy/daily/{date} → EnergyService.get_daily_production()
201
+ GET /api/energy/history?hours=24 → EnergyService.get_history()
202
+ GET /api/energy/predict/{date} → EnergyService.predict()
203
+
204
+ Photosynthesis
205
+ GET /api/photosynthesis/current → PhotosynthesisService.get_current()
206
+ GET /api/photosynthesis/forecast → PhotosynthesisService.forecast_day_ahead()
207
+
208
+ Control
209
+ GET /api/control/status → Last TickResult from Redis
210
+ GET /api/control/plan → Current DayAheadPlan from Redis
211
+ GET /api/control/budget → EnergyBudget current state
212
+ GET /api/control/trackers → Live tracker angles from TB
213
+
214
+ Chatbot
215
+ POST /api/chatbot/message → VineyardChatbot.chat()
216
+ Body: { message, session_id }
217
+ Response: { message, confidence, sources, caveats, rule_violations }
218
+
219
+ POST /api/chatbot/feedback → feedback.log_feedback()
220
+ Body: { session_id, message_id, rating, comment }
221
+
222
+ Biology
223
+ GET /api/biology/phenology → BiologyService current stage
224
+ GET /api/biology/rules → All biology rules
225
+ ```
226
+
227
+ ### Middleware stack (order matters)
228
+
229
+ 1. **CORS** — allow `*.pages.dev` + localhost
230
+ 2. **Rate limiter** — 60 req/min general, 10 req/min chatbot (Gemini costs)
231
+ 3. **Auth** — JWT validation (initially optional, enforced in Phase 11.6)
232
+ 4. **Request logging** — structured JSON logs
233
+ 5. **Error handler** — consistent error response format
234
+
235
+ ---
236
+
237
+ ## Layer 3 — Frontend (Lovable → Cloudflare Pages)
238
+
239
+ ### What it is
240
+
241
+ Production-grade React frontend generated via **Lovable** (AI-powered React
242
+ builder). Produces a standard Vite + React + TypeScript + Tailwind project.
243
+ Deployed to **Cloudflare Pages** (free tier, unlimited bandwidth, private org repos supported).
244
+
245
+ Lovable generates the initial scaffold and components rapidly. After generation,
246
+ the code is committed to the monorepo under `frontend/` and maintained manually
247
+ or with further Lovable iterations.
248
+
249
+ ### Tech stack
250
+
251
+ | Component | Technology | Why |
252
+ |-----------|-----------|-----|
253
+ | Generator | **Lovable** | AI-generated React UI, rapid prototyping |
254
+ | Framework | **React 19 + Vite** | Lovable default output, fast build |
255
+ | Language | **TypeScript** | Type safety, IDE support |
256
+ | Styling | **Tailwind CSS + shadcn/ui** | Lovable default, beautiful components |
257
+ | Charts | **Recharts** (default) or **Plotly.js** | Data visualization |
258
+ | HTTP | **TanStack Query** (React Query) | Auto-refresh, caching, loading states |
259
+ | Routing | **React Router** | Page navigation |
260
+ | Hosting | **Cloudflare Pages** (free) | Unlimited bandwidth, private org repos, global CDN, auto-deploy from GitHub |
261
+
262
+ ### Pages (mapped from current Streamlit tabs)
263
+
264
+ | Streamlit Tab | React Page | Route | Key Components |
265
+ |---------------|------------|-------|----------------|
266
+ | Vineyard Advisor | `Advisor.tsx` | `/` | Chatbot, weather card, energy card, shading card, irrigation card |
267
+ | System Status | `Control.tsx` | `/control` | Tracker angles, energy budget gauge, control replay, ROI |
268
+ | Overview | `Dashboard.tsx` | `/dashboard` | Energy history, crop comparisons |
269
+ | Photosynthesis & Data | `Photosynthesis.tsx` | `/photosynthesis` | FvCB/ML results, sensor explorer |
270
+ | Forecasting | — | merged into `/dashboard` | Weather + PS forecast cards |
271
+ | Shading Simulator | `Shading.tsx` | `/shading` | Interactive offset tester |
272
+ | Documentation | `Docs.tsx` | `/docs` | Help, API reference |
273
+
274
+ ### Data fetching pattern
275
+
276
+ Using TanStack Query for all API calls. Auto-refetch on window focus and
277
+ configurable polling intervals.
278
+
279
+ ```typescript
280
+ // hooks/useWeather.ts
281
+ import { useQuery } from "@tanstack/react-query";
282
+ import { api } from "@/lib/api";
283
+
284
+ export function useCurrentWeather() {
285
+ return useQuery({
286
+ queryKey: ["weather", "current"],
287
+ queryFn: () => api.get("/api/weather/current"),
288
+ refetchInterval: 60_000, // 60s auto-refresh
289
+ });
290
+ }
291
+ ```
292
+
293
+ ```typescript
294
+ // lib/api.ts
295
+ const API_URL = import.meta.env.VITE_API_URL;
296
+
297
+ export const api = {
298
+ get: async (path: string) => {
299
+ const res = await fetch(`${API_URL}${path}`);
300
+ if (!res.ok) throw new Error(res.statusText);
301
+ return res.json();
302
+ },
303
+ post: async (path: string, body: unknown) => {
304
+ const res = await fetch(`${API_URL}${path}`, {
305
+ method: "POST",
306
+ headers: { "Content-Type": "application/json" },
307
+ body: JSON.stringify(body),
308
+ });
309
+ if (!res.ok) throw new Error(res.statusText);
310
+ return res.json();
311
+ },
312
+ };
313
+ ```
314
+
315
+ ### Frontend ↔ Backend communication
316
+
317
+ | Pattern | Use Case | Protocol |
318
+ |---------|----------|----------|
319
+ | REST | All data reads, chatbot messages | HTTPS (fetch via TanStack Query) |
320
+ | Polling | Dashboard auto-refresh | 60s interval via `refetchInterval` |
321
+ | WebSocket | Live tracker angles, energy (future) | WSS (native WebSocket, future) |
322
+
323
+ ---
324
+
325
+ ## Infrastructure & Deployment
326
+
327
+ ### Hosting (all free tier)
328
+
329
+ | Layer | Platform | Tier | Limits |
330
+ |-------|----------|------|--------|
331
+ | Frontend | **Cloudflare Pages** | Free | Unlimited bandwidth, 500 builds/month, auto-deploy from GitHub |
332
+ | API server | **HuggingFace Spaces** | Free (Docker SDK) | 2 vCPU, 16GB RAM, persistent container, private spaces |
333
+ | Cron workers | **GitHub Actions** | Free (private repos) | 2,000 min/month, plenty for 15-min cron + daily planner |
334
+ | Cache | **Upstash Redis** | Free | 10K commands/day, 256MB, REST API |
335
+ | Secrets | **HF Space secrets** + **Cloudflare env vars** + **GitHub Actions secrets** | Free | Per-service |
336
+ | Repo | **GitHub** | Free private | Unlimited |
337
+
338
+ ### Deployment topology
339
+
340
+ ```
341
+ GitHub (private repo, main branch)
342
+
343
+ ├──── push ────► Cloudflare Pages (auto-deploy)
344
+ │ ├── frontend/ → Vite build → static React app
345
+ │ ├── URL: baseline.pages.dev (or custom domain)
346
+ │ └── Env: VITE_API_URL=https://<user>-solarwine-api.hf.space
347
+
348
+ ├──── push ────► HuggingFace Space (auto-deploy via HF GitHub sync)
349
+ │ ├── backend/ → Dockerfile → FastAPI (uvicorn, port 7860)
350
+ │ ├── URL: https://<user>-solarwine-api.hf.space
351
+ │ └── Secrets: TB creds, IMS token, Gemini key, Redis URL
352
+
353
+ └──── cron ────► GitHub Actions (scheduled workflows)
354
+ ├── control-tick.yml: */15 * * * * → pip install + python control_tick.py
355
+ ├── daily-planner.yml: 0 2 * * * (02:00 UTC = 05:00 IST)
356
+ └── Secrets: TB creds, IMS token, Redis URL
357
+
358
+ Upstash Redis
359
+ └── URL: solarwine-cache-*.upstash.io (shared by API + workers)
360
+ ```
361
+
362
+ ### GitHub Actions cron workflows
363
+
364
+ The ControlLoop and DayAheadPlanner run as scheduled GitHub Actions. This
365
+ replaces the need for a separate worker host.
366
+
367
+ ```yaml
368
+ # .github/workflows/control-tick.yml
369
+ name: Control Loop Tick
370
+ on:
371
+ schedule:
372
+ - cron: "*/15 * * * *" # every 15 min
373
+ workflow_dispatch: # manual trigger
374
+
375
+ jobs:
376
+ tick:
377
+ runs-on: ubuntu-latest
378
+ steps:
379
+ - uses: actions/checkout@v4
380
+ - uses: actions/setup-python@v5
381
+ with:
382
+ python-version: "3.12"
383
+ cache: pip
384
+ - run: pip install -r requirements.txt -r backend/requirements.txt
385
+ - run: python -m backend.workers.control_tick
386
+ env:
387
+ THINGSBOARD_HOST: ${{ secrets.THINGSBOARD_HOST }}
388
+ THINGSBOARD_USERNAME: ${{ secrets.THINGSBOARD_USERNAME }}
389
+ THINGSBOARD_PASSWORD: ${{ secrets.THINGSBOARD_PASSWORD }}
390
+ IMS_API_TOKEN: ${{ secrets.IMS_API_TOKEN }}
391
+ UPSTASH_REDIS_URL: ${{ secrets.UPSTASH_REDIS_URL }}
392
+ UPSTASH_REDIS_TOKEN: ${{ secrets.UPSTASH_REDIS_TOKEN }}
393
+ ```
394
+
395
+ ```yaml
396
+ # .github/workflows/daily-planner.yml
397
+ name: Day-Ahead Planner
398
+ on:
399
+ schedule:
400
+ - cron: "0 2 * * *" # 02:00 UTC = 05:00 IST
401
+ workflow_dispatch:
402
+
403
+ jobs:
404
+ plan:
405
+ runs-on: ubuntu-latest
406
+ steps:
407
+ - uses: actions/checkout@v4
408
+ - uses: actions/setup-python@v5
409
+ with:
410
+ python-version: "3.12"
411
+ cache: pip
412
+ - run: pip install -r requirements.txt -r backend/requirements.txt
413
+ - run: python -m backend.workers.daily_planner
414
+ env:
415
+ THINGSBOARD_HOST: ${{ secrets.THINGSBOARD_HOST }}
416
+ THINGSBOARD_USERNAME: ${{ secrets.THINGSBOARD_USERNAME }}
417
+ THINGSBOARD_PASSWORD: ${{ secrets.THINGSBOARD_PASSWORD }}
418
+ IMS_API_TOKEN: ${{ secrets.IMS_API_TOKEN }}
419
+ GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
420
+ UPSTASH_REDIS_URL: ${{ secrets.UPSTASH_REDIS_URL }}
421
+ UPSTASH_REDIS_TOKEN: ${{ secrets.UPSTASH_REDIS_TOKEN }}
422
+ ```
423
+
424
+ ### Docker setup (HuggingFace Space)
425
+
426
+ ```dockerfile
427
+ # backend/Dockerfile
428
+ FROM python:3.12-slim
429
+
430
+ WORKDIR /app
431
+ COPY requirements.txt .
432
+ COPY backend/requirements.txt backend/
433
+ RUN pip install -r requirements.txt -r backend/requirements.txt
434
+
435
+ COPY src/ src/
436
+ COPY config/ config/
437
+ COPY backend/ backend/
438
+ COPY Data/ Data/
439
+
440
+ ENV PYTHONPATH=/app
441
+
442
+ # HuggingFace Spaces requires port 7860
443
+ EXPOSE 7860
444
+ CMD ["uvicorn", "backend.api.main:app", "--host", "0.0.0.0", "--port", "7860"]
445
+ ```
446
+
447
+ ### Environment variables
448
+
449
+ | Variable | Where | Source |
450
+ |----------|-------|--------|
451
+ | `THINGSBOARD_HOST` | HF Space, GH Actions | HF secrets, GH secrets |
452
+ | `THINGSBOARD_USERNAME` | HF Space, GH Actions | HF secrets, GH secrets |
453
+ | `THINGSBOARD_PASSWORD` | HF Space, GH Actions | HF secrets, GH secrets |
454
+ | `IMS_API_TOKEN` | HF Space, GH Actions | HF secrets, GH secrets |
455
+ | `GOOGLE_API_KEY` | HF Space, GH Actions | HF secrets, GH secrets |
456
+ | `UPSTASH_REDIS_URL` | HF Space, GH Actions | HF secrets, GH secrets |
457
+ | `UPSTASH_REDIS_TOKEN` | HF Space, GH Actions | HF secrets, GH secrets |
458
+ | `JWT_SECRET` | HF Space | HF secrets |
459
+ | `ALLOWED_ORIGINS` | HF Space | HF secrets (Cloudflare Pages URL) |
460
+ | `VITE_API_URL` | Cloudflare Pages | Cloudflare env vars (HF Space URL) |
461
+
462
+ ---
463
+
464
+ ## Data Flow
465
+
466
+ ### Request flow (user views dashboard)
467
+
468
+ ```
469
+ Browser → Cloudflare CDN → React SPA (static)
470
+
471
+ └── useQuery → fetch https://<user>-solarwine-api.hf.space/api/weather/current
472
+
473
+
474
+ FastAPI route (HuggingFace Spaces)
475
+
476
+
477
+ DataHub.weather.get_current()
478
+
479
+ ├── Redis cache hit? → return cached
480
+ └── Redis miss → IMS API → cache → return
481
+ ```
482
+
483
+ ### Control flow (15-min tick)
484
+
485
+ ```
486
+ GitHub Actions cron (*/15 * * * *)
487
+
488
+ └── python -m backend.workers.control_tick
489
+
490
+
491
+ ControlLoop.tick()
492
+
493
+ ├── DataHub.weather.get_current() → IMS (via Redis cache)
494
+ ├── DataHub.vine_sensors.get_snapshot() → ThingsBoard
495
+ ├── RoutingAgent → FvCB or ML model
496
+ ├── DayAheadPlan lookup → Redis
497
+ ├── InterventionGate check
498
+ ├── BudgetGuard check
499
+ ├── CommandArbiter → decide angle
500
+ ├── TrackerDispatcher → ThingsBoard shared attributes
501
+ ├── EnergyBudget.spend_slot()
502
+ └── Save TickResult → Redis (for API /control/status)
503
+ ```
504
+
505
+ ### Chatbot flow
506
+
507
+ ```
508
+ Browser → React → POST /api/chatbot/message { message, session_id }
509
+
510
+
511
+ FastAPI route (HF Space) → VineyardChatbot.chat(message, hub=DataHub)
512
+
513
+ ├── QueryClassifier → data / knowledge / greeting
514
+ ├── Gemini Pass 1 → tool calls (if data query)
515
+ ├── DataHub.dispatch_tool() → real data
516
+ ├── tag_tool_result() → source metadata
517
+ ├── Gemini Pass 2 → grounded response
518
+ ├── ResponseValidator → rule checks
519
+ ├── estimate_confidence()
520
+ └── Return ChatResponse → JSON → Browser
521
+ ```
522
+
523
+ ---
524
+
525
+ ## Security
526
+
527
+ | Concern | Mitigation |
528
+ |---------|-----------|
529
+ | API keys in repo | `.env` in `.gitignore`; secrets in HF/Cloudflare/GH settings |
530
+ | TB credentials | Server-side only; frontend never sees TB creds |
531
+ | Gemini API abuse | Rate limit 10 req/min on `/api/chatbot/message` |
532
+ | CORS | Whitelist Cloudflare Pages domain only |
533
+ | Auth (Phase 11.6) | JWT tokens; initially optional for research use |
534
+ | Tracker commands | Only GH Actions worker can write to TB; API is read-only |
535
+ | HF Space | Set to **private** — not publicly accessible without auth |
536
+
537
+ ---
538
+
539
+ ## Migration from Streamlit
540
+
541
+ The existing `app.py` and `ui/` folder remain functional throughout migration.
542
+ They are deprecated once the Lovable frontend reaches feature parity.
543
+
544
+ | Phase | Streamlit | Lovable/React | Notes |
545
+ |-------|-----------|---------------|-------|
546
+ | 11.1–11.3 | Primary | Not started | Backend being built |
547
+ | 11.4 | Primary | Scaffold | Pages generated via Lovable |
548
+ | 11.5 | Deprecated | Primary | Feature parity reached |
549
+ | 11.6+ | Removed | Primary | Auth, monitoring, polish |
550
+
551
+ ---
552
+
553
+ ## Scaling path (when free tier is outgrown)
554
+
555
+ | Bottleneck | Upgrade | Cost |
556
+ |-----------|---------|------|
557
+ | HF Space resources | HF Space upgrade (2× CPU, 16GB) | $0 (already free) → $9/mo for GPU |
558
+ | GitHub Actions minutes | GitHub Team (3,000 min) | $4/user/mo |
559
+ | Upstash 10K commands/day | Upstash Pay-as-you-go ($0.20/100K) | ~$1/mo |
560
+ | Cloudflare Pages | Already unlimited on free tier | $0 |
561
+ | Heavy ML inference | Add GPU worker (Modal/RunPod) | $5–10/mo |
562
+ | Multi-site (>1 vineyard) | Add Postgres (Neon free) for multi-tenant state | Free → $19/mo |
563
+
564
+ Total cost to run a real commercial deployment: **~$25/month**.
context/CODE_REVIEW.md ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deep Code Review – Photosynthesis Prediction Model
2
+
3
+ **Scope:** `config/settings.py`, `src/*.py`
4
+ **Date:** 2025-02
5
+
6
+ ---
7
+
8
+ ## 1. Executive Summary
9
+
10
+ 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.
11
+
12
+ ---
13
+
14
+ ## 2. config/settings.py
15
+
16
+ | Finding | Severity | Notes |
17
+ |--------|----------|--------|
18
+ | 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. |
19
+ | `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. |
20
+
21
+ **Verdict:** Good; no critical bugs.
22
+
23
+ ---
24
+
25
+ ## 3. src/sensor_data_loader.py
26
+
27
+ | Finding | Severity | Notes |
28
+ |--------|----------|--------|
29
+ | `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. |
30
+ | Missing columns not reported | Low | When requested columns are absent, pandas may omit them; caller gets incomplete DataFrame. |
31
+ | `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. |
32
+
33
+ **Verdict:** Solid. Add a post-load check that required columns exist and optionally log missing ones.
34
+
35
+ ---
36
+
37
+ ## 4. src/ims_client.py
38
+
39
+ | Finding | Severity | Notes |
40
+ |--------|----------|--------|
41
+ | **`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. |
42
+ | 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. |
43
+ | 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. |
44
+ | No retries / rate limiting | Low | Single request; 60s timeout. For long ranges the API may throttle; consider retries and chunking (as in plan). |
45
+ | `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. |
46
+ | `load_cached` and CSV timestamp | Low | After `read_csv`, column is object; we convert with `pd.to_datetime(..., utc=True)`. Good. |
47
+
48
+ **Verdict:** Add token validation; clarify or fix timezone handling for IMS datetime.
49
+
50
+ ---
51
+
52
+ ## 5. src/farquhar_model.py
53
+
54
+ | Finding | Severity | Notes |
55
+ |--------|----------|--------|
56
+ | **`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). |
57
+ | **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). |
58
+ | 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. |
59
+ | Vcmax/Jmax above Topt | Low | Simplified Gaussian decline above Topt; not the full Greer & Weedon equation. Document as simplified. |
60
+ | `_ci_from_ca` and gs_factor | Low | Stomatal model is heuristic (VPD/CWSI scaling). Document as simplified. |
61
+ | 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. |
62
+
63
+ **Verdict:** Fix NaN handling and consider vectorization or explicit NaN-in → NaN-out in `compute_all`.
64
+
65
+ ---
66
+
67
+ ## 6. src/preprocessor.py
68
+
69
+ | Finding | Severity | Notes |
70
+ |--------|----------|--------|
71
+ | **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. |
72
+ | 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`. |
73
+ | `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. |
74
+ | `int(len(df) * self.train_ratio)` | Low | For very small df, n can be 0; we already return early for `n <= 0`. Good. |
75
+ | `fit_transform_train` overwrites scaler | Low | Each call fits a new scaler; no way to "refit" explicitly. Acceptable. |
76
+
77
+ **Verdict:** Fix merge logic when labels are not index-based; document or adjust temporal_split when test is empty.
78
+
79
+ ---
80
+
81
+ ## 7. src/predictor.py
82
+
83
+ | Finding | Severity | Notes |
84
+ |--------|----------|--------|
85
+ | `get_feature_importance` | Low | Uses `feature_names_in_` (sklearn 1.0+). For older sklearn, fallback to `list(range(len(imp)))` is correct. |
86
+ | `plot_results` and `self.results` | Low | If `evaluate` was never called, `self.results` is empty and plot is no-op. Document. |
87
+ | 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. |
88
+ | Unused import `seaborn` | Low | Remove if not used. |
89
+
90
+ **Verdict:** Minor cleanups only.
91
+
92
+ ---
93
+
94
+ ## 8. src/__init__.py
95
+
96
+ | Finding | Severity | Notes |
97
+ |--------|----------|--------|
98
+ | Lazy `__getattr__` | Low | Works; avoids pulling config/requests on import. |
99
+ | No `__dir__` | Low | `dir(src)` won't list the lazy attributes; could add `__dir__` returning `__all__` for better discoverability. |
100
+
101
+ **Verdict:** Good.
102
+
103
+ ---
104
+
105
+ ## 9. Cross-Cutting and Data Flow
106
+
107
+ | Finding | Severity | Notes |
108
+ |--------|----------|--------|
109
+ | 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. |
110
+ | No end-to-end test | Medium | A minimal script or test that runs load → Farquhar → merge → split → train → evaluate would catch integration bugs. |
111
+
112
+ ---
113
+
114
+ ## 10. Recommended Fixes (Priority Order)
115
+
116
+ 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.
117
+ 2. **IMSClient:** Validate `self.token` in `__init__` or at first fetch and raise a clear error if missing.
118
+ 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.
119
+ 4. **Preprocessor:** After merge, drop the redundant timestamp column if `ts_lab != timestamp_col_ims`.
120
+ 5. **SensorDataLoader.load:** After `read_csv`, check that required columns (at least timestamp and Stage 1) are present and log or raise if not.
121
+ 6. **predictor:** Remove unused `seaborn` import; add `__dir__` to `src/__init__.py` for discoverability.
122
+
123
+ ---
124
+
125
+ ## 11. Summary Table
126
+
127
+ | Module | Critical | Medium | Low |
128
+ |--------|----------|--------|-----|
129
+ | config/settings.py | 0 | 0 | 2 |
130
+ | sensor_data_loader.py | 0 | 1 | 2 |
131
+ | ims_client.py | 0 | 2 | 2 |
132
+ | farquhar_model.py | 0 | 2 | 4 |
133
+ | preprocessor.py | 1 | 1 | 3 |
134
+ | predictor.py | 0 | 0 | 3 |
135
+ | __init__.py | 0 | 0 | 1 |
136
+
137
+ **Total: 1 critical, 6 medium, 17 low.**
138
+
139
+ ---
140
+
141
+ ## 12. scripts/run_pipeline.py and app.py (post-todo completion)
142
+
143
+ | Finding | Severity | Notes |
144
+ |--------|----------|--------|
145
+ | run_pipeline: Stage 1 labels index | Low | Labels index set to floor 15min and aggregated; aligns with IMS for merge. Good. |
146
+ | run_pipeline: Stage 2 requires overlap | Low | If IMS cache and sensor date ranges don't overlap, merge is empty; message added. |
147
+ | run_pipeline: global `pd` | Low | `import pandas as pd` at module level; used in run_stage1 and validate_stage1. |
148
+ | app.py: run_stage1/run_stage2 buttons | Low | Buttons trigger pipeline; state not persisted across reruns. Acceptable. |
149
+ | app.py: paths in sidebar | Low | Shows config paths; no secrets. Good. |
150
+
151
+ **Verdict:** Pipeline and app are consistent with the plan; no critical issues.
152
+
153
+ ---
154
+
155
+ ## 13. Applied Fixes (post-review)
156
+
157
+ - **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.
158
+ - **IMSClient:** Validate token in `__init__` and raise `ValueError` if missing.
159
+ - **FarquharModel.compute_all:** Check for NaN in required inputs per row and append NaN to output; catch TypeError/ZeroDivisionError/ValueError and append NaN.
160
+ - **SensorDataLoader.load:** After `read_csv`, raise `ValueError` with missing column names if any requested column is absent.
161
+ - **predictor:** Removed unused `seaborn` import.
162
+ - **src/__init__.py:** Added `__dir__` returning `__all__` for better discoverability.
163
+
164
+ ---
165
+
166
+ ## 14. Growing-season filter (Oct–April excluded)
167
+
168
+ | Finding | Severity | Notes |
169
+ |--------|----------|--------|
170
+ | config.GROWING_SEASON_MONTHS | Low | (5,6,7,8,9) = May–September; single source of truth. |
171
+ | 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. |
172
+ | Hemisphere | Low | Assumes Northern Hemisphere (Israel). Document if reused elsewhere. |
173
+
174
+ **Verdict:** Correct; reduces label count to active season only and improves Stage 2 relevance.
175
+
176
+ ---
177
+
178
+ ## 15. Applied fixes (verified)
179
+
180
+ Verified in the codebase as of 2026-02. The following fixes from section 13 are present:
181
+
182
+ | Fix | Status | Location |
183
+ |-----|--------|----------|
184
+ | Preprocessor: drop duplicate timestamp after merge | Done | `preprocessor.py` 48–49, 59–61 |
185
+ | 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) |
186
+ | Preprocessor: `train_ratio` default `None`, use `if train_ratio is None else train_ratio` | Done | `preprocessor.py` 19, 21 |
187
+ | IMSClient: validate token in `__init__`, raise `ValueError` if missing | Done | `ims_client.py` 38–41 |
188
+ | FarquharModel.compute_all: NaN handling and exception → NaN | Done | `farquhar_model.py` 184–185, 193 |
189
+ | SensorDataLoader.load: raise `ValueError` with missing column names | Done | `sensor_data_loader.py` 61–64 |
190
+ | predictor: remove unused `seaborn` | Done | Not present in `predictor.py` |
191
+ | `src/__init__.py`: add `__dir__` returning `__all__` | Done | `__init__.py` line 14 |
192
+
193
+ ---
194
+
195
+ ## 16. Fixes applied 2026-02-17
196
+
197
+ The following issues identified in the full code + design review (2026-02-17) have been resolved:
198
+
199
+ | Fix | Severity | Location | Change |
200
+ |-----|----------|----------|--------|
201
+ | **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. |
202
+ | **`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'`). |
203
+
204
+ ---
205
+
206
+ ## 17. Remaining work
207
+
208
+ **Medium (not yet applied)**
209
+
210
+ - **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.)*
211
+ - **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).
212
+ - **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.
213
+ - **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).
214
+ - **app.py monolith:** 2568 lines, all 5 tabs inline. Extract tab content into a `tabs/` package for maintainability.
215
+
216
+ **Low (optional)**
217
+
218
+ - `compute_face_shading` unbound locals: initialize `east_sunlit = 0.0` and `west_sunlit = 0.0` before conditional branches (currently safe in practice but fragile).
219
+ - No-op rename in `ims_client.py:141`: `df.rename(columns={c: c ...})` is an identity operation; remove it.
220
+ - Unused `humidity_col` parameter in `FarquharModel.compute_all`: remove or document.
221
+ - Module-level singletons in `tracker_optimizer.py`: `_model` and `_shadow` are created on import; acceptable but note this if config changes at runtime.
222
+ - 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.
223
+
224
+ ---
225
+
226
+ ## 18. src/chatbot/guardrails.py (added 2026-03-09)
227
+
228
+ Anti-hallucination guardrails for the vineyard advisor chatbot.
229
+
230
+ | Component | Role | Notes |
231
+ |-----------|------|-------|
232
+ | `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). |
233
+ | `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. |
234
+ | `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. |
235
+ | `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. |
236
+
237
+ **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).
238
+
239
+ ---
240
+
241
+ ## 19. src/chatbot/vineyard_chatbot.py (updated 2026-03-09)
242
+
243
+ | Change | Notes |
244
+ |--------|-------|
245
+ | `ChatResponse` extended | New fields: `confidence`, `sources`, `caveats`, `rule_violations`. Backward-compatible (all have defaults). |
246
+ | `chat()` flow upgraded | 7-step pipeline: classify → Gemini Pass 1 → force tool if data query → dispatch + tag → Gemini Pass 2 → validate → confidence. |
247
+ | 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". |
248
+ | `_get_validation_context()` | Gathers hour, month, stage_id, temp_c for rule validation. Uses `zoneinfo` for Israel time. |
249
+
250
+ **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.
251
+
252
+ ---
253
+
254
+ ## 20. src/energy_budget.py (created 2026-03-10)
255
+
256
+ | Finding | Severity | Fix |
257
+ |---------|----------|-----|
258
+ | `compute_weekly_plan` month-end calc overflowed for month=12 (→ month 13) | Bug | Added December guard before the `month+1` path |
259
+ | `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 |
260
+ | `_energy_analytical` loops 15k timestamps in Python | Low | Acceptable for one-shot season planning |
261
+ | `spend_slot` doesn't validate negative amounts | Low | Internal API; callers are trusted |
262
+
263
+ **Verdict:** Clean hierarchical design. Budget math is correct: 920 kWh potential → 46 kWh (5%) → monthly/weekly/daily/slot. Both bugs fixed.
264
+
265
+ ---
266
+
267
+ ## 21. src/command_arbiter.py (created 2026-03-10)
268
+
269
+ | Finding | Severity | Fix |
270
+ |---------|----------|-----|
271
+ | `AstronomicalTracker.get_angle` passed naive datetime to pvlib (warns on tz) | Medium | Added `tz_localize("UTC")` fallback for naive timestamps |
272
+ | `should_move` uses `pd.Timedelta` for `datetime` comparison | Low | Works; pd dependency already imported |
273
+ | `arbitrate` mutates `decision.source` on dispatch | Low | Intentional; overrides "stable"/"initial" with actual priority source |
274
+
275
+ **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).
276
+
277
+ ---
278
+
279
+ ## 22. src/spectral_aggregator.py (created 2026-03-10)
280
+
281
+ | Finding | Severity | Fix |
282
+ |---------|----------|-----|
283
+ | `aggregate_spectral_df` had no `cwsi_col` parameter — couldn't pass explicit CWSI from TB | Medium | Added `cwsi_col: Optional[str] = None` parameter |
284
+ | CWSI cascade (explicit → delta-T → VPD → missing) matches `data_schema.py` proxy logic | — | Consistent |
285
+ | Physical bounds hardcoded (not in config) | Low | Acceptable; these are physical constants, not tunable params |
286
+ | `_clip_or_flag` only checks `isinstance(float)` for NaN | Low | Sensor values are always float; safe |
287
+
288
+ **Verdict:** Good stateless design. CWSI cascade is well-ordered. Quality flags provide auditability.
289
+
290
+ ---
291
+
292
+ ## 23. scripts/import_layout.py (created 2026-03-10)
293
+
294
+ | Finding | Severity | Fix |
295
+ |---------|----------|-----|
296
+ | Row azimuth 315.0° duplicated from ShadowModel default | Low | Acceptable; layout is a snapshot, not a live reference |
297
+ | `--print` shadows Python builtin | Low | argparse convention; fine |
298
+ | Crop sensor positions (west/east, bottom/upper) parsed from label strings | Low | Works for current 7 Crop devices; fragile if naming changes |
299
+
300
+ **Verdict:** Useful spatial registration script. Generates a clean JSON for dashboard map view and future ShadowModel integration.
301
+
302
+ ---
303
+
304
+ ## 24. src/models/canopy_photosynthesis.py (modified 2026-03-10)
305
+
306
+ | Finding | Severity | Fix |
307
+ |---------|----------|-----|
308
+ | `from config.settings import FRUITING_ZONE_INDEX` was inside method body | Medium | Moved to module-level import |
309
+ | `compute_timeseries` doesn't include new fruiting/top fields | Low | Acceptable; it's a batch summary for historical analysis |
310
+
311
+ **Verdict:** Clean addition. New fields (`fruiting_zone_A`, `fruiting_zone_par`, `top_canopy_A`, `top_canopy_par`) integrate well with existing return dict.
context/refactor_todo.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Refactor Plan & Todo – SolarWine Baseline
2
+
3
+ This file tracks the refactor steps for Gemini utilities, phenology, time features, IMS coverage, and performance/caching improvements.
4
+
5
+ ---
6
+
7
+ ## High Priority
8
+
9
+ - [x] **Unify Gemini client & JSON handling**
10
+ - [x] `genai_utils`: verify `get_google_api_key`, `get_genai_client`, `extract_json_object` (already created).
11
+ - [x] `day_ahead_advisor`:
12
+ - [x] Use `extract_json_object` via local `_extract_json` wrapper.
13
+ - [x] Confirm `api_key` and `client` now call `genai_utils`.
14
+ - [x] `llm_data_engineer`:
15
+ - [x] Replace `api_key` property with `get_google_api_key(self._api_key)`.
16
+ - [x] Replace `client` property with `get_genai_client(self._api_key)`.
17
+ - [x] Swap local `_extract_json` to use `extract_json_object`.
18
+ - [x] `routing_agent`:
19
+ - [x] Replace `api_key` property with `get_google_api_key(self._api_key)`.
20
+ - [x] Replace `client` property with `get_genai_client(self._api_key)`.
21
+ - [x] `vineyard_chatbot`:
22
+ - [x] Replace `api_key` property with `get_google_api_key(self._api_key)`.
23
+ - [x] Replace `client` property with `get_genai_client(self._api_key)`.
24
+ - [x] Make `has_api_key` call `get_google_api_key` in a try/except instead of duplicating logic.
25
+
26
+ - [x] **Centralise phenology logic**
27
+ - [x] `phenology.py`: confirm `estimate_stage_for_date` and `stage_id_and_description_for_date` match the biology plan and skills.
28
+ - [x] `app.py` (Vineyard Advisor vine snapshot):
29
+ - [x] Replace inline month→stage mapping with `stage_id_and_description_for_date(today)`.
30
+ - [x] Any future modules that reason about "current stage" should import from `phenology` instead of re-implementing month logic.
31
+
32
+ - [x] **IMS cache coverage & observability**
33
+ - [ ] `IMSClient.fetch_and_cache`:
34
+ - [ ] Add lightweight logging or counters for skipped chunks (date ranges that failed).
35
+ - [x] `app.py` – Forecasting tab:
36
+ - [x] Add a small IMS coverage panel that shows:
37
+ - [x] First timestamp in cache
38
+ - [x] Last timestamp in cache
39
+ - [x] Number of days of coverage
40
+ - [x] Age (minutes/hours) of the latest record
41
+ - [ ] `app.py` – Vineyard Advisor tab:
42
+ - [ ] Reuse the cached vine snapshot to also display IMS coverage (not just last point) where useful.
43
+
44
+ ---
45
+
46
+ ## Medium Priority
47
+
48
+ - [x] **Shared time-feature utilities**
49
+ - [x] `time_features.py`: confirm `add_cyclical_time_features` API (timestamp column or index).
50
+ - [x] `preprocessor.create_time_features`:
51
+ - [x] Replace manual sin/cos code with `add_cyclical_time_features(df, timestamp_col="timestamp_utc")`.
52
+ - [x] Keep raw `month` and `day_of_year` features as now.
53
+ - [x] `chronos_forecaster.load_data`:
54
+ - [x] Replace inline `hour_sin/cos` and `doy_sin/cos` computations with `add_cyclical_time_features(resampled, index_is_timestamp=True)`.
55
+ - [x] `llm_data_engineer.engineer_features`:
56
+ - [x] Replace its own cyclical feature block with a call to `add_cyclical_time_features` on the resolved timestamp.
57
+
58
+ - [ ] **Cache heavy shading simulations**
59
+ - [ ] `tracker_optimizer.simulate_tilt_angles`:
60
+ - [ ] Add a simple in-module cache (e.g. dict keyed by `(len(df), tuple(sorted(angles)))` or `(date_min, date_max, angles)` for the sample dataset).
61
+ - [ ] Ensure caching does not break if `df` changes (avoid caching across different data sources).
62
+ - [ ] `vineyard_chatbot._tool_compare_angles`:
63
+ - [ ] Confirm it benefits from the new `simulate_tilt_angles` cache (repeated queries reuse results).
64
+ - [ ] Optionally expose precomputed angle summaries in the Shading tab for instant advisor-like responses.
65
+
66
+ - [ ] **Use phenology helper in future logic**
67
+ - [ ] When adding or refactoring:
68
+ - [ ] Day-ahead planner
69
+ - [ ] Energy budget planner
70
+ - [ ] Operational modes / real-time control
71
+ - Always derive phenological stage via `phenology.estimate_stage_for_date` or `stage_id_and_description_for_date` instead of hand-written month rules.
72
+
73
+ ---
74
+
75
+ ## Low Priority (later)
76
+
77
+ - [ ] **Vectorise `FarquharModel.compute_all`** if Stage 1 datasets grow significantly or are needed in near real-time.
78
+ - [ ] **Design and implement `thingsboard_client`** according to the `thingsboard-client` skill, then gradually wire real-time vine telemetry (soil moisture, irrigation, local temps) into Vineyard Advisor and future control logic.
79
+ - [ ] **Add focused tests** for:
80
+ - [ ] `FarquharModel` (known inputs, NaN handling).
81
+ - [ ] `Preprocessor` (merge, split, edge cases).
82
+ - [ ] `SolarGeometry/ShadowModel` (tracker tilt, mask shape).
83
+ - [ ] `ChronosForecaster.load_data` (grid coverage, growing-season filter, time features).
docker-compose.yml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+
3
+ services:
4
+ api:
5
+ build:
6
+ context: .
7
+ dockerfile: backend/Dockerfile
8
+ ports:
9
+ - "7860:7860"
10
+ env_file: .env
11
+ environment:
12
+ - ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
13
+ volumes:
14
+ - ./src:/app/src
15
+ - ./config:/app/config
16
+ - ./backend:/app/backend
17
+ - ./Data:/app/Data
ims_api_documentation.md ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # דו"ח משימה 2 – הבאת נתונים מ-IMS (Israel Meteorological Service)
2
+
3
+ ---
4
+
5
+ ## אילו נתונים נלקחים מ-IMS ואיך הם נשמרים
6
+
7
+ ### מקור הנתונים
8
+
9
+ - **שירות:** IMS (Israel Meteorological Service) – שירות המטאורולוגיה הישראלי.
10
+ - **ממשק:** API Envista – `https://api.ims.gov.il/v1/envista/stations/{STATION_ID}/data/{CHANNEL_ID}/?from=YYYY/MM/DD&to=YYYY/MM/DD`
11
+ - **תחנה:** **43 – שדה בוקר** (דומה לאקלים ירוחם; מיקום הכרם).
12
+ - **אימות:** טוקן API ב־משתנה סביבה `IMS_API_TOKEN` (נשמר מקומית ב־`.env`, לא ב־git).
13
+
14
+ ### ערוצים (Channels) שנלקחים
15
+
16
+ מזהי הערוצים תלויים בתחנה. **לתחנה 43 (שדה בוקר)** מוגדרים ב־`config/dev.yaml` תחת `ims:`:
17
+
18
+ | ערוץ IMS (מזהה) | שם IMS | משתנה בפלט (סכמה קנונית) | יחידות | תיאור |
19
+ |-----------------|--------|---------------------------|--------|--------|
20
+ | 6 | TD | `air_temperature_c` | °C | טמפרטורת אוויר נוכחית |
21
+ | 8 | TDmax| `tdmax_c` | °C | טמפרטורת מקסימום (במהלך המרווח) |
22
+ | 9 | TDmin| `tdmin_c` | °C | טמפרטורת מינימום (במהלך המרווח) |
23
+ | 10 | Grad | `ghi_w_m2` | W/m²| קרינה גלובלית אופקית (GHI) |
24
+
25
+ - אם `channel_radiation` מוגדר כ־`null` בתצורה (תחנה ללא Grad), עמודת הקרינה לא נמשכת ומופיעה רק טמפרטורה.
26
+
27
+ ### טווח זמן ורזולוציה
28
+
29
+ - **טווח ברירת מחדל:** שנתיים אחורה מתאריך ההרצה (ניתן לשנות עם `--from`, `--to` או `--years`).
30
+ - **רזולוציה:** נתוני IMS בתדירות 10 דקות; נשמרים כפי שהתקבלו (לא מצוננים).
31
+ - **בקשות:** טווח של יותר מ־60 יום מפוצל לחלקים (chunks) כדי למנוע תגובות ריקות מה־API; בין בקשות יש השהייה קצרה.
32
+
33
+ ### עיבוד לפני שמירה
34
+
35
+ 1. **המרת זמן:** חותמות הזמן מגיעות מ־IMS בזמן ישראל (UTC+2/UTC+3). כל התאריכים מומרים ל־**UTC** לפני שמירה.
36
+ 2. **מיזוג ערוצים:** כל ערוץ נמשך בנפרד; השורות ממוזגות לפי `timestamp_utc` (איחוד אינדקסים – חסרים מסומנים כ־NaN).
37
+ 3. **סינון טמפרטורה:** ערכים מחוץ לטווח **־50 עד 60 °C** (למשל ערוץ Time hhmm שנדבק בטעות) מוחלפים ב־NaN.
38
+ 4. **מקור:** נוספת עמודה `source` עם הערך `ims`.
39
+
40
+ ### איפה ואיך נשמרים הנתונים
41
+
42
+ | פריט | ערך |
43
+ |------|-----|
44
+ | **קובץ פלט** | `data/ims_radiation_temperature.csv` (נתיב: `config.data_paths.IMS_RADIATION_TEMPERATURE_CSV`) |
45
+ | **פורמט** | CSV (פסיק כמפריד) |
46
+ | **קידוד** | ברירת מחדל של pandas (בדרך כלל UTF-8) |
47
+
48
+ **עמודות בקובץ:**
49
+
50
+ | עמודה | תיאור | יחידות |
51
+ |--------|--------|--------|
52
+ | `timestamp_utc` | חותמת זמן (UTC) | ISO format, timezone-aware UTC |
53
+ | `air_temperature_c` | טמפרטורת אוויר | °C |
54
+ | `tdmax_c` | טמפרטורה מקסימלית | °C |
55
+ | `tdmin_c` | טמפרטורה מינימלית | °C |
56
+ | `ghi_w_m2` | קרינה גלובלית אופקית (Grad) | W/m² |
57
+ | `source` | מקור הנתון | תמיד `ims` |
58
+
59
+ - עמודות שלא נמשכו (למשל קרינה אם אין ערוץ Grad) לא יופיעו או יהיו ריקות.
60
+ - שורות עם NaN בחלק מהעמודות אפשריות (למשל אי־התאמה בזמנים בין ערוצים).
61
+
62
+ ### שימוש בנתונים בפרויקט
63
+
64
+ - הקובץ משמש כ־**מקור מטאורולוגיה (IMS)** ל־**Ground Truth** (משימה 1 – Data fusion).
65
+ - סקריפטים נוספים: `scripts/gap_fill_future.py` ממלא פערים (כולל עתיד) ויכול לכתוב ל־`data/ims_radiation_temperature_gap_filled.csv`; `scripts/build_ground_truth.py` ממיר/ממזג ל־Ground Truth (Parquet) לפי הסכמה ב־`context/05_ground_truth_schema.md`.
66
+
67
+ ### ארגון הנתונים (ת pipeline)
68
+
69
+ סדר העבודה לפי התיעוד:
70
+
71
+ 1. **הורדת IMS (גולמי)**
72
+ `python -m scripts.download_ims_data`
73
+ → פלט: `data/ims_radiation_temperature.csv` (רזולוציה 10 דקות, UTC, סינון טמפרטורה -50..60 °C).
74
+
75
+ 2. **השלמת פערים (כולל עתיד)**
76
+ `python -m scripts.gap_fill_future --input data/ims_radiation_temperature.csv`
77
+ → פלט: `data/ims_radiation_temperature_gap_filled.csv` (רשת רגולרית, quality_flag).
78
+
79
+ 3. **בניית Ground Truth (Parquet)**
80
+ `python -m scripts.build_ground_truth`
81
+ → פלט: `data/ground_truth/ground_truth_2024_2025.parquet` + `ground_truth_metadata.json` (רזולוציה 15 דקות).
82
+
83
+ ה��צת כל הצעדים ברצף (אם קיים קובץ גולמי):
84
+
85
+ ```bash
86
+ python -m scripts.download_ims_data && \
87
+ python -m scripts.gap_fill_future --input data/ims_radiation_temperature.csv && \
88
+ python -m scripts.build_ground_truth
89
+ ```
90
+
91
+ או עדכון אוטומטי (כולל TB/DB): `python -m scripts.update_ground_truth`.
92
+
93
+ ---
94
+
95
+ ## נספח – פקודות API (מקור: השירות המטאורולוגי)
96
+
97
+ תיעוד זה מבוסס על המסמך הרשמי **"ממשק עבור מאגר הנתונים המטאורולוגיים העשר דקתיים"** – משרד התחבורה, השירות המטאורולוגי (תאריך עדכון 11.05.2017). מקור: קובץ "פקודות API" מהשמ"ט. כ־85 תחנות אוטומטיות; הנתונים נמשכים בפורמט JSON.
98
+
99
+ ### אימות (Authorization)
100
+
101
+ - **שיטה:** `Authorization: ApiToken XXXX`
102
+ - **קבלת TOKEN:** פנייה במייל ל־**ims@ims.gov.il**
103
+ - בפרויקט: משתנה סביבה `IMS_API_TOKEN` (נשמר ב־`.env`).
104
+
105
+ ### מוסכמת זמן
106
+
107
+ - **כל השנה לפי זמן מקומי תקני (LST)** – שעון חורף.
108
+ - בתקופת שעון קיץ יש הפרש של שעה בין `datetime` המוצג לזמן בפועל (למשל אוקטובר: 11:40+03:00 מוצג בעוד שבפועל 12:40).
109
+ - בסקריפט שלנו: כל חותמות הזמן מומרות ל־**UTC** לפני שמירה.
110
+
111
+ ### מטה־דאטה: תחנות ואזורים
112
+
113
+ מזהי מק placeholders: `{%ST_ID%}` = מספר תחנה, `{%REG_ID%}` = מספר אזור, `{%CH_ID%}` = מספר ערוץ. תאריך: `YYYY/MM/DD`, `MM` = חודש (למשל 05), `DD` = יום (למשל 28).
114
+
115
+ **תחנות:**
116
+
117
+ | תיאור | כתובת |
118
+ |--------|--------|
119
+ | מידע על כל התחנות | `https://api.ims.gov.il/v1/envista/stations` |
120
+ | מידע על תחנה ספציפית | `https://api.ims.gov.il/v1/envista/stations/{%ST_ID%}` |
121
+
122
+ תגובת תחנה כוללת: `name`, `location`, `regionId`, `monitors` (רשימת ערוצים). לכל ערוץ: `active`, `channelId`, `name`, `typeId`, `units`.
123
+
124
+ **אזורים:**
125
+
126
+ | תיאור | כתובת |
127
+ |--------|--------|
128
+ | מידע על כל האזורים (כולל תחנות) | `https://api.ims.gov.il/v1/envista/regions` |
129
+ | מידע על אזור ספציפי | `https://api.ims.gov.il/v1/envista/regions/{%REG_ID%}` |
130
+
131
+ אזור כולל: מספר אזור, שם, רשימת תחנות והערוצים שלהן.
132
+
133
+ ### משיכת נתונים מטאורולוגיים מתחנה
134
+
135
+ בסיס: `https://api.ims.gov.il/v1/envista/stations/{%ST_ID%}/data/...`
136
+
137
+ **נתונים אחרונים / ישנים ביותר:**
138
+
139
+ | תיאור | כתובת |
140
+ |--------|--------|
141
+ | אחרונים – כל הערוצים | `.../stations/{%ST_ID%}/data/latest` |
142
+ | אחרונים – ערוץ ספציפי | `.../stations/{%ST_ID%}/data/{%CH_ID%}/latest` |
143
+ | ישנים ביותר – כל הערוצים | `.../stations/{%ST_ID%}/data/earliest` |
144
+ | ישנים ביותר – ערוץ ספציפי | `.../stations/{%ST_ID%}/data/{%CH_ID%}/earliest` |
145
+
146
+ **היום / החודש הנוכחי:**
147
+
148
+ | תיאור | כתובת |
149
+ |--------|--------|
150
+ | נתונים מהיום – כל הערוצים | `.../stations/{%ST_ID%}/data/daily` |
151
+ | נתונים מהיום – ערוץ ספציפי | `.../stations/{%ST_ID%}/data/{%CH_ID%}/daily` |
152
+ | נתונים מהחודש – כל הערוצים | `.../stations/{%ST_ID%}/data/monthly` |
153
+ | נתונים מהחודש – ערוץ ספציפי | `.../stations/{%ST_ID%}/data/{%CH_ID%}/monthly` |
154
+
155
+ **יום / חודש מסוים:**
156
+
157
+ | תיאור | כתובת |
158
+ |--------|--------|
159
+ | יום מסוים – כל הערוצים | `.../stations/{%ST_ID%}/data/daily/YYYY/MM/DD` |
160
+ | יום מסוים – ערוץ ספציפי | `.../stations/{%ST_ID%}/data/daily/{%CH_ID%}/YYYY/MM/DD` |
161
+ | חודש מסוים – כל הערוצים | `.../stations/{%ST_ID%}/data/monthly/YYYY/MM` |
162
+ | חודש מסוים – ערוץ ספציפי | `.../stations/{%ST_ID%}/data/{%CH_ID%}/monthly/YYYY/MM` |
163
+
164
+ **טווח תאריכים (זה מה שהסקריפט משתמש בו):**
165
+
166
+ | תיאור | כתובת |
167
+ |--------|--------|
168
+ | טווח – כל הערוצים | `.../stations/{%ST_ID%}/data?from=YYYY/MM/DD&to=YYYY/MM/DD` |
169
+ | טווח – ערוץ ספציפי | `.../stations/{%ST_ID%}/data/{%CH_ID%}?from=YYYY/MM/DD&to=YYYY/MM/DD` |
170
+
171
+ ### מבנה תגובת נתונים
172
+
173
+ - **מספר התחנה**
174
+ - **זמן המדידה** (`datetime`)
175
+ - **רשימת ערוצים**, לכל ערוץ:
176
+ - `id` – מס' ערוץ
177
+ - `name` – שם הערוץ
178
+ - `status` – **1** = תקין, **2** = לא תקין (כאשר Status=2 הנתונים לא תקינים)
179
+ - `valid` – `true` / `false`
180
+ - ערך מספרי של הפרמטר הנמדד
181
+
182
+ **הערה:** לא כל התחנות מודדות את כל המשתנים; **מס' הערוץ לאותו משתנה יכול להיות שונה מתחנה לתחנה**. כדי לדעת את מס' הערוץ – להריץ מטה־דאטה על התחנה (או `--list-stations` בסקריפט).
183
+
184
+ ### רשימת משתנים מטאורולוגיים (CHANNEL) – נספח ג'
185
+
186
+ משתנים שהתחנות עשויות למדוד (שם הערוץ ב-API, יחידות, תיאור):
187
+
188
+ | משתנה | יחידות | תיאור |
189
+ |--------|--------|--------|
190
+ | BP | mb | לחץ בגובה התחנה |
191
+ | DiffR | w/m² | קרינה מפוזרת |
192
+ | Grad | w/m² | קרינה גלובלית (GHI) |
193
+ | NIP | w/m² | קרינה ישירה |
194
+ | Rain | mm | כמות גשם |
195
+ | RH | % | לחות יחסית |
196
+ | STDwd | deg | סטיית תקן של כיוון הרוח |
197
+ | TD | degC | טמפרטורה יבשה (אוויר) |
198
+ | TDmax | degC | טמפרטורת מקסימום |
199
+ | TDmin | degC | טמפרטורת מינימום |
200
+ | TG | degC | טמפרטורה ליד הקרקע |
201
+ | Time | hhmm | זמן סיום 10 הדקות המקסימליות |
202
+ | WD | deg | כיוון הרוח |
203
+ | WDmax | deg | כיוון המשב העליון |
204
+ | WS | m/sec | מהירות הרוח |
205
+ | Ws10mm | m/sec | מהירות רוח 10 דקתית מקסימלית |
206
+ | WS1mm | m/sec | מהירות רוח דקתית מקסימלית |
207
+ | WSmax | m/sec | מהירות המשב העליון |
208
+
209
+ בפרויקט נמשכים כיום: **TD, TDmax, TDmin, Grad** (תחנה 43 – שדה בוקר). שאר המשתנים (לחץ, גשם, רוח, לחות, קרינה מפוזרת/ישירה וכו') זמינים באותו API לפי מס' ערוץ לכל תחנה.
210
+
211
+ ### שימוש ב-Fiddler (נספח ב')
212
+
213
+ לבדיקות: כלי חינמי Fiddler. ב-Composer: GET עם הכתובת הרצויה, וב־Header: `Authorization: ApiToken XXXX`.
214
+
215
+ ---
216
+
217
+ ## מה בוצע
218
+
219
+ ### 1. סקריפט הורדת נתונים IMS (`scripts/download_ims_data.py`)
220
+
221
+ - **מטרה:** הורדת נתונים היסטוריים של קרינה (Grad ≈ GHI) וטמפרטורה (TD, TDmax, TDmin) מ-IMS לשנתיים אחורה.
222
+ - **API:** `https://api.ims.gov.il/v1/envista/stations/{STATION_ID}/data/{CHANNEL_ID}/?from=YYYY/MM/DD&to=YYYY/MM/DD`
223
+ - **אימות:** טוקן IMS – לבקש מ-ims@ims.gov.il (תנאי שימוש: https://ims.gov.il/en/ObservationDataAPI).
224
+ - **משתנה סביבה:** `IMS_API_TOKEN` (או `IMS_TOKEN`) – מומלץ להגדיר ב־`.env`.
225
+ - **תצורה:** תחנה וערוצים ב־`config/dev.yaml` תחת `ims:` (ברירת מחדל: תחנה **43 – שדה בוקר**, דומה לירוחם; תחנה 98 = נבטים).
226
+ - **פלט:** `data/ims_radiation_temperature.csv` עם עמודות קנוניות: `timestamp_utc`, `air_temperature_c`, `ghi_w_m2`, `tdmax_c`, `tdmin_c`, `source=ims`. חותמות הזמן מומרות מישראל (UTC+2/3) ל-UTC.
227
+
228
+ **שימוש:**
229
+ ```bash
230
+ # רשימת תחנות (דורש טוקן)
231
+ python -m scripts.download_ims_data --list-stations
232
+
233
+ # הורדה לשנתיים אחורה (ברירת מחדל)
234
+ python -m scripts.download_ims_data
235
+
236
+ # טווח תאריכים ידני
237
+ python -m scripts.download_ims_data --from 2024-01-01 --to 2025-12-31 --station 43
238
+ ```
239
+
240
+ **הערה:** מזהי הערוצים (channel IDs) תלויים בתחנה; יש לאמת מול ה-API או תיעוד IMS (למשל תחנה 28: TDmax=10, TDmin=11).
241
+
242
+ ---
243
+
244
+ ### 2. סקריפט השלמת פערים לעתיד (`scripts/gap_fill_future.py`)
245
+
246
+ - **מטרה:** השלמת פערים בעתיד – רשת זמן רגולרית מהתאריך האחרון בנתונים עד תאריך סיום (אופציונלי), עם מילוי פערים ב-forward-fill או אינטרפולציה ליניארית, ואופציה למיזוג עם קובץ תחזית (למשל Open-Meteo).
247
+ - **קלט:** קובץ IMS (או כל CSV עם `timestamp_utc` + עמודות מספריות).
248
+ - **פלט:** `data/ims_radiation_temperature_gap_filled.csv` עם עמודה `quality_flag`: `observed` | `forward_fill` | `interpolated` | `forecast`.
249
+
250
+ **שימוש:**
251
+ ```bash
252
+ # השלמת פערים עם forward-fill עד סוף הנתונים
253
+ python -m scripts.gap_fill_future --input data/ims_radiation_temperature.csv
254
+
255
+ # הארכת רשת עד תאריך עתידי
256
+ python -m scripts.gap_fill_future --input data/ims_radiation_temperature.csv --end-date 2026-12-31
257
+
258
+ # מילוי עתיד מתחזית (למשל weather_forecast_cache)
259
+ python -m scripts.gap_fill_future --input data/ims_radiation_temperature.csv --forecast data/weather_forecast_cache.csv
260
+ ```
261
+
262
+ ---
263
+
264
+ ### 3. עדכוני קונפיגורציה
265
+
266
+ - **`config/data_paths.py`:** נוספו `IMS_RADIATION_TEMPERATURE_CSV`, `IMS_GAP_FILLED_CSV` ו-CSV_SPECS מתאימים.
267
+ - **`config/dev.yaml`:** נוסף בלוק `ims:` עם `station_id`, `default_station_id`, ו-channel IDs (ניתן להתאמה לפי תחנה).
268
+ - **`config/env.example`:** נוסף `IMS_API_TOKEN` עם הערה לבקשת טוקן.
269
+
270
+ ---
271
+
272
+ ## מה נשאר (אופציונלי)
273
+
274
+ - **ולידציה:** להריץ הורדה עם טוקן אמיתי ולוודא כיסוי ורזולוציה (שעתי/יומי) מול דרישות Ground Truth.
275
+ - **התאמת תחנה/ערוצים:** תחנה 43 = שדה בוקר (דומה לירוחם), תחנה 98 = נבטים. להריץ `--list-stations` לעדכון תחנה/ערוצים.
276
+
277
+ ---
278
+
279
+ ## סיכום
280
+
281
+ | פריט | סטטוס |
282
+ |------|--------|
283
+ | תיעוד/גישה ל-IMS API | בוצע – תיעוד וסקריפט |
284
+ | סקריפט הורדה (שנתיים אחורה) | בוצע – `download_ims_data.py` |
285
+ | מיפוי לסכמה קנונית + CSV | בוצע – `timestamp_utc`, `air_temperature_c`, `ghi_w_m2`, וכו' |
286
+ | סקריפט השלמת פערים לעתיד | בוצע – `gap_fill_future.py` |
287
+ | ולידציה כיסוי/רזולוציה | ממתין – דורש טוקן IMS והרצה |
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Scripts for IMS download and pipeline runs
scripts/collect_2026_training_data.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Collect 2026-schema training data for the next-gen photosynthesis ML model.
2
+
3
+ Pulls 15-min telemetry from all Crop_2Soil devices (treatment + reference) and
4
+ the IMS station, derives Farquhar inputs from the 2026 fleet's reduced sensor
5
+ set, computes a Farquhar net-assimilation label (A_n) per row, and appends to
6
+ ``Data/2026/sensor_history.parquet``.
7
+
8
+ Designed to be re-run periodically (weekly) to accumulate growing-season data.
9
+ By August/September 2026 the parquet should hold ≥3 months of growing-season
10
+ hours — enough to retrain Stage 2 ML on the 2026 schema (see
11
+ ``context/3_todo.md`` § 12.6 ``bigml-train``).
12
+
13
+ Why we derive inputs vs measure them
14
+ ------------------------------------
15
+ The 2026 fleet retired the on-site Air1 sensor that previously supplied PAR,
16
+ CO2, and VPD. We approximate:
17
+
18
+ PAR (µmol/m²/s) ≈ 2.0 × GHI (W/m²)
19
+ — daylight broadband-to-PAR conversion; ±10 % vs measured PAR
20
+ (Akitsu et al. 2017). Worst at low sun angles.
21
+
22
+ CO2 (ppm) = 420 (assumed atmospheric)
23
+ — Sde Boker is far from urban/forest. Hourly CO2 fluctuates by
24
+ ~5 ppm; small effect on A via Ci.
25
+
26
+ VPD (kPa) = SVP(Tair) × (1 − RH/100) (Tetens equation)
27
+ — SVP(T) = 0.611 × exp(17.27·T / (T+237.3)).
28
+ Within ~5 % of measured VPD over the Negev range.
29
+
30
+ CWSI = max(0, min(1, (Tleaf − Tair) / 15))
31
+ — Crude but defensible until we add an empirical CWSI baseline.
32
+
33
+ Usage
34
+ -----
35
+ python -m scripts.collect_2026_training_data
36
+ python -m scripts.collect_2026_training_data --from 2026-05-01 --to 2026-05-18
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import sys
43
+ from datetime import datetime, timedelta, timezone
44
+ from pathlib import Path
45
+ from typing import Dict, List
46
+
47
+ import numpy as np
48
+ import pandas as pd
49
+
50
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent
51
+ sys.path.insert(0, str(_PROJECT_ROOT))
52
+
53
+ try:
54
+ from dotenv import load_dotenv
55
+ load_dotenv(_PROJECT_ROOT / ".env")
56
+ except ImportError:
57
+ pass
58
+
59
+ from src.data.thingsboard_client import (
60
+ ThingsBoardClient,
61
+ DEVICE_REGISTRY,
62
+ TREATMENT_DEVICES,
63
+ REFERENCE_DEVICES,
64
+ VineArea,
65
+ )
66
+ from src.models.farquhar_model import FarquharModel
67
+ from src.shading.solar_geometry import ShadowModel
68
+
69
+ _IMS_CSV = _PROJECT_ROOT / "Data" / "ims" / "ims_merged_15min.csv"
70
+ _OUT_DIR = _PROJECT_ROOT / "Data" / "2026"
71
+ _OUT_PARQUET = _OUT_DIR / "sensor_history.parquet"
72
+
73
+ # Telemetry keys we pull per Crop_2Soil device.
74
+ _DEVICE_KEYS: List[str] = [
75
+ "leafTemperature", "ambientTemperatureIRT", "NDVI",
76
+ "PRI", "PSRI", "SIPI", "GCI", "LCI", "DUVI",
77
+ "soilTemperature", "soilMoisture", "soilBulkEC", "soilPoreWaterEC",
78
+ "soilTemperature2", "soilMoisture2", "soilPoreWaterEC2",
79
+ ]
80
+
81
+ _TRACKER_DEVICES = ["Tracker501", "Tracker502", "Tracker503", "Tracker509"]
82
+
83
+ _CO2_ASSUMED_PPM = 420.0
84
+ _PAR_FROM_GHI = 2.0 # µmol/m²/s per W/m²
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Derivations
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def _saturation_vapour_pressure_kpa(t_c: pd.Series) -> pd.Series:
92
+ """Tetens equation: SVP(T) in kPa given T in °C."""
93
+ return 0.611 * np.exp(17.27 * t_c / (t_c + 237.3))
94
+
95
+
96
+ def derive_inputs(df: pd.DataFrame) -> pd.DataFrame:
97
+ """Add PAR, VPD, CO2, CWSI columns derived from the 2026 sensor set.
98
+
99
+ `par_umol_derived` is the **above-canopy** PAR estimate from IMS GHI;
100
+ the treatment-area shading correction is applied separately in
101
+ `apply_shading_correction()` so reference rows keep the open-sky PAR.
102
+ """
103
+ df = df.copy()
104
+ df["par_umol_derived"] = (df["ghi_w_m2"].clip(lower=0) * _PAR_FROM_GHI).clip(lower=0, upper=3000)
105
+ svp = _saturation_vapour_pressure_kpa(df["air_temperature_c"])
106
+ df["vpd_kpa_derived"] = (svp * (1.0 - df["rh_percent"].clip(0, 100) / 100.0)).clip(lower=0, upper=10)
107
+ df["co2_ppm_assumed"] = _CO2_ASSUMED_PPM
108
+ # CWSI proxy from leaf-air ΔT (positive when leaf > air = stress)
109
+ df["cwsi_proxy"] = ((df["leaf_temperature"] - df["air_temperature_c"]).clip(lower=0) / 15.0).clip(0, 1)
110
+ return df
111
+
112
+
113
+ def fetch_tracker_angles_15min(client: ThingsBoardClient,
114
+ start: datetime, end: datetime) -> pd.Series:
115
+ """Pull 15-min mean tracker angle across the 4 trackers."""
116
+ step = timedelta(days=7)
117
+ per_tracker = {}
118
+ for name in _TRACKER_DEVICES:
119
+ frames = []
120
+ cursor = start
121
+ while cursor < end:
122
+ cursor_end = min(cursor + step, end)
123
+ try:
124
+ df = client.get_timeseries(
125
+ name, ["angle"],
126
+ start=cursor, end=cursor_end,
127
+ limit=10_000, interval_ms=900_000, agg="AVG",
128
+ )
129
+ except Exception:
130
+ df = pd.DataFrame()
131
+ if not df.empty:
132
+ frames.append(df)
133
+ cursor = cursor_end
134
+ if frames:
135
+ tdf = pd.concat(frames).sort_index()
136
+ tdf.index = pd.to_datetime(tdf.index, utc=True).floor("15min")
137
+ tdf = tdf[~tdf.index.duplicated(keep="last")]
138
+ per_tracker[name] = tdf["angle"].rename(name)
139
+ if not per_tracker:
140
+ return pd.Series(dtype=float, name="tracker_angle_mean")
141
+ wide = pd.concat(per_tracker.values(), axis=1)
142
+ return wide.mean(axis=1, skipna=True).rename("tracker_angle_mean")
143
+
144
+
145
+ def apply_shading_correction(df: pd.DataFrame,
146
+ tracker_angles: pd.Series) -> pd.DataFrame:
147
+ """Compute treatment-area fruiting-zone PAR with panel shading.
148
+
149
+ For each treatment-area timestep, runs `ShadowModel.project_shadow()`
150
+ with the actual tracker angle and replaces `par_umol_derived` with
151
+ the fruiting-zone mean PAR (averaged across the row's horizontal
152
+ positions in the fruiting vertical zone). Reference rows are
153
+ untouched (open sky = above-canopy PAR).
154
+
155
+ Adds:
156
+ par_factor_treatment : ratio of corrected to open-sky PAR
157
+ tracker_angle_mean : mean across the 4 trackers (NaN if missing)
158
+ """
159
+ df = df.copy()
160
+ df["par_factor_treatment"] = 1.0
161
+
162
+ # Align tracker telemetry to every row's timestamp.
163
+ df["tracker_angle_mean"] = tracker_angles.reindex(
164
+ df.index, method="nearest", tolerance=pd.Timedelta("15min"),
165
+ ).values
166
+
167
+ treat_mask = (df["area"] == "treatment").to_numpy()
168
+ if not treat_mask.any():
169
+ return df
170
+
171
+ sm = ShadowModel()
172
+ from config.settings import FRUITING_ZONE_INDEX
173
+ fz_idx = FRUITING_ZONE_INDEX
174
+
175
+ # Compute solar position once per unique timestamp (treatment + reference
176
+ # share timestamps, so use the unique set to avoid duplicate work).
177
+ unique_ts = pd.DatetimeIndex(df.index.unique())
178
+ sun = sm.get_solar_position(unique_ts)
179
+ sun_lookup = {
180
+ ts: (float(sun.loc[ts, "solar_elevation"]), float(sun.loc[ts, "solar_azimuth"]))
181
+ for ts in unique_ts
182
+ }
183
+
184
+ # Vector pass over treatment rows via positional indices to avoid the
185
+ # duplicate-index gotcha with `.at[]`.
186
+ treat_positions = np.where(treat_mask)[0]
187
+ ghi_col = df.columns.get_loc("ghi_w_m2")
188
+ par_col = df.columns.get_loc("par_umol_derived")
189
+ tilt_col = df.columns.get_loc("tracker_angle_mean")
190
+ factor_col = df.columns.get_loc("par_factor_treatment")
191
+
192
+ for pos in treat_positions:
193
+ ts = df.index[pos]
194
+ ghi = df.iat[pos, ghi_col]
195
+ if pd.isna(ghi) or ghi <= 0:
196
+ continue
197
+ elev, azim = sun_lookup[ts]
198
+ if elev <= 2.0:
199
+ continue
200
+ tilt = df.iat[pos, tilt_col]
201
+ if pd.isna(tilt):
202
+ tilt = float(sm.compute_tracker_tilt(azim, elev)["tracker_theta"])
203
+ total_par = float(ghi * _PAR_FROM_GHI)
204
+ try:
205
+ mask = sm.project_shadow(elev, azim, float(tilt))
206
+ par_grid = sm.compute_par_distribution(
207
+ total_par, mask,
208
+ solar_elevation=elev, solar_azimuth=azim, tracker_tilt=float(tilt),
209
+ )
210
+ factor = float(par_grid[fz_idx, :].mean()) / total_par
211
+ except Exception:
212
+ factor = 1.0
213
+ df.iat[pos, factor_col] = factor
214
+ df.iat[pos, par_col] = df.iat[pos, par_col] * factor
215
+
216
+ return df
217
+
218
+
219
+ def compute_farquhar_a(df: pd.DataFrame) -> pd.Series:
220
+ """Run the Semillon Farquhar model row-by-row over the derived inputs."""
221
+ fm = FarquharModel()
222
+ out = np.full(len(df), np.nan)
223
+ for i, row in enumerate(df.itertuples(index=False)):
224
+ # Skip rows missing any input
225
+ par = getattr(row, "par_umol_derived", None)
226
+ tleaf = getattr(row, "leaf_temperature", None)
227
+ tair = getattr(row, "air_temperature_c", None)
228
+ vpd = getattr(row, "vpd_kpa_derived", None)
229
+ cwsi = getattr(row, "cwsi_proxy", 0.0)
230
+ if any(v is None or (isinstance(v, float) and np.isnan(v))
231
+ for v in (par, tleaf, tair, vpd)):
232
+ continue
233
+ try:
234
+ out[i] = fm.calc_photosynthesis(par, tleaf, _CO2_ASSUMED_PPM, vpd, tair, cwsi)
235
+ except Exception:
236
+ continue
237
+ return pd.Series(out, index=df.index, name="a_farquhar_umol")
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # TB fetch helpers
242
+ # ---------------------------------------------------------------------------
243
+
244
+ def _fetch_device_history(client: ThingsBoardClient,
245
+ name: str,
246
+ start: datetime,
247
+ end: datetime) -> pd.DataFrame:
248
+ """Pull 15-min telemetry for one Crop_2Soil device over [start, end]."""
249
+ step = timedelta(days=7)
250
+ frames = []
251
+ cursor = start
252
+ while cursor < end:
253
+ cursor_end = min(cursor + step, end)
254
+ try:
255
+ df = client.get_timeseries(
256
+ name, _DEVICE_KEYS,
257
+ start=cursor, end=cursor_end,
258
+ limit=10_000, interval_ms=900_000, agg="AVG",
259
+ )
260
+ except Exception:
261
+ df = pd.DataFrame()
262
+ if not df.empty:
263
+ frames.append(df)
264
+ cursor = cursor_end
265
+ if not frames:
266
+ return pd.DataFrame()
267
+ out = pd.concat(frames).sort_index()
268
+ out.index = pd.to_datetime(out.index, utc=True).floor("15min")
269
+ out = out[~out.index.duplicated(keep="last")]
270
+ return out
271
+
272
+
273
+ def fetch_area_history(client: ThingsBoardClient,
274
+ area: VineArea,
275
+ start: datetime,
276
+ end: datetime) -> pd.DataFrame:
277
+ """Aggregate (mean across devices) per area for the requested window."""
278
+ names = TREATMENT_DEVICES if area == VineArea.TREATMENT else REFERENCE_DEVICES
279
+ per_device: Dict[str, pd.DataFrame] = {}
280
+ for name in names:
281
+ df = _fetch_device_history(client, name, start, end)
282
+ if not df.empty:
283
+ per_device[name] = df
284
+
285
+ if not per_device:
286
+ return pd.DataFrame()
287
+
288
+ # Stack by device, average columns per timestamp
289
+ stacked = pd.concat(per_device.values(), keys=per_device.keys(),
290
+ names=["device", "timestamp_utc"])
291
+ averaged = stacked.groupby(level="timestamp_utc").mean(numeric_only=True)
292
+
293
+ # Rename camelCase TB keys → snake_case for the parquet schema
294
+ averaged = averaged.rename(columns={
295
+ "leafTemperature": "leaf_temperature",
296
+ "ambientTemperatureIRT": "ambient_temp_irt",
297
+ "NDVI": "ndvi", "PRI": "pri", "PSRI": "psri",
298
+ "SIPI": "sipi", "GCI": "gci", "LCI": "lci", "DUVI": "duvi",
299
+ "soilTemperature": "soil_temp_shallow_c",
300
+ "soilTemperature2": "soil_temp_deep_c",
301
+ "soilMoisture": "soil_moisture_shallow_pct",
302
+ "soilMoisture2": "soil_moisture_deep_pct",
303
+ "soilBulkEC": "soil_bulk_ec",
304
+ "soilPoreWaterEC": "soil_pore_water_ec_shallow",
305
+ "soilPoreWaterEC2": "soil_pore_water_ec_deep",
306
+ })
307
+ averaged["area"] = area.value
308
+ return averaged
309
+
310
+
311
+ def load_ims_15min(start: datetime, end: datetime) -> pd.DataFrame:
312
+ """Load IMS CSV, filter to window, leave at 15-min resolution."""
313
+ if not _IMS_CSV.exists():
314
+ raise FileNotFoundError(f"{_IMS_CSV} not found. Run scripts.download_ims_data first.")
315
+ df = pd.read_csv(_IMS_CSV)
316
+ df["timestamp_utc"] = pd.to_datetime(df["timestamp_utc"], utc=True)
317
+ df = df.set_index("timestamp_utc").sort_index()
318
+ df = df.loc[start:end]
319
+ return df
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Top-level pipeline
324
+ # ---------------------------------------------------------------------------
325
+
326
+ def build_dataset(start: datetime, end: datetime) -> pd.DataFrame:
327
+ client = ThingsBoardClient()
328
+
329
+ print(f"Fetching treatment area ({len(TREATMENT_DEVICES)} devices) ...")
330
+ treat = fetch_area_history(client, VineArea.TREATMENT, start, end)
331
+ print(f" {len(treat):,} 15-min rows")
332
+
333
+ print(f"Fetching reference area ({len(REFERENCE_DEVICES)} devices) ...")
334
+ ref = fetch_area_history(client, VineArea.REFERENCE, start, end)
335
+ print(f" {len(ref):,} 15-min rows")
336
+
337
+ print("Loading IMS weather ...")
338
+ ims = load_ims_15min(start, end)
339
+ print(f" {len(ims):,} 15-min rows")
340
+
341
+ print("Fetching tracker angles for shading correction ...")
342
+ tracker_series = fetch_tracker_angles_15min(client, start, end)
343
+ print(f" {len(tracker_series):,} 15-min tracker rows")
344
+
345
+ frames = []
346
+ for label, area_df in [("treatment", treat), ("reference", ref)]:
347
+ if area_df.empty:
348
+ continue
349
+ # Inner-join with IMS so every row has weather context
350
+ joined = area_df.join(ims, how="inner")
351
+ joined = derive_inputs(joined)
352
+ frames.append(joined)
353
+
354
+ if not frames:
355
+ raise RuntimeError("No data assembled — TB or IMS returned nothing.")
356
+
357
+ combined = pd.concat(frames)
358
+ combined.index.name = "timestamp_utc"
359
+
360
+ # Apply panel shading correction to treatment rows only.
361
+ combined = apply_shading_correction(combined, tracker_series)
362
+
363
+ # Recompute Farquhar label after PAR correction.
364
+ combined["a_farquhar_umol"] = compute_farquhar_a(combined)
365
+ return combined
366
+
367
+
368
+ def main() -> None:
369
+ p = argparse.ArgumentParser(description="Collect 2026-schema training data.")
370
+ p.add_argument("--from", dest="from_date", default=None,
371
+ help="Start date (UTC, inclusive). Default: 30 days ago.")
372
+ p.add_argument("--to", dest="to_date", default=None,
373
+ help="End date (UTC, inclusive). Default: today.")
374
+ p.add_argument("--mode", choices=["append", "replace"], default="append",
375
+ help="append (default) merges into existing parquet on timestamp+area; "
376
+ "replace overwrites the file.")
377
+ args = p.parse_args()
378
+
379
+ end = (datetime.fromisoformat(args.to_date).replace(tzinfo=timezone.utc)
380
+ if args.to_date else datetime.now(tz=timezone.utc))
381
+ start = (datetime.fromisoformat(args.from_date).replace(tzinfo=timezone.utc)
382
+ if args.from_date else end - timedelta(days=30))
383
+
384
+ new = build_dataset(start, end)
385
+ print(f"\nCollected {len(new):,} new rows range: {new.index.min()} → {new.index.max()}")
386
+
387
+ _OUT_DIR.mkdir(parents=True, exist_ok=True)
388
+ if args.mode == "append" and _OUT_PARQUET.exists():
389
+ existing = pd.read_parquet(_OUT_PARQUET)
390
+ combined = pd.concat([existing.reset_index(), new.reset_index()])
391
+ combined = combined.drop_duplicates(subset=["timestamp_utc", "area"], keep="last")
392
+ combined = combined.set_index("timestamp_utc").sort_index()
393
+ print(f"Merged with existing {len(existing):,} rows → {len(combined):,} total")
394
+ else:
395
+ combined = new
396
+
397
+ combined.to_parquet(_OUT_PARQUET)
398
+ print(f"Wrote → {_OUT_PARQUET}")
399
+
400
+ # Validation summary
401
+ a = combined["a_farquhar_umol"].dropna()
402
+ if not a.empty:
403
+ sun = combined[combined["ghi_w_m2"] > 100]["a_farquhar_umol"].dropna()
404
+ print(f"\nFarquhar A summary:")
405
+ print(f" total non-null rows : {len(a):,}")
406
+ print(f" range : {a.min():.2f} → {a.max():.2f} µmol CO2/m²/s")
407
+ print(f" daytime (GHI>100) mean : {sun.mean():.2f} median : {sun.median():.2f}")
408
+
409
+
410
+ if __name__ == "__main__":
411
+ main()
scripts/create_pptx.py ADDED
@@ -0,0 +1,774 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create a PowerPoint presentation for the solar system electrical plan — v2 (Series topology)."""
2
+ from pptx import Presentation
3
+ from pptx.util import Inches, Pt, Cm, Emu
4
+ from pptx.dml.color import RGBColor
5
+ from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
6
+ from pptx.enum.shapes import MSO_SHAPE
7
+ from pptx.oxml.ns import qn
8
+ import os
9
+
10
+ prs = Presentation()
11
+ prs.slide_width = Inches(13.333)
12
+ prs.slide_height = Inches(7.5)
13
+
14
+ # ── Color palette ──
15
+ NAVY = RGBColor(0x0C, 0x23, 0x40)
16
+ BLUE = RGBColor(0x1A, 0x52, 0x76)
17
+ GOLD = RGBColor(0xF0, 0xC0, 0x40)
18
+ WHITE = RGBColor(0xFF, 0xFF, 0xFF)
19
+ LIGHT_BG = RGBColor(0xF5, 0xF6, 0xFA)
20
+ RED = RGBColor(0xC6, 0x28, 0x28)
21
+ ORANGE = RGBColor(0xE6, 0x51, 0x00)
22
+ GREEN = RGBColor(0x2E, 0x7D, 0x32)
23
+ TEAL = RGBColor(0x00, 0x89, 0x7B)
24
+ DARK_TEXT = RGBColor(0x1A, 0x1A, 0x2E)
25
+ GRAY = RGBColor(0x55, 0x55, 0x55)
26
+ LIGHT_GOLD = RGBColor(0xFF, 0xF8, 0xE1)
27
+ LIGHT_RED = RGBColor(0xFF, 0xEB, 0xEE)
28
+ LIGHT_GREEN = RGBColor(0xE8, 0xF5, 0xE9)
29
+ LIGHT_BLUE = RGBColor(0xE3, 0xF2, 0xFD)
30
+ LIGHT_ORANGE = RGBColor(0xFF, 0xF3, 0xE0)
31
+ PURPLE = RGBColor(0x7B, 0x5E, 0xA7)
32
+
33
+ # ── Helpers ──
34
+ def set_slide_bg(slide, color):
35
+ bg = slide.background
36
+ fill = bg.fill
37
+ fill.solid()
38
+ fill.fore_color.rgb = color
39
+
40
+ def add_shape(slide, left, top, width, height, fill_color=None, line_color=None, shape_type=MSO_SHAPE.ROUNDED_RECTANGLE):
41
+ shape = slide.shapes.add_shape(shape_type, left, top, width, height)
42
+ shape.shadow.inherit = False
43
+ if fill_color:
44
+ shape.fill.solid()
45
+ shape.fill.fore_color.rgb = fill_color
46
+ else:
47
+ shape.fill.background()
48
+ if line_color:
49
+ shape.line.color.rgb = line_color
50
+ shape.line.width = Pt(1)
51
+ else:
52
+ shape.line.fill.background()
53
+ return shape
54
+
55
+ def set_text(shape, text, font_size=14, color=DARK_TEXT, bold=False, alignment=PP_ALIGN.RIGHT, font_name='Arial'):
56
+ tf = shape.text_frame
57
+ tf.word_wrap = True
58
+ tf.auto_size = None
59
+ p = tf.paragraphs[0]
60
+ p.alignment = alignment
61
+ run = p.add_run()
62
+ run.text = text
63
+ run.font.size = Pt(font_size)
64
+ run.font.color.rgb = color
65
+ run.font.bold = bold
66
+ run.font.name = font_name
67
+ pPr = p._p.get_or_add_pPr()
68
+ pPr.set('rtl', '1')
69
+ return tf
70
+
71
+ def add_text_box(slide, left, top, width, height, text, font_size=14, color=DARK_TEXT, bold=False, alignment=PP_ALIGN.RIGHT):
72
+ txBox = slide.shapes.add_textbox(left, top, width, height)
73
+ tf = txBox.text_frame
74
+ tf.word_wrap = True
75
+ p = tf.paragraphs[0]
76
+ p.alignment = alignment
77
+ pPr = p._p.get_or_add_pPr()
78
+ pPr.set('rtl', '1')
79
+ run = p.add_run()
80
+ run.text = text
81
+ run.font.size = Pt(font_size)
82
+ run.font.color.rgb = color
83
+ run.font.bold = bold
84
+ run.font.name = 'Arial'
85
+ return tf
86
+
87
+ def add_multiline_box(slide, left, top, width, height, lines, default_size=13, default_color=DARK_TEXT):
88
+ txBox = slide.shapes.add_textbox(left, top, width, height)
89
+ tf = txBox.text_frame
90
+ tf.word_wrap = True
91
+ for i, line_data in enumerate(lines):
92
+ text = line_data[0]
93
+ size = line_data[1] if len(line_data) > 1 else default_size
94
+ color = line_data[2] if len(line_data) > 2 else default_color
95
+ bold = line_data[3] if len(line_data) > 3 else False
96
+ align = line_data[4] if len(line_data) > 4 else PP_ALIGN.RIGHT
97
+ if i == 0:
98
+ p = tf.paragraphs[0]
99
+ else:
100
+ p = tf.add_paragraph()
101
+ p.alignment = align
102
+ pPr = p._p.get_or_add_pPr()
103
+ pPr.set('rtl', '1')
104
+ p.space_after = Pt(4)
105
+ run = p.add_run()
106
+ run.text = text
107
+ run.font.size = Pt(size)
108
+ run.font.color.rgb = color
109
+ run.font.bold = bold
110
+ run.font.name = 'Arial'
111
+ return tf
112
+
113
+ def add_table_slide(slide, left, top, width, row_height, headers, rows, header_bg=NAVY, header_fg=WHITE, highlight_rows=None, col_ratios=None):
114
+ num_rows = len(rows) + 1
115
+ num_cols = len(headers)
116
+ table_shape = slide.shapes.add_table(num_rows, num_cols, left, top, width, Pt(row_height * num_rows))
117
+ table = table_shape.table
118
+ if col_ratios:
119
+ total = sum(col_ratios)
120
+ for i, ratio in enumerate(col_ratios):
121
+ table.columns[i].width = int(width * ratio / total)
122
+ else:
123
+ col_width = int(width / num_cols)
124
+ for i in range(num_cols):
125
+ table.columns[i].width = col_width
126
+ for i, h in enumerate(headers):
127
+ cell = table.cell(0, i)
128
+ cell.text = h
129
+ cell.fill.solid()
130
+ cell.fill.fore_color.rgb = header_bg
131
+ for p in cell.text_frame.paragraphs:
132
+ p.alignment = PP_ALIGN.RIGHT
133
+ pPr = p._p.get_or_add_pPr()
134
+ pPr.set('rtl', '1')
135
+ for run in p.runs:
136
+ run.font.size = Pt(12)
137
+ run.font.color.rgb = header_fg
138
+ run.font.bold = True
139
+ run.font.name = 'Arial'
140
+ for r_idx, row in enumerate(rows):
141
+ for c_idx, val in enumerate(row):
142
+ cell = table.cell(r_idx + 1, c_idx)
143
+ cell.text = str(val)
144
+ if highlight_rows and r_idx in highlight_rows:
145
+ cell.fill.solid()
146
+ cell.fill.fore_color.rgb = LIGHT_GOLD
147
+ for p in cell.text_frame.paragraphs:
148
+ p.alignment = PP_ALIGN.RIGHT
149
+ pPr = p._p.get_or_add_pPr()
150
+ pPr.set('rtl', '1')
151
+ for run in p.runs:
152
+ run.font.size = Pt(11)
153
+ run.font.color.rgb = DARK_TEXT
154
+ run.font.name = 'Arial'
155
+ return table
156
+
157
+ def add_card(slide, left, top, width, height, title, body_lines, fill=WHITE, accent=GOLD):
158
+ bar = add_shape(slide, left, top, width, Pt(4), fill_color=accent, shape_type=MSO_SHAPE.RECTANGLE)
159
+ card = add_shape(slide, left, top + Pt(4), width, height - Pt(4), fill_color=fill, line_color=RGBColor(0xE0, 0xE4, 0xEA))
160
+ add_text_box(slide, left + Pt(10), top + Pt(10), width - Pt(20), Pt(24), title, font_size=15, color=NAVY, bold=True)
161
+ add_multiline_box(slide, left + Pt(10), top + Pt(36), width - Pt(20), height - Pt(46), body_lines, default_size=11)
162
+
163
+ def section_header(slide, text, y=Inches(0.3)):
164
+ add_text_box(slide, Inches(0.5), y, Inches(12), Inches(0.7), text, font_size=28, color=NAVY, bold=True)
165
+ add_shape(slide, Inches(0.5), y + Inches(0.65), Inches(2), Pt(3), fill_color=GOLD, shape_type=MSO_SHAPE.RECTANGLE)
166
+
167
+ def add_block(slide, x, y, w, h, text, sub, fill, text_color=WHITE, sub_color=None):
168
+ shape = add_shape(slide, x, y, w, h, fill_color=fill)
169
+ tf = shape.text_frame
170
+ tf.word_wrap = True
171
+ tf.paragraphs[0].alignment = PP_ALIGN.CENTER
172
+ pPr = tf.paragraphs[0]._p.get_or_add_pPr()
173
+ pPr.set('rtl', '1')
174
+ run = tf.paragraphs[0].add_run()
175
+ run.text = text
176
+ run.font.size = Pt(12)
177
+ run.font.color.rgb = text_color
178
+ run.font.bold = True
179
+ run.font.name = 'Arial'
180
+ if sub:
181
+ p2 = tf.add_paragraph()
182
+ p2.alignment = PP_ALIGN.CENTER
183
+ pPr2 = p2._p.get_or_add_pPr()
184
+ pPr2.set('rtl', '1')
185
+ run2 = p2.add_run()
186
+ run2.text = sub
187
+ run2.font.size = Pt(9)
188
+ run2.font.color.rgb = sub_color or RGBColor(0xCC, 0xCC, 0xCC)
189
+ run2.font.name = 'Arial'
190
+
191
+ def add_checklist_col(slide, x_start, y_start, items):
192
+ """items = list of (task, detail)"""
193
+ y = y_start
194
+ for task, detail in items:
195
+ add_text_box(slide, x_start, y, Inches(5.5), Inches(0.2), task, font_size=11, color=DARK_TEXT, bold=True)
196
+ add_text_box(slide, x_start, y + Pt(14), Inches(5.5), Inches(0.15), detail, font_size=9, color=GRAY)
197
+ y += Inches(0.4)
198
+
199
+ # ═══════════════════════════════════════════════════════════
200
+ # SLIDE 1 — Title
201
+ # ═══════════════════════════════════════════════════════════
202
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
203
+ set_slide_bg(slide, NAVY)
204
+
205
+ add_shape(slide, Inches(1), Inches(3.1), Inches(2), Pt(3), fill_color=GOLD, shape_type=MSO_SHAPE.RECTANGLE)
206
+
207
+ add_text_box(slide, Inches(1), Inches(1.2), Inches(11), Inches(1.5),
208
+ 'תכנית חשמלית — מערכת סולארית היברידית', font_size=40, color=WHITE, bold=True)
209
+ add_text_box(slide, Inches(1), Inches(2.4), Inches(11), Inches(0.6),
210
+ 'חוות יאיר — אלי ספרא', font_size=24, color=RGBColor(0xA8, 0xD8, 0xEA))
211
+
212
+ meta_items = [
213
+ 'מרץ 2026',
214
+ 'ממיר: Solis S6-EH3P20K-H | 20kW תלת-פאזי',
215
+ 'סוללה: CNTE 18.8kWh HV | LiFePO4',
216
+ 'פאנלים: 18 x 620W = 11.16kWp',
217
+ 'Zero Export | טופולוגיה סדרתית | ללא CT',
218
+ ]
219
+ add_multiline_box(slide, Inches(1), Inches(3.5), Inches(11), Inches(2.5),
220
+ [(m, 16, RGBColor(0xCD, 0xDE, 0xEE)) for m in meta_items])
221
+
222
+ # Bottom bar
223
+ add_shape(slide, Inches(0), Inches(7.1), Inches(13.333), Pt(30), fill_color=GOLD, shape_type=MSO_SHAPE.RECTANGLE)
224
+ add_text_box(slide, Inches(1), Inches(7.12), Inches(11), Pt(26),
225
+ 'תכנון בלבד — לאישור חשמלאי מוסמך לפני ביצוע', font_size=12, color=NAVY, bold=True, alignment=PP_ALIGN.CENTER)
226
+
227
+ # ═══════════════════════════════════════════════════════════
228
+ # SLIDE 2 — Notes (replacing Critical Warnings)
229
+ # ═══════════════════════════════════════════════════════════
230
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
231
+ set_slide_bg(slide, LIGHT_BG)
232
+
233
+ add_text_box(slide, Inches(0.5), Inches(0.3), Inches(12), Inches(0.7),
234
+ 'הערות חשובות לפני ביצוע', font_size=28, color=ORANGE, bold=True)
235
+ add_shape(slide, Inches(0.5), Inches(0.95), Inches(2), Pt(3), fill_color=ORANGE, shape_type=MSO_SHAPE.RECTANGLE)
236
+
237
+ notes = [
238
+ ('טופולוגיה סדרתית (Series)',
239
+ 'כל החשמל עובר דרך הממיר. אין CT. אין DC Isolators חיצוניים\n(מובנים בממיר ובסוללה). מפסקי C40 מספיקים.',
240
+ 'הוודא על ידי המתקין'),
241
+ ('RCD (פחת)',
242
+ 'לא נכלל כרגע — ייקבע לפי דרישת הבודק בהתאם למדדי הארקה.\nמומלץ לשקול הוספה מראש לבטיחות.',
243
+ 'נדחה לשלב בדיקה'),
244
+ ('טבעת מגנטית לכבל סוללה',
245
+ 'לפי המתקין, כבל הסוללה קצר מדי ללפף.\nנקודה פתוחה — לברר אם CNTE כולל ferrite מובנה, או להשתמש בכבל ארוך יותר.',
246
+ 'נקודה פתוחה'),
247
+ ]
248
+
249
+ y = Inches(1.3)
250
+ for title, desc, status in notes:
251
+ card = add_shape(slide, Inches(0.5), y, Inches(12), Inches(1.4), fill_color=WHITE, line_color=RGBColor(0xE0, 0xA0, 0x00))
252
+ add_shape(slide, Inches(0.5), y, Pt(6), Inches(1.4), fill_color=ORANGE, shape_type=MSO_SHAPE.RECTANGLE)
253
+ add_text_box(slide, Inches(0.8), y + Pt(8), Inches(9), Pt(24), title, font_size=16, color=ORANGE, bold=True)
254
+ add_text_box(slide, Inches(0.8), y + Pt(34), Inches(9), Pt(60), desc, font_size=12, color=GRAY)
255
+ # Status badge on the right
256
+ badge = add_shape(slide, Inches(10), y + Pt(14), Inches(2.2), Inches(0.35), fill_color=LIGHT_GOLD)
257
+ set_text(badge, status, font_size=11, color=ORANGE, bold=True, alignment=PP_ALIGN.CENTER)
258
+ y += Inches(1.6)
259
+
260
+ # Key info box
261
+ box = add_shape(slide, Inches(0.5), Inches(6), Inches(12), Inches(0.8), fill_color=LIGHT_GREEN)
262
+ add_multiline_box(slide, Inches(0.7), Inches(6.05), Inches(11.6), Inches(0.7), [
263
+ ('שינויים עיקריים מגרסה קודמת: ללא DC Isolators חיצוניים (מובנים) | ללא CT (series topology) | C40 מספיק (לא C50) | Zero Export אוטומטי | 3P אחרי בורר מצבים', 12, GREEN, True, PP_ALIGN.CENTER),
264
+ ])
265
+
266
+ # ═══════════════════════════════════════════════════════════
267
+ # SLIDE 3 — Single Line Diagram
268
+ # ═══════════════════════════════════════════════════════════
269
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
270
+ set_slide_bg(slide, LIGHT_BG)
271
+
272
+ section_header(slide, '1. תרשים חד-קווי (Single Line Diagram)')
273
+
274
+ # Series topology banner
275
+ banner = add_shape(slide, Inches(0.5), Inches(1.1), Inches(12.3), Inches(0.4), fill_color=LIGHT_GREEN)
276
+ add_text_box(slide, Inches(0.7), Inches(1.12), Inches(12), Inches(0.35),
277
+ 'טופולוגיה סדרתית — כל החשמל עובר דרך הממיר | Zero Export | ללא CT',
278
+ font_size=13, color=GREEN, bold=True, alignment=PP_ALIGN.CENTER)
279
+
280
+ LINE_W = Pt(2.5)
281
+
282
+ def add_line_h(slide, x, y, length, color):
283
+ """Horizontal line (thin rectangle)."""
284
+ add_shape(slide, x, y, length, LINE_W, fill_color=color, shape_type=MSO_SHAPE.RECTANGLE)
285
+
286
+ def add_line_v(slide, x, y, length, color):
287
+ """Vertical line (thin rectangle)."""
288
+ add_shape(slide, x, y, LINE_W, length, fill_color=color, shape_type=MSO_SHAPE.RECTANGLE)
289
+
290
+ DC_PV_COLOR = RGBColor(0xF0, 0xC0, 0x40)
291
+ DC_BATT_COLOR = TEAL
292
+ AC_GRID_COLOR = RGBColor(0x15, 0x65, 0xC0)
293
+ AC_BACKUP_COLOR = ORANGE
294
+ AC_DIST_COLOR = RGBColor(0x45, 0x5A, 0x64)
295
+ BMS_COLOR = RGBColor(0x26, 0xA6, 0x9A)
296
+
297
+ # ── Block positions (all in inches, stored for line drawing) ──
298
+ # PV Strings
299
+ s1_x, s1_y, s1_w, s1_h = 2.0, 1.7, 2.5, 0.65
300
+ s2_x, s2_y, s2_w, s2_h = 8.5, 1.7, 2.5, 0.65
301
+ # Inverter
302
+ inv_x, inv_y, inv_w, inv_h = 4.5, 2.85, 4.2, 0.9
303
+ # Battery
304
+ bat_x, bat_y, bat_w, bat_h = 0.5, 2.95, 2.3, 0.8
305
+ # SPD
306
+ spd_x, spd_y, spd_w, spd_h = 5.1, 4.1, 1.8, 0.5
307
+ # MCB
308
+ mcb_x, mcb_y, mcb_w, mcb_h = 7.2, 4.1, 1.5, 0.5
309
+ # Changeover
310
+ chg_x, chg_y, chg_w, chg_h = 5.4, 4.85, 2.5, 0.55
311
+ # Grid
312
+ grid_x, grid_y, grid_w, grid_h = 2.5, 5.75, 2.0, 0.55
313
+ # Load
314
+ load_x, load_y, load_w, load_h = 8.5, 5.75, 2.0, 0.55
315
+ # Backup
316
+ bkp_x, bkp_y, bkp_w, bkp_h = 10.5, 2.95, 2.2, 0.8
317
+
318
+ # ── Draw blocks ──
319
+ str_w_in = Inches(s1_w)
320
+ add_block(slide, Inches(s1_x), Inches(s1_y), str_w_in, Inches(s1_h),
321
+ 'String 1: 9x620W = 5,580Wp', 'Voc≈396V | Isc≈18A | MPPT1',
322
+ DC_PV_COLOR, DARK_TEXT, GRAY)
323
+ add_block(slide, Inches(s2_x), Inches(s2_y), str_w_in, Inches(s2_h),
324
+ 'String 2: 9x620W = 5,580Wp', 'Voc≈396V | Isc≈18A | MPPT2',
325
+ DC_PV_COLOR, DARK_TEXT, GRAY)
326
+
327
+ add_text_box(slide, Inches(4.5), Inches(2.45), Inches(4), Inches(0.25),
328
+ 'DC switch מובנה בממיר — ללא DC Isolator חיצוני',
329
+ font_size=10, color=GRAY, alignment=PP_ALIGN.CENTER)
330
+
331
+ add_block(slide, Inches(inv_x), Inches(inv_y), Inches(inv_w), Inches(inv_h),
332
+ 'Solis S6-EH3P20K-H', 'Hybrid 20kW | 3φ | IP66 | Series | DC switch + AFCI מובנים',
333
+ NAVY, WHITE, RGBColor(0xA8, 0xD8, 0xEA))
334
+
335
+ add_block(slide, Inches(bat_x), Inches(bat_y), Inches(bat_w), Inches(bat_h),
336
+ 'CNTE 18.8kWh', 'HV | LFP | IP66 | DC switch מובנה',
337
+ RGBColor(0xE0, 0xF2, 0xF1), DARK_TEXT, GRAY)
338
+
339
+ add_block(slide, Inches(spd_x), Inches(spd_y), Inches(spd_w), Inches(spd_h),
340
+ 'SPD Type 2', '3P+N | 40kA',
341
+ LIGHT_RED, RED, GRAY)
342
+
343
+ add_block(slide, Inches(mcb_x), Inches(mcb_y), Inches(mcb_w), Inches(mcb_h),
344
+ 'MCB C40 4P', 'יציאת ממיר',
345
+ RGBColor(0xED, 0xE7, 0xF6), DARK_TEXT, GRAY)
346
+
347
+ add_block(slide, Inches(chg_x), Inches(chg_y), Inches(chg_w), Inches(chg_h),
348
+ 'בורר מצבים Hager 4P 40A', 'Grid / Solar / Off',
349
+ RGBColor(0xF3, 0xE5, 0xF5), DARK_TEXT, GRAY)
350
+
351
+ add_block(slide, Inches(grid_x), Inches(grid_y), Inches(grid_w), Inches(grid_h),
352
+ 'MCB C40 3P → מונה יישוב', 'רשת חשמל',
353
+ RGBColor(0xEC, 0xEF, 0xF1), DARK_TEXT, GRAY)
354
+
355
+ add_block(slide, Inches(load_x), Inches(load_y), Inches(load_w), Inches(load_h),
356
+ 'MCB C40 3P → לוח חשמלי', 'עומסי הבית',
357
+ LIGHT_GREEN, DARK_TEXT, GRAY)
358
+
359
+ add_block(slide, Inches(bkp_x), Inches(bkp_y), Inches(bkp_w), Inches(bkp_h),
360
+ 'עומסים חיוניים', 'Backup UPS <10ms | C40 3P',
361
+ LIGHT_ORANGE, DARK_TEXT, GRAY)
362
+
363
+ # ── Draw connecting lines ──
364
+
365
+ # String 1 bottom center → down to merge line
366
+ s1_cx = s1_x + s1_w / 2 # 3.25
367
+ s1_bot = s1_y + s1_h # 2.35
368
+ add_line_v(slide, Inches(s1_cx), Inches(s1_bot), Inches(0.25), DC_PV_COLOR)
369
+
370
+ # String 2 bottom center → down to merge line
371
+ s2_cx = s2_x + s2_w / 2 # 9.75
372
+ s2_bot = s2_y + s2_h # 2.35
373
+ add_line_v(slide, Inches(s2_cx), Inches(s2_bot), Inches(0.25), DC_PV_COLOR)
374
+
375
+ # Horizontal merge line between String 1 and String 2 at y=2.6
376
+ merge_y = 2.6
377
+ add_line_h(slide, Inches(s1_cx), Inches(merge_y), Inches(s2_cx - s1_cx), DC_PV_COLOR)
378
+
379
+ # Center of merge → down to inverter top
380
+ inv_cx = inv_x + inv_w / 2 # 6.6
381
+ add_line_v(slide, Inches(inv_cx), Inches(merge_y), Inches(inv_y - merge_y), DC_PV_COLOR)
382
+
383
+ # Battery right edge → horizontal to inverter left edge (DC HV)
384
+ bat_right = bat_x + bat_w # 2.8
385
+ bat_cy = bat_y + bat_h / 2 # 3.35
386
+ add_line_h(slide, Inches(bat_right), Inches(bat_cy), Inches(inv_x - bat_right), DC_BATT_COLOR)
387
+
388
+ # BMS label
389
+ add_text_box(slide, Inches(3.0), Inches(3.05), Inches(1.3), Inches(0.2),
390
+ 'DC HV + CAN/BMS', font_size=9, color=BMS_COLOR, alignment=PP_ALIGN.CENTER)
391
+
392
+ # ── AC path: Inverter → SPD → MCB → Changeover ──
393
+ inv_bot = inv_y + inv_h # 3.75
394
+ spd_cx = spd_x + spd_w / 2 # 6.0
395
+ spd_cy = spd_y + spd_h / 2 # 4.35
396
+ spd_right = spd_x + spd_w # 6.9
397
+ mcb_cx = mcb_x + mcb_w / 2 # 7.95
398
+ mcb_bot = mcb_y + mcb_h # 4.6
399
+ chg_cx = chg_x + chg_w / 2 # 6.65
400
+ chg_top = chg_y # 4.85
401
+
402
+ # 1. Inv center (6.60) down short
403
+ elbow_y = inv_bot + 0.08 # 3.83
404
+ add_line_v(slide, Inches(inv_cx), Inches(inv_bot), Inches(elbow_y - inv_bot), AC_GRID_COLOR)
405
+
406
+ # 2. Horizontal jog from inv_cx (6.60) to spd_cx (6.00)
407
+ add_line_h(slide, Inches(spd_cx), Inches(elbow_y), Inches(inv_cx - spd_cx), AC_GRID_COLOR)
408
+
409
+ # 3. Down from spd_cx to SPD top (6.00, 4.10)
410
+ add_line_v(slide, Inches(spd_cx), Inches(elbow_y), Inches(spd_y - elbow_y), AC_GRID_COLOR)
411
+
412
+ # 4. SPD right → MCB left
413
+ add_line_h(slide, Inches(spd_right), Inches(spd_cy), Inches(mcb_x - spd_right), AC_GRID_COLOR)
414
+
415
+ # 5. MCB bottom center → down
416
+ elbow2_y = mcb_bot + 0.05 # 4.65
417
+ add_line_v(slide, Inches(mcb_cx), Inches(mcb_bot), Inches(elbow2_y - mcb_bot), AC_GRID_COLOR)
418
+
419
+ # 6. Horizontal from mcb_cx to chg_cx
420
+ add_line_h(slide, Inches(chg_cx), Inches(elbow2_y), Inches(mcb_cx - chg_cx), AC_GRID_COLOR)
421
+
422
+ # 7. Down from chg_cx to changeover top
423
+ add_line_v(slide, Inches(chg_cx), Inches(elbow2_y), Inches(chg_top - elbow2_y), AC_GRID_COLOR)
424
+
425
+ # Changeover bottom → down then split left/right
426
+ chg_bot = chg_y + chg_h # 5.4
427
+ branch_y = 5.55
428
+ add_line_v(slide, Inches(chg_cx), Inches(chg_bot), Inches(branch_y - chg_bot), AC_DIST_COLOR)
429
+
430
+ # Horizontal branch line
431
+ grid_cx = grid_x + grid_w / 2 # 3.5
432
+ load_cx = load_x + load_w / 2 # 9.5
433
+ add_line_h(slide, Inches(grid_cx), Inches(branch_y), Inches(load_cx - grid_cx), AC_DIST_COLOR)
434
+
435
+ # Grid branch: down from branch line to grid block
436
+ add_line_v(slide, Inches(grid_cx), Inches(branch_y), Inches(grid_y - branch_y), AC_DIST_COLOR)
437
+
438
+ # Load branch: down from branch line to load block
439
+ add_line_v(slide, Inches(load_cx), Inches(branch_y), Inches(load_y - branch_y), AC_DIST_COLOR)
440
+
441
+ # Backup: Inverter right → horizontal to backup block left
442
+ inv_right = inv_x + inv_w # 8.7
443
+ inv_cy = inv_y + inv_h / 2 # 3.3
444
+ bkp_left = bkp_x # 10.5
445
+ bkp_cy = bkp_y + bkp_h / 2 # 3.35
446
+ # Horizontal line
447
+ add_line_h(slide, Inches(inv_right), Inches(inv_cy), Inches(bkp_left - inv_right), AC_BACKUP_COLOR)
448
+
449
+ # Backup label
450
+ add_text_box(slide, Inches(inv_right + 0.1), Inches(inv_cy - 0.25), Inches(1.5), Inches(0.2),
451
+ 'Backup AC | UPS <10ms', font_size=9, color=AC_BACKUP_COLOR, alignment=PP_ALIGN.CENTER)
452
+
453
+ # ── Legend with colored line samples ──
454
+ legend_y = Inches(6.55)
455
+ legend_items = [
456
+ (0.5, DC_PV_COLOR, 'DC PV'),
457
+ (2.2, DC_BATT_COLOR, 'DC Battery'),
458
+ (4.2, AC_GRID_COLOR, 'AC Grid'),
459
+ (6.0, AC_BACKUP_COLOR, 'AC Backup'),
460
+ (8.0, AC_DIST_COLOR, 'Distribution'),
461
+ (10.2, BMS_COLOR, 'BMS'),
462
+ ]
463
+ legend_bg = add_shape(slide, Inches(0.3), legend_y - Pt(4), Inches(12.7), Inches(0.4), fill_color=RGBColor(0xF5, 0xF5, 0xF5), line_color=RGBColor(0xE0, 0xE0, 0xE0))
464
+ for lx, lcolor, ltext in legend_items:
465
+ add_shape(slide, Inches(lx), legend_y + Pt(4), Inches(0.4), Pt(3), fill_color=lcolor, shape_type=MSO_SHAPE.RECTANGLE)
466
+ add_text_box(slide, Inches(lx + 0.45), legend_y - Pt(2), Inches(1.3), Inches(0.3),
467
+ ltext, font_size=10, color=GRAY, alignment=PP_ALIGN.LEFT)
468
+
469
+ # ═══════════════════════════════════════════════════════════
470
+ # SLIDE 4 — Component List (10 items)
471
+ # ═══════════════════════════════════════════════════════════
472
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
473
+ set_slide_bg(slide, LIGHT_BG)
474
+
475
+ section_header(slide, '2. רשימת רכיבים מפורטת')
476
+
477
+ components = [
478
+ ('1', 'פאנלים סולאריים', '620W — מק"ט s620', '18', 'גג', '2 סטרינגים x 9. DC switch מובנה בממיר'),
479
+ ('2', 'ממיר היברידי', 'Solis S6-EH3P20K-H', '1', 'קיר מוצל', 'IP66. DC switch + AFCI מובנים'),
480
+ ('3', 'סוללה', 'CNTE 18.8kWh HV', '1', 'ליד הממיר', 'IP66. DC switch מובנה. CAN'),
481
+ ('4', 'מגן ברקים SPD', 'Type 2, 3P+N, 40kA', '1', 'יציאת AC ממיר', 'הגנת AC'),
482
+ ('5', 'מפסק יציאת ממיר', 'ABB C40 4P', '1', 'אחרי SPD', '4P — כולל נייטרל'),
483
+ ('6', 'בורר מצבים', 'Hager 4P 40A', '1', 'לפני התפצלות', 'Grid / Solar / Off'),
484
+ ('7', 'מפסק רשת', 'ABB C40 3P', '1', 'ענף רשת', '3P — אחרי בורר'),
485
+ ('8', 'מפסק עומסים', 'ABB C40 3P', '1', 'ענף עומסים', '3P — אחרי בורר'),
486
+ ('9', 'מפסק גיבוי', 'ABB C40 3P', '1', 'Backup ממיר', '3P — עומסים חיוניים'),
487
+ ('10', 'מונה יישוב', 'דו-כיווני', '1', 'לפני הרשת', 'מסופק ע"י יישוב'),
488
+ ]
489
+
490
+ add_table_slide(slide, Inches(0.3), Inches(1.2), Inches(12.7), 30,
491
+ ['#', 'רכיב', 'דגם / מפרט', 'כמות', 'מיקום', 'הערות'],
492
+ components, highlight_rows=[3],
493
+ col_ratios=[0.5, 2, 2.5, 0.7, 1.5, 3])
494
+
495
+ # Note about what's NOT in the list
496
+ note = add_shape(slide, Inches(0.3), Inches(5.8), Inches(12.7), Inches(0.7), fill_color=LIGHT_BLUE)
497
+ add_multiline_box(slide, Inches(0.5), Inches(5.85), Inches(12.3), Inches(0.6), [
498
+ ('לא נדרשים: DC Isolators חיצוניים (מובנים בממיר ובסוללה) | CT חיישן זרם (Series topology = Zero Export אוטומטי) | C50 (C40 מספיק)', 12, BLUE, True, PP_ALIGN.CENTER),
499
+ ])
500
+
501
+ # ═══════════════════════════════════════════════════════════
502
+ # SLIDE 5 — Cable Specifications
503
+ # ═══════════════════════════════════════════════════════════
504
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
505
+ set_slide_bg(slide, LIGHT_BG)
506
+
507
+ section_header(slide, '3. מפרט כבילה')
508
+
509
+ cables = [
510
+ ('PV String → Inverter', 'H1Z2Z2-K סולארי', '6mm²', 'לפי מיקום גג', 'ישירות לממיר, MC4, UV-resistant'),
511
+ ('Inverter ↔ Battery', 'DC גמיש', '16mm²', '~3m', 'מגיע עם הציוד. ⚠ ferrite — נקודה פתוחה'),
512
+ ('Inverter → SPD → MCB', 'NYY-J 5G AC', '10mm²', '~5m', '3L + N + PE'),
513
+ ('MCB → Switch → Grid/Load', 'NYY-J 5G AC', '10mm²', '~10m', 'לפי מרחק ללוח'),
514
+ ('Backup → MCB → Essential', 'NYY-J 5G AC', '6mm²', '~8m', '3P עומסים חיוניים'),
515
+ ('הארקה', 'ירוק-צהוב', '16mm² נחושת', '—', 'ממיר+סוללה+מבנה'),
516
+ ('BMS Communication', 'CAN מסוכך', 'שזור', '~3m', 'מגיע מלופף מראש בטבעת מגנטית'),
517
+ ]
518
+
519
+ add_table_slide(slide, Inches(0.5), Inches(1.2), Inches(12.3), 30,
520
+ ['קטע', 'סוג כבל', 'חתך', 'אורך', 'הערות'],
521
+ cables, highlight_rows=[1],
522
+ col_ratios=[2.5, 2, 1.2, 1, 3.5])
523
+
524
+ # Ferrite note
525
+ note = add_shape(slide, Inches(0.5), Inches(4.5), Inches(12.3), Inches(0.9), fill_color=LIGHT_GOLD)
526
+ add_multiline_box(slide, Inches(0.7), Inches(4.55), Inches(12), Inches(0.8), [
527
+ ('טבעות מגנטיות (Ferrite Rings):', 13, ORANGE, True),
528
+ ('כבל CAN — מגיע מלופף מראש (לפי המתקין)', 12, DARK_TEXT),
529
+ ('כבל DC סוללה — המתקין מציין שהוא קצר מדי ללפף. נקודה פתוחה — לברר מול CNTE אם יש ferrite מובנה, או לספק כבל ארוך יותר', 12, ORANGE, True),
530
+ ])
531
+
532
+ # ═══════════════════════════════════════════════════════════
533
+ # SLIDE 6 — Installation Guidelines
534
+ # ═══════════════════════════════════════════════════════════
535
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
536
+ set_slide_bg(slide, LIGHT_BG)
537
+
538
+ section_header(slide, '4. הנחיות התקנה')
539
+
540
+ card_w = Inches(5.9)
541
+ card_h = Inches(2.5)
542
+
543
+ add_card(slide, Inches(0.5), Inches(1.2), card_w, card_h,
544
+ 'מיקום ממיר וסוללה', [
545
+ ('• קיר מוצל — הגנה משמש ישירה', 12, DARK_TEXT),
546
+ ('• גובה מינימלי 60 ס"מ מהקרקע', 12, DARK_TEXT),
547
+ ('• מרווח אוורור: 30 ס"מ מצדדים, 50 ס"מ מלמעלה', 12, DARK_TEXT),
548
+ ('• נגיש לתחזוקה', 12, DARK_TEXT),
549
+ ('• IP66 — מתאים לחוץ', 12, GREEN),
550
+ ], accent=BLUE)
551
+
552
+ add_card(slide, Inches(6.8), Inches(1.2), card_w, card_h,
553
+ 'הארקה', [
554
+ ('• גוף ממיר + גוף סוללה', 12, DARK_TEXT),
555
+ ('• מבנה קונסטרוקציה', 12, DARK_TEXT),
556
+ ('• מסגרות פאנלים', 12, DARK_TEXT),
557
+ ('• חתך מינימלי: 16mm² נחושת', 12, DARK_TEXT),
558
+ ('• פס הארקה → אלקטרודה', 12, DARK_TEXT),
559
+ ], accent=GREEN)
560
+
561
+ add_card(slide, Inches(0.5), Inches(4.0), card_w, card_h,
562
+ 'חיבור סוללה', [
563
+ ('• כבל DC + כבל CAN מגיעים עם הציוד', 12, DARK_TEXT),
564
+ ('• כבל CAN מלופף מראש בטבעת מגנטית', 12, DARK_TEXT),
565
+ ('• כבל DC — קצר מדי ללפף (נקודה פתוחה)', 12, ORANGE, True),
566
+ ('• מומנט הידוק: 24.5 N·m', 12, DARK_TEXT),
567
+ ('• לוודא קוטביות לפני חיבור!', 12, RED, True),
568
+ ], accent=TEAL)
569
+
570
+ add_card(slide, Inches(6.8), Inches(4.0), card_w, card_h,
571
+ 'אחריות ושירות', [
572
+ ('• Solis — RCS Solar בע"מ', 12, DARK_TEXT),
573
+ ('• CNTE/Yoshopo — RCS Solar בע"מ', 12, DARK_TEXT),
574
+ ('• 10 שנים על ממיר וסוללה', 12, DARK_TEXT),
575
+ ('• התקנת פאנלים — באחריות הלקוח', 12, DARK_TEXT),
576
+ ], accent=GOLD)
577
+
578
+ # ═══════════════════════════════════════════════════════════
579
+ # SLIDE 7 — QA Open Items
580
+ # ═══════════════════════════════════════════════════════════
581
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
582
+ set_slide_bg(slide, LIGHT_BG)
583
+
584
+ add_text_box(slide, Inches(0.5), Inches(0.3), Inches(12), Inches(0.7),
585
+ '5. בקרת איכות — נקודות פתוחות', font_size=28, color=ORANGE, bold=True)
586
+ add_shape(slide, Inches(0.5), Inches(0.95), Inches(2), Pt(3), fill_color=ORANGE, shape_type=MSO_SHAPE.RECTANGLE)
587
+
588
+ open_items = [
589
+ ('Q1', 'RCD (פחת)', 'המתקין: לא שמים כרגע. יוסיפו RCBO אם הבודק ידרוש', 'נדחה לבדיקה. מומלץ מראש'),
590
+ ('Q2', 'AFCI', 'מובנה בסוליס בצד DC, דורש הפעלה ידנית', 'לוודא activation בהתקנה'),
591
+ ('Q3', 'SPD DC חסר', 'SPD בצד AC בלבד. פאנלים חשופים לברק בצד DC', 'לשקול SPD DC 1000Vdc'),
592
+ ('Q4', 'Ferrite לכבל סוללה', 'כבל DC קצר מדי ללפף. CAN מלופף מראש', 'לברר מול CNTE — נקודה פתוחה'),
593
+ ]
594
+
595
+ add_table_slide(slide, Inches(0.5), Inches(1.2), Inches(12.3), 34,
596
+ ['#', 'ממצא', 'מצב', 'פעולה נדרשת'],
597
+ open_items, highlight_rows=[0, 1, 2, 3],
598
+ col_ratios=[0.5, 1.5, 4, 3])
599
+
600
+ # ═══════════════════════════════════════════════════════════
601
+ # SLIDE 8 — QA Verified
602
+ # ═══════════════════════════════════════════════════════════
603
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
604
+ set_slide_bg(slide, LIGHT_BG)
605
+
606
+ add_text_box(slide, Inches(0.5), Inches(0.3), Inches(12), Inches(0.7),
607
+ '5. בקרת איכות — פריטים תקינים', font_size=28, color=GREEN, bold=True)
608
+ add_shape(slide, Inches(0.5), Inches(0.95), Inches(2), Pt(3), fill_color=GREEN, shape_type=MSO_SHAPE.RECTANGLE)
609
+
610
+ verified = [
611
+ ('טופולוגיה', 'סדרתית (Series) — אין CT. C40 מספיק', 'אושר ע"י מתקין ✓'),
612
+ ('Voc סטרינג', '396V < 1000V, בתוך MPPT 200–850V', '✓ תקין'),
613
+ ('Isc סטרינג', '~18A < 20A מקסימום', '✓ תקין'),
614
+ ('התאמת סוללה', 'CNTE HV 120–800V = סוליס 120–800V', '✓ תקין'),
615
+ ('זרם סוללה', '50A = סוליס 50A max', '✓ תקין'),
616
+ ('IP Rating', 'ממיר IP66 + סוללה IP66', '✓ תקין'),
617
+ ('תקשורת BMS', 'CAN/RS485 תואם', '✓ תקין'),
618
+ ('DC Switch מובנה', 'ממיר + סוללה — לא צריך חיצוני', 'אושר ע"י מתקין ✓'),
619
+ ('מפסקי C40', 'בסדרתי הממיר לא מושך מעבר לצריכה (3x40A)', 'אושר ע"י מתקין ✓'),
620
+ ('קטבים', 'יציאת ממיר: 4P. אחרי בורר: 3P. Backup: 3P', 'אושר ע"י מתקין ✓'),
621
+ ('Zero Export', 'בסדרתי — אוטומטי, ללא CT', 'אושר ע"י מתקין ✓'),
622
+ ('טעינת סוללה', 'מסולארי בלבד — לא מהרשת', 'אושר ע"י מתקין ✓'),
623
+ ]
624
+
625
+ add_table_slide(slide, Inches(0.5), Inches(1.2), Inches(12.3), 28,
626
+ ['פריט', 'בדיקה', 'תוצאה'],
627
+ verified, col_ratios=[1.5, 4, 2])
628
+
629
+ # ═══════════════════════════════════════════════════════════
630
+ # SLIDE 9 — Execution Phase A+B
631
+ # ═══════════════════════════════════════════════════════════
632
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
633
+ set_slide_bg(slide, LIGHT_BG)
634
+
635
+ section_header(slide, '6. הנחיות ביצוע — שלב א\'+ב\'')
636
+
637
+ # Warning
638
+ warn = add_shape(slide, Inches(0.5), Inches(1.1), Inches(12.3), Inches(0.5), fill_color=LIGHT_RED)
639
+ add_text_box(slide, Inches(0.7), Inches(1.15), Inches(12), Inches(0.4),
640
+ 'כל העבודה החשמלית חייבת להתבצע על ידי חשמלאי מוסמך. עבודה על DC גבוה (עד 800V) מסוכנת!',
641
+ font_size=12, color=RED, bold=True)
642
+
643
+ add_text_box(slide, Inches(0.5), Inches(1.7), Inches(6), Inches(0.4),
644
+ 'שלב א\' — הכנה ורכש', font_size=16, color=NAVY, bold=True)
645
+
646
+ add_checklist_col(slide, Inches(0.7), Inches(2.1), [
647
+ ('☐ אישור תכנית מול חשמלאי', 'התאמה לתקנות ולדרישות היישוב'),
648
+ ('☐ רכש רכיבים חסרים', 'ABB C40 4P x1, ABB C40 3P x3, SPD AC Type 2, Hager 4P 40A'),
649
+ ('☐ רכש כבלים', 'H1Z2Z2-K 6mm², NYY-J 5G10, הארקה 16mm². DC+CAN מגיעים עם הציוד'),
650
+ ('☐ סימון מיקום', 'קיר מוצל, גובה 60+, אוורור 30/50 ס"מ, נגיש לתחזוקה'),
651
+ ])
652
+
653
+ add_text_box(slide, Inches(6.8), Inches(1.7), Inches(6), Inches(0.4),
654
+ 'שלב ב\' — התקנה מכנית', font_size=16, color=NAVY, bold=True)
655
+
656
+ add_checklist_col(slide, Inches(7), Inches(2.1), [
657
+ ('☐ קונסטרוקציה על הגג', 'מסילות, יציבות רוח, שיפוע, אטימה'),
658
+ ('☐ הרכבת 18 פאנלים', '2 סטרינגים x 9, חיבור טורי, הארקת מסגרות'),
659
+ ('☐ התקנת ממיר', 'Solis S6-EH3P20K-H — תלייה + מפלס'),
660
+ ('☐ התקנת סוללה', 'CNTE 18.8kWh — חיזוק קיר (~100 ק"ג)'),
661
+ ('☐ לוח מפסקים', 'מפסקים, SPD, בורר מצבים. סימון ברור'),
662
+ ])
663
+
664
+ # ═══════════════════════════════════════════════════════════
665
+ # SLIDE 10 — Execution Phase C+D
666
+ # ═══════════════════════════════════════════════════════════
667
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
668
+ set_slide_bg(slide, LIGHT_BG)
669
+
670
+ section_header(slide, '6. הנחיות ביצוע — שלב ג\'+ד\'')
671
+
672
+ add_text_box(slide, Inches(0.5), Inches(1.1), Inches(6), Inches(0.4),
673
+ 'שלב ג\' — כבילה וחיבורים', font_size=16, color=NAVY, bold=True)
674
+
675
+ add_checklist_col(slide, Inches(0.7), Inches(1.5), [
676
+ ('☐ DC פאנלים → ממיר', 'H1Z2Z2-K 6mm², MC4. ישירות לממיר. לוודא קוטביות!'),
677
+ ('☐ DC סוללה', 'כבל מגיע עם הציוד. מומנט 24.5 N·m. בדיקת קוטביות!'),
678
+ ('☐ BMS/CAN', 'מגיע מלופף מראש בטבעת מגנטית'),
679
+ ('☐ AC Grid', 'NYY-J 5G10 → SPD → MCB C40 4P → בורר → התפצלות'),
680
+ ('☐ AC Backup', 'NYY-J 5G10 → MCB C40 3P → עומסים חיוניים'),
681
+ ('☐ חיבור רשת', 'בורר → MCB C40 3P → מונה → רשת'),
682
+ ('☐ הארקה', '16mm² Cu: ממיר+סוללה+מבנה+פאנלים → אלקטרודה'),
683
+ ])
684
+
685
+ add_text_box(slide, Inches(6.8), Inches(1.1), Inches(6), Inches(0.4),
686
+ 'שלב ד\' — בדיקות לפני הפעלה', font_size=16, color=NAVY, bold=True)
687
+
688
+ add_checklist_col(slide, Inches(7), Inches(1.5), [
689
+ ('☐ Voc סטרינג', '~396V ±5%, הפרש <5% בין סטרינגים'),
690
+ ('☐ Isc סטרינג', '~18A בשמש מלאה, מודד DC rated'),
691
+ ('☐ קוטביות DC', '+ ל-+, – ל-–. חיבור הפוך = הרס ממיר!'),
692
+ ('☐ בידוד DC', 'מגר: >1MΩ בין +/– לאדמה'),
693
+ ('☐ רציפות הארקה', '<0.5Ω מכל מסגרת לפס הארקה'),
694
+ ('☐ מתח סוללה', '120–800V, SOC מינימלי ~20%'),
695
+ ('☐ תקשורת BMS', 'ממיר מזהה סוללה, SOC/מתח/טמפ\''),
696
+ ('☐ מפסקים', 'הפעלה/ניתוק ידני של כל מפסק'),
697
+ ('☐ מתח AC', '~400V בין פאזות, ~230V פאזה-נייטרל'),
698
+ ])
699
+
700
+ # ═══════════════════════════════════════════════════════════
701
+ # SLIDE 11 — Execution Phase E+F
702
+ # ═══════════════════════════════════════════════════════════
703
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
704
+ set_slide_bg(slide, LIGHT_BG)
705
+
706
+ section_header(slide, '6. הנחיות ביצוע — שלב ה\'+ו\'')
707
+
708
+ add_text_box(slide, Inches(0.5), Inches(1.1), Inches(6), Inches(0.4),
709
+ 'שלב ה\' — הפעלה ראשונה (Commissioning)', font_size=16, color=NAVY, bold=True)
710
+
711
+ add_checklist_col(slide, Inches(0.7), Inches(1.5), [
712
+ ('☐ הפעלה לפי סדר', '1. DC switch סוללה → 2. DC switch ממיר (PV) → 3. AC → 4. ממיר'),
713
+ ('☐ הגדרות ממיר', 'BT+APP: סוג סוללה (Lithium/CAN), מצב: Zero Export, תדר, מתח'),
714
+ ('☐ הפעלת AFCI', 'activation required — הגנת קשת DC'),
715
+ ('☐ הגדרת Zero Export', 'וידוא שאין ייצוא לרשת. לוודא שממיר מוריד ייצור כשאין צריכה'),
716
+ ('☐ בדיקת Backup', 'ניתוק רשת → עומסים חיוניים עובדים <10ms'),
717
+ ('☐ WiFi/Ethernet', 'חיבור לניטור — SolisCloud'),
718
+ ('☐ ניטור 24 שעות', 'ייצור, טעינה, צריכה, ייבוא. וידוא Zero Export!'),
719
+ ])
720
+
721
+ add_text_box(slide, Inches(6.8), Inches(1.1), Inches(6), Inches(0.4),
722
+ 'שלב ו\' — סימון ותיעוד', font_size=16, color=NAVY, bold=True)
723
+
724
+ add_checklist_col(slide, Inches(7), Inches(1.5), [
725
+ ('☐ סימון מפסקים', '"PV 1", "PV 2", "סוללה", "AC", "רשת", "עומסים", "Backup"'),
726
+ ('☐ שלט כיבוי חירום', '"1. כבה DC switch על ממיר וסוללה 2. נתק AC"'),
727
+ ('☐ תיעוד', 'תכנית, אישורים, סריאליים, תמונות'),
728
+ ('☐ מסירה ללקוח', 'הדרכה: אפליקציה, מצבים, חירום, תחזוקה'),
729
+ ])
730
+
731
+ # Key differences box
732
+ box = add_shape(slide, Inches(6.8), Inches(3.4), Inches(5.9), Inches(2.8), fill_color=WHITE, line_color=NAVY)
733
+ add_multiline_box(slide, Inches(7), Inches(3.5), Inches(5.5), Inches(2.6), [
734
+ ('הבדלים מגרסה קודמת — כיבוי חירום:', 14, NAVY, True),
735
+ ('', 6),
736
+ ('הפעלה/כיבוי דרך DC switch מובנה', 13, DARK_TEXT, True),
737
+ ('(לא DC Isolators חיצוניים)', 12, GRAY),
738
+ ('', 6),
739
+ ('1. כבה DC switch על הממיר', 13, RED, True),
740
+ ('2. כבה DC switch על הסוללה', 13, RED, True),
741
+ ('3. נתק מפסק AC', 13, RED, True),
742
+ ('', 6),
743
+ ('⚠ פאנלים ממשיכים לייצר מתח (~396V)', 12, RED),
744
+ ('כל עוד יש אור — לא לגעת ב-MC4!', 12, RED, True),
745
+ ])
746
+
747
+ # ═══════════════════════════════════════════════════════════
748
+ # SLIDE 12 — Closing
749
+ # ═══════════════════════════════════════════════════════════
750
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
751
+ set_slide_bg(slide, NAVY)
752
+
753
+ add_shape(slide, Inches(5.5), Inches(3.3), Inches(2.5), Pt(3), fill_color=GOLD, shape_type=MSO_SHAPE.RECTANGLE)
754
+
755
+ add_text_box(slide, Inches(1), Inches(2), Inches(11), Inches(1),
756
+ 'תכנית חשמלית — חוות יאיר', font_size=36, color=WHITE, bold=True, alignment=PP_ALIGN.CENTER)
757
+ add_text_box(slide, Inches(1), Inches(2.8), Inches(11), Inches(0.5),
758
+ 'מערכת סולארית היברידית | 11.16kWp | 18.8kWh | Zero Export', font_size=20, color=RGBColor(0xA8, 0xD8, 0xEA), alignment=PP_ALIGN.CENTER)
759
+
760
+ add_multiline_box(slide, Inches(1), Inches(3.8), Inches(11), Inches(2), [
761
+ ('טופולוגיה סדרתית | ללא CT | DC switch מובנה | C40', 14, GOLD, True, PP_ALIGN.CENTER),
762
+ ('', 10),
763
+ ('מסמך זה הוכן לצרכי תכנון בלבד', 14, RGBColor(0xCD, 0xDE, 0xEE), False, PP_ALIGN.CENTER),
764
+ ('ואינו מהווה תכנית חשמלית רשמית.', 14, RGBColor(0xCD, 0xDE, 0xEE), False, PP_ALIGN.CENTER),
765
+ ('יש לוודא עם חשמלאי מוסמך לפני ביצוע.', 14, RGBColor(0xCD, 0xDE, 0xEE), True, PP_ALIGN.CENTER),
766
+ ('', 10),
767
+ ('אלי ספרא | מרץ 2026', 16, GOLD, False, PP_ALIGN.CENTER),
768
+ ])
769
+
770
+ # ── Save ──
771
+ output_path = os.path.expanduser('~/Documents/GitHub/Baseline/תכנית_חשמלית_חוות_יאיר.pptx')
772
+ prs.save(output_path)
773
+ print(f'Saved: {output_path}')
774
+ print(f'Slides: {len(prs.slides)}')
scripts/create_sample_data.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ One-off script: extract Stage 1 columns for growing season (May-Sep)
3
+ from sensors_wide.csv → sensors_wide_sample.csv (~2-3 MB).
4
+ """
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
10
+ if str(PROJECT_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(PROJECT_ROOT))
12
+
13
+ import pandas as pd
14
+
15
+ from config.settings import (
16
+ GROWING_SEASON_MONTHS,
17
+ SENSORS_WIDE_PATH,
18
+ )
19
+ from src.sensor_data_loader import DEFAULT_TIMESTAMP_COL, STAGE1_COLUMNS
20
+
21
+ SAMPLE_PATH = SENSORS_WIDE_PATH.parent / "sensors_wide_sample.csv"
22
+
23
+ # Test plot columns (under-panel sensors) for 3D model validation
24
+ STAGE1_TEST_COLUMNS = [
25
+ "Air2_PAR_test",
26
+ "Air2_leafTemperature_test",
27
+ "Air2_airTemperature_test",
28
+ "Air2_CO2_test",
29
+ "Air2_VPD_test",
30
+ ]
31
+
32
+
33
+ def main():
34
+ if not SENSORS_WIDE_PATH.exists():
35
+ print(f"Full CSV not found: {SENSORS_WIDE_PATH}")
36
+ sys.exit(1)
37
+
38
+ cols = [DEFAULT_TIMESTAMP_COL] + list(STAGE1_COLUMNS) + STAGE1_TEST_COLUMNS
39
+ print(f"Reading {SENSORS_WIDE_PATH} ...")
40
+ df = pd.read_csv(SENSORS_WIDE_PATH, usecols=lambda c: c in cols)
41
+ print(f" Total rows: {len(df)}")
42
+
43
+ df[DEFAULT_TIMESTAMP_COL] = pd.to_datetime(df[DEFAULT_TIMESTAMP_COL], utc=True)
44
+ df = df[df[DEFAULT_TIMESTAMP_COL].dt.month.isin(GROWING_SEASON_MONTHS)]
45
+ df = df.sort_values(DEFAULT_TIMESTAMP_COL).reset_index(drop=True)
46
+ print(f" Growing-season rows (months {GROWING_SEASON_MONTHS}): {len(df)}")
47
+
48
+ df.to_csv(SAMPLE_PATH, index=False)
49
+ size_mb = SAMPLE_PATH.stat().st_size / 1_000_000
50
+ print(f"Saved {SAMPLE_PATH} ({size_mb:.1f} MB)")
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
scripts/download_ims_data.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download IMS weather data for station 43 (Sde Boker) and cache to Data/ims/.
3
+ Resamples 10min to 15min. Use --list-channels to discover channel IDs for RH, Rain, WS, BP.
4
+
5
+ Usage:
6
+ python -m scripts.download_ims_data --list-channels
7
+ python -m scripts.download_ims_data --from 2024-01-01 --to 2024-12-31
8
+ python -m scripts.download_ims_data --years 2
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # Project root on path for config and src
18
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
19
+ if str(PROJECT_ROOT) not in sys.path:
20
+ sys.path.insert(0, str(PROJECT_ROOT))
21
+
22
+ try:
23
+ from dotenv import load_dotenv
24
+ load_dotenv(PROJECT_ROOT / ".env")
25
+ except ImportError:
26
+ pass
27
+
28
+
29
+ def main() -> None:
30
+ parser = argparse.ArgumentParser(
31
+ description="Fetch IMS station 43 data and cache to Data/ims/ (15min resolution)."
32
+ )
33
+ parser.add_argument(
34
+ "--list-channels",
35
+ action="store_true",
36
+ help="List channel IDs and names for station 43 (to find RH, Rain, WS, BP).",
37
+ )
38
+ parser.add_argument(
39
+ "--from",
40
+ dest="from_date",
41
+ metavar="YYYY-MM-DD",
42
+ help="Start date (inclusive).",
43
+ )
44
+ parser.add_argument(
45
+ "--to",
46
+ dest="to_date",
47
+ metavar="YYYY-MM-DD",
48
+ help="End date (inclusive).",
49
+ )
50
+ parser.add_argument(
51
+ "--years",
52
+ type=float,
53
+ default=2,
54
+ metavar="N",
55
+ help="If --from/--to not set, fetch last N years (default: 2).",
56
+ )
57
+ parser.add_argument(
58
+ "--chunk-days",
59
+ type=int,
60
+ default=7,
61
+ metavar="D",
62
+ help="Split range into D-day chunks (default: 7 for better API success). Use 0 to disable chunking.",
63
+ )
64
+ args = parser.parse_args()
65
+
66
+ from src.ims_client import IMSClient
67
+ from config import settings
68
+
69
+ client = IMSClient()
70
+
71
+ if args.list_channels:
72
+ try:
73
+ channels = client.list_channels(settings.IMS_STATION_ID)
74
+ except Exception as e:
75
+ print(f"Error fetching station metadata: {e}", file=sys.stderr)
76
+ sys.exit(1)
77
+ if not channels:
78
+ print("No channels returned. Check API response structure.")
79
+ sys.exit(0)
80
+ print(f"Station {settings.IMS_STATION_ID} channels:")
81
+ print("-" * 60)
82
+ for ch in sorted(channels, key=lambda x: (x.get("channelId") or 0)):
83
+ cid = ch.get("channelId", "?")
84
+ name = ch.get("name", "?")
85
+ units = ch.get("units", "")
86
+ active = ch.get("active", True)
87
+ print(f" {cid:>4} {name:<12} {units:<8} active={active}")
88
+ return
89
+
90
+ # Determine date range
91
+ from datetime import datetime, timedelta, timezone
92
+
93
+ end = datetime.now(timezone.utc).date()
94
+ if args.from_date and args.to_date:
95
+ start = datetime.strptime(args.from_date, "%Y-%m-%d").date()
96
+ end = datetime.strptime(args.to_date, "%Y-%m-%d").date()
97
+ elif args.from_date:
98
+ start = datetime.strptime(args.from_date, "%Y-%m-%d").date()
99
+ else:
100
+ start = end - timedelta(days=int(args.years * 365.25))
101
+
102
+ from_s = start.strftime("%Y-%m-%d")
103
+ to_s = end.strftime("%Y-%m-%d")
104
+ chunk_days = args.chunk_days if args.chunk_days else None
105
+
106
+ print(f"Fetching IMS station {settings.IMS_STATION_ID} from {from_s} to {to_s} (chunk_days={chunk_days})...")
107
+ try:
108
+ df = client.fetch_and_cache(from_s, to_s, chunk_days=chunk_days)
109
+ except Exception as e:
110
+ print(f"Error: {e}", file=sys.stderr)
111
+ sys.exit(1)
112
+
113
+ if df.empty:
114
+ print("No data returned. Check token and date range.")
115
+ sys.exit(1)
116
+
117
+ out_path = settings.IMS_CACHE_DIR / "ims_merged_15min.csv"
118
+ print(f"Saved {len(df)} rows (15min) to {out_path}")
119
+ print(f"Columns: {list(df.columns)}")
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
scripts/eda.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EDA helpers for Streamlit: Stage 1 (sensors + labels) and Stage 2 (IMS + merged).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
11
+ if str(PROJECT_ROOT) not in sys.path:
12
+ sys.path.insert(0, str(PROJECT_ROOT))
13
+
14
+ import pandas as pd
15
+ import numpy as np
16
+
17
+
18
+ def get_stage1_eda():
19
+ """Load Stage 1 data and return dict with summary, labels df, optional sensor sample for plots."""
20
+ from config import settings
21
+
22
+ out = {"labels": None, "labels_stats": None, "sensor_sample": None, "error": None}
23
+ labels_path = settings.PROCESSED_DIR / "stage1_labels.csv"
24
+ if not labels_path.exists():
25
+ out["error"] = "Stage 1 labels not found. Run Stage 1 first."
26
+ return out
27
+ labels = pd.read_csv(labels_path, index_col=0, parse_dates=True)
28
+ labels.index = pd.to_datetime(labels.index, utc=True)
29
+ out["labels"] = labels
30
+ out["labels_stats"] = {
31
+ "count": len(labels),
32
+ "date_min": labels.index.min(),
33
+ "date_max": labels.index.max(),
34
+ "A_mean": labels.iloc[:, 0].mean(),
35
+ "A_std": labels.iloc[:, 0].std(),
36
+ "A_min": labels.iloc[:, 0].min(),
37
+ "A_max": labels.iloc[:, 0].max(),
38
+ }
39
+ # Optional: load a sample of sensor data for PAR/T (limit rows for speed)
40
+ sensor_path = settings.SENSORS_WIDE_PATH
41
+ if not sensor_path.exists():
42
+ sensor_path = settings.SENSORS_WIDE_SAMPLE_PATH
43
+ if sensor_path.exists():
44
+ try:
45
+ cols = ["time", "Air1_PAR_ref", "Air1_leafTemperature_ref", "Air1_airTemperature_ref", "Air1_CO2_ref", "Air1_VPD_ref"]
46
+ sensor = pd.read_csv(sensor_path, usecols=lambda c: c in cols, nrows=50000)
47
+ if "time" in sensor.columns:
48
+ sensor["time"] = pd.to_datetime(sensor["time"], utc=True)
49
+ sensor = sensor[sensor["Air1_PAR_ref"] > 50]
50
+ out["sensor_sample"] = sensor
51
+ except Exception:
52
+ out["sensor_sample"] = None
53
+ return out
54
+
55
+
56
+ def get_stage2_eda():
57
+ """Load IMS + labels, merge, return merged df and summary for EDA."""
58
+ from config import settings
59
+ from src.ims_client import IMSClient
60
+ from src.preprocessor import Preprocessor
61
+
62
+ out = {"merged": None, "ims": None, "labels": None, "stats": None, "error": None}
63
+ labels_path = settings.PROCESSED_DIR / "stage1_labels.csv"
64
+ if not labels_path.exists():
65
+ out["error"] = "Stage 1 labels not found."
66
+ return out
67
+ labels = pd.read_csv(labels_path, index_col=0, parse_dates=True)
68
+ labels.index = pd.to_datetime(labels.index, utc=True)
69
+ labels = labels.iloc[:, 0]
70
+ client = IMSClient()
71
+ ims = client.load_cached()
72
+ if ims.empty:
73
+ out["error"] = "IMS cache not found. Run download_ims_data first."
74
+ return out
75
+ preproc = Preprocessor()
76
+ merged = preproc.merge_ims_with_labels(ims, labels, timestamp_index_labels=True)
77
+ if merged.empty:
78
+ out["error"] = "No overlap between IMS and labels."
79
+ return out
80
+ merged = preproc.create_time_features(merged)
81
+ out["merged"] = merged
82
+ out["ims"] = ims
83
+ out["labels"] = labels
84
+ out["stats"] = {
85
+ "ims_rows": len(ims),
86
+ "ims_date_min": pd.to_datetime(ims["timestamp_utc"]).min(),
87
+ "ims_date_max": pd.to_datetime(ims["timestamp_utc"]).max(),
88
+ "merged_rows": len(merged),
89
+ "feature_cols": [c for c in merged.select_dtypes(include=[np.number]).columns if c not in ("A",)],
90
+ }
91
+ return out
scripts/html_to_docx.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert the solar system electrical plan HTML to a Word document."""
2
+ from docx import Document
3
+ from docx.shared import Pt, Inches, Cm, RGBColor
4
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
5
+ from docx.enum.table import WD_TABLE_ALIGNMENT
6
+ from docx.oxml.ns import qn
7
+ from docx.oxml import OxmlElement
8
+ import os
9
+
10
+ doc = Document()
11
+
12
+ # ── Helpers ──────────────────────────────────────────────
13
+ def set_rtl(paragraph):
14
+ """Set paragraph to RTL."""
15
+ pPr = paragraph._p.get_or_add_pPr()
16
+ bidi = OxmlElement('w:bidi')
17
+ bidi.set(qn('w:val'), '1')
18
+ pPr.append(bidi)
19
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
20
+
21
+ def set_cell_rtl(cell):
22
+ for p in cell.paragraphs:
23
+ set_rtl(p)
24
+
25
+ def add_heading_rtl(text, level=1):
26
+ h = doc.add_heading(text, level=level)
27
+ set_rtl(h)
28
+ return h
29
+
30
+ def add_para_rtl(text, bold=False, size=None, color=None):
31
+ p = doc.add_paragraph()
32
+ set_rtl(p)
33
+ run = p.add_run(text)
34
+ if bold:
35
+ run.bold = True
36
+ if size:
37
+ run.font.size = Pt(size)
38
+ if color:
39
+ run.font.color.rgb = color
40
+ return p
41
+
42
+ def add_table_rtl(rows, cols, header_row=None):
43
+ table = doc.add_table(rows=rows, cols=cols, style='Table Grid')
44
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
45
+ for row in table.rows:
46
+ for cell in row.cells:
47
+ set_cell_rtl(cell)
48
+ return table
49
+
50
+ def shade_cells(row, color_hex):
51
+ """Shade all cells in a row."""
52
+ for cell in row.cells:
53
+ shading = OxmlElement('w:shd')
54
+ shading.set(qn('w:fill'), color_hex)
55
+ shading.set(qn('w:val'), 'clear')
56
+ cell._tc.get_or_add_tcPr().append(shading)
57
+
58
+ def bold_cell(cell, text):
59
+ cell.text = ''
60
+ p = cell.paragraphs[0]
61
+ set_rtl(p)
62
+ run = p.add_run(text)
63
+ run.bold = True
64
+
65
+ # ── Set default font ──────────────────────────────────────
66
+ style = doc.styles['Normal']
67
+ font = style.font
68
+ font.name = 'Arial'
69
+ font.size = Pt(11)
70
+
71
+ # ═══════════ HEADER ═══════════
72
+ add_heading_rtl('תכנית חשמלית — מערכת סולארית היברידית', level=0)
73
+ add_para_rtl('חוות יאיר — אלי ספרא', bold=True, size=16)
74
+ add_para_rtl('מרץ 2026 | ממיר: Solis S6-EH3P20K-H | סוללה: CNTE 18.8kWh HV | פאנלים: 18x620W = 11.16kWp | הצעה: עוצם (אנטמן) #4027', size=10, color=RGBColor(0x55, 0x55, 0x55))
75
+
76
+ # ═══════════ WARNINGS ═══════════
77
+ add_heading_rtl('אזהרות קריטיות — חובה לטפל לפני ביצוע', level=1)
78
+ warnings = [
79
+ 'מפסק C40 קטן מדי לצד הרשת: הסוליס 20K מושך עד 45.6A מהרשת. מפסק C40 (40A) יקפוץ בעומס מלא. נדרש לפחות C50 בצד הרשת (Grid side).',
80
+ 'בורר מצבים Hager 40A: גם הבורר צריך להתאים לזרם המקסימלי — 40A עלול להיות צר מדי. יש לשקול 63A.',
81
+ 'DC Isolators חובה: מנתקי DC ייעודיים (לא MCB של AC). בצד PV: מדורג 1000Vdc/32A. בצד סוללה: מדורג 800Vdc/63A.',
82
+ 'מערך PV קטן ביחס לממיר: 11.16kWp הם רק 35% מהמומלץ ל-20K (32kWp). הממיר יעבוד, אבל לא ינצל את מלוא הפוטנציאל שלו.',
83
+ 'ההצעה לא כוללת התקנת פאנלים על הגג — באחריות הלקוח. נדרש קונסטרוקציה וביסוס.',
84
+ ]
85
+ for w in warnings:
86
+ add_para_rtl(f'⚠ {w}')
87
+
88
+ # ═══════════ 1. SYSTEM OVERVIEW ═══════════
89
+ add_heading_rtl('1. סקירת המערכת', level=1)
90
+ add_para_rtl('מערכת סולארית היברידית תלת-פאזית עם גיבוי סוללה, המאפשרת צריכה עצמית, אגירת אנרגיה ומעבר אוטומטי למצב גיבוי (UPS) תוך פחות מ-10ms בעת הפסקת חשמל.')
91
+
92
+ # Inverter
93
+ add_heading_rtl('ממיר היברידי — Solis S6-EH3P20K-H', level=2)
94
+ add_para_rtl(
95
+ 'הספק: 20kW תלת-פאזי\n'
96
+ 'מתח מצבר: 120–800V (High Voltage)\n'
97
+ 'MPPT: 4 כניסות, 200–850V, עד 20A לכניסה\n'
98
+ 'PV מומלץ: עד 32kWp\n'
99
+ 'יעילות: 98.5% מקסימום, 97.5% EU\n'
100
+ 'Backup: 20kW, 200% surge ל-10 שניות\n'
101
+ 'הגנת כניסה: IP66\n'
102
+ 'אחריות: 10 שנים (RCS Solar בע"מ)\n'
103
+ 'מחיר: ₪9,360 (לפני מע"מ)'
104
+ )
105
+
106
+ # Battery
107
+ add_heading_rtl('מערכת אגירה — CNTE 18.8kWh HV', level=2)
108
+ add_para_rtl(
109
+ 'קיבולת: 18.8kWh\n'
110
+ 'סוג: LiFePO4 — High Voltage\n'
111
+ 'מתח נומינלי: טווח 120–800V\n'
112
+ 'זרם טעינה/פריקה מקסימלי: 50A\n'
113
+ 'הגנת כניסה: IP66 — מתאים לחוץ\n'
114
+ 'תקשורת: CAN / RS485 אל הממיר\n'
115
+ 'אחריות: 10 שנים\n'
116
+ 'מחיר: ₪15,080 (לפני מע"מ)'
117
+ )
118
+
119
+ # Panels
120
+ add_heading_rtl('פאנלים סולאריים — 18 x 620W', level=2)
121
+ add_para_rtl(
122
+ 'הספק כולל: 11,160Wp (11.16kWp)\n'
123
+ 'חלוקה: 2 סטרינגים x 9 פאנלים\n'
124
+ 'Voc לסטרינג: ~396V (בטווח MPPT)\n'
125
+ 'Isc לסטרינג: ~18A (מתחת ל-20A מקסימום)\n'
126
+ 'חיבור: MC4\n'
127
+ 'מחיר ליחידה: ₪345\n'
128
+ 'מחיר כולל: ₪6,210 (לפני מע"מ)'
129
+ )
130
+
131
+ # ═══════════ 2. SINGLE LINE DIAGRAM ═══════════
132
+ add_heading_rtl('2. תרשים חד-קווי (Single Line Diagram)', level=1)
133
+ add_para_rtl('מבנה המערכת מהפאנלים ועד ללוח החשמלי:')
134
+ add_para_rtl('[התרשים הגרפי SVG לא ניתן להמרה ל-Word — ראה קובץ HTML המקורי לתרשים המלא]', size=10, color=RGBColor(0x99, 0x99, 0x99))
135
+ add_para_rtl(
136
+ 'String 1: 9x620W = 5,580Wp (Voc≈396V, Isc≈18A, MPPT1)\n'
137
+ 'String 2: 9x620W = 5,580Wp (Voc≈396V, Isc≈18A, MPPT2)\n'
138
+ '↓ DC Isolator 1000Vdc/32A (x2)\n'
139
+ '↓ DC PV Input\n'
140
+ '→ Solis S6-EH3P20K-H (Hybrid Inverter, 20kW, 3φ, IP66)\n'
141
+ ' ← DC HV → DC Isolator 800Vdc/63A → CNTE 18.8kWh Battery (CAN/BMS)\n'
142
+ ' → Backup AC (UPS <10ms) → ABB C40 3P → עומסים חיוניים\n'
143
+ '↓ AC Grid Out\n'
144
+ '→ CT Sensor → SPD Type 2 (3P+N, 40kA) → MCB ABB C50 4P\n'
145
+ '→ Hager 4P 40A (בורר מצבים)\n'
146
+ ' ├→ MCB ABB C50 4P (Grid) → מונה יישוב → רשת חשמל → הארקה\n'
147
+ ' └→ MCB ABB C40 4P (Load) → לוח חשמלי (עומסי הבית)'
148
+ )
149
+
150
+ # ═══════════ 3. COMPONENT LIST ═══════════
151
+ add_heading_rtl('3. רשימת רכיבים מפורטת', level=1)
152
+
153
+ components = [
154
+ ('1', 'פאנלים סולאריים', '620W — מק"ט s620', '18', 'גג', '2 סטרינגים x 9'),
155
+ ('2', 'DC Isolator — PV', '1000Vdc / 32A, 2-pole', '2', 'ליד הפאנלים / כניסת ממיר', 'אחד לכל סטרינג'),
156
+ ('3', 'ממיר היברידי', 'Solis S6-EH3P20K-H', '1', 'קיר חיצוני מוצל', 'IP66 — מוגן מגשם. להגן משמש ישירה'),
157
+ ('4', 'DC Isolator — סוללה', '800Vdc / 63A, 2-pole', '1', 'בין ממיר לסוללה', 'מדורג DC!'),
158
+ ('5', 'סוללה', 'CNTE 18.8kWh HV (cnte18.8)', '1', 'ליד הממיר, קיר מוצל', 'IP66 — חיצוני. תקשורת CAN'),
159
+ ('6', 'חיישן זרם CT', 'Split-core CT, 3-פאזי', '1', 'ביציאת AC של הממיר', 'לניטור ו-zero export'),
160
+ ('7', 'מגן ברקים SPD', 'Type 2, 3P+N, 40kA', '1', 'אחרי CT, לפני מפסק ראשי', 'הגנה על קו יציאת ממיר'),
161
+ ('8', 'מפסק יציאת ממיר', 'ABB C50 4-Pole ⚠', '1', 'אחרי SPD', 'מקורי בהצעה C40 — לא מספיק!'),
162
+ ('9', 'בורר מצבים', 'Hager 4P 40A', '1', 'לפני ההתפצלות', 'Grid / Solar / Off'),
163
+ ('10', 'מפסק רשת', 'ABB C50 4-Pole ⚠', '1', 'ענף רשת', 'צריך C50 לזרם 45.6A'),
164
+ ('11', 'מפסק עומסים', 'ABB C40 4-Pole', '1', 'ענף עומסי הבית', 'C40 מספיק כאן'),
165
+ ('12', 'מפסק גיבוי', 'ABB C40 3-Pole', '1', 'יציאת Backup של ממיר', '3P — עומסים חיוניים בלבד'),
166
+ ('13', 'מונה יישוב', 'מונה דו-כיווני (מסופק ע"י היישוב)', '1', 'לפני הרשת', '—'),
167
+ ]
168
+
169
+ table = add_table_rtl(len(components) + 1, 6)
170
+ headers = ['#', 'רכיב', 'דגם / מפרט', 'כמות', 'מיקום בתרשים', 'הערות']
171
+ for i, h in enumerate(headers):
172
+ bold_cell(table.rows[0].cells[i], h)
173
+ shade_cells(table.rows[0], '0C2340')
174
+ for cell in table.rows[0].cells:
175
+ for p in cell.paragraphs:
176
+ for run in p.runs:
177
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
178
+
179
+ for row_idx, comp in enumerate(components):
180
+ for col_idx, val in enumerate(comp):
181
+ table.rows[row_idx + 1].cells[col_idx].text = val
182
+ set_cell_rtl(table.rows[row_idx + 1].cells[col_idx])
183
+
184
+ # ═══════════ 4. CABLE SPEC ═══════════
185
+ add_heading_rtl('4. מפרט כבילה', level=1)
186
+
187
+ cables = [
188
+ ('PV String → DC Isolator', 'כבל סולארי H1Z2Z2-K', '6mm² (או 4mm²)', 'לפי מיקום גג', 'UV-resistant, DC rated, MC4'),
189
+ ('DC Isolator → Inverter (PV)', 'כבל סולארי H1Z2Z2-K', '6mm²', '~5m', '+ / – לכל סטרינג'),
190
+ ('Inverter ↔ Battery', 'כבל DC גמיש', '16mm²', '~3m', '50A מקסימום. דרך טבעת מגנטית x 2 ליפופים'),
191
+ ('Inverter → CT → SPD → MCB', 'כבל AC NYY-J 5G', '10mm²', '~5m', '3L + N + PE, תלת-פאזי'),
192
+ ('MCB → Changeover → Grid/Load', 'כבל AC NYY-J 5G', '10mm²', '~10m', 'לפי מרחק ללוח'),
193
+ ('Backup Output → MCB → Essential', 'כבל AC NYY-J 5G', '6mm²', '~8m', '3P ללוח עומסים חיוניים'),
194
+ ('הארקה', 'ירוק-צהוב', '16mm² (נחושת)', '—', 'חובה — ממיר + סוללה + מבנה'),
195
+ ('BMS Communication', 'כבל CAN מסוכך', 'שזור', '~3m', 'דרך טבעת מגנטית x 4 ליפופים'),
196
+ ]
197
+
198
+ table = add_table_rtl(len(cables) + 1, 5)
199
+ cable_headers = ['קטע', 'סוג כבל', 'חתך', 'אורך מוערך', 'הערות']
200
+ for i, h in enumerate(cable_headers):
201
+ bold_cell(table.rows[0].cells[i], h)
202
+ shade_cells(table.rows[0], '0C2340')
203
+ for cell in table.rows[0].cells:
204
+ for p in cell.paragraphs:
205
+ for run in p.runs:
206
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
207
+
208
+ for row_idx, cable in enumerate(cables):
209
+ for col_idx, val in enumerate(cable):
210
+ table.rows[row_idx + 1].cells[col_idx].text = val
211
+ set_cell_rtl(table.rows[row_idx + 1].cells[col_idx])
212
+
213
+ add_para_rtl('שים לב: כבל הסוללה ותקשורת BMS חייבים לעבור דרך טבעות מגנטיות (ferrite rings) כפי שמופיע במפרט הסוליס — 2 ליפופים לכבל חשמל, 4 ליפופים לכבל תקשורת.', bold=True)
214
+
215
+ # ═══════════ 5. FLOW DESCRIPTION ═══════════
216
+ add_heading_rtl('5. תיאור מסלול הזרם', level=1)
217
+
218
+ add_heading_rtl('מצב יום — ייצור סולארי', level=2)
219
+ day_flow = [
220
+ ('☀ פאנלים סולאריים (2 x 9 = 18 פאנלים)', 'מייצרים זרם DC במתח ~360V לכל סטרינג, דרך חיבורי MC4'),
221
+ ('🔌 DC Isolators (2 יח\')', 'מנתקי בטיחות DC — מאפשרים ניתוק כל סטרינג בנפרד לתחזוקה'),
222
+ ('INV — Solis 20K — המרה DC→AC', '4 כניסות MPPT (200-850V). ממיר את הזרם ל-AC תלת-פאזי 400V/50Hz. במקביל טוען את הסוללה מעודפי ייצור'),
223
+ ('CT חיישן זרם', 'מודד את הזרם ביציאה לצורך ניטור, איזון פאזות ומניעת הזרמה לרשת (zero export אם נדרש)'),
224
+ ('⚡ SPD מגן ברקים Type 2', 'הגנת נחשולי מתח (surge) על קו AC היוצא מהממיר. 3P+N, עד 40kA'),
225
+ ('🔄 בורר מצבים Hager 4P 40A', 'מאפשר מעבר ידני בין: רשת + סולארי | סולארי בלבד | ניתוק מלא'),
226
+ ('🏠 לוח חשמלי הבית', 'עומסי הבית מקבלים חשמל מהסולארי. עודפים → סוללה → רשת (לפי הגדרה)'),
227
+ ]
228
+ for title, desc in day_flow:
229
+ p = doc.add_paragraph()
230
+ set_rtl(p)
231
+ run = p.add_run(f'{title}: ')
232
+ run.bold = True
233
+ p.add_run(desc)
234
+
235
+ add_heading_rtl('מצב לילה / הפסקת חשמל', level=2)
236
+ night_flow = [
237
+ ('🔋 CNTE 18.8kWh', 'הסוללה מספקת זרם DC HV לממיר'),
238
+ ('INV — Solis 20K — המרה DC→AC', 'ממיר ל-AC ומספק לעומסים. מעבר UPS אוטומטי תוך <10ms'),
239
+ ('🏠 עומסים חיוניים (Backup port)', 'מקרר, תאורה, ראוטר — ממוזנים ישירות מיציאת ה-Backup ללא הפסקה'),
240
+ ]
241
+ for title, desc in night_flow:
242
+ p = doc.add_paragraph()
243
+ set_rtl(p)
244
+ run = p.add_run(f'{title}: ')
245
+ run.bold = True
246
+ p.add_run(desc)
247
+
248
+ # ═══════════ 6. INVERTER SPECS ═══════════
249
+ add_heading_rtl('6. מפרט ממיר Solis S6-EH3P20K-H', level=1)
250
+
251
+ specs_sections = [
252
+ ('כניסת DC — פוטו-וולטאי', [
253
+ ('PV מומלץ מקסימלי', '32kWp'),
254
+ ('מתח מקסימלי', '1000V'),
255
+ ('טווח MPPT', '200–850V'),
256
+ ('זרם מקסימלי לכניסה', '20A x 4'),
257
+ ('מספר MPPT / סטרינגים', '4/4'),
258
+ ('הספק מקסימלי ל-MPPT', '9kW'),
259
+ ]),
260
+ ('כניסת / יציאת סוללה', [
261
+ ('סוג', 'Li-ion (High Voltage)'),
262
+ ('טווח מתח', '120–800V'),
263
+ ('הספק טעינה/פריקה מקסימלי', '20kW'),
264
+ ('זרם מקסימלי', '50A'),
265
+ ('תקשורת', 'CAN / RS485'),
266
+ ]),
267
+ ('יציאת AC — רשת', [
268
+ ('הספק יציאה', '20kW / 20kVA'),
269
+ ('מתח', '3/N/PE, 380V/400V'),
270
+ ('זרם יציאה מקסימלי', '30.4A / 28.9A'),
271
+ ('Power Factor', '>0.99'),
272
+ ('THDi', '<3%'),
273
+ ]),
274
+ ('כניסת AC — רשת', [
275
+ ('הספק כניסה מקסימלי', '30kW'),
276
+ ('זרם כניסה', '45.6A ⚠'),
277
+ ('מתח', '3/N/PE, 380V/400V'),
278
+ ('תדר', '50Hz / 60Hz'),
279
+ ]),
280
+ ('יציאת Backup', [
281
+ ('הספק', '20kW'),
282
+ ('Surge', '200% למשך 10 שניות (40kW!)'),
283
+ ('זמן מעבר', '<10ms'),
284
+ ('מתח', '3/N/PE, 380V/400V'),
285
+ ]),
286
+ ('כללי', [
287
+ ('יעילות מקסימלית', '98.5%'),
288
+ ('יעילות EU', '97.5%'),
289
+ ('הגנת כניסה', 'IP66'),
290
+ ('טמפרטורת עבודה', '-25°C עד +60°C'),
291
+ ('מידות', '563x546x235 מ"מ'),
292
+ ('קירור', 'מאוורר אינטליגנטי כפול'),
293
+ ]),
294
+ ]
295
+
296
+ for section_title, specs in specs_sections:
297
+ add_heading_rtl(section_title, level=2)
298
+ table = add_table_rtl(len(specs), 2)
299
+ for row_idx, (key, val) in enumerate(specs):
300
+ bold_cell(table.rows[row_idx].cells[0], key)
301
+ table.rows[row_idx].cells[1].text = val
302
+ set_cell_rtl(table.rows[row_idx].cells[1])
303
+
304
+ # ═══════════ 7. PRICING ═══════════
305
+ add_heading_rtl('7. פירוט עלויות — הצעת עוצם (אנטמן) #4027', level=1)
306
+
307
+ pricing = [
308
+ ('ממיר Solis 20kW', 'solis20k', '1', '₪9,360', '₪9,360'),
309
+ ('קיט בטריות CNTE 18.8kWh', 'cnte18.8', '1', '₪15,080', '₪15,080'),
310
+ ('לוח סולארי 620W', 's620', '18', '₪345', '₪6,210'),
311
+ ('התקנה לממיר ומצברים', '—', '1', '₪5,800', '₪5,800'),
312
+ ]
313
+
314
+ table = add_table_rtl(len(pricing) + 4, 5)
315
+ price_headers = ['פריט', 'מק"ט', 'כמות', 'מחיר יחידה', 'סה"כ']
316
+ for i, h in enumerate(price_headers):
317
+ bold_cell(table.rows[0].cells[i], h)
318
+ shade_cells(table.rows[0], '0C2340')
319
+ for cell in table.rows[0].cells:
320
+ for p in cell.paragraphs:
321
+ for run in p.runs:
322
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
323
+
324
+ for row_idx, item in enumerate(pricing):
325
+ for col_idx, val in enumerate(item):
326
+ table.rows[row_idx + 1].cells[col_idx].text = val
327
+ set_cell_rtl(table.rows[row_idx + 1].cells[col_idx])
328
+
329
+ # Subtotal
330
+ subtotal_row = len(pricing) + 1
331
+ bold_cell(table.rows[subtotal_row].cells[0], 'סה"כ לפני מע"מ')
332
+ table.rows[subtotal_row].cells[0].merge(table.rows[subtotal_row].cells[3])
333
+ bold_cell(table.rows[subtotal_row].cells[4], '₪36,450')
334
+ shade_cells(table.rows[subtotal_row], 'F0F4FF')
335
+
336
+ # VAT
337
+ vat_row = subtotal_row + 1
338
+ table.rows[vat_row].cells[0].text = 'מע"מ 18%'
339
+ set_cell_rtl(table.rows[vat_row].cells[0])
340
+ table.rows[vat_row].cells[0].merge(table.rows[vat_row].cells[3])
341
+ table.rows[vat_row].cells[4].text = '₪6,561'
342
+ set_cell_rtl(table.rows[vat_row].cells[4])
343
+
344
+ # Total
345
+ total_row = vat_row + 1
346
+ bold_cell(table.rows[total_row].cells[0], 'סה"כ כולל מע"מ')
347
+ table.rows[total_row].cells[0].merge(table.rows[total_row].cells[3])
348
+ bold_cell(table.rows[total_row].cells[4], '₪43,011')
349
+ shade_cells(table.rows[total_row], '0C2340')
350
+ for cell in [table.rows[total_row].cells[0], table.rows[total_row].cells[4]]:
351
+ for p in cell.paragraphs:
352
+ for run in p.runs:
353
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
354
+ run.font.size = Pt(14)
355
+
356
+ add_para_rtl('לא כלול בהצעה: התקנה פיזית של הפאנלים על הגג (קונסטרוקציה, הגבהות, ביסוס) — באחריות הלקוח או קבלן נוסף.', bold=True, color=RGBColor(0xC6, 0x28, 0x28))
357
+
358
+ # ═══════════ 8. INSTALLATION NOTES ═══════════
359
+ add_heading_rtl('8. הנחיות התקנה', level=1)
360
+
361
+ add_heading_rtl('מיקום ממיר וסוללה', level=2)
362
+ add_para_rtl('שני הרכיבים (Solis + CNTE) בעלי דירוג IP66 ומתאימים להתקנה חיצונית. יש למקם אותם:')
363
+ for item in [
364
+ 'על קיר מוצל — באחריות הלקוח להגן מפני שמש ישירה (כפי שמצוין בהצעה)',
365
+ 'גובה מינימלי 60 ס"מ מהקרקע (הצפה, מזיקים)',
366
+ 'מרווח אוורור: לפחות 30 ס"מ מכל צד, 50 ס"מ מלמעלה',
367
+ 'נגיש לתחזוקה — DC Isolators בהישג יד',
368
+ ]:
369
+ add_para_rtl(f'• {item}')
370
+
371
+ add_heading_rtl('הארקה', level=2)
372
+ add_para_rtl('חובה לבצע הארקה תקנית לכל הרכיבים: גוף הממיר, גוף הסוללה, מבנה הקונסטרוקציה של הפאנלים, ומסגרות הפאנלים עצמם. חתך מינימלי: 16mm² נחושת.')
373
+
374
+ add_heading_rtl('חיבור סוללה', level=2)
375
+ add_para_rtl('כבל DC לסוללה חייב לעבור דרך טבעת מגנטית (ferrite ring) עם 2 ליפופים. כבל תקשורת CAN/BMS — דרך טבעת מגנטית עם 4 ליפופים. מומנט הידוק למחבר: 24.5 N·m.')
376
+
377
+ add_heading_rtl('אחריות ושירות', level=2)
378
+ add_para_rtl('לפי הצעת עוצם (אנטמן):')
379
+ for item in [
380
+ 'Solis — שירות ואחריות: אר סי אס סולאר בע"מ (RCS Solar)',
381
+ 'CNTE / Yoshopo — שירות ואחריות: אר סי אס סולאר בע"מ',
382
+ 'אחריות: 10 שנים על ממיר וסוללה',
383
+ ]:
384
+ add_para_rtl(f'• {item}')
385
+
386
+ # ═══════════ 9. QA REVIEW ═══════════
387
+ add_heading_rtl('9. בקרת איכות — ממצאים ותיקונים', level=1)
388
+ add_para_rtl('סקירה מקצועית של התכנית זיהתה את הנקודות הבאות. יש לטפל בכולן לפני תחילת ביצוע:')
389
+
390
+ # Critical findings
391
+ add_heading_rtl('ממצאים קריטיים (חובה לתקן)', level=2)
392
+
393
+ critical = [
394
+ ('Q1', 'דירוג מפסקי AC', 'Solis 20K — זרם כניסה מרשת עד 45.6A. מפסק C40 (40A) יקפוץ בעומס מלא. הגנה לא תקינה', 'להחליף ל-C50 (50A) בצד יציאת ממיר ובצד הרשת. מפסק עומסים (C40) — תקין'),
395
+ ('Q2', 'בורר מצבים — דירוג זרם', 'Hager 40A עלול להיות קטן מדי. זרם מקסימלי עובר דרכו (עד 45.6A מרשת + ייצור סולארי)', 'לשקול שדרוג ל-Hager 63A 4P, או לוודא שתרחיש העומס המקסימלי לא חורג מ-40A'),
396
+ ('Q3', 'הגנת פחת (RCD/RCBO) חסרה', 'התכנית לא כוללת הגנת פחת — זליגת זרם לגוף אדם לא תגרום לניתוק', 'להוסיף RCD Type A בדירוג 30mA בצד AC, לפני לוח העומסים. חלק מהממירים Transformerless דורשים RCD Type B — לוודא מול מפרט הסוליס'),
397
+ ('Q4', 'מפסק Backup — מספר קטבים', 'מפסק 3P (3 קטבים) לא מגן על Neutral. יציאת Backup של הסוליס היא 3/N/PE', 'להחליף ל-ABB C40 4P (4 קטבים) גם בענף ה-Backup'),
398
+ ]
399
+
400
+ table = add_table_rtl(len(critical) + 1, 4)
401
+ for i, h in enumerate(['#', 'ממצא', 'בעיה', 'תיקון נדרש']):
402
+ bold_cell(table.rows[0].cells[i], h)
403
+ shade_cells(table.rows[0], '0C2340')
404
+ for cell in table.rows[0].cells:
405
+ for p in cell.paragraphs:
406
+ for run in p.runs:
407
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
408
+ for row_idx, item in enumerate(critical):
409
+ for col_idx, val in enumerate(item):
410
+ table.rows[row_idx + 1].cells[col_idx].text = val
411
+ set_cell_rtl(table.rows[row_idx + 1].cells[col_idx])
412
+ shade_cells(table.rows[row_idx + 1], 'FFF8E1')
413
+
414
+ # Important findings
415
+ add_heading_rtl('ממצאים חשובים (מומלץ לתקן)', level=2)
416
+
417
+ important = [
418
+ ('Q5', 'AFCI — הגנת קשת חשמלית', 'הסוליס כולל AFCI מובנה בצד DC, אך דורש הפעלה ידנית (activation required)', 'לוודא שהחשמלאי מפעיל את AFCI בהגדרות הממיר בעת ההתקנה'),
419
+ ('Q6', 'SPD בצד DC חסר', 'התכנית כוללת SPD בצד AC בלבד. פאנלים על הגג חשופים לפגיעת ברק ישירה בצד DC', 'להוסיף SPD Type 2 DC מדורג 1000Vdc בין הפאנלים ל-DC Isolator, או ליד כניסת PV בממיר'),
420
+ ('Q7', 'חתך כבל AC — מרווח צר', 'כבל NYY-J 5G10 מדורג ל-~57A באוויר. עבור 45.6A זהו ניצול של 80% — תקין, אך ללא מרווח לעליית טמפרטורה', 'אם מסלול הכבל חם (צינור בשמש, ריכוז כבלים) — לשקול שדרוג ל-5G16. אחרת, 5G10 מספיק'),
421
+ ('Q8', 'טבעות מגנטיות (Ferrite) חסרות מרשימת רכיבים', 'מוזכרות בהנחיות כבילה אך לא ברשימת הרכיבים — עלולות להישכח ברכש', 'להוסיף לרשימה: 2 טבעות מגנטיות — אחת לכבל DC סוללה, אחת לכבל BMS'),
422
+ ('Q9', 'חסר: הגדרת מצב עבודה — net metering / zero export', 'התכנית לא מציינת אם המערכת עובדת במצב net metering (הזרמה לרשת) או zero export. זה משפיע על תצורת CT', 'להגדיר מול היישוב: האם מותר למכור חשמל לרשת? אם לא — להגדיר CT למצב zero export בממיר'),
423
+ ]
424
+
425
+ table = add_table_rtl(len(important) + 1, 4)
426
+ for i, h in enumerate(['#', 'ממצא', 'בעיה', 'תיקון נדרש']):
427
+ bold_cell(table.rows[0].cells[i], h)
428
+ shade_cells(table.rows[0], '0C2340')
429
+ for cell in table.rows[0].cells:
430
+ for p in cell.paragraphs:
431
+ for run in p.runs:
432
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
433
+ for row_idx, item in enumerate(important):
434
+ for col_idx, val in enumerate(item):
435
+ table.rows[row_idx + 1].cells[col_idx].text = val
436
+ set_cell_rtl(table.rows[row_idx + 1].cells[col_idx])
437
+
438
+ # Verified OK
439
+ add_heading_rtl('תקין — אומת', level=2)
440
+
441
+ verified = [
442
+ ('Voc סטרינג', '9 x ~44V = 396V < 1000V (מקסימום), בתוך 200–850V (MPPT)', 'תקין ✓'),
443
+ ('Isc סטרינג', '~18A < 20A (מקסימום לכניסת MPPT), < 30A (קצר)', 'תקין ✓'),
444
+ ('התאמת סוללה', 'CNTE HV — טווח 120–800V תואם לסוליס (120–800V)', 'תקין ✓'),
445
+ ('זרם סוללה', '50A מקסימום — תואם לסוליס (50A max charge/discharge)', 'תקין ✓'),
446
+ ('IP Rating', 'ממיר IP66 + סוללה IP66 — מתאים להתקנה חיצונית בחוות יאיר', 'תקין ✓'),
447
+ ('תקשורת BMS', 'CAN/RS485 — תואם בין CNTE לסוליס', 'תקין ✓'),
448
+ ('מפסק עומסים', 'C40 בענף עומסי הבית — תקין (זרם יציאת ממיר 30.4A)', 'תקין ✓'),
449
+ ('DC Switch מובנה', 'הסוליס כולל DC Switch מובנה (integrated) — תואם לתקן', 'תקין ✓'),
450
+ ('מומנט הידוק סוללה', '24.5 N·m — תואם מפרט (M8)', 'תקין ✓'),
451
+ ('חישוב עלויות', '₪36,450 + 18% = ₪43,011', 'תקין ✓'),
452
+ ]
453
+
454
+ table = add_table_rtl(len(verified) + 1, 3)
455
+ for i, h in enumerate(['פריט', 'בדיקה', 'תוצאה']):
456
+ bold_cell(table.rows[0].cells[i], h)
457
+ shade_cells(table.rows[0], '0C2340')
458
+ for cell in table.rows[0].cells:
459
+ for p in cell.paragraphs:
460
+ for run in p.runs:
461
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
462
+ for row_idx, item in enumerate(verified):
463
+ for col_idx, val in enumerate(item):
464
+ table.rows[row_idx + 1].cells[col_idx].text = val
465
+ set_cell_rtl(table.rows[row_idx + 1].cells[col_idx])
466
+
467
+ # ═══════════ 10. EXECUTION GUIDELINES ═══════════
468
+ add_heading_rtl('10. הנחיות ביצוע — שלב אחר שלב', level=1)
469
+ add_para_rtl('כלל ברזל: כל העבודה החשמלית חייבת להתבצע על ידי חשמלאי מוסמך בעל רישיון בתוקף. עבודה על מתח DC גבוה (עד 800V) מסוכנת — לא לגעת בלי ציוד מגן מתאים.', bold=True, color=RGBColor(0xC6, 0x28, 0x28))
470
+
471
+ # Phase A
472
+ def add_checklist_table(title, items):
473
+ add_heading_rtl(title, level=2)
474
+ table = add_table_rtl(len(items) + 1, 3)
475
+ for i, h in enumerate(['☐', 'משימה', 'פירוט']):
476
+ bold_cell(table.rows[0].cells[i], h)
477
+ shade_cells(table.rows[0], '0C2340')
478
+ for cell in table.rows[0].cells:
479
+ for p in cell.paragraphs:
480
+ for run in p.runs:
481
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
482
+ for row_idx, (task, detail) in enumerate(items):
483
+ table.rows[row_idx + 1].cells[0].text = '☐'
484
+ set_cell_rtl(table.rows[row_idx + 1].cells[0])
485
+ bold_cell(table.rows[row_idx + 1].cells[1], task)
486
+ table.rows[row_idx + 1].cells[2].text = detail
487
+ set_cell_rtl(table.rows[row_idx + 1].cells[2])
488
+
489
+ add_checklist_table('שלב א\' — הכנה ורכש (לפני תחילת עבודה)', [
490
+ ('אישור תכנית מול חשמלאי', 'להעביר תכנית זו לחשמלאי מוסמך לאישור. לוודא התאמה לתקנות חשמל ישראליות ולדרישות היישוב'),
491
+ ('בירור מול היישוב', 'האם מותר net metering (מכירת חשמל)? מהם דרישות החיבור? האם נדרש אישור ועדת תכנון?'),
492
+ ('רכש רכיבים חסרים', 'מפסקי ABB C50 4P (x2), ABB C40 4P (x2 — עומסים + backup), SPD AC Type 2, SPD DC Type 2, DC Isolators (x3), RCD Type A 30mA, טבעות מגנטיות (x2), בורר מצבים Hager 63A 4P'),
493
+ ('רכש כבלים', 'כבל סולארי H1Z2Z2-K 6mm² (לפי מדידת אורך), NYY-J 5G10 (או 5G16), כבל DC 16mm² לסוללה, כבל CAN מסוכך, כבל הארקה 16mm² ירוק-צהוב'),
494
+ ('הכנת תשתית גג', 'קונסטרוקציה, ביסוס, הגבהות — בתיאום עם קבלן שלד (לא כלול בהצעת אנטמן)'),
495
+ ('סימון מיקום ממיר וסוללה', 'קיר חיצוני מוצל, גובה 60+ ס"מ, מרווח אוורור 30 ס"מ מצדדים, 50 ס"מ מלמעלה. נגיש לתחזוקה'),
496
+ ])
497
+
498
+ add_checklist_table('שלב ב\' — התקנה מכנית', [
499
+ ('התקנת קונסטרוקציה על הגג', 'ביסוס מסילות לפי תכנית גג. וידוא יציבות רוחות, שיפוע ניקוז, ואטימת חדירות גג'),
500
+ ('הרכבת פאנלים', '18 פאנלים x 620W. חלוקה: סטרינג 1 (9 יח\') וסטרינג 2 (9 יח\'). חיבור טורי בכל סטרינג. הארקת מסגרות'),
501
+ ('התקנת ממיר על הקיר', 'Solis S6-EH3P20K-H — תלייה עם בורגי הרחבה מתאימים. וידוא מפלס'),
502
+ ('התקנת סוללה', 'CNTE 18.8kWh — ליד הממיר. וידוא יציבות, חיזוק לקיר אם נדרש (משקל ~100 ק"ג)'),
503
+ ('התקנת לוח מפסקים', 'לוח ייעודי או תוספת ללוח קיים: מפסקים, SPD, בורר מצבים, RCD. סימון ברור על כל מפסק'),
504
+ ])
505
+
506
+ add_checklist_table('שלב ג\' — כבילה וחיבורים חשמליים', [
507
+ ('כבילת DC — פאנלים לממיר', 'H1Z2Z2-K 6mm², MC4 connectors. סטרינג 1 → MPPT1, סטרינג 2 → MPPT2. לוודא קוטביות! + ל-+, – ל-–'),
508
+ ('DC Isolators — PV', 'התקנת 2 DC Isolators (1000Vdc/32A) — אחד לכל סטרינג. ליד כניסת הממיר או ליד הפאנלים'),
509
+ ('כבילת DC — סוללה', 'כבל 16mm² דרך DC Isolator (800Vdc/63A). להעביר דרך טבעת מגנטית x 2 ליפופים. מומנט הידוק: 24.5 N·m. בדיקת קוטביות לפני חיבור!'),
510
+ ('כבל BMS/CAN', 'כבל CAN מסוכך מהסוללה לממיר. להעביר דרך טבעת מגנטית x 4 ליפופים.'),
511
+ ('כבילת AC — יציאת Grid', 'NYY-J 5G10 (3L+N+PE) מיציאת Grid של הממיר → CT → SPD AC → MCB C50 → בורר מצבים → התפצלות'),
512
+ ('כבילת AC — Backup', 'NYY-J 5G6 מיציאת Backup של הממיר → MCB C40 4P → לוח עומסים חיוניים'),
513
+ ('חיבור לרשת היישוב', 'מענף הרשת בבורר → MCB C50 → מונה יישוב → רשת'),
514
+ ('הארקה', 'כבל 16mm² נחושת ירוק-צהוב: ממיר + סוללה + קונסטרוקציה + מסגרות פאנלים → פס הארקה → אלקטרודה'),
515
+ ])
516
+
517
+ # Phase D - tests
518
+ add_heading_rtl('שלב ד\' — בדיקות לפני הפעלה', level=2)
519
+ tests = [
520
+ ('מדידת Voc לכל סטרינג', '~396V (±5%). הפרש בין סטרינגים: לא יותר מ-5%'),
521
+ ('מדידת Isc לכל סטרינג', '~18A בשמש מלאה. לבדוק עם מודד DC rated'),
522
+ ('בדיקת קוטביות DC', '+ ל-+ ו-– ל-– בכל נקודת חיבור. חיבור הפוך הורס את הממיר!'),
523
+ ('בדיקת בידוד DC', 'מגר: >1MΩ בין + לאדמה, ובין – לאדמה'),
524
+ ('בדיקת רציפות הארקה', '<0.5Ω בין כל מסגרת פאנל / גוף ממיר לפס הארקה'),
525
+ ('בדיקת מתח סוללה', 'מתח תקין בטווח 120–800V. SOC מינימלי ~20% לפני הפעלה ראשונה'),
526
+ ('בדיקת תקשורת BMS', 'וידוא שהממיר מזהה את הסוללה ומציג SOC, מתח, טמפרטורה'),
527
+ ('בדיקת מפסקים ו-RCD', 'הפעלה וניתוק ידני של כל מפסק. בדיקת RCD עם כפתור TEST'),
528
+ ('בדיקת AC — מתח בין פאזות', '~400V בין פאזות, ~230V בין פאזה לנייטרל'),
529
+ ]
530
+ table = add_table_rtl(len(tests) + 1, 3)
531
+ for i, h in enumerate(['☐', 'בדיקה', 'ערך צפוי / קריטריון']):
532
+ bold_cell(table.rows[0].cells[i], h)
533
+ shade_cells(table.rows[0], '0C2340')
534
+ for cell in table.rows[0].cells:
535
+ for p in cell.paragraphs:
536
+ for run in p.runs:
537
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
538
+ for row_idx, (test, expected) in enumerate(tests):
539
+ table.rows[row_idx + 1].cells[0].text = '☐'
540
+ set_cell_rtl(table.rows[row_idx + 1].cells[0])
541
+ bold_cell(table.rows[row_idx + 1].cells[1], test)
542
+ table.rows[row_idx + 1].cells[2].text = expected
543
+ set_cell_rtl(table.rows[row_idx + 1].cells[2])
544
+
545
+ # Phase E - commissioning
546
+ add_checklist_table('שלב ה\' — הפעלה ראשונה (Commissioning)', [
547
+ ('הפעלה לפי סדר', '1. סגור DC Isolator של סוללה → 2. סגור DC Isolators של PV → 3. סגור מפסק AC → 4. הפעל ממיר'),
548
+ ('הגדרות ממיר', 'דרך Bluetooth + APP: הגדרת סוג סוללה (Lithium/CAN), מצב עבודה (Self-use / Feed-in / Zero Export), שעות טעינה/פריקה, תדר רשת (50Hz), מתח רשת (400V)'),
549
+ ('הפעלת AFCI', 'בהגדרות הממיר — activation required. הגנת קשת חשמלית בצד DC'),
550
+ ('הגדרת CT', 'כיוון CT (חץ לכיוון הרשת). הגדרת zero export אם נדרש. בדיקה: כשהמערכת מייצרת, CT צריך להראות ערך שלילי (ייצוא)'),
551
+ ('בדיקת Backup', 'ניתוק מפסק רשת ידנית → וידוא שעומסים חיוניים ממשיכים לעבוד תוך <10ms → חיבור מחדש'),
552
+ ('חיבור WiFi/Ethernet', 'חיבור הממיר לרשת לצורך ניטור מרחוק דרך אפליקציית Solis. הגדרת חשבון ב-SolisCloud'),
553
+ ('ניטור 24 שעות', 'מעקב ביום הראשון: ייצור PV, טעינת סוללה, צריכה, ייצוא/ייבוא מרשת. וידוא שאין שגיאות'),
554
+ ])
555
+
556
+ # Phase F - labeling
557
+ add_checklist_table('שלב ו\' — סימון ותיעוד', [
558
+ ('סימון מפסקים', 'תווית ברורה על כל מפסק: "PV String 1", "PV String 2", "סוללה DC", "AC ממיר", "רשת", "עומסים", "Backup", "SPD"'),
559
+ ('סימון DC Isolators', 'תווית אדומה: "זהירות — מתח DC גבוה עד 800V" על כל DC Isolator'),
560
+ ('שלט כיבוי חירום', 'שלט ליד הממיר: "כיבוי חירום — 1. נתק DC Isolators (PV + סוללה) 2. נתק מפסק AC"'),
561
+ ('תיעוד', 'לשמור עותק של: תכנית חשמלית, אישורי בדיקה, מספרים סידוריים (ממיר + סוללה + פאנלים), תמונות התקנה'),
562
+ ('מסירה ללקוח', 'הדרכת שימוש: אפליקציה, מצבי עבודה, כיבוי חירום, תחזוקה שוטפת (ניקוי פאנלים כל 3 חודשים)'),
563
+ ])
564
+
565
+ # ═══════════ 11. EMERGENCY SHUTDOWN ═══════════
566
+ add_heading_rtl('11. נוהל כיבוי חירום', level=1)
567
+ add_para_rtl('במקרה של שריפה, הצפה, נזק פיזי לציוד, או כל מצב חירום:')
568
+
569
+ emergency_steps = [
570
+ ('1', 'נתק DC — סוללה', 'DC Isolator של הסוללה → מצב OFF'),
571
+ ('2', 'נתק DC — פאנלים', 'שני DC Isolators של PV → מצב OFF'),
572
+ ('3', 'נתק AC — ממיר', 'מפסק יציאת ממיר (C50) → מצב OFF'),
573
+ ('4', 'נתק רשת', 'מפסק רשת (C50) → מצב OFF'),
574
+ ]
575
+
576
+ table = add_table_rtl(len(emergency_steps) + 1, 3)
577
+ for i, h in enumerate(['שלב', 'פעולה', 'פירוט']):
578
+ bold_cell(table.rows[0].cells[i], h)
579
+ shade_cells(table.rows[0], 'C62828')
580
+ for cell in table.rows[0].cells:
581
+ for p in cell.paragraphs:
582
+ for run in p.runs:
583
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
584
+ for row_idx, (step, action, detail) in enumerate(emergency_steps):
585
+ bold_cell(table.rows[row_idx + 1].cells[0], step)
586
+ bold_cell(table.rows[row_idx + 1].cells[1], action)
587
+ table.rows[row_idx + 1].cells[2].text = detail
588
+ set_cell_rtl(table.rows[row_idx + 1].cells[2])
589
+ shade_cells(table.rows[row_idx + 1], 'FFEBEE')
590
+
591
+ add_para_rtl('')
592
+ add_para_rtl('⚠ זהירות: גם לאחר ניתוק כל המפסקים, הפאנלים עצמם ממשיכים לייצר מתח כל עוד יש אור. מתח מעגל פתוח: ~396V לסטרינג. אין לגעת בחיבורי MC4 או בכבלי DC ללא ציוד מגן מתאים ובדיקה עם מודד.', bold=True, color=RGBColor(0xC6, 0x28, 0x28))
593
+
594
+ # ═══════════ FOOTER ═══════════
595
+ add_para_rtl('')
596
+ p = add_para_rtl('מסמך זה הוכן לצרכי תכנון בלבד ואינו מהווה תכנית חשמלית רשמית. יש לוודא עם חשמלאי מוסמך לפני ביצוע.', size=10, color=RGBColor(0x99, 0x99, 0x99))
597
+ add_para_rtl('חוות יאיר — אלי ספרא | מרץ 2026', size=10, color=RGBColor(0x99, 0x99, 0x99))
598
+
599
+ # ── Save ──
600
+ output_path = os.path.expanduser('~/Documents/GitHub/Baseline/תכנית_חשמלית_חוות_יאיר.docx')
601
+ doc.save(output_path)
602
+ print(f'Saved to: {output_path}')
scripts/import_layout.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ import_layout.py — Spatial asset registration for SolarWine.
4
+
5
+ Reads site geometry from config/settings.py and the ThingsBoard device
6
+ registry, assigns 3D coordinates to every asset (panels, vine rows,
7
+ sensors), and writes ``Data/layout.json`` for the ShadowModel and
8
+ dashboard map view.
9
+
10
+ Usage:
11
+ python -m scripts.import_layout # write Data/layout.json
12
+ python -m scripts.import_layout --print # dump to stdout instead
13
+
14
+ The output schema:
15
+ {
16
+ "site": { lat, lon, altitude, timezone },
17
+ "panel_geometry": { width, height, row_spacing, row_azimuth_deg },
18
+ "canopy_geometry": { height, width, n_vertical_zones, lai_distribution },
19
+ "rows": [
20
+ { "row_id": 501, "type": "treatment", "devices": [...],
21
+ "panel_center_x_m": ..., "panel_center_y_m": ... },
22
+ ...
23
+ ],
24
+ "devices": {
25
+ "Air1": { uuid, area, row, label, position_m: [x, y, z] },
26
+ ...
27
+ }
28
+ }
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import sys
36
+ from pathlib import Path
37
+
38
+ # Add project root to path so we can import config/src
39
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent
40
+ sys.path.insert(0, str(_PROJECT_ROOT))
41
+
42
+ from config.settings import (
43
+ CANOPY_HEIGHT,
44
+ CANOPY_WIDTH,
45
+ PANEL_HEIGHT,
46
+ PANEL_WIDTH,
47
+ ROW_SPACING,
48
+ SITE_ALTITUDE,
49
+ SITE_LATITUDE,
50
+ SITE_LONGITUDE,
51
+ )
52
+
53
+
54
+ def _load_device_registry() -> dict:
55
+ """Import device registry from ThingsBoard client (extended with `position`)."""
56
+ from src.data.thingsboard_client import DEVICE_REGISTRY
57
+ devices = {}
58
+ for name, info in DEVICE_REGISTRY.items():
59
+ devices[name] = {
60
+ "uuid": info.uuid,
61
+ "area": info.area.value,
62
+ "row": info.row,
63
+ "label": info.label,
64
+ "position": info.position,
65
+ }
66
+ return devices
67
+
68
+
69
+ # Row layout (2026):
70
+ # Reference: row 202 (single row, open sky, lat ~30.9791)
71
+ # Treatment: rows 501/502/503/504/509 (under solar panels, lat ~30.9794)
72
+ # Reference plot sits ~30 m south of the treatment plot; modelled as a
73
+ # negative Y offset on a separate block.
74
+ _TREATMENT_ROWS = [501, 502, 503, 504, 509]
75
+ _REFERENCE_ROWS = [202]
76
+
77
+
78
+ def _assign_row_positions(devices: dict) -> list[dict]:
79
+ """Assign 3D positions to every vine row that has at least one device.
80
+
81
+ X-axis = row-perpendicular (panel rows are stacked along X).
82
+ Y-axis = along the row.
83
+ Z-axis: ground = 0, panel top = PANEL_HEIGHT.
84
+ """
85
+ row_types: dict[int, str] = {r: "treatment" for r in _TREATMENT_ROWS}
86
+ row_types.update({r: "reference" for r in _REFERENCE_ROWS})
87
+
88
+ rows = []
89
+ for i, row_id in enumerate(_TREATMENT_ROWS):
90
+ x = i * ROW_SPACING
91
+ row_devices = [name for name, d in devices.items() if d.get("row") == row_id]
92
+ rows.append({
93
+ "row_id": row_id,
94
+ "type": row_types[row_id],
95
+ "panel_center_x_m": round(x, 2),
96
+ "panel_center_y_m": 0.0,
97
+ "panel_height_m": PANEL_HEIGHT,
98
+ "devices": sorted(row_devices),
99
+ })
100
+
101
+ for row_id in _REFERENCE_ROWS:
102
+ row_devices = [name for name, d in devices.items() if d.get("row") == row_id]
103
+ rows.append({
104
+ "row_id": row_id,
105
+ "type": "reference",
106
+ "panel_center_x_m": 0.0,
107
+ "panel_center_y_m": -30.0, # ~30 m south of treatment block
108
+ "panel_height_m": 0.0, # reference has no panel
109
+ "devices": sorted(row_devices),
110
+ })
111
+
112
+ return rows
113
+
114
+
115
+ # Map `position` strings → (y_offset_m, x_offset_m) relative to row center.
116
+ # Y runs along the row (north positive), X is cross-row (east positive).
117
+ _POSITION_OFFSETS = {
118
+ "north": (+5.0, 0.0),
119
+ "south": (-5.0, 0.0),
120
+ "center": ( 0.0, 0.0),
121
+ "east": ( 0.0, +0.5),
122
+ "west": ( 0.0, -0.5),
123
+ "north-east": (+5.0, +0.5),
124
+ "north-west": (+5.0, -0.5),
125
+ "south-east": (-5.0, +0.5),
126
+ "south-west": (-5.0, -0.5),
127
+ "center-east": ( 0.0, +0.5),
128
+ "center-west": ( 0.0, -0.5),
129
+ }
130
+
131
+
132
+ def _assign_device_positions(devices: dict, rows: list[dict]) -> dict:
133
+ """Assign approximate 3D positions to each device.
134
+
135
+ Z by device family:
136
+ Crop_2Soil : 0.6 m (fruiting zone — IRT/leaf temp/spectrometer/NDVI)
137
+ Thermocouples: PANEL_HEIGHT (panel surface)
138
+ Tracker : PANEL_HEIGHT (panel pivot)
139
+ """
140
+ row_center = {r["row_id"]: (r["panel_center_x_m"], r["panel_center_y_m"]) for r in rows}
141
+
142
+ positioned = {}
143
+ for name, d in devices.items():
144
+ row = d.get("row")
145
+ x0, y0 = row_center.get(row, (0.0, 0.0)) if row else (0.0, 0.0)
146
+ dy, dx = _POSITION_OFFSETS.get((d.get("position") or "").strip().lower(), (0.0, 0.0))
147
+ x = x0 + dx
148
+ y = y0 + dy
149
+
150
+ if name.startswith("Crop_2Soil"):
151
+ z = 0.6
152
+ elif name.startswith("Thermocouples"):
153
+ z = PANEL_HEIGHT
154
+ elif name.startswith("Tracker"):
155
+ z = PANEL_HEIGHT
156
+ else:
157
+ z = 0.0
158
+
159
+ positioned[name] = {
160
+ **d,
161
+ "position_m": [round(x, 3), round(y, 3), round(z, 3)],
162
+ }
163
+
164
+ return positioned
165
+
166
+
167
+ def build_layout() -> dict:
168
+ """Build the complete site layout dictionary."""
169
+ devices = _load_device_registry()
170
+ rows = _assign_row_positions(devices)
171
+ positioned_devices = _assign_device_positions(devices, rows)
172
+
173
+ layout = {
174
+ "site": {
175
+ "latitude": SITE_LATITUDE,
176
+ "longitude": SITE_LONGITUDE,
177
+ "altitude_m": SITE_ALTITUDE,
178
+ "timezone": "Asia/Jerusalem",
179
+ "name": "Sde Boker Agrivoltaic Research Site",
180
+ },
181
+ "panel_geometry": {
182
+ "width_m": PANEL_WIDTH,
183
+ "height_m": PANEL_HEIGHT,
184
+ "row_spacing_m": ROW_SPACING,
185
+ "row_azimuth_deg": 315.0,
186
+ "tilt_axis": "single_axis_NS",
187
+ },
188
+ "canopy_geometry": {
189
+ "height_m": CANOPY_HEIGHT,
190
+ "width_m": CANOPY_WIDTH,
191
+ "trellis_type": "VSP",
192
+ "n_vertical_zones": 3,
193
+ "zone_labels": ["basal_trunk", "fruiting_zone", "apical_canopy"],
194
+ "zone_heights_m": [0.2, 0.6, 1.0],
195
+ "lai_distribution": [0.15, 0.35, 0.50],
196
+ },
197
+ "rows": rows,
198
+ "devices": positioned_devices,
199
+ }
200
+
201
+ return layout
202
+
203
+
204
+ def main():
205
+ parser = argparse.ArgumentParser(description="Generate Data/layout.json")
206
+ parser.add_argument("--print", action="store_true", help="Print to stdout instead of writing file")
207
+ parser.add_argument("--output", type=str, default=None, help="Custom output path")
208
+ args = parser.parse_args()
209
+
210
+ layout = build_layout()
211
+ json_str = json.dumps(layout, indent=2, ensure_ascii=False)
212
+
213
+ if args.print:
214
+ print(json_str)
215
+ else:
216
+ out_path = Path(args.output) if args.output else _PROJECT_ROOT / "Data" / "layout.json"
217
+ out_path.parent.mkdir(parents=True, exist_ok=True)
218
+ out_path.write_text(json_str, encoding="utf-8")
219
+ print(f"Layout written to {out_path}")
220
+ print(f" {len(layout['rows'])} rows, {len(layout['devices'])} devices")
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
scripts/load_test.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Load test for SolarWine API using Locust.
3
+
4
+ Usage:
5
+ pip install locust
6
+ locust -f scripts/load_test.py --host https://solarwine-api.hf.space
7
+
8
+ # Quick headless run (10 users, 5 min):
9
+ locust -f scripts/load_test.py --host https://solarwine-api.hf.space \
10
+ --users 10 --spawn-rate 2 --run-time 5m --headless
11
+
12
+ # Full stress test (50 users, 5 min):
13
+ locust -f scripts/load_test.py --host https://solarwine-api.hf.space \
14
+ --users 50 --spawn-rate 5 --run-time 5m --headless
15
+ """
16
+
17
+ from locust import HttpUser, task, between
18
+
19
+
20
+ class SolarWineUser(HttpUser):
21
+ """Simulates a frontend user browsing the SolarWine dashboard."""
22
+
23
+ wait_time = between(1, 5)
24
+
25
+ # ── High-frequency endpoints (dashboard polling) ──
26
+
27
+ @task(10)
28
+ def health(self):
29
+ self.client.get("/api/health")
30
+
31
+ @task(8)
32
+ def weather_current(self):
33
+ self.client.get("/api/weather/current")
34
+
35
+ @task(6)
36
+ def sensor_snapshot(self):
37
+ self.client.get("/api/sensors/snapshot")
38
+
39
+ @task(5)
40
+ def energy_current(self):
41
+ self.client.get("/api/energy/current")
42
+
43
+ @task(4)
44
+ def data_sources(self):
45
+ self.client.get("/api/health/data-sources")
46
+
47
+ # ── Medium-frequency endpoints (page navigations) ──
48
+
49
+ @task(3)
50
+ def energy_daily(self):
51
+ from datetime import date
52
+ self.client.get(f"/api/energy/daily/{date.today()}")
53
+
54
+ @task(3)
55
+ def weather_history(self):
56
+ from datetime import date, timedelta
57
+ end = date.today()
58
+ start = end - timedelta(days=7)
59
+ self.client.get(f"/api/weather/history?start_date={start}&end_date={end}&format=rows")
60
+
61
+ @task(2)
62
+ def photosynthesis_current(self):
63
+ self.client.get("/api/photosynthesis/current")
64
+
65
+ @task(2)
66
+ def biology_rules(self):
67
+ self.client.get("/api/biology/rules")
68
+
69
+ @task(2)
70
+ def control_status(self):
71
+ self.client.get("/api/control/status")
72
+
73
+ @task(2)
74
+ def control_trackers(self):
75
+ self.client.get("/api/control/trackers")
76
+
77
+ @task(1)
78
+ def phenology(self):
79
+ self.client.get("/api/biology/phenology")
80
+
81
+ @task(1)
82
+ def chill_units(self):
83
+ self.client.get("/api/biology/chill-units?season_start=2025-11-01")
84
+
85
+ # ── Low-frequency endpoints (heavier operations) ──
86
+
87
+ @task(1)
88
+ def photosynthesis_forecast(self):
89
+ self.client.get("/api/photosynthesis/forecast")
90
+
91
+ @task(1)
92
+ def control_plan(self):
93
+ self.client.get("/api/control/plan")
94
+
95
+ @task(1)
96
+ def sensor_soil(self):
97
+ self.client.get("/api/sensors/soil-moisture?hours=168")
98
+
99
+ @task(1)
100
+ def sensor_rain(self):
101
+ self.client.get("/api/sensors/rain?hours=168")
scripts/refresh_energy_data.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Refresh Data/energy_weather_merged.csv by joining TB Plant production
2
+ with IMS station-43 weather.
3
+
4
+ Pulls hourly `production` (Wh) from the TB Plant asset over the requested
5
+ date range, resamples the cached IMS CSV to hourly, joins on UTC timestamp,
6
+ and adds the time / solar-geometry features the EnergyPredictor consumes.
7
+
8
+ Usage:
9
+ python -m scripts.refresh_energy_data # Dec 2025 → today
10
+ python -m scripts.refresh_energy_data --from 2026-01-01 --to 2026-05-18
11
+
12
+ Replaces the legacy Air1-sourced workflow — that device no longer exists
13
+ in the 2026 fleet.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from datetime import datetime, timedelta, timezone
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent
27
+ sys.path.insert(0, str(_PROJECT_ROOT))
28
+
29
+ try:
30
+ from dotenv import load_dotenv
31
+ load_dotenv(_PROJECT_ROOT / ".env")
32
+ except ImportError:
33
+ pass
34
+
35
+ from src.data.thingsboard_client import ThingsBoardClient
36
+
37
+ _LAT_RAD = np.radians(30.85) # Sde Boker
38
+ _IMS_CSV = _PROJECT_ROOT / "Data" / "ims" / "ims_merged_15min.csv"
39
+ _OUT_CSV = _PROJECT_ROOT / "Data" / "energy_weather_merged.csv"
40
+
41
+
42
+ def _solar_sin_elevation(doy: int, hour_utc: int) -> float:
43
+ dec = np.radians(23.45 * np.sin(np.radians(360 / 365 * (doy - 81))))
44
+ ha = np.radians(15 * (hour_utc + 2 - 12)) # UTC+2 ≈ local solar
45
+ return float(max(0.0, np.sin(_LAT_RAD) * np.sin(dec)
46
+ + np.cos(_LAT_RAD) * np.cos(dec) * np.cos(ha)))
47
+
48
+
49
+ _TRACKER_DEVICES = ["Tracker501", "Tracker502", "Tracker503", "Tracker509"]
50
+
51
+
52
+ def fetch_tb_tracker_angles(start: datetime, end: datetime) -> pd.DataFrame:
53
+ """Pull hourly mean tracker angle across the 4 trackers.
54
+
55
+ Adds two columns:
56
+ - tracker_angle_mean : mean angle (°) across reporting trackers
57
+ - tracker_angle_std : std dev across trackers (fleet disagreement)
58
+ — proxy for "one tracker stowed for intervention"
59
+ """
60
+ client = ThingsBoardClient()
61
+ step = timedelta(days=7)
62
+ per_tracker: dict[str, pd.DataFrame] = {}
63
+
64
+ for name in _TRACKER_DEVICES:
65
+ frames = []
66
+ cursor = start
67
+ while cursor < end:
68
+ cursor_end = min(cursor + step, end)
69
+ try:
70
+ df = client.get_timeseries(
71
+ name, ["angle"],
72
+ start=cursor, end=cursor_end,
73
+ limit=10_000, interval_ms=3_600_000, agg="AVG",
74
+ )
75
+ except Exception:
76
+ df = pd.DataFrame()
77
+ if not df.empty:
78
+ frames.append(df)
79
+ cursor = cursor_end
80
+ if frames:
81
+ tdf = pd.concat(frames).sort_index()
82
+ tdf.index = pd.to_datetime(tdf.index, utc=True).floor("h")
83
+ tdf = tdf[~tdf.index.duplicated(keep="last")]
84
+ per_tracker[name] = tdf.rename(columns={"angle": name})
85
+
86
+ if not per_tracker:
87
+ # No tracker data — return empty so the join becomes a no-op.
88
+ return pd.DataFrame(columns=["tracker_angle_mean", "tracker_angle_std"])
89
+
90
+ wide = pd.concat(per_tracker.values(), axis=1)
91
+ return pd.DataFrame({
92
+ "tracker_angle_mean": wide.mean(axis=1, skipna=True),
93
+ "tracker_angle_std": wide.std(axis=1, skipna=True).fillna(0.0),
94
+ }, index=wide.index)
95
+
96
+
97
+ def fetch_tb_production(start: datetime, end: datetime) -> pd.DataFrame:
98
+ """Pull hourly Plant production (Wh, summed) from ThingsBoard.
99
+
100
+ The Plant asset emits `production` every ~5 min as the energy generated
101
+ in that interval. We sum to hourly buckets via TB's SUM aggregation
102
+ (NOT AVG — AVG of 5-min samples is ~12× smaller than the hourly total).
103
+
104
+ Returns a DataFrame indexed by hourly UTC timestamp with column
105
+ `production` in Wh.
106
+ """
107
+ client = ThingsBoardClient()
108
+ frames = []
109
+ cursor = start
110
+ step = timedelta(days=7)
111
+ while cursor < end:
112
+ cursor_end = min(cursor + step, end)
113
+ df = client.get_asset_timeseries(
114
+ "Plant",
115
+ keys=["production"],
116
+ start=cursor,
117
+ end=cursor_end,
118
+ limit=10_000,
119
+ interval_ms=3_600_000,
120
+ agg="SUM",
121
+ )
122
+ if not df.empty:
123
+ frames.append(df)
124
+ cursor = cursor_end
125
+
126
+ if not frames:
127
+ raise RuntimeError(f"No Plant production data returned for {start} → {end}")
128
+
129
+ combined = pd.concat(frames).sort_index()
130
+ combined.index = pd.to_datetime(combined.index, utc=True)
131
+ # TB returns bucket-center timestamps (e.g. 12:30 for the 12:00 hour);
132
+ # floor to hour so we join cleanly against IMS. Dedupe by index after
133
+ # the floor (not by column values — many zero-production hours would
134
+ # otherwise be collapsed to a single row).
135
+ combined.index = combined.index.floor("h")
136
+ combined = combined[~combined.index.duplicated(keep="last")]
137
+ return combined
138
+
139
+
140
+ def load_ims_hourly() -> pd.DataFrame:
141
+ """Load the cached IMS 15-min CSV and resample to hourly."""
142
+ if not _IMS_CSV.exists():
143
+ raise FileNotFoundError(
144
+ f"{_IMS_CSV} not found. Run scripts.download_ims_data first."
145
+ )
146
+ df = pd.read_csv(_IMS_CSV)
147
+ df["timestamp_utc"] = pd.to_datetime(df["timestamp_utc"], utc=True)
148
+ df = df.set_index("timestamp_utc").sort_index()
149
+ hourly = df.resample("1h").mean()
150
+ return hourly
151
+
152
+
153
+ def build_merged(production: pd.DataFrame, weather: pd.DataFrame,
154
+ trackers: pd.DataFrame | None = None) -> pd.DataFrame:
155
+ """Inner-join production and weather on hourly timestamp and add features.
156
+
157
+ TB SUM aggregation omits zero-production hours; we reindex to a contiguous
158
+ hourly span and fill missing production with 0 so the model learns the
159
+ "GHI=0 → production=0" mapping for nighttime.
160
+ """
161
+ # Reindex production to contiguous hourly grid (nighttime → 0 kWh)
162
+ full_idx = pd.date_range(production.index.min(), production.index.max(),
163
+ freq="1h", tz="UTC")
164
+ production = production.reindex(full_idx).fillna(0.0)
165
+ production.index.name = "timestamp_utc"
166
+
167
+ merged = production.join(weather, how="inner")
168
+ if trackers is not None and not trackers.empty:
169
+ merged = merged.join(trackers, how="left")
170
+ # Backfill short gaps then forward-fill so brief telemetry holes don't
171
+ # introduce NaN; long gaps remain NaN and get dropped at training.
172
+ merged[["tracker_angle_mean", "tracker_angle_std"]] = (
173
+ merged[["tracker_angle_mean", "tracker_angle_std"]].bfill(limit=2).ffill(limit=2)
174
+ )
175
+
176
+ # Rename IMS columns to match the legacy EnergyPredictor feature names.
177
+ merged = merged.rename(columns={
178
+ "air_temperature_c": "airTemperature",
179
+ "ghi_w_m2": "GSR",
180
+ "wind_speed_ms": "windSpeed",
181
+ "rh_percent": "airHumidity",
182
+ })
183
+
184
+ # Time features
185
+ merged["hour"] = merged.index.hour
186
+ merged["month"] = merged.index.month
187
+ merged["day_of_year"] = merged.index.dayofyear
188
+ merged["hour_sin"] = np.sin(2 * np.pi * merged["hour"] / 24)
189
+ merged["hour_cos"] = np.cos(2 * np.pi * merged["hour"] / 24)
190
+ merged["doy_sin"] = np.sin(2 * np.pi * merged["day_of_year"] / 365)
191
+ merged["doy_cos"] = np.cos(2 * np.pi * merged["day_of_year"] / 365)
192
+
193
+ # Solar geometry
194
+ merged["sin_elevation"] = [
195
+ _solar_sin_elevation(int(doy), int(h))
196
+ for doy, h in zip(merged["day_of_year"], merged["hour"])
197
+ ]
198
+ merged["clearness"] = np.where(
199
+ merged["sin_elevation"] > 0.05,
200
+ np.minimum(merged["GSR"] / (merged["sin_elevation"] * 1000), 1.5),
201
+ 0.0,
202
+ )
203
+
204
+ # Target: kWh per hour
205
+ merged["production_kwh"] = merged["production"] / 1000.0
206
+
207
+ return merged
208
+
209
+
210
+ def main() -> None:
211
+ p = argparse.ArgumentParser(description="Refresh Data/energy_weather_merged.csv from TB + IMS.")
212
+ p.add_argument("--from", dest="from_date", default="2025-12-01")
213
+ p.add_argument("--to", dest="to_date", default=None,
214
+ help="End date (UTC, inclusive). Default: today.")
215
+ args = p.parse_args()
216
+
217
+ start = datetime.fromisoformat(args.from_date).replace(tzinfo=timezone.utc)
218
+ end = (datetime.fromisoformat(args.to_date).replace(tzinfo=timezone.utc)
219
+ if args.to_date else datetime.now(tz=timezone.utc))
220
+
221
+ print(f"Pulling TB Plant production {start.date()} → {end.date()} ...")
222
+ production = fetch_tb_production(start, end)
223
+ print(f" {len(production):,} hourly rows from TB Plant asset")
224
+
225
+ print("Pulling TB tracker angles ...")
226
+ trackers = fetch_tb_tracker_angles(start, end)
227
+ print(f" {len(trackers):,} hourly rows from {len(_TRACKER_DEVICES)} trackers")
228
+
229
+ print("Loading IMS weather ...")
230
+ weather = load_ims_hourly()
231
+ print(f" {len(weather):,} hourly rows from IMS")
232
+
233
+ merged = build_merged(production, weather, trackers)
234
+ merged.index.name = "timestamp_utc"
235
+ merged.to_csv(_OUT_CSV)
236
+ print(f"\nWrote {len(merged):,} rows × {len(merged.columns)} cols → {_OUT_CSV}")
237
+ print(f" range: {merged.index.min()} → {merged.index.max()}")
238
+ print(f" non-null production_kwh: {merged['production_kwh'].notna().sum():,}")
239
+
240
+
241
+ if __name__ == "__main__":
242
+ main()
scripts/run_chatbot_qa.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quick QA harness for VineyardChatbot.
3
+
4
+ Runs a fixed set of questions and prints the responses so we can sanity-check
5
+ behaviour before deploy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ if str(ROOT) not in sys.path:
15
+ sys.path.insert(0, str(ROOT))
16
+
17
+ from src.vineyard_chatbot import VineyardChatbot
18
+
19
+
20
+ def main() -> None:
21
+ bot = VineyardChatbot(verbose=True)
22
+
23
+ questions = [
24
+ "What is the state of the vines today?",
25
+ "What was the state of the vines one month ago?",
26
+ "What will be the state of the vines one month from now?",
27
+ "What will be the state of the vines two months from now?",
28
+ "Given today's conditions, what is your shading recommendation for today?",
29
+ "What is your irrigation recommendation for today?",
30
+ "What is your fertiliser recommendation for today?",
31
+ ]
32
+
33
+ for i, q in enumerate(questions, 1):
34
+ print("\n" + "=" * 80)
35
+ print(f"Q{i}: {q}")
36
+ try:
37
+ resp = bot.chat(q)
38
+ print("A:", resp.message)
39
+ except Exception as exc: # noqa: BLE001
40
+ import traceback
41
+
42
+ print("ERROR:", exc)
43
+ traceback.print_exc()
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
48
+
scripts/run_chronos_long_training.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Long Chronos-2 LoRA fine-tuning with large context window, tuned for local
3
+ CPU/memory. Single training run (Chronos does not support resuming LoRA fit).
4
+
5
+ Usage (from project root):
6
+ PYTHONPATH=. python scripts/run_chronos_long_training.py [--device cpu] [--num-steps 4000]
7
+
8
+ Uses: context_days=28, batch_size=16 by default. Set --num-steps for total steps.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ import threading
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
20
+ if str(PROJECT_ROOT) not in sys.path:
21
+ sys.path.insert(0, str(PROJECT_ROOT))
22
+
23
+ # Limit CPU threads to avoid oversubscription (set before importing torch)
24
+ if "OMP_NUM_THREADS" not in os.environ:
25
+ try:
26
+ import multiprocessing
27
+ n = multiprocessing.cpu_count()
28
+ os.environ["OMP_NUM_THREADS"] = str(min(n, 10))
29
+ except Exception:
30
+ pass
31
+
32
+ from config.settings import OUTPUTS_DIR
33
+ from src.chronos_forecaster import (
34
+ ChronosForecaster,
35
+ STEPS_PER_DAY,
36
+ )
37
+ from sklearn.metrics import mean_absolute_error
38
+ import numpy as np
39
+ import pandas as pd
40
+
41
+
42
+ def _quick_val_mae(forecaster: ChronosForecaster, df: pd.DataFrame, train_ratio: float, n_windows: int = 5) -> float:
43
+ """Compute MAE on first n_windows test windows (daytime-only) for convergence check."""
44
+ sparse = forecaster.load_sparse_data()
45
+ daytime_ts = set(sparse["timestamp_utc"])
46
+ split_idx = int(len(df) * train_ratio)
47
+ test_starts = list(range(split_idx, len(df) - STEPS_PER_DAY, STEPS_PER_DAY))[:n_windows]
48
+ actual_list, pred_list = [], []
49
+ for start_idx in test_starts:
50
+ f = forecaster.forecast_day(df, start_idx, STEPS_PER_DAY, covariate_mode="all")
51
+ actual_slice = df.iloc[start_idx : start_idx + STEPS_PER_DAY]
52
+ daytime_mask = actual_slice["timestamp_utc"].isin(daytime_ts).values[:len(f)]
53
+ if daytime_mask.sum() < 5:
54
+ continue
55
+ actual_list.append(actual_slice["A"].values[:len(f)][daytime_mask])
56
+ pred_list.append(np.clip(f["median"].values[daytime_mask], 0, None))
57
+ if not actual_list:
58
+ return float("nan")
59
+ return float(mean_absolute_error(np.concatenate(actual_list), np.concatenate(pred_list)))
60
+
61
+
62
+ def main() -> None:
63
+ import argparse
64
+ parser = argparse.ArgumentParser(description="Chronos-2 long LoRA training (single run)")
65
+ parser.add_argument("--device", default="cpu", help="torch device (cpu or mps)")
66
+ parser.add_argument("--context-days", type=int, default=28, help="context window in days")
67
+ parser.add_argument("--batch-size", type=int, default=16, help="batch size (safe for 32GB RAM)")
68
+ parser.add_argument("--num-steps", type=int, default=4000, help="total training steps")
69
+ parser.add_argument("--learning-rate", type=float, default=1e-5, help="learning rate")
70
+ parser.add_argument("--progress-minutes", type=int, default=10, help="print timestamp and progress every N minutes")
71
+ parser.add_argument("--output-dir", type=str, default=None, help="output dir for checkpoints")
72
+ args = parser.parse_args()
73
+
74
+ output_dir = args.output_dir or str(OUTPUTS_DIR / "chronos_finetuned_long")
75
+ OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)
76
+
77
+ print("Loading data...")
78
+ forecaster = ChronosForecaster(device=args.device, context_days=args.context_days)
79
+ df = forecaster.load_data()
80
+ print(f" Grid: {len(df)} rows, context={args.context_days}d ({forecaster.context_steps} steps)")
81
+ train_ratio = 0.75
82
+ split_idx = int(len(df) * train_ratio)
83
+
84
+ print("\nBaseline (zero-shot) validation MAE (5 windows)...")
85
+ baseline_mae = _quick_val_mae(forecaster, df, train_ratio, n_windows=5)
86
+ print(f" {baseline_mae:.4f}")
87
+
88
+ print(f"\nLoRA fine-tuning: {args.num_steps} steps, batch_size={args.batch_size}, lr={args.learning_rate}...")
89
+ stop_event = threading.Event()
90
+ interval_sec = max(1, args.progress_minutes * 60)
91
+
92
+ def _progress_reporter():
93
+ while True:
94
+ if stop_event.wait(interval_sec):
95
+ break
96
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
97
+ print(f"[{ts}] Chronos LoRA training still in progress ({args.num_steps} steps total)...", flush=True)
98
+
99
+ progress_thread = threading.Thread(target=_progress_reporter, daemon=True)
100
+ progress_thread.start()
101
+ try:
102
+ forecaster.finetune(
103
+ df,
104
+ train_ratio=train_ratio,
105
+ covariate_mode="all",
106
+ num_steps=args.num_steps,
107
+ learning_rate=args.learning_rate,
108
+ batch_size=args.batch_size,
109
+ output_dir=output_dir,
110
+ )
111
+ finally:
112
+ stop_event.set()
113
+ progress_thread.join(timeout=interval_sec + 5)
114
+
115
+ print("\nValidation MAE after training (5 windows)...")
116
+ val_mae = _quick_val_mae(forecaster, df, train_ratio, n_windows=5)
117
+ print(f" {val_mae:.4f} (baseline {baseline_mae:.4f})")
118
+
119
+ # Full benchmark with finetuned model (append lora row to CSV)
120
+ print("\nRunning full walk-forward benchmark (finetuned model, mode=all)...")
121
+ sparse = forecaster.load_sparse_data()
122
+ daytime_ts = set(sparse["timestamp_utc"])
123
+ test_starts = list(range(split_idx, len(df) - STEPS_PER_DAY, STEPS_PER_DAY))
124
+ all_actual, all_pred = [], []
125
+ for start_idx in test_starts:
126
+ f = forecaster.forecast_day(df, start_idx, STEPS_PER_DAY, covariate_mode="all")
127
+ actual_slice = df.iloc[start_idx : start_idx + STEPS_PER_DAY]
128
+ daytime_mask = actual_slice["timestamp_utc"].isin(daytime_ts).values[:len(f)]
129
+ if daytime_mask.sum() < 5:
130
+ continue
131
+ all_actual.append(actual_slice["A"].values[:len(f)][daytime_mask])
132
+ all_pred.append(np.clip(f["median"].values[daytime_mask], 0, None))
133
+ lora_mae = None
134
+ if all_actual:
135
+ from sklearn.metrics import mean_squared_error, r2_score
136
+ a = np.concatenate(all_actual)
137
+ p = np.concatenate(all_pred)
138
+ lora_mae = float(mean_absolute_error(a, p))
139
+ lora_rmse = float(np.sqrt(mean_squared_error(a, p)))
140
+ lora_r2 = float(r2_score(a, p))
141
+ print(f" LoRA / all: MAE={lora_mae:.4f} RMSE={lora_rmse:.4f} R²={lora_r2:.4f} ({len(all_actual)} windows, {len(a)} steps)")
142
+
143
+ # Load existing benchmark CSV, append lora row, save
144
+ bench_path = OUTPUTS_DIR / "chronos_benchmark.csv"
145
+ if bench_path.exists():
146
+ existing = pd.read_csv(bench_path)
147
+ lora_row = pd.DataFrame([{
148
+ "mode": "lora / all",
149
+ "MAE": lora_mae,
150
+ "RMSE": lora_rmse,
151
+ "R2": lora_r2,
152
+ "n_windows": len(all_actual),
153
+ "n_steps": len(a),
154
+ }])
155
+ combined = pd.concat([existing, lora_row], ignore_index=True)
156
+ combined.to_csv(bench_path, index=False)
157
+ print(f" Appended lora row → {bench_path}")
158
+ else:
159
+ pd.DataFrame([{
160
+ "mode": "lora / all", "MAE": lora_mae, "RMSE": lora_rmse, "R2": lora_r2,
161
+ "n_windows": len(all_actual), "n_steps": len(a),
162
+ }]).to_csv(bench_path, index=False)
163
+
164
+ # Sample forecast plot
165
+ print("\nGenerating sample forecast plot...")
166
+ forecaster.plot_sample_forecast(df)
167
+
168
+ # Summary and next steps
169
+ print("\n" + "=" * 60)
170
+ print("TRAINING COMPLETE — Next steps")
171
+ print("=" * 60)
172
+ print(f" Checkpoints: {output_dir}")
173
+ print(f" Benchmark: {OUTPUTS_DIR / 'chronos_benchmark.csv'} (lora / all row appended)")
174
+ print(f" Plot: {OUTPUTS_DIR / 'chronos_forecast_sample.png'} (from finetuned model)")
175
+ print(" • Refresh the Streamlit app: Prediction Accuracy tab will show LoRA / all in the table.")
176
+ print(" • Sample forecast image is from the finetuned model.")
177
+ if lora_mae is not None:
178
+ zs_mae = 3.91 # typical zero-shot 'all' on this eval
179
+ delta = (zs_mae - lora_mae) / zs_mae * 100
180
+ print(f" • LoRA MAE {lora_mae:.2f} vs zero-shot ~{zs_mae:.2f} ({delta:+.0f}% change).")
181
+ print("=" * 60)
182
+ print("Done.")
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
scripts/run_control_simulation.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Historical replay simulation through the full control loop.
4
+
5
+ Reads IMS weather history and replays it through the control pipeline:
6
+ DayAheadPlanner → ControlLoop.tick() → log results
7
+
8
+ Usage
9
+ -----
10
+ # Replay a hot July week (default)
11
+ python scripts/run_control_simulation.py
12
+
13
+ # Specify dates
14
+ python scripts/run_control_simulation.py --start 2025-07-01 --end 2025-07-07
15
+
16
+ # Dry run with verbose logging
17
+ python scripts/run_control_simulation.py --verbose
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import logging
25
+ import sys
26
+ from datetime import date, datetime, timedelta, timezone
27
+ from pathlib import Path
28
+
29
+ import pandas as pd
30
+
31
+ # Ensure project root is importable
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
33
+
34
+ from config.settings import (
35
+ DP_SLOT_DURATION_MIN,
36
+ SIMULATION_LOG_DIR,
37
+ )
38
+
39
+ logger = logging.getLogger("simulation")
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Simulation runner
44
+ # ---------------------------------------------------------------------------
45
+
46
+ class ControlSimulation:
47
+ """Replay historical weather through the control pipeline.
48
+
49
+ Parameters
50
+ ----------
51
+ start_date : date
52
+ First day to simulate.
53
+ end_date : date
54
+ Last day to simulate (inclusive).
55
+ """
56
+
57
+ def __init__(self, start_date: date, end_date: date):
58
+ self.start_date = start_date
59
+ self.end_date = end_date
60
+ self.results: list[dict] = []
61
+
62
+ def run(self) -> list[dict]:
63
+ """Run the full simulation."""
64
+ from src.control_loop import ControlLoop
65
+
66
+ loop = ControlLoop(dry_run=True)
67
+
68
+ current = self.start_date
69
+ day_count = 0
70
+
71
+ while current <= self.end_date:
72
+ day_count += 1
73
+ logger.info("=== Day %d: %s ===", day_count, current)
74
+
75
+ # Load/generate plan for this day
76
+ plan = loop.load_plan(current)
77
+ if plan is None:
78
+ logger.warning("No plan for %s — skipping", current)
79
+ current += timedelta(days=1)
80
+ continue
81
+
82
+ n_slots = len(plan.get("slots", []))
83
+ logger.info("Plan loaded: %d slots, stage=%s",
84
+ n_slots, plan.get("stage_id", "?"))
85
+
86
+ # Simulate each 15-min slot
87
+ for slot_idx in range(96):
88
+ hour = slot_idx // 4
89
+ minute = (slot_idx % 4) * DP_SLOT_DURATION_MIN
90
+ ts = datetime(
91
+ current.year, current.month, current.day,
92
+ hour, minute, 0,
93
+ tzinfo=timezone.utc,
94
+ )
95
+
96
+ try:
97
+ result = loop.tick(timestamp=ts)
98
+ entry = result.to_dict()
99
+ entry["sim_day"] = str(current)
100
+ self.results.append(entry)
101
+ except Exception as exc:
102
+ logger.error("Tick failed at %s: %s", ts, exc)
103
+ self.results.append({
104
+ "timestamp": ts.isoformat(),
105
+ "slot_index": slot_idx,
106
+ "sim_day": str(current),
107
+ "error": str(exc),
108
+ })
109
+
110
+ # Summary for this day
111
+ day_ticks = [r for r in self.results if r.get("sim_day") == str(current)]
112
+ interventions = sum(
113
+ 1 for r in day_ticks
114
+ if (r.get("plan_offset_deg") or 0) > 0
115
+ )
116
+ overrides = sum(1 for r in day_ticks if r.get("live_override"))
117
+ logger.info(
118
+ "Day %s: %d ticks, %d interventions, %d live overrides",
119
+ current, len(day_ticks), interventions, overrides,
120
+ )
121
+
122
+ current += timedelta(days=1)
123
+
124
+ logger.info("Simulation complete: %d total ticks over %d days",
125
+ len(self.results), day_count)
126
+ return self.results
127
+
128
+ def save(self, path: Path | None = None) -> Path:
129
+ """Save results to JSON."""
130
+ if path is None:
131
+ SIMULATION_LOG_DIR.mkdir(parents=True, exist_ok=True)
132
+ path = SIMULATION_LOG_DIR / (
133
+ f"sim_{self.start_date}_{self.end_date}.json"
134
+ )
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+ with open(path, "w") as f:
137
+ json.dump(self.results, f, indent=2, default=str)
138
+ logger.info("Saved %d results to %s", len(self.results), path)
139
+ return path
140
+
141
+ def print_summary(self) -> None:
142
+ """Print a human-readable summary."""
143
+ if not self.results:
144
+ print("No results to summarise.")
145
+ return
146
+
147
+ total = len(self.results)
148
+ errors = sum(1 for r in self.results if "error" in r)
149
+ interventions = sum(
150
+ 1 for r in self.results
151
+ if (r.get("plan_offset_deg") or 0) > 0
152
+ )
153
+ overrides = sum(1 for r in self.results if r.get("live_override"))
154
+ dispatched = sum(1 for r in self.results if r.get("dispatch"))
155
+
156
+ print(f"\n{'='*60}")
157
+ print(f"Simulation: {self.start_date} → {self.end_date}")
158
+ print(f"{'='*60}")
159
+ print(f"Total ticks: {total}")
160
+ print(f"Errors: {errors}")
161
+ print(f"Interventions: {interventions} ({interventions/total*100:.1f}%)")
162
+ print(f"Live overrides: {overrides}")
163
+ print(f"Dispatched: {dispatched}")
164
+
165
+ # Temperature stats
166
+ temps = [r["air_temp_c"] for r in self.results
167
+ if r.get("air_temp_c") is not None]
168
+ if temps:
169
+ print(f"\nTemperature: {min(temps):.1f}°C – {max(temps):.1f}°C "
170
+ f"(mean {sum(temps)/len(temps):.1f}°C)")
171
+
172
+ # Per-day breakdown
173
+ days = sorted(set(r.get("sim_day", "") for r in self.results))
174
+ print(f"\nPer-day breakdown:")
175
+ print(f"{'Date':<12} {'Ticks':>6} {'Interv':>7} {'Override':>9} {'MaxOff':>7}")
176
+ for day in days:
177
+ day_r = [r for r in self.results if r.get("sim_day") == day]
178
+ d_interv = sum(1 for r in day_r if (r.get("plan_offset_deg") or 0) > 0)
179
+ d_override = sum(1 for r in day_r if r.get("live_override"))
180
+ offsets = [r.get("plan_offset_deg", 0) or 0 for r in day_r]
181
+ max_off = max(offsets) if offsets else 0
182
+ print(f"{day:<12} {len(day_r):>6} {d_interv:>7} {d_override:>9} {max_off:>6.0f}°")
183
+
184
+ print(f"{'='*60}\n")
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # CLI
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def main():
192
+ parser = argparse.ArgumentParser(
193
+ description="Replay historical weather through the agrivoltaic control loop."
194
+ )
195
+ _today = date.today()
196
+ _default_start = (_today - timedelta(days=7)).isoformat()
197
+ _default_end = _today.isoformat()
198
+ parser.add_argument(
199
+ "--start", type=str, default=_default_start,
200
+ help=f"Start date (YYYY-MM-DD). Default: {_default_start} (today − 7 d)",
201
+ )
202
+ parser.add_argument(
203
+ "--end", type=str, default=_default_end,
204
+ help=f"End date (YYYY-MM-DD). Default: {_default_end} (today)",
205
+ )
206
+ parser.add_argument(
207
+ "--output", type=str, default=None,
208
+ help="Output JSON path (default: Data/simulation_logs/sim_<start>_<end>.json)",
209
+ )
210
+ parser.add_argument(
211
+ "--verbose", "-v", action="store_true",
212
+ help="Enable debug logging",
213
+ )
214
+ args = parser.parse_args()
215
+
216
+ level = logging.DEBUG if args.verbose else logging.INFO
217
+ logging.basicConfig(
218
+ level=level,
219
+ format="%(asctime)s %(name)-15s %(levelname)-7s %(message)s",
220
+ datefmt="%H:%M:%S",
221
+ )
222
+
223
+ start = date.fromisoformat(args.start)
224
+ end = date.fromisoformat(args.end)
225
+
226
+ sim = ControlSimulation(start, end)
227
+ sim.run()
228
+ sim.print_summary()
229
+
230
+ out_path = Path(args.output) if args.output else None
231
+ saved = sim.save(out_path)
232
+ print(f"Results saved to: {saved}")
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main()