iints-sdk-python35 1.5.3__py3-none-any.whl → 1.5.4__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 (35) hide show
  1. iints/__init__.py +1 -1
  2. iints/ai/cli.py +8 -8
  3. iints/analysis/__init__.py +10 -0
  4. iints/analysis/baseline.py +5 -1
  5. iints/analysis/carelink_workbench.py +1 -1
  6. iints/analysis/eucys_results.py +2 -2
  7. iints/analysis/hardware_benchmark.py +17 -7
  8. iints/analysis/reporting.py +5 -2
  9. iints/analysis/study_engine.py +1 -0
  10. iints/analysis/study_experiment.py +168 -0
  11. iints/analysis/study_protocol.py +33 -0
  12. iints/cli/cli.py +1460 -388
  13. iints/cli/patient_cli.py +14 -14
  14. iints/core/algorithms/clinical_baseline.py +151 -0
  15. iints/core/algorithms/pid_controller.py +22 -6
  16. iints/core/patient/models.py +27 -8
  17. iints/core/simulator.py +2 -1
  18. iints/live_patient/__init__.py +12 -0
  19. iints/live_patient/api.py +91 -36
  20. iints/live_patient/daemon.py +3 -2
  21. iints/live_patient/edge_benchmark.py +1 -1
  22. iints/live_patient/edge_ops.py +561 -6
  23. iints/live_patient/long_study.py +885 -0
  24. iints/live_patient/runtime.py +98 -20
  25. iints/live_patient/service_export.py +107 -0
  26. iints/live_patient/uno_q.py +3 -3
  27. iints_sdk_python35-1.5.4.dist-info/METADATA +200 -0
  28. {iints_sdk_python35-1.5.3.dist-info → iints_sdk_python35-1.5.4.dist-info}/RECORD +34 -31
  29. iints_sdk_python35-1.5.3.dist-info/METADATA +0 -123
  30. {iints_sdk_python35-1.5.3.dist-info → iints_sdk_python35-1.5.4.dist-info}/WHEEL +0 -0
  31. {iints_sdk_python35-1.5.3.dist-info → iints_sdk_python35-1.5.4.dist-info}/entry_points.txt +0 -0
  32. {iints_sdk_python35-1.5.3.dist-info → iints_sdk_python35-1.5.4.dist-info}/licenses/LICENSE +0 -0
  33. {iints_sdk_python35-1.5.3.dist-info → iints_sdk_python35-1.5.4.dist-info}/licenses/LICENSE-MIT-IINTS-LEGACY +0 -0
  34. {iints_sdk_python35-1.5.3.dist-info → iints_sdk_python35-1.5.4.dist-info}/licenses/NOTICE +0 -0
  35. {iints_sdk_python35-1.5.3.dist-info → iints_sdk_python35-1.5.4.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.3"
14
+ __version__ = "1.5.4"
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/ai/cli.py CHANGED
@@ -160,7 +160,7 @@ def _render_local_check(console: Console, status: dict[str, object]) -> None:
160
160
  console.print("[bold red]Ollama is too old for the open Ministral 3 runtime.[/bold red]")
161
161
 
162
162
 
163
- @app.command("models")
163
+ @app.command(name="models")
164
164
  def models() -> None:
165
165
  console = Console()
166
166
  table = Table(title="IINTS AI Local Mistral Model Guide")
@@ -189,7 +189,7 @@ def models() -> None:
189
189
  )
190
190
 
191
191
 
