Fix race conditions, error handling, timezone, divergence check
Browse files- backend/api/main.py +10 -9
- backend/api/routes/chatbot.py +4 -5
- backend/api/routes/energy.py +5 -13
- backend/api/routes/health.py +4 -1
- backend/api/routes/sensors.py +3 -11
- backend/api/utils.py +21 -0
- backend/workers/control_tick.py +2 -1
- backend/workers/daily_planner.py +5 -2
- src/control_loop.py +1 -1
backend/api/main.py
CHANGED
|
@@ -196,15 +196,16 @@ async def lifespan(app: FastAPI):
|
|
| 196 |
|
| 197 |
# Start background refresh loops
|
| 198 |
import asyncio
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
| 208 |
|
| 209 |
|
| 210 |
def get_uptime() -> float:
|
|
|
|
| 196 |
|
| 197 |
# Start background refresh loops
|
| 198 |
import asyncio
|
| 199 |
+
tasks: list[asyncio.Task] = []
|
| 200 |
+
try:
|
| 201 |
+
tasks.append(asyncio.create_task(_ims_refresh_loop()))
|
| 202 |
+
tasks.append(asyncio.create_task(_sensor_refresh_loop()))
|
| 203 |
+
tasks.append(asyncio.create_task(_data_flow_alert_loop()))
|
| 204 |
+
yield
|
| 205 |
+
finally:
|
| 206 |
+
for t in tasks:
|
| 207 |
+
t.cancel()
|
| 208 |
+
log.info("SolarWine API shutting down (uptime=%.0fs)", get_uptime())
|
| 209 |
|
| 210 |
|
| 211 |
def get_uptime() -> float:
|
backend/api/routes/chatbot.py
CHANGED
|
@@ -45,13 +45,11 @@ _chatbot_init_failed = False
|
|
| 45 |
|
| 46 |
def _get_chatbot(hub: DataHub):
|
| 47 |
global _chatbot, _chatbot_init_failed
|
| 48 |
-
if _chatbot is not None:
|
| 49 |
-
return _chatbot
|
| 50 |
-
if _chatbot_init_failed:
|
| 51 |
-
return None
|
| 52 |
with _chatbot_lock:
|
| 53 |
if _chatbot is not None:
|
| 54 |
return _chatbot
|
|
|
|
|
|
|
| 55 |
try:
|
| 56 |
from src.chatbot.vineyard_chatbot import VineyardChatbot
|
| 57 |
_chatbot = VineyardChatbot(hub=hub)
|
|
@@ -90,7 +88,8 @@ async def chat_briefing(hub: DataHub = Depends(get_datahub)):
|
|
| 90 |
if bot is None:
|
| 91 |
raise HTTPException(status_code=503, detail="Chatbot unavailable")
|
| 92 |
try:
|
| 93 |
-
|
|
|
|
| 94 |
except Exception as exc:
|
| 95 |
log.error("Briefing error: %s", exc)
|
| 96 |
return {"briefing": ""}
|
|
|
|
| 45 |
|
| 46 |
def _get_chatbot(hub: DataHub):
|
| 47 |
global _chatbot, _chatbot_init_failed
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
with _chatbot_lock:
|
| 49 |
if _chatbot is not None:
|
| 50 |
return _chatbot
|
| 51 |
+
if _chatbot_init_failed:
|
| 52 |
+
return None
|
| 53 |
try:
|
| 54 |
from src.chatbot.vineyard_chatbot import VineyardChatbot
|
| 55 |
_chatbot = VineyardChatbot(hub=hub)
|
|
|
|
| 88 |
if bot is None:
|
| 89 |
raise HTTPException(status_code=503, detail="Chatbot unavailable")
|
| 90 |
try:
|
| 91 |
+
briefing_fn = getattr(bot, "build_status_briefing", None) or getattr(bot, "_build_status_briefing", None)
|
| 92 |
+
return {"briefing": briefing_fn() if briefing_fn else ""}
|
| 93 |
except Exception as exc:
|
| 94 |
log.error("Briefing error: %s", exc)
|
| 95 |
return {"briefing": ""}
|
backend/api/routes/energy.py
CHANGED
|
@@ -8,6 +8,7 @@ import re
|
|
| 8 |
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 9 |
|
| 10 |
from backend.api.deps import get_datahub
|
|
|
|
| 11 |
from src.data.data_providers import DataHub
|
| 12 |
|
| 13 |
log = logging.getLogger(__name__)
|
|
@@ -22,26 +23,17 @@ def _validate_date(value: str) -> str:
|
|
| 22 |
return value
|
| 23 |
|
| 24 |
|
| 25 |
-
def _check_error(result: dict, context: str = "") -> dict:
|
| 26 |
-
"""If a data provider returned an error dict, raise HTTPException."""
|
| 27 |
-
if isinstance(result, dict) and "error" in result and result.get("daily_kwh") is None:
|
| 28 |
-
msg = result["error"]
|
| 29 |
-
log.warning("Data provider error%s: %s", f" ({context})" if context else "", msg)
|
| 30 |
-
raise HTTPException(status_code=502, detail=msg)
|
| 31 |
-
return result
|
| 32 |
-
|
| 33 |
-
|
| 34 |
@router.get("/current")
|
| 35 |
async def energy_current(hub: DataHub = Depends(get_datahub)):
|
| 36 |
result = hub.energy.get_current()
|
| 37 |
-
return
|
| 38 |
|
| 39 |
|
| 40 |
@router.get("/daily/{target_date}")
|
| 41 |
async def energy_daily(target_date: str, hub: DataHub = Depends(get_datahub)):
|
| 42 |
_validate_date(target_date)
|
| 43 |
result = hub.energy.get_daily_production(target_date=target_date)
|
| 44 |
-
return
|
| 45 |
|
| 46 |
|
| 47 |
@router.get("/history")
|
|
@@ -50,11 +42,11 @@ async def energy_history(
|
|
| 50 |
hub: DataHub = Depends(get_datahub),
|
| 51 |
):
|
| 52 |
result = hub.energy.get_history(hours_back=hours)
|
| 53 |
-
return
|
| 54 |
|
| 55 |
|
| 56 |
@router.get("/predict/{target_date}")
|
| 57 |
async def energy_predict(target_date: str, hub: DataHub = Depends(get_datahub)):
|
| 58 |
_validate_date(target_date)
|
| 59 |
result = hub.energy.predict(target_date=target_date)
|
| 60 |
-
return
|
|
|
|
| 8 |
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 9 |
|
| 10 |
from backend.api.deps import get_datahub
|
| 11 |
+
from backend.api.utils import check_service_error
|
| 12 |
from src.data.data_providers import DataHub
|
| 13 |
|
| 14 |
log = logging.getLogger(__name__)
|
|
|
|
| 23 |
return value
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
@router.get("/current")
|
| 27 |
async def energy_current(hub: DataHub = Depends(get_datahub)):
|
| 28 |
result = hub.energy.get_current()
|
| 29 |
+
return check_service_error(result, "energy current")
|
| 30 |
|
| 31 |
|
| 32 |
@router.get("/daily/{target_date}")
|
| 33 |
async def energy_daily(target_date: str, hub: DataHub = Depends(get_datahub)):
|
| 34 |
_validate_date(target_date)
|
| 35 |
result = hub.energy.get_daily_production(target_date=target_date)
|
| 36 |
+
return check_service_error(result, "energy daily")
|
| 37 |
|
| 38 |
|
| 39 |
@router.get("/history")
|
|
|
|
| 42 |
hub: DataHub = Depends(get_datahub),
|
| 43 |
):
|
| 44 |
result = hub.energy.get_history(hours_back=hours)
|
| 45 |
+
return check_service_error(result, "energy history")
|
| 46 |
|
| 47 |
|
| 48 |
@router.get("/predict/{target_date}")
|
| 49 |
async def energy_predict(target_date: str, hub: DataHub = Depends(get_datahub)):
|
| 50 |
_validate_date(target_date)
|
| 51 |
result = hub.energy.predict(target_date=target_date)
|
| 52 |
+
return check_service_error(result, "energy predict")
|
backend/api/routes/health.py
CHANGED
|
@@ -34,7 +34,10 @@ async def _check_thingsboard() -> bool:
|
|
| 34 |
@router.api_route("/health", methods=["GET", "HEAD"])
|
| 35 |
async def health():
|
| 36 |
redis = get_redis_client()
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
from backend.api.main import get_uptime
|
| 40 |
|
|
|
|
| 34 |
@router.api_route("/health", methods=["GET", "HEAD"])
|
| 35 |
async def health():
|
| 36 |
redis = get_redis_client()
|
| 37 |
+
try:
|
| 38 |
+
redis_ok = redis.ping() if redis else False
|
| 39 |
+
except Exception:
|
| 40 |
+
redis_ok = False
|
| 41 |
|
| 42 |
from backend.api.main import get_uptime
|
| 43 |
|
backend/api/routes/sensors.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import Optional
|
|
| 9 |
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 10 |
|
| 11 |
from backend.api.deps import get_datahub
|
|
|
|
| 12 |
from src.data.data_providers import DataHub
|
| 13 |
|
| 14 |
log = logging.getLogger(__name__)
|
|
@@ -27,22 +28,13 @@ class AreaType(str, Enum):
|
|
| 27 |
ambient = "ambient"
|
| 28 |
|
| 29 |
|
| 30 |
-
def _check_error(result: dict, context: str = "") -> dict:
|
| 31 |
-
"""If a data provider returned an error dict, raise HTTPException."""
|
| 32 |
-
if isinstance(result, dict) and "error" in result:
|
| 33 |
-
msg = result["error"]
|
| 34 |
-
log.warning("Data provider error%s: %s", f" ({context})" if context else "", msg)
|
| 35 |
-
raise HTTPException(status_code=502, detail=msg)
|
| 36 |
-
return result
|
| 37 |
-
|
| 38 |
-
|
| 39 |
@router.get("/snapshot")
|
| 40 |
async def sensors_snapshot(
|
| 41 |
light: bool = False,
|
| 42 |
hub: DataHub = Depends(get_datahub),
|
| 43 |
):
|
| 44 |
result = hub.vine_sensors.get_snapshot(light=light)
|
| 45 |
-
return
|
| 46 |
|
| 47 |
|
| 48 |
@router.get("/history")
|
|
@@ -58,7 +50,7 @@ async def sensors_history(
|
|
| 58 |
area=area.value if area else "treatment",
|
| 59 |
hours_back=hours,
|
| 60 |
)
|
| 61 |
-
return
|
| 62 |
except HTTPException:
|
| 63 |
raise
|
| 64 |
except Exception as exc:
|
|
|
|
| 9 |
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 10 |
|
| 11 |
from backend.api.deps import get_datahub
|
| 12 |
+
from backend.api.utils import check_service_error
|
| 13 |
from src.data.data_providers import DataHub
|
| 14 |
|
| 15 |
log = logging.getLogger(__name__)
|
|
|
|
| 28 |
ambient = "ambient"
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
@router.get("/snapshot")
|
| 32 |
async def sensors_snapshot(
|
| 33 |
light: bool = False,
|
| 34 |
hub: DataHub = Depends(get_datahub),
|
| 35 |
):
|
| 36 |
result = hub.vine_sensors.get_snapshot(light=light)
|
| 37 |
+
return check_service_error(result, "snapshot")
|
| 38 |
|
| 39 |
|
| 40 |
@router.get("/history")
|
|
|
|
| 50 |
area=area.value if area else "treatment",
|
| 51 |
hours_back=hours,
|
| 52 |
)
|
| 53 |
+
return check_service_error(result, "sensor history")
|
| 54 |
except HTTPException:
|
| 55 |
raise
|
| 56 |
except Exception as exc:
|
backend/api/utils.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities for API route handlers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
|
| 9 |
+
log = logging.getLogger("solarwine.api")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def check_service_error(result, context: str = ""):
|
| 13 |
+
"""If a data provider returned an error dict, raise HTTPException(502).
|
| 14 |
+
|
| 15 |
+
Standardized error check used by all route modules.
|
| 16 |
+
"""
|
| 17 |
+
if isinstance(result, dict) and "error" in result:
|
| 18 |
+
msg = result["error"]
|
| 19 |
+
log.warning("Data provider error%s: %s", f" ({context})" if context else "", msg)
|
| 20 |
+
raise HTTPException(status_code=502, detail=msg)
|
| 21 |
+
return result
|
backend/workers/control_tick.py
CHANGED
|
@@ -62,11 +62,12 @@ def main():
|
|
| 62 |
log.info("Tick result saved to Redis")
|
| 63 |
|
| 64 |
# Also persist budget state for the /control/budget endpoint
|
|
|
|
| 65 |
budget_info = {
|
| 66 |
"energy_cost_kwh": result_dict.get("energy_cost_kwh", 0),
|
| 67 |
"budget_spent_kwh": result_dict.get("budget_spent_kwh", 0),
|
| 68 |
"budget_remaining_kwh": result_dict.get("budget_remaining_kwh", 0),
|
| 69 |
-
"daily_budget_kwh": 25.0 *
|
| 70 |
"stage": result_dict.get("stage_id", "unknown"),
|
| 71 |
"last_updated": datetime.now(timezone.utc).isoformat(),
|
| 72 |
}
|
|
|
|
| 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 = {
|
| 67 |
"energy_cost_kwh": result_dict.get("energy_cost_kwh", 0),
|
| 68 |
"budget_spent_kwh": result_dict.get("budget_spent_kwh", 0),
|
| 69 |
"budget_remaining_kwh": result_dict.get("budget_remaining_kwh", 0),
|
| 70 |
+
"daily_budget_kwh": 25.0 * MAX_ENERGY_REDUCTION_PCT / 100.0,
|
| 71 |
"stage": result_dict.get("stage_id", "unknown"),
|
| 72 |
"last_updated": datetime.now(timezone.utc).isoformat(),
|
| 73 |
}
|
backend/workers/daily_planner.py
CHANGED
|
@@ -80,8 +80,11 @@ def main():
|
|
| 80 |
from src.data.redis_cache import get_redis
|
| 81 |
from config.settings import DAILY_PLAN_PATH, MAX_ENERGY_REDUCTION_PCT
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
# Build forecast inputs
|
| 87 |
forecast_temps, forecast_ghi = _get_forecast(target)
|
|
|
|
| 80 |
from src.data.redis_cache import get_redis
|
| 81 |
from config.settings import DAILY_PLAN_PATH, MAX_ENERGY_REDUCTION_PCT
|
| 82 |
|
| 83 |
+
from datetime import timedelta
|
| 84 |
+
# Use Israel Standard Time (UTC+2) — HF Spaces runs in UTC
|
| 85 |
+
IST = timezone(timedelta(hours=2))
|
| 86 |
+
target = datetime.now(IST).date()
|
| 87 |
+
log.info("Computing day-ahead plan for %s (IST)", target)
|
| 88 |
|
| 89 |
# Build forecast inputs
|
| 90 |
forecast_temps, forecast_ghi = _get_forecast(target)
|
src/control_loop.py
CHANGED
|
@@ -701,7 +701,7 @@ class ControlLoop:
|
|
| 701 |
|
| 702 |
# 10. Check plan divergence and trigger re-plan if needed
|
| 703 |
if slot_plan:
|
| 704 |
-
actual_offset =
|
| 705 |
needs_replan = self._check_plan_divergence(
|
| 706 |
slot_index=slot_index,
|
| 707 |
planned_offset=result.plan_offset_deg,
|
|
|
|
| 701 |
|
| 702 |
# 10. Check plan divergence and trigger re-plan if needed
|
| 703 |
if slot_plan:
|
| 704 |
+
actual_offset = 0.0 if result.live_override else live_offset
|
| 705 |
needs_replan = self._check_plan_divergence(
|
| 706 |
slot_index=slot_index,
|
| 707 |
planned_offset=result.plan_offset_deg,
|