avant 50
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -23,15 +24,22 @@ async def lifespan(app: FastAPI):
|
||||
from app.seed import run_seed
|
||||
run_seed()
|
||||
if ENABLE_SCHEDULER:
|
||||
from app.services.scheduler import setup_scheduler
|
||||
from app.services.scheduler import setup_scheduler, backfill_station_missing_dates
|
||||
setup_scheduler()
|
||||
# Backfill des dates manquantes en arrière-plan (ne bloque pas le démarrage)
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.run_in_executor(None, backfill_station_missing_dates)
|
||||
yield
|
||||
if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER:
|
||||
from app.services.scheduler import scheduler
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
app = FastAPI(title="Jardin API", lifespan=lifespan)
|
||||
app = FastAPI(
|
||||
title="Jardin API",
|
||||
lifespan=lifespan,
|
||||
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.3/bundles/redoc.standalone.js"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -51,6 +51,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
||||
("boutique_url", "TEXT", None),
|
||||
("tarif_achat", "REAL", None),
|
||||
("date_achat", "TEXT", None),
|
||||
("cell_ids", "TEXT", None), # JSON : liste des IDs de zones (multi-sélect)
|
||||
],
|
||||
"plantvariety": [
|
||||
# ancien nom de table → migration vers "plant" si présente
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import JSON as SA_JSON
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
@@ -7,6 +9,7 @@ class PlantingCreate(SQLModel):
|
||||
garden_id: int
|
||||
variety_id: int
|
||||
cell_id: Optional[int] = None
|
||||
cell_ids: Optional[List[int]] = None # multi-sélect zones
|
||||
date_semis: Optional[date] = None
|
||||
date_plantation: Optional[date] = None
|
||||
date_repiquage: Optional[date] = None
|
||||
@@ -28,6 +31,10 @@ class Planting(SQLModel, table=True):
|
||||
garden_id: int = Field(foreign_key="garden.id", index=True)
|
||||
variety_id: int = Field(foreign_key="plant.id", index=True)
|
||||
cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
|
||||
cell_ids: Optional[List[int]] = Field(
|
||||
default=None,
|
||||
sa_column=Column("cell_ids", SA_JSON, nullable=True),
|
||||
)
|
||||
date_semis: Optional[date] = None
|
||||
date_plantation: Optional[date] = None
|
||||
date_repiquage: Optional[date] = None
|
||||
|
||||
@@ -115,6 +115,19 @@ def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_sessio
|
||||
return cell
|
||||
|
||||
|
||||
@router.put("/gardens/{id}/cells/{cell_id}", response_model=GardenCell)
|
||||
def update_cell(id: int, cell_id: int, data: GardenCell, session: Session = Depends(get_session)):
|
||||
c = session.get(GardenCell, cell_id)
|
||||
if not c or c.garden_id != id:
|
||||
raise HTTPException(status_code=404, detail="Case introuvable")
|
||||
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "garden_id"}).items():
|
||||
setattr(c, k, v)
|
||||
session.add(c)
|
||||
session.commit()
|
||||
session.refresh(c)
|
||||
return c
|
||||
|
||||
|
||||
@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
|
||||
def list_measurements(id: int, session: Session = Depends(get_session)):
|
||||
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
|
||||
|
||||
@@ -15,7 +15,11 @@ def list_plantings(session: Session = Depends(get_session)):
|
||||
|
||||
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
|
||||
def create_planting(data: PlantingCreate, session: Session = Depends(get_session)):
|
||||
p = Planting(**data.model_dump())
|
||||
d = data.model_dump()
|
||||
# Rétro-compatibilité : cell_id = première zone sélectionnée
|
||||
if d.get("cell_ids") and not d.get("cell_id"):
|
||||
d["cell_id"] = d["cell_ids"][0]
|
||||
p = Planting(**d)
|
||||
session.add(p)
|
||||
session.commit()
|
||||
session.refresh(p)
|
||||
@@ -35,7 +39,12 @@ def update_planting(id: int, data: PlantingCreate, session: Session = Depends(ge
|
||||
p = session.get(Planting, id)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
d = data.model_dump(exclude_unset=True)
|
||||
# Rétro-compatibilité : cell_id = première zone sélectionnée
|
||||
if "cell_ids" in d:
|
||||
ids = d["cell_ids"] or []
|
||||
d["cell_id"] = ids[0] if ids else None
|
||||
for k, v in d.items():
|
||||
setattr(p, k, v)
|
||||
p.updated_at = datetime.now(timezone.utc)
|
||||
session.add(p)
|
||||
|
||||
@@ -90,6 +90,70 @@ def _store_open_meteo() -> None:
|
||||
logger.info(f"Open-Meteo stocké : {len(rows)} jours")
|
||||
|
||||
|
||||
def backfill_station_missing_dates(max_days_back: int = 365) -> None:
|
||||
"""Remplit les dates manquantes de la station météo au démarrage.
|
||||
|
||||
Cherche toutes les dates sans entrée « veille » dans meteostation
|
||||
depuis max_days_back jours en arrière jusqu'à hier (excl. aujourd'hui),
|
||||
puis télécharge les fichiers NOAA mois par mois pour remplir les trous.
|
||||
Un seul appel HTTP par mois manquant.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from itertools import groupby
|
||||
from app.services.station import fetch_month_summaries
|
||||
from app.models.meteo import MeteoStation
|
||||
from app.database import engine
|
||||
from sqlmodel import Session, select
|
||||
|
||||
today = date.today()
|
||||
start_date = today - timedelta(days=max_days_back)
|
||||
|
||||
# 1. Dates « veille » déjà présentes en BDD
|
||||
with Session(engine) as session:
|
||||
rows = session.exec(
|
||||
select(MeteoStation.date_heure).where(MeteoStation.type == "veille")
|
||||
).all()
|
||||
existing_dates: set[str] = {dh[:10] for dh in rows}
|
||||
|
||||
# 2. Dates manquantes entre start_date et hier (aujourd'hui exclu)
|
||||
missing: list[date] = []
|
||||
cursor = start_date
|
||||
while cursor < today:
|
||||
if cursor.isoformat() not in existing_dates:
|
||||
missing.append(cursor)
|
||||
cursor += timedelta(days=1)
|
||||
|
||||
if not missing:
|
||||
logger.info("Backfill station : aucune date manquante")
|
||||
return
|
||||
|
||||
logger.info(f"Backfill station : {len(missing)} date(s) manquante(s) à récupérer")
|
||||
|
||||
# 3. Grouper par (année, mois) → 1 requête HTTP par mois
|
||||
def month_key(d: date) -> tuple[int, int]:
|
||||
return (d.year, d.month)
|
||||
|
||||
filled = 0
|
||||
for (year, month), group_iter in groupby(sorted(missing), key=month_key):
|
||||
month_data = fetch_month_summaries(year, month)
|
||||
if not month_data:
|
||||
logger.debug(f"Backfill station : pas de données NOAA pour {year}-{month:02d}")
|
||||
continue
|
||||
|
||||
with Session(engine) as session:
|
||||
for d in group_iter:
|
||||
data = month_data.get(d.day)
|
||||
if not data:
|
||||
continue
|
||||
date_heure = f"{d.isoformat()}T00:00"
|
||||
if not session.get(MeteoStation, date_heure):
|
||||
session.add(MeteoStation(date_heure=date_heure, type="veille", **data))
|
||||
filled += 1
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Backfill station terminé : {filled} date(s) insérée(s)")
|
||||
|
||||
|
||||
def setup_scheduler() -> None:
|
||||
"""Configure et démarre le scheduler."""
|
||||
scheduler.add_job(
|
||||
|
||||
@@ -130,45 +130,63 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_noaa_day_line(parts: list[str]) -> dict | None:
|
||||
"""Parse une ligne de données journalières du fichier NOAA WeeWX.
|
||||
|
||||
Format standard : day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
|
||||
"""
|
||||
if not parts or not parts[0].isdigit():
|
||||
return None
|
||||
# Format complet avec timestamps hh:mm en positions 3 et 5
|
||||
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
|
||||
return {
|
||||
"temp_ext": _safe_float(parts[1]),
|
||||
"t_max": _safe_float(parts[2]),
|
||||
"t_min": _safe_float(parts[4]),
|
||||
"pluie_mm": _safe_float(parts[8]),
|
||||
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
|
||||
}
|
||||
# Fallback générique (anciens formats sans hh:mm)
|
||||
return {
|
||||
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
|
||||
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
|
||||
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
|
||||
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
|
||||
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
|
||||
}
|
||||
|
||||
|
||||
def fetch_month_summaries(year: int, month: int, base_url: str = STATION_URL) -> dict[int, dict]:
|
||||
"""Récupère tous les résumés journaliers d'un mois depuis le fichier NOAA WeeWX.
|
||||
|
||||
Retourne un dict {numéro_jour: data_dict} pour chaque jour disponible du mois.
|
||||
Un seul appel HTTP par mois — utilisé pour le backfill groupé.
|
||||
"""
|
||||
try:
|
||||
url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year:04d}-{month:02d}.txt"
|
||||
r = httpx.get(url, timeout=15)
|
||||
r.raise_for_status()
|
||||
|
||||
result: dict[int, dict] = {}
|
||||
for line in r.text.splitlines():
|
||||
parts = line.split()
|
||||
if not parts or not parts[0].isdigit():
|
||||
continue
|
||||
data = _parse_noaa_day_line(parts)
|
||||
if data:
|
||||
result[int(parts[0])] = data
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Station fetch_month_summaries({year}-{month:02d}) error: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None:
|
||||
"""Récupère le résumé de la veille via le fichier NOAA mensuel de la station WeeWX.
|
||||
|
||||
Retourne un dict avec : temp_ext (moy), t_min, t_max, pluie_mm — ou None.
|
||||
"""
|
||||
yesterday = (datetime.now() - timedelta(days=1)).date()
|
||||
year = yesterday.strftime("%Y")
|
||||
month = yesterday.strftime("%m")
|
||||
day = yesterday.day
|
||||
|
||||
try:
|
||||
url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year}-{month}.txt"
|
||||
r = httpx.get(url, timeout=15)
|
||||
r.raise_for_status()
|
||||
|
||||
for line in r.text.splitlines():
|
||||
parts = line.split()
|
||||
if not parts or not parts[0].isdigit() or int(parts[0]) != day:
|
||||
continue
|
||||
|
||||
# Format WeeWX NOAA (fréquent) :
|
||||
# day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
|
||||
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
|
||||
return {
|
||||
"temp_ext": _safe_float(parts[1]),
|
||||
"t_max": _safe_float(parts[2]),
|
||||
"t_min": _safe_float(parts[4]),
|
||||
"pluie_mm": _safe_float(parts[8]),
|
||||
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
|
||||
}
|
||||
|
||||
# Fallback générique (anciens formats)
|
||||
return {
|
||||
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
|
||||
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
|
||||
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
|
||||
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
|
||||
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Station fetch_yesterday_summary error: {e}")
|
||||
return None
|
||||
month_data = fetch_month_summaries(yesterday.year, yesterday.month, base_url)
|
||||
return month_data.get(yesterday.day)
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
import os
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://localhost:8070")
|
||||
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://ai-service:8070")
|
||||
|
||||
# Mapping class_name YOLO → nom commun français (partiel)
|
||||
_NOMS_FR = {
|
||||
"Tomato___healthy": "Tomate (saine)",
|
||||
"Tomato___Early_blight": "Tomate (mildiou précoce)",
|
||||
"Tomato___Late_blight": "Tomate (mildiou tardif)",
|
||||
"Pepper__bell___healthy": "Poivron (sain)",
|
||||
"Apple___healthy": "Pommier (sain)",
|
||||
"Potato___healthy": "Pomme de terre (saine)",
|
||||
"Grape___healthy": "Vigne (saine)",
|
||||
"Corn_(maize)___healthy": "Maïs (sain)",
|
||||
"Strawberry___healthy": "Fraisier (sain)",
|
||||
"Peach___healthy": "Pêcher (sain)",
|
||||
# Mapping complet class_name YOLO → Infos détaillées
|
||||
_DIAGNOSTICS = {
|
||||
"Tomato___healthy": {
|
||||
"label": "Tomate (saine)",
|
||||
"conseil": "Votre plant est en pleine forme. Pensez au paillage pour garder l'humidité.",
|
||||
"actions": ["Pailler le pied", "Vérifier les gourmands"]
|
||||
},
|
||||
"Tomato___Early_blight": {
|
||||
"label": "Tomate (Alternariose)",
|
||||
"conseil": "Champignon fréquent. Retirez les feuilles basses touchées et évitez de mouiller le feuillage.",
|
||||
"actions": ["Retirer feuilles infectées", "Traitement bouillie bordelaise"]
|
||||
},
|
||||
"Tomato___Late_blight": {
|
||||
"label": "Tomate (Mildiou)",
|
||||
"conseil": "Urgent : Le mildiou se propage vite avec l'humidité. Coupez les parties atteintes immédiatement.",
|
||||
"actions": ["Couper parties infectées", "Traitement purin de prêle", "Abriter de la pluie"]
|
||||
},
|
||||
"Pepper__bell___healthy": {
|
||||
"label": "Poivron (sain)",
|
||||
"conseil": "Le poivron aime la chaleur et un sol riche.",
|
||||
"actions": ["Apport de compost", "Arrosage régulier"]
|
||||
},
|
||||
"Potato___healthy": {
|
||||
"label": "Pomme de terre (saine)",
|
||||
"conseil": "Pensez à butter les pieds pour favoriser la production de tubercules.",
|
||||
"actions": ["Butter les pieds"]
|
||||
},
|
||||
"Grape___healthy": {
|
||||
"label": "Vigne (saine)",
|
||||
"conseil": "Surveillez l'apparition d'oïdium si le temps est chaud et humide.",
|
||||
"actions": ["Taille en vert", "Vérifier sous les feuilles"]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def identify(image_bytes: bytes) -> List[dict]:
|
||||
"""Appelle l'ai-service interne et retourne les détections YOLO."""
|
||||
"""Appelle l'ai-service interne et retourne les détections YOLO avec diagnostics."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
@@ -36,10 +56,18 @@ async def identify(image_bytes: bytes) -> List[dict]:
|
||||
results = []
|
||||
for det in data[:3]:
|
||||
cls = det.get("class_name", "")
|
||||
diag = _DIAGNOSTICS.get(cls, {
|
||||
"label": cls.replace("___", " — ").replace("_", " "),
|
||||
"conseil": "Pas de diagnostic spécifique disponible pour cette espèce.",
|
||||
"actions": []
|
||||
})
|
||||
|
||||
results.append({
|
||||
"species": cls.replace("___", " — ").replace("_", " "),
|
||||
"common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")),
|
||||
"species": cls,
|
||||
"common_name": diag["label"],
|
||||
"confidence": det.get("confidence", 0.0),
|
||||
"conseil": diag["conseil"],
|
||||
"actions": diag["actions"],
|
||||
"image_url": "",
|
||||
})
|
||||
return results
|
||||
|
||||
Reference in New Issue
Block a user