Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
549 lines
17 KiB
Python
549 lines
17 KiB
Python
"""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
|