Deploy: 2026 sensor migration + redesign + bucket B endpoints
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .claude/settings.local.json +113 -0
- .devcontainer/devcontainer.json +33 -0
- .env.example +31 -0
- .gitattributes +2 -0
- .github/workflows/control-tick.yml +27 -0
- .github/workflows/daily-planner.yml +28 -0
- .gitignore +38 -0
- .streamlit/config.toml +10 -0
- CLAUDE.md +205 -0
- DEPLOY.md +79 -0
- Data/2026/manual_observations.csv +1 -0
- Data/2026/sensor_history.parquet +3 -0
- Data/energy_predictor_model.pkl +3 -0
- Data/energy_weather_merged.csv +0 -0
- Data/ims/ims_merged_15min.csv +672 -672
- Data/layout.json +536 -0
- Data/processed/stage1_labels.csv +0 -0
- README.md +117 -82
- app.py +201 -0
- assets/logo.png +0 -0
- assets/vineyard_closeup.png +3 -0
- assets/vineyard_panels.png +3 -0
- backend/api/routes/control.py +101 -0
- backend/api/routes/energy.py +80 -1
- backend/api/routes/events.py +85 -1
- backend/api/routes/photosynthesis.py +76 -0
- backend/workers/control_tick.py +11 -0
- config/settings.py +31 -0
- context/.gitkeep +0 -0
- context/1_purpose.md +20 -0
- context/2_plan.md +1534 -0
- context/3_todo.md +555 -0
- context/4_production.md +564 -0
- context/CODE_REVIEW.md +311 -0
- context/refactor_todo.md +83 -0
- docker-compose.yml +17 -0
- ims_api_documentation.md +287 -0
- scripts/__init__.py +1 -0
- scripts/collect_2026_training_data.py +411 -0
- scripts/create_pptx.py +774 -0
- scripts/create_sample_data.py +54 -0
- scripts/download_ims_data.py +123 -0
- scripts/eda.py +91 -0
- scripts/html_to_docx.py +602 -0
- scripts/import_layout.py +224 -0
- scripts/load_test.py +101 -0
- scripts/refresh_energy_data.py +242 -0
- scripts/run_chatbot_qa.py +48 -0
- scripts/run_chronos_long_training.py +186 -0
- 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-
|
| 3 |
-
2026-
|
| 4 |
-
2026-
|
| 5 |
-
2026-
|
| 6 |
-
2026-
|
| 7 |
-
2026-
|
| 8 |
-
2026-
|
| 9 |
-
2026-
|
| 10 |
-
2026-
|
| 11 |
-
2026-
|
| 12 |
-
2026-
|
| 13 |
-
2026-
|
| 14 |
-
2026-
|
| 15 |
-
2026-
|
| 16 |
-
2026-
|
| 17 |
-
2026-
|
| 18 |
-
2026-
|
| 19 |
-
2026-
|
| 20 |
-
2026-
|
| 21 |
-
2026-
|
| 22 |
-
2026-
|
| 23 |
-
2026-
|
| 24 |
-
2026-
|
| 25 |
-
2026-
|
| 26 |
-
2026-
|
| 27 |
-
2026-
|
| 28 |
-
2026-
|
| 29 |
-
2026-
|
| 30 |
-
2026-
|
| 31 |
-
2026-
|
| 32 |
-
2026-
|
| 33 |
-
2026-
|
| 34 |
-
2026-
|
| 35 |
-
2026-
|
| 36 |
-
2026-
|
| 37 |
-
2026-
|
| 38 |
-
2026-
|
| 39 |
-
2026-
|
| 40 |
-
2026-
|
| 41 |
-
2026-
|
| 42 |
-
2026-
|
| 43 |
-
2026-
|
| 44 |
-
2026-
|
| 45 |
-
2026-
|
| 46 |
-
2026-
|
| 47 |
-
2026-
|
| 48 |
-
2026-
|
| 49 |
-
2026-
|
| 50 |
-
2026-
|
| 51 |
-
2026-
|
| 52 |
-
2026-
|
| 53 |
-
2026-
|
| 54 |
-
2026-
|
| 55 |
-
2026-
|
| 56 |
-
2026-
|
| 57 |
-
2026-
|
| 58 |
-
2026-
|
| 59 |
-
2026-
|
| 60 |
-
2026-
|
| 61 |
-
2026-
|
| 62 |
-
2026-
|
| 63 |
-
2026-
|
| 64 |
-
2026-
|
| 65 |
-
2026-
|
| 66 |
-
2026-
|
| 67 |
-
2026-
|
| 68 |
-
2026-
|
| 69 |
-
2026-
|
| 70 |
-
2026-
|
| 71 |
-
2026-
|
| 72 |
-
2026-
|
| 73 |
-
2026-
|
| 74 |
-
2026-
|
| 75 |
-
2026-
|
| 76 |
-
2026-
|
| 77 |
-
2026-
|
| 78 |
-
2026-
|
| 79 |
-
2026-
|
| 80 |
-
2026-
|
| 81 |
-
2026-
|
| 82 |
-
2026-
|
| 83 |
-
2026-
|
| 84 |
-
2026-
|
| 85 |
-
2026-
|
| 86 |
-
2026-
|
| 87 |
-
2026-
|
| 88 |
-
2026-
|
| 89 |
-
2026-
|
| 90 |
-
2026-
|
| 91 |
-
2026-
|
| 92 |
-
2026-
|
| 93 |
-
2026-
|
| 94 |
-
2026-
|
| 95 |
-
2026-
|
| 96 |
-
2026-
|
| 97 |
-
2026-
|
| 98 |
-
2026-
|
| 99 |
-
2026-
|
| 100 |
-
2026-
|
| 101 |
-
2026-
|
| 102 |
-
2026-
|
| 103 |
-
2026-
|
| 104 |
-
2026-
|
| 105 |
-
2026-
|
| 106 |
-
2026-
|
| 107 |
-
2026-
|
| 108 |
-
2026-
|
| 109 |
-
2026-
|
| 110 |
-
2026-
|
| 111 |
-
2026-
|
| 112 |
-
2026-
|
| 113 |
-
2026-
|
| 114 |
-
2026-
|
| 115 |
-
2026-
|
| 116 |
-
2026-
|
| 117 |
-
2026-
|
| 118 |
-
2026-
|
| 119 |
-
2026-
|
| 120 |
-
2026-
|
| 121 |
-
2026-
|
| 122 |
-
2026-
|
| 123 |
-
2026-
|
| 124 |
-
2026-
|
| 125 |
-
2026-
|
| 126 |
-
2026-
|
| 127 |
-
2026-
|
| 128 |
-
2026-
|
| 129 |
-
2026-
|
| 130 |
-
2026-
|
| 131 |
-
2026-
|
| 132 |
-
2026-
|
| 133 |
-
2026-
|
| 134 |
-
2026-
|
| 135 |
-
2026-
|
| 136 |
-
2026-
|
| 137 |
-
2026-
|
| 138 |
-
2026-
|
| 139 |
-
2026-
|
| 140 |
-
2026-
|
| 141 |
-
2026-
|
| 142 |
-
2026-
|
| 143 |
-
2026-
|
| 144 |
-
2026-
|
| 145 |
-
2026-
|
| 146 |
-
2026-
|
| 147 |
-
2026-
|
| 148 |
-
2026-
|
| 149 |
-
2026-
|
| 150 |
-
2026-
|
| 151 |
-
2026-
|
| 152 |
-
2026-
|
| 153 |
-
2026-
|
| 154 |
-
2026-
|
| 155 |
-
2026-
|
| 156 |
-
2026-
|
| 157 |
-
2026-
|
| 158 |
-
2026-
|
| 159 |
-
2026-
|
| 160 |
-
2026-
|
| 161 |
-
2026-
|
| 162 |
-
2026-
|
| 163 |
-
2026-
|
| 164 |
-
2026-
|
| 165 |
-
2026-
|
| 166 |
-
2026-
|
| 167 |
-
2026-
|
| 168 |
-
2026-
|
| 169 |
-
2026-
|
| 170 |
-
2026-
|
| 171 |
-
2026-
|
| 172 |
-
2026-
|
| 173 |
-
2026-
|
| 174 |
-
2026-
|
| 175 |
-
2026-
|
| 176 |
-
2026-
|
| 177 |
-
2026-
|
| 178 |
-
2026-
|
| 179 |
-
2026-
|
| 180 |
-
2026-
|
| 181 |
-
2026-
|
| 182 |
-
2026-
|
| 183 |
-
2026-
|
| 184 |
-
2026-
|
| 185 |
-
2026-
|
| 186 |
-
2026-
|
| 187 |
-
2026-
|
| 188 |
-
2026-
|
| 189 |
-
2026-
|
| 190 |
-
2026-
|
| 191 |
-
2026-
|
| 192 |
-
2026-
|
| 193 |
-
2026-
|
| 194 |
-
2026-
|
| 195 |
-
2026-
|
| 196 |
-
2026-
|
| 197 |
-
2026-
|
| 198 |
-
2026-
|
| 199 |
-
2026-
|
| 200 |
-
2026-
|
| 201 |
-
2026-
|
| 202 |
-
2026-
|
| 203 |
-
2026-
|
| 204 |
-
2026-
|
| 205 |
-
2026-
|
| 206 |
-
2026-
|
| 207 |
-
2026-
|
| 208 |
-
2026-
|
| 209 |
-
2026-
|
| 210 |
-
2026-
|
| 211 |
-
2026-
|
| 212 |
-
2026-
|
| 213 |
-
2026-
|
| 214 |
-
2026-
|
| 215 |
-
2026-
|
| 216 |
-
2026-
|
| 217 |
-
2026-
|
| 218 |
-
2026-
|
| 219 |
-
2026-
|
| 220 |
-
2026-
|
| 221 |
-
2026-
|
| 222 |
-
2026-
|
| 223 |
-
2026-
|
| 224 |
-
2026-
|
| 225 |
-
2026-
|
| 226 |
-
2026-
|
| 227 |
-
2026-
|
| 228 |
-
2026-
|
| 229 |
-
2026-
|
| 230 |
-
2026-
|
| 231 |
-
2026-
|
| 232 |
-
2026-
|
| 233 |
-
2026-
|
| 234 |
-
2026-
|
| 235 |
-
2026-
|
| 236 |
-
2026-
|
| 237 |
-
2026-
|
| 238 |
-
2026-
|
| 239 |
-
2026-
|
| 240 |
-
2026-
|
| 241 |
-
2026-
|
| 242 |
-
2026-
|
| 243 |
-
2026-
|
| 244 |
-
2026-
|
| 245 |
-
2026-
|
| 246 |
-
2026-
|
| 247 |
-
2026-
|
| 248 |
-
2026-
|
| 249 |
-
2026-
|
| 250 |
-
2026-
|
| 251 |
-
2026-
|
| 252 |
-
2026-
|
| 253 |
-
2026-
|
| 254 |
-
2026-
|
| 255 |
-
2026-
|
| 256 |
-
2026-
|
| 257 |
-
2026-
|
| 258 |
-
2026-
|
| 259 |
-
2026-
|
| 260 |
-
2026-
|
| 261 |
-
2026-
|
| 262 |
-
2026-
|
| 263 |
-
2026-
|
| 264 |
-
2026-
|
| 265 |
-
2026-
|
| 266 |
-
2026-
|
| 267 |
-
2026-
|
| 268 |
-
2026-
|
| 269 |
-
2026-
|
| 270 |
-
2026-
|
| 271 |
-
2026-
|
| 272 |
-
2026-
|
| 273 |
-
2026-
|
| 274 |
-
2026-
|
| 275 |
-
2026-
|
| 276 |
-
2026-
|
| 277 |
-
2026-
|
| 278 |
-
2026-
|
| 279 |
-
2026-
|
| 280 |
-
2026-
|
| 281 |
-
2026-
|
| 282 |
-
2026-
|
| 283 |
-
2026-
|
| 284 |
-
2026-
|
| 285 |
-
2026-
|
| 286 |
-
2026-
|
| 287 |
-
2026-
|
| 288 |
-
2026-
|
| 289 |
-
2026-
|
| 290 |
-
2026-
|
| 291 |
-
2026-
|
| 292 |
-
2026-
|
| 293 |
-
2026-
|
| 294 |
-
2026-
|
| 295 |
-
2026-
|
| 296 |
-
2026-
|
| 297 |
-
2026-
|
| 298 |
-
2026-
|
| 299 |
-
2026-
|
| 300 |
-
2026-
|
| 301 |
-
2026-
|
| 302 |
-
2026-
|
| 303 |
-
2026-
|
| 304 |
-
2026-
|
| 305 |
-
2026-
|
| 306 |
-
2026-
|
| 307 |
-
2026-
|
| 308 |
-
2026-
|
| 309 |
-
2026-
|
| 310 |
-
2026-
|
| 311 |
-
2026-
|
| 312 |
-
2026-
|
| 313 |
-
2026-
|
| 314 |
-
2026-
|
| 315 |
-
2026-
|
| 316 |
-
2026-
|
| 317 |
-
2026-
|
| 318 |
-
2026-
|
| 319 |
-
2026-
|
| 320 |
-
2026-
|
| 321 |
-
2026-
|
| 322 |
-
2026-
|
| 323 |
-
2026-
|
| 324 |
-
2026-
|
| 325 |
-
2026-
|
| 326 |
-
2026-
|
| 327 |
-
2026-
|
| 328 |
-
2026-
|
| 329 |
-
2026-
|
| 330 |
-
2026-
|
| 331 |
-
2026-
|
| 332 |
-
2026-
|
| 333 |
-
2026-
|
| 334 |
-
2026-
|
| 335 |
-
2026-
|
| 336 |
-
2026-
|
| 337 |
-
2026-
|
| 338 |
-
2026-
|
| 339 |
-
2026-
|
| 340 |
-
2026-
|
| 341 |
-
2026-
|
| 342 |
-
2026-
|
| 343 |
-
2026-
|
| 344 |
-
2026-
|
| 345 |
-
2026-
|
| 346 |
-
2026-
|
| 347 |
-
2026-
|
| 348 |
-
2026-
|
| 349 |
-
2026-
|
| 350 |
-
2026-
|
| 351 |
-
2026-
|
| 352 |
-
2026-
|
| 353 |
-
2026-
|
| 354 |
-
2026-
|
| 355 |
-
2026-
|
| 356 |
-
2026-
|
| 357 |
-
2026-
|
| 358 |
-
2026-
|
| 359 |
-
2026-
|
| 360 |
-
2026-
|
| 361 |
-
2026-
|
| 362 |
-
2026-
|
| 363 |
-
2026-
|
| 364 |
-
2026-
|
| 365 |
-
2026-
|
| 366 |
-
2026-
|
| 367 |
-
2026-
|
| 368 |
-
2026-
|
| 369 |
-
2026-
|
| 370 |
-
2026-
|
| 371 |
-
2026-
|
| 372 |
-
2026-
|
| 373 |
-
2026-
|
| 374 |
-
2026-
|
| 375 |
-
2026-
|
| 376 |
-
2026-
|
| 377 |
-
2026-
|
| 378 |
-
2026-
|
| 379 |
-
2026-
|
| 380 |
-
2026-
|
| 381 |
-
2026-
|
| 382 |
-
2026-
|
| 383 |
-
2026-
|
| 384 |
-
2026-
|
| 385 |
-
2026-
|
| 386 |
-
2026-
|
| 387 |
-
2026-
|
| 388 |
-
2026-
|
| 389 |
-
2026-
|
| 390 |
-
2026-
|
| 391 |
-
2026-
|
| 392 |
-
2026-
|
| 393 |
-
2026-
|
| 394 |
-
2026-
|
| 395 |
-
2026-
|
| 396 |
-
2026-
|
| 397 |
-
2026-
|
| 398 |
-
2026-
|
| 399 |
-
2026-
|
| 400 |
-
2026-
|
| 401 |
-
2026-
|
| 402 |
-
2026-
|
| 403 |
-
2026-
|
| 404 |
-
2026-
|
| 405 |
-
2026-
|
| 406 |
-
2026-
|
| 407 |
-
2026-
|
| 408 |
-
2026-
|
| 409 |
-
2026-
|
| 410 |
-
2026-
|
| 411 |
-
2026-
|
| 412 |
-
2026-
|
| 413 |
-
2026-
|
| 414 |
-
2026-
|
| 415 |
-
2026-
|
| 416 |
-
2026-
|
| 417 |
-
2026-
|
| 418 |
-
2026-
|
| 419 |
-
2026-
|
| 420 |
-
2026-
|
| 421 |
-
2026-
|
| 422 |
-
2026-
|
| 423 |
-
2026-
|
| 424 |
-
2026-
|
| 425 |
-
2026-
|
| 426 |
-
2026-
|
| 427 |
-
2026-
|
| 428 |
-
2026-
|
| 429 |
-
2026-
|
| 430 |
-
2026-
|
| 431 |
-
2026-
|
| 432 |
-
2026-
|
| 433 |
-
2026-
|
| 434 |
-
2026-
|
| 435 |
-
2026-
|
| 436 |
-
2026-
|
| 437 |
-
2026-
|
| 438 |
-
2026-
|
| 439 |
-
2026-
|
| 440 |
-
2026-
|
| 441 |
-
2026-
|
| 442 |
-
2026-
|
| 443 |
-
2026-
|
| 444 |
-
2026-
|
| 445 |
-
2026-
|
| 446 |
-
2026-
|
| 447 |
-
2026-
|
| 448 |
-
2026-
|
| 449 |
-
2026-
|
| 450 |
-
2026-
|
| 451 |
-
2026-
|
| 452 |
-
2026-
|
| 453 |
-
2026-
|
| 454 |
-
2026-
|
| 455 |
-
2026-
|
| 456 |
-
2026-
|
| 457 |
-
2026-
|
| 458 |
-
2026-
|
| 459 |
-
2026-
|
| 460 |
-
2026-
|
| 461 |
-
2026-
|
| 462 |
-
2026-
|
| 463 |
-
2026-
|
| 464 |
-
2026-
|
| 465 |
-
2026-
|
| 466 |
-
2026-
|
| 467 |
-
2026-
|
| 468 |
-
2026-
|
| 469 |
-
2026-
|
| 470 |
-
2026-
|
| 471 |
-
2026-
|
| 472 |
-
2026-
|
| 473 |
-
2026-
|
| 474 |
-
2026-
|
| 475 |
-
2026-
|
| 476 |
-
2026-
|
| 477 |
-
2026-
|
| 478 |
-
2026-
|
| 479 |
-
2026-
|
| 480 |
-
2026-
|
| 481 |
-
2026-
|
| 482 |
-
2026-
|
| 483 |
-
2026-
|
| 484 |
-
2026-
|
| 485 |
-
2026-
|
| 486 |
-
2026-
|
| 487 |
-
2026-
|
| 488 |
-
2026-
|
| 489 |
-
2026-
|
| 490 |
-
2026-
|
| 491 |
-
2026-
|
| 492 |
-
2026-
|
| 493 |
-
2026-
|
| 494 |
-
2026-
|
| 495 |
-
2026-
|
| 496 |
-
2026-
|
| 497 |
-
2026-
|
| 498 |
-
2026-
|
| 499 |
-
2026-
|
| 500 |
-
2026-
|
| 501 |
-
2026-
|
| 502 |
-
2026-
|
| 503 |
-
2026-
|
| 504 |
-
2026-
|
| 505 |
-
2026-
|
| 506 |
-
2026-
|
| 507 |
-
2026-
|
| 508 |
-
2026-
|
| 509 |
-
2026-
|
| 510 |
-
2026-
|
| 511 |
-
2026-
|
| 512 |
-
2026-
|
| 513 |
-
2026-
|
| 514 |
-
2026-
|
| 515 |
-
2026-
|
| 516 |
-
2026-
|
| 517 |
-
2026-
|
| 518 |
-
2026-
|
| 519 |
-
2026-
|
| 520 |
-
2026-
|
| 521 |
-
2026-
|
| 522 |
-
2026-
|
| 523 |
-
2026-
|
| 524 |
-
2026-
|
| 525 |
-
2026-
|
| 526 |
-
2026-
|
| 527 |
-
2026-
|
| 528 |
-
2026-
|
| 529 |
-
2026-
|
| 530 |
-
2026-
|
| 531 |
-
2026-
|
| 532 |
-
2026-
|
| 533 |
-
2026-
|
| 534 |
-
2026-
|
| 535 |
-
2026-
|
| 536 |
-
2026-
|
| 537 |
-
2026-
|
| 538 |
-
2026-
|
| 539 |
-
2026-
|
| 540 |
-
2026-
|
| 541 |
-
2026-
|
| 542 |
-
2026-
|
| 543 |
-
2026-
|
| 544 |
-
2026-
|
| 545 |
-
2026-
|
| 546 |
-
2026-
|
| 547 |
-
2026-
|
| 548 |
-
2026-
|
| 549 |
-
2026-
|
| 550 |
-
2026-
|
| 551 |
-
2026-
|
| 552 |
-
2026-
|
| 553 |
-
2026-
|
| 554 |
-
2026-
|
| 555 |
-
2026-
|
| 556 |
-
2026-
|
| 557 |
-
2026-
|
| 558 |
-
2026-
|
| 559 |
-
2026-
|
| 560 |
-
2026-
|
| 561 |
-
2026-
|
| 562 |
-
2026-
|
| 563 |
-
2026-
|
| 564 |
-
2026-
|
| 565 |
-
2026-
|
| 566 |
-
2026-
|
| 567 |
-
2026-
|
| 568 |
-
2026-
|
| 569 |
-
2026-
|
| 570 |
-
2026-
|
| 571 |
-
2026-
|
| 572 |
-
2026-
|
| 573 |
-
2026-
|
| 574 |
-
2026-
|
| 575 |
-
2026-
|
| 576 |
-
2026-
|
| 577 |
-
2026-
|
| 578 |
-
2026-
|
| 579 |
-
2026-
|
| 580 |
-
2026-
|
| 581 |
-
2026-
|
| 582 |
-
2026-
|
| 583 |
-
2026-
|
| 584 |
-
2026-
|
| 585 |
-
2026-
|
| 586 |
-
2026-
|
| 587 |
-
2026-
|
| 588 |
-
2026-
|
| 589 |
-
2026-
|
| 590 |
-
2026-
|
| 591 |
-
2026-
|
| 592 |
-
2026-
|
| 593 |
-
2026-
|
| 594 |
-
2026-
|
| 595 |
-
2026-
|
| 596 |
-
2026-
|
| 597 |
-
2026-
|
| 598 |
-
2026-
|
| 599 |
-
2026-
|
| 600 |
-
2026-
|
| 601 |
-
2026-
|
| 602 |
-
2026-
|
| 603 |
-
2026-
|
| 604 |
-
2026-
|
| 605 |
-
2026-
|
| 606 |
-
2026-
|
| 607 |
-
2026-
|
| 608 |
-
2026-
|
| 609 |
-
2026-
|
| 610 |
-
2026-
|
| 611 |
-
2026-
|
| 612 |
-
2026-
|
| 613 |
-
2026-
|
| 614 |
-
2026-
|
| 615 |
-
2026-
|
| 616 |
-
2026-
|
| 617 |
-
2026-
|
| 618 |
-
2026-
|
| 619 |
-
2026-
|
| 620 |
-
2026-
|
| 621 |
-
2026-
|
| 622 |
-
2026-
|
| 623 |
-
2026-
|
| 624 |
-
2026-
|
| 625 |
-
2026-
|
| 626 |
-
2026-
|
| 627 |
-
2026-
|
| 628 |
-
2026-
|
| 629 |
-
2026-
|
| 630 |
-
2026-
|
| 631 |
-
2026-
|
| 632 |
-
2026-
|
| 633 |
-
2026-
|
| 634 |
-
2026-
|
| 635 |
-
2026-
|
| 636 |
-
2026-
|
| 637 |
-
2026-
|
| 638 |
-
2026-
|
| 639 |
-
2026-
|
| 640 |
-
2026-
|
| 641 |
-
2026-
|
| 642 |
-
2026-
|
| 643 |
-
2026-
|
| 644 |
-
2026-
|
| 645 |
-
2026-
|
| 646 |
-
2026-
|
| 647 |
-
2026-
|
| 648 |
-
2026-
|
| 649 |
-
2026-
|
| 650 |
-
2026-
|
| 651 |
-
2026-
|
| 652 |
-
2026-
|
| 653 |
-
2026-
|
| 654 |
-
2026-
|
| 655 |
-
2026-
|
| 656 |
-
2026-
|
| 657 |
-
2026-
|
| 658 |
-
2026-
|
| 659 |
-
2026-
|
| 660 |
-
2026-
|
| 661 |
-
2026-
|
| 662 |
-
2026-
|
| 663 |
-
2026-
|
| 664 |
-
2026-
|
| 665 |
-
2026-
|
| 666 |
-
2026-
|
| 667 |
-
2026-
|
| 668 |
-
2026-
|
| 669 |
-
2026-
|
| 670 |
-
2026-
|
| 671 |
-
2026-
|
| 672 |
-
2026-
|
| 673 |
-
2026-
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
##
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
-
|
| 37 |
-
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
-
|
| 41 |
-
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
-
|
| 56 |
-
|
| 57 |
-
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 2 — ML 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
|
assets/vineyard_panels.png
ADDED
|
Git LFS Details
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 < 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()
|