| """ |
| 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 |
| model_version: str = MODEL_VERSION_MOCK |
| inference_time_ms: float = 0.0 |
| sa_log: Optional[dict] = None |
|
|
| |
| id: str = field(default_factory=lambda: str(uuid.uuid4())) |
| created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) |
|
|
| |
|
|
| @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 |
|
|
| |
|
|
| 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)", |
| ) |
|
|
| |
|
|
| 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(), |
| } |
|
|