Skip to content

Physiology primitives for wearables and computational models - #1707

Open
awatson1978 wants to merge 56 commits into
synthetichealth:masterfrom
awatson1978:physiology-primitives
Open

awatson1978 wants to merge 56 commits into
synthetichealth:masterfrom
awatson1978:physiology-primitives

Conversation

@awatson1978

@awatson1978 awatson1978 commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #1347 (which was itself a rebuild of #1254). This branch continues that work: it addresses the review feedback on #1347, cleans up the model collection for merge-readiness, and is up to date with current master.

What this is

This PR adds a library of physiology primitives to Synthea: fourteen published, peer-reviewed ODE models (from the EMBL-EBI BioModels repository) wired into Synthea's existing physiology engine, along with simulation configs, three example disease/device modules, documentation, and tests.

The same models serve two purposes:

  • Wearables simulation — generating device-style signals: an ECG trace from a smartwatch, a nocturnal melatonin curve from a sleep tracker, oxygen readings from a pulse oximeter or CPAP machine.
  • Digital twins / digital avatars — modeling the underlying physiological state of a simulated person over time.

The project started as a single simulated Apple Watch heart-rate signal and grew into a collection of building blocks organized along three axes: device type (smartwatch, sleep monitor, CGM, cycle tracker, smart scale), physiology system (cardiac, pulmonary, endocrine/circadian, metabolic, cellular/aging), and time scale (from ~1 beat per second to ~1 cycle per month).

Models

synthea-biomodel-graphs

What's now included

System Model Time scale Example device
Cardiac ECG (McSharry2003) ~1/second smartwatch / Holter
Pulmonary Pulmonary oxygen intake (Guyton1972) ~1/second pulse-ox / CPAP
Pulmonary O2 transport & metabolism (Lai2007) ~1/minute —
Pulmonary Pulmonary fluid dynamics (Guyton1972) ~1/minute —
Circadian Circadian clock (Hong2009) ~1/day sleep tracker
Circadian Non-24h circadian rhythm (Leloup2004) ~1/day —
Circadian Plasma melatonin (Brown1997) ~1/day light / sleep tracker
Endocrine / Stress Cortisol stress response (HPA axis) ~1/day stress monitor
Endocrine / Stress Pulsatile cortisol rhythm (Kyrylov2005) ~1–2h stress monitor
Endocrine Menstrual cycle (Roblitz2013) ~1/month cycle tracker
Metabolic Insulin signalling, normal & diabetic (Brännmark2013) ~1/minute CGM
Metabolic Weight change (ChowHall2008) ~1/day smart scale
Cellular / Aging Telomere-associated DNA damage (Talemi2015) long-term —

Three example modules consume these models during a normal Synthea run: wearables_cardiac_monitoring (ECG), wearables_sleepapnea (circadian clock + pulmonary oxygen intake), and endometriosis (menstrual cycle). Each model can also be run standalone from the command line to produce charts, CSV data, and — new in this PR — a FHIR R4 Bundle of SampledData Observations.

Everything is opt-in

No default behavior changes. Both integration paths are off unless explicitly enabled:

physiology.generators.enabled = true   # physiology-driven VitalSigns
physiology.state.enabled = true        # Physiology states inside modules

With both flags at their default (false), Synthea runs exactly as before — which is how CI stays green.

Review feedback from #1347, addressed

@jawalonoski's review of the sleep apnea module has been addressed on this branch:

  • Encounter classes changed from emergency to ambulatory, and encounters no longer overlap (the baseline encounter ends before the sleep study begins).
  • Condition onset states now reference actual conditions with codes.
  • Observation states have values and units of measure.
  • Symptom states have proper probabilities; module logic was reworked from age-based gating to population-prevalence-based onset.

Cleanup since #1347

  • Dropped the Jerby2010 liver metabolism model: a genome-scale ODE (3,438 species) that doesn't conserve mass as a time-course simulation — not salvageable.
  • Re-encoded the HPA-axis / cortisol model with rate rules and a time-stepped chronic stressor; it previously simulated flat lines. It now produces a realistic blunted-cortisol stress response.
  • Added the Kyrylov2005 HPA-axis model as a standalone simulation, with its abstract states mapped to named biomarkers (CRH, ACTH, cortisol). It renders a clean pulsatile cortisol rhythm and complements the stress-response model.
  • Fixed the plasma melatonin simulation duration so it reaches the nocturnal secretion window (a clean melatonin pulse instead of a decay curve).
  • Labeled all chart axes and titles; removed one orphaned model file.
  • Added a FHIR export facade (PhysiologySimulationExporter) so standalone simulations can emit FHIR R4 SampledData Observations, reusing the existing FhirR4.mapValueToSampledData.
  • Added tests: PhysiologyModuleStateTest (module-state path runs when enabled) and a cortisol stress-response test in PhysiologySimulatorTest.
  • Reorganized config/simulations/README.md around the device × system × time-scale catalog, with both entry paths and the enabling flags documented.

Checkstyle and the full test suite are green.

Trying it out

Run any model standalone:

