Hybrid clinic#
A walk-in clinic serving a population of 500. People fall sick at random, walk in, queue for treatment, and go home to recover. Meanwhile the doctors tire: a continuous fatigue stock rises with utilization and lowers the clinic's effective capacity. Agents, a queue, and a feedback loop, in one document on one clock.
Generated from spec/examples/clinic-hybrid.osdl.json
abm des sdparadigmsThree paradigms, one calendar#
Each part of this model uses the paradigm that fits it. The population is agent-based, because falling sick is a per-person state change. The clinic is discrete-event, because queues and service are event-driven. Fatigue is system dynamics, because it accumulates continuously.
In OSDL these are not three models coupled together. Agent transitions, entity transfers, and integration boundaries are all events on one calendar, ordered by time and insertion sequence. The paradigms interact through ordinary references: the fatigue stock reads the servers' utilization, and the servers' capacity reads the fatigue stock.
The component contracts are in the abm, des, and sd library references.
The document, piece by piece#
The population is a single abm.population component. Each agent carries a statechart: well, sick, recovering. Falling sick is a rate-triggered transition, and entering the sick state sends the agent out of the population as an entity, into the clinic:
{
"initial": "well",
"states": [
{
"id": "well"
},
{
"id": "sick",
"description": "Departs to the clinic on entry; remains in this state while queued and treated.",
"onEnter": [
{
"type": "depart",
"port": "out"
}
]
},
{
"id": "recovering"
}
],
"transitions": [
{
"from": "well",
"to": "sick",
"trigger": {
"type": "rate",
"rate": {
"param": "sicknessRate"
}
}
},
{
"from": "sick",
"to": "recovering",
"trigger": {
"type": "arrive",
"port": "in"
},
"actions": [
{
"type": "set",
"target": "visits",
"value": {
"expr": "visits + 1"
}
}
]
},
{
"from": "recovering",
"to": "well",
"trigger": {
"type": "timeout",
"delay": 48
}
}
]
}The clinic itself is two des components. The servers' capacity is not a constant; it is an expression over the continuous side of the model:
{
"serviceTime": {
"dist": "exponential",
"rate": {
"expr": "1 / treatmentTime"
}
},
"capacity": {
"expr": "effectiveDoctors.value"
}
}Fatigue is a stock filled by utilization and drained by rest. This closes the feedback loop: busy doctors tire, tired doctors serve fewer patients, the queue grows, and the doctors stay busy:
{
"rate": {
"expr": "fatigueGain * doctors.utilization"
}
}Run it#
View the model document
{
"$schema": "https://osdl.dev/schemas/0.1/osdl.schema.json",
"osdl": "0.1",
"model": {
"name": "clinic-hybrid",
"label": "Urgent-Care Clinic (hybrid DES + ABM + SD)",
"description": "All three paradigms in one model. Patients are agents (ABM) who fall sick stochastically and depart into the clinic's discrete-event process (DES): a triage queue feeding doctors. Doctor workload accumulates as fatigue (SD), which feeds back to reduce effective treatment capacity. Treated patients return to the population and eventually recover to a well state.",
"time": {
"unit": "hours",
"integration": {
"method": "euler",
"dt": 0.1
}
},
"parameters": [
{
"name": "nPatients",
"description": "Population size served by the clinic.",
"type": "integer",
"default": 500
},
{
"name": "sicknessRate",
"description": "Hazard of a well person falling sick.",
"default": 0.005,
"unit": "1/hour"
},
{
"name": "treatmentTime",
"description": "Mean treatment duration per patient.",
"default": 0.75,
"unit": "hours"
},
{
"name": "baseDoctors",
"description": "Doctors on shift when fully rested.",
"type": "integer",
"default": 3
},
{
"name": "fatigueGain",
"description": "Fatigue accumulated per hour at full utilization.",
"default": 0.05,
"unit": "1/hour"
},
{
"name": "fatigueRecovery",
"description": "Fractional fatigue recovery per hour.",
"default": 0.1,
"unit": "1/hour"
}
],
"components": [
{
"id": "patients",
"type": "abm.population",
"label": "Patient population",
"params": {
"size": {
"param": "nPatients"
},
"agent": {
"attributes": [
{
"name": "visits",
"init": 0,
"description": "Completed clinic visits."
}
],
"behavior": {
"initial": "well",
"states": [
{
"id": "well"
},
{
"id": "sick",
"description": "Departs to the clinic on entry; remains in this state while queued and treated.",
"onEnter": [
{
"type": "depart",
"port": "out"
}
]
},
{
"id": "recovering"
}
],
"transitions": [
{
"from": "well",
"to": "sick",
"trigger": {
"type": "rate",
"rate": {
"param": "sicknessRate"
}
}
},
{
"from": "sick",
"to": "recovering",
"trigger": {
"type": "arrive",
"port": "in"
},
"actions": [
{
"type": "set",
"target": "visits",
"value": {
"expr": "visits + 1"
}
}
]
},
{
"from": "recovering",
"to": "well",
"trigger": {
"type": "timeout",
"delay": 48
}
}
]
}
}
}
},
{
"id": "triage",
"type": "des.queue",
"label": "Triage queue",
"params": {
"discipline": "fifo"
}
},
{
"id": "doctors",
"type": "des.server",
"label": "Doctors",
"params": {
"serviceTime": {
"dist": "exponential",
"rate": {
"expr": "1 / treatmentTime"
}
},
"capacity": {
"expr": "effectiveDoctors.value"
}
}
},
{
"id": "fatigue",
"type": "sd.stock",
"label": "Doctor fatigue",
"params": {
"initial": 0,
"min": 0,
"max": 1
}
},
{
"id": "tiring",
"type": "sd.flow",
"params": {
"rate": {
"expr": "fatigueGain * doctors.utilization"
}
}
},
{
"id": "resting",
"type": "sd.flow",
"params": {
"rate": {
"expr": "fatigueRecovery * fatigue.value"
}
}
},
{
"id": "effectiveDoctors",
"type": "sd.auxiliary",
"label": "Effective doctors",
"description": "Fatigue feedback: rested doctors work at full capacity; exhausted doctors at half. Never below one doctor.",
"params": {
"value": {
"expr": "max(1, round(baseDoctors * (1 - 0.5 * fatigue.value)))"
}
}
}
],
"connections": [
{
"id": "toTriage",
"from": "patients.out",
"to": "triage.in"
},
{
"id": "toDoctors",
"from": "triage.out",
"to": "doctors.in"
},
{
"id": "discharge",
"from": "doctors.out",
"to": "patients.in"
},
{
"id": "tiringIn",
"from": "tiring.out",
"to": "fatigue.in"
},
{
"id": "restingOut",
"from": "fatigue.out",
"to": "resting.in"
}
]
},
"experiments": [
{
"name": "one-week",
"description": "A week of clinic operation after a day of warmup.",
"duration": 168,
"warmup": 24,
"replications": 10,
"seed": 42,
"outputs": [
{
"path": "triage.length",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "doctors.utilization",
"records": [
{
"type": "summary",
"statistics": [
"mean",
"max"
]
}
]
},
{
"path": "fatigue.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "patients.inState.sick",
"records": [
{
"type": "summary"
}
]
}
]
},
{
"name": "browser",
"description": "A week of clinic operation after a day of warmup.",
"duration": 168,
"warmup": 24,
"replications": 1,
"seed": 42,
"outputs": [
{
"path": "triage.length",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "doctors.utilization",
"records": [
{
"type": "summary",
"statistics": [
"mean",
"max"
]
}
]
},
{
"path": "fatigue.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "patients.inState.sick",
"records": [
{
"type": "summary"
}
]
}
]
}
],
"views": [
{
"id": "docs-canvas",
"label": "Documentation layout",
"type": "canvas",
"elements": [
{
"ref": "patients",
"x": 0,
"y": 40
},
{
"ref": "triage",
"x": 280,
"y": 40
},
{
"ref": "doctors",
"x": 560,
"y": 40
},
{
"ref": "effectiveDoctors",
"x": 780,
"y": 210
},
{
"ref": "tiring",
"x": 280,
"y": 350
},
{
"ref": "fatigue",
"x": 480,
"y": 350
},
{
"ref": "resting",
"x": 680,
"y": 350
}
],
"routes": [
{
"ref": "discharge",
"vertices": [
{
"x": 350,
"y": 260
}
]
}
]
}
]
}Watch the two charts against each other. The queue length is a step series that moves at event instants; fatigue is a smooth curve integrated between them. Their interaction, discrete load driving a continuous stock that throttles discrete capacity, is the point of the model.
Reference#
Parameters#
| Name | Type | Default | Description |
|---|---|---|---|
nPatients |
integer |
500 |
Population size served by the clinic. |
sicknessRate |
number |
0.005 |
Hazard of a well person falling sick. unit: 1/hour |
treatmentTime |
number |
0.75 |
Mean treatment duration per patient. unit: hours |
baseDoctors |
integer |
3 |
Doctors on shift when fully rested. |
fatigueGain |
number |
0.05 |
Fatigue accumulated per hour at full utilization. unit: 1/hour |
fatigueRecovery |
number |
0.1 |
Fractional fatigue recovery per hour. unit: 1/hour |
Components#
| Id | Type | Params |
|---|---|---|
patientsPatient population |
abm.population |
{"size":{"param":"nPatients"},"agent":{"attributes":[{"name":"visits","init":0,"description":"Completed clini… |
triageTriage queue |
des.queue |
{"discipline":"fifo"} |
doctorsDoctors |
des.server |
{"serviceTime":{"dist":"exponential","rate":{"expr":"1 / treatmentTime"}},"capacity":{"expr":"effectiveDoctor… |
fatigueDoctor fatigue |
sd.stock |
{"initial":0,"min":0,"max":1} |
tiring |
sd.flow |
{"rate":{"expr":"fatigueGain * doctors.utilization"}} |
resting |
sd.flow |
{"rate":{"expr":"fatigueRecovery * fatigue.value"}} |
effectiveDoctorsEffective doctors |
sd.auxiliary |
{"value":{"expr":"max(1, round(baseDoctors * (1 - 0.5 * fatigue.value)))"}} |
Connections#
patients.out→triage.intriage.out→doctors.indoctors.out→patients.intiring.out→fatigue.infatigue.out→resting.in
Experiments#
| Name | Duration | Replications | Sweep | Outputs |
|---|---|---|---|---|
one-week |
168 warmup 24 | 10 | — | triage.length, doctors.utilization, fatigue.value, patients.inState.sick |
Source#
spec/examples/clinic-hybrid.osdl.json · 7.9 KB
{
"$schema": "https://osdl.dev/schemas/0.1/osdl.schema.json",
"osdl": "0.1",
"model": {
"name": "clinic-hybrid",
"label": "Urgent-Care Clinic (hybrid DES + ABM + SD)",
"description": "All three paradigms in one model. Patients are agents (ABM) who fall sick stochastically and depart into the clinic's discrete-event process (DES): a triage queue feeding doctors. Doctor workload accumulates as fatigue (SD), which feeds back to reduce effective treatment capacity. Treated patients return to the population and eventually recover to a well state.",
"time": {
"unit": "hours",
"integration": {
"method": "euler",
"dt": 0.1
}
},
"parameters": [
{
"name": "nPatients",
"description": "Population size served by the clinic.",
"type": "integer",
"default": 500
},
{
"name": "sicknessRate",
"description": "Hazard of a well person falling sick.",
"default": 0.005,
"unit": "1/hour"
},
{
"name": "treatmentTime",
"description": "Mean treatment duration per patient.",
"default": 0.75,
"unit": "hours"
},
{
"name": "baseDoctors",
"description": "Doctors on shift when fully rested.",
"type": "integer",
"default": 3
},
{
"name": "fatigueGain",
"description": "Fatigue accumulated per hour at full utilization.",
"default": 0.05,
"unit": "1/hour"
},
{
"name": "fatigueRecovery",
"description": "Fractional fatigue recovery per hour.",
"default": 0.1,
"unit": "1/hour"
}
],
"components": [
{
"id": "patients",
"type": "abm.population",
"label": "Patient population",
"params": {
"size": {
"param": "nPatients"
},
"agent": {
"attributes": [
{
"name": "visits",
"init": 0,
"description": "Completed clinic visits."
}
],
"behavior": {
"initial": "well",
"states": [
{
"id": "well"
},
{
"id": "sick",
"description": "Departs to the clinic on entry; remains in this state while queued and treated.",
"onEnter": [
{
"type": "depart",
"port": "out"
}
]
},
{
"id": "recovering"
}
],
"transitions": [
{
"from": "well",
"to": "sick",
"trigger": {
"type": "rate",
"rate": {
"param": "sicknessRate"
}
}
},
{
"from": "sick",
"to": "recovering",
"trigger": {
"type": "arrive",
"port": "in"
},
"actions": [
{
"type": "set",
"target": "visits",
"value": {
"expr": "visits + 1"
}
}
]
},
{
"from": "recovering",
"to": "well",
"trigger": {
"type": "timeout",
"delay": 48
}
}
]
}
}
}
},
{
"id": "triage",
"type": "des.queue",
"label": "Triage queue",
"params": {
"discipline": "fifo"
}
},
{
"id": "doctors",
"type": "des.server",
"label": "Doctors",
"params": {
"serviceTime": {
"dist": "exponential",
"rate": {
"expr": "1 / treatmentTime"
}
},
"capacity": {
"expr": "effectiveDoctors.value"
}
}
},
{
"id": "fatigue",
"type": "sd.stock",
"label": "Doctor fatigue",
"params": {
"initial": 0,
"min": 0,
"max": 1
}
},
{
"id": "tiring",
"type": "sd.flow",
"params": {
"rate": {
"expr": "fatigueGain * doctors.utilization"
}
}
},
{
"id": "resting",
"type": "sd.flow",
"params": {
"rate": {
"expr": "fatigueRecovery * fatigue.value"
}
}
},
{
"id": "effectiveDoctors",
"type": "sd.auxiliary",
"label": "Effective doctors",
"description": "Fatigue feedback: rested doctors work at full capacity; exhausted doctors at half. Never below one doctor.",
"params": {
"value": {
"expr": "max(1, round(baseDoctors * (1 - 0.5 * fatigue.value)))"
}
}
}
],
"connections": [
{
"id": "toTriage",
"from": "patients.out",
"to": "triage.in"
},
{
"id": "toDoctors",
"from": "triage.out",
"to": "doctors.in"
},
{
"id": "discharge",
"from": "doctors.out",
"to": "patients.in"
},
{
"id": "tiringIn",
"from": "tiring.out",
"to": "fatigue.in"
},
{
"id": "restingOut",
"from": "fatigue.out",
"to": "resting.in"
}
]
},
"experiments": [
{
"name": "one-week",
"description": "A week of clinic operation after a day of warmup.",
"duration": 168,
"warmup": 24,
"replications": 10,
"seed": 42,
"outputs": [
{
"path": "triage.length",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "doctors.utilization",
"records": [
{
"type": "summary",
"statistics": [
"mean",
"max"
]
}
]
},
{
"path": "fatigue.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "patients.inState.sick",
"records": [
{
"type": "summary"
}
]
}
]
}
],
"views": [
{
"id": "main",
"label": "Clinic overview",
"type": "canvas",
"elements": [
{
"ref": "patients",
"x": 0,
"y": 100
},
{
"ref": "triage",
"x": 260,
"y": 100
},
{
"ref": "doctors",
"x": 500,
"y": 100
},
{
"ref": "tiring",
"x": 380,
"y": 300
},
{
"ref": "fatigue",
"x": 560,
"y": 300
},
{
"ref": "resting",
"x": 740,
"y": 300
},
{
"ref": "effectiveDoctors",
"x": 560,
"y": 200
}
],
"routes": [
{
"ref": "discharge",
"vertices": [
{
"x": 580,
"y": 40
},
{
"x": 80,
"y": 40
}
]
}
]
}
]
}