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.
Files changed (60) hide show
  1. anasim/__init__.py +3 -0
  2. anasim/__main__.py +4 -0
  3. anasim/cli.py +107 -0
  4. anasim/core/__init__.py +0 -0
  5. anasim/core/constants.py +151 -0
  6. anasim/core/drug_api.py +371 -0
  7. anasim/core/engine.py +924 -0
  8. anasim/core/enums.py +11 -0
  9. anasim/core/initialization.py +396 -0
  10. anasim/core/metrics.py +66 -0
  11. anasim/core/monitors.py +311 -0
  12. anasim/core/projection.py +355 -0
  13. anasim/core/recorder.py +67 -0
  14. anasim/core/runtime.py +737 -0
  15. anasim/core/state.py +208 -0
  16. anasim/core/tci.py +303 -0
  17. anasim/core/units.py +75 -0
  18. anasim/core/utils.py +67 -0
  19. anasim/machine/circuit.py +90 -0
  20. anasim/machine/ventilator.py +146 -0
  21. anasim/machine/volatile.py +40 -0
  22. anasim/monitors/__init__.py +0 -0
  23. anasim/monitors/alarms.py +88 -0
  24. anasim/monitors/capno.py +200 -0
  25. anasim/monitors/ecg.py +126 -0
  26. anasim/monitors/nibp.py +99 -0
  27. anasim/monitors/spo2.py +75 -0
  28. anasim/patient/__init__.py +0 -0
  29. anasim/patient/patient.py +118 -0
  30. anasim/patient/pd/__init__.py +10 -0
  31. anasim/patient/pd/anesthesia.py +268 -0
  32. anasim/patient/pd/nmba.py +142 -0
  33. anasim/patient/pk_models.py +1343 -0
  34. anasim/patient/volatile_pk.py +150 -0
  35. anasim/physiology/disturbances.py +91 -0
  36. anasim/physiology/hemo_config.py +185 -0
  37. anasim/physiology/hemo_types.py +37 -0
  38. anasim/physiology/hemodynamics.py +1313 -0
  39. anasim/physiology/resp_mech.py +383 -0
  40. anasim/physiology/respiration.py +557 -0
  41. anasim/ui/__init__.py +0 -0
  42. anasim/ui/config_dialog.py +341 -0
  43. anasim/ui/controls_widget.py +1118 -0
  44. anasim/ui/main_window.py +375 -0
  45. anasim/ui/monitor_widget.py +537 -0
  46. anasim/ui/scenarios/__init__.py +25 -0
  47. anasim/ui/scenarios/anaphylaxis.py +88 -0
  48. anasim/ui/scenarios/base.py +313 -0
  49. anasim/ui/scenarios/emergence.py +184 -0
  50. anasim/ui/scenarios/hemorrhage.py +128 -0
  51. anasim/ui/scenarios/induction.py +292 -0
  52. anasim/ui/scenarios/sepsis.py +121 -0
  53. anasim/ui/styles.py +552 -0
  54. anasim/ui/tutorial_overlay.py +179 -0
  55. anasim_simulator-1.0.dist-info/METADATA +135 -0
  56. anasim_simulator-1.0.dist-info/RECORD +60 -0
  57. anasim_simulator-1.0.dist-info/WHEEL +5 -0
  58. anasim_simulator-1.0.dist-info/entry_points.txt +2 -0
  59. anasim_simulator-1.0.dist-info/licenses/LICENSE +21 -0
  60. anasim_simulator-1.0.dist-info/top_level.txt +1 -0
anasim/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AnaSim anesthesia simulator."""
2
+
3
+ __version__ = "1.0"
anasim/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()
File without changes
@@ -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
+ )
@@ -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"])