LIBRE / src /infrastructure /model /mock_model_service.py
RyZ
feat: adding predicted ecg
75b7786
Raw
History Blame Contribute Delete
6.9 kB
"""
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__)
# Physiologically plausible reference ranges for mock output
_SBP_BASE = 115.0 # Normal systolic
_DBP_BASE = 75.0 # Normal diastolic
_JITTER = 15.0 # ± range around base values
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
# ── ModelService interface ────────────────────────────────────────────────
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) # simulate loading latency
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()
# Deterministic jitter: hash the signal ID to get a repeatable seed
hash_bytes = hashlib.sha256(ppg_signal_id.encode()).digest()
seed = int.from_bytes(hash_bytes[:4], "big")
# Determine number of segments (always at least 1)
n_segments = segments.shape[0] if len(segments.shape) > 1 and segments.shape[0] > 0 else 1
# 1. Generate fake segments of size 224 to simulate JIT features computation
fake_ppg = np.zeros((n_segments, 224), dtype=np.float32)
fake_ecg = np.zeros((n_segments, 224), dtype=np.float32)
# Populate with deterministic signal + noise + optional plateau
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)
# i%3 == 0: noisy (high entropy), others: clean (low entropy)
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)
# i%4 == 0: inject flat lines (plateau)
if i % 4 == 0:
fake_ppg[i, 50:70] = 0.5
fake_ecg[i, 100:120] = -0.5
# 2. Generate segment predictions
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))
# Add variance to noisy windows
if i % 3 == 0:
s += rng_seg.uniform(-25, 25)
d += rng_seg.uniform(-12, 12)
# Clamp within physiological bounds
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)
# 3. Run Simulated Annealing (1000 steps)
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))
# Assemble SA log dict
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"],
}
# Simulate short inference time
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