Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
baeafb3
Set up stochattic probability-based transitions to allow for measurin…
Rowanmh Oct 11, 2021
4037277
add stochastic_rounding
Rowanmh Oct 12, 2021
425223d
Random seed for parset sampling, random.randn -> default_rng sampling
Rowanmh Nov 5, 2021
e3c6285
default_rng for programs/covouts also
Rowanmh Nov 5, 2021
6cbcb6f
Update project.py
Rowanmh Nov 5, 2021
752154a
Update project.py
Rowanmh Nov 5, 2021
3965276
Pass the same random number generator within a given model run rather…
Rowanmh Nov 7, 2021
ac4e458
Consistent seeding
Rowanmh Nov 7, 2021
1e7c94f
Pass generators within kwargs, and handle that option in parallel_pro…
Rowanmh Nov 7, 2021
930c10e
Optional acceptance criteria for sampled_sims - to ensure that sample…
Rowanmh Nov 7, 2021
0b4e493
Update project.py
Rowanmh Nov 7, 2021
0e3bb45
Acceptance criteria evaluated during runtime or postprocessing as app…
Rowanmh Nov 8, 2021
224c2a6
Merge branch 'develop' into stochastic_transitions
Rowanmh Feb 22, 2023
211a7fd
Fix ResidualJunctionCompartment not applying stochasticity correctly …
kelvinburke Mar 1, 2023
98b7274
Whitespace
Rowanmh Mar 3, 2023
b955a10
Merge pull request #473 from atomicateam/stochastic_transitions_fix
Rowanmh May 10, 2023
3188662
Merge remote-tracking branch 'origin/master' into stochastic_transitions
RomeshA Jun 18, 2023
a545715
Add rng_sampler=None to Programset.sample()
kelvinburke Nov 1, 2023
61ffc2d
Merge branch 'develop' into stochastic_transitions
Rowanmh Nov 1, 2023
da06bbb
Merge branch 'stochastic_transitions' of github.com:atomicateam/atomi…
RomeshA Nov 2, 2023
72f231d
Keep "Output" column in worksheet and don't group by output, just pri…
kelvinburke Nov 2, 2023
76a1dba
Also make sure the required_width of a column fits the column name
kelvinburke Nov 2, 2023
1ee101c
Add "constant" to reserved_keywords
Rowanmh Nov 6, 2023
efcf4c2
Merge remote-tracking branch 'origin/databook_fixes' into stochastic_…
Rowanmh Nov 10, 2023
eb464d0
Merge remote-tracking branch 'origin/dont-group-results-excel' into s…
Rowanmh Nov 10, 2023
70f34cd
Use ax = fig.gca() instead of ax = plt.gca() so that you can have mul…
kelvinburke Nov 24, 2023
02c4cb9
Improve error message when different y-labels
kelvinburke Nov 24, 2023
269f712
Add test_tox_markovchain.py
kelvinburke Apr 11, 2024
c9dfa22
Merge remote-tracking branch 'origin/stochastic_transitions' into sto…
RomeshA Jun 5, 2024
c6d990c
Merge remote-tracking branch 'origin/develop' into stochastic_transit…
RomeshA Jun 5, 2024
9dcb653
Fix tests
RomeshA Jun 7, 2024
e39b780
Fix seed selection
RomeshA Jun 7, 2024
8abb36a
Implement very simple example of test reordering for further use later
RomeshA Jun 7, 2024
2a8bc1e
Formatting pass
RomeshA Jun 7, 2024
3e76bf7
Update to Python 3.12
RomeshA Jun 26, 2024
222ad88
Merge branch 'enable-transfer-calibration' into stochastic_transitions
RomeshA Jun 28, 2024
c2fe4ae
Rename stochastic to multinomial
RomeshA Aug 19, 2024
d1c2223
Prefill interaction tables with Y/N more logically
Rowanmh Sep 2, 2024
c752086
More informative sc.prepr output (useful when debugging)
Rowanmh Sep 2, 2024
59851ff
Fix save divide for pop_aggregation == "weighted" in PlotData
kelvinburke Feb 28, 2025
5e42910
Plotdata.set_colors add option for colors=tuple with (color, opacity)
kelvinburke Feb 28, 2025
86d19a6
plotting.py plot_series add optional kwarg colors=None, which gets pa…
kelvinburke Feb 28, 2025
aab2c7c
ProjectSettings.tvec update to latest function using arange - to matc…
kelvinburke Feb 28, 2025
422ff3d
Merge remote-tracking branch 'origin/develop' into stochastic_transit…
RomeshA Jul 24, 2026
bedcfc8
Merge remote-tracking branch 'origin/main' into stochastic_transitions
RomeshA Jul 24, 2026
b494a89
Merge branch 'main' into stochastic_transitions
RomeshA Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 28 additions & 27 deletions atomica/demos.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,30 @@
from .scenarios import BudgetScenario
from .utils import TimeSeries

