anasim-simulator 1.0__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.
- anasim/__init__.py +3 -0
- anasim/__main__.py +4 -0
- anasim/cli.py +107 -0
- anasim/core/__init__.py +0 -0
- anasim/core/constants.py +151 -0
- anasim/core/drug_api.py +371 -0
- anasim/core/engine.py +924 -0
- anasim/core/enums.py +11 -0
- anasim/core/initialization.py +396 -0
- anasim/core/metrics.py +66 -0
- anasim/core/monitors.py +311 -0
- anasim/core/projection.py +355 -0
- anasim/core/recorder.py +67 -0
- anasim/core/runtime.py +737 -0
- anasim/core/state.py +208 -0
- anasim/core/tci.py +303 -0
- anasim/core/units.py +75 -0
- anasim/core/utils.py +67 -0
- anasim/machine/circuit.py +90 -0
- anasim/machine/ventilator.py +146 -0
- anasim/machine/volatile.py +40 -0
- anasim/monitors/__init__.py +0 -0
- anasim/monitors/alarms.py +88 -0
- anasim/monitors/capno.py +200 -0
- anasim/monitors/ecg.py +126 -0
- anasim/monitors/nibp.py +99 -0
- anasim/monitors/spo2.py +75 -0
- anasim/patient/__init__.py +0 -0
- anasim/patient/patient.py +118 -0
- anasim/patient/pd/__init__.py +10 -0
- anasim/patient/pd/anesthesia.py +268 -0
- anasim/patient/pd/nmba.py +142 -0
- anasim/patient/pk_models.py +1343 -0
- anasim/patient/volatile_pk.py +150 -0
- anasim/physiology/disturbances.py +91 -0
- anasim/physiology/hemo_config.py +185 -0
- anasim/physiology/hemo_types.py +37 -0
- anasim/physiology/hemodynamics.py +1313 -0
- anasim/physiology/resp_mech.py +383 -0
- anasim/physiology/respiration.py +557 -0
- anasim/ui/__init__.py +0 -0
- anasim/ui/config_dialog.py +341 -0
- anasim/ui/controls_widget.py +1118 -0
- anasim/ui/main_window.py +375 -0
- anasim/ui/monitor_widget.py +537 -0
- anasim/ui/scenarios/__init__.py +25 -0
- anasim/ui/scenarios/anaphylaxis.py +88 -0
- anasim/ui/scenarios/base.py +313 -0
- anasim/ui/scenarios/emergence.py +184 -0
- anasim/ui/scenarios/hemorrhage.py +128 -0
- anasim/ui/scenarios/induction.py +292 -0
- anasim/ui/scenarios/sepsis.py +121 -0
- anasim/ui/styles.py +552 -0
- anasim/ui/tutorial_overlay.py +179 -0
- anasim_simulator-1.0.dist-info/METADATA +135 -0
- anasim_simulator-1.0.dist-info/RECORD +60 -0
- anasim_simulator-1.0.dist-info/WHEEL +5 -0
- anasim_simulator-1.0.dist-info/entry_points.txt +2 -0
- anasim_simulator-1.0.dist-info/licenses/LICENSE +21 -0
- anasim_simulator-1.0.dist-info/top_level.txt +1 -0
anasim/__init__.py
ADDED
anasim/__main__.py
ADDED
anasim/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from anasim.core.engine import SimulationEngine, SimulationConfig
|
|
9
|
+
from anasim.patient.patient import Patient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PATIENT_CONFIG_FIELDS = {
|
|
13
|
+
field.name for field in fields(Patient)
|
|
14
|
+
if field.name not in {"lbm", "bmi", "bsa"}
|
|
15
|
+
}
|
|
16
|
+
SIMULATION_CONFIG_FIELDS = {field.name for field in fields(SimulationConfig)}
|
|
17
|
+
CONFIG_FIELDS = PATIENT_CONFIG_FIELDS | SIMULATION_CONFIG_FIELDS
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_models_from_config(config_data: dict) -> tuple[Patient, SimulationConfig]:
|
|
21
|
+
"""Build typed inputs and reject misspelled or obsolete keys."""
|
|
22
|
+
unknown = set(config_data) - CONFIG_FIELDS
|
|
23
|
+
if unknown:
|
|
24
|
+
names = ", ".join(sorted(unknown))
|
|
25
|
+
raise ValueError(f"Unknown configuration key(s): {names}")
|
|
26
|
+
|
|
27
|
+
patient_kwargs = {
|
|
28
|
+
key: value for key, value in config_data.items()
|
|
29
|
+
if key in PATIENT_CONFIG_FIELDS
|
|
30
|
+
}
|
|
31
|
+
simulation_kwargs = {
|
|
32
|
+
key: value for key, value in config_data.items()
|
|
33
|
+
if key in SIMULATION_CONFIG_FIELDS
|
|
34
|
+
}
|
|
35
|
+
return Patient(**patient_kwargs), SimulationConfig(**simulation_kwargs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_headless(args):
|
|
39
|
+
"""Run simulation in headless mode."""
|
|
40
|
+
print(f"Starting Headless Simulation (Duration: {args.duration}s)...")
|
|
41
|
+
|
|
42
|
+
# Simple default config or load from file
|
|
43
|
+
config_data = {}
|
|
44
|
+
if args.config:
|
|
45
|
+
try:
|
|
46
|
+
config_data = json.loads(Path(args.config).read_text())
|
|
47
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
48
|
+
print(f"Error loading config: {e}")
|
|
49
|
+
raise SystemExit(1) from e
|
|
50
|
+
try:
|
|
51
|
+
patient, sim_config = build_models_from_config(config_data)
|
|
52
|
+
except ValueError as e:
|
|
53
|
+
print(f"Error loading config: {e}")
|
|
54
|
+
raise SystemExit(1) from e
|
|
55
|
+
|
|
56
|
+
engine = SimulationEngine(patient, sim_config)
|
|
57
|
+
if args.record:
|
|
58
|
+
engine.start_recording(output_dir=args.record_dir, sample_interval_sec=args.record_interval)
|
|
59
|
+
engine.start()
|
|
60
|
+
|
|
61
|
+
# Run loop
|
|
62
|
+
start_real = time.perf_counter()
|
|
63
|
+
steps = int(args.duration / sim_config.dt)
|
|
64
|
+
|
|
65
|
+
for i in range(steps):
|
|
66
|
+
engine.step(sim_config.dt)
|
|
67
|
+
if i % 100 == 0:
|
|
68
|
+
state = engine.get_latest_state()
|
|
69
|
+
hr = state.display_value("hr")
|
|
70
|
+
map_val = state.display_value("map")
|
|
71
|
+
spo2 = state.display_value("spo2")
|
|
72
|
+
print(f"Time: {state.time:.2f}s | HR: {hr:.1f} | MAP: {map_val:.1f} | SpO2: {spo2:.1f}")
|
|
73
|
+
|
|
74
|
+
end_real = time.perf_counter()
|
|
75
|
+
print(f"Simulation completed in {end_real - start_real:.2f}s real time.")
|
|
76
|
+
|
|
77
|
+
def run_ui():
|
|
78
|
+
"""Run simulation with UI."""
|
|
79
|
+
from PySide6.QtWidgets import QApplication
|
|
80
|
+
from anasim.ui.main_window import MainWindow
|
|
81
|
+
|
|
82
|
+
app = QApplication.instance()
|
|
83
|
+
if app is None:
|
|
84
|
+
app = QApplication(sys.argv)
|
|
85
|
+
|
|
86
|
+
window = MainWindow()
|
|
87
|
+
window.show()
|
|
88
|
+
sys.exit(app.exec())
|
|
89
|
+
|
|
90
|
+
def main():
|
|
91
|
+
parser = argparse.ArgumentParser(description="AnaSim - Anesthesia Simulator")
|
|
92
|
+
parser.add_argument("--mode", choices=["ui", "headless"], default="ui", help="Run mode (default: ui)")
|
|
93
|
+
parser.add_argument("--duration", type=float, default=10.0, help="Duration for headless mode in seconds")
|
|
94
|
+
parser.add_argument("--config", type=str, help="Path to JSON configuration file")
|
|
95
|
+
parser.add_argument("--record", action="store_true", help="Enable CSV recording (headless only)")
|
|
96
|
+
parser.add_argument("--record-dir", type=str, default="recordings", help="Output directory for recordings")
|
|
97
|
+
parser.add_argument("--record-interval", type=float, default=1.0, help="Sample interval in seconds for CSV")
|
|
98
|
+
|
|
99
|
+
args = parser.parse_args()
|
|
100
|
+
|
|
101
|
+
if args.mode == "headless":
|
|
102
|
+
run_headless(args)
|
|
103
|
+
else:
|
|
104
|
+
run_ui()
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
anasim/core/__init__.py
ADDED
|
File without changes
|
anasim/core/constants.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Physiological and Numerical Constants for AnaSim.
|
|
3
|
+
|
|
4
|
+
This module centralizes magic numbers used throughout the simulation.
|
|
5
|
+
|
|
6
|
+
NOTE: Only add constants here that are ACTIVELY IMPORTED elsewhere.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
# Physiological bounds (used in hemodynamics.py).
|
|
12
|
+
|
|
13
|
+
# Heart Rate Bounds (bpm)
|
|
14
|
+
HR_MIN = 10.0 # Below this, functional asystole
|
|
15
|
+
HR_MAX = 220.0 # Maximum physiological HR
|
|
16
|
+
|
|
17
|
+
# Blood Pressure Bounds (mmHg) - imported but available for bounds checking
|
|
18
|
+
MAP_MAX = 200.0 # Severe hypertensive crisis
|
|
19
|
+
SBP_MAX = 260.0 # Extreme hypertension
|
|
20
|
+
DBP_MAX = 160.0 # Extreme diastolic hypertension
|
|
21
|
+
|
|
22
|
+
# Total Peripheral Resistance minimum (Wood Units)
|
|
23
|
+
TPR_MIN = 0.006 # Minimum physiologically reasonable (severe vasodilation)
|
|
24
|
+
|
|
25
|
+
# Blood Volume (mL)
|
|
26
|
+
BLOOD_VOLUME_MIN = 500.0 # Below this, effectively exsanguinated
|
|
27
|
+
|
|
28
|
+
# Respiratory constants (used in respiration.py).
|
|
29
|
+
|
|
30
|
+
# Respiratory Rate Thresholds (bpm)
|
|
31
|
+
RR_APNEA_THRESHOLD = 2.0 # Below this, complete apnea
|
|
32
|
+
RR_BRADYPNEA_THRESHOLD = 4.0 # 2-4 bpm = severe bradypnea with irregular breathing
|
|
33
|
+
|
|
34
|
+
# Tidal Volume Minimum (mL)
|
|
35
|
+
VT_MIN = 50.0 # Threshold for functional apnea
|
|
36
|
+
|
|
37
|
+
# Pharmacokinetic constants (used in utils.py hill_function).
|
|
38
|
+
|
|
39
|
+
# Epsilon for preventing division by zero in Hill functions
|
|
40
|
+
HILL_EPSILON = 1e-12
|
|
41
|
+
|
|
42
|
+
# Maximum Hill coefficient (gamma) to prevent numerical overflow
|
|
43
|
+
GAMMA_MAX = 20.0
|
|
44
|
+
|
|
45
|
+
# Concentration ratio above which Hill function returns near-saturation
|
|
46
|
+
CONCENTRATION_RATIO_SATURATION = 100.0
|
|
47
|
+
|
|
48
|
+
# TCI controller constants (used in tci.py).
|
|
49
|
+
|
|
50
|
+
# Minimum interval between target changes (seconds)
|
|
51
|
+
TCI_MIN_TARGET_CHANGE_INTERVAL = 5.0
|
|
52
|
+
|
|
53
|
+
# Maximum TCI peak time search (seconds)
|
|
54
|
+
TCI_PEAK_TIME_MAX = 600.0
|
|
55
|
+
|
|
56
|
+
# Thermoregulation constants (used in respiration.py, hemodynamics.py).
|
|
57
|
+
|
|
58
|
+
# Temperature coefficient for metabolic rate (Q10 effect)
|
|
59
|
+
# VCO2 decreases ~7% per °C below 37°C (Q10 ≈ 2.0)
|
|
60
|
+
# Reference: Sessler. Anesthesiology. 2000.
|
|
61
|
+
TEMP_METABOLIC_COEFFICIENT = 0.93
|
|
62
|
+
|
|
63
|
+
# Thermoregulation effect on TPR per degree deviation from 37°C
|
|
64
|
+
# TPR increases ~10% per °C below normal (vasoconstriction)
|
|
65
|
+
# Reference: Frank et al. JAMA. 1997.
|
|
66
|
+
TEMP_TPR_COEFFICIENT = 0.10
|
|
67
|
+
|
|
68
|
+
# Shivering model constants (used in runtime.py, respiration.py).
|
|
69
|
+
# Clinically, shivering appears near 36.5°C in awake patients and is
|
|
70
|
+
# suppressed by anesthetics/opioids; maximal shivering can raise
|
|
71
|
+
# metabolic rate ~3-5x baseline.
|
|
72
|
+
SHIVER_BASE_THRESHOLD = 36.5
|
|
73
|
+
SHIVER_DEPTH_DROP_MAX = 2.0
|
|
74
|
+
SHIVER_REMI_DROP_MAX = 0.8
|
|
75
|
+
SHIVER_DELTA_FULL = 1.5
|
|
76
|
+
SHIVER_BIS_ON = 60.0
|
|
77
|
+
SHIVER_BIS_FULL = 80.0
|
|
78
|
+
SHIVER_MAX_MULTIPLIER = 3.0
|
|
79
|
+
SHIVER_TAU_ON = 30.0
|
|
80
|
+
SHIVER_TAU_OFF = 90.0
|
|
81
|
+
|
|
82
|
+
# Apnea PaCO2 rise rates (mmHg/min) and fast-phase duration.
|
|
83
|
+
APNEA_PACO2_RISE_FAST_MMHG_MIN = 10.0
|
|
84
|
+
APNEA_PACO2_RISE_SLOW_MMHG_MIN = 3.6
|
|
85
|
+
APNEA_PACO2_RISE_FAST_DURATION_SEC = 60.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class AirwayTuning:
|
|
90
|
+
"""Centralized airway tuning parameters."""
|
|
91
|
+
# Laryngospasm dynamics (seconds)
|
|
92
|
+
laryngospasm_tau_on: float = 1.0
|
|
93
|
+
laryngospasm_tau_off: float = 8.0
|
|
94
|
+
|
|
95
|
+
# Resistance impact (cmH2O/(L/s))
|
|
96
|
+
upper_resistance_gain: float = 40.0
|
|
97
|
+
bronch_resistance_gain: float = 20.0
|
|
98
|
+
|
|
99
|
+
# Ventilation efficiency weighting
|
|
100
|
+
vent_efficiency_bronch_weight: float = 0.5
|
|
101
|
+
vent_efficiency_upper_weight: float = 0.2
|
|
102
|
+
vent_efficiency_min: float = 0.1
|
|
103
|
+
|
|
104
|
+
# Capnography obstruction weighting
|
|
105
|
+
capno_obstruction_upper_weight: float = 1.0
|
|
106
|
+
capno_obstruction_bronch_weight: float = 0.7
|
|
107
|
+
|
|
108
|
+
# V/Q mismatch weighting
|
|
109
|
+
vq_mismatch_bronch_weight: float = 0.85
|
|
110
|
+
vq_mismatch_upper_weight: float = 0.25
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class ThermalTuning:
|
|
115
|
+
"""Centralized thermal model tuning parameters."""
|
|
116
|
+
ambient_temp_c: float = 20.0
|
|
117
|
+
base_conductance_w_per_c: float = 3.0
|
|
118
|
+
anesthetic_conductance_gain: float = 0.5
|
|
119
|
+
redistribution_gain_w_per_depth: float = 50000.0
|
|
120
|
+
bair_hugger_gain_w_per_c: float = 7.0
|
|
121
|
+
metabolic_reduction_max: float = 0.2
|
|
122
|
+
depth_propofol_scale: float = 4.0
|
|
123
|
+
metabolic_temp_threshold_c: float = 0.5
|
|
124
|
+
specific_heat_j_kg_k: float = 3470.0
|
|
125
|
+
temp_min_c: float = 25.0
|
|
126
|
+
temp_max_c: float = 42.0
|
|
127
|
+
|
|
128
|
+
# Centralized tuple configurations for Engine state models
|
|
129
|
+
PK_HEMODYNAMIC_MODEL_ATTRS = (
|
|
130
|
+
("propofol", "pk_prop"),
|
|
131
|
+
("remi", "pk_remi"),
|
|
132
|
+
("nore", "pk_nore"),
|
|
133
|
+
("roc", "pk_roc"),
|
|
134
|
+
("epi", "pk_epi"),
|
|
135
|
+
("phenyl", "pk_phenyl"),
|
|
136
|
+
("vaso", "pk_vaso"),
|
|
137
|
+
("dobu", "pk_dobu"),
|
|
138
|
+
("milri", "pk_mil"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
BOLUS_TARGETS = (
|
|
142
|
+
("prop", "pk_prop"),
|
|
143
|
+
("remi", "pk_remi"),
|
|
144
|
+
("nore", "pk_nore"),
|
|
145
|
+
("epi", "pk_epi"),
|
|
146
|
+
("phenyl", "pk_phenyl"),
|
|
147
|
+
("vaso", "pk_vaso"),
|
|
148
|
+
("dobu", "pk_dobu"),
|
|
149
|
+
("milri", "pk_mil"),
|
|
150
|
+
("roc", "pk_roc"),
|
|
151
|
+
)
|
anasim/core/drug_api.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drug Controller API Mixin for SimulationEngine.
|
|
3
|
+
|
|
4
|
+
This module contains drug control interface methods extracted from engine.py
|
|
5
|
+
for better maintainability while preserving the SimulationEngine API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .units import convert_rate
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .engine import SimulationEngine
|
|
14
|
+
from .tci import TCIController
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DrugControllerMixin:
|
|
18
|
+
"""
|
|
19
|
+
Mixin providing drug control interface for SimulationEngine.
|
|
20
|
+
|
|
21
|
+
Provides methods for:
|
|
22
|
+
- Setting drug infusion rates
|
|
23
|
+
- Enabling/disabling TCI (Target Controlled Infusion)
|
|
24
|
+
- Getting drug metadata and state for UI
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_DRUG_ORDER = (
|
|
28
|
+
"propofol",
|
|
29
|
+
"remi",
|
|
30
|
+
"nore",
|
|
31
|
+
"vaso",
|
|
32
|
+
"phenyl",
|
|
33
|
+
"epi",
|
|
34
|
+
"dobu",
|
|
35
|
+
"milri",
|
|
36
|
+
"roc",
|
|
37
|
+
)
|
|
38
|
+
_DRUG_SPECS = {
|
|
39
|
+
"propofol": {
|
|
40
|
+
"name": "Propofol 1%",
|
|
41
|
+
"rate_attr": "propofol_rate_mg_sec",
|
|
42
|
+
"rate_unit": "mg/hr",
|
|
43
|
+
"rate_unit_internal": "mg/sec",
|
|
44
|
+
"tci_attr": "tci_prop",
|
|
45
|
+
"tci_name": "Propofol",
|
|
46
|
+
"tci_unit": "ug/mL",
|
|
47
|
+
"tci_mode": None,
|
|
48
|
+
"tci_range": (0.0, 10.0),
|
|
49
|
+
"pk_attr": "pk_prop",
|
|
50
|
+
"max_rate": ("mg_kg_min", 0.3),
|
|
51
|
+
"default_bolus": 150,
|
|
52
|
+
"bolus_unit": "mg",
|
|
53
|
+
},
|
|
54
|
+
"remi": {
|
|
55
|
+
"name": "Remifentanil 50mcg/mL",
|
|
56
|
+
"rate_attr": "remi_rate_ug_sec",
|
|
57
|
+
"rate_unit": "ug/min",
|
|
58
|
+
"rate_unit_internal": "ug/sec",
|
|
59
|
+
"tci_attr": "tci_remi",
|
|
60
|
+
"tci_name": "Remifentanil",
|
|
61
|
+
"tci_unit": "ng/mL",
|
|
62
|
+
"tci_mode": None,
|
|
63
|
+
"tci_range": (0.0, 10.0),
|
|
64
|
+
"pk_attr": "pk_remi",
|
|
65
|
+
"max_rate": ("ug_kg_min", 0.5),
|
|
66
|
+
"default_bolus": 10,
|
|
67
|
+
"bolus_unit": "mcg",
|
|
68
|
+
},
|
|
69
|
+
"nore": {
|
|
70
|
+
"name": "Norepinephrine 16mcg/mL",
|
|
71
|
+
"rate_attr": "nore_rate_ug_sec",
|
|
72
|
+
"rate_unit": "ug/min",
|
|
73
|
+
"rate_unit_internal": "ug/sec",
|
|
74
|
+
"tci_attr": "tci_nore",
|
|
75
|
+
"tci_name": "Norepinephrine",
|
|
76
|
+
"tci_unit": "ng/mL",
|
|
77
|
+
"tci_mode": "plasma",
|
|
78
|
+
"tci_range": (0.0, 30.0),
|
|
79
|
+
"pk_attr": "pk_nore",
|
|
80
|
+
"max_rate": ("ug_kg_min", 1.0),
|
|
81
|
+
"default_bolus": 10,
|
|
82
|
+
"bolus_unit": "mcg",
|
|
83
|
+
},
|
|
84
|
+
"vaso": {
|
|
85
|
+
"name": "Vasopressin 20U/mL",
|
|
86
|
+
"rate_attr": "vaso_rate_mu_sec",
|
|
87
|
+
"rate_unit": "U/min",
|
|
88
|
+
"rate_unit_internal": "mU/sec",
|
|
89
|
+
"tci_attr": "tci_vaso",
|
|
90
|
+
"tci_name": "Vasopressin",
|
|
91
|
+
"tci_unit": "mU/L",
|
|
92
|
+
"tci_mode": "plasma",
|
|
93
|
+
"tci_range": (0.0, 80.0),
|
|
94
|
+
"pk_attr": "pk_vaso",
|
|
95
|
+
"max_rate": ("u_min", 0.1),
|
|
96
|
+
"default_bolus": 1.0,
|
|
97
|
+
"bolus_unit": "U",
|
|
98
|
+
},
|
|
99
|
+
"epi": {
|
|
100
|
+
"name": "Epinephrine 100mcg/mL",
|
|
101
|
+
"rate_attr": "epi_rate_ug_sec",
|
|
102
|
+
"rate_unit": "ug/min",
|
|
103
|
+
"rate_unit_internal": "ug/sec",
|
|
104
|
+
"tci_attr": "tci_epi",
|
|
105
|
+
"tci_name": "Epinephrine",
|
|
106
|
+
"tci_unit": "ng/mL",
|
|
107
|
+
"tci_mode": "plasma",
|
|
108
|
+
"tci_range": (0.0, 20.0),
|
|
109
|
+
"pk_attr": "pk_epi",
|
|
110
|
+
"max_rate": ("ug_kg_min", 0.5),
|
|
111
|
+
"default_bolus": 10,
|
|
112
|
+
"bolus_unit": "mcg",
|
|
113
|
+
},
|
|
114
|
+
"phenyl": {
|
|
115
|
+
"name": "Phenylephrine 100mcg/mL",
|
|
116
|
+
"rate_attr": "phenyl_rate_ug_sec",
|
|
117
|
+
"rate_unit": "ug/min",
|
|
118
|
+
"rate_unit_internal": "ug/sec",
|
|
119
|
+
"tci_attr": "tci_phenyl",
|
|
120
|
+
"tci_name": "Phenylephrine",
|
|
121
|
+
"tci_unit": "ng/mL",
|
|
122
|
+
"tci_mode": "plasma",
|
|
123
|
+
"tci_range": (0.0, 120.0),
|
|
124
|
+
"pk_attr": "pk_phenyl",
|
|
125
|
+
"max_rate": ("ug_kg_min", 2.0),
|
|
126
|
+
"default_bolus": 100,
|
|
127
|
+
"bolus_unit": "mcg",
|
|
128
|
+
},
|
|
129
|
+
"dobu": {
|
|
130
|
+
"name": "Dobutamine 1mg/mL",
|
|
131
|
+
"rate_attr": "dobu_rate_ug_sec",
|
|
132
|
+
"rate_unit": "ug/min",
|
|
133
|
+
"rate_unit_internal": "ug/sec",
|
|
134
|
+
"tci_attr": "tci_dobu",
|
|
135
|
+
"tci_name": "Dobutamine",
|
|
136
|
+
"tci_unit": "ng/mL",
|
|
137
|
+
"tci_mode": "plasma",
|
|
138
|
+
"tci_range": (0.0, 500.0),
|
|
139
|
+
"pk_attr": "pk_dobu",
|
|
140
|
+
"max_rate": ("ug_kg_min", 20.0),
|
|
141
|
+
"default_bolus": 0.0,
|
|
142
|
+
"bolus_unit": "mcg",
|
|
143
|
+
},
|
|
144
|
+
"milri": {
|
|
145
|
+
"name": "Milrinone 200mcg/mL",
|
|
146
|
+
"rate_attr": "mil_rate_ug_sec",
|
|
147
|
+
"rate_unit": "ug/min",
|
|
148
|
+
"rate_unit_internal": "ug/sec",
|
|
149
|
+
"tci_attr": "tci_mil",
|
|
150
|
+
"tci_name": "Milrinone",
|
|
151
|
+
"tci_unit": "ng/mL",
|
|
152
|
+
"tci_mode": "plasma",
|
|
153
|
+
"tci_range": (0.0, 500.0),
|
|
154
|
+
"pk_attr": "pk_mil",
|
|
155
|
+
"max_rate": ("ug_kg_min", 0.75),
|
|
156
|
+
"default_bolus": 0.0,
|
|
157
|
+
"bolus_unit": "mcg",
|
|
158
|
+
},
|
|
159
|
+
"roc": {
|
|
160
|
+
"name": "Rocuronium 10mg/mL",
|
|
161
|
+
"rate_attr": "roc_rate_mg_sec",
|
|
162
|
+
"rate_unit": "mg/hr",
|
|
163
|
+
"rate_unit_internal": "mg/sec",
|
|
164
|
+
"tci_attr": "tci_roc",
|
|
165
|
+
"tci_name": "Rocuronium",
|
|
166
|
+
"tci_unit": "ug/mL",
|
|
167
|
+
"tci_mode": None,
|
|
168
|
+
"tci_range": (0.0, 10.0),
|
|
169
|
+
"pk_attr": "pk_roc",
|
|
170
|
+
"max_rate": ("mg_kg_hr", 1.0),
|
|
171
|
+
"default_bolus": 50,
|
|
172
|
+
"bolus_unit": "mg",
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def set_propofol_rate(self: "SimulationEngine", mg_per_hr: float):
|
|
177
|
+
self._set_rate_from_user("propofol", mg_per_hr)
|
|
178
|
+
|
|
179
|
+
def set_remi_rate(self: "SimulationEngine", ug_per_min: float):
|
|
180
|
+
self._set_rate_from_user("remi", ug_per_min)
|
|
181
|
+
|
|
182
|
+
def set_nore_rate(self: "SimulationEngine", ug_per_min: float):
|
|
183
|
+
self._set_rate_from_user("nore", ug_per_min)
|
|
184
|
+
|
|
185
|
+
def set_epi_rate(self: "SimulationEngine", ug_per_min: float):
|
|
186
|
+
self._set_rate_from_user("epi", ug_per_min)
|
|
187
|
+
|
|
188
|
+
def set_phenyl_rate(self: "SimulationEngine", ug_per_min: float):
|
|
189
|
+
self._set_rate_from_user("phenyl", ug_per_min)
|
|
190
|
+
|
|
191
|
+
def set_vaso_rate(self: "SimulationEngine", u_per_min: float):
|
|
192
|
+
self._set_rate_from_user("vaso", u_per_min)
|
|
193
|
+
|
|
194
|
+
def set_dobu_rate(self: "SimulationEngine", ug_per_min: float):
|
|
195
|
+
self._set_rate_from_user("dobu", ug_per_min)
|
|
196
|
+
|
|
197
|
+
def set_milri_rate(self: "SimulationEngine", ug_per_min: float):
|
|
198
|
+
self._set_rate_from_user("milri", ug_per_min)
|
|
199
|
+
|
|
200
|
+
def set_rocuronium_rate(self: "SimulationEngine", mg_per_hr: float):
|
|
201
|
+
self._set_rate_from_user("roc", mg_per_hr)
|
|
202
|
+
|
|
203
|
+
def enable_tci(self: "SimulationEngine", drug: str, target: float, mode: str = 'effect_site'):
|
|
204
|
+
"""
|
|
205
|
+
Enable TCI for a drug.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
drug: 'propofol', 'remi', 'nore', 'vaso', 'epi', 'dobu', 'milri', 'phenyl', 'roc'
|
|
209
|
+
target: Target concentration
|
|
210
|
+
mode: 'plasma' or 'effect_site' (ignored for nore/epi/phenyl which use plasma)
|
|
211
|
+
"""
|
|
212
|
+
from .tci import TCIController
|
|
213
|
+
|
|
214
|
+
sampling_time = 1.0
|
|
215
|
+
if hasattr(self, "config") and getattr(self.config, "dt", None):
|
|
216
|
+
# TCI control does not benefit from waveform-rate updates. A 100 ms
|
|
217
|
+
# floor avoids excessive controller iterations and coarse-step
|
|
218
|
+
# oscillation while remaining much faster than infusion-pump updates.
|
|
219
|
+
sampling_time = max(self.config.dt, 0.1)
|
|
220
|
+
control_time = max(10.0, sampling_time)
|
|
221
|
+
|
|
222
|
+
drug = drug.lower()
|
|
223
|
+
spec = self._DRUG_SPECS.get(drug)
|
|
224
|
+
if not spec:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
max_rate = self._tci_max_rate(spec)
|
|
228
|
+
tci_attr = spec["tci_attr"]
|
|
229
|
+
pk_attr = spec["pk_attr"]
|
|
230
|
+
controller = getattr(self, tci_attr, None)
|
|
231
|
+
tci_mode = spec["tci_mode"] if spec["tci_mode"] else mode
|
|
232
|
+
|
|
233
|
+
if not controller:
|
|
234
|
+
controller = TCIController(
|
|
235
|
+
getattr(self, pk_attr),
|
|
236
|
+
spec["tci_name"],
|
|
237
|
+
tci_mode,
|
|
238
|
+
max_rate=max_rate,
|
|
239
|
+
sampling_time=sampling_time,
|
|
240
|
+
control_time=control_time,
|
|
241
|
+
)
|
|
242
|
+
setattr(self, tci_attr, controller)
|
|
243
|
+
|
|
244
|
+
controller.max_rate = max_rate
|
|
245
|
+
self._seed_tci_state(controller, getattr(self, pk_attr))
|
|
246
|
+
controller.target = target
|
|
247
|
+
|
|
248
|
+
def _seed_tci_state(self: "SimulationEngine", controller, pk_model):
|
|
249
|
+
"""Seed TCI state from current PK concentrations to avoid bolus overshoot."""
|
|
250
|
+
if not controller or not pk_model or not hasattr(pk_model, "state"):
|
|
251
|
+
return
|
|
252
|
+
if hasattr(controller, "sync_state_estimate"):
|
|
253
|
+
controller.sync_state_estimate(pk_model)
|
|
254
|
+
return
|
|
255
|
+
state = pk_model.state
|
|
256
|
+
c1 = getattr(state, "c1", 0.0)
|
|
257
|
+
c2 = getattr(state, "c2", 0.0)
|
|
258
|
+
c3 = getattr(state, "c3", 0.0)
|
|
259
|
+
ce = getattr(state, "ce", 0.0)
|
|
260
|
+
if controller.n_state == 1:
|
|
261
|
+
controller.set_state(c1)
|
|
262
|
+
elif controller.n_state == 2:
|
|
263
|
+
controller.set_state(c1, c2)
|
|
264
|
+
elif controller.n_state == 3:
|
|
265
|
+
controller.set_state(c1, c2, ce)
|
|
266
|
+
else:
|
|
267
|
+
controller.set_state(c1, c2, c3, ce)
|
|
268
|
+
|
|
269
|
+
def sync_active_tci_from_pk(self: "SimulationEngine", *drug_keys: str):
|
|
270
|
+
"""Resynchronize active TCI controllers with the live PK model state."""
|
|
271
|
+
keys = drug_keys or tuple(self._DRUG_ORDER)
|
|
272
|
+
for key in keys:
|
|
273
|
+
spec = self._DRUG_SPECS.get(key)
|
|
274
|
+
if not spec:
|
|
275
|
+
continue
|
|
276
|
+
controller = getattr(self, spec["tci_attr"], None)
|
|
277
|
+
pk_model = getattr(self, spec["pk_attr"], None)
|
|
278
|
+
if not controller or not pk_model:
|
|
279
|
+
continue
|
|
280
|
+
if hasattr(controller, "sync_from_pk_model"):
|
|
281
|
+
controller.sync_from_pk_model(pk_model)
|
|
282
|
+
else:
|
|
283
|
+
self._seed_tci_state(controller, pk_model)
|
|
284
|
+
|
|
285
|
+
def _tci_max_rate(self: "SimulationEngine", spec: dict) -> float:
|
|
286
|
+
"""Return a clinically realistic max infusion rate for a drug."""
|
|
287
|
+
weight = max(0.0, getattr(self.patient, "weight", 0.0))
|
|
288
|
+
if weight <= 0.0:
|
|
289
|
+
return 0.0
|
|
290
|
+
unit, value = spec["max_rate"]
|
|
291
|
+
if unit == "mg_kg_min" or unit == "ug_kg_min":
|
|
292
|
+
return weight * value / 60.0
|
|
293
|
+
if unit == "mg_kg_hr":
|
|
294
|
+
return weight * value / 3600.0
|
|
295
|
+
if unit == "u_min":
|
|
296
|
+
# Vasopressin: return mU/sec (model units)
|
|
297
|
+
return (value * 1000.0) / 60.0
|
|
298
|
+
return 0.0
|
|
299
|
+
|
|
300
|
+
def disable_tci(self: "SimulationEngine", drug: str):
|
|
301
|
+
"""Disable TCI for a drug and reset infusion rate to zero."""
|
|
302
|
+
drug = drug.lower()
|
|
303
|
+
spec = self._DRUG_SPECS.get(drug)
|
|
304
|
+
if not spec:
|
|
305
|
+
return
|
|
306
|
+
setattr(self, spec["tci_attr"], None)
|
|
307
|
+
setattr(self, spec["rate_attr"], 0.0)
|
|
308
|
+
|
|
309
|
+
def get_controllable_drugs(self: "SimulationEngine"):
|
|
310
|
+
"""
|
|
311
|
+
Return metadata for all controllable drugs.
|
|
312
|
+
Used by UI to build controls dynamically.
|
|
313
|
+
"""
|
|
314
|
+
drugs = []
|
|
315
|
+
for key in self._DRUG_ORDER:
|
|
316
|
+
spec = self._DRUG_SPECS[key]
|
|
317
|
+
drugs.append({
|
|
318
|
+
"key": key,
|
|
319
|
+
"name": spec["name"],
|
|
320
|
+
"rate_unit": spec["rate_unit"],
|
|
321
|
+
"tci_unit": spec["tci_unit"],
|
|
322
|
+
"can_tci": True,
|
|
323
|
+
"default_bolus": spec["default_bolus"],
|
|
324
|
+
"bolus_unit": spec.get("bolus_unit"),
|
|
325
|
+
"tci_range": spec.get("tci_range"),
|
|
326
|
+
})
|
|
327
|
+
return drugs
|
|
328
|
+
|
|
329
|
+
def set_drug_rate(self: "SimulationEngine", key: str, rate_user_unit: float):
|
|
330
|
+
"""Generic setter for drug infusion rate."""
|
|
331
|
+
self._set_rate_from_user(key, rate_user_unit)
|
|
332
|
+
|
|
333
|
+
def set_drug_target(self: "SimulationEngine", key: str, target: float):
|
|
334
|
+
"""Generic setter for TCI target. None/Negative disables TCI."""
|
|
335
|
+
if target is None or target < 0:
|
|
336
|
+
self.disable_tci(key)
|
|
337
|
+
else:
|
|
338
|
+
self.enable_tci(key, target)
|
|
339
|
+
|
|
340
|
+
def get_drug_state(self: "SimulationEngine", key: str):
|
|
341
|
+
"""Return current state generic dict."""
|
|
342
|
+
state = {'rate': 0.0, 'target': 0.0, 'is_tci': False}
|
|
343
|
+
|
|
344
|
+
spec = self._DRUG_SPECS.get(key)
|
|
345
|
+
if not spec:
|
|
346
|
+
return state
|
|
347
|
+
|
|
348
|
+
state["rate"] = self._get_rate_to_user(key)
|
|
349
|
+
controller = getattr(self, spec["tci_attr"], None)
|
|
350
|
+
if controller:
|
|
351
|
+
state["is_tci"] = True
|
|
352
|
+
state["target"] = controller.target
|
|
353
|
+
|
|
354
|
+
return state
|
|
355
|
+
|
|
356
|
+
def _set_rate_from_user(self: "SimulationEngine", key: str, rate_user_unit: float):
|
|
357
|
+
spec = self._DRUG_SPECS.get(key)
|
|
358
|
+
if not spec:
|
|
359
|
+
return
|
|
360
|
+
if rate_user_unit is None:
|
|
361
|
+
return
|
|
362
|
+
rate_internal = convert_rate(rate_user_unit, spec["rate_unit"], spec["rate_unit_internal"])
|
|
363
|
+
rate_internal = max(0.0, rate_internal)
|
|
364
|
+
setattr(self, spec["rate_attr"], rate_internal)
|
|
365
|
+
|
|
366
|
+
def _get_rate_to_user(self: "SimulationEngine", key: str) -> float:
|
|
367
|
+
spec = self._DRUG_SPECS.get(key)
|
|
368
|
+
if not spec:
|
|
369
|
+
return 0.0
|
|
370
|
+
rate_internal = getattr(self, spec["rate_attr"], 0.0)
|
|
371
|
+
return convert_rate(rate_internal, spec["rate_unit_internal"], spec["rate_unit"])
|