Add auth to control endpoints, sigmoid Farquhar transition
Browse files- backend/api/routes/control.py +12 -4
- src/models/farquhar_model.py +14 -4
backend/api/routes/control.py
CHANGED
|
@@ -6,6 +6,7 @@ import logging
|
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
|
|
|
|
| 9 |
from backend.api.deps import get_datahub, get_redis_client
|
| 10 |
from src.data.data_providers import DataHub
|
| 11 |
|
|
@@ -14,7 +15,7 @@ router = APIRouter()
|
|
| 14 |
|
| 15 |
|
| 16 |
@router.get("/status")
|
| 17 |
-
async def control_status():
|
| 18 |
"""Last ControlLoop tick result (stored in Redis by the worker)."""
|
| 19 |
redis = get_redis_client()
|
| 20 |
if redis:
|
|
@@ -25,7 +26,7 @@ async def control_status():
|
|
| 25 |
|
| 26 |
|
| 27 |
@router.get("/plan")
|
| 28 |
-
async def control_plan():
|
| 29 |
"""Current day-ahead plan."""
|
| 30 |
redis = get_redis_client()
|
| 31 |
if redis:
|
|
@@ -36,17 +37,24 @@ async def control_plan():
|
|
| 36 |
try:
|
| 37 |
import json
|
| 38 |
from config.settings import DAILY_PLAN_PATH
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
return json.load(f)
|
| 41 |
except FileNotFoundError:
|
| 42 |
return {"status": "waiting", "message": "No plan generated yet", "slots": []}
|
|
|
|
|
|
|
| 43 |
except Exception as exc:
|
| 44 |
log.error("Failed to load plan from file: %s", exc)
|
| 45 |
raise HTTPException(status_code=500, detail="Plan loading failed")
|
| 46 |
|
| 47 |
|
| 48 |
@router.get("/budget")
|
| 49 |
-
async def control_budget():
|
| 50 |
"""Current energy budget state."""
|
| 51 |
redis = get_redis_client()
|
| 52 |
if redis:
|
|
|
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
|
| 9 |
+
from backend.api.auth import optional_auth
|
| 10 |
from backend.api.deps import get_datahub, get_redis_client
|
| 11 |
from src.data.data_providers import DataHub
|
| 12 |
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
@router.get("/status")
|
| 18 |
+
async def control_status(user: dict = Depends(optional_auth)):
|
| 19 |
"""Last ControlLoop tick result (stored in Redis by the worker)."""
|
| 20 |
redis = get_redis_client()
|
| 21 |
if redis:
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
@router.get("/plan")
|
| 29 |
+
async def control_plan(user: dict = Depends(optional_auth)):
|
| 30 |
"""Current day-ahead plan."""
|
| 31 |
redis = get_redis_client()
|
| 32 |
if redis:
|
|
|
|
| 37 |
try:
|
| 38 |
import json
|
| 39 |
from config.settings import DAILY_PLAN_PATH
|
| 40 |
+
from pathlib import Path
|
| 41 |
+
|
| 42 |
+
plan_path = Path(DAILY_PLAN_PATH)
|
| 43 |
+
if not plan_path.is_relative_to(plan_path.parent.parent):
|
| 44 |
+
raise HTTPException(status_code=400, detail="Invalid plan path")
|
| 45 |
+
with open(plan_path) as f:
|
| 46 |
return json.load(f)
|
| 47 |
except FileNotFoundError:
|
| 48 |
return {"status": "waiting", "message": "No plan generated yet", "slots": []}
|
| 49 |
+
except HTTPException:
|
| 50 |
+
raise
|
| 51 |
except Exception as exc:
|
| 52 |
log.error("Failed to load plan from file: %s", exc)
|
| 53 |
raise HTTPException(status_code=500, detail="Plan loading failed")
|
| 54 |
|
| 55 |
|
| 56 |
@router.get("/budget")
|
| 57 |
+
async def control_budget(user: dict = Depends(optional_auth)):
|
| 58 |
"""Current energy budget state."""
|
| 59 |
redis = get_redis_client()
|
| 60 |
if redis:
|
src/models/farquhar_model.py
CHANGED
|
@@ -272,12 +272,22 @@ class FarquharModel:
|
|
| 272 |
Ac, Aj, Rd = self._compute_rates(PAR, Tleaf, CO2, VPD, CWSI or 0.0)
|
| 273 |
An = min(Ac, Aj) - Rd
|
| 274 |
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
state = "RuBP_Limited"
|
| 277 |
-
|
| 278 |
-
else:
|
| 279 |
state = "Rubisco_Limited"
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
return float(max(0.0, An)), state, shading_helps
|
| 283 |
|
|
|
|
| 272 |
Ac, Aj, Rd = self._compute_rates(PAR, Tleaf, CO2, VPD, CWSI or 0.0)
|
| 273 |
An = min(Ac, Aj) - Rd
|
| 274 |
|
| 275 |
+
# Smooth sigmoid transition (28–32°C) instead of hard threshold.
|
| 276 |
+
# rubisco_weight = 0 below 28°C, 1 above 32°C, sigmoid in between.
|
| 277 |
+
import math
|
| 278 |
+
_TRANSITION_WIDTH = 2.0 # °C half-width of sigmoid zone
|
| 279 |
+
rubisco_weight = 1.0 / (1.0 + math.exp(-(Tleaf - transition_temp) / (_TRANSITION_WIDTH / 2.5)))
|
| 280 |
+
|
| 281 |
+
if rubisco_weight < 0.3:
|
| 282 |
state = "RuBP_Limited"
|
| 283 |
+
elif rubisco_weight > 0.7:
|
|
|
|
| 284 |
state = "Rubisco_Limited"
|
| 285 |
+
else:
|
| 286 |
+
state = "Transition"
|
| 287 |
+
|
| 288 |
+
# shading_helps is weighted: only meaningful when Rubisco-limited
|
| 289 |
+
# AND light is abundant relative to enzyme capacity (Aj > Ac)
|
| 290 |
+
shading_helps = rubisco_weight > 0.5 and (Aj > Ac)
|
| 291 |
|
| 292 |
return float(max(0.0, An)), state, shading_helps
|
| 293 |
|