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.
- iints/__init__.py +1 -1
- iints/cli/cli.py +232 -2
- iints/core/patient/models.py +1 -1
- iints/core/patient/profile.py +1 -1
- iints/core/simulator.py +116 -16
- iints/data/virtual_patients/clinic_safe_baseline.yaml +1 -1
- iints/data/virtual_patients/clinic_safe_hyper_challenge.yaml +1 -1
- iints/data/virtual_patients/clinic_safe_hypo_prone.yaml +1 -1
- iints/data/virtual_patients/clinic_safe_midnight.yaml +1 -1
- iints/data/virtual_patients/clinic_safe_pizza.yaml +1 -1
- iints/data/virtual_patients/clinic_safe_stress_meal.yaml +1 -1
- iints/data/virtual_patients/default_patient.yaml +1 -1
- iints/jetson/endurance.py +232 -10
- iints/live_patient/pico_pump.py +418 -0
- iints/research/__init__.py +6 -0
- iints/research/control.py +19 -4
- iints/research/local_ai.py +365 -0
- iints/templates/pico_pump/README.md +47 -0
- iints/templates/pico_pump/code.py +49 -0
- iints/templates/pico_pump/serial_protocol.txt +26 -0
- iints/validation/schemas.py +1 -1
- {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/METADATA +2 -2
- {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/RECORD +29 -24
- {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/WHEEL +0 -0
- {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/entry_points.txt +0 -0
- {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/licenses/LICENSE +0 -0
- {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/licenses/LICENSE-MIT-IINTS-LEGACY +0 -0
- {iints_sdk_python35-1.5.6.dist-info → iints_sdk_python35-1.5.7.dist-info}/licenses/NOTICE +0 -0
- {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.
|
|
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.
|
|
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")] = "
|
|
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,
|
iints/core/patient/models.py
CHANGED
|
@@ -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.
|
|
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
|
iints/core/patient/profile.py
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
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 =
|
|
366
|
-
|
|
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
|
-
|
|
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
|
|
686
|
-
glucose_trend =
|
|
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":
|
|
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]:
|
|
@@ -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.
|
|
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
|