"""Pure vape calculations (datamodel-health-vape.md §7) — unit-testable. Money is expressed in euro cents; derived rates (cost/ml, cost/day, savings) may be fractional cents (floats/Decimals), only stored amounts are ints. Every function here is pure: no database, no HTTP, no application errors. """ from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass from datetime import UTC, date, datetime, time, timedelta from decimal import Decimal from itertools import pairwise from math import floor from statistics import fmean from zoneinfo import ZoneInfo DEFAULT_COIL_LIFESPAN_DAYS = 14.0 COIL_AVG_LAST_N = 5 NICOTINE_MG_PER_CIG = 12.0 MINUTES_PER_CIG = 11 NICOTINE_MISMATCH_TOLERANCE = 0.10 # 10 % gap target vs recomputed TREND_SLOPE_THRESHOLD = 0.05 # mg/day per day: below = stable PURCHASE_FALLBACK_WINDOW_DAYS = 90 # §7.1 fallback cost/ml window SECONDS_PER_DAY = 86400.0 def date_range(start: date, end: date) -> Iterator[date]: """Every local day from `start` to `end`, both bounds included.""" day = start while day <= end: yield day day += timedelta(days=1) # --------------------------------------------------------------------------- # Mix / recipe costing (§6.3, §7.1) # --------------------------------------------------------------------------- @dataclass(frozen=True) class ComponentInput: """One recipe line: quantity in the product's size_unit + catalog price.""" quantity: Decimal price_cents: int size_value: Decimal nicotine_mg_ml: Decimal | None = None # boosters only vg_pct: Decimal | None = None def component_cost_cents(component: ComponentInput) -> Decimal: """quantity × (package price / package size), in cents.""" return component.quantity * Decimal(component.price_cents) / component.size_value def mix_cost_total_cents(components: Iterable[ComponentInput]) -> Decimal: """Σ quantity × (price / size_value), in cents.""" total = Decimal(0) for c in components: total += component_cost_cents(c) return total def mix_cost_per_ml_cents( components: Iterable[ComponentInput], total_ml: Decimal ) -> Decimal | None: if total_ml <= 0: return None return mix_cost_total_cents(components) / total_ml def mix_nicotine_check_mg_ml( components: Iterable[ComponentInput], total_ml: Decimal ) -> Decimal: """Σ (booster qty × booster mg/ml) / total_ml (§6.3).""" if total_ml <= 0: return Decimal(0) total_mg = Decimal(0) for c in components: if c.nicotine_mg_ml is not None: total_mg += c.quantity * c.nicotine_mg_ml return total_mg / total_ml def mix_vg_pct( components: Iterable[ComponentInput], total_ml: Decimal ) -> Decimal | None: """Weighted VG % — only when every component declares vg_pct.""" comps = list(components) if total_ml <= 0 or not comps or any(c.vg_pct is None for c in comps): return None weighted = sum( (c.quantity * c.vg_pct for c in comps if c.vg_pct is not None), Decimal(0), ) return weighted / total_ml def nicotine_mismatch_warning( target_mg_ml: Decimal, check_mg_ml: Decimal ) -> str | None: """'nicotine_mismatch' when the recomputed rate drifts > 10 % from target.""" tolerance = abs(target_mg_ml) * Decimal(str(NICOTINE_MISMATCH_TOLERANCE)) if abs(check_mg_ml - target_mg_ml) > tolerance: return "nicotine_mismatch" return None @dataclass(frozen=True) class RecipeQuantities: booster_ml: Decimal aroma_ml: Decimal base_ml: Decimal def recipe_quantities( total_ml: Decimal, target_nicotine_mg_ml: Decimal, booster_nicotine_mg_ml: Decimal, aroma_pct: Decimal, ) -> RecipeQuantities: """Stateless recipe assistant (§6.3). booster_ml = total × target / n_booster ; aroma_ml = total × aroma_pct/100 ; base_ml = total − booster − aroma. Raises ValueError when the recipe is impossible (booster too weak, aroma dosage too high). """ if total_ml <= 0: raise ValueError("total_ml must be > 0") if booster_nicotine_mg_ml <= 0: raise ValueError("booster nicotine rate must be > 0") booster_ml = total_ml * target_nicotine_mg_ml / booster_nicotine_mg_ml aroma_ml = total_ml * aroma_pct / Decimal(100) base_ml = total_ml - booster_ml - aroma_ml if base_ml <= 0: raise ValueError("base volume would be <= 0") return RecipeQuantities(booster_ml=booster_ml, aroma_ml=aroma_ml, base_ml=base_ml) def fallback_cost_per_ml_cents( purchases: Iterable[tuple[Decimal, Decimal]], ) -> Decimal | None: """Weighted average cost/ml of liquid purchases (§7.1 fallback). `purchases` = (total_cents, ml_bought) pairs. None when nothing was bought. """ total_cents = Decimal(0) total_ml = Decimal(0) for cents, ml in purchases: total_cents += cents total_ml += ml if total_ml <= 0: return None return total_cents / total_ml # --------------------------------------------------------------------------- # Daily consumption (§6.4, §7.2) # --------------------------------------------------------------------------- def effective_daily_ml( entries: Iterable[tuple[str, Decimal]], ) -> Decimal | None: """Daily consumption for ONE day: a daily_total overrides the refill sum. `entries` = (kind, ml) pairs of that day. None = untracked day. """ rows = list(entries) totals = [ml for kind, ml in rows if kind == "daily_total"] if totals: return totals[0] refills = [ml for kind, ml in rows if kind == "refill"] if not refills: return None return sum(refills, Decimal(0)) def mean_ml_per_day(values: Iterable[Decimal | None]) -> Decimal | None: """Mean over tracked days only (§7.2); None when nothing is tracked.""" vals = [v for v in values if v is not None] if not vals: return None return sum(vals, Decimal(0)) / Decimal(len(vals)) def tracked_days_ratio(values: Sequence[Decimal | None]) -> float: """Share of tracked days in the window — reliability indicator (§7.2).""" if not values: return 0.0 return sum(1 for v in values if v is not None) / len(values) def sum_ml_between( daily_ml_by_date: Mapping[date, Decimal | None], start: date, end: date ) -> Decimal | None: """Σ tracked daily ml over [start, end) — volume through one coil (§6.5). None when no day of the interval is tracked. """ values = [ daily_ml_by_date.get(day) for day in date_range(start, end - timedelta(days=1)) if daily_ml_by_date.get(day) is not None ] if not values: return None return sum((v for v in values if v is not None), Decimal(0)) # --------------------------------------------------------------------------- # Coils (§7.3) # --------------------------------------------------------------------------- def coil_intervals_days(changed_ats: Sequence[datetime]) -> list[float]: """Days between consecutive changes, oldest first.""" ordered = sorted(changed_ats) return [ (nxt - prev).total_seconds() / SECONDS_PER_DAY for prev, nxt in pairwise(ordered) ] def avg_coil_lifespan_days( intervals_days: Sequence[float], last_n: int = COIL_AVG_LAST_N, default: float = DEFAULT_COIL_LIFESPAN_DAYS, ) -> tuple[float, bool]: """Mean of the last `last_n` cycles; (default, True) when < 2 changes.""" if not intervals_days: return default, True recent = list(intervals_days)[-last_n:] return fmean(recent), False def coil_cost_per_day_cents( coil_unit_price_cents: float | Decimal | None, avg_lifespan_days: float ) -> float: """Amortized coil cost; 0 when no coil product/price is known.""" if coil_unit_price_cents is None or avg_lifespan_days <= 0: return 0.0 return float(coil_unit_price_cents) / avg_lifespan_days def coil_age_days(changed_at: datetime, now_utc: datetime) -> float: """Provisional lifespan of the coil currently installed.""" return (now_utc - changed_at).total_seconds() / SECONDS_PER_DAY # --------------------------------------------------------------------------- # Daily costs & cigarette baseline (§7.4, §7.5) # --------------------------------------------------------------------------- def vape_cost_per_day_cents( ml_per_day: Decimal | float | None, cost_per_ml_cents: Decimal | float | None, coil_cpd_cents: float, ) -> float | None: """ml/day × cost/ml + coil amortization; None when cost/ml is unknown.""" if cost_per_ml_cents is None or ml_per_day is None: return None return float(ml_per_day) * float(cost_per_ml_cents) + coil_cpd_cents def cig_cost_per_day_cents( cigs_per_day: Decimal | float, cigs_per_pack: int, pack_price_cents: int, ) -> float: """Frozen cigarette baseline: cigs/day ÷ cigs/pack × pack price.""" if cigs_per_pack <= 0: return 0.0 return float(cigs_per_day) / cigs_per_pack * pack_price_cents def savings_per_day_cents( cig_cpd_cents: float, vape_cpd_cents: float | None ) -> float | None: """Daily gain of vaping over the frozen cigarette baseline (§7.5).""" if vape_cpd_cents is None: return None return cig_cpd_cents - vape_cpd_cents def real_cost_per_day_cents(total_spend_cents: float, window_days: int) -> float | None: """Purchase-based cost/day over a window (§7.4).""" if window_days <= 0: return None return total_spend_cents / window_days def theoretical_vape_cost_cents( day_ml: Decimal | None, imputed_ml_per_day: Decimal | None, cost_per_ml_cents: Decimal | float | None, coil_cpd_cents: float, ) -> float: """Theoretical cost of one day, mean-imputed when untracked (§7.5). Untracked days use ml_per_day(30) so tracking gaps do not inflate savings. An unknown cost/ml contributes 0 (cost metrics themselves are null'ed upstream per §7.1) — coil amortization still applies. """ ml = day_ml if day_ml is not None else (imputed_ml_per_day or Decimal(0)) liquid = float(ml) * float(cost_per_ml_cents or 0) return liquid + coil_cpd_cents def cumulative_savings_theoretical( quit_date: date, today: date, daily_ml_by_date: Mapping[date, Decimal | None], imputed_ml_per_day: Decimal | None, cost_per_ml_cents: Decimal | float | None, coil_cpd_cents: float, cig_cpd_cents: float, ) -> list[tuple[date, float]]: """Frozen-baseline theoretical savings, cumulative point per day (§7.5). cum(d) = cig_cpd × (d − quit_date).days − Σ_{x=quit}^{d} vape_cost(x) """ if today < quit_date: return [] out: list[tuple[date, float]] = [] vape_sum = 0.0 for day in date_range(quit_date, today): vape_sum += theoretical_vape_cost_cents( daily_ml_by_date.get(day), imputed_ml_per_day, cost_per_ml_cents, coil_cpd_cents, ) elapsed = (day - quit_date).days out.append((day, cig_cpd_cents * elapsed - vape_sum)) return out def cumulative_savings_real( quit_date: date, today: date, spend_cents_by_date: Mapping[date, Decimal | int | float], cig_cpd_cents: float, ) -> list[tuple[date, float]]: """Purchase-based savings: cig baseline minus real spending, cumulative.""" if today < quit_date: return [] out: list[tuple[date, float]] = [] spent = 0.0 for day in date_range(quit_date, today): spent += float(spend_cents_by_date.get(day, 0)) elapsed = (day - quit_date).days out.append((day, cig_cpd_cents * elapsed - spent)) return out # --------------------------------------------------------------------------- # Nicotine (§7.6) & avoided cigarettes (§7.7) # --------------------------------------------------------------------------- def nicotine_mg_for_day( entries: Iterable[tuple[Decimal, Decimal | None]], ) -> float | None: """Σ ml × effective mg/ml over the day's entries; None if untracked. Entries without a resolvable nicotine rate contribute 0 mg. """ rows = list(entries) if not rows: return None return float(sum((ml * (nic or Decimal(0)) for ml, nic in rows), Decimal(0))) def cig_equivalent(nicotine_mg: float) -> float: """Informative equivalence (~12 mg nicotine per cigarette).""" return nicotine_mg / NICOTINE_MG_PER_CIG def days_since_quit(quit_date: date, today: date) -> int: """Whole local days elapsed since the quit date (never negative).""" return max(0, (today - quit_date).days) def cigarettes_avoided(elapsed_days: int, cigs_per_day: Decimal | float) -> int: return floor(elapsed_days * float(cigs_per_day)) def packs_avoided(cigs_avoided: int, cigs_per_pack: int) -> float: if cigs_per_pack <= 0: return 0.0 return cigs_avoided / cigs_per_pack def time_regained_minutes(cigs_avoided: int) -> int: return cigs_avoided * MINUTES_PER_CIG # --------------------------------------------------------------------------- # Series helpers # --------------------------------------------------------------------------- def moving_average( values: Sequence[float | None], window: int = 7 ) -> list[float | None]: """Trailing moving average ignoring None gaps (None when window is empty).""" out: list[float | None] = [] for i in range(len(values)): chunk = [v for v in values[max(0, i - window + 1) : i + 1] if v is not None] out.append(fmean(chunk) if chunk else None) return out def regression_slope(points: Sequence[tuple[date, float]]) -> float | None: """OLS slope in unit/day over (day, value) points (§5.5); None if < 3 pts.""" if len(points) < 3: return None t0 = points[0][0] ts = [float((d - t0).days) for d, _ in points] ws = [w for _, w in points] t_mean = fmean(ts) w_mean = fmean(ws) denom = sum((t - t_mean) ** 2 for t in ts) if denom == 0: return None num = sum((t - t_mean) * (w - w_mean) for t, w in zip(ts, ws, strict=True)) return num / denom def trend_status(slope: float | None, threshold: float = TREND_SLOPE_THRESHOLD) -> str: """'down' / 'stable' / 'up' — stable identifiers mapped to labels by the UI.""" if slope is None or abs(slope) < threshold: return "stable" return "down" if slope < 0 else "up" # --------------------------------------------------------------------------- # Health milestones (§7.8 — WHO-style timeline, static, French labels) # --------------------------------------------------------------------------- @dataclass(frozen=True) class Milestone: code: str offset: timedelta label_fr: str MILESTONES: tuple[Milestone, ...] = ( Milestone( "hr_bp_normal", timedelta(minutes=20), "Fréquence cardiaque et tension redescendent", ), Milestone( "co_halved", timedelta(hours=8), "Le monoxyde de carbone sanguin diminue de moitié", ), Milestone( "co_normal", timedelta(hours=24), "Monoxyde de carbone éliminé ; les poumons commencent à évacuer les résidus", ), Milestone( "nicotine_out", timedelta(hours=48), "Plus de nicotine dans le corps ; goût et odorat s'améliorent", ), Milestone( "breathing_easier", timedelta(hours=72), "Respiration plus facile, énergie en hausse (bronches détendues)", ), Milestone("circulation", timedelta(days=14), "Circulation sanguine améliorée"), Milestone( "lung_function", timedelta(days=90), "Fonction pulmonaire améliorée jusqu'à +30 %", ), Milestone( "cilia_recovery", timedelta(days=270), "Cils bronchiques régénérés ; toux et essoufflement diminuent", ), Milestone( "chd_risk_half", timedelta(days=365), "Risque de maladie coronarienne réduit de moitié", ), Milestone( "stroke_risk_normal", timedelta(days=5 * 365), "Risque d'AVC ramené à celui d'un non-fumeur", ), Milestone( "lung_cancer_half", timedelta(days=10 * 365), "Risque de cancer du poumon réduit de moitié", ), Milestone( "chd_risk_normal", timedelta(days=15 * 365), "Risque coronarien équivalent à celui d'un non-fumeur", ), ) @dataclass(frozen=True) class MilestoneStatus: code: str label_fr: str reached_at: datetime # UTC achieved: bool progress_pct: float def milestone_statuses( quit_date: date, tz: ZoneInfo, now_utc: datetime ) -> list[MilestoneStatus]: """Milestones measured from local midnight of quit_date (§7.8).""" t0 = datetime.combine(quit_date, time(0, 0), tzinfo=tz) elapsed = (now_utc - t0).total_seconds() out: list[MilestoneStatus] = [] for m in MILESTONES: reached_at = (t0 + m.offset).astimezone(UTC) progress = 100.0 * elapsed / m.offset.total_seconds() out.append( MilestoneStatus( code=m.code, label_fr=m.label_fr, reached_at=reached_at, achieved=now_utc >= reached_at, progress_pct=max(0.0, min(100.0, progress)), ) ) return out def next_milestone(statuses: Sequence[MilestoneStatus]) -> MilestoneStatus | None: """First milestone not reached yet (None once the timeline is complete).""" for status in statuses: if not status.achieved: return status return None