iints-sdk-python35 1.5.6__py3-none-any.whl → 1.5.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. iints/__init__.py +1 -1
  2. iints/cli/cli.py +232 -2
  3. iints/core/patient/models.py +1 -1
  4. iints/core/patient/profile.py +1 -1
  5. iints/core/simulator.py +116 -16
  6. iints/data/virtual_patients/clinic_safe_baseline.yaml +1 -1
  7. iints/data/virtual_patients/clinic_safe_hyper_challenge.yaml +1 -1
  8. iints/data/virtual_patients/clinic_safe_hypo_prone.yaml +1 -1
  9. iints/data/virtual_patients/clinic_safe_midnight.yaml +1 -1
  10. iints/data/virtual_patients/clinic_safe_pizza.yaml +1 -1
  11. iints/data/virtual_patients/clinic_safe_stress_meal.yaml +1 -1
  12. iints/data/virtual_patients/default_patient.yaml +1 -1
  13. iints/jetson/endurance.py +232 -10
  14. iints/live_patient/pico_pump.py +418 -0
  15. iints/research/__init__.py +6 -0
  16. iints/research/control.py +19 -4
  17. iints/research/local_ai.py +365 -0
  18. iints/templates/pico_pump/README.md +47 -0
  19. iints/templates/pico_pump/code.py +49 -0
  20. iints/templates/pico_pump/serial_protocol.txt +26 -0
  21. iints/validation/schemas.py +1 -1
  22. {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/METADATA +2 -2
  23. {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/RECORD +29 -24
  24. {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/WHEEL +0 -0
  25. {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/entry_points.txt +0 -0
  26. {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/licenses/LICENSE +0 -0
  27. {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/licenses/LICENSE-MIT-IINTS-LEGACY +0 -0
  28. {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/licenses/NOTICE +0 -0
  29. {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/top_level.txt +0 -0
iints/__init__.py CHANGED
@@ -11,7 +11,7 @@ except ImportError: # pragma: no cover - Python < 3.8 fallback
11
11
  try:
12
12
  __version__ = version("iints-sdk-python35")
13
13
  except PackageNotFoundError: # pragma: no cover - source tree fallback
14
- __version__ = "1.5.6"
14
+ __version__ = "1.5.7"
15
15
 
16
16
  # Note to developers: this SDK is currently maintained by a single author.
17
17
  # Please report bugs via GitHub issues and feel free to contribute fixes via PRs.
iints/cli/cli.py CHANGED
@@ -137,6 +137,15 @@ from iints.live_patient.uno_q import (
137
137
  run_uno_q_bridge_test,
138
138
  uno_q_bridge_environment_report,
139
139
  )
140
+ from iints.live_patient.pico_pump import (
141
+ PICO_PUMP_BAUDRATE,
142
+ PICO_PUMP_CONFIRMATION,
143
+ build_pico_pump_bundle,
144
+ create_pico_pump_lab,
145
+ export_pico_pump_firmware,
146
+ run_pico_pump_serial_self_test,
147
+ upload_pico_pump_bundle,
148
+ )
140
149
  from iints.mdmp.backend import (
141
150
  MDMP_GRADE_ORDER,
142
151
  active_mdmp_backend,
@@ -232,6 +241,7 @@ plugin_app = typer.Typer(help="Install and manage local IINTS extension plugins.
232
241
  plugin_register_app = typer.Typer(help="Register a local plugin file by extension kind.")
233
242
  patientmodel_app = typer.Typer(help="Patient model registry and extension discovery.")
234
243
  edge_app = typer.Typer(help="Single-board computer and edge deployment tools.")
244
+ edge_pump_app = typer.Typer(help="Bench-only Raspberry Pi Pico pump research workflow.")
235
245
  makerfaire_app = typer.Typer(help="Maker Faire booth startup helpers for the physical virtual patient setup.")
236
246
  jetson_app = typer.Typer(help="NVIDIA Jetson headless research tooling.")
237
247
  jetson_endurance_app = typer.Typer(help="Headless long-running adversarial endurance tests.")
@@ -247,6 +257,7 @@ app.add_typer(plugin_app, name="plugin")
247
257
  plugin_app.add_typer(plugin_register_app, name="register")
248
258
  app.add_typer(patientmodel_app, name="patientmodel")
249
259
  app.add_typer(edge_app, name="edge")
260
+ edge_app.add_typer(edge_pump_app, name="pump")
250
261
  app.add_typer(makerfaire_app, name="makerfaire")
251
262
  app.add_typer(patient_app, name="patient")
252
263
  app.add_typer(jetson_app, name="jetson")
@@ -4077,7 +4088,7 @@ def presets_create(
4077
4088
  f"basal_insulin_rate: {basal_insulin_rate}\n"
4078
4089
  f"insulin_sensitivity: {insulin_sensitivity}\n"
4079
4090
  f"carb_factor: {carb_factor}\n"
4080
- "glucose_decay_rate: 0.03\n"
4091
+ "glucose_decay_rate: 0.003\n"
4081
4092
  f"initial_glucose: {initial_glucose}\n"
4082
4093
  "glucose_absorption_rate: 0.03\n"
4083
4094
  "insulin_action_duration: 300.0\n"
@@ -7174,6 +7185,96 @@ def research_evaluate_controller(
7174
7185
  console.print(f"[green]Evaluation report:[/green] {report['artifacts']['report_md']}")
7175
7186
 
7176
7187
 
7188
+ @research_app.command(name="local-ai-lab")
7189
+ def research_local_ai_lab(
7190
+ run: Annotated[
7191
+ List[str],
7192
+ typer.Option(
7193
+ "--run",
7194
+ help="Repeatable run input in label=path form; path may contain a Jetson endurance bundle.",
7195
+ ),
7196
+ ],
7197
+ output_dir: Annotated[Path, typer.Option(help="Output directory for datasets, models, and reports")] = Path(
7198
+ "results/local_ai_lab"
7199
+ ),
7200
+ train_predictor: Annotated[
7201
+ bool,
7202
+ typer.Option(
7203
+ "--train-predictor/--skip-predictor",
7204
+ help="Train the local glucose predictor from the generated predictor dataset.",
7205
+ ),
7206
+ ] = True,
7207
+ train_neural: Annotated[
7208
+ bool,
7209
+ typer.Option(
7210
+ "--train-neural/--skip-neural",
7211
+ help="Train the PyTorch controller in addition to the auditable linear controller.",
7212
+ ),
7213
+ ] = True,
7214
+ evaluate: Annotated[
7215
+ bool,
7216
+ typer.Option(
7217
+ "--evaluate/--skip-evaluation",
7218
+ help="Run held-out closed-loop evaluation after training.",
7219
+ ),
7220
+ ] = True,
7221
+ predictor_config: Annotated[
7222
+ Optional[Path],
7223
+ typer.Option(help="Optional predictor config YAML. Defaults to research/configs/predictor.yaml."),
7224
+ ] = None,
7225
+ duration_minutes: Annotated[int, typer.Option(help="Duration per held-out controller-evaluation run.")] = 1440,
7226
+ ):
7227
+ """Turn completed runs into local AI datasets, models, and evaluation evidence."""
7228
+ console = Console()
7229
+ if not run:
7230
+ console.print("[bold red]At least one --run label=path value is required.[/bold red]")
7231
+ raise typer.Exit(code=1)
7232
+ parsed_runs: list[tuple[str, Path]] = []
7233
+ for item in run:
7234
+ if "=" not in item:
7235
+ console.print(f"[bold red]Invalid --run value:[/bold red] {item}")
7236
+ raise typer.Exit(code=1)
7237
+ label, raw_path = item.split("=", 1)
7238
+ path = Path(raw_path)
7239
+ if not label.strip() or not path.exists():
7240
+ console.print(f"[bold red]Invalid or missing run:[/bold red] {item}")
7241
+ raise typer.Exit(code=1)
7242
+ parsed_runs.append((label.strip(), path))
7243
+
7244
+ from iints.research.local_ai import run_local_ai_lab
7245
+
7246
+ try:
7247
+ report = run_local_ai_lab(
7248
+ parsed_runs,
7249
+ output_dir=output_dir,
7250
+ repo_root=Path(__file__).resolve().parents[3],
7251
+ train_predictor=train_predictor,
7252
+ predictor_config_path=predictor_config,
7253
+ train_neural=train_neural,
7254
+ evaluate=evaluate,
7255
+ evaluation_duration_minutes=duration_minutes,
7256
+ )
7257
+ except Exception as exc:
7258
+ console.print(f"[bold red]Local AI lab failed:[/bold red] {exc}")
7259
+ raise typer.Exit(code=1)
7260
+
7261
+ table = Table(title="Local AI Research Lab")
7262
+ table.add_column("Artifact", style="cyan")
7263
+ table.add_column("Value", overflow="fold")
7264
+ table.add_row("Predictor rows", str(report["predictor_dataset"]["rows"]))
7265
+ table.add_row("Controller rows", str(report["controller_dataset"]["rows"]))
7266
+ table.add_row("Linear controller", report["linear_controller"]["model_path"])
7267
+ table.add_row("Neural status", report["neural_controller"]["status"])
7268
+ table.add_row("Predictor status", report["predictor_training"]["status"])
7269
+ table.add_row("Evaluation status", report["closed_loop_evaluation"]["status"])
7270
+ table.add_row("Report", report["artifacts"]["report_md"])
7271
+ console.print(table)
7272
+ console.print(
7273
+ "[yellow]Research only:[/yellow] trained artifacts are for simulator research, "
7274
+ "not medical treatment or pump dosing."
7275
+ )
7276
+
7277
+
7177
7278
  @research_app.command(name="export-onnx")
7178
7279
  def research_export_onnx(
7179
7280
  model: Annotated[Path, typer.Option(help="Predictor checkpoint (.pt)")] = Path("models/hupa_finetuned_v2/predictor.pt"),
@@ -8158,6 +8259,10 @@ def _print_endurance_status(console: Console, status: Dict[str, Any]) -> None:
8158
8259
  ("Interventions", status.get("interventions")),
8159
8260
  ("Critical events", status.get("critical_events")),
8160
8261
  ("Worst glucose", status.get("worst_glucose_mgdl")),
8262
+ ("Physiology warnings", status.get("physiology_warning_count")),
8263
+ ("Fail-soft rows", status.get("input_validator_fail_soft_rows")),
8264
+ ("Blind hyperglycemia rows", status.get("algorithm_blind_hyperglycemia_rows")),
8265
+ ("Truth/sensor gap", status.get("mean_abs_truth_sensor_gap_mgdl")),
8161
8266
  ("Last checkpoint minute", status.get("last_checkpoint_minute")),
8162
8267
  ("Resume count", status.get("resume_count")),
8163
8268
  ("Wall elapsed seconds", status.get("wall_elapsed_seconds")),
@@ -8208,7 +8313,7 @@ def jetson_endurance_start(
8208
8313
  output_dir: Annotated[Path, typer.Option(help="Output directory for the endurance study")] = Path("results/jetson_endurance"),
8209
8314
  profile: Annotated[str, typer.Option(help="Endurance profile name")] = "mixed_adversarial",
8210
8315
  seed: Annotated[int, typer.Option(help="Deterministic simulation seed")] = 42,
8211
- patient_model: Annotated[str, typer.Option(help="Patient model name passed to PatientFactory")] = "auto",
8316
+ patient_model: Annotated[str, typer.Option(help="Patient model name passed to PatientFactory")] = "bergman",
8212
8317
  sensor_profile: Annotated[str, typer.Option(help="Sensor profile for the simulated CGM stream")] = "free_living_cgm",
8213
8318
  custom_profile: Annotated[Optional[Path], typer.Option(help="YAML file for --profile custom")] = None,
8214
8319
  time_step: Annotated[int, typer.Option(help="Simulation step size in minutes")] = 5,
@@ -10215,6 +10320,131 @@ def edge_hardware_bridge(
10215
10320
  console.print(table)
10216
10321
 
10217
10322
 
10323
+ @edge_pump_app.command(name="init")
10324
+ def edge_pump_init(
10325
+ output_dir: Annotated[Path, typer.Option(help="Directory where the Pico pump lab workspace should be written.")] = Path("iints_pico_pump_lab"),
10326
+ algorithm: Annotated[Optional[Path], typer.Option(help="Optional existing SDK algorithm to copy into the lab workspace.")] = None,
10327
+ ) -> None:
10328
+ console = Console()
10329
+ try:
10330
+ outputs = create_pico_pump_lab(output_dir, algorithm_path=algorithm)
10331
+ except Exception as exc:
10332
+ console.print(f"[bold red]Pico pump lab setup failed:[/bold red] {exc}")
10333
+ raise typer.Exit(code=1)
10334
+
10335
+ table = Table(title="IINTS Pico Pump Lab")
10336
+ table.add_column("Artifact", style="cyan")
10337
+ table.add_column("Path", overflow="fold")
10338
+ for key in ("output_dir", "algorithm", "safety_contract", "firmware_dir", "readme", "package_script"):
10339
+ table.add_row(key, outputs[key])
10340
+ console.print(table)
10341
+ console.print("[yellow]Bench-only scope:[/yellow] no real insulin delivery, no animal/human use, no motor actuation.")
10342
+
10343
+
10344
+ @edge_pump_app.command(name="firmware")
10345
+ def edge_pump_firmware(
10346
+ output_dir: Annotated[Path, typer.Option(help="Directory where locked Pico bench firmware should be written.")] = Path("pico_pump_firmware"),
10347
+ ) -> None:
10348
+ console = Console()
10349
+ try:
10350
+ outputs = export_pico_pump_firmware(output_dir)
10351
+ except Exception as exc:
10352
+ console.print(f"[bold red]Pico pump firmware export failed:[/bold red] {exc}")
10353
+ raise typer.Exit(code=1)
10354
+
10355
+ table = Table(title="IINTS Pico Pump Bench Firmware")
10356
+ table.add_column("Artifact", style="cyan")
10357
+ table.add_column("Path", overflow="fold")
10358
+ for key, value in outputs.items():
10359
+ table.add_row(key, value)
10360
+ console.print(table)
10361
+
10362
+
10363
+ @edge_pump_app.command(name="package")
10364
+ def edge_pump_package(
10365
+ algorithm: Annotated[Path, typer.Option(help="SDK algorithm Python file to package for bench-only Pico testing.")],
10366
+ output_dir: Annotated[Path, typer.Option(help="Output bundle directory.")] = Path("pico_pump_bundle"),
10367
+ safety_contract: Annotated[Optional[Path], typer.Option(help="Optional zero-delivery safety contract JSON.")] = None,
10368
+ label: Annotated[str, typer.Option(help="Human-readable bundle label written into the manifest.")] = "pico_pump_bench",
10369
+ ) -> None:
10370
+ console = Console()
10371
+ try:
10372
+ outputs = build_pico_pump_bundle(
10373
+ algorithm,
10374
+ output_dir,
10375
+ safety_contract_path=safety_contract,
10376
+ label=label,
10377
+ )
10378
+ except Exception as exc:
10379
+ console.print(f"[bold red]Pico pump bundle failed:[/bold red] {exc}")
10380
+ raise typer.Exit(code=1)
10381
+
10382
+ table = Table(title="IINTS Pico Pump Bench Bundle")
10383
+ table.add_column("Artifact", style="cyan")
10384
+ table.add_column("Value", overflow="fold")
10385
+ table.add_row("output_dir", outputs["output_dir"])
10386
+ table.add_row("algorithm", outputs["algorithm"])
10387
+ table.add_row("algorithm_sha256", outputs["algorithm_sha256"])
10388
+ table.add_row("safety_contract", outputs["safety_contract"])
10389
+ table.add_row("manifest", outputs["manifest"])
10390
+ console.print(table)
10391
+ console.print("[green]Bundle ready for bench-only upload.[/green]")
10392
+
10393
+
10394
+ @edge_pump_app.command(name="upload")
10395
+ def edge_pump_upload(
10396
+ bundle_dir: Annotated[Path, typer.Option(help="Bundle directory from `iints edge pump package`.")],
10397
+ mount_dir: Annotated[Path, typer.Option(help="Mounted writable Pico/CircuitPython-style drive or a test folder.")],
10398
+ bench_only_confirm: Annotated[str, typer.Option(help=f"Must be exactly: {PICO_PUMP_CONFIRMATION}")] = "",
10399
+ write: Annotated[bool, typer.Option("--write", help="Actually copy files. Without this flag, only prints the copy plan.")] = False,
10400
+ ) -> None:
10401
+ console = Console()
10402
+ try:
10403
+ payload = upload_pico_pump_bundle(
10404
+ bundle_dir,
10405
+ mount_dir,
10406
+ bench_only_confirmation=bench_only_confirm,
10407
+ write=write,
10408
+ )
10409
+ except Exception as exc:
10410
+ console.print(f"[bold red]Pico pump upload refused:[/bold red] {exc}")
10411
+ raise typer.Exit(code=1)
10412
+
10413
+ table = Table(title="IINTS Pico Pump Upload Plan" if not write else "IINTS Pico Pump Upload")
10414
+ table.add_column("Field", style="cyan")
10415
+ table.add_column("Value", overflow="fold")
10416
+ table.add_row("bundle_dir", payload["bundle_dir"])
10417
+ table.add_row("mount_dir", payload["mount_dir"])
10418
+ table.add_row("write", str(payload["write"]))
10419
+ table.add_row("scope", str(payload["manifest"].get("scope", "-")))
10420
+ for destination in payload["copied"] if write else payload["planned"]:
10421
+ table.add_row("copied" if write else "planned", destination)
10422
+ console.print(table)
10423
+ if not write:
10424
+ console.print("[yellow]Dry run only.[/yellow] Add --write after checking the mount path.")
10425
+
10426
+
10427
+ @edge_pump_app.command(name="serial-test")
10428
+ def edge_pump_serial_test(
10429
+ port: Annotated[str, typer.Option(help="Serial port for the Pico bench firmware, for example /dev/ttyACM0 or /dev/tty.usbmodem*.")],
10430
+ baudrate: Annotated[int, typer.Option(help="Serial baud rate used by the Pico bench firmware.")] = PICO_PUMP_BAUDRATE,
10431
+ timeout_seconds: Annotated[float, typer.Option(help="Read timeout per command in seconds.")] = 1.5,
10432
+ ) -> None:
10433
+ console = Console()
10434
+ try:
10435
+ results = run_pico_pump_serial_self_test(port, baudrate=baudrate, timeout_seconds=timeout_seconds)
10436
+ except Exception as exc:
10437
+ console.print(f"[bold red]Pico pump serial test failed:[/bold red] {exc}")
10438
+ raise typer.Exit(code=1)
10439
+
10440
+ table = Table(title="IINTS Pico Pump Serial Test")
10441
+ table.add_column("Command", style="cyan")
10442
+ table.add_column("Response", overflow="fold")
10443
+ for result in results:
10444
+ table.add_row(str(result["command"]), str(result["response"] or "-"))
10445
+ console.print(table)
10446
+
10447
+
10218
10448
  @edge_app.command(name="bridge-test")
10219
10449
  def edge_bridge_test(
10220
10450
  port: Annotated[Optional[str], typer.Option(help="Serial port for the UNO Q STM32 side. Use `auto` or omit it if exactly one port is connected.")] = None,
@@ -14,7 +14,7 @@ class CustomPatientModel:
14
14
  This model is intended for educational and stress-testing purposes, not for clinical accuracy.
15
15
  """
16
16
  def __init__(self, basal_insulin_rate: float = 0.8, insulin_sensitivity: float = 50.0,
17
- carb_factor: float = 10.0, glucose_decay_rate: float = 0.05,
17
+ carb_factor: float = 10.0, glucose_decay_rate: float = 0.001,
18
18
  initial_glucose: float = 120.0, glucose_absorption_rate: float = 0.03,
19
19
  basal_glucose_target: Optional[float] = None,
20
20
  insulin_action_duration: float = 300.0, # minutes, e.g., 5 hours
@@ -18,7 +18,7 @@ class PatientProfile:
18
18
  dawn_end_hour: float = 8.0
19
19
 
20
20
  # Advanced knobs (optional)
21
- glucose_decay_rate: float = 0.05
21
+ glucose_decay_rate: float = 0.001
22
22
  glucose_absorption_rate: float = 0.03
23
23
  insulin_action_duration: float = 300.0
24
24
  insulin_peak_time: float = 75.0
iints/core/simulator.py CHANGED
@@ -9,6 +9,11 @@ from iints.core.patient.models import PatientModel
9
9
  from iints.api.base_algorithm import InsulinAlgorithm, AlgorithmInput
10
10
  from iints.core.supervisor import IndependentSupervisor, SafetyLevel
11
11
  from iints.core.safety import InputValidator, SafetyConfig
12
+ from iints.core.safety.config import (
13
+ SIMULATION_GLUCOSE_CEILING_MGDL,
14
+ SIMULATION_GLUCOSE_FLOOR_MGDL,
15
+ SENSOR_MAX_GLUCOSE_RATE_PER_MIN_MGDL,
16
+ )
12
17
  from iints.core.devices.models import SensorModel, PumpModel
13
18
  from iints.core.physiology_variation import EmpiricalResidualModel
14
19
  import numpy as np
@@ -119,9 +124,9 @@ class Simulator:
119
124
  self.seed = seed
120
125
  self.safety_config = safety_config or SafetyConfig()
121
126
  self.supervisor = IndependentSupervisor(safety_config=self.safety_config)
122
- # Raw physiology and CGM-facing values each need their own temporal
123
- # history. Sharing one stateful validator made raw glucose compare
124
- # against the previous noisy sensor sample.
127
+ # Keep the raw validator for older snapshots/audits, but do not apply
128
+ # CGM rate limits to hidden patient truth; only algorithm-facing sensor
129
+ # inputs are plausibility-filtered.
125
130
  self.raw_input_validator = InputValidator(safety_config=self.safety_config)
126
131
  self.input_validator = InputValidator(safety_config=self.safety_config)
127
132
  self.sensor_model = sensor_model or SensorModel(seed=seed)
@@ -161,6 +166,7 @@ class Simulator:
161
166
  self._ratio_overrides: List[Dict[str, Any]] = []
162
167
  self._base_ratio_state: Optional[Dict[str, float]] = None
163
168
  self._previous_glucose_for_trend: Optional[float] = None
169
+ self._glucose_trend_history: List[Tuple[float, float]] = []
164
170
  self._profiling_samples: Dict[str, List[float]] = {
165
171
  "algorithm_latency_ms": [],
166
172
  "supervisor_latency_ms": [],
@@ -361,10 +367,20 @@ class Simulator:
361
367
  self._input_validator_last_error = str(exc)
362
368
  self._input_validator_last_step_fail_soft = True
363
369
  fallback = active_validator.last_valid_glucose
370
+ incoming_value = float(glucose_value)
371
+ if not np.isfinite(incoming_value):
372
+ incoming_value = float(fallback) if fallback is not None else active_validator.min_glucose
373
+ if fallback is not None and active_validator.last_validation_time is not None:
374
+ time_delta = max(float(current_time) - float(active_validator.last_validation_time), 0.0)
375
+ if time_delta > 0.0:
376
+ allowed_delta = active_validator.max_glucose_delta_per_5_min * (time_delta / 5.0)
377
+ requested_delta = incoming_value - float(fallback)
378
+ fallback = float(fallback) + float(
379
+ max(-allowed_delta, min(requested_delta, allowed_delta))
380
+ )
364
381
  if fallback is None:
365
- fallback = float(
366
- max(active_validator.min_glucose, min(glucose_value, active_validator.max_glucose))
367
- )
382
+ fallback = incoming_value
383
+ fallback = float(max(active_validator.min_glucose, min(fallback, active_validator.max_glucose)))
368
384
  self._write_audit_log(
369
385
  {
370
386
  "timestamp": current_time,
@@ -378,6 +394,81 @@ class Simulator:
378
394
  active_validator.last_valid_glucose = fallback
379
395
  active_validator.last_validation_time = current_time
380
396
  return fallback
397
+
398
+ def _bound_simulation_glucose(self, glucose_value: float, current_time: float) -> float:
399
+ """
400
+ Keep the hidden physiological state numerically bounded without applying
401
+ CGM input rate limits to the patient truth trace.
402
+
403
+ The input validator is intentionally strict for sensor values that reach
404
+ an algorithm. The simulated patient state can move faster during stress
405
+ tests, so rate-limiting it would hide the very physiology we need to
406
+ evaluate.
407
+ """
408
+ if not np.isfinite(glucose_value):
409
+ fallback = float(self.patient_model.get_current_glucose())
410
+ self._write_audit_log(
411
+ {
412
+ "timestamp": current_time,
413
+ "event": "simulation_glucose_non_finite",
414
+ "input_value": glucose_value,
415
+ "fallback_value": fallback,
416
+ }
417
+ )
418
+ return fallback
419
+ clipped = float(
420
+ np.clip(
421
+ glucose_value,
422
+ SIMULATION_GLUCOSE_FLOOR_MGDL,
423
+ SIMULATION_GLUCOSE_CEILING_MGDL,
424
+ )
425
+ )
426
+ if clipped != float(glucose_value):
427
+ self._write_audit_log(
428
+ {
429
+ "timestamp": current_time,
430
+ "event": "simulation_glucose_clipped",
431
+ "input_value": glucose_value,
432
+ "clipped_value": clipped,
433
+ }
434
+ )
435
+ return clipped
436
+
437
+ def _update_glucose_trend(self, current_time: float, glucose_value: float) -> float:
438
+ """
439
+ Estimate CGM trend with a short rolling slope instead of one noisy delta.
440
+
441
+ A single noisy 5-minute CGM drop can otherwise look like an impossible
442
+ 30-minute hypoglycemia trajectory. A rolling 20-minute regression keeps
443
+ the signal responsive while avoiding startup/noise spikes.
444
+ """
445
+ self._glucose_trend_history.append((float(current_time), float(glucose_value)))
446
+ window_minutes = max(20.0, float(self.time_step) * 3.0)
447
+ cutoff = float(current_time) - window_minutes
448
+ self._glucose_trend_history = [
449
+ (t, g) for (t, g) in self._glucose_trend_history if t >= cutoff
450
+ ]
451
+ self._previous_glucose_for_trend = float(glucose_value)
452
+
453
+ if len(self._glucose_trend_history) < 3:
454
+ return 0.0
455
+
456
+ times = np.array([t for t, _ in self._glucose_trend_history], dtype=float)
457
+ values = np.array([g for _, g in self._glucose_trend_history], dtype=float)
458
+ if float(times[-1] - times[0]) < max(float(self.time_step) * 2.0, 10.0):
459
+ return 0.0
460
+
461
+ centered_time = times - float(times.mean())
462
+ denom = float(np.dot(centered_time, centered_time))
463
+ if denom <= 0.0:
464
+ return 0.0
465
+
466
+ slope = float(np.dot(centered_time, values - float(values.mean())) / denom)
467
+ max_rate = min(
468
+ float(getattr(self.safety_config, "glucose_rate_alarm", SENSOR_MAX_GLUCOSE_RATE_PER_MIN_MGDL)),
469
+ SENSOR_MAX_GLUCOSE_RATE_PER_MIN_MGDL,
470
+ )
471
+ return float(np.clip(slope, -max_rate, max_rate))
381
472
  def _apply_ratio_overrides(self, current_time: float) -> Dict[str, float]:
382
473
  if self._base_ratio_state is None:
383
474
  self._base_ratio_state = self.patient_model.get_ratio_state()
@@ -572,6 +663,7 @@ class Simulator:
572
663
  self._ratio_overrides = []
573
664
  self._base_ratio_state = self.patient_model.get_ratio_state()
574
665
  self._previous_glucose_for_trend = None
666
+ self._glucose_trend_history = []
575
667
  self._input_validator_fail_soft_count = 0
576
668
  self._input_validator_negative_insulin_clamp_count = 0
577
669
  self._input_validator_last_error = None
@@ -600,12 +692,9 @@ class Simulator:
600
692
  if self.physiology_variation_model is not None:
601
693
  physiology_residual = self.physiology_variation_model.offset_at(float(current_time))
602
694
  actual_glucose_reading = mechanistic_glucose_reading + physiology_residual
603
- # Validate the raw sensor reading
604
- actual_glucose_reading = self._validate_glucose_fail_soft(
695
+ actual_glucose_reading = self._bound_simulation_glucose(
605
696
  actual_glucose_reading,
606
697
  float(current_time),
607
- "sensor_raw",
608
- validator=self.raw_input_validator,
609
698
  )
610
699
  sensor_reading = self.sensor_model.read(actual_glucose_reading, float(current_time))
611
700
  glucose_to_algorithm = sensor_reading.value
@@ -682,11 +771,8 @@ class Simulator:
682
771
  effective_dia = float(ratio_state.get("dia_minutes", self.patient_model.insulin_action_duration))
683
772
  effective_basal = float(ratio_state.get("basal_rate_u_per_hr", self.patient_model.basal_insulin_rate))
684
773
 
685
- # Glucose trend (mg/dL per minute) based on sensor value
686
- glucose_trend = 0.0
687
- if self._previous_glucose_for_trend is not None:
688
- glucose_trend = (glucose_to_algorithm - self._previous_glucose_for_trend) / float(self.time_step)
689
- self._previous_glucose_for_trend = glucose_to_algorithm
774
+ # Glucose trend (mg/dL per minute) based on a short smoothed CGM window.
775
+ glucose_trend = self._update_glucose_trend(float(current_time), glucose_to_algorithm)
690
776
 
691
777
  predicted_glucose_heuristic = self._predict_glucose(
692
778
  current_glucose=glucose_to_algorithm,
@@ -909,6 +995,13 @@ class Simulator:
909
995
  current_time=float(current_time),
910
996
  )
911
997
 
998
+ bolus_insulin_reported = insulin_output.get("bolus_insulin")
999
+ if bolus_insulin_reported is None:
1000
+ bolus_insulin_reported = (
1001
+ float(insulin_output.get("meal_bolus", 0.0))
1002
+ + float(insulin_output.get("correction_bolus", 0.0))
1003
+ )
1004
+
912
1005
  # --- Record Data ---
913
1006
  record = {
914
1007
  "time_minutes": current_time,
@@ -933,7 +1026,8 @@ class Simulator:
933
1026
  "pump_status": pump_delivery.status,
934
1027
  "pump_reason": pump_delivery.reason,
935
1028
  "basal_insulin_units": insulin_output.get("basal_insulin", 0.0),
936
- "bolus_insulin_units": insulin_output.get("bolus_insulin", 0.0) + insulin_output.get("meal_bolus", 0.0), # Combine for simplicity
1029
+ "bolus_insulin_units": bolus_insulin_reported,
1030
+ "meal_bolus_units": insulin_output.get("meal_bolus", 0.0),
937
1031
  "correction_bolus_units": insulin_output.get("correction_bolus", 0.0),
938
1032
  "carb_intake_grams": patient_carb_intake_this_step,
939
1033
  "patient_iob_units": self.patient_model.insulin_on_board,
@@ -1003,6 +1097,7 @@ class Simulator:
1003
1097
  "meal_queue": self.meal_queue,
1004
1098
  "stress_events": [event.__dict__ for event in self.stress_events],
1005
1099
  "critical_low_minutes": self._critical_low_minutes,
1100
+ "glucose_trend_history": self._glucose_trend_history,
1006
1101
  }
1007
1102
 
1008
1103
  def load_state(self, state: Dict[str, Any]) -> None:
@@ -1020,6 +1115,11 @@ class Simulator:
1020
1115
  self.meal_queue = state.get("meal_queue", [])
1021
1116
  self.stress_events = [StressEvent(**payload) for payload in state.get("stress_events", [])]
1022
1117
  self._critical_low_minutes = state.get("critical_low_minutes", 0)
1118
+ self._glucose_trend_history = [
1119
+ (float(item[0]), float(item[1]))
1120
+ for item in state.get("glucose_trend_history", [])
1121
+ if isinstance(item, (list, tuple)) and len(item) == 2
1122
+ ]
1023
1123
  self._resume_state = True
1024
1124
 
1025
1125
  def _build_performance_report(self) -> Dict[str, Any]:
@@ -1,7 +1,7 @@
1
1
  basal_insulin_rate: 0.5
2
2
  insulin_sensitivity: 50.0
3
3
  carb_factor: 10.0
4
- glucose_decay_rate: 0.03
4
+ glucose_decay_rate: 0.003
5
5
  initial_glucose: 140.0
6
6
  glucose_absorption_rate: 0.03
7
7
  insulin_action_duration: 300.0
@@ -1,7 +1,7 @@
1
1
  basal_insulin_rate: 0.55
2
2
  insulin_sensitivity: 45.0
3
3
  carb_factor: 9.0
4
- glucose_decay_rate: 0.03
4
+ glucose_decay_rate: 0.003
5
5
  initial_glucose: 150.0
6
6
  glucose_absorption_rate: 0.03
7
7
  insulin_action_duration: 300.0
@@ -1,7 +1,7 @@
1
1
  basal_insulin_rate: 0.35
2
2
  insulin_sensitivity: 55.0
3
3
  carb_factor: 12.0
4
- glucose_decay_rate: 0.03
4
+ glucose_decay_rate: 0.003
5
5
  initial_glucose: 130.0
6
6
  glucose_absorption_rate: 0.03
7
7
  insulin_action_duration: 300.0
@@ -1,7 +1,7 @@
1
1
  basal_insulin_rate: 0.45
2
2
  insulin_sensitivity: 65.0
3
3
  carb_factor: 11.0
4
- glucose_decay_rate: 0.03
4
+ glucose_decay_rate: 0.003
5
5
  initial_glucose: 125.0
6
6
  glucose_absorption_rate: 0.03
7
7
  insulin_action_duration: 300.0
@@ -1,7 +1,7 @@
1
1
  basal_insulin_rate: 0.5
2
2
  insulin_sensitivity: 50.0
3
3
  carb_factor: 10.0
4
- glucose_decay_rate: 0.03
4
+ glucose_decay_rate: 0.003
5
5
  initial_glucose: 135.0
6
6
  glucose_absorption_rate: 0.03
7
7
  insulin_action_duration: 300.0
@@ -1,7 +1,7 @@
1
1
  basal_insulin_rate: 0.4
2
2
  insulin_sensitivity: 55.0
3
3
  carb_factor: 11.0
4
- glucose_decay_rate: 0.03
4
+ glucose_decay_rate: 0.003
5
5
  initial_glucose: 120.0
6
6
  glucose_absorption_rate: 0.03
7
7
  insulin_action_duration: 300.0
@@ -4,7 +4,7 @@
4
4
  basal_insulin_rate: 0.8 # U/hr
5
5
  insulin_sensitivity: 50.0 # mg/dL per Unit
6
6
  carb_factor: 10.0 # g/Unit
7
- glucose_decay_rate: 0.05 # Rate at which glucose naturally decreases
7
+ glucose_decay_rate: 0.001 # Rate at which glucose naturally decreases
8
8
  initial_glucose: 120.0 # mg/dL
9
9
  glucose_absorption_rate: 0.03 # Rate at which carbs are absorbed into glucose
10
10
  insulin_action_duration: 300.0 # minutes, e.g., 5 hours