__all__ = ["demo", "make_demo_scenarios"]


def demo(which: str = None, do_run: bool = True, addprogs: bool = True) -> Project:
__all__ = ["demo", "demo_projects", "make_demo_scenarios"]


demo_projects = [
"udt",
"udt_dyn",
"usdt",
"cervicalcancer",
"sir",
"diabetes",
"combined",
"service",
"hypertension",
"hypertension_dyn",
"hiv",
"hiv_dyn",
"tb_simple",
"tb_simple_dyn",
"tb",
"dt",
]


def demo(which: str = "sir", do_run: bool = True, addprogs: bool = True) -> Project:
"""
Return a demo project

Expand All @@ -25,30 +45,11 @@

"""

options = [
"udt",
"udt_dyn",
"usdt",
"cervicalcancer",
"sir",
"diabetes",
"combined",
# 'service',
"hypertension",
"hypertension_dyn",
"hiv",
"hiv_dyn",
"tb_simple",
"tb_simple_dyn",
"environment",
"tb",
]

dtdict = sc.odict.fromkeys(options, 1.0)
dtdict = sc.odict.fromkeys(demo_projects, 1.0)
dtdict["tb"] = 0.5

if which is None or which not in options:
raise Exception("Supported project types are:\n%s" % ("\n".join(options)))
if which not in demo_projects:

Check warning on line 51 in atomica/demos.py

View check run for this annotation

Claude / Claude Code Review

demo() raises NameError instead of informative Exception

In `demo()`, the fallback error for an unsupported `which` references the undefined name `demos` instead of `demo_projects` (renamed by this PR). This causes a `NameError` to mask the intended informative exception whenever an invalid project name is passed to `demo()`.
Comment on lines 50 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In demo(), the fallback error for an unsupported which references the undefined name demos instead of demo_projects (renamed by this PR). This causes a NameError to mask the intended informative exception whenever an invalid project name is passed to demo().

Extended reasoning...

The bug: In atomica/demos.py, this PR renames the local options list to a module-level demo_projects list (also newly exported via __all__). The if which not in demo_projects check on line 51 correctly uses the new name, but the exception message on line 52 still refers to the old name via a variable called demos, which has never existed anywhere in the module:

if which not in demo_projects:
    raise Exception("Supported demonstration projects are:\n%s" % ("\n".join(demos)))

How it manifests: Python evaluates function arguments before the function call itself, so "\n".join(demos) is evaluated first. Since demos is not defined in the local scope, module scope, or as a builtin/import, this raises NameError: name 'demos' is not defined — and that NameError propagates instead of the intended Exception("Supported demonstration projects are:...").

Trigger path: Any call to at.demo(which='invalid_name') (or any which not present in demo_projects) hits this line. Nothing upstream validates which before this point — the default value was also changed from None to 'sir' in this PR, but that only affects the default argument; any explicit invalid string still reaches the broken exception line.

Step-by-step proof:

  1. Call at.demo(which='nonexistent').
  2. dtdict = sc.odict.fromkeys(demo_projects, 1.0) executes fine.
  3. if which not in demo_projects: evaluates to True since 'nonexistent' isn't in the list.
  4. Python begins evaluating the raise Exception(...) statement's arguments, starting with "\n".join(demos).
  5. Name resolution for demos fails (not a local, not a global in demos.py, not a builtin) → NameError: name 'demos' is not defined is raised instead of the intended Exception.