./gradlew physiology --args="config/simulations/ecg.yml"
./gradlew physiology --args="config/simulations/kyrylov_hpa_axis.yml"
./gradlew physiology --args="config/simulations/plasma_melatonin.yml"

Charts (PNG) and raw data (CSV) land in output/physiology/{name}/. Or generate a population with physiology enabled:

./run_synthea -s 21 -p 1000 --physiology.generators.enabled="true" --physiology.state.enabled="true"

See config/simulations/README.md for the full catalog and usage.

Finding the data

Population runs write FHIR bundles to output/fhir/. Two quick searches find the physiology data:

grep -l "valueSampledData" output/fhir/*.json          # waveform observations
grep -l '"resourceType": "Media"' output/fhir/*.json   # rendered chart images

(Standalone runs put their CSV, PNG, and optional FHIR bundle in output/physiology/{name}/.)

Observations with SampledData

When a module's Physiology state runs, the model's output time series is stored on the person and recorded by the next Observation state as a FHIR SampledData value. Here is an ECG from wearables_cardiac_monitoring, exactly as exported (data truncated):

{
  "resourceType": "Observation",
  "status": "final",
  "category": [ { "coding": [ {
    "system": "http://terminology.hl7.org/CodeSystem/observation-category",
    "code": "procedure", "display": "Procedure" } ] } ],
  "code": {
    "coding": [ {
      "system": "http://snomed.info/sct",
      "code": "29303009",
      "display": "Electrocardiographic procedure" } ],
    "text": "Electrocardiographic procedure"
  },
  "effectiveDateTime": "2020-05-16T10:07:08-05:00",
  "valueSampledData": {
    "origin": { "value": 0.0, "system": "http://unitsofmeasure.org" },
    "period": 10.0,
    "dimensions": 1,
    "data": "0.038 0.031 0.004 -0.014 -0.02 -0.017 -0.014 -0.01 ..."
  }
}

Media (rendered charts)

Observation states with a chart attachment export as Media resources carrying the rendered PNG (R4 Observations cannot hold attachments), so the same encounter also contains the picture of that waveform:

Media resource example (base64 truncated)
{
  "resourceType": "Media",
  "status": "completed",
  "type": { "coding": [ {
    "system": "http://terminology.hl7.org/CodeSystem/media-type",
    "code": "image", "display": "Image" } ], "text": "Image" },
  "reasonCode": [ { "coding": [ {
    "system": "http://snomed.info/sct",
    "code": "29303009",
    "display": "Electrocardiographic procedure" } ] } ],
  "height": 200,
  "width": 400,
  "content": {
    "contentType": "image/png",
    "data": "iVBORw0KGgoAAAANSUhEUgAAAZAAAADICAYAAADGFbfi...",
    "title": "Electrocardiogram"
  }
}

Standalone simulations can emit FHIR too: with exportFhir: true in the sim config, the new PhysiologySimulationExporter writes a Bundle of SampledData Observations (one per non-constant model variable) to output/physiology/{name}/{name}.fhir.json.

Model inputs — what drives a simulation, and where it lives

Inputs are expressed in three places, depending on the entry path:

1. Standalone sim configs (config/simulations/*.yml) set model parameters to literal values. From ecg.yml — mean heart rate and the arrhythmia-episode window:

inputs:
    hrmean: 80
    arr_t0: 0.25
    arr_t1: 0.35
    arr_base: 0.2
    rng_seed: 0.54345

2. Module Physiology states map attributes of the simulated person into model parameters through expressions, so patient state drives the physiology. From wearables_cardiac_monitoring, the person's BMI modulates the ECG model's mean heart rate, and the model's waveform comes back as a person attribute that the next Observation state records:

"inputs": [
  { "from_exp": "#{BMI} * 0.497 + 56.15", "to": "hrmean" }
],
"outputs": [
  { "from_list": "zf", "to": "ecg_result", "type": "Attribute" }
]

Any person attribute or vital sign can appear in a #{...} expression (#{BMI}, #{age}, boolean attributes as #b{...}).

3. Physiology generators (src/main/resources/physiology/generators/*.yml) use the same expression language plus a variance threshold (skip re-running the simulation for small input changes) and a preGenerator (values to use before the model first runs). These ship dormant in this PR — their VITAL_SIGN output types are commented out — so they are configuration examples rather than active data sources.

One fix included here makes the command-line flag work as documented: State.ENABLE_PHYSIOLOGY_STATE used to be frozen when the class loaded (before --config arguments were applied), so --physiology.state.enabled=true was silently ignored and the flag only worked from synthea.properties. Generator.init() now re-reads it once configuration is final.

References

awatson1978 and others added 22 commits November 30, 2023 23:17
- Re-encode HPA/cortisol model (Major_Depressive_Disorder_Model) with rate rules
  plus a time-stepped chronic stressor. It was previously inert (every species was
  boundaryCondition=true and derivatives were emitted as product species), so the
  whole axis stayed flat. It now produces a blunted-cortisol stress response.
- Fix plasma_melatonin duration to reach the nocturnal secretion window (clean
  melatonin pulse instead of a decay) and label axes.
- Relabel pulmonary_fluid_dynamics and document that it needs an input perturbation.
- Add a FHIR facade (PhysiologySimulationExporter): standalone sims can emit a FHIR
  R4 Bundle of SampledData Observations via 'exportFhir: true', reusing
  FhirR4.mapValueToSampledData. Enabled for ecg and cortisol_depression.
- Add PhysiologyModuleStateTest (state-path modules run when enabled) and a cortisol
  stress-response test in PhysiologySimulatorTest.
- Reorganize config/simulations/README.md by device x system x time-scale, with both
  entry paths and the enabling flags documented.
- Drop liver_metabolism (Jerby2010): unbounded genome-scale ODE, not viable as a
  time-course simulation.

Physiology remains opt-in (defaults unchanged). checkstyle + full test suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Superseded by Leloup2004_CircadianRhythmsSet4.xml, which is what
mammalian_circadian_rhythm_non_24hr.yml actually loads. The Non_24hr file had no
sim config, no module, and no PDF referencing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace placeholder '???' / mislabeled axes and terse titles on
o2_transport_metabolism, telomere_associated_dna_damage, and weight_change with
descriptive titles and honest 'level (mixed units)' y-axis labels (each plots
several heterogeneous-unit series).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The model was orphaned (no sim config) but is already a well-formed 5-state
rate-rule ODE and renders a clean ultradian pulsatile cortisol rhythm. Add a
sim config (states y0-y4) plus README catalog/examples entries. It complements
cortisol_depression.yml: this is the healthy rhythm, that is the stressor
response. Biomarker naming of y0-y4 is a documented follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add readable alias outputs to the SBML (CRH=y0, ACTH=y1, cortisol=y2) and plot the
named hormones instead of the abstract states. The mapping is derived from the model's
own interaction topology (the r0-r4 assignment rules) and canonical HPA feedback
(CRH -> ACTH -> cortisol; cortisol inhibits CRH and ACTH). The three hormone identities
are high-confidence; the pituitary-vs-adrenal assignment of the two gland-size states
(y3/y4) is uncertain and left as-is. The mapping basis is documented in the model file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@awatson1978 awatson1978 mentioned this pull request Sep 16, 2026
FHIRR4ExporterTest US Core 5/6 runs failed intermittently on invariant
us-core-2 (an Observation needs value[x] or dataAbsentReason when it has no
component/hasMember). Two Observation states in the new wearables modules
recorded no value at all, and FhirR4 never emits a dataAbsentReason; the
failures were intermittent because the test population is randomly generated.

- wearables_sleepapnea SleepAnalysis (LOINC 93832-4 sleep duration): add a
  4-7h range value, mirroring the cardiac module's SleepAnalysis. This state
  is reached both via CircadianClock_Sim's physiology-disabled alt path and
  the CPAP path.
- wearables_cardiac_monitoring Fitness_Baseline: convert to a Procedure
  state - its SNOMED code (410189008, vitals education) is a procedure code
  and there was nothing being measured.

Add a regression test that drives every Observation state in the three new
modules and asserts each recorded observation carries a value (verified to
fail against the previous module definitions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@awatson1978 awatson1978 changed the title Physiology primitives for wearables and digital twins Physiology primitives for wearables and physiology sims Sep 17, 2026
@awatson1978 awatson1978 changed the title Physiology primitives for wearables and physiology sims Physiology primitives for wearables and computational models Sep 17, 2026
awatson1978 and others added 2 commits September 16, 2026 22:08
Seven chart configs still used their raw config-name as the chart title
(e.g. "insulin_signalling_normal"). Title them like the already-descriptive
charts, and replace two cryptic y-axis labels (kms, MB) with the plotted
species. Also rename "ECG: AFib" - the chart renderer truncated the title at
the colon, so the rendered chart just said "ECG".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
State.ENABLE_PHYSIOLOGY_STATE is captured in a static initializer, which runs
when module scanning first loads the State class - before App.main applies
--config command-line overrides. As a result the documented
"--physiology.state.enabled=true" argument was silently ignored, and the flag
only took effect when set in synthea.properties. Re-read the flag in
Generator.init(), which always runs after configuration is final.

Verified end to end: a population generated with the flag on the command line
now contains SampledData Observations and Media chart resources from the
wearables modules, where before it contained none.

TestHelper now enables the flag after constructing the Generator, since
Generator.init() refreshes it from configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@awatson1978
awatson1978 marked this pull request as ready for review September 17, 2026 17:09
@awatson1978

Copy link
Copy Markdown
Collaborator Author

Hi @dehall, @hadleynet, @jawalonoski,
I took a few Fable tokens, and pointed it at PR #1347 and asked it to close gaps and address concerns raised, and help get the PR across the finish line. It did a great job, and was able to get from ~4 working BioModels to 14 working models.

FYI, I have a grant application in with NASA Translational Research Institute for Space Health (TRISH) to study venous thrombosis (blood clots), and included references to Synthea and Biomodels. It would include funding for a new venous-thrombosis model with an additional 3 models in addition to these 14.

Hope things are doing well with everyone,
Abbie

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant