This commit is contained in:
2026-03-01 07:21:46 +01:00
parent 9db5cbf236
commit 7967f63fea
39 changed files with 3297 additions and 1646 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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