Why nothing prevents this: There's no test coverage for the invalid-which error path (existing tests such as tests/test_tox_library.py only exercise valid demo names via at.demo_projects), so the mismatched rename slipped through.

Impact: Limited to the error-reporting path for invalid input — a real exception is still raised (integration/valid-usage code paths are unaffected), but the message is a confusing NameError rather than the helpful list of supported project names, which would be misleading for anyone debugging a typo'd demo name.

Fix: Change demos to demo_projects on that line:

raise Exception("Supported demonstration projects are:\n%s" % ("\n".join(demo_projects)))

raise Exception("Supported demonstration projects are:\n%s" % ("\n".join(demos)))
Comment on lines +51 to +52

framework = LIBRARY_PATH / f"{which}_framework.xlsx"
databook = LIBRARY_PATH / f"{which}_databook.xlsx"
Expand All @@ -61,7 +62,7 @@
if do_run:
P.run_sim(store_results=True)

if addprogs:
if addprogs and progbook.exists():
logger.debug("Loading progbook")
P.load_progbook(progbook)

Expand Down
5 changes: 3 additions & 2 deletions atomica/excel.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,8 @@
raise Exception('Unknown TimeDependentConnections type - must be "transfer" or "interaction"')

def __repr__(self):
return '<TDC %s "%s">' % (self.type.title(), self.code_name)
# return '<TDC %s "%s">' % (self.type.title(), self.code_name)
return sc.prepr(self)

@classmethod
def from_tables(cls, tables: list, interaction_type):
Expand Down Expand Up @@ -859,13 +860,13 @@
from_pop, to_pop = interaction
if not self.enable_diagonal and from_pop == to_pop:
raise Exception("Trying to write a diagonal entry to a table that is not allowed to contain diagonal terms") # This is because data loss will occur if the user adds entries on the diagonal, then writes the table, and then reads it back in
from_idx = self.from_pops.index(from_pop)
to_idx = self.to_pops.index(to_pop)
if boolean_choice:
value = "Y" if value else "N"
value = "Y" if (value.vals or value.assumption) else "N"
content[from_idx, to_idx] = value
Comment on lines 865 to 867

# Write the content

Check failure on line 869 in atomica/excel.py

View check run for this annotation

Claude / Claude Code Review

TDC boolean_choice Y/N check treats zero assumption as falsy, dropping data

