Skip to content

Repository files navigation

loop-risk-simulator-gui

Streamlit MVP for running a Tidepool Loop risk assessment without a terminal. This is the view layer only — all simulator/validation logic lives in gui_runner.py, which stays in the data-science-simulator repo as the sanctioned in-process service layer.

Dependency model (Phase 4): pinned, not sibling

The simulator is consumed as a pinned git ref, not an editable sibling checkout — the two repos no longer need to be cloned side by side. conda-environment.yml installs git+https://github.com/tidepool-org/data-science-simulator@main and is the single source of truth for the pins. The simulator is tracked on main (not a fixed tag) because this GUI is an exploration wrapper only — updates flow in on the next env rebuild with no re-tag or re-pin.

Two simulator paths — post_processing/severity_model.py and scenario_configs/ — are not part of the installed package. gui_runner does import severity_model, and ScenarioParserV2 resolves reusable.* pointers from a path hardcoded relative to its own module (<package>/../../scenario_configs/…, not any env var), so both paths must sit beside the installed package. The packaged bundle vendors them from the same pinned tag and its launcher symlinks them into site-packages (scenario_configs/ and a top-level severity_model.py), making every resolution native. For a dev checkout, an editable install already puts them in the right place; LOOP_RISK_GUI_SCENARIO_CONFIGS_ROOT / LOOP_RISK_GUI_POST_PROCESSING_DIR can redirect browsing/tests if needed.

Setup (development)

Requires an arm64 conda (matches the committed arm64 .dylib in the simulator — never use ambient conda or uv run):

conda env create -f conda-environment.yml
conda activate loop-risk-simulator-gui

Running the app

streamlit run streamlit_app.py

Packaging a release bundle (maintainer)

Build a versioned, self-contained macOS bundle (pinned env spec + vendored LoopAlgorithmToPython + vendored simulator orphan paths + app code + launcher), then publish it as a GitHub Release asset:

python packaging/build_bundle.py build \
  --simulator-ref main \
  --simulator-repo ../data-science-simulator \
  --swift-repo ../LoopAlgorithmToPython \
  --output-dir dist/

The release number comes from version.py (see "Versioning" below), so --version is optional; passing one that disagrees with APP_VERSION fails the build rather than producing an archive numbered differently from the app inside it. The build also refuses to run if a local module streamlit_app.py imports is missing from APP_ARTIFACTS (see "Bundled app artifacts" below).

Before publishing, run the bundle-boundary test against the bundle you just built. It is the only check that a built bundle actually launches; the build-time guard proves the files are staged, not that the app starts:

tar -xzf dist/loop-risk-simulator-gui-<version>.tar.gz -C /tmp/bundle-check
export LOOP_RISK_GUI_BUNDLE_DIR=/tmp/bundle-check
cd /tmp && ~/miniconda3/envs/<bundle-env>/bin/python -m pytest \
  <gui-repo>/tests/test_phase4_bundle_integration.py

The builder prints the exact gh release create … command to publish the archive — publishing is a deliberate, separate step, never run automatically. The bundle's run_simulator_gui.command establishes the arm64 env from the pinned spec, builds the Swift .dylib on first run, symlinks the vendored paths beside the package, and launches the app. (The polished colleague-facing launcher UX is Phase 5.)

Versioning

version.py's APP_VERSION is the single source of the app's version number. The app renders it in the shared page header on every page, and packaging/build_bundle.py reads the same constant for the archive name, BUNDLE_VERSION.json's bundle_version, and the gui-bundle-v<version> release tag. There is deliberately no second source: there is no pyproject.toml, so importlib.metadata has no distribution to read; BUNDLE_VERSION.json exists only inside a built bundle; and git describe fails in the shipped tarball.

Semantic versioning per SOP-0005 §7.1, read for this GUI as:

Component Bump when SOP-0005
MAJOR Significant new capability, or a major change to what the user sees §7.1.1.1
MINOR An ordinary new feature; a cosmetic or content change §7.1.2.1–2
PATCH An anomaly (bug) fix — nothing else §7.1.3.1

1.0.0 is the MVP internal release.

The displayed number means the last released version, not the state of the working tree. It is bumped by hand as part of making a release, not on ticket merge, and nothing enforces that — no CI check, no pre-commit hook, no dirty-tree suffix. A checkout between releases therefore shows the number of the release it followed. Build metadata (SOP-0005 §7.1.4, optional) is deliberately not displayed; per-build provenance — the simulator ref and both resolved SHAs — stays in the bundle's BUNDLE_VERSION.json.

Running tests

python -m pytest tests/                 # unit + Phase-3 integration (arm64 env)

The Phase-4 bundle-boundary test (tests/test_phase4_bundle_integration.py) is opt-in — it needs a built, extracted bundle. Run it with the bundle env's interpreter and LOOP_RISK_GUI_BUNDLE_DIR set to the extracted bundle (see the module docstring). Run everything with the conda env's own interpreter, never uv run — the simulator and its deps are only available there.

Phase 4 change summary

What changed (≤100 words): Replaced the Phase-3 -e ../data-science-simulator editable-sibling install with a pinned git tag, removing the "clone as siblings" constraint. Added packaging/build_bundle.py, which produces a versioned macOS bundle: it renders the pinned env spec from this repo's conda-environment.yml, extracts the two non-packaged simulator paths (severity_model.py, scenario_configs/ incl. reusable/) from the same tag, vendors LoopAlgorithmToPython source, stamps provenance, and emits the publish command. The launcher symlinks the vendored paths beside the package so resolution is native. Phase-3 integration fixtures moved to a self-contained temp library.

Validation (≤100 words): Packaging logic covered by unit tests (pin rendering, version stamp, staging assembly — git boundary mocked). All six Phase-3 integration cases pass from the relocated temp-library fixtures, writing nothing into the installed library. A bundle-boundary integration test builds a bundle, installs it into a fresh arm64 env with no sibling checkout, and asserts the pinned (non-editable) simulator imports, severity_model resolves, scenario_configs/reusable resolve beside the package, the version stamp matches the built tag, and a real TLR-QAE-482-test run completes with a populated assessment and non-blank PNGs.

Cautions / limitations: arm64 only (matches the committed .dylib); never uv run. First run builds the Swift .dylib and a full conda env (minutes) and needs GitHub connectivity to resolve the pinned deps. The bundle vendors the whole reusable/ subtree (~18 MB). The launcher writes two symlinks into the env's site-packages. .app freeze/sign/notarization and the colleague-facing launcher UX are out of scope (later phases).

Breaking change + migration: The install/dev-setup contract changed — siblings are no longer required and the env-spec format moved from -e ../… to git+…@tag. To migrate: recreate the conda env from the updated conda-environment.yml. For a live-checkout dev loop, use an editable install or set LOOP_RISK_GUI_SCENARIO_CONFIGS_ROOT / LOOP_RISK_GUI_POST_PROCESSING_DIR.

Rollback (High regression risk): Revert the pinned data-science-simulator line in conda-environment.yml back to - -e ../data-science-simulator, re-clone the two repos as siblings, and recreate the env. That restores the exact Phase-3 editable-sibling behavior.

Accessibility tests (TRSET-4)

tests/test_accessibility.py adds a regression-guarding layer for three basic WCAG requirements against the app's own tokens and emitted markup — no browser, Playwright, Selenium, or axe-core:

  • 1.4.3 Contrast (unit): a small pure-Python contrast_ratio() helper computes WCAG ratios for the text pairs the app renders. Tokens are read from their real sources (.streamlit/config.toml and streamlit_app._BRAND_CSS), so the ratios re-derive if a token changes. Gated pairs — textColor on backgroundColor (~15.9:1) and the CSS-override text on secondaryBackgroundColor (~14.7:1) — must clear 4.5:1 (normal text).
  • 2.1 Keyboard & 1.3 Adaptable (rendered smoke): via the existing AppTest.from_file harness — interactive widgets carry accessible labels, emitted <img>s keep alt text (guards the TRSET-3 logo), the app's unsafe_allow_html blocks add no positive tabindex, and severity info is conveyed as text, not color alone. A full-run integration test asserts all three markers at the rendered-tree boundary against the real config library.

Example:

python -m pytest tests/test_accessibility.py     # arm64 conda env, never `uv run`

Known finding (not a gate): brand primaryColor #627CFF is ~3.6:1 on white — below the 4.5:1 normal-text minimum. It is excluded from the pass/fail gate per adjudication (brand color; remediation is a separate ticket); one test documents the known value and flags loudly only if it shifts out of band. Regression risk for this change is Low (additive, GUI-repo test layer; no simulator or gui_runner change), so no rollback note applies.

Disclaimer banner (TRSET-5)

What changed (≤100 words): Added a persistent regulatory disclaimer at the top of the main page — a custom-styled role="alert" caution box (rendered via st.markdown(..., unsafe_allow_html=True), above the logo/header on every load) stating the tool is not medical software and must not drive insulin dosing decisions. The wording lives in a single DISCLAIMER_TEXT constant. Colors reuse the existing Tidepool palette — no new brand colors: background is the theme's secondaryBackgroundColor (#281946), text is the _BRAND_CSS override color (#F5F5FA). Styling is inline on the element, leaving _BRAND_CSS untouched. It is a distinct box, not st.warning.

Example:

streamlit run streamlit_app.py     # banner shows at the top of the page

Validation (≤100 words): Extends the existing AppTest.from_file harness — no browser/Playwright/Selenium. test_streamlit_app.py adds a full-run integration test asserting the banner renders once with the exact verbatim text, carries the ⚠ icon and role="alert", and precedes the logo <img>; plus a guard that it does not collide with at.warning. test_accessibility.py adds the banner's #F5F5FA-on-#281946 pair to the contrast gate (~14.7:1 ≥ 4.5:1), asserts it reuses existing palette tokens (never #627CFF), and checks the alert is conveyed by text + semantics, not color alone. Full suite green (22 tests).

Cautions / limitations: Presentation layer only — no change to gui_runner, the Loop algorithm interface, the scenario-config schema, or RTF output. Banner is on the main page only, not exported artifacts. Regression risk Low–Medium (shares the render path and interacts with the TRSET-4 contrast gate, but reuses already-gated tokens); not a breaking change, so no rollback note applies.

Loop home-screen charts in the results pane (TRSET-23)

What changed (≤100 words): The results pane no longer renders the generic three-panel simulator PNGs. Each TLR-* expander now shows one row per VP profile, each row three co-equal columns — Pre-mitigation | No Loop | Post-mitigation — of TRSET-22 Loop-home-screen charts, read from that sim's <sim_id>.tsv via TRSET-21's read_trace. gui_runner.RiskDirRunResult gains an additive trace_paths field (scenario_config_filename -> {sim_id: tsv}) that supplies them. Stage identity comes from severity_model.classify_sim_id and the order/labels from STAGE_ORDER/STAGE_DISPLAY, re-exported through gui_runner so the view layer no longer redeclares them.

Example:

streamlit run streamlit_app.py     # run a TLR directory; charts appear under its metrics table

Validation (≤100 words): An integration test runs the real run_risk_assessment on the real test/TLR-QAE-482-test config directory (copied into a temp library), then drives the returned trace paths through the real read_tracerender_loop_home_screen, asserting non-blank 900×1100 PNGs, and re-renders the resulting RunResult through AppTest to assert one chart per present stage. That directory genuinely defines no no_loop stage, so the "No data" placeholder is exercised against real data. Unit tests cover the profile label parser, the (profile → stage) grouping, and the placeholder/error branches. Full GUI suite: 108 passed, 7 skipped.

Cautions / limitations: The three stages are always drawn side by side as equals with no selector and no auto-collapsing (TWI-0006 §2.g.ii). A missing stage renders an explicit "No data"; an unreadable trace renders its own message so a failed read stays distinguishable from an absent stage. PNG bytes are st.cache_data-cached by trace path, since Streamlit re-executes the script on every interaction and one directory is three charts per profile. png_paths and plot_sim_results plumbing remains in gui_runner but is no longer rendered — removing it is a follow-up. Known limitation: classify_sim_id does not match three post-stage prefix spellings present in the library (post-Loop_withMitigations_, post-Loop-withMitigations_, post_Loop_WithMitigations_), so those directories show "No data" in the Post-mitigation column — the same pre-existing gap their metrics-table row already has; filed separately, not fixed here. Regression risk Medium (shared runner entry point plus the app's main render path), contained by the gui_runner change being purely additive: no existing field, signature, or schema changed, so this is not a breaking change and no rollback note applies.

Exportable results (TRSET-7)

What changed (≤100 words): A completed run now offers an Export results control that produces one zip and hands it over as a browser download. The zip nests everything under a single risk_run_<timestamp>/ folder: every raw output the run wrote (summary CSVs, <sim_id>.tsv traces, the simulator figures, loop_algo_io/, metadata.json), the risk_summary_<sim_id>.rtf severity summaries — generated at export time by the unmodified create_severity_summary.process_results_directory — and a charts/ folder of the Loop home-screen PNGs, one per VP profile × stage. Assembly lives in the new streamlit-free export_bundle.py; gui_runner gains a metadata.json write and re-exports the RTF renderer.

Example:

streamlit run streamlit_app.py     # run a directory, then: Export results -> Download export (.zip)

Chart files are named <TLR dir>_<profile>_<stage>.png (e.g. TLR-909_Adolescent_profile_Pre-mitigation.png), sanitized to [A-Za-z0-9._-]; a stage with no trace, or an unreadable one, is skipped and listed in an on-screen warning rather than exported as a blank chart.

Validation (≤100 words): An integration test runs the real run_risk_assessment on the real test/TLR-QAE-482-test config (copied into a temp library), exports it for real, and asserts at the zip boundary: metadata.json dated like the displayed assessment, an RTF whose bytes are identical to the one on disk, charts/ holding exactly the two PNGs (900×1100) for the stages that directory genuinely defines and none for the no_loop stage it lacks, the raw outputs present, and nothing from outside save_dir. It then drives the whole thing through AppTest. Unit tests cover naming/sanitization, archive layout, the guard rails, and the control's states. GUI suite: 138 passed, 7 skipped. Simulator test_gui_runner 16/16, RTF renderer suite 32/32 unchanged.

Cautions / limitations: Export is two clicks by necessity — st.download_button materializes its payload at render time, so a one-click version would rebuild the zip on every rerun. The zip is built to a session-scoped tempfile directory and written file-by-file (never assembled in memory); its bytes are read once for the download and cached by path. A cancelled run is not exportable, and a run directory missing metadata.json or any TLR-* dir raises rather than shipping a summary-free zip — the two cases process_results_directory otherwise only prints and returns on. Charts are loose files: the filename carries their identity, not the app's profile × stage grid. The classify_sim_id prefix gap noted under TRSET-23 applies here too — a stage it cannot classify has no chart in the export, listed as skipped. Regression risk Medium (GUI run path plus run-directory contents, which now also carry metadata.json and the RTFs); additive only — no existing field, signature, schema, or RTF byte changed — so this is not a breaking change and no rollback note applies.

Configurable meal entries (TRSET-9)

What changed (≤100 words): The app gained a second config source. Alongside Choose from the config library, Configure meals & boluses lets a user define meal and bolus entries directly and generate four runnable scenario configs — one per T1 profile (Median, Resistant, Adolescent, Sensitive) — with TLR-<YYYYMMDD-HHMMSS> as the risk id. Meal values come from one of three modes: the per-profile standard, a 0.25-step multiplier of it, or one custom grams value. Generation lives in the new streamlit-free meal_config.py; export_bundle gained an optional generated_configs parameter. gui_runner is unchanged.

Example:

streamlit run streamlit_app.py     # Config source -> Configure meals & boluses -> Generate configs -> Run Tool

The generated configs are written to a session temp directory laid out like the library (with reusable/ symlinked so pointer resolution works as it does for a real collection) and handed to run_risk_assessment as its config_dir — the "configure parameters directly" mode streamlit_app.py's docstring reserves. Nothing is ever written into scenario_configs/. They are downloadable on their own (<risk_id>_configs.zip, nested under one <risk_id>/ folder) and ride along in the run export under generated_configs/.

Generated configs use the library's own baseline template — the 2_0/swift base configs (reusable.simulations.base_<profile>_2_0_v1), flat_110_12hr glucose, and the post stage's target_range_<profile>_v1 + controller_settings_<profile>_swift guardrails. Only carb_entries / bolus_entries come from the user. The three sim_ids (pre-Loop_NoMitigations_, pre-noLoop_, post-Loop_WithMitigations_) all classify through severity_model.classify_sim_id, so every stage reaches the results grid and the export.

Validation (≤100 words): A single integration test drives the whole feature through the real app with no mocks: configure → generate → validate_config_dir (zero errors) → a real four-profile Swift run → export. It asserts at the boundaries — the JSON on disk, the <sim_id>_override_config.json files the run itself wrote, and the zip. Multiplier 2.0 really produced 62/66/120/50 g in the simulations; the duration-less meal really resolved to 180 minutes in the real parser; the No Loop stage really ran on its numeric bolus. 20 integration + 45 unit tests, ~53 s. Full GUI suite: 213 passed, 7 skipped.

Cautions / limitations: Entries must fall inside the base configs' simulation window (8/15/2019 12:00 + 8 h) — that window and the standard baselines (31/33/60/25 g) are read from the library at generation time, never hardcoded, so a library change flows straight through. accept_recommendation is written to patient.patient_model only: ConfigValidator rejects it under patient.pump.bolus_entries, where it would reach the Loop input JSON verbatim and crash the Swift bridge's Double decode. Because the No Loop stage has no controller to make a recommendation, any sentinel bolus must carry a numeric units value for that stage; a blank dose is an error, never a silent 0 U. The mode is per configuration but the value input is per meal row. Only settings/targets/schedules and the t2 profiles remain out of scope (later stages). The scenario-config schema, scenario_json_parser_v2.py and the Loop algorithm interface are untouched. Regression risk Medium (shares validate_config_dir / run_risk_assessment / the export control, and adds a second way to reach them); additive only — gui_runner is unchanged and build_export_zip's new parameter is keyword-defaulted — so this is not a breaking change and no rollback note applies.

One TRSET-4 follow-through: the editor introduced st.time_input, whose value box sits on secondaryBackgroundColor but was not in _BRAND_CSS's text-color override — the entered time rendered #281946 on #281946, an invisible field (found in a running app, not by inspection). It needed its own selector rather than the existing role="group"/input ones, because its value is a div inside a baseweb select. test_accessibility.py gained a guard that derives the widget types the app actually renders and fails if one is absent from the override; the guard was confirmed to fail without the fix. It checks presence, not cascade — resolving the cascade needs a browser, which that suite deliberately avoids.

Post-release fix — results never outlive their selection: as first shipped, the results pane rendered unconditionally at the bottom of the page, so a previous run's expanders and charts stayed on screen after switching config source or generating a new config set. That misled in practice: a library physical-activity directory (TLR-1117_bike — no meals, so an empty Active Carbohydrates panel by construction) was still displayed under a freshly generated meal configuration and read as that configuration's output. The displayed run is now cleared whenever the selection behind it changes — switching config source, generating a new config set, or picking another collection or TLR-* directory. One check (_sync_selection) covers every case, keyed on (config source, config dir, target TLR dir), so the results pane always matches the current selection or is empty. It is deliberately keyed on the selection changing rather than on any rerun happening: the rerun that follows a run completing must not wipe the run that just finished. Skipped while a run is in flight, since that run owns the state and has nothing displayed yet.

Selectable simulation duration (TRSET-13)

What changed (≤100 words): The meal/bolus editor gained a duration picker, framed by the investigative question rather than an hour count: overdelivery (8 h), underdelivery (23 h), a full day (24 h), or a free 2.0–7.5 h short-term value in 0.5-h steps. One duration applies to the whole configuration and is written into every override_config entry, where it replaces the base config's own. Window arithmetic in meal_config.py is now datetime-aware, fixing a latent wrap that made every entry check false past midnight. OverrideItem already declares duration_hours; no parser, schema, gui_runner or severity_model change.

Example:

streamlit run streamlit_app.py     # Configure meals & boluses -> Simulation duration -> Generate configs -> Run Tool
import meal_config

start, end, hours = meal_config.simulation_window(24.0)     # datetimes; never wraps
earliest, latest = meal_config.authoring_window(24.0)       # view bound: ends 23:59:59 on the start day
spec = meal_config.MealConfigSpec.aligned(mode, entries, duration_hours=24.0)

Validation (≤100 words): Two real runs, no mocks (tests/test_trset13_integration.py). A 2-hour run proves picker → duration_hours in all twelve overrides → ConfigValidator → the merged config the run resolved → a trace that stops at 14:00 rather than 20:00, and the results pane marking LBGI, DKAI and Severity as not valid. A -m slow 24-hour run proves the midnight-crossing path: a 23:30 entry kept where it was authored, and a trace reaching 8/16. The 8- and 23-hour presets are asserted at the config boundary. The slow run takes 4m25s against roughly a minute for the 8-hour default. Full GUI suite: 292 passed, 7 skipped, 1 deselected (-m slow: 1 passed).

Cautions / limitations: Under 8 hours, LBGI and DKAI are not valid — the app says so in the editor and marks the cells in the results, but the RTF summaries and the export zip carry the raw values unmarked (deliberately out of scope; raise separately before treating an exported short run as a record). Entries can only be authored on the start calendar day, so a 23/24-hour run has no entries after midnight; the run itself continues past it. Carb absorption still defaults to 180 minutes, so a sub-3-hour run truncates it. The duration applies to the Configure meals & boluses path only — library configs keep their own duration_hours, and gui_runner.run_risk_assessment's signature is unchanged. A 23/24-hour run takes roughly three times as long as the 8-hour default.

Internal shape changes (this repo only, no external consumers): MealEntry.start_time and BolusEntry.time are datetime.datetime rather than datetime.time; simulation_window() returns (start, end, duration_hours) as datetimes and takes an optional duration; _window_bounds() is replaced by authoring_window(). The start-day clamp lives in the view (streamlit_app._on_start_day), never inside meal_config — adding multi-day authoring is a day control plus a widened bound, not a rewrite.

Empty carb panel on a No Loop stage (TRSET-9 follow-up, renderer)

What changed (≤100 words): Two fixes in loop_home_renderer.py. (1) loop_cob is the Loop algorithm's carbs-on-board estimate, so it is absent on a stage that runs no controller (pre-noLoop_*, controller: null); the renderer drew an invisible all-NaN line and still legended it "Carbs on board", which reads as this scenario had no carbs. The line and its legend entry are now omitted there and an in-panel note says why. (2) x-limits are pinned to the data span, so an event at simulation t=0 sat on the left spine with half its glyph outside the axes; carb and dose markers now draw unclipped.

Example:

python -m pytest tests/test_loop_home_renderer.py     # arm64 conda env, never `uv run`

Validation (≤100 words): Diagnosed against a real run: the No Loop trace carries the same carb events as the Loop stages (true_carb_value populated on all three; loop_cob 97 points on the Loop stages, 0 on No Loop), and its glucose runs 110 → ~475 mg/dL — the meal absorbing unopposed. Marker position measured at fraction 0.0000 across the axis. Seven unit tests added, each mutation-checked: reverting either fix fails exactly the tests that assert it. A populated-but-all-zero loop_cob is deliberately still treated as computed. Full GUI suite: 228 passed, 7 skipped.

Cautions / limitations: There is no non-Loop COB series to substitute — SimulationTrace exposes only loop_cob, and the TSV's other carb columns are the discrete entry values/durations. So the No Loop panel shows carb-entry markers and the note, never a curve. Deriving a patient-side COB would mean modelling absorption in the view layer, which this deliberately does not do. clip_on=False lets a boundary marker overhang the axes by half a glyph; that is the intent (the alternative, padding the x-limits, would reintroduce the empty forward gutter TRSET-22 removed). This touches the TRSET-22/23 renderer, not TRSET-9 code — the clipping predates this branch; TRSET-9 only made it prominent by defaulting meals to simulation start.

Catastrophic-findings severity on a short run (TRSET-30, TRSET-13 follow-up)

What changed (≤100 words): TRSET-13 AC 11 scoped the sub-8-hour "not valid" marking to the per-stage table. The Catastrophic findings (severity 4→5) table in the same expander kept stating an updated_severity derived from the same LBGI, so the two tables contradicted each other on a short run. That column now carries SUB_MINIMUM_DURATION_CELL when meal_config.metrics_are_valid() is False, under a caption saying the row set is LBGI-gated too. sim_id, stage and condition still render: condition is read from the BG trace and holds at any duration. Presentation only — streamlit_app.py alone, no severity_model change.

Example:

streamlit run streamlit_app.py     # Simulation duration -> Short term (2 h) -> Generate configs -> Run Tool
import streamlit_app
streamlit_app.INVALIDATED_CATASTROPHIC_COLUMNS   # ("updated_severity",)
streamlit_app._render_catastrophic_table(findings, metrics_valid=False)

Validation (≤100 words): Six AppTest cases in tests/test_streamlit_app.py, beside the TRSET-13 marking test, over a fixture holding both an escalated row (zero_or_negative, 5) and an unescalated one (none, 4) — the pair proves the marking is not keyed on the escalated value. Covered: marked at 2 h; rows and condition survive rather than being suppressed; the row-set caption is present; the SeverityAssessment is not mutated; unchanged at 8 h and on a library run (no known duration). Mutation-checked — reverting streamlit_app.py fails four of the six. Affected suites: 196 passed, 1 deselected, no new failures.

Cautions / limitations: Marking only. A short run's catastrophic findings still reach the RTF summaries and the export zip unmarked — the same carve-out TRSET-13 recorded, still open, still to be raised separately before an exported short run is treated as a record. The row set is reported unreliable, not corrected: severity_model selects rows on lbgi_risk_score == 4 before check_catastrophic_conditions ever runs, so a sim the short run mis-scored is never assessed for escalation at all, and no view-layer change can recover it. extended_low needs 48 consecutive readings under 40 mg/dL (4 hours at 5-minute steps), so it is unreachable below a ~4-hour run while zero_or_negative is reachable at any length — the table's two conditions are not equally available on short runs. Marking is keyed on the duration snapshot taken at run start (_run_duration_hours), so a library run is never marked.

Start page (TRSET-34)

What changed (≤100 words): A start page now stands in front of the tool on a fresh session. It carries a purpose statement (risk exploration, not formal risk assessment; not medical software) and an AI code disclosure (the UI wrapper was written with AI assistance; TRSET itself is unmodified, and every change went through a human-defined, human-reviewed process). Got it advances to the app. It lives in its own start_page.py as verbatim text constants plus a zero-argument render(); the session gate is a few lines in main(). Page config, _BRAND_CSS, the disclaimer banner and the logo run above the gate, so both pages carry them.

Example:

streamlit run streamlit_app.py     # start page first; Got it opens the tool

Validation (≤100 words): tests/test_trset34_integration.py — the Feature gate — drives the real app through AppTest in three phases: the unacknowledged first render (start page present, text asserted against the module constants, chrome present, no config radio/selectbox/results), a real Got it click (tool present, start page gone, still exactly one role="alert" banner), and the gate staying down across an unrelated widget change and repeated reruns — the _init_session_state() clobbering failure mode. test_accessibility.py gains start-page label/tabindex/alt-text/banner gates. Every other AppTest suite bypasses the gate through one make_app_test factory in tests/conftest.py. Full suite: 310 passed, 7 skipped.

Cautions / limitations: Per-session only, by design — a refresh shows the page again, and nothing is persisted. No control returns to the start page once acknowledged. The page is not in the export bundle, so a reviewer holding an exported zip still sees no AI disclosure (separate ticket). The page title restates the app title rather than importing it, so the two are kept in step by a test rather than by construction; the naming inconsistency this originally shipped with is resolved in TRSET-44 below. st.navigation/st.Page was deliberately not adopted: AppTest renders only the default page of a multipage app. render() takes no arguments and does not act on its own button, so that migration can adopt it unchanged.

One name for the tool (TRSET-44)

What changed (≤100 words): The tool is called the Risk Severity Evaluation Tool everywhere. Three names for it used to render within a screenful: st.title and set_page_config said "Estimation", start_page.PAGE_TITLE and the purpose statement said "Estimation", DISCLAIMER_TEXT said "Evaluation". "Evaluation" is correct — it is what TRSET expands to (Tidepool Risk Severity Evaluation Tool), confirmed against the QMS documentation on 2026-09-01. A one-word substitution at four sites; no behavioural change, and no other wording touched. "Loop" stays a title-only qualifier (see Cautions).

Validation (≤100 words): tests/test_trset44_integration.py holds the invariant. It extracts the name from each site with one regex and compares the captures rather than reading the strings — TRSET-34 showed the eyeball check is the one that misses — so a future edit to any single site fails here instead of shipping a fresh split. Each of the four sites was mutation-checked individually, and each failure was observed. Two independent gates: the agreement test catches any divergence, a second test blocklists "Estimation" on everything rendered. The set_page_config title is asserted at source level, since AppTest does not expose it. Suite: 313 passed, 7 skipped (+3, this file).

Cautions / limitations: The invariant is on the canonical core, "Risk Severity Evaluation Tool", with "Loop" optional — not on the full string. st.title and PAGE_TITLE name this GUI ("Tidepool Loop Risk Severity Evaluation Tool"); DISCLAIMER_TEXT and the purpose statement name the underlying tool without "Loop", and the purpose statement's "(TRSET)" makes its occurrence the acronym's expansion, which has no "Loop" in it. So the DISCLAIMER_TEXT body is unchanged by design. Nothing outside the app UI renames: no export filename, no RTF content, no export-bundle path contained the name, so there is no downstream break. The launcher's "Tidepool Loop Risk Simulator GUI" is a different name and was left alone.

Bundled app artifacts (TRSET-47)

What changed (≤100 words): APP_ARTIFACTS staged five of the app's nine runtime artifacts. streamlit_app.py imports loop_home_renderer, export_bundle, meal_config and start_page at module level and none was staged, so the builder produced a bundle that raised ModuleNotFoundError on the colleague's first double-click — after env provisioning had already succeeded. The four names are added, and pytest.ini with them, so the bundled tests/ run under the repo's markers. The list was correct at Phase 4; TRSET-7, TRSET-9, TRSET-22 and TRSET-34 each added a module without updating it, so a guard now makes the list self-maintaining.

Validation (≤100 words): verify_app_artifacts_complete() parses streamlit_app.py with ast — stdlib-only, parse-only, never imported — resolves which imports are local by what exists in the repo (a second hardcoded list would be the same defect one level up), and raises before any git or staging work. Scope is one level of indirection, bounded on purpose. test_build_bundle.py runs the guard against the real repo and mutation-checks it per module: dropping any one of the four raises, and removing the call itself was observed to fail the build-level test. Suite: 324 passed, 7 skipped.

Cautions / limitations: The guard reads module-level imports only — an import nested inside a function or an if is not seen — and does not walk past one level of indirection. It proves the files are staged, not that the bundle launches: only tests/test_phase4_bundle_integration.py does that, and it is opt-in and has not been run since TRSET-7. Running it needs a real arm64 conda env and a built bundle; until it runs, this fix is verified by unit tests alone. LICENSE is still unstaged (a separate call, not an ImportError).

Version number in the header (TRSET-35)

What changed (≤100 words): The app displayed no version anywhere, and the only version concept was build-time — build_bundle.py's required --version, which nothing tied to the running app, so the two could disagree silently. A new version.py holds APP_VERSION = "1.0.0" and is now the single source for both: the app renders Version 1.0.0 in the shared header, above the start-page gate, so every page carries it from one insertion point; --version becomes optional, defaults to the constant, and raises if given a value that disagrees. version.py ships in the bundle. See "Versioning" above for the SOP-0005 §7.1 reading.

Validation (≤100 words): tests/test_trset35_integration.py drives the real app through a bare AppTest in four phases: the version on the unacknowledged start-page render and still there after a real Got it click; the string asserted against version.APP_VERSION rather than a literal, with a mutation that repoints the constant and watches the screen follow; all three release artifacts carrying APP_VERSION, with a disagreeing --version raising before any archive exists; and version.py present in the built tree with its real contents. Suite: 342 passed, 7 skipped.

Cautions / limitations: Rendered with st.markdown, not st.caption, on a measured finding: Streamlit fades captions with opacity: 0.6 on the caption container, compositing the theme's #281946 to #7E7590 on white — 4.34:1, under the 4.5:1 WCAG 1.4.3 minimum for normal text (14px, weight 400, so the large-text allowance does not apply). Measured in a running app on 2026-09-02 and pinned by test_accessibility.py. The number is bumped manually and nothing enforces it, so a stale number is possible between releases. Phases 3–4 mock the git boundary (resolve_ref/extract_tree_paths) as test_build_bundle.py does; the app repo they build from is real.

Narrative risk description (TRSET-36)

What changed (≤100 words): The Configure meals & boluses editor's first control is a 250-character Risk description text area. Its text is written to metadata.risk_description in all four generated configs, replacing the hardcoded RISK_DESCRIPTION placeholder, and echoed in the generated-configs summary — read back from the written JSON, not from the widget. meal_config.RISK_DESCRIPTION_MAX_CHARS is the only limit; the widget reads it and _validate_risk_description mirrors it for non-Streamlit callers. Text is stripped before the empty check, the limit check and the write. Changing it invalidates a generated set, exactly as changing the duration does. No parser, schema, gui_runner or severity_model change.

Example:

streamlit run streamlit_app.py     # Configure meals & boluses -> Risk description -> Generate configs
import meal_config

spec = meal_config.MealConfigSpec.aligned(
    mode, entries, risk_description="  Large evening meal\nno correction for 4 h  "
)
meal_config.resolve_risk_description(None)        # -> "GUI-configured meal and bolus entries"
meal_config.resolve_risk_description("  x \n ")   # -> "x"   (stripped, then written)

An unfilled field falls back to the existing constant, so a spec that names no description produces byte-identical output to the version before this feature.

Validation (≤100 words): tests/test_trset36_integration.py — 15 tests, no mocks, real configs under the LOOP_RISK_GUI_SCENARIO_CONFIGS_ROOT seam. Padded text with an embedded newline reaches all four files stripped, newline serialized as \n, the other three metadata keys untouched; empty and whitespace-only both write and echo the constant — which is also the proof the echo reads the file, not the widget; editing it drops the set, regenerating writes the new text, changing the meal count drops nothing; generate_config() refuses 251 stripped characters, accepts 250. test_trset13's real 2-hour run carries one into the export zip; test_accessibility covers the new surface. Suite: 359 passed, 7 skipped, 1 deselected (-m slow: 1 passed).

Cautions / limitations: Counting is code points, as both max_chars and len() count — an emoji or a combining accent may cost more than one, so 250 is not a grapheme guarantee. The MealConfigError for an over-long description is unreachable from the UI (max_chars blocks first) and exists to defend generate_config() for another caller; that is by design, not a gap. The description is not surfaced in the results pane or the severity RTF — those stay byte-identical, and surfacing it in both together is a separate item. The library path is untouched: the descriptions its configs already carry are still never displayed. The echo goes through st.caption, so markdown in the text renders as markdown (HTML is escaped, not executed); the file keeps the text verbatim. And library configs spell the neighbouring key risk-id where generate_config() writes risk_id — accepted by ScenarioMetadata by design, untouched here, and still needing a decision.

Two Streamlit behaviours this feature rests on, measured in a running app on 2026-09-02 rather than assumed. (1) max_chars is enforced in Streamlit's own JS, not as an HTML maxlength, and an over-limit edit is rejected whole rather than truncated: at 250 characters further keystrokes and an over-long paste do nothing, with no message — which is what makes the mirrored MealConfigError unreachable from the UI, and also means a user pasting 300 characters sees the field stay empty. (2) st.text_area commits on blur (or Ctrl+Enter), not per keystroke, so a generated set is dropped when the field loses focus rather than vanishing mid-sentence.

Controller settings and dosing strategy (TRSET-15)

What changed (≤100 words): The Configure meals & boluses editor gains two radio groups: Controller settings (Tidepool Loop 2.x, default; Tidepool Loop 1.x) and Dosing strategy (Autobolus, default; Temp basal). The settings group chooses base_configbase_<profile>_2_0_v1 or base_<profile>_1dotx — for all four T1 profiles. 2.x + Temp basal writes partial_application_factor: 0.0 on both Loop stages, inlining the guardrails file's contents on the post-mitigation stage. Both choices land in metadata and are echoed in the summary, read back from the JSON. Changing either invalidates a generated set. No parser, schema, gui_runner or severity_model change.

Loop 1.x is temp-basal only, and the two controls are coupled accordingly: selecting it removes Autobolus from the options rather than showing it and ignoring it. 1dotX.json already ships partial_application_factor: 0.0, and SwiftLoopController picks recommendationType from the truthiness of that value alone, so "1.x + Autobolus" would mean overriding the very settings file the choice names — and matches no released Loop, since autobolus arrived with 2.x. For the same reason 1.x writes no override at all: the base already resolves to temp basal.

maximum_autobolus is never written. resolve_override applies only keys already present in the resolved base and then raises "Only applied N of M overriding values" on a count mismatch, so writing it would hard-fail the run rather than be ignored.

Example:

streamlit run streamlit_app.py     # Configure meals & boluses -> Controller settings -> Generate configs
import meal_config

two_x, one_x = meal_config.SETTINGS_GROUPS
spec = meal_config.MealConfigSpec.aligned(
    mode, entries, settings_group=one_x, dosing_strategy=meal_config.DOSING_AUTOBOLUS
)
spec.resolved_dosing_strategy      # -> "Temp basal"  (1.x forces it)
spec.writes_temp_basal_override    # -> False         (1dotX.json already says 0.0)

Breaking change (§6): the generated-config output schema moves. Every generated file's metadata gains controller_settings_group and dosing_strategy, and in the 2.x + Temp basal combination the pre-mitigation stage gains a controller key it did not have before. The only consumer is the export bundle, which ships the configs verbatim. Additions are safe for the run: every model in schema_models.py sets extra="allow", and ScenarioParserV2 reads only metadata["simulation_id"] from that block. Rollback note: reverting restores the four-key metadata and removes the pre-mitigation controller key; configs generated while this was in place stay readable, since both additions are ignored by the parser.

Validation (≤100 words): tests/test_trset15_integration.py — 46 tests to the approved five-phase plan, no mocks, real configs under the LOOP_RISK_GUI_SCENARIO_CONFIGS_ROOT seam. Per-combination config shape across all four profiles; the guardrails inline dict compared against the file read at test time, never against literals; maximum_autobolus asserted absent recursively; a no-delta comparison against the committed pre-change fixture; the 1.x coupling and the Autobolus-selected transition; metadata, echo and invalidation both ways; every combination validated and parsed; one real 2-hour end-to-end run on 2.x + Temp basal. Three mutation checks kill their tests. Suite: 405 passed, 7 skipped, 1 deselected.

Cautions / limitations: (1) Switching to Loop 1.x and back to 2.x leaves Temp basal selected rather than restoring Autobolus — the last thing shown selected is what stays selected, since silently re-enabling a dosing strategy is the worse surprise. (2) The info text and per-option captions are written from the ticket's described behaviour, not copied from its verbatim UI copy, which is not recorded in the request; the tests assert the module constants rather than the strings, so replacing the wording is a one-line change. (3) Streamlit radios have no per-option tooltip, so the per-option text renders as captions beneath each option — more discoverable than a hover tooltip, but not hidden. (4) Loop 1.x reaches the four base_<profile>_1dotx configs, whose settings pointer casing is corrected by simulator-side TRSET-48; on a case-sensitive filesystem 1.x is broken until that lands. (5) Loop 1.x results change when simulator-side TRSET-49 lands — this suite deliberately asserts config shape only, never 1.x results.

About

Streamlit GUI for the Tidepool Loop risk simulator

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages