RyZ commited on
Commit
4c21e13
Β·
1 Parent(s): 93f31a8

fix: commit src.infrastructure.model code and restrict gitignore model rule to root

Browse files
.gitignore CHANGED
@@ -218,4 +218,5 @@ __marimo__/
218
 
219
  # Streamlit
220
  .streamlit/secrets.toml
221
- model/
 
 
218
 
219
  # Streamlit
220
  .streamlit/secrets.toml
221
+ /model/
222
+ /models/
src/infrastructure/model/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Model service subpackage
src/infrastructure/model/cardiogan.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ infrastructure/model/cardiogan.py
3
+ ──────────────────────────────────
4
+ CardioGAN U-Net Generator model architecture.
5
+ Strictly defines the PyTorch nn.Module architecture (SRP). No signal preprocessing.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def _build_attention_gate_module():
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+
15
+ class AttentionGate(nn.Module):
16
+ """Attention gate for 1-D signals on skip connections."""
17
+
18
+ def __init__(self, F_l: int, F_g: int, F_int: int):
19
+ super().__init__()
20
+ self.W_x = nn.Conv1d(F_l, F_int, kernel_size=1, stride=1, bias=True)
21
+ self.W_g = nn.Conv1d(F_g, F_int, kernel_size=1, stride=1, bias=True)
22
+ self.psi = nn.Conv1d(F_int, 1, kernel_size=1, stride=1, bias=True)
23
+ self.relu = nn.ReLU(inplace=True)
24
+ self.sigmoid = nn.Sigmoid()
25
+
26
+ def forward(self, x_l: torch.Tensor, g: torch.Tensor) -> torch.Tensor:
27
+ if g.shape[-1] != x_l.shape[-1]:
28
+ g = F.interpolate(g, size=x_l.shape[-1], mode="nearest")
29
+ theta_x = self.W_x(x_l)
30
+ phi_g = self.W_g(g)
31
+ f = self.relu(theta_x + phi_g)
32
+ alpha = self.sigmoid(self.psi(f))
33
+ return x_l * alpha
34
+
35
+ return AttentionGate
36
+
37
+
38
+ def _build_encoder_block_module():
39
+ import torch.nn as nn
40
+
41
+ class EncoderBlock(nn.Module):
42
+ """Encoder block: Conv1d -> [GroupNorm] -> LeakyReLU."""
43
+
44
+ def __init__(self, in_ch: int, out_ch: int, kernel_size: int = 16,
45
+ stride: int = 2, use_norm: bool = True):
46
+ super().__init__()
47
+ pad = (kernel_size - 1) // 2
48
+ self.conv = nn.Conv1d(in_ch, out_ch, kernel_size, stride, pad)
49
+ self.norm = nn.GroupNorm(1, out_ch) if use_norm else nn.Identity()
50
+ self.act = nn.LeakyReLU(0.2, inplace=True)
51
+
52
+ def forward(self, x):
53
+ return self.act(self.norm(self.conv(x)))
54
+
55
+ return EncoderBlock
56
+
57
+
58
+ def _build_decoder_block_module():
59
+ import torch.nn as nn
60
+
61
+ class DecoderBlock(nn.Module):
62
+ """Decoder block: ConvTranspose1d -> [GroupNorm] -> ReLU."""
63
+
64
+ def __init__(self, in_ch: int, out_ch: int, kernel_size: int = 16,
65
+ stride: int = 2, use_norm: bool = True):
66
+ super().__init__()
67
+ pad = (kernel_size - 1) // 2
68
+ out_pad = stride - 1 if stride > 1 else 0
69
+ self.deconv = nn.ConvTranspose1d(
70
+ in_ch, out_ch, kernel_size, stride, pad, output_padding=out_pad
71
+ )
72
+ self.norm = nn.GroupNorm(1, out_ch) if use_norm else nn.Identity()
73
+ self.act = nn.ReLU(inplace=True)
74
+
75
+ def forward(self, x):
76
+ return self.act(self.norm(self.deconv(x)))
77
+
78
+ return DecoderBlock
79
+
80
+
81
+ def build_attention_unet_generator():
82
+ """
83
+ Build and return a fresh AttentionUNetGenerator instance.
84
+ """
85
+ import torch
86
+ import torch.nn as nn
87
+ import torch.nn.functional as F
88
+
89
+ AttentionGate = _build_attention_gate_module()
90
+ EncoderBlock = _build_encoder_block_module()
91
+ DecoderBlock = _build_decoder_block_module()
92
+
93
+ class AttentionUNetGenerator(nn.Module):
94
+ """Attention U-Net Generator for CardioGAN."""
95
+
96
+ def __init__(self):
97
+ super().__init__()
98
+ enc_filters = [64, 128, 256, 512, 512, 512]
99
+
100
+ self.enc1 = EncoderBlock(1, enc_filters[0], use_norm=False)
101
+ self.enc2 = EncoderBlock(enc_filters[0], enc_filters[1])
102
+ self.enc3 = EncoderBlock(enc_filters[1], enc_filters[2])
103
+ self.enc4 = EncoderBlock(enc_filters[2], enc_filters[3])
104
+ self.enc5 = EncoderBlock(enc_filters[3], enc_filters[4])
105
+ self.enc6 = EncoderBlock(enc_filters[4], enc_filters[5])
106
+
107
+ self.attn5 = AttentionGate(enc_filters[4], enc_filters[4], enc_filters[4] // 2)
108
+ self.attn4 = AttentionGate(enc_filters[3], enc_filters[3], enc_filters[3] // 2)
109
+ self.attn3 = AttentionGate(enc_filters[2], enc_filters[2], enc_filters[2] // 2)
110
+ self.attn2 = AttentionGate(enc_filters[1], enc_filters[1], enc_filters[1] // 2)
111
+ self.attn1 = AttentionGate(enc_filters[0], enc_filters[0], enc_filters[0] // 2)
112
+
113
+ self.dec6 = DecoderBlock(enc_filters[5], enc_filters[4])
114
+ self.dec5 = DecoderBlock(enc_filters[4] * 2, enc_filters[3])
115
+ self.dec4 = DecoderBlock(enc_filters[3] * 2, enc_filters[2])
116
+ self.dec3 = DecoderBlock(enc_filters[2] * 2, enc_filters[1])
117
+ self.dec2 = DecoderBlock(enc_filters[1] * 2, enc_filters[0])
118
+
119
+ self.final = nn.Sequential(
120
+ nn.ConvTranspose1d(enc_filters[0] * 2, 1, kernel_size=16,
121
+ stride=2, padding=7, output_padding=0),
122
+ nn.Tanh()
123
+ )
124
+
125
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
126
+ e1 = self.enc1(x) # (B, 64, 256)
127
+ e2 = self.enc2(e1) # (B, 128, 128)
128
+ e3 = self.enc3(e2) # (B, 256, 64)
129
+ e4 = self.enc4(e3) # (B, 512, 32)
130
+ e5 = self.enc5(e4) # (B, 512, 16)
131
+ e6 = self.enc6(e5) # (B, 512, 8)
132
+
133
+ d6 = self.dec6(e6)
134
+ a5 = self.attn5(e5, d6)
135
+ d5 = self.dec5(torch.cat([self._match(d6, a5), a5], dim=1))
136
+
137
+ a4 = self.attn4(e4, d5)
138
+ d4 = self.dec4(torch.cat([self._match(d5, a4), a4], dim=1))
139
+
140
+ a3 = self.attn3(e3, d4)
141
+ d3 = self.dec3(torch.cat([self._match(d4, a3), a3], dim=1))
142
+
143
+ a2 = self.attn2(e2, d3)
144
+ d2 = self.dec2(torch.cat([self._match(d3, a2), a2], dim=1))
145
+
146
+ a1 = self.attn1(e1, d2)
147
+ out = self.final(torch.cat([self._match(d2, a1), a1], dim=1))
148
+
149
+ return out
150
+
151
+ @staticmethod
152
+ def _match(decoder_feat: torch.Tensor, skip_feat: torch.Tensor) -> torch.Tensor:
153
+ if decoder_feat.shape[-1] != skip_feat.shape[-1]:
154
+ decoder_feat = F.interpolate(
155
+ decoder_feat, size=skip_feat.shape[-1], mode="nearest"
156
+ )
157
+ return decoder_feat
158
+
159
+ return AttentionUNetGenerator()
src/infrastructure/model/gan_vgtlnet_service.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ infrastructure/model/gan_vgtlnet_service.py
3
+ ────────────────────────────────────────────
4
+ GANVGTLNetService β€” production AI inference pipeline (CardioGAN + VGTL-Net).
5
+
6
+ Arsitektur Pipeline (SOLID & Clean):
7
+ PPG segments (@ 125 Hz, 224 samples/window)
8
+ β”‚
9
+ β–Ό [CardioGAN Preprocessor] -> SciPy/Numba implementation
10
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
11
+ β”‚ CardioGANSignalPreprocessor β”‚ PPG segments -> segmented, filtered [-1, 1] windows @ 128 Hz
12
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
13
+ β”‚
14
+ β–Ό [CardioGAN Generator Model (G_E)]
15
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
16
+ β”‚ AttentionUNetGenerator β”‚ PPG windows -> synthetic ECG windows @ 128 Hz
17
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
18
+ β”‚
19
+ β–Ό [CardioGAN Postprocessor]
20
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
21
+ β”‚ CardioGANSignalPreprocessor β”‚ synthetic ECG windows @ 128 Hz -> aligned windows @ 125 Hz
22
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
23
+ β”‚
24
+ β–Ό [VGTL-Net Preprocessor]
25
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
26
+ β”‚ VGTLNetSignalPreprocessor β”‚ PPG + ECG windows @ 125 Hz -> VG Adjacency RGB Tensor
27
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
28
+ β”‚
29
+ β–Ό [VGTL-Net Predictor Model]
30
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
31
+ β”‚ ConvNeXtV2BPModel β”‚ RGB Tensor -> (SBP_pred, DBP_pred) -> averaged SBP/DBP
32
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import time
37
+ from pathlib import Path
38
+ from typing import Any, Optional, Tuple
39
+
40
+ import numpy as np
41
+
42
+ from src.domain.entities.prediction import BPPrediction
43
+ from src.domain.exceptions.pipeline_exceptions import ModelInferenceError, PreprocessingError
44
+ from src.domain.interfaces.services.cardiogan_preprocessor import CardioGANSignalPreprocessor
45
+ from src.domain.interfaces.services.vgtlnet_preprocessor import VGTLNetSignalPreprocessor
46
+ from src.domain.interfaces.services.model_service import ModelService
47
+ from src.shared.config import get_settings
48
+ from src.shared.constants import (
49
+ MODEL_VERSION_GAN_VGTLNET,
50
+ BP_SBP_MIN,
51
+ BP_SBP_MAX,
52
+ BP_DBP_MIN,
53
+ BP_DBP_MAX,
54
+ )
55
+ from src.shared.logger import get_logger
56
+
57
+ logger = get_logger(__name__)
58
+
59
+
60
+ class GANVGTLNetService(ModelService):
61
+ """
62
+ Production model service that orchestrates CardioGAN and VGTL-Net models.
63
+
64
+ Adheres fully to SOLID:
65
+ - SRP: Only orchestrates model loading and execution. Preprocessing is delegated.
66
+ - DIP: Depends entirely on abstract preprocessor interfaces, not concrete classes.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ gan_preprocessor: Optional[CardioGANSignalPreprocessor] = None,
72
+ vgtlnet_preprocessor: Optional[VGTLNetSignalPreprocessor] = None,
73
+ gan_checkpoint_path: Optional[str] = None,
74
+ vgtlnet_checkpoint_path: Optional[str] = None,
75
+ ) -> None:
76
+ settings = get_settings()
77
+ self._gan_path = Path(gan_checkpoint_path or settings.gan_checkpoint_path)
78
+ self._vgtlnet_path = Path(vgtlnet_checkpoint_path or settings.vgtlnet_checkpoint_path)
79
+
80
+ # Lazy import defaults if not provided (retains ease of use + testing flexibility)
81
+ if gan_preprocessor is None:
82
+ from src.infrastructure.processing.scipy_cardiogan_preprocessor import SciPyCardioGANPreprocessor
83
+ self._gan_preprocessor: CardioGANSignalPreprocessor = SciPyCardioGANPreprocessor()
84
+ else:
85
+ self._gan_preprocessor = gan_preprocessor
86
+
87
+ if vgtlnet_preprocessor is None:
88
+ from src.infrastructure.processing.numba_vgtlnet_preprocessor import NumbaVGTLNetPreprocessor
89
+ self._vgtlnet_preprocessor: VGTLNetSignalPreprocessor = NumbaVGTLNetPreprocessor()
90
+ else:
91
+ self._vgtlnet_preprocessor = vgtlnet_preprocessor
92
+
93
+ self._gan_model: Optional[Any] = None # AttentionUNetGenerator (G_E)
94
+ self._vgtlnet_model: Optional[Any] = None # ConvNeXtV2BPModel
95
+ self._loaded = False
96
+ self._device: str = "cpu"
97
+
98
+ # ── ModelService interface ────────────────────────────────────────────────
99
+
100
+ async def load_model(self) -> None:
101
+ """
102
+ Loads CardioGAN and VGTL-Net weights from disk.
103
+ """
104
+ if self._loaded:
105
+ return
106
+
107
+ try:
108
+ import torch
109
+ from src.infrastructure.model.cardiogan import build_attention_unet_generator
110
+ from src.infrastructure.model.vgtlnet import build_convnextv2_bp_model
111
+
112
+ self._device = "cuda" if torch.cuda.is_available() else "cpu"
113
+ logger.info("GANVGTLNetService.load_model() - device=%s", self._device)
114
+
115
+ # 1. Load CardioGAN (G_E: PPG -> ECG)
116
+ if not self._gan_path.exists():
117
+ raise ModelInferenceError(
118
+ "CardioGAN",
119
+ f"Checkpoint not found at: {self._gan_path}. "
120
+ "Ensure weights are downloaded or set USE_MOCK_MODEL=true."
121
+ )
122
+
123
+ logger.info("Loading CardioGAN from %s ...", self._gan_path)
124
+ self._gan_model = build_attention_unet_generator()
125
+ ckpt = torch.load(self._gan_path, map_location=self._device)
126
+
127
+ if isinstance(ckpt, dict) and "G_E" in ckpt:
128
+ state_dict = ckpt["G_E"]
129
+ elif isinstance(ckpt, dict) and all(
130
+ k.startswith(("enc", "dec", "attn", "final")) for k in list(ckpt.keys())[:5]
131
+ ):
132
+ state_dict = ckpt
133
+ else:
134
+ state_dict = ckpt
135
+
136
+ self._gan_model.load_state_dict(state_dict, strict=True)
137
+ self._gan_model.to(self._device)
138
+ self._gan_model.eval()
139
+
140
+ # 2. Load VGTL-Net (ConvNeXt V2 BP Model)
141
+ if not self._vgtlnet_path.exists():
142
+ raise ModelInferenceError(
143
+ "VGTLNet",
144
+ f"Checkpoint not found at: {self._vgtlnet_path}. "
145
+ "Ensure weights are downloaded or set USE_MOCK_MODEL=true."
146
+ )
147
+
148
+ logger.info("Loading VGTL-Net from %s ...", self._vgtlnet_path)
149
+ self._vgtlnet_model = build_convnextv2_bp_model(pretrained=False)
150
+ vgtl_ckpt = torch.load(self._vgtlnet_path, map_location=self._device)
151
+
152
+ if isinstance(vgtl_ckpt, dict) and "model_state_dict" in vgtl_ckpt:
153
+ vgtl_state = vgtl_ckpt["model_state_dict"]
154
+ elif isinstance(vgtl_ckpt, dict) and "state_dict" in vgtl_ckpt:
155
+ vgtl_state = vgtl_ckpt["state_dict"]
156
+ else:
157
+ vgtl_state = vgtl_ckpt
158
+
159
+ if any(k.startswith("module.") for k in vgtl_state.keys()):
160
+ vgtl_state = {k[len("module."):]: v for k, v in vgtl_state.items()}
161
+
162
+ self._vgtlnet_model.load_state_dict(vgtl_state, strict=True)
163
+ self._vgtlnet_model.to(self._device)
164
+ self._vgtlnet_model.eval()
165
+
166
+ self._loaded = True
167
+ logger.info("GANVGTLNetService initialized successfully on %s", self._device)
168
+
169
+ except Exception as e:
170
+ if isinstance(e, ModelInferenceError):
171
+ raise e
172
+ raise ModelInferenceError("Initialization", f"Unexpected error while loading models: {e}") from e
173
+
174
+ async def predict(
175
+ self,
176
+ ppg_signal_id: str,
177
+ segments: np.ndarray,
178
+ ) -> BPPrediction:
179
+ """
180
+ Executes the full pipeline:
181
+ Ingested PPG -> Preprocessing -> CardioGAN translation (aligned) ->
182
+ VG Adjacency extraction -> VGTL-Net raw prediction ->
183
+ Simulated Annealing threshold optimization -> BPPrediction
184
+ """
185
+ if not self._loaded:
186
+ await self.load_model()
187
+
188
+ start = time.perf_counter()
189
+
190
+ try:
191
+ # 1. CardioGAN Pipeline (PPG -> synthetic ECG, both aligned at 125 Hz, 224 samples)
192
+ ppg_segments_224, ecg_segments_224 = self._run_gan_inference_aligned(segments)
193
+
194
+ # 2. VGTL-Net Raw Predictions
195
+ sbp_preds, dbp_preds = self._run_vgtlnet_raw_predictions(ppg_segments_224, ecg_segments_224)
196
+
197
+ # 3. Simulated Annealing Optimization (1000 steps)
198
+ from src.infrastructure.processing.sa_helpers import run_simulated_annealing
199
+
200
+ sa_result = run_simulated_annealing(
201
+ ppg_segments=ppg_segments_224,
202
+ ecg_segments=ecg_segments_224,
203
+ sbp_preds=sbp_preds,
204
+ dbp_preds=dbp_preds,
205
+ n_steps=1000,
206
+ )
207
+
208
+ # 4. Filter predictions based on optimized thresholds
209
+ clean_indices = sa_result["clean_indices"]
210
+ if len(clean_indices) > 0:
211
+ sbp = float(np.mean(sbp_preds[clean_indices]))
212
+ dbp = float(np.mean(dbp_preds[clean_indices]))
213
+ else:
214
+ sbp = float(np.mean(sbp_preds))
215
+ dbp = float(np.mean(dbp_preds))
216
+
217
+ # Assemble SA logs
218
+ sa_log = {
219
+ "optimal_lo": sa_result["optimal_lo"],
220
+ "optimal_hi": sa_result["optimal_hi"],
221
+ "optimal_max_plateau": sa_result["optimal_max_plateau"],
222
+ "best_loss": sa_result["best_loss"],
223
+ "initial_loss": sa_result["initial_loss"],
224
+ "n_total_segments": sa_result["n_total_segments"],
225
+ "n_clean_segments": sa_result["n_clean_segments"],
226
+ "yield_rate": sa_result["yield_rate"],
227
+ "history": sa_result["history"],
228
+ }
229
+
230
+ elapsed_ms = (time.perf_counter() - start) * 1000
231
+
232
+ logger.info(
233
+ "Inference complete (SA optimized) - signal_id=%s, segments=%d (clean=%d) "
234
+ "SBP=%.1f mmHg, DBP=%.1f mmHg (%.1f ms)",
235
+ ppg_signal_id, len(ppg_segments_224), len(clean_indices), sbp, dbp, elapsed_ms,
236
+ )
237
+
238
+ return BPPrediction(
239
+ ppg_signal_id=ppg_signal_id,
240
+ predicted_sbp=round(float(sbp), 1),
241
+ predicted_dbp=round(float(dbp), 1),
242
+ model_version=self.model_version,
243
+ inference_time_ms=round(elapsed_ms, 2),
244
+ sa_log=sa_log,
245
+ )
246
+
247
+ except (PreprocessingError, ModelInferenceError) as e:
248
+ logger.error("Pipeline failure for signal %s: %s", ppg_signal_id, e)
249
+ raise e
250
+ except Exception as e:
251
+ logger.error("Unexpected error in prediction pipeline: %s", e)
252
+ raise ModelInferenceError("PipelineOrchestration", f"Prediction execution failed: {e}") from e
253
+
254
+ def is_loaded(self) -> bool:
255
+ return self._loaded
256
+
257
+ @property
258
+ def model_version(self) -> str:
259
+ return MODEL_VERSION_GAN_VGTLNET
260
+
261
+ # ── Private Inference Execution ───────────────────────────────────────────
262
+
263
+ def _run_gan_inference_aligned(self, ppg_segments: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
264
+ import torch
265
+
266
+ assert self._gan_model is not None, "CardioGAN generator model has not been loaded"
267
+
268
+ try:
269
+ # Step 1.1: Preprocess raw ppg windows into CardioGAN segmented, normalized segments
270
+ # preprocess_ppg handles resample, bandpass butter, z-score, and min-max
271
+ ppg_wins = self._gan_preprocessor.preprocess_ppg(ppg_segments)
272
+
273
+ # Step 1.2: Neural Network inference
274
+ ppg_tensor = torch.tensor(ppg_wins, dtype=torch.float32).unsqueeze(1).to(self._device)
275
+ with torch.no_grad():
276
+ ecg_tensor = self._gan_model(ppg_tensor)
277
+
278
+ ecg_128 = ecg_tensor.squeeze(1).cpu().numpy()
279
+
280
+ # Step 1.3: Postprocess both ECG and PPG to 125 Hz and 224 samples (aligned)
281
+ ecg_segments_out = self._gan_preprocessor.postprocess_ecg(ecg_128)
282
+ ppg_segments_out = self._gan_preprocessor.postprocess_ecg(ppg_wins)
283
+
284
+ return ppg_segments_out, ecg_segments_out
285
+
286
+ except PreprocessingError as e:
287
+ raise e
288
+ except Exception as e:
289
+ raise ModelInferenceError("CardioGAN", f"Forward pass or post-processing failed: {e}") from e
290
+
291
+ def _run_vgtlnet_raw_predictions(
292
+ self,
293
+ ppg_segments: np.ndarray,
294
+ ecg_segments: np.ndarray,
295
+ ) -> Tuple[np.ndarray, np.ndarray]:
296
+ import torch
297
+
298
+ assert self._vgtlnet_model is not None, "VGTL-Net model has not been loaded"
299
+
300
+ try:
301
+ # Step 2.1: Preprocess PPG & ECG segments into Visibility Graph RGB normalise Tensors
302
+ batch_tensor = self._vgtlnet_preprocessor.preprocess_signals(
303
+ ppg_segments, ecg_segments
304
+ )
305
+
306
+ # Step 2.2: Neural Network inference
307
+ batch_device = batch_tensor.to(self._device)
308
+ with torch.no_grad():
309
+ sbp_preds, dbp_preds = self._vgtlnet_model(batch_device)
310
+
311
+ return sbp_preds.cpu().numpy(), dbp_preds.cpu().numpy()
312
+
313
+ except PreprocessingError as e:
314
+ raise e
315
+ except Exception as e:
316
+ raise ModelInferenceError("VGTLNet", f"Forward pass or processing failed: {e}") from e
src/infrastructure/model/mock_model_service.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ infrastructure/model/mock_model_service.py
3
+ ───────────────────────────────────────────
4
+ MockModelService β€” deterministic fake inference for testing and local dev.
5
+
6
+ Returns physiologically plausible (but fake) BP values without loading
7
+ any model weights or requiring a GPU.
8
+
9
+ Use case: run the full ETL pipeline locally with USE_MOCK_MODEL=true.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import hashlib
15
+ import time
16
+
17
+ import numpy as np
18
+
19
+ from src.domain.entities.prediction import BPPrediction
20
+ from src.domain.interfaces.services.model_service import ModelService
21
+ from src.shared.constants import MODEL_VERSION_MOCK
22
+ from src.shared.logger import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+
26
+ # Physiologically plausible reference ranges for mock output
27
+ _SBP_BASE = 115.0 # Normal systolic
28
+ _DBP_BASE = 75.0 # Normal diastolic
29
+ _JITTER = 15.0 # Β± range around base values
30
+
31
+
32
+ class MockModelService(ModelService):
33
+ """
34
+ Deterministic mock model service for testing and local development.
35
+
36
+ Given the same ``ppg_signal_id``, it always returns the same BP values
37
+ (deterministic via hash of the ID) β€” making tests reproducible.
38
+ """
39
+
40
+ def __init__(self) -> None:
41
+ self._loaded = False
42
+
43
+ # ── ModelService interface ────────────────────────────────────────────────
44
+
45
+ async def load_model(self) -> None:
46
+ """Simulates model loading β€” just flips a flag."""
47
+ if self._loaded:
48
+ return
49
+ logger.info("MockModelService.load_model() β€” simulating 100ms load delay")
50
+ await asyncio.sleep(0.1) # simulate loading latency
51
+ self._loaded = True
52
+ logger.info("MockModelService ready (mock mode).")
53
+
54
+ async def predict(
55
+ self,
56
+ ppg_signal_id: str,
57
+ segments: np.ndarray,
58
+ ) -> BPPrediction:
59
+ """
60
+ Return deterministic fake BP values based on the signal ID hash.
61
+
62
+ The hash ensures the same ID always gives the same output, making
63
+ unit tests with MockModelService fully reproducible.
64
+
65
+ Args:
66
+ ppg_signal_id: UUID of the source PPGSignal.
67
+ segments: Preprocessed signal segments (shape: N Γ— window).
68
+
69
+ Returns:
70
+ BPPrediction with fake but physiologically valid SBP/DBP.
71
+ """
72
+ if not self._loaded:
73
+ await self.load_model()
74
+
75
+ start = time.perf_counter()
76
+
77
+ # Deterministic jitter: hash the signal ID to get a repeatable seed
78
+ hash_bytes = hashlib.sha256(ppg_signal_id.encode()).digest()
79
+ seed = int.from_bytes(hash_bytes[:4], "big")
80
+
81
+ # Determine number of segments (always at least 1)
82
+ n_segments = segments.shape[0] if len(segments.shape) > 1 and segments.shape[0] > 0 else 1
83
+
84
+ # 1. Generate fake segments of size 224 to simulate JIT features computation
85
+ fake_ppg = np.zeros((n_segments, 224), dtype=np.float32)
86
+ fake_ecg = np.zeros((n_segments, 224), dtype=np.float32)
87
+
88
+ # Populate with deterministic signal + noise + optional plateau
89
+ for i in range(n_segments):
90
+ seg_seed = (seed + i) & 0xffffffff
91
+ rng_seg = np.random.default_rng(seg_seed)
92
+ t = np.linspace(0, 2 * np.pi, 224)
93
+ # i%3 == 0: noisy (high entropy), others: clean (low entropy)
94
+ noise_lvl = 0.5 if (i % 3 == 0) else 0.05
95
+ fake_ppg[i] = np.sin(t) + rng_seg.normal(0, noise_lvl, 224)
96
+ fake_ecg[i] = np.sin(t * 2) + rng_seg.normal(0, noise_lvl, 224)
97
+
98
+ # i%4 == 0: inject flat lines (plateau)
99
+ if i % 4 == 0:
100
+ fake_ppg[i, 50:70] = 0.5
101
+ fake_ecg[i, 100:120] = -0.5
102
+
103
+ # 2. Generate segment predictions
104
+ sbp_preds = []
105
+ dbp_preds = []
106
+ for i in range(n_segments):
107
+ seg_seed = (seed + i) & 0xffffffff
108
+ rng_seg = np.random.default_rng(seg_seed)
109
+ s = float(_SBP_BASE + rng_seg.uniform(-_JITTER, _JITTER))
110
+ d = float(_DBP_BASE + rng_seg.uniform(-_JITTER / 2, _JITTER / 2))
111
+
112
+ # Add variance to noisy windows
113
+ if i % 3 == 0:
114
+ s += rng_seg.uniform(-25, 25)
115
+ d += rng_seg.uniform(-12, 12)
116
+ # Clamp within physiological bounds
117
+ s = max(80.0, min(200.0, s))
118
+ d = max(40.0, min(120.0, d))
119
+
120
+ if s <= d:
121
+ s = d + 30.0
122
+
123
+ sbp_preds.append(s)
124
+ dbp_preds.append(d)
125
+
126
+ sbp_preds_arr = np.array(sbp_preds)
127
+ dbp_preds_arr = np.array(dbp_preds)
128
+
129
+ # 3. Run Simulated Annealing (1000 steps)
130
+ from src.infrastructure.processing.sa_helpers import run_simulated_annealing
131
+
132
+ sa_result = run_simulated_annealing(
133
+ ppg_segments=fake_ppg,
134
+ ecg_segments=fake_ecg,
135
+ sbp_preds=sbp_preds_arr,
136
+ dbp_preds=dbp_preds_arr,
137
+ n_steps=1000,
138
+ )
139
+
140
+ clean_indices = sa_result["clean_indices"]
141
+ if len(clean_indices) > 0:
142
+ sbp = float(np.mean(sbp_preds_arr[clean_indices]))
143
+ dbp = float(np.mean(dbp_preds_arr[clean_indices]))
144
+ else:
145
+ sbp = float(np.mean(sbp_preds_arr))
146
+ dbp = float(np.mean(dbp_preds_arr))
147
+
148
+ # Assemble SA log dict
149
+ sa_log = {
150
+ "optimal_lo": sa_result["optimal_lo"],
151
+ "optimal_hi": sa_result["optimal_hi"],
152
+ "optimal_max_plateau": sa_result["optimal_max_plateau"],
153
+ "best_loss": sa_result["best_loss"],
154
+ "initial_loss": sa_result["initial_loss"],
155
+ "n_total_segments": sa_result["n_total_segments"],
156
+ "n_clean_segments": sa_result["n_clean_segments"],
157
+ "yield_rate": sa_result["yield_rate"],
158
+ "history": sa_result["history"],
159
+ }
160
+
161
+ # Simulate short inference time
162
+ await asyncio.sleep(0.05)
163
+ elapsed_ms = (time.perf_counter() - start) * 1000
164
+
165
+ logger.info(
166
+ "MockModelService.predict() β€” signal_id=%s segments=%d (clean=%d) "
167
+ "SBP=%.1f DBP=%.1f (%.1f ms)",
168
+ ppg_signal_id,
169
+ n_segments,
170
+ len(clean_indices),
171
+ sbp,
172
+ dbp,
173
+ elapsed_ms,
174
+ )
175
+
176
+ return BPPrediction(
177
+ ppg_signal_id=ppg_signal_id,
178
+ predicted_sbp=round(sbp, 1),
179
+ predicted_dbp=round(dbp, 1),
180
+ model_version=self.model_version,
181
+ inference_time_ms=round(elapsed_ms, 2),
182
+ sa_log=sa_log,
183
+ )
184
+
185
+ def is_loaded(self) -> bool:
186
+ return self._loaded
187
+
188
+ @property
189
+ def model_version(self) -> str:
190
+ return MODEL_VERSION_MOCK
src/infrastructure/model/vgtlnet.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ infrastructure/model/vgtlnet.py
3
+ ────────────────────────────────
4
+ VGTL-Net Model Architecture.
5
+ Strictly defines the PyTorch BP prediction architecture (SRP). No signal preprocessing.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def build_bp_mlp(in_features: int):
11
+ """
12
+ MLP head for SBP or DBP prediction.
13
+ """
14
+ import torch.nn as nn
15
+ return nn.Sequential(
16
+ nn.Linear(in_features, 1024),
17
+ nn.BatchNorm1d(1024),
18
+ nn.ReLU(inplace=True),
19
+ nn.Dropout(0.3),
20
+ nn.Linear(1024, 512),
21
+ nn.BatchNorm1d(512),
22
+ nn.ReLU(inplace=True),
23
+ nn.Dropout(0.2),
24
+ nn.Linear(512, 1),
25
+ )
26
+
27
+
28
+ def build_convnextv2_bp_model(pretrained: bool = False):
29
+ """
30
+ Build ConvNeXtV2BPModel (VGTL-Net backbone + dual MLP head).
31
+ """
32
+ try:
33
+ import timm
34
+ import torch.nn as nn
35
+
36
+ class ConvNeXtV2BPModel(nn.Module):
37
+ """VGTL-Net: ConvNeXt V2 Tiny + Dual MLP Head for SBP/DBP."""
38
+
39
+ def __init__(self, pretrained: bool = False):
40
+ super().__init__()
41
+ self.feature_extractor = timm.create_model(
42
+ "convnextv2_tiny.fcmae_ft_in22k_in1k",
43
+ pretrained=pretrained,
44
+ num_classes=0,
45
+ global_pool="avg",
46
+ )
47
+ feat_dim = self.feature_extractor.num_features # 768
48
+ self.mlp_sbp = build_bp_mlp(feat_dim)
49
+ self.mlp_dbp = build_bp_mlp(feat_dim)
50
+
51
+ def forward(self, x):
52
+ """
53
+ Args:
54
+ x: (B, 3, 224, 224) visibility graph image tensor
55
+ Returns:
56
+ Tuple (sbp_pred, dbp_pred)
57
+ """
58
+ feat = self.feature_extractor(x)
59
+ return self.mlp_sbp(feat).squeeze(-1), self.mlp_dbp(feat).squeeze(-1)
60
+
61
+ return ConvNeXtV2BPModel(pretrained=pretrained)
62
+
63
+ except ImportError as e:
64
+ raise RuntimeError(
65
+ f"Dependencies for VGTL-Net model are missing: {e}. "
66
+ "Please run: pip install timm"
67
+ ) from e