safraeli commited on
Commit
6d7f91e
·
verified ·
1 Parent(s): 063a7cd

Fix race conditions, error handling, timezone, divergence check

Browse files
backend/api/main.py CHANGED
@@ -196,15 +196,16 @@ async def lifespan(app: FastAPI):
196
 
197
  # Start background refresh loops
198
  import asyncio
199
- ims_task = asyncio.create_task(_ims_refresh_loop())
200
- sensor_task = asyncio.create_task(_sensor_refresh_loop())
201
- alert_task = asyncio.create_task(_data_flow_alert_loop())
202
-
203
- yield
204
- ims_task.cancel()
205
- sensor_task.cancel()
206
- alert_task.cancel()
207
- log.info("SolarWine API shutting down (uptime=%.0fs)", get_uptime())
 
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
- return {"briefing": bot._build_status_briefing()}
 
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 _check_error(result, "energy current")
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 _check_error(result, "energy daily")
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 _check_error(result, "energy history")
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 _check_error(result, "energy predict")
 
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
- redis_ok = redis.ping() if redis else False
 
 
 
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 _check_error(result, "snapshot")
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 _check_error(result, "sensor history")
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 * 5.0 / 100.0, # MAX_ENERGY_REDUCTION_PCT
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
- target = date.today()
84
- log.info("Computing day-ahead plan for %s", target)
 
 
 
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 = live_offset if not result.live_override else 0.0
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,