In `TimeDependentConnections._write_pop_matrix` (atomica/excel.py:866), the boolean gating cell is now computed as `"Y" if (value.vals or value.assumption) else "N"`, which uses truthiness rather than presence-of-data. A transfer/interaction entry that has an explicit constant value of exactly `0.0` (empty `vals`, `assumption == 0.0`) will be written as `N` instead of `Y`, causing that entry to be silently dropped when the databook is re-read (e.g. after recalculation on open, or on any write→re
Comment on lines 863 to 869

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 In TimeDependentConnections._write_pop_matrix (atomica/excel.py:866), the boolean gating cell is now computed as "Y" if (value.vals or value.assumption) else "N", which uses truthiness rather than presence-of-data. A transfer/interaction entry that has an explicit constant value of exactly 0.0 (empty vals, assumption == 0.0) will be written as N instead of Y, causing that entry to be silently dropped when the databook is re-read (e.g. after recalculation on open, or on any write→read round trip). The fix is to check presence of data (e.g. value.has_data) rather than truthiness, mirroring TimeSeries.has_data (assumption is not None).

Extended reasoning...

The bug: atomica/excel.py:866 was changed from value = "Y" if value else "N" to value = "Y" if (value.vals or value.assumption) else "N". The old code was actually a no-op bug of its own — TimeSeries defines __slots__ with no __bool__/__len__, so any TimeSeries instance is always truthy, meaning every cell was always written "Y". The new code fixes that non-writing issue but introduces a new one: it now uses truthiness of value.vals and value.assumption, rather than checking whether data is actually present. TimeSeries.has_data (the canonical way this check is done elsewhere in the codebase) explicitly uses self.assumption is not None, precisely because 0.0 is a legitimate value that must not be treated as "absent."

Where it manifests: Consider a transfer or interaction TimeSeries that has only a constant, explicitly-entered value of 0.0 (e.g., an interaction weight of 0 between two populations, or a transfer rate of exactly zero) and no time-varying values. In that case value.vals == [] (falsy) and value.assumption == 0.0 (falsy), so value.vals or value.assumption evaluates to False, and the cell is written as "N" instead of "Y".

Why this causes data loss: The Y/N matrix cell acts as a gate for the corresponding time-dependent data row. In write() (~line 703-708), gate_content() wraps the from/to population cells and OR-interaction cell in =IF(gating_cell="Y", ref, "..."), so when the gate is "N", those cells become "..." once Excel recalculates the formulas. On read, from_tables() (~line 560) explicitly skips any row where row[0].value == "...". So a zero-valued transfer/interaction entry that round-trips through a save/open/re-save (or is read back after Excel recalculates) will vanish from self.ts entirely — not just render as zero, but disappear as an entry.

Why nothing else catches this: There is no validation elsewhere that cross-checks the boolean matrix against the underlying TimeSeries presence of data — the boolean cell is the sole source of truth for whether a row is included on read, so a truthiness bug here directly translates into silent data loss with no error or warning.

Step-by-step proof:

  1. Suppose a TDC entry for population pair (A, B) is entered in the databook as a constant value of 0.0 (no time-specific values) — i.e., ts.vals == [], ts.assumption == 0.0.
  2. On _write_pop_matrix(), value.vals or value.assumption[] or 0.00.0, which is falsy in Python, so content[from_idx, to_idx] = "N".
  3. The boolean cell for (A, B) is written as "N" in the spreadsheet.
  4. In write(), the row for (A, B) has its from/to-population cells (and the underlying value cell) wrapped in =IF(A2="Y", ..., "...") referencing that boolean cell.
  5. When Excel opens/recalculates the sheet (or if the workbook is read back without a live Excel recalculation but xlsxwriter's cached formula values reflect the "N" gate), the from-population cell resolves to "...".
  6. On from_tables(), the loop hits if row[0].value == "...": continue (skipping the row), so the (A, B) entry never gets included when reconstructing self.ts.
  7. Net effect: the explicit 0.0 transfer/interaction value the user entered is silently gone after a write→read round trip, whereas a time-varying 0.0 (vals=[0.0]) would survive because a non-empty list is truthy.

Suggested fix: Replace the condition with a presence check, e.g. value.has_data (or equivalently value.vals or value.assumption is not None), matching the semantics already used by TimeSeries.has_data elsewhere in the codebase.

for from_idx in range(0, len(self.from_pops)):
for to_idx in range(0, len(self.to_pops)):
row = start_row + 1 + from_idx
Expand Down
3 changes: 2 additions & 1 deletion atomica/migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,4 +868,5 @@ def _progset_nontargetable_flag(progset):
for d in progset.comps.values():
if 'non_targetable' not in d:
d['non_targetable'] = False
return progset
return progset

187 changes: 156 additions & 31 deletions atomica/model.py

Large diffs are not rendered by default.

15 changes: 8 additions & 7 deletions atomica/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def interpolate(self, tvec, pop_name: str) -> np.array:

return self.ts[pop_name].interpolate(tvec, method=self._interpolation_method)

def sample(self, constant: bool) -> None:
def sample(self, constant: bool, rng_sampler=None) -> None:
"""
Perturb parameter based on uncertainties

Expand All @@ -105,11 +105,11 @@ def sample(self, constant: bool) -> None:

:param constant: If True, time series will be perturbed by a single constant offset. If False,
a different perturbation will be applied to each time specific value independently.

:param rng_sampler: Optional random number generator that may have been seeded to generate consistent results
"""

for k, ts in self.ts.items():
self.ts[k] = ts.sample(constant)
self.ts[k] = ts.sample(constant, rng_sampler)

def smooth(self, tvec, method="smoothinterp", pop_names=None, **kwargs):
"""
Expand Down Expand Up @@ -530,19 +530,20 @@ def get_par(self, name: str, pop: str = None) -> Parameter:

raise KeyError(f'Parameter "{name}" not found')

def sample(self, constant=True):
def sample(self, constant: bool = True, rng_sampler=None):
"""
Return a sampled copy of the ParameterSet

:param constant: If True, time series will be perturbed by a single constant offset. If False,
a different perturbation will be applied to each time specific value independently.
:param rng_sampler: Optional random number generator that may have been seeded to generate consistent results
:return: A new :class:`ParameterSet` with perturbed values

"""

new = sc.dcp(self)
for par in new.all_pars():
par.sample(constant)

for i, par in enumerate(new.all_pars()):
par.sample(constant=constant, rng_sampler=rng_sampler)
return new

def make_constant(self, year: float):
Expand Down
15 changes: 10 additions & 5 deletions atomica/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ def placeholder_pop():
numerator = sum(aggregated_outputs[x][output_name] * weights[x] for x in pop_labels) # Add together all the outputs
denominator = sum([weights[x] for x in pop_labels])
vals = np.divide(numerator, denominator, out=np.full(numerator.shape, np.nan, dtype=float), where=numerator != 0)

else:
raise Exception(f'Unknown population aggregation method "{aggregation}"')

Expand Down Expand Up @@ -1132,7 +1133,9 @@ def set_colors(self, colors=None, results="all", pops="all", outputs="all", over
elif isinstance(colors, list):
assert len(colors) == len(targets), "Number of colors must either be a string, or a list with as many elements as colors to set"
colors = colors
elif colors.startswith("#") or colors not in [m for m in plt.cm.datad if not m.endswith("_r")]:
elif isinstance(colors, tuple) and colors[0].startswith("#"): # tuple with (color, opacity)
colors = [colors for _ in range(len(targets))] # Apply color to all requested outputs
elif isinstance(colors, str) and (colors.startswith("#") or colors not in [m for m in plt.cm.datad if not m.endswith("_r")]):
colors = [colors for _ in range(len(targets))] # Apply color to all requested outputs
Comment on lines +1136 to 1139
else:
color_norm = matplotlib_colors.Normalize(vmin=-1, vmax=len(targets))
Expand Down Expand Up @@ -1658,7 +1661,8 @@ def process_input_stacks(input_stacks, available_items):
return figs


def plot_series(plotdata, plot_type="line", axis=None, data=None, legend_mode=None, lw=None, n_cols: int = None) -> list:
def plot_series(plotdata, plot_type="line", axis=None, data=None, legend_mode=None, lw=None, n_cols: int = None,
colors=None) -> list:
"""
Produce a time series plot

Expand All @@ -1671,6 +1675,7 @@ def plot_series(plotdata, plot_type="line", axis=None, data=None, legend_mode=No
:param lw: override the default line width
:param n_cols: If None (default), separate figures will be created for each axis. If provided, axes will be tiled as subplots in a single figure
window with the requested number of columns
:param colors: Colors to be passed to plotdata.set_colors
:return: A list of newly created Figures

"""
Expand Down Expand Up @@ -1724,7 +1729,7 @@ def _prepare_figures(dim1, dim2, n_cols):
logger.warning("At least one Series has only one timepoint. Series must have at least 2 time points to be rendered as a line - `plot_bars` may be more suitable for such data")

if axis == "results":
plotdata.set_colors(results=plotdata.results.keys())
plotdata.set_colors(colors=colors, results=plotdata.results.keys())

figs, axes = _prepare_figures(plotdata.pops, plotdata.outputs, n_cols)

Expand Down Expand Up @@ -1759,7 +1764,7 @@ def _prepare_figures(dim1, dim2, n_cols):
_render_legend(ax, plot_type)

elif axis == "pops":
plotdata.set_colors(pops=plotdata.pops.keys())
plotdata.set_colors(colors=colors, pops=plotdata.pops.keys())

figs, axes = _prepare_figures(plotdata.results, plotdata.outputs, n_cols)

Expand Down Expand Up @@ -1792,7 +1797,7 @@ def _prepare_figures(dim1, dim2, n_cols):
_render_legend(ax, plot_type)

elif axis == "outputs":
plotdata.set_colors(outputs=plotdata.outputs.keys())
plotdata.set_colors(colors=colors, outputs=plotdata.outputs.keys())

figs, axes = _prepare_figures(plotdata.results, plotdata.pops, n_cols)

Expand Down
22 changes: 13 additions & 9 deletions atomica/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,7 @@

return {(covout.par, covout.pop): covout.get_outcome(prop_coverage) for covout in self.covouts.values()}

def sample(self, constant: bool = True):
def sample(self, constant: bool = True, rng_sampler=None):
"""
Perturb programs based on uncertainties

Expand All @@ -1158,9 +1158,9 @@

new = sc.dcp(self)
for prog in new.programs.values():
prog.sample(constant)
prog.sample(constant, rng_sampler=rng_sampler)
for covout in new.covouts.values():
covout.sample()
covout.sample(rng_sampler=rng_sampler)
return new


Expand Down Expand Up @@ -1221,24 +1221,25 @@
"""
return "/year" not in self.unit_cost.units

def sample(self, constant: bool) -> None:
def sample(self, constant: bool, rng_sampler=None) -> None:
"""
Perturb program values based on uncertainties

Calling this function will perturb the original values based on their uncertainties. The
values will change in-place. Normally, this method would be called by
:meth:`ProgramSet.sample()` which will copy the ``Program`` instance first.

:param constant: If True, time series will be perturbed by a single constant offset. If False,
an different perturbation will be applied to each time specific value independently.
:param rng_sampler: Optional random number generator that may have been seeded to generate consistent results
"""

self.spend_data = self.spend_data.sample(constant)
self.unit_cost = self.unit_cost.sample(constant)
self.capacity_constraint = self.capacity_constraint.sample(constant)
self.capacity_constraint = self.capacity_constraint.sample(constant, rng_sampler)
self.saturation = self.saturation.sample(constant)
self.coverage = self.coverage.sample(constant)
Comment on lines 1237 to 1241

Check failure on line 1242 in atomica/programs.py

View check run for this annotation

Claude / Claude Code Review

Program.sample() does not thread rng_sampler to most TimeSeries fields

In `Program.sample()` (atomica/programs.py:1237-1241), only `capacity_constraint` is sampled with the seeded `rng_sampler`; `spend_data`, `unit_cost`, `saturation`, and `coverage` are called with `.sample(constant)` and no `rng_sampler`, so they silently fall back to the unseeded global `np.random` inside `TimeSeries.sample()`. This defeats the reproducibility goal of this PR for any program with uncertainty on those fields — fix by passing `rng_sampler` to all four calls, matching the `capacity
Comment on lines 1231 to 1242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 In Program.sample() (atomica/programs.py:1237-1241), only capacity_constraint is sampled with the seeded rng_sampler; spend_data, unit_cost, saturation, and coverage are called with .sample(constant) and no rng_sampler, so they silently fall back to the unseeded global np.random inside TimeSeries.sample(). This defeats the reproducibility goal of this PR for any program with uncertainty on those fields — fix by passing rng_sampler to all four calls, matching the capacity_constraint call.

Extended reasoning...

The bug: Program.sample() in atomica/programs.py is supposed to perturb all five of a program's uncertain TimeSeries fields using the caller-supplied rng_sampler so that seeded runs are reproducible. However, only one of the five calls actually forwards rng_sampler:

self.spend_data = self.spend_data.sample(constant)
self.unit_cost = self.unit_cost.sample(constant)
self.capacity_constraint = self.capacity_constraint.sample(constant, rng_sampler)
self.saturation = self.saturation.sample(constant)
self.coverage = self.coverage.sample(constant)

Why it manifests: Looking at TimeSeries.sample() in atomica/utils.py, the signature is sample(self, constant=True, rng_sampler=None), and when rng_sampler is None it falls back with rng_sampler = np.random (the global, unseeded module). So for spend_data, unit_cost, saturation, and coverage, the perturbation draws from whatever global numpy random state happens to exist at call time, rather than the seeded generator that was threaded all the way down from Project.run_sampled_sims(rand_seed=...)_run_sampled_simProgramSet.sample(rng_sampler=...)Program.sample(constant, rng_sampler=...).

Why nothing else catches this: ProgramSet.sample() correctly forwards rng_sampler into prog.sample(constant, rng_sampler=rng_sampler), and Covout.sample() also correctly uses rng_sampler (falling back to np.random only when none is supplied at its own call site, which is expected/documented default behavior at the top-level entry point). The omission is localized specifically to four of the five TimeSeries.sample() calls inside Program.sample() — there's no other code path that re-injects the seed for these fields.

Impact: For any program whose spend_data, unit_cost, saturation, or coverage has an entered sigma (uncertainty), the sampled program values used during a seeded run_sampled_sims will not be reproducible run-to-run, even though the user supplied rand_seed. This silently undermines the core reproducibility guarantee that this PR is introducing — it will not raise an error or crash, so this is likely to go unnoticed until someone tries to reproduce a specific seeded stochastic result and finds it differs.

Step-by-step proof:

  1. User calls proj.run_sampled_sims(parset, progset, ..., rand_seed=42, n_samples=2).
  2. Internally, for each sample, a seeded rng_attempt = np.random.default_rng(seed=rand_seeds[attempts]) is created in _run_sampled_sim (atomica/project.py), and passed as sampled_progset = progset.sample(rng_sampler=rng_attempt).
  3. Inside ProgramSet.sample(), this correctly reaches each Program via prog.sample(constant, rng_sampler=rng_attempt).
  4. Inside Program.sample(), self.capacity_constraint.sample(constant, rng_sampler) correctly uses rng_attempt, so its perturbation is deterministic given the seed.
  5. But self.spend_data.sample(constant) is called with no rng_sampler argument, so inside TimeSeries.sample(), rng_sampler is None and it sets rng_sampler = np.random — the global module-level state. If a program has spend_data.sigma set, its perturbed value depends on the global numpy random state, which varies depending on what other code executed before this call (e.g., other samples/attempts, other library calls) — not solely on rand_seed=42.
  6. Running the exact same run_sampled_sims(rand_seed=42, ...) call tw, in a fresh process each time will reproduce the capacity_constraint perturbations identically, but the spend_data/unit_cost/saturation/coverage perturbations will differ between the two runs whenever any global numpy randomness has been consumed differently beforehand (e.g. due to other sampled attempts, other programs, or any other numpy-using code executed earlier in the process) — breaking the reproducibility contract for exactly the fields most likely to carry uncertainty (spend and unit cost).

Fix: Pass rng_sampler through to all four remaining calls, e.g.:

self.spend_data = self.spend_data.sample(constant, rng_sampler)
self.unit_cost = self.unit_cost.sample(constant, rng_sampler)
self.capacity_constraint = self.capacity_constraint.sample(constant, rng_sampler)
self.saturation = self.saturation.sample(constant, rng_sampler)
self.coverage = self.coverage.sample(constant, rng_sampler)

def __repr__(self):
output = sc.prepr(self)
output += " Program name: %s\n" % self.name
Expand Down Expand Up @@ -1397,25 +1398,28 @@

return len(self.progs)

def sample(self) -> None:
def sample(self, rng_sampler=None) -> None:
"""
Perturb the values entered in the databook

The :class:`Covout` instance is modified in-place. Note that the program outcomes are scalars
that do not vary over time - therefore, :meth:`Covout.sample()` does not have a ``constant``
argument.

:param rng_sampler: Optional random number generator that may have been seeded to generate consistent results
"""

if self.sigma is None:
return

if rng_sampler is None:
rng_sampler = np.random

for k, v in self.progs.items():
self.progs[k] = v + self.sigma * np.random.randn(1)[0]
self.progs[k] = v + self.sigma * rng_sampler.standard_normal(1)[0]
# Perturb the interactions
if self._interactions:
for k, v in self.interactions.items():
self.interactions[k] = v + self.sigma * np.random.randn(1)[0]
self.interactions[k] = v + self.sigma * rng_sampler.standard_normal(1)[0]
tokens = ["%s=%.4f" % ("+".join(k), v) for k, v in self.interactions.items()]
self.imp_interaction = ",".join(tokens)

Expand Down
Loading
Loading