Time and units
Recommendation
- Keep Starsim’s time types and their names.
ss.Duration/ss.per/ss.prob/ss.freqas distinct types, each doing its own conversion, is an idea that many frameworks reached independently. The names are kept (a probability per period isss.prob(0.1, 'day')orss.probperday(0.1), a count per unit time isss.freqorss.freqperyear(80), and the one-unit instances aress.daytoss.year); only the class of a duration is spelled out,ss.Duration(it wasss.dur). - Calendar dates wherever they are wanted, and one reading of a bare time. Interventions have dates, data has dates, and converting them to step indices is a recurring source of error, so a sim’s start and stop, the times of interventions, and data can all be dates. Whether a sim has a calendar is decided once, from its start, and every bare time in it is read on that axis (a year, or a time from 0).
- Do not assume a global timestep. Per-module timelines interleaved by absolute time already work.
- Accept a bare number; it takes the sim’s unit. A type system that makes
beta=0.05an error will lose the audience. If nothing in the model has a unit, time is counted in timesteps and no unit is invented. - Print the conversion. odin’s transparency and Starsim’s safety are not in conflict when the library can print what it resolved.
- Check the dimensions of rate expressions when the model is built, with an error like camdl’s, not a shape mismatch.
Why: five independent arrivals
This is finding 2 of the landscape review, and it is as settled as anything in it:
| Framework | Medium | Vocabulary |
|---|---|---|
| Starsim | Python class hierarchy | Duration / per / prob / freq |
| camdl | Compiler dimensional types | rate / probability / count / time |
| Atomica | A spreadsheet column | Probability / Duration / Rate / Number / Proportion |
| EMULSION | Three alternative YAML keys | rate: / proba: / duration: |
| MIRA / Catalyst.jl | SymPy units / @unit_checks |
Symbolic units per concept |
Five media, five communities, one small vocabulary. Atomica, the only tool in the review built for non-programmers, made the same distinction, so it matters to modelers and not only to programmers.
Most frameworks leave this to the modeler, which keeps them simple and leaves conversions unchecked. EpiModel: “everything is per-step, unitless, and unchecked; rates, probabilities, and counts are all numeric. Changing the step size is a manual rescaling exercise.” odin, summer2, epipack, Epydemix, epiworld, individual, LASER, SimInf, EMOD, EpiHiper, flepiMoP, MEmilio: no dimensional types, anywhere.
The specific error this prevents: 0.1 per year could be a hazard, a probability, or an event count, and they convert to a per-timestep quantity by three different formulas (1 - exp(-r·dt), rescaling, r·dt). Mixing them up is a common error that is easy to miss.
A textbook model has no unit
Most of the frameworks surveyed (EpiModel, individual, LASER, epiworld, SimInf, odin, summer2, and epipack) count time in steps or in an unnamed unit, and that is what a textbook model means: beta=0.3 per step, dur=10 steps. Forcing such a model into dates or years adds something its author never said, which is why a model with no unit anywhere counts time in timesteps, and why no default unit is assumed (Starsim 3 assumed years).
Print the conversion, do not make people write it
odin puts dt in scope so the modeler writes the conversion (p_SI <- 1 - exp(-(beta * I / N * dt))). Its review calls this “more transparent and less safe: nothing stops a modeler writing p_SI <- beta * I / N * dt, which is correct only for small dt, and nothing marks beta as a rate rather than a probability.” Starsim makes the conversion the type’s job. Both can be had: the library converts and prints what it resolved, so odin’s transparency comes from the printed model rather than from the source. camdl’s verbosity is “the right trade for correctness and a real trade against approachability”; taking the kind of quantity from the argument slot and printing the result gives most of the correctness without the verbosity.
Per-module timesteps
From the Starsim review: “a within-host progression module can run at dt = 1 day while a demographic module runs at dt = 1 year in the same sim. The Loop interleaves them by absolute time. Nothing else in the landscape does this.” Contrast EpiModel, where the network’s dissolution coefficients are calibrated in time steps, so the network and the epidemic are locked to a common step by construction. epymorph has a different structure again, sub-step scheduling (leaves=TickIndex(step=0), returns=TickDelta(step=1, days=0): leave in the morning, mix at the destination, return in the evening). No sub-step mechanism is added for it: commuting is handled by the mixing (metapopulation.md), and a model that needs a day and a night phase can use a half-day timestep.
Dimension checking catches the missing /N
camdl’s error[E300]: transition 'infection' rate has wrong dimension with rate = ((beta * S) * I) is the missing /N, the check that justifies the whole apparatus. Here the common case cannot go wrong that way, because the user writes S >> I @ beta * I and the mixing supplies the denominator (population-and-mixing.md); a longer expression with typed values is still checked, and the error suggests the fix (error messages are part of the language). Bare numbers cannot be checked this way, which is one reason to type the values in a model that mixes timescales.
The specification
02-types.md specifies the time types (ss.Duration, ss.per, ss.prob, ss.freq, with ss.days to ss.years, ss.perday to ss.peryear, ss.prob(0.1, 'day'), ss.freqperday to ss.freqperyear, ss.day to ss.year, and ss.date), arithmetic between them (meaningless arithmetic and wrong dimensions raise a TypeError naming the two types), bare numbers and where the unit comes from, and time in a sim. Per-module timesteps and the ordered loop are in 10-execution.md. The docs show how to use these in Time.
Trade-offs
ss.perday(0.05)is more to type than0.05. Which is why bare numbers work; the typed form is for when the model spans timescales or the units are not obvious.- Rate-to-probability conversion has corners at large
dt. Starsim’s review flags this, particularly around calendar durations versus float-year arithmetic. The mitigation is a note when a step is large for a transition, where the exponential conversion starts to matter and people’s intuitions stop being reliable. - Per-module timesteps make the execution order harder to reason about. Starsim’s loop is an object that can be printed and plotted (
sim.loop), which helps, but the printout is the fully unrolled list of every call at every time and is hard to read. Sodescribe()shows the loop as a schedule instead: one line for each step size, with the modules in the order they run (schedule every day: randomnet, sir), and the unrolled list issim.loop. - Bare numbers skip the dimension check, and an inferred unit can be the wrong one. Both are handled by printing:
describe()shows every resolved value with its unit, and a unit taken fromdt, the dates, or the typed values is announced when the sim is built. Typed values in different units alongside bare numbers are aTypeErrorasking forunit=, since the unit of the bare numbers would be a guess. - A unitless model cannot be compared with dated data or given a dated intervention. Doing either supplies a unit, at which point the bare numbers that were “per step” become “per day” (or whatever the dates imply), and the note says so.
- Starsim’s time classes are a lot (its review says so). Avoid adding more. If a new one seems necessary, check whether an existing one already covers it.
Rejected
- Unitless steps as the only kind of time (EpiModel,
individual, LASER, epiworld, SimInf). The cost, documented across five reviews, is that rates, probabilities, and counts are all unchecked numbers and changing the step size means rescaling by hand. A unitless model is fine; having no other kind is not. - A default unit when none is given (an earlier draft assumed days; Starsim 3 assumed years). Either one adds something the modeler did not say.
- Making the user write
1 - exp(-r*dt)(odin,individual). Transparent, with no check on the formula; here the transparency comes from the printout. - Units as an optional annotation (SBML’s
unitDefinition, which is in the standard and “widely ignored in practice”). Here the kind of quantity comes from the argument slot and the unit from the sim, so every value ends up with both whether or not the user typed them. - Renaming Starsim’s types (
ss.pertoss.rate, for example). The current names work and existing code uses them. - Guessing the unit from the size of the numbers. A default that depends on the values is harder to predict than one that does not. (One exception is kept from Starsim: a bare
startfrom 1800 on, such asstart=2000, or with no start a barestop, such asstop=2030, unlessunit=says otherwise, is a calendar year, and gives the sim a calendar.) - A sub-step mechanism (epymorph’s tick offsets). See above.
- A general dimensional algebra (mass, length, currency). Epidemiology needs time⁻¹, dimensionless, and counts. Cost per unit of the intervention is a number with a currency label, not a dimension to check.
Open questions
The open questions from this document are in 19-open-questions.md.