Spaces:
Running
Running
| """ | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Phoebe Bioweather API v2.0 | |
| EmpedocLabs Β© 2025 | |
| Weather-driven headache risk scoring + actionable clinical advice. | |
| Designed for the Phoebe iOS app. | |
| GET / β Status | |
| GET /health β Model status | |
| POST /predict β Risk score + condition + personalized actions | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| """ | |
| import logging | |
| import os | |
| import pickle | |
| import numpy as np | |
| import pandas as pd | |
| from typing import List | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| # ββ Logging ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s") | |
| logger = logging.getLogger("bioweather") | |
| # ββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="Phoebe Bioweather API", | |
| version="2.0.0", | |
| description="Weather-driven headache risk scoring for the Phoebe iOS app by EmpedocLabs.", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| risk_model = None | |
| advice_model = None | |
| FEATURE_COLS = ["temp_c", "pressure_hpa", "humidity", "wind_kph", | |
| "uv_index", "pressure_drop", "temp_change"] | |
| async def load_models(): | |
| global risk_model, advice_model | |
| for name, filename in [("risk", "risk_model.pkl"), ("advice", "advice_model.pkl")]: | |
| path = filename | |
| if not os.path.exists(path): | |
| path = os.path.join("model", filename) | |
| if not os.path.exists(path): | |
| path = os.path.join(os.path.dirname(__file__), filename) | |
| try: | |
| with open(path, "rb") as f: | |
| if name == "risk": | |
| risk_model = pickle.load(f) | |
| else: | |
| advice_model = pickle.load(f) | |
| logger.info(f"β {name}_model loaded from {path}") | |
| except Exception as e: | |
| logger.error(f"β Failed to load {name}_model: {e}") | |
| if risk_model and advice_model: | |
| logger.info("β Bioweather v2.0 ready") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ADVICE LIBRARY β 15 biometeo conditions with 3 severity tiers each | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ADVICE_LIBRARY = { | |
| 0: { | |
| "title": "Clear Skies, Clear Head", | |
| "emoji": "πΏ", | |
| "texts": { | |
| "Low": "Weather-driven headache pressure is low today. If symptoms show up, hydration, sleep debt, skipped meals, stress, or screen strain are more likely drivers than the atmosphere.", | |
| "Moderate": "The weather pattern is mostly calm, but your combined inputs still show some vulnerability. Keep meals regular, hydrate early, and avoid stacking other triggers on top.", | |
| "High": "The atmosphere itself looks relatively stable, but your overall risk is still elevated. Treat this like a threshold-protection day: reduce glare, hydrate aggressively, and keep your routine steady." | |
| }, | |
| "actions": [ | |
| "Keep hydration and meals consistent.", | |
| "Use this lower-weather-stress window for demanding work, but do not overdo screens.", | |
| "If symptoms appear, check non-weather triggers first: sleep, caffeine timing, stress, or dehydration." | |
| ] | |
| }, | |
| 1: { | |
| "title": "Rapid Pressure Drop", | |
| "emoji": "π", | |
| "texts": { | |
| "Low": "Pressure is dipping, but the signal is still mild. Very sensitive users may notice slight heaviness behind the eyes or a drop in energy.", | |
| "Moderate": "Pressure is falling fast enough to lower your migraine threshold. This is a day to reduce other triggers and keep hydration steady.", | |
| "High": "A sharp pressure drop is one of today's main headache drivers. Lower sensory load, keep rescue medication accessible if prescribed, and avoid overexertion." | |
| }, | |
| "actions": [ | |
| "Lower sensory load: dim lights, shorter screen blocks, less noise.", | |
| "Keep hydration steady and avoid skipped meals.", | |
| "If you use rescue medication under clinician guidance, keep it nearby." | |
| ] | |
| }, | |
| 2: { | |
| "title": "The Pressure Squeeze", | |
| "emoji": "π", | |
| "texts": { | |
| "Low": "Pressure is rising gradually. You may feel mild facial tightness or subtle ear pressure, but the trigger strength is still limited.", | |
| "Moderate": "A meaningful pressure rise can create sinus and jaw tension. Support your body with hydration, posture awareness, and short breaks from screens.", | |
| "High": "A strong pressure rise is likely compressing sinus and facial tissues enough to trigger discomfort. Reduce jaw clenching, use warmth, and protect the rest of the day from extra triggers." | |
| }, | |
| "actions": [ | |
| "Relax your jaw and avoid clenching.", | |
| "Try a warm compress over the face or around the eyes.", | |
| "Take short posture breaks to reduce neck and forehead tension." | |
| ] | |
| }, | |
| 3: { | |
| "title": "The 'Sauna' Effect", | |
| "emoji": "π₯΅", | |
| "texts": { | |
| "Low": "Heat and humidity are present, but the current trigger load is still manageable. Hydration matters more than usual today.", | |
| "Moderate": "Warm, humid air is increasing dehydration and vascular stress. Pre-hydrate and spend more time in cooler, shaded spaces.", | |
| "High": "Hot, heavy air is a major driver today. Your best move is to protect hydration, electrolytes, and body temperature before symptoms ramp up." | |
| }, | |
| "actions": [ | |
| "Drink water early and add electrolytes if you are sweating.", | |
| "Prefer cool, shaded, or air-conditioned spaces.", | |
| "Avoid hard outdoor exercise during the hottest part of the day." | |
| ] | |
| }, | |
| 4: { | |
| "title": "High Wind Warning", | |
| "emoji": "π¬οΈ", | |
| "texts": { | |
| "Low": "Wind is noticeable and may create mild sensory irritation, especially around the ears and neck.", | |
| "Moderate": "Strong wind can raise headache risk through tension, sensory overload, and rapid local pressure changes around the ears.", | |
| "High": "Wind is one of the main drivers today. Protect your ears and neck, limit exposure, and actively reduce shoulder and jaw tension." | |
| }, | |
| "actions": [ | |
| "Protect ears and neck outdoors.", | |
| "Watch for shoulder elevation and jaw clenching.", | |
| "Reduce time in open, exposed outdoor spaces." | |
| ] | |
| }, | |
| 5: { | |
| "title": "High UV Glare", | |
| "emoji": "π", | |
| "texts": { | |
| "Low": "Brightness is elevated, so light-sensitive users may still want a bit more visual protection than usual.", | |
| "Moderate": "High UV and glare can push visual strain, photophobia, and screen fatigue. Protect your eyes early rather than after symptoms start.", | |
| "High": "Strong glare is a major trigger today. Use sunglasses outdoors, cut visual overload indoors, and avoid long unbroken screen sessions." | |
| }, | |
| "actions": [ | |
| "Wear polarized sunglasses outdoors.", | |
| "Lower screen brightness and use shorter screen intervals.", | |
| "Avoid bright reflections from glass, water, or white surfaces." | |
| ] | |
| }, | |
| 6: { | |
| "title": "Bitter Cold Tension", | |
| "emoji": "βοΈ", | |
| "texts": { | |
| "Low": "Cold air may cause mild tightening in the scalp, neck, or face, especially if you are already tense.", | |
| "Moderate": "Cold exposure is likely increasing muscle guarding and vascular constriction. Keep your neck and face warm and avoid abrupt exposure.", | |
| "High": "Bitter cold is a strong trigger today. Warm the air you breathe, protect your neck, and treat upper-body tension before it turns into a full headache." | |
| }, | |
| "actions": [ | |
| "Cover your neck and, if needed, nose and mouth outdoors.", | |
| "Use warm drinks or warmth after exposure.", | |
| "Massage jaw, temples, and upper neck after coming inside." | |
| ] | |
| }, | |
| 7: { | |
| "title": "Drastic Temp Drop", | |
| "emoji": "π₯Ά", | |
| "texts": { | |
| "Low": "A cooler shift is underway, but the risk signal is still limited. Sensitive users may feel mild stiffness or heaviness.", | |
| "Moderate": "A real temperature drop is stressing your system and can tighten muscles and narrow blood vessels. Keep your environment steady and your meals regular.", | |
| "High": "A sharp temperature drop is a key risk factor today. Layer up, avoid abrupt exposure, and reduce extra stressors that compound the vascular shift." | |
| }, | |
| "actions": [ | |
| "Dress in layers and avoid sudden cold exposure.", | |
| "Do not skip meals while your body is adapting.", | |
| "Use warmth on shoulders or neck if tension starts building." | |
| ] | |
| }, | |
| 8: { | |
| "title": "Heat Shock", | |
| "emoji": "π‘οΈ", | |
| "texts": { | |
| "Low": "A warmer shift is starting, and sensitive users may notice slight fatigue or early throbbing if hydration slips.", | |
| "Moderate": "A meaningful heat jump is pushing vasodilation and dehydration risk. Back off intense activity and replace fluids earlier than usual.", | |
| "High": "A sharp temperature spike is a major trigger today. Stay cool, protect hydration, and slow your pace before the throbbing escalates." | |
| }, | |
| "actions": [ | |
| "Stay in cooler environments when possible.", | |
| "Hydrate early and consider electrolytes.", | |
| "Avoid intense workouts or long periods in direct heat." | |
| ] | |
| }, | |
| 9: { | |
| "title": "Heavy Dampness", | |
| "emoji": "π«οΈ", | |
| "texts": { | |
| "Low": "Moist, stagnant air may cause mild heaviness or slight brain fog in sensitive users.", | |
| "Moderate": "Heavy dampness can worsen sinus pressure, lethargy, and that classic 'heavy head' feeling. Keep air moving and your posture clean.", | |
| "High": "Thick, wet air is likely contributing to congestion, fatigue, and dull head pressure today. Improve airflow indoors and limit slumped posture." | |
| }, | |
| "actions": [ | |
| "Use ventilation or a dehumidifier if available.", | |
| "Take movement breaks to avoid slouching.", | |
| "Try steam, saline, or light breathwork if sinus pressure builds." | |
| ] | |
| }, | |
| 10: { | |
| "title": "Mild Barometric Dip", | |
| "emoji": "π", | |
| "texts": { | |
| "Low": "A small pressure dip is present. Many people will feel nothing, but highly sensitive users may notice subtle dullness or eye pressure.", | |
| "Moderate": "A mild barometric drop is enough to lower your threshold a bit. Stay ahead on hydration and avoid combining it with skipped meals or long screen blocks.", | |
| "High": "Even though the pressure drop is not extreme, your combined risk is elevated. Protect the basics today and avoid stacking preventable triggers." | |
| }, | |
| "actions": [ | |
| "Hydrate steadily across the day.", | |
| "Avoid long, uninterrupted screen sessions.", | |
| "Keep meals and caffeine timing consistent." | |
| ] | |
| }, | |
| 11: { | |
| "title": "Mild Pressure Squeeze", | |
| "emoji": "π€οΈ", | |
| "texts": { | |
| "Low": "Pressure is creeping upward, which may create only mild tightness or ear fullness for sensitive users.", | |
| "Moderate": "A gentle pressure rise can still increase jaw, face, or sinus tension. Stay aware of clenching and support your posture.", | |
| "High": "The pressure rise is modest, but your overall risk is not. Prevent tension build-up early with warmth, breaks, and active jaw relaxation." | |
| }, | |
| "actions": [ | |
| "Relax your jaw and separate your teeth.", | |
| "Use warmth if facial tightness starts.", | |
| "Take short neck and shoulder mobility breaks." | |
| ] | |
| }, | |
| 12: { | |
| "title": "Breezy Pollen Risk", | |
| "emoji": "πΎ", | |
| "texts": { | |
| "Low": "Air movement may be stirring light environmental irritation, especially if you already have mild allergy sensitivity.", | |
| "Moderate": "Breezy conditions can carry pollen and dust that push sinus and histamine-related headaches. Keep indoor air cleaner and limit exposure if needed.", | |
| "High": "Wind-driven allergen exposure is likely one of today's main triggers. Protect your airways, keep windows controlled, and manage the histamine load early." | |
| }, | |
| "actions": [ | |
| "Keep windows closed if pollen is a usual issue.", | |
| "Wash face and rinse nasal passages after time outside.", | |
| "Use an air purifier or cleaner indoor airflow when possible." | |
| ] | |
| }, | |
| 13: { | |
| "title": "Dry Air Warning", | |
| "emoji": "ποΈ", | |
| "texts": { | |
| "Low": "Dry air is present and may quietly increase thirst, eye strain, or sinus irritation without feeling dramatic at first.", | |
| "Moderate": "Dry air is now meaningful enough to support dehydration and sinus discomfort. Sip fluids consistently and do not wait for thirst.", | |
| "High": "Very dry air is likely pushing dehydration and airway irritation today. Rehydrate early, reduce diuretics, and support your sinuses before pain builds." | |
| }, | |
| "actions": [ | |
| "Drink water consistently, not in one big catch-up.", | |
| "Use a humidifier or saline spray if your nose feels dry.", | |
| "Go easy on excess caffeine or alcohol." | |
| ] | |
| }, | |
| 14: { | |
| "title": "Stagnant & Gloomy", | |
| "emoji": "βοΈ", | |
| "texts": { | |
| "Low": "The weather is flat and dull, which may lead to low energy more than direct headache pressure.", | |
| "Moderate": "Still, gloomy weather can reduce movement, worsen posture, and increase brain fog. A little structured movement will help more than you think.", | |
| "High": "The atmosphere is not violent, but the stagnation is clearly affecting your threshold. Fight lethargy on purpose: move, hydrate, improve lighting, and protect posture." | |
| }, | |
| "actions": [ | |
| "Get light movement every 45β60 minutes.", | |
| "Improve indoor lighting if screens feel heavy on the eyes.", | |
| "Watch posture and avoid collapsing into the desk." | |
| ] | |
| }, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # REQUEST / RESPONSE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class WeatherInput(BaseModel): | |
| temp_c: float = Field(..., description="Temperature in Celsius") | |
| pressure_hpa: float = Field(..., description="Barometric pressure in hPa/mbar") | |
| humidity: float = Field(..., description="Relative humidity %") | |
| wind_kph: float = Field(..., description="Wind speed km/h") | |
| uv_index: int = Field(..., ge=0, le=11, description="UV index 0-11") | |
| pressure_drop: float = Field(..., description="24h pressure change in hPa (negative = drop)") | |
| temp_change: float = Field(..., description="24h temperature change in Β°C") | |
| class ConditionResponse(BaseModel): | |
| id: int | |
| title: str | |
| emoji: str | |
| text: str | |
| actions: List[str] | |
| class PredictResponse(BaseModel): | |
| risk_score: int | |
| risk_level: str | |
| condition: ConditionResponse | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LOGIC | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def clamp_risk(value) -> int: | |
| try: | |
| return int(max(0, min(100, round(float(value))))) | |
| except Exception: | |
| return 0 | |
| def get_risk_level(score: int) -> str: | |
| if score > 55: return "High" | |
| if score > 30: return "Moderate" | |
| return "Low" | |
| def infer_rule_condition(row: dict) -> tuple: | |
| """Rule-based coherence β corrects ML when physics is obvious.""" | |
| temp = float(row["temp_c"]) | |
| hum = float(row["humidity"]) | |
| wind = float(row["wind_kph"]) | |
| uv = int(row["uv_index"]) | |
| pd_ = float(row["pressure_drop"]) | |
| tc = float(row["temp_change"]) | |
| cands = [] | |
| if pd_ <= -8: cands.append((1, 95)) | |
| elif pd_ <= -4: cands.append((10, 72)) | |
| if pd_ >= 8: cands.append((2, 95)) | |
| elif pd_ >= 4: cands.append((11, 72)) | |
| if temp >= 29 and hum >= 70: cands.append((3, 92)) | |
| if wind >= 40: cands.append((4, 90)) | |
| elif wind >= 20: cands.append((12, 66)) | |
| if uv >= 8: cands.append((5, 88)) | |
| if temp <= 0: cands.append((6, 84)) | |
| if tc <= -7: cands.append((7, 89)) | |
| elif tc >= 7: cands.append((8, 89)) | |
| if hum >= 92 and wind <= 12: cands.append((9, 76)) | |
| if hum <= 30: cands.append((13, 78)) | |
| if uv <= 2 and hum >= 75 and wind <= 10: cands.append((14, 64)) | |
| if not cands: | |
| return 0, 0 | |
| return max(cands, key=lambda x: x[1]) | |
| def select_condition(ml_id: int, row: dict, risk: int) -> int: | |
| """ML first, rules correct obvious mismatches.""" | |
| rule_id, strength = infer_rule_condition(row) | |
| if ml_id not in ADVICE_LIBRARY: | |
| return rule_id if strength > 0 else 0 | |
| if strength >= 90 and ml_id != rule_id: | |
| return rule_id | |
| if ml_id == 0 and risk >= 45 and strength >= 65: | |
| return rule_id | |
| return ml_id | |
| def build_actions(cond_id: int, risk: int, row: dict) -> List[str]: | |
| level = get_risk_level(risk) | |
| acts = [] | |
| if level == "High": | |
| acts.extend([ | |
| "Reduce stimulation for the next few hours: dim lights, lower audio, and shorten screen sessions.", | |
| "Keep hydration, food intake, and routine stable today.", | |
| "If you have a clinician-approved rescue plan, keep it accessible.", | |
| ]) | |
| elif level == "Moderate": | |
| acts.extend([ | |
| "Protect the basics early: hydration, meals, and shorter screen blocks.", | |
| "Avoid stacking other triggers like dehydration, long fasting, or poor posture.", | |
| ]) | |
| else: | |
| acts.append("No need to overreact, but stay consistent with hydration and meals.") | |
| acts.extend(ADVICE_LIBRARY[cond_id]["actions"]) | |
| if row["uv_index"] >= 7: | |
| acts.append("Use sunglasses outdoors and reduce glare indoors.") | |
| if row["humidity"] >= 70 and row["temp_c"] >= 27: | |
| acts.append("Prioritize electrolytes and cooler environments.") | |
| if row["humidity"] <= 30: | |
| acts.append("Support dry sinuses with humidity or saline if needed.") | |
| if row["wind_kph"] >= 25: | |
| acts.append("Protect your ears and neck when outside.") | |
| if abs(row["temp_change"]) >= 7: | |
| acts.append("Avoid abrupt indoor/outdoor temperature swings; transition gradually.") | |
| if abs(row["pressure_drop"]) >= 4: | |
| acts.append("Keep the rest of the day trigger-light: no skipped meals, no dehydration, no unnecessary strain.") | |
| # Dedupe keeping order | |
| seen = set() | |
| unique = [] | |
| for a in acts: | |
| if a not in seen: | |
| seen.add(a) | |
| unique.append(a) | |
| return unique[:6] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ENDPOINTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def home(): | |
| return { | |
| "service": "Phoebe Bioweather API", | |
| "version": "2.0.0", | |
| "by": "EmpedocLabs", | |
| "status": "running" if risk_model and advice_model else "models_not_loaded", | |
| } | |
| def health(): | |
| return { | |
| "status": "healthy" if risk_model and advice_model else "degraded", | |
| "risk_model_loaded": risk_model is not None, | |
| "advice_model_loaded": advice_model is not None, | |
| } | |
| def predict(input_data: WeatherInput): | |
| if not risk_model or not advice_model: | |
| raise HTTPException(503, "Models not loaded") | |
| row = input_data.model_dump() | |
| df = pd.DataFrame([row])[FEATURE_COLS] | |
| # 1. Risk score | |
| risk_pred = risk_model.predict(df)[0] | |
| risk_score = clamp_risk(risk_pred) | |
| risk_level = get_risk_level(risk_score) | |
| # 2. Condition from ML | |
| ml_condition = int(advice_model.predict(df)[0]) | |
| # 3. Deterministic coherence | |
| condition_id = select_condition(ml_condition, row, risk_score) | |
| content = ADVICE_LIBRARY.get(condition_id, ADVICE_LIBRARY[0]) | |
| # 4. Text by risk level | |
| text = content["texts"][risk_level] | |
| # 5. Actions | |
| actions = build_actions(condition_id, risk_score, row) | |
| logger.info(f"Predict: risk={risk_score} ({risk_level}), cond={condition_id} ({content['title']})") | |
| return PredictResponse( | |
| risk_score=risk_score, | |
| risk_level=risk_level, | |
| condition=ConditionResponse( | |
| id=condition_id, | |
| title=content["title"], | |
| emoji=content["emoji"], | |
| text=text, | |
| actions=actions, | |
| ), | |
| ) | |