stoneray/PPML_FASTAPI
0
1from fastapi import FastAPI, HTTPException2from fastapi.middleware.cors import CORSMiddleware3from fastapi.responses import RedirectResponse4from pydantic import BaseModel, Field5from typing import Optional6import logging7import os8import sys9import subprocess10import re11import json12from pathlib import Path13from datetime import datetime14 15import numpy as np16import pandas as pd17import mlflow18import mlflow.xgboost19from pandas.api.types import is_datetime64_any_dtype20from sklearn.preprocessing import OrdinalEncoder21 22from preprocessing.transformation import transform_single_flight_dataset23from preprocessing.load import load_single_flight_model_input_to_s324 25 26# =========================================================27# LOGGING28# =========================================================29logging.basicConfig(level=logging.INFO)30logger = logging.getLogger(__name__)31 32 33# =========================================================34# APP35# =========================================================36app = FastAPI(37 title="FlyOnTime FastAPI",38 description="API for flight delay prediction",39 version="3.0.0"40)41 42app.add_middleware(43 CORSMiddleware,44 allow_origins=["*"],45 allow_credentials=True,46 allow_methods=["*"],47 allow_headers=["*"],48)49 50 51# =========================================================52# CONFIG53# =========================================================54BASE_DIR = Path(__file__).resolve().parent55 56MLFLOW_TRACKING_URI = os.getenv(57 "MLFLOW_TRACKING_URI",58 "https://ppml2026-ppml-mlflow.hf.space"59)60 61CLASSIFIER_MODEL_URI = "models:/XGBoost_Classifier_registered@challenger"62REGRESSOR_MODEL_URI = "models:/XGBoost_Regressor_registered@challenger"63 64TEST_DATA_PATH = BASE_DIR / "data" / "df_train_final.parquet"65TEST_ROW_INDEX = 066 67FLIGHT_LOOKUP_DIR = BASE_DIR / "flight_lookup"68GLOBAL_RUN_SINGLE_FLIGHT_PATH = FLIGHT_LOOKUP_DIR / "GlobalRunSingleFlight.py"69OUTPUT_ROOT = FLIGHT_LOOKUP_DIR / "output"70 71ENABLE_S3_UPLOAD = os.getenv("ENABLE_S3_UPLOAD", "0")72 73 74# =========================================================75# AWS / HF SECRETS MAPPING76# =========================================================77aws_key = os.getenv("AWS_ACCESS_KEY_ID") or os.getenv("AWS_ACCESS_KEY")78aws_secret = os.getenv("AWS_SECRET_ACCESS_KEY")79aws_region = os.getenv("AWS_DEFAULT_REGION", "eu-north-1")80 81if aws_key:82 os.environ["AWS_ACCESS_KEY_ID"] = aws_key83if aws_secret:84 os.environ["AWS_SECRET_ACCESS_KEY"] = aws_secret85if aws_region:86 os.environ["AWS_DEFAULT_REGION"] = aws_region87 88logger.info("AWS_ACCESS_KEY_ID present: %s", bool(os.getenv("AWS_ACCESS_KEY_ID")))89logger.info("AWS_SECRET_ACCESS_KEY present: %s", bool(os.getenv("AWS_SECRET_ACCESS_KEY")))90logger.info("AWS_DEFAULT_REGION present: %s", bool(os.getenv("AWS_DEFAULT_REGION")))91logger.info("MLFLOW_TRACKING_URI present: %s", bool(os.getenv("MLFLOW_TRACKING_URI")))92 93mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)94 95 96# =========================================================97# CONSTANTES PREPROCESSING XGBOOST98# =========================================================99COLS_A_VIRER_CLASSIFIER = [100 "departure_delay_min",101 "arrival_delay_min",102 "time_dep",103 "time_arr",104]105 106COLS_A_VIRER_REGRESSOR = [107 "departure_delay_min",108 "arrival_delay_min",109 "time_dep",110 "time_arr",111]112 113DATETIME_COLS_NOTEBOOK = [114 "flight_date",115 "scheduled_departure_dep",116 "scheduled_arrival_arr",117]118 119 120# =========================================================121# GLOBAL OBJECTS122# =========================================================123classifier_model = None124regressor_model = None125df_reference = None126 127clf_preprocessor = None128reg_preprocessor = None129 130 131# =========================================================132# REQUEST / RESPONSE133# =========================================================134class PredictionRequest(BaseModel):135 flight_number: str = Field(..., example="AF1234")136 date: str = Field(..., example="2026-04-20T14:30:00")137 departure_airport: str = Field(..., example="CDG - Paris Charles de Gaulle")138 arrival_airport: Optional[str] = Field(None, example="NCE - Nice Côte d'Azur")139 140 141class PredictionResponse(BaseModel):142 status: str143 flight_number: str144 date: str145 departure_airport: str146 arrival_airport: Optional[str] = None147 delay_probability: Optional[float] = None148 predicted_arrival_delay_minutes: Optional[float] = None149 is_delayed: Optional[bool] = None150 message: Optional[str] = None151 warning_message: Optional[str] = None152 153 154# =========================================================155# HELPERS GÉNÉRAUX156# =========================================================157def normalize_departure_airport(user_value: Optional[str]) -> Optional[str]:158 if not user_value:159 return user_value160 161 value = user_value.strip()162 if " - " in value:163 return value.split(" - ")[0].strip()164 165 return value166 167 168def normalize_flight_number(value: str) -> str:169 if not value:170 return ""171 return str(value).replace(" ", "").upper().strip()172 173 174def extract_request_id_from_stdout(stdout: str) -> str:175 match = re.search(r"REQUEST_ID:\s*(\S+)", stdout)176 if not match:177 raise ValueError("Impossible de récupérer REQUEST_ID depuis le stdout du pipeline")178 return match.group(1).strip()179 180 181def get_request_dir(request_id: str) -> Path:182 return OUTPUT_ROOT / request_id183 184 185def get_request_status_path(request_id: str) -> Path:186 return get_request_dir(request_id) / "flight_request_status.json"187 188 189def get_request_error_log_path(request_id: str) -> Path:190 return get_request_dir(request_id) / "API_Single_ERR.log"191 192 193def read_request_status(request_id: str) -> dict:194 status_path = get_request_status_path(request_id)195 if not status_path.exists():196 return {}197 198 with open(status_path, "r", encoding="utf-8") as f:199 return json.load(f)200 201 202def build_user_friendly_pipeline_error(request_id: str, fallback_message: str) -> str:203 status_payload = read_request_status(request_id)204 if status_payload and status_payload.get("user_message"):205 return status_payload["user_message"]206 207 log_path = get_request_error_log_path(request_id)208 if log_path.exists():209 return fallback_message210 211 return fallback_message212 213 214def load_reference_dataframe() -> pd.DataFrame:215 logger.info("Loading reference dataframe from: %s", TEST_DATA_PATH)216 217 if not TEST_DATA_PATH.exists():218 raise FileNotFoundError(f"Reference dataset not found at path: {TEST_DATA_PATH}")219 220 if TEST_DATA_PATH.suffix == ".parquet":221 df = pd.read_parquet(TEST_DATA_PATH)222 elif TEST_DATA_PATH.suffix == ".csv":223 df = pd.read_csv(TEST_DATA_PATH)224 else:225 raise ValueError("TEST_DATA_PATH must point to a .parquet or .csv file")226 227 if df.empty:228 raise ValueError("Reference dataframe is empty")229 230 logger.info("Reference dataframe loaded with shape: %s", df.shape)231 return df232 233 234def get_reference_row(df: pd.DataFrame, row_index: int) -> pd.DataFrame:235 if row_index < 0 or row_index >= len(df):236 raise IndexError(f"TEST_ROW_INDEX={row_index} is out of bounds for dataframe of length {len(df)}")237 return df.iloc[[row_index]].copy()238 239 240def align_single_row_columns(df_single: pd.DataFrame) -> pd.DataFrame:241 """242 Sécurise les noms pour rester cohérent avec le training notebook.243 On garde scheduled_departure_dep / scheduled_arrival_arr.244 """245 df_single = df_single.copy()246 247 if "scheduled_departure_dep" not in df_single.columns and "scheduled_departure" in df_single.columns:248 df_single["scheduled_departure_dep"] = df_single["scheduled_departure"]249 250 if "scheduled_arrival_arr" not in df_single.columns and "scheduled_arrival" in df_single.columns:251 df_single["scheduled_arrival_arr"] = df_single["scheduled_arrival"]252 253 if "movement_date" not in df_single.columns and "movement_date_dep" in df_single.columns:254 df_single["movement_date"] = df_single["movement_date_dep"]255 256 if "status" not in df_single.columns and "status_dep" in df_single.columns:257 df_single["status"] = df_single["status_dep"]258 259 return df_single260 261 262def datetime_clean_like_notebook(df: pd.DataFrame, datetime_cols: list[str]) -> pd.DataFrame:263 df = df.copy()264 bad_datetime_cols = []265 266 for col in datetime_cols:267 if col in df.columns:268 df[col] = pd.to_datetime(df[col], errors="coerce")269 if not is_datetime64_any_dtype(df[col]):270 bad_datetime_cols.append(col)271 272 usable_datetime_cols = [273 col for col in datetime_cols274 if col in df.columns and col not in bad_datetime_cols275 ]276 277 if "flight_date" in usable_datetime_cols:278 df["flight_month"] = df["flight_date"].dt.month279 df["flight_day"] = df["flight_date"].dt.day280 df["flight_dayofweek"] = df["flight_date"].dt.dayofweek281 282 if "scheduled_departure_dep" in usable_datetime_cols:283 df["sched_dep_hour"] = df["scheduled_departure_dep"].dt.hour284 df["sched_dep_minute"] = df["scheduled_departure_dep"].dt.minute285 286 if "scheduled_arrival_arr" in usable_datetime_cols:287 df["sched_arr_hour"] = df["scheduled_arrival_arr"].dt.hour288 df["sched_arr_minute"] = df["scheduled_arrival_arr"].dt.minute289 290 logger.info("Colonnes datetime problématiques droppées : %s", bad_datetime_cols)291 292 df = df.drop(columns=datetime_cols, errors="ignore")293 return df294 295 296def build_training_frame_for_classifier(df_ref: pd.DataFrame) -> pd.DataFrame:297 X = df_ref.drop(298 columns=COLS_A_VIRER_CLASSIFIER + ["retard_arrivee"],299 errors="ignore",300 ).copy()301 X = datetime_clean_like_notebook(X, DATETIME_COLS_NOTEBOOK)302 return X303 304 305def build_training_frame_for_regressor(df_ref: pd.DataFrame) -> pd.DataFrame:306 X = df_ref.drop(307 columns=COLS_A_VIRER_REGRESSOR + ["arrival_delay_min", "retard_arrivee"],308 errors="ignore",309 ).copy()310 X = datetime_clean_like_notebook(X, DATETIME_COLS_NOTEBOOK)311 return X312 313 314def fit_preprocessor_from_training(315 X_train_ref: pd.DataFrame,316 model,317 task_name: str,318) -> dict:319 X_train_ref = X_train_ref.copy()320 321 cat_cols = X_train_ref.select_dtypes(include=["object", "category"]).columns.tolist()322 323 encoder = None324 if cat_cols:325 encoder = OrdinalEncoder(326 handle_unknown="use_encoded_value",327 unknown_value=-1,328 )329 X_train_ref[cat_cols] = encoder.fit_transform(X_train_ref[cat_cols].astype(str))330 331 num_cols = X_train_ref.select_dtypes(include=[np.number]).columns.tolist()332 medians = X_train_ref[num_cols].median() if num_cols else pd.Series(dtype=float)333 334 if num_cols:335 X_train_ref[num_cols] = X_train_ref[num_cols].fillna(medians)336 337 expected_cols = None338 if hasattr(model, "feature_names_in_"):339 expected_cols = list(model.feature_names_in_)340 else:341 expected_cols = list(X_train_ref.columns)342 343 logger.info("[%s] cat_cols: %s", task_name, cat_cols)344 logger.info("[%s] num_cols count: %s", task_name, len(num_cols))345 logger.info("[%s] expected_cols count: %s", task_name, len(expected_cols))346 347 return {348 "cat_cols": cat_cols,349 "num_cols": num_cols,350 "encoder": encoder,351 "medians": medians,352 "expected_cols": expected_cols,353 }354 355 356def prepare_single_row_base(357 df_real_single: pd.DataFrame,358 df_reference: pd.DataFrame,359 flight_number: str,360 date: str,361 departure_airport: str,362) -> pd.DataFrame:363 X = get_reference_row(df_reference, TEST_ROW_INDEX)364 df_real_single = align_single_row_columns(df_real_single)365 366 common_cols = [c for c in df_real_single.columns if c in X.columns]367 for col in common_cols:368 X[col] = df_real_single.iloc[0][col]369 370 if "flight_number" in X.columns:371 X["flight_number"] = flight_number372 373 if "airport_origin" in X.columns:374 X["airport_origin"] = departure_airport375 376 if "flight_date" in X.columns:377 X["flight_date"] = date378 379 return X380 381 382def apply_preprocessor_to_single_row(383 X_single: pd.DataFrame,384 task: str,385 preprocessor: dict,386) -> pd.DataFrame:387 X_single = X_single.copy()388 389 if task == "classifier":390 X_single = X_single.drop(391 columns=COLS_A_VIRER_CLASSIFIER + ["retard_arrivee"],392 errors="ignore",393 )394 elif task == "regressor":395 X_single = X_single.drop(396 columns=COLS_A_VIRER_REGRESSOR + ["arrival_delay_min", "retard_arrivee"],397 errors="ignore",398 )399 else:400 raise ValueError(f"Task inconnue: {task}")401 402 X_single = datetime_clean_like_notebook(X_single, DATETIME_COLS_NOTEBOOK)403 404 cat_cols = preprocessor["cat_cols"]405 encoder = preprocessor["encoder"]406 num_cols = preprocessor["num_cols"]407 medians = preprocessor["medians"]408 expected_cols = preprocessor["expected_cols"]409 410 for col in cat_cols:411 if col not in X_single.columns:412 X_single[col] = ""413 414 if cat_cols and encoder is not None:415 X_single[cat_cols] = encoder.transform(X_single[cat_cols].astype(str))416 417 for col in expected_cols:418 if col not in X_single.columns:419 X_single[col] = np.nan420 421 if num_cols:422 missing_num_cols = [c for c in num_cols if c not in X_single.columns]423 for col in missing_num_cols:424 X_single[col] = np.nan425 426 fill_cols = [c for c in num_cols if c in X_single.columns and c in medians.index]427 if fill_cols:428 X_single[fill_cols] = X_single[fill_cols].fillna(medians[fill_cols])429 430 X_single = X_single[expected_cols].copy()431 432 logger.info("[%s] Prepared shape: %s", task, X_single.shape)433 logger.info("[%s] Prepared columns: %s", task, X_single.columns.tolist())434 435 return X_single436 437 438def select_single_flight_row(439 df: pd.DataFrame,440 flight_number: str,441 departure_airport: str442) -> pd.DataFrame:443 df = df.copy()444 445 if "flight_number" in df.columns:446 mask_flight = (447 df["flight_number"].astype(str).apply(normalize_flight_number)448 == normalize_flight_number(flight_number)449 )450 if mask_flight.any():451 df = df[mask_flight].copy()452 453 if "airport_origin" in df.columns:454 mask_airport = (455 df["airport_origin"].astype(str).str.upper().str.strip()456 == departure_airport.upper()457 )458 if mask_airport.any():459 df = df[mask_airport].copy()460 461 if df.empty:462 raise ValueError("Aucune ligne correspondant au vol demandé après ETL")463 464 logger.info("Rows remaining after ETL filtering: %s", len(df))465 return df.iloc[[0]].copy()466 467 468# =========================================================469# MODELS + PREPROCESSORS470# =========================================================471def load_models():472 global classifier_model, regressor_model, df_reference, clf_preprocessor, reg_preprocessor473 474 if df_reference is None:475 df_reference = load_reference_dataframe()476 477 if classifier_model is None:478 logger.info("Loading classifier model from MLflow: %s", CLASSIFIER_MODEL_URI)479 classifier_model = mlflow.xgboost.load_model(CLASSIFIER_MODEL_URI)480 logger.info("Classifier loaded successfully")481 482 if regressor_model is None:483 logger.info("Loading regressor model from MLflow: %s", REGRESSOR_MODEL_URI)484 regressor_model = mlflow.xgboost.load_model(REGRESSOR_MODEL_URI)485 logger.info("Regressor loaded successfully")486 487 if clf_preprocessor is None:488 X_clf_train_ref = build_training_frame_for_classifier(df_reference)489 clf_preprocessor = fit_preprocessor_from_training(490 X_train_ref=X_clf_train_ref,491 model=classifier_model,492 task_name="classifier",493 )494 495 if reg_preprocessor is None:496 X_reg_train_ref = build_training_frame_for_regressor(df_reference)497 reg_preprocessor = fit_preprocessor_from_training(498 X_train_ref=X_reg_train_ref,499 model=regressor_model,500 task_name="regressor",501 )502 503 504# =========================================================505# FLIGHT LOOKUP + ETL506# =========================================================507def run_single_flight_lookup_pipeline(508 flight_number: str,509 date: str,510 departure_airport: str,511 arrival_airport: Optional[str] = None,512) -> dict:513 if not GLOBAL_RUN_SINGLE_FLIGHT_PATH.exists():514 raise FileNotFoundError(f"GlobalRunSingleFlight.py introuvable à : {GLOBAL_RUN_SINGLE_FLIGHT_PATH}")515 516 cmd = [517 sys.executable,518 str(GLOBAL_RUN_SINGLE_FLIGHT_PATH),519 flight_number,520 date,521 departure_airport,522 ]523 524 if arrival_airport:525 cmd.append(arrival_airport)526 527 env = os.environ.copy()528 env["ENABLE_S3_UPLOAD"] = ENABLE_S3_UPLOAD529 530 logger.info("Running flight_lookup pipeline: %s", " ".join(cmd))531 532 completed = subprocess.run(533 cmd,534 cwd=str(FLIGHT_LOOKUP_DIR),535 env=env,536 capture_output=True,537 text=True,538 check=False,539 )540 541 logger.info("flight_lookup stdout:\n%s", completed.stdout)542 if completed.stderr:543 logger.warning("flight_lookup stderr:\n%s", completed.stderr)544 545 request_id = extract_request_id_from_stdout(completed.stdout)546 547 if completed.returncode != 0:548 friendly_message = build_user_friendly_pipeline_error(549 request_id=request_id,550 fallback_message="Vol introuvable. Veuillez vérifier le numéro de vol, la date, l’horaire et l’aéroport de départ."551 )552 raise ValueError(friendly_message)553 554 run_date = datetime.now().strftime("%Y-%m-%d")555 556 logger.info("Recovered request_id=%s run_date=%s", request_id, run_date)557 558 return {559 "request_id": request_id,560 "run_date": run_date,561 "stdout": completed.stdout,562 }563 564 565def run_etl_pipeline(566 request_id: str,567 run_date: str568) -> tuple[pd.DataFrame, str]:569 logger.info("Running ETL pipeline for request_id=%s run_date=%s", request_id, run_date)570 571 df_single, _, output_path = transform_single_flight_dataset(572 request_id=request_id,573 run_date=run_date,574 encode_categories=False,575 save_output=True,576 )577 578 logger.info("Local transformed parquet path: %s", output_path)579 580 upload_result = load_single_flight_model_input_to_s3(581 request_id=request_id,582 run_date=run_date,583 )584 585 logger.info("Processed parquet uploaded to S3: %s", upload_result["s3_uri"])586 587 if df_single.empty:588 raise ValueError("Le parquet ETL final est vide")589 590 return df_single, upload_result["s3_uri"]591 592 593# =========================================================594# PREDICTION595# =========================================================596def run_prediction(597 flight_number: str,598 date: str,599 departure_airport: str,600 arrival_airport: Optional[str] = None,601) -> dict:602 if (603 classifier_model is None604 or regressor_model is None605 or df_reference is None606 or clf_preprocessor is None607 or reg_preprocessor is None608 ):609 raise RuntimeError("Models, preprocessors or reference dataframe not loaded")610 611 departure_airport_clean = normalize_departure_airport(departure_airport)612 arrival_airport_clean = normalize_departure_airport(arrival_airport) if arrival_airport else None613 flight_number_clean = normalize_flight_number(flight_number)614 615 logger.info(616 "Running REAL prediction for flight=%s date=%s airport=%s",617 flight_number_clean,618 date,619 departure_airport_clean620 )621 622 lookup_result = run_single_flight_lookup_pipeline(623 flight_number=flight_number_clean,624 date=date,625 departure_airport=departure_airport_clean,626 arrival_airport=arrival_airport_clean,627 )628 629 request_id = lookup_result["request_id"]630 run_date = lookup_result["run_date"]631 632 status_payload = read_request_status(request_id)633 634 df_single_etl, processed_s3_uri = run_etl_pipeline(635 request_id=request_id,636 run_date=run_date,637 )638 639 matched_flight_number = status_payload.get("matched_flight_number") or flight_number_clean640 641 df_real_single = select_single_flight_row(642 df=df_single_etl,643 flight_number=matched_flight_number,644 departure_airport=departure_airport_clean,645 )646 647 returned_arrival_airport = None648 if "airport_destination" in df_real_single.columns:649 returned_arrival_airport = str(df_real_single.iloc[0]["airport_destination"]).strip()650 elif "arrival_airport" in df_real_single.columns:651 returned_arrival_airport = str(df_real_single.iloc[0]["arrival_airport"]).strip()652 653 X_base = prepare_single_row_base(654 df_real_single=df_real_single,655 df_reference=df_reference,656 flight_number=matched_flight_number,657 date=date,658 departure_airport=departure_airport_clean,659 )660 661 X_clf = apply_preprocessor_to_single_row(662 X_single=X_base,663 task="classifier",664 preprocessor=clf_preprocessor,665 )666 667 X_reg = apply_preprocessor_to_single_row(668 X_single=X_base,669 task="regressor",670 preprocessor=reg_preprocessor,671 )672 673 clf_pred = classifier_model.predict(X_clf)674 clf_pred_value = int(clf_pred[0])675 676 if hasattr(classifier_model, "predict_proba"):677 clf_proba = classifier_model.predict_proba(X_clf)678 clf_proba_value = float(clf_proba[0][1])679 else:680 clf_proba_value = float(clf_pred_value)681 682 reg_pred = regressor_model.predict(X_reg)683 reg_pred_value = float(reg_pred[0])684 reg_pred_value = max(0.0, reg_pred_value)685 686 final_message = "Prédiction générée avec succès."687 if status_payload.get("warning_message"):688 final_message = status_payload.get("user_message") or final_message689 690 return {691 "status": "success",692 "flight_number": matched_flight_number,693 "date": date,694 "departure_airport": departure_airport_clean,695 "arrival_airport": returned_arrival_airport,696 "delay_probability": clf_proba_value,697 "predicted_arrival_delay_minutes": reg_pred_value,698 "is_delayed": bool(clf_pred_value),699 "message": f"{final_message} (request_id={request_id}, processed={processed_s3_uri})",700 "warning_message": status_payload.get("warning_message"),701 }702 703 704# =========================================================705# STARTUP706# =========================================================707@app.on_event("startup")708def startup_event():709 try:710 load_models()711 logger.info("Startup completed successfully")712 except Exception:713 logger.exception("Startup failed")714 raise715 716 717# =========================================================718# ENDPOINTS719# =========================================================720@app.get("/health")721def health_check():722 return {723 "status": "ok",724 "service": "flyontime-fastapi"725 }726 727 728@app.get("/")729def root():730 return RedirectResponse(url="/docs")731 732 733@app.get("/debug-models")734def debug_models():735 return {736 "tracking_uri": MLFLOW_TRACKING_URI,737 "classifier_model_uri": CLASSIFIER_MODEL_URI,738 "regressor_model_uri": REGRESSOR_MODEL_URI,739 "test_data_path": str(TEST_DATA_PATH),740 "test_row_index": TEST_ROW_INDEX,741 "reference_loaded": df_reference is not None,742 "n_rows": 0 if df_reference is None else int(len(df_reference)),743 "n_cols": 0 if df_reference is None else int(df_reference.shape[1]),744 "flight_lookup_dir": str(FLIGHT_LOOKUP_DIR),745 "global_run_single_flight_exists": GLOBAL_RUN_SINGLE_FLIGHT_PATH.exists(),746 "clf_preprocessor_ready": clf_preprocessor is not None,747 "reg_preprocessor_ready": reg_preprocessor is not None,748 }749 750 751@app.post("/predict", response_model=PredictionResponse)752def predict(payload: PredictionRequest):753 try:754 logger.info("Received prediction request: %s", payload.model_dump())755 756 result = run_prediction(757 flight_number=payload.flight_number,758 date=payload.date,759 departure_airport=payload.departure_airport,760 arrival_airport=payload.arrival_airport,761 )762 763 return PredictionResponse(**result)764 765 except ValueError as e:766 logger.warning("Validation/business error: %s", str(e))767 raise HTTPException(status_code=400, detail=str(e))768 769 except FileNotFoundError as e:770 logger.error("Model or file not found: %s", str(e))771 raise HTTPException(772 status_code=500,773 detail=f"Model or resource not found: {str(e)}"774 )775 776 except Exception:777 logger.exception("Unexpected error during prediction")778 raise HTTPException(779 status_code=500,780 detail="Une erreur technique est survenue pendant la prédiction. Merci de réessayer."781 )