| """ |
| infrastructure/model/mock_model_service.py |
| ─────────────────────────────────────────── |
| MockModelService — deterministic fake inference for testing and local dev. |
| |
| Returns physiologically plausible (but fake) BP values without loading |
| any model weights or requiring a GPU. |
| |
| Use case: run the full ETL pipeline locally with USE_MOCK_MODEL=true. |
| """ |
| from __future__ import annotations |
|
|
| import asyncio |
| import hashlib |
| import time |
|
|
| import numpy as np |
|
|
| from src.domain.entities.prediction import BPPrediction |
| from src.domain.interfaces.services.model_service import ModelService |
| from src.shared.constants import MODEL_VERSION_MOCK |
| from src.shared.logger import get_logger |
|
|
| logger = get_logger(__name__) |
|
|
| |
| _SBP_BASE = 115.0 |
| _DBP_BASE = 75.0 |
| _JITTER = 15.0 |
|
|
|
|
| class MockModelService(ModelService): |
| """ |
| Deterministic mock model service for testing and local development. |
| |
| Given the same ``ppg_signal_id``, it always returns the same BP values |
| (deterministic via hash of the ID) — making tests reproducible. |
| """ |
|
|
| def __init__(self) -> None: |
| self._loaded = False |
|
|
| |
|
|
| async def load_model(self) -> None: |
| """Simulates model loading — just flips a flag.""" |
| if self._loaded: |
| return |
| logger.info("MockModelService.load_model() — simulating 100ms load delay") |
| await asyncio.sleep(0.1) |
| self._loaded = True |
| logger.info("MockModelService ready (mock mode).") |
|
|
| async def predict( |
| self, |
| ppg_signal_id: str, |
| segments: np.ndarray, |
| ) -> BPPrediction: |
| """ |
| Return deterministic fake BP values based on the signal ID hash. |
| |
| The hash ensures the same ID always gives the same output, making |
| unit tests with MockModelService fully reproducible. |
| |
| Args: |
| ppg_signal_id: UUID of the source PPGSignal. |
| segments: Preprocessed signal segments (shape: N × window). |
| |
| Returns: |
| BPPrediction with fake but physiologically valid SBP/DBP. |
| """ |
| if not self._loaded: |
| await self.load_model() |
|
|
| start = time.perf_counter() |
|
|
| |
| hash_bytes = hashlib.sha256(ppg_signal_id.encode()).digest() |
| seed = int.from_bytes(hash_bytes[:4], "big") |
|
|
| |
| n_segments = segments.shape[0] if len(segments.shape) > 1 and segments.shape[0] > 0 else 1 |
|
|
| |
| fake_ppg = np.zeros((n_segments, 224), dtype=np.float32) |
| fake_ecg = np.zeros((n_segments, 224), dtype=np.float32) |
|
|
| |
| for i in range(n_segments): |
| seg_seed = (seed + i) & 0xffffffff |
| rng_seg = np.random.default_rng(seg_seed) |
| t = np.linspace(0, 2 * np.pi, 224) |
| |
| noise_lvl = 0.5 if (i % 3 == 0) else 0.05 |
| fake_ppg[i] = np.sin(t) + rng_seg.normal(0, noise_lvl, 224) |
| fake_ecg[i] = np.sin(t * 2) + rng_seg.normal(0, noise_lvl, 224) |
|
|
| |
| if i % 4 == 0: |
| fake_ppg[i, 50:70] = 0.5 |
| fake_ecg[i, 100:120] = -0.5 |
|
|
| |
| sbp_preds = [] |
| dbp_preds = [] |
| for i in range(n_segments): |
| seg_seed = (seed + i) & 0xffffffff |
| rng_seg = np.random.default_rng(seg_seed) |
| s = float(_SBP_BASE + rng_seg.uniform(-_JITTER, _JITTER)) |
| d = float(_DBP_BASE + rng_seg.uniform(-_JITTER / 2, _JITTER / 2)) |
| |
| |
| if i % 3 == 0: |
| s += rng_seg.uniform(-25, 25) |
| d += rng_seg.uniform(-12, 12) |
| |
| s = max(80.0, min(200.0, s)) |
| d = max(40.0, min(120.0, d)) |
| |
| if s <= d: |
| s = d + 30.0 |
|
|
| sbp_preds.append(s) |
| dbp_preds.append(d) |
|
|
| sbp_preds_arr = np.array(sbp_preds) |
| dbp_preds_arr = np.array(dbp_preds) |
|
|
| |
| from src.infrastructure.processing.sa_helpers import run_simulated_annealing |
|
|
| sa_result = run_simulated_annealing( |
| ppg_segments=fake_ppg, |
| ecg_segments=fake_ecg, |
| sbp_preds=sbp_preds_arr, |
| dbp_preds=dbp_preds_arr, |
| n_steps=1000, |
| ) |
|
|
| clean_indices = sa_result["clean_indices"] |
| if len(clean_indices) > 0: |
| sbp = float(np.mean(sbp_preds_arr[clean_indices])) |
| dbp = float(np.mean(dbp_preds_arr[clean_indices])) |
| else: |
| sbp = float(np.mean(sbp_preds_arr)) |
| dbp = float(np.mean(dbp_preds_arr)) |
|
|
| |
| sa_log = { |
| "optimal_lo": sa_result["optimal_lo"], |
| "optimal_hi": sa_result["optimal_hi"], |
| "optimal_max_plateau": sa_result["optimal_max_plateau"], |
| "best_loss": sa_result["best_loss"], |
| "initial_loss": sa_result["initial_loss"], |
| "n_total_segments": sa_result["n_total_segments"], |
| "n_clean_segments": sa_result["n_clean_segments"], |
| "yield_rate": sa_result["yield_rate"], |
| "history": sa_result["history"], |
| } |
|
|
| |
| await asyncio.sleep(0.05) |
| elapsed_ms = (time.perf_counter() - start) * 1000 |
|
|
| logger.info( |
| "MockModelService.predict() — signal_id=%s segments=%d (clean=%d) " |
| "SBP=%.1f DBP=%.1f (%.1f ms)", |
| ppg_signal_id, |
| n_segments, |
| len(clean_indices), |
| sbp, |
| dbp, |
| elapsed_ms, |
| ) |
|
|
| return BPPrediction( |
| ppg_signal_id=ppg_signal_id, |
| predicted_sbp=round(sbp, 1), |
| predicted_dbp=round(dbp, 1), |
| predicted_ecg=fake_ecg.tolist(), |
| model_version=self.model_version, |
| inference_time_ms=round(elapsed_ms, 2), |
| sa_log=sa_log, |
| ) |
|
|
| def is_loaded(self) -> bool: |
| return self._loaded |
|
|
| @property |
| def model_version(self) -> str: |
| return MODEL_VERSION_MOCK |
|
|