""" domain/entities/prediction.py ────────────────────────────── BPPrediction — domain entity representing a blood pressure prediction result. Design decisions: • Pure Python @dataclass — no ORM, no Pydantic. • SBP and DBP are validated against physiological bounds in ``validate()``. • MAP (Mean Arterial Pressure) is a computed property — not stored. """ from __future__ import annotations import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional from src.domain.exceptions.domain_exceptions import PredictionOutOfRangeError from src.shared.constants import ( BP_DBP_MAX, BP_DBP_MIN, BP_SBP_MAX, BP_SBP_MIN, MODEL_VERSION_MOCK, ) @dataclass class BPPrediction: """ Represents the output of an AI blood pressure prediction for one PPG signal. Attributes: ppg_signal_id: Foreign key — which PPGSignal was the input. predicted_sbp: Predicted Systolic Blood Pressure (mmHg). predicted_dbp: Predicted Diastolic Blood Pressure (mmHg). predicted_ecg: Synthetic ECG signal segments produced by CardioGAN (list of segments, each segment is a list of float samples). model_version: Version string of the model that produced this result. inference_time_ms: Wall-clock time spent on inference (milliseconds). id: Auto-generated UUID. created_at: UTC timestamp of when the prediction was made. """ ppg_signal_id: str predicted_sbp: float predicted_dbp: float predicted_ecg: Optional[list] = None # list[list[float]] — GAN-generated ECG windows model_version: str = MODEL_VERSION_MOCK inference_time_ms: float = 0.0 sa_log: Optional[dict] = None # Populated by repository on persist id: str = field(default_factory=lambda: str(uuid.uuid4())) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) # ── Computed Properties ─────────────────────────────────────────────────── @property def mean_arterial_pressure(self) -> float: """ MAP = DBP + (SBP - DBP) / 3. Clinically used as a single summary of perfusion pressure. """ return self.predicted_dbp + (self.predicted_sbp - self.predicted_dbp) / 3.0 @property def pulse_pressure(self) -> float: """Pulse pressure = SBP - DBP (should be positive).""" return self.predicted_sbp - self.predicted_dbp @property def hypertension_stage(self) -> str: """ Simple hypertension classification based on American Heart Association tiers. Returns one of: 'Normal', 'Elevated', 'Stage 1', 'Stage 2', 'Crisis'. """ sbp = self.predicted_sbp dbp = self.predicted_dbp if sbp >= 180 or dbp >= 120: return "Crisis" if sbp >= 140 or dbp >= 90: return "Stage 2" if sbp >= 130 or dbp >= 80: return "Stage 1" if sbp >= 120: return "Elevated" return "Normal" @property def is_valid(self) -> bool: """Quick boolean check — does not raise.""" try: self.validate() return True except PredictionOutOfRangeError: return False # ── Domain Validation ───────────────────────────────────────────────────── def validate(self) -> None: """ Enforce physiological bounds on the predicted values. Raises: PredictionOutOfRangeError: If SBP or DBP is physiologically implausible. """ if not (BP_SBP_MIN <= self.predicted_sbp <= BP_SBP_MAX): raise PredictionOutOfRangeError( self.predicted_sbp, self.predicted_dbp, f"SBP must be between {BP_SBP_MIN} and {BP_SBP_MAX} mmHg", ) if not (BP_DBP_MIN <= self.predicted_dbp <= BP_DBP_MAX): raise PredictionOutOfRangeError( self.predicted_sbp, self.predicted_dbp, f"DBP must be between {BP_DBP_MIN} and {BP_DBP_MAX} mmHg", ) if self.predicted_sbp <= self.predicted_dbp: raise PredictionOutOfRangeError( self.predicted_sbp, self.predicted_dbp, "SBP must be greater than DBP (pulse pressure cannot be ≤ 0)", ) # ── Dunder Methods ──────────────────────────────────────────────────────── def __repr__(self) -> str: return ( f"BPPrediction(id={self.id!r}, " f"ppg_signal_id={self.ppg_signal_id!r}, " f"SBP={self.predicted_sbp} mmHg, " f"DBP={self.predicted_dbp} mmHg, " f"MAP={self.mean_arterial_pressure:.1f} mmHg, " f"stage={self.hypertension_stage!r}, " f"model={self.model_version!r})" ) def to_dict(self) -> dict: """Serialise to plain dict.""" return { "id": self.id, "ppg_signal_id": self.ppg_signal_id, "predicted_sbp": self.predicted_sbp, "predicted_dbp": self.predicted_dbp, "predicted_ecg": self.predicted_ecg, "mean_arterial_pressure": self.mean_arterial_pressure, "pulse_pressure": self.pulse_pressure, "hypertension_stage": self.hypertension_stage, "model_version": self.model_version, "inference_time_ms": self.inference_time_ms, "sa_log": self.sa_log, "created_at": self.created_at.isoformat(), }