192
- @app.command("prepare")
192
+ @app.command(name="prepare")
193
193
  def prepare(
194
194
  run_dir: Annotated[Path, typer.Argument(help="Run output directory containing results.csv and run_metadata.json.")],
195
195
  create_dev_mdmp_cert: Annotated[
@@ -254,7 +254,7 @@ def _build_assistant(
254
254
  )
255
255
 
256
256
 
257
- @app.command("local-check")
257
+ @app.command(name="local-check")
258
258
  def local_check(
259
259
  model: Annotated[str, typer.Option(help="Ollama model name to validate locally.")] = DEFAULT_MINISTRAL_MODEL,
260
260
  ollama_host: Annotated[Optional[str], typer.Option(help="Override the Ollama base URL.")] = None,
@@ -294,7 +294,7 @@ def local_check(
294
294
  raise typer.Exit(code=1)
295
295
 
296
296
 
297
- @app.command("explain")
297
+ @app.command(name="explain")
298
298
  def explain(
299
299
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with a single simulation step or decision context.")],
300
300
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -335,7 +335,7 @@ def explain(
335
335
  raise typer.Exit(code=1)
336
336
 
337
337
 
338
- @app.command("trends")
338
+ @app.command(name="trends")
339
339
  def trends(
340
340
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with glucose trace data or a run payload.")],
341
341
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -376,7 +376,7 @@ def trends(
376
376
  raise typer.Exit(code=1)
377
377
 
378
378
 
379
- @app.command("anomalies")
379
+ @app.command(name="anomalies")
380
380
  def anomalies(
381
381
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with simulation results or run summary.")],
382
382
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -417,7 +417,7 @@ def anomalies(
417
417
  raise typer.Exit(code=1)
418
418
 
419
419
 
420
- @app.command("report")
420
+ @app.command(name="report")
421
421
  def report(
422
422
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with run-level simulation outputs.")],
423
423
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -458,7 +458,7 @@ def report(
458
458
  raise typer.Exit(code=1)
459
459
 
460
460
 
461
- @app.command("review")
461
+ @app.command(name="review")
462
462
  def review(
463
463
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with run-level simulation outputs.")],
464
464
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -15,6 +15,12 @@ from .study_protocol import (
15
15
  render_study_protocol_markdown,
16
16
  write_study_protocol_bundle,
17
17
  )
18
+ from .study_experiment import (
19
+ StudyExperimentConfig,
20
+ build_study_experiment_template,
21
+ load_study_experiment_config,
22
+ render_study_experiment_yaml,
23
+ )
18
24
  from .study_analysis import (
19
25
  analyze_run_directory,
20
26
  analyze_study_directory,
@@ -112,8 +118,11 @@ __all__ = [
112
118
  "build_algorithm_registry",
113
119
  "build_study_design_payload",
114
120
  "build_study_protocol_payload",
121
+ "build_study_experiment_template",
115
122
  "render_study_protocol_markdown",
123
+ "render_study_experiment_yaml",
116
124
  "write_study_protocol_bundle",
125
+ "load_study_experiment_config",
117
126
  "load_study_summary",
118
127
  "quality_badges_for_metrics",
119
128
  "run_baseline_comparison",
@@ -121,6 +130,7 @@ __all__ = [
121
130
  "StudyArmSpec",
122
131
  "StudyComparison",
123
132
  "StudyDesignPayload",
133
+ "StudyExperimentConfig",
124
134
  "StudyMatrixRow",
125
135
  "StudyProfileSpec",
126
136
  "StudyRunSummary",
@@ -8,6 +8,7 @@ import pandas as pd
8
8
 
9
9
  from iints.analysis.clinical_metrics import ClinicalMetricsCalculator
10
10
  from iints.api.base_algorithm import InsulinAlgorithm
11
+ from iints.core.algorithms.clinical_baseline import ClinicalBaselineAlgorithm
11
12
  from iints.core.algorithms.pid_controller import PIDController
12
13
  from iints.core.algorithms.standard_pump_algo import StandardPumpAlgorithm
13
14
  from iints.core.patient.models import PatientModel
@@ -51,7 +52,10 @@ def run_baseline_comparison(
51
52
  }
52
53
  )
53
54
 
54
- baselines: List[Tuple[str, InsulinAlgorithm]] = [("Standard PID", PIDController())]
55
+ baselines: List[Tuple[str, InsulinAlgorithm]] = [
56
+ ("Clinical Baseline", ClinicalBaselineAlgorithm()),
57
+ ("Standard PID", PIDController()),
58
+ ]
55
59
  if compare_standard_pump:
56
60
  baselines.append(("Standard Pump", StandardPumpAlgorithm()))
57
61
 
@@ -43,7 +43,7 @@ def _round(value: Any, digits: int = 2) -> float | int | None:
43
43
  return None
44
44
  try:
45
45
  number = float(value)
46
- except Exception:
46
+ except (TypeError, ValueError, OverflowError):
47
47
  return None
48
48
  if math.isnan(number) or math.isinf(number):
49
49
  return None
@@ -70,7 +70,7 @@ def build_eucys_abstract_draft_markdown() -> str:
70
70
  "",
71
71
  "The central research question is whether a transparent, safety-first benchmark can evaluate candidate insulin-decision algorithms more rigorously than isolated demo runs or single-metric comparisons. "
72
72
  "The platform combines virtual patient simulation, baseline comparison, safety-layer analysis, protocol bundles, subgroup summaries, and exportable study artifacts. "
73
- "Instead of showing one favorable run, IINTS-AF compares a candidate algorithm against classical baselines such as `PID Controller`, `Standard Pump`, and `Correction Bolus` across predefined patient profiles, scenario families, and random seeds.",
73
+ "Instead of showing one favorable run, IINTS-AF compares a candidate algorithm against classical baselines such as `Clinical Baseline`, `PID Controller`, `Standard Pump`, and `Correction Bolus` across predefined patient profiles, scenario families, and random seeds.",
74
74
  "",
75
75
  "In the final benchmark bundle, the platform evaluated **[RUN_COUNT]** runs across **[PROFILE_COUNT]** patient profiles, **[SCENARIO_COUNT]** scenario families, and **[ALGORITHM_COUNT]** algorithms. "
76
76
  "The candidate algorithm achieved **[CANDIDATE_TIR]%** time in range compared with **[BASELINE_TIR]%** for the strongest baseline, while the safety analysis showed **[SAFETY_RESULT]**. "
@@ -235,7 +235,7 @@ def build_eucys_filled_abstract_markdown(
235
235
  "In this project, I developed **IINTS-AF**, a simulation-first benchmark platform for testing insulin-decision algorithms under fixed study protocols.",
236
236
  "",
237
237
  "The platform combines virtual patient simulation, baseline comparison, safety-layer analysis, protocol bundles, subgroup summaries, and exportable study artifacts. "
238
- "Instead of showing a single favorable run, IINTS-AF compares a candidate algorithm against classical baselines such as `PID Controller`, `Standard Pump`, and `Correction Bolus` across predefined profiles, scenario families, and random seeds.",
238
+ "Instead of showing a single favorable run, IINTS-AF compares a candidate algorithm against classical baselines such as `Clinical Baseline`, `PID Controller`, `Standard Pump`, and `Correction Bolus` across predefined profiles, scenario families, and random seeds.",
239
239
  "",
240
240
  f"In the current benchmark bundle, the platform evaluated **{run_count}** runs across **{counts['profile_count']}** patient profiles, **{counts['scenario_count']}** scenario families, and **{counts['algorithm_count']}** algorithms. "
241
241
  f"In the clean certified arm, **{candidate_name}** achieved **{candidate_tir}** time in range compared with **{baseline_tir}** for **{strongest_baseline}**, while the safety layer recorded **{safety_result}**. "
@@ -1,11 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
1
5
  import time
2
6
  import subprocess
3
7
  import threading
4
8
  import psutil
5
- import json
6
9
  from typing import Dict, List, Optional
7
10
  from dataclasses import dataclass, asdict
8
11
 
12
+
13
+ logger = logging.getLogger(__name__)
14
+
9
15
  @dataclass
10
16
  class PerformanceMetrics:
11
17
  timestamp: float
@@ -32,7 +38,8 @@ class HardwareBenchmark:
32
38
  with open('/proc/device-tree/model', 'r') as f:
33
39
  model = f.read().strip()
34
40
  return 'jetson' in model.lower()
35
- except:
41
+ except (FileNotFoundError, OSError, ValueError) as exc:
42
+ logger.debug("Jetson detection unavailable: %s", exc)
36
43
  return False
37
44
 
38
45
  def _get_tegrastats_metrics(self) -> Optional[Dict]:
@@ -51,8 +58,11 @@ class HardwareBenchmark:
51
58
  line = lines[-1] # Get last line
52
59
  # This is a simplified parser - real implementation would be more robust
53
60
  return {"raw_tegrastats": line}
54
- except:
55
- pass
61
+ logger.debug("tegrastats exited with non-zero status: %s", result.returncode)
62
+ except subprocess.TimeoutExpired as exc:
63
+ logger.debug("tegrastats timed out: %s", exc)
64
+ except (FileNotFoundError, OSError, ValueError) as exc:
65
+ logger.debug("tegrastats unavailable: %s", exc)
56
66
  return None
57
67
 
58
68
  def _collect_metrics(self) -> PerformanceMetrics:
@@ -82,8 +92,8 @@ class HardwareBenchmark:
82
92
  with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
83
93
  temp_millicelsius = int(f.read().strip())
84
94
  temperature = temp_millicelsius / 1000.0
85
- except:
86
- pass
95
+ except (FileNotFoundError, OSError, ValueError) as exc:
96
+ logger.debug("temperature probe unavailable: %s", exc)
87
97
 
88
98
  return PerformanceMetrics(
89
99
  timestamp=timestamp,
@@ -218,4 +228,4 @@ class HardwareBenchmark:
218
228
 
219
229
  def clear_metrics(self):
220
230
  """Clear collected metrics."""
221
- self.metrics_history.clear()
231
+ self.metrics_history.clear()
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import tempfile
3
+ import logging
3
4
  from pathlib import Path
4
5
  from typing import Any, Dict, Optional
5
6
 
@@ -14,6 +15,8 @@ from fpdf.enums import XPos, YPos
14
15
  from iints.analysis.clinical_metrics import ClinicalMetricsCalculator
15
16
  from iints.utils.plotting import apply_plot_style
16
17
 
18
+ logger = logging.getLogger("iints")
19
+
17
20
 
18
21
  class ClinicalReportGenerator:
19
22
  """Generate a clean, publication-ready PDF report."""
@@ -41,8 +44,8 @@ class ClinicalReportGenerator:
41
44
  x_pos = pdf.w - pdf.r_margin - logo_width
42
45
  y_pos = 6
43
46
  pdf.image(str(logo_path), x=x_pos, y=y_pos, w=logo_width)
44
- except Exception:
45
- # Fallback silently if image fails to load
47
+ except (OSError, RuntimeError, ValueError) as exc:
48
+ logger.debug("Skipping report logo render: %s", exc)
46
49
  return
47
50
 
48
51
  def _plot_glucose(self, df: pd.DataFrame, output_path: Path) -> None:
@@ -9,6 +9,7 @@ from iints.scenarios.study_pack import build_eucys_study_pack
9
9
 
10
10
  DEFAULT_PROFILE_SET = "clinic_safe_core"
11
11
  DEFAULT_BASELINE_ALGORITHMS = [
12
+ "Clinical Baseline",
12
13
  "PID Controller",
13
14
  "Standard Pump",
14
15
  "Correction Bolus",
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class StudyExperimentConfig:
12
+ source_path: Path
13
+ name: str
14
+ preset: str
15
+ title: str
16
+ profile_set: str
17
+ scenarios: list[str]
18
+ seeds: list[int]
19
+ duration_minutes: int | None
20
+ time_step: int
21
+ include_default_baselines: bool
22
+ extra_algorithms: list[str]
23
+ prepare_ai: bool
24
+ gate_profile: str | None
25
+ gate_profiles_path: Path | None
26
+ fail_on_gate: bool
27
+ external_reference_label: str
28
+ candidate_algorithm: Path
29
+ output_dir: Path | None
30
+ carelink_metrics: Path | None
31
+ reference_csv: Path | None
32
+
33
+
34
+ def _resolve_optional_path(base_dir: Path, raw_value: Any) -> Path | None:
35
+ if raw_value in (None, "", "null"):
36
+ return None
37
+ path = Path(str(raw_value)).expanduser()
38
+ if not path.is_absolute():
39
+ path = (base_dir / path).resolve()
40
+ return path
41
+
42
+
43
+ def _require_mapping(payload: Any, label: str) -> dict[str, Any]:
44
+ if payload is None:
45
+ return {}
46
+ if not isinstance(payload, dict):
47
+ raise ValueError(f"{label} must be a mapping in the experiment file.")
48
+ return dict(payload)
49
+
50
+
51
+ def _normalize_str_list(raw_value: Any, label: str) -> list[str]:
52
+ if raw_value in (None, "", []):
53
+ return []
54
+ if isinstance(raw_value, str):
55
+ return [item.strip() for item in raw_value.split(",") if item.strip()]
56
+ if isinstance(raw_value, list):
57
+ values = [str(item).strip() for item in raw_value if str(item).strip()]
58
+ return values
59
+ raise ValueError(f"{label} must be a list or comma-separated string.")
60
+
61
+
62
+ def _normalize_seed_list(raw_value: Any) -> list[int]:
63
+ if raw_value in (None, "", []):
64
+ return [1, 2, 3, 4, 5]
65
+ if isinstance(raw_value, str):
66
+ return [int(item.strip()) for item in raw_value.split(",") if item.strip()]
67
+ if isinstance(raw_value, list):
68
+ return [int(item) for item in raw_value]
69
+ raise ValueError("experiment.seeds must be a list or comma-separated string.")
70
+
71
+
72
+ def load_study_experiment_config(path: str | Path) -> StudyExperimentConfig:
73
+ source_path = Path(path).expanduser().resolve()
74
+ if not source_path.is_file():
75
+ raise FileNotFoundError(f"Study experiment config not found: {source_path}")
76
+
77
+ raw_payload = yaml.safe_load(source_path.read_text(encoding="utf-8")) or {}
78
+ if not isinstance(raw_payload, dict):
79
+ raise ValueError("Study experiment config must be a YAML mapping.")
80
+
81
+ experiment = _require_mapping(raw_payload.get("experiment"), "experiment")
82
+ algorithm = _require_mapping(raw_payload.get("algorithm"), "algorithm")
83
+ paths = _require_mapping(raw_payload.get("paths"), "paths")
84
+ study = _require_mapping(raw_payload.get("study"), "study")
85
+ base_dir = source_path.parent
86
+
87
+ candidate_raw = algorithm.get("candidate") or paths.get("candidate_algorithm")
88
+ if not candidate_raw:
89
+ raise ValueError("Study experiment config must define algorithm.candidate or paths.candidate_algorithm.")
90
+
91
+ candidate_algorithm = _resolve_optional_path(base_dir, candidate_raw)
92
+ if candidate_algorithm is None:
93
+ raise ValueError("Study experiment config candidate path could not be resolved.")
94
+
95
+ return StudyExperimentConfig(
96
+ source_path=source_path,
97
+ name=str(experiment.get("name") or source_path.stem),
98
+ preset=str(experiment.get("preset") or "default"),
99
+ title=str(experiment.get("title") or "IINTS Scientific Validation Protocol"),
100
+ profile_set=str(experiment.get("profile_set") or "clinic_safe_core"),
101
+ scenarios=_normalize_str_list(study.get("scenarios", experiment.get("scenarios")), "study.scenarios"),
102
+ seeds=_normalize_seed_list(experiment.get("seeds")),
103
+ duration_minutes=int(experiment["duration_minutes"]) if experiment.get("duration_minutes") is not None else None,
104
+ time_step=int(experiment.get("time_step", 5)),
105
+ include_default_baselines=bool(experiment.get("include_default_baselines", True)),
106
+ extra_algorithms=_normalize_str_list(algorithm.get("extra_algorithms", experiment.get("extra_algorithms")), "algorithm.extra_algorithms"),
107
+ prepare_ai=bool(experiment.get("prepare_ai", True)),
108
+ gate_profile=str(experiment["gate_profile"]).strip() if experiment.get("gate_profile") not in (None, "") else None,
109
+ gate_profiles_path=_resolve_optional_path(base_dir, paths.get("gate_profiles_path") or experiment.get("gate_profiles_path")),
110
+ fail_on_gate=bool(experiment.get("fail_on_gate", False)),
111
+ external_reference_label=str(
112
+ experiment.get("external_reference_label") or "CareLink personal workbench metrics"
113
+ ),
114
+ candidate_algorithm=candidate_algorithm,
115
+ output_dir=_resolve_optional_path(base_dir, paths.get("output_dir")),
116
+ carelink_metrics=_resolve_optional_path(base_dir, paths.get("carelink_metrics")),
117
+ reference_csv=_resolve_optional_path(base_dir, paths.get("reference_csv")),
118
+ )
119
+
120
+
121
+ def build_study_experiment_template(
122
+ *,
123
+ preset: str,
124
+ title: str,
125
+ profile_set: str,
126
+ seeds: list[int],
127
+ candidate_algorithm: str,
128
+ scenarios: list[str],
129
+ include_default_baselines: bool,
130
+ extra_algorithms: list[str] | None = None,
131
+ external_reference_label: str = "CareLink personal workbench metrics",
132
+ default_output_dir: str | None = None,
133
+ ) -> dict[str, Any]:
134
+ if default_output_dir is None:
135
+ default_output_dir = "results/eucys_study" if preset == "eucys" else "results/study_bundle"
136
+ return {
137
+ "experiment": {
138
+ "name": "iints_scientific_validation",
139
+ "preset": preset,
140
+ "title": title,
141
+ "profile_set": profile_set,
142
+ "seeds": list(seeds),
143
+ "duration_minutes": None,
144
+ "time_step": 5,
145
+ "include_default_baselines": include_default_baselines,
146
+ "prepare_ai": True,
147
+ "gate_profile": None,
148
+ "fail_on_gate": False,
149
+ "external_reference_label": external_reference_label,
150
+ },
151
+ "study": {
152
+ "scenarios": list(scenarios),
153
+ },
154
+ "algorithm": {
155
+ "candidate": candidate_algorithm,
156
+ "extra_algorithms": list(extra_algorithms or []),
157
+ },
158
+ "paths": {
159
+ "output_dir": default_output_dir,
160
+ "carelink_metrics": None,
161
+ "reference_csv": None,
162
+ "gate_profiles_path": None,
163
+ },
164
+ }
165
+
166
+
167
+ def render_study_experiment_yaml(payload: dict[str, Any]) -> str:
168
+ return yaml.safe_dump(payload, sort_keys=False)
@@ -15,6 +15,10 @@ from iints.analysis.study_engine import (
15
15
  StudyDesignPayload,
16
16
  build_study_design_payload,
17
17
  )
18
+ from iints.analysis.study_experiment import (
19
+ build_study_experiment_template,
20
+ render_study_experiment_yaml,
21
+ )
18
22
 
19
23
  DEFAULT_SCENARIOS = [
20
24
  "baseline_day",
@@ -231,10 +235,38 @@ def write_study_protocol_bundle(
231
235
  design_json = target / "study_design.json"
232
236
  matrix_csv = target / "study_matrix.csv"
233
237
  algorithms_json = target / "algorithms.json"
238
+ experiment_yaml = target / "study_experiment.yaml"
234
239
 
235
240
  markdown_path.write_text(render_study_protocol_markdown(payload), encoding="utf-8")
236
241
  design_json.write_text(json.dumps(payload, indent=2), encoding="utf-8")
237
242
  algorithms_json.write_text(json.dumps(payload.get("algorithms", []), indent=2), encoding="utf-8")
243
+ candidate_entry = next(
244
+ (
245
+ entry
246
+ for entry in payload.get("algorithms", [])
247
+ if isinstance(entry, dict) and entry.get("role") == "candidate"
248
+ ),
249
+ {},
250
+ )
251
+ extra_algorithm_labels = [
252
+ str(entry.get("display_name"))
253
+ for entry in payload.get("algorithms", [])
254
+ if isinstance(entry, dict)
255
+ and entry.get("role") != "candidate"
256
+ and str(entry.get("display_name")) not in DEFAULT_BASELINE_ALGORITHMS
257
+ ]
258
+ experiment_payload = build_study_experiment_template(
259
+ preset=str(payload.get("preset", preset)),
260
+ title=str(payload.get("title", title)),
261
+ profile_set=str(payload.get("profile_set", profile_set)),
262
+ seeds=list(payload.get("seed_policy", {}).get("seeds", [])),
263
+ candidate_algorithm=str(candidate_entry.get("source_ref") or candidate_entry.get("display_name") or "algorithms/example_algorithm.py"),
264
+ scenarios=[str(item.get("slug")) for item in payload.get("scenarios", []) if isinstance(item, dict) and item.get("slug")],
265
+ include_default_baselines=include_default_baselines,
266
+ extra_algorithms=extra_algorithm_labels,
267
+ external_reference_label=str(payload.get("external_validation", {}).get("reference_label", external_reference_label)),
268
+ )
269
+ experiment_yaml.write_text(render_study_experiment_yaml(experiment_payload), encoding="utf-8")
238
270
 
239
271
  with matrix_csv.open("w", encoding="utf-8", newline="") as handle:
240
272
  fieldnames = [
@@ -271,4 +303,5 @@ def write_study_protocol_bundle(
271
303
  "study_design_json": str(design_json),
272
304
  "study_matrix_csv": str(matrix_csv),
273
305
  "algorithms_json": str(algorithms_json),
306
+ "study_experiment_yaml": str(experiment_yaml),
274
307
  }