Time¶
How the axis works: its units, the time inside a step, impulses, windows and positions.
Physical units are not in the model¶
A Model records no step length: rates are per step and positions and periods
are step counts. The axis of a model is its independent variable, whatever
quantity it is (time is one case), so the engines never ask which dimension it
has. Model.step_unit is optional; only the unit check and AxisUnits read
it. AxisUnits (context/axis_units.py) converts a value in any unit of the
step dimension to steps on pint, and Model.axis_units() builds it from
step_unit (or step) and the engine of the model. steps returns a float
and snaps a result within 1e-9 of an integer to it; whether a position must
be whole is the engine's check, not the converter's.
The axis owns the window grid: AxisUnits.window_start, window_end,
window_index and window_ends take the width as a span and a unit, as
steps does, and check that it is a positive whole number of steps. The
engine computes the grid in steps (window_bounds, window_index,
window_ends, windows_in of crates/commol-calibration/src/observation.rs)
that windowed_totals, _observed_report and the ticks of a figure read, with
a window of any positive length: the window of an observation of a
differential model need not be whole. No public function takes a width in
steps: a width of n steps is the span n in the step unit.
The rate conversions (rate_from_mean_span, rate_from_probability) ask the
engine: RateMeaning::rate_from_mean_span and rate_from_probability
(crates/commol-core/src/types/engine.rs) hold the formula of each meaning, so
a new engine picks a meaning and Python does not change. The per-step rate
from a mean span is 1 - e^(-1/n): it keeps the survival of the continuous
process at every step, and its mean residence is 1/rate, not n (10.508
steps for n = 10). r = 1/n keeps the mean and loses the survival.
Rule: no formula of a rate per engine nor of a window in Python; ask the engine.
Guards: tests/simulation/test_axis_units.py,
crates/commol-core/src/types/engine.rs (tests a_rate_from_…),
tests/simulation/test_windows.py::test_windows_come_from_the_engine,
tests/suite/test_engine_rules.py::test_only_the_engine_computes_a_window.
An origin puts the axis on the calendar¶
AxisUnits.origin (context/axis_units.py) is the calendar moment of
position 0. It belongs to the axis, so ModelBuilder declares it next to
step_unit, Model.origin keeps it and Model.axis_units() passes it on. On
a Simulation, two runs of one model could read one position as two dates.
The engines never see it: it converts at the edges, as the step unit does.
The origin and every moment are naive (naive_moment): a date is its
midnight, and a moment with a time zone raises ValueError with the way to
convert it. A step has no time zone, so position = (moment - origin) / step
stays exact; converting to UTC would move the end of a day with summer time,
and dropping the zone would lose information.
An origin needs a step unit of time whose length is a whole number of days or
a whole fraction of a day (seconds_per_step). pint defines month and
year with a fixed length (a month is 1/12 of a year), so with an origin on
1 January, moment_of(1) in months would be 31 January 10:30, not
1 February. The test of a whole number of days, or of days per step, rejects
them and every unit made from them without a list of names.
A day or a week ends at the midnight that starts the next one. day_ends and
week_ends give these ends in (start, end], the interval of a window, from
the first midnight after start; the first end can close a partial period.
first_weekday numbers days as date.weekday does and has no default: which
day starts a week is the user's choice. The number of a period is not on the
axis.
plot.series of results labels the ticks with _date_label (the date,
and the time when it is not midnight) when the model has an origin and the
call has no position_label. With no tick_every, it formats the ticks that
matplotlib places, so a long run does not get one label per position.
Rule: a conversion between dates and positions goes through AxisUnits;
an application keeps no origin of its own.
Guards: tests/units/test_calendar_axis.py,
tests/plotting/test_run_figures.py::test_an_axis_with_origin_labels_ticks_with_dates.
One step unit in the unit check¶
The engine converts no value between units. So UnitChecker._step_unit
(context/_model/units.py) uses one step unit: the declared
Model.step_unit, of any single dimension (check_step_unit), or else step,
the default increment of any axis (dimension [step]). The check never infers
the step unit from the parameters: a parameter in 1/meter may be a density,
not a rate along the axis, and inferring only from units of time gives time a
privilege that no other axis has. Without step_unit, a failed check says to
declare it. The check reads the flows the engine plans (transition_flows,
in UnitChecker._collect_unit_errors), so a proportional rate is checked as
its flow, rate × source, and never by a rule of its own in Python.
check_equation_units requires every flow to be equal to
<bin unit>/<step unit>, and every impulse to <bin unit>, not only
convertible to it (utils/equations.py, same_unit).
A unit means what pint defines; there is no list of aliases. A name that
pint does not define becomes the one unit of its own dimension
(define_units). In the check, t (a position) has the step unit and step
(a count) is dimensionless. A number written in a rate and added to,
subtracted from or compared with a quantity is read in its unit, as the engine
reads it (parse_equation_unit); a computed number stays dimensionless.
Rule: never accept a convertible unit as equal. 1/week and 1/day in one
model would read a weekly rate as a daily one.
Guards: tests/units/test_model_units.py::TestConsistentModels
(test_a_proportional_rate_is_checked_as_its_flow),
tests/units/test_time_units.py::TestStepUnit,
tests/units/test_time_units.py::TestAxisUnit,
tests/units/test_time_units.py::TestPositionOnTheAxis.
Time inside a step¶
A differential model integrates in continuous time, but on [n, n + 1) its
rates see step = n: t is continuous, step is the floor of t. A rate that
reads step or a "pulse" series (and so AxisPattern.pulse, pulses,
periodic) is therefore constant on its unit interval. When the right-hand
side reads one of them (CompiledModel::depends_on_step), the engine ends a
piece of integration at every integer, so each jump of the rate is the start of
a piece (breakpoints in crates/commol-differential/src/solve.rs).
A "step_function" or "linear" series reads t
(SeriesParameter::evaluate), so it holds or interpolates from any position.
The engine ends a piece at each of its points (CompiledModel::series_points),
where the series jumps or bends. A difference engine reads t = step, so a
series gives its value at each step, and it rejects a point between integers.
A rate can also jump where t reaches a value it compares t with
(t >= t_on, and so AxisPattern.interval and linear_ramp). The compiler
collects those values when they read only numbers, pi, e and parameters
that do not change during a run (time_thresholds in
crates/commol-model/src/builder.rs), and the engine evaluates them with the
current parameters and ends a piece there. Without that, an adaptive solver
whose rate is 0 before a short window grows its step and steps over the window.
A comparison with the state is a state event, which the engine does not detect.
On a piece [start, end) the right-hand side sees t no larger than the
float just below end (RightHandSide::last_t). A Runge–Kutta step evaluates
the right-hand side at the end of its interval; without the limit, a value that
jumps at end would already act on the last step of the piece.
Whether an expression reads step comes from reads_variable, not from
get_variables: get_variables leaves out the special variables, because it
lists the variables a model must define.
Consequence: a proportional pulse p moves p of its source in a difference
model and 1 − e^(−p) in a differential model. An impulse moves p in both.
The alternative, step = t, makes a pulse step == n true on a set of zero
length, so it would never act.
Guards: tests/simulation/test_differential.py::TestTimeInsideAStep,
crates/commol-differential/src/tests.rs::a_pulse_rate_is_a_constant_rate_on_its_unit_interval,
a_rate_reads_the_step_of_its_unit_interval,
a_rate_that_switches_on_t_acts_only_on_its_window,
a_linear_series_matches_the_integral_of_its_interpolant,
a_step_function_series_holds_from_a_position_between_steps,
crates/commol-model/src/builder.rs::a_comparison_of_t_with_a_fixed_value_is_a_threshold,
tests/axis_patterns/test_pattern_runs.py::TestDifferentialIntegration.
Impulses come before the flows of their step¶
At step n the state jumps first, then the flows of the step run from the new
state, in every engine of the aggregate kind (CompiledModel::apply_impulses;
Units::apply_impulses for the stochastic engines). Every jump of one
step is computed from the state before any of them. The output at n shows the
state after the jump. With the other order, the differential output at n
would show the state before the jump and the difference output the state after
it, so the two engines would disagree at the output steps. A difference model
needs integer impulse steps; a differential model stops its integration at each
impulse step and restarts from the new state, so diffsol needs no event.
Guards: tests/transitions/test_impulses.py::TestJump,
crates/commol-differential/src/tests.rs::an_impulse_moves_its_fraction_at_once_in_both_engines.
Windowed observations¶
An observation at at with window = W is compared with
output(at) - output(at - W), anchored at its own position. The Rust loss and
windowed_totals (api/windows.py) give the same number from one rule
(window_increments), and the predictive reads windows through the observation operator
(One observation operator). Window w
covers (w · W, (w + 1) · W]; window_index places a position with
max(ceil(at / W) − 1, 0), and the observed block of a report (_observed_report in api/reporting.py) calls it.
plot.series of results reads the model of a windowed observation the
same way, through its own window at its own position, never on a grid of
windows from 0: an observation at 10.5 with a window of 7 falls on no window
end of that grid.
Guards: tests/simulation/test_windows.py::TestAgreementWithTheCalibrationLoss (the loss at
the generating parameters is zero),
tests/reporting/test_simulation_report.py::test_observed_report_places_each_observation_by_window_index
tests/plotting/test_band_figures.py::TestPredictiveSeries::test_a_windowed_band_of_one_draw_is_its_windowed_run
and
tests/plotting/test_figures.py::test_each_observed_point_is_read_through_its_own_window_and_position.
The engine owns positions¶
A position on the axis is an f64 in every engine call and a float in every
public Python name (at); a length is window or period; a count of steps
keeps num_steps. The SimulationEngine trait
(crates/commol-core/src/simulation_engine.rs) runs only at positions
(run_at_positions_into_buffer); run(n) runs on the grid the engine builds,
grid(n), and returns it. One rule says which positions each engine accepts
(EngineType::check_position in crates/commol-core/src/types/engine.rs),
and each engine's check_position and the builder read it: an engine whose
rate is per step (DifferenceEquations, StochasticSteps,
EngineType::reports_at_integer_steps) non-negative integers, the others
(DifferentialEquations, StochasticEvents) any finite, non-negative
position. Its message starts with "The check_positions in
crates/commol-calibration/src/calibration_problem.rs), the impulse transition
or series parameter when the model is built or its parameters change
(check_model in crates/commol-model/src/builder.rs, for an engine that
steps only at integers), or the positions and observations of a predictive before any
draw runs (Predictive::new; observation_draws through ForwardModel::with_observations).
Rule: Python passes positions on and shows the engine's message. It never checks whether a model type accepts a position.
Guards: crates/commol-calibration/tests/test_positions.rs,
crates/commol-core/tests/positions.rs::only_the_engines_of_rates_per_step_report_at_integer_steps_alone,
tests/simulation/test_runs.py::test_each_engine_names_itself_when_it_rejects_a_position,
crates/commol-difference/src/simulation.rs::integer_positions_are_accepted_and_others_rejected,
crates/commol-differential/src/tests.rs::a_difference_engine_names_a_series_point_between_steps,
crates/commol-model/tests/schedules.rs::an_engine_that_steps_at_integers_rejects_a_position_between_steps_at_the_check,
tests/building/test_parameters.py::test_a_point_the_engine_cannot_run_is_rejected_at_build_and_update,
tests/calibration/test_observations.py::TestPositionsAndEngines,
crates/commol-inference/tests/test_predictive.rs::positions_the_engine_cannot_run_are_rejected_before_any_draw,
crates/commol-inference/tests/test_predictive.rs::observation_draws_reject_a_position_the_engine_rejects.
Results carry their positions¶
Simulation.run and run_at return SimulationResults
(api/results.py): a dict from output name to values, with the positions in
positions and the rows in rows(). A dict keeps results["A"],
json.dumps and pandas.DataFrame(results) working; a reserved key could clash
with an output name. Windows, reports and plots read a value at a position
with the Rust index_at of an observation (observation.rs, a relative 1e-9), never at a list index, so
a model type that reports between integers fits them. The engines take the
same tolerance (same_position in crates/commol-core/src/position.rs). A
predictive holds the positions it was asked for (Predictive.positions).
Guards: tests/simulation/test_runs.py::TestResults, tests/simulation/test_runs.py::test_a_result_reads_a_position_with_the_tolerance_of_the_engine,
crates/commol-core/tests/positions.rs::same_position_is_relative_to_the_larger_magnitude,
tests/reporting/test_simulation_report.py::TestNonIntegerPositions,
tests/plotting/test_figures.py::TestDifferentialModel.