From 35ad3a31f866475f3d1ed99e586a1734e6c5b57c Mon Sep 17 00:00:00 2001 From: Mitzi Morris Date: Thu, 19 Feb 2026 15:14:36 -0500 Subject: [PATCH 1/7] add day of week effect code and tests --- pyrenew/model/multisignal_model.py | 24 +++ pyrenew/observation/base.py | 30 +++ pyrenew/observation/count_observations.py | 78 +++++++- test/test_observation_counts.py | 226 ++++++++++++++++++++++ 4 files changed, 356 insertions(+), 2 deletions(-) diff --git a/pyrenew/model/multisignal_model.py b/pyrenew/model/multisignal_model.py index d9aad03d..0026309c 100644 --- a/pyrenew/model/multisignal_model.py +++ b/pyrenew/model/multisignal_model.py @@ -131,6 +131,30 @@ def pad_observations( padding = jnp.full(pad_shape, jnp.nan) return jnp.concatenate([padding, obs], axis=axis) + def compute_first_day_dow(self, obs_start_dow: int) -> int: + """ + Compute the day of the week for the start of the shared time axis. + + The shared time axis begins ``n_init`` days before the first + observation. This method converts the known day of the week of + the first observation into the day of the week of the shared + time axis start (element 0), accounting for the initialization + period offset. + + Parameters + ---------- + obs_start_dow : int + Day of the week of the first observation day + (0=Monday, 6=Sunday, ISO convention). + + Returns + ------- + int + Day of the week for element 0 of the shared time axis. + """ + n_init = self.latent.n_initialization_points + return (obs_start_dow - n_init % 7) % 7 + def shift_times(self, times: jnp.ndarray) -> jnp.ndarray: """ Shift time indices from natural coordinates to shared time axis. diff --git a/pyrenew/observation/base.py b/pyrenew/observation/base.py index b87df223..04e1c9fc 100644 --- a/pyrenew/observation/base.py +++ b/pyrenew/observation/base.py @@ -185,6 +185,36 @@ def _validate_pmf( if jnp.any(pmf < 0): raise ValueError(f"{param_name} must have non-negative values") + def _validate_dow_effect( + self, + dow_effect: ArrayLike, + param_name: str, + ) -> None: + """ + Validate a day-of-week effect vector. + + Checks that the vector has exactly 7 non-negative elements + (one per day, 0=Monday through 6=Sunday, ISO convention). + + Parameters + ---------- + dow_effect : ArrayLike + Day-of-week multiplicative effects to validate. + param_name : str + Name of the parameter (for error messages). + + Raises + ------ + ValueError + If shape is not (7,) or any values are negative. + """ + if dow_effect.shape != (7,): + raise ValueError( + f"{param_name} must return shape (7,), got {dow_effect.shape}" + ) + if jnp.any(dow_effect < 0): + raise ValueError(f"{param_name} must have non-negative values") + def _convolve_with_alignment( self, latent_incidence: ArrayLike, diff --git a/pyrenew/observation/count_observations.py b/pyrenew/observation/count_observations.py index 72199f77..8f86d2a1 100644 --- a/pyrenew/observation/count_observations.py +++ b/pyrenew/observation/count_observations.py @@ -11,11 +11,13 @@ import jax.numpy as jnp from jax.typing import ArrayLike +from pyrenew.arrayutils import tile_until_n from pyrenew.convolve import compute_prop_already_reported from pyrenew.metaclass import RandomVariable from pyrenew.observation.base import BaseObservationProcess from pyrenew.observation.noise import CountNoise from pyrenew.observation.types import ObservationSample +from pyrenew.time import validate_dow class _CountBase(BaseObservationProcess): @@ -32,6 +34,7 @@ def __init__( delay_distribution_rv: RandomVariable, noise: CountNoise, right_truncation_rv: RandomVariable | None = None, + day_of_week_rv: RandomVariable | None = None, ) -> None: """ Initialize count observation base. @@ -52,11 +55,22 @@ def __init__( When provided (along with ``right_truncation_offset`` at sample time), predicted counts are scaled down for recent timepoints to account for incomplete reporting. + day_of_week_rv : RandomVariable | None + Optional day-of-week multiplicative effect. Must sample to + shape (7,) with non-negative values, where entry j is the + multiplier for day-of-week j (0=Monday, 6=Sunday, ISO + convention). An effect of 1.0 means no adjustment for that + day. Values summing to 7.0 preserve weekly totals and keep + the ascertainment rate interpretable; other sums rescale + overall predicted counts. When provided (along with + ``first_day_dow`` at sample time), predicted counts are + scaled by a periodic weekly pattern. """ super().__init__(name=name, temporal_pmf_rv=delay_distribution_rv) self.ascertainment_rate_rv = ascertainment_rate_rv self.noise = noise self.right_truncation_rv = right_truncation_rv + self.day_of_week_rv = day_of_week_rv def validate(self) -> None: """ @@ -84,6 +98,10 @@ def validate(self) -> None: rt_pmf = self.right_truncation_rv() self._validate_pmf(rt_pmf, "right_truncation_rv") + if self.day_of_week_rv is not None: + dow_effect = self.day_of_week_rv() + self._validate_dow_effect(dow_effect, "day_of_week_rv") + def lookback_days(self) -> int: """ Return required lookback days for this observation. @@ -191,6 +209,42 @@ def _apply_right_truncation( prop = prop[:, None] return predicted * prop + def _apply_day_of_week( + self, + predicted: ArrayLike, + first_day_dow: int, + ) -> ArrayLike: + """ + Apply day-of-week multiplicative adjustment to predicted counts. + + Tiles a 7-element effect vector across the full time axis, + aligned to the calendar via ``first_day_dow``. NaN values + in the initialization period propagate unchanged (NaN * effect = NaN), + which is correct since masked days are excluded from the likelihood. + + Parameters + ---------- + predicted : ArrayLike + Predicted counts. Shape: (n_timepoints,) or + (n_timepoints, n_subpops). + first_day_dow : int + Day of the week for element 0 of the time axis + (0=Monday, 6=Sunday, ISO convention). + + Returns + ------- + ArrayLike + Adjusted predicted counts, same shape as input. + """ + validate_dow(first_day_dow, "first_day_dow") + dow_effect = self.day_of_week_rv() + n_timepoints = predicted.shape[0] + daily_effect = tile_until_n(dow_effect, n_timepoints, offset=first_day_dow) + self._deterministic("day_of_week_effect", daily_effect) + if predicted.ndim == 2: + daily_effect = daily_effect[:, None] + return predicted * daily_effect + class Counts(_CountBase): """ @@ -231,7 +285,8 @@ def __repr__(self) -> str: f"ascertainment_rate_rv={self.ascertainment_rate_rv!r}, " f"delay_distribution_rv={self.temporal_pmf_rv!r}, " f"noise={self.noise!r}, " - f"right_truncation_rv={self.right_truncation_rv!r})" + f"right_truncation_rv={self.right_truncation_rv!r}, " + f"day_of_week_rv={self.day_of_week_rv!r})" ) def validate_data( @@ -268,6 +323,7 @@ def sample( infections: ArrayLike, obs: ArrayLike | None = None, right_truncation_offset: int | None = None, + first_day_dow: int | None = None, ) -> ObservationSample: """ Sample aggregated counts. @@ -288,6 +344,12 @@ def sample( right_truncation_offset : int | None If provided (and ``right_truncation_rv`` was set at construction), apply right-truncation adjustment to predicted counts. + first_day_dow : int | None + Day of the week for the first timepoint on the shared time + axis (0=Monday, 6=Sunday, ISO convention). Required when + ``day_of_week_rv`` was set at construction. Use + ``model.compute_first_day_dow(obs_start_dow)`` to convert + from the day of the week of the first observation. Returns ------- @@ -296,6 +358,8 @@ def sample( `predicted` (predicted counts before noise, shape: n_total). """ predicted_counts = self._predicted_obs(infections) + if self.day_of_week_rv is not None and first_day_dow is not None: + predicted_counts = self._apply_day_of_week(predicted_counts, first_day_dow) if self.right_truncation_rv is not None and right_truncation_offset is not None: predicted_counts = self._apply_right_truncation( predicted_counts, right_truncation_offset @@ -358,7 +422,8 @@ def __repr__(self) -> str: f"ascertainment_rate_rv={self.ascertainment_rate_rv!r}, " f"delay_distribution_rv={self.temporal_pmf_rv!r}, " f"noise={self.noise!r}, " - f"right_truncation_rv={self.right_truncation_rv!r})" + f"right_truncation_rv={self.right_truncation_rv!r}, " + f"day_of_week_rv={self.day_of_week_rv!r})" ) def infection_resolution(self) -> str: @@ -419,6 +484,7 @@ def sample( subpop_indices: ArrayLike, obs: ArrayLike | None = None, right_truncation_offset: int | None = None, + first_day_dow: int | None = None, ) -> ObservationSample: """ Sample subpopulation-level counts. @@ -443,6 +509,12 @@ def sample( right_truncation_offset : int | None If provided (and ``right_truncation_rv`` was set at construction), apply right-truncation adjustment to predicted counts. + first_day_dow : int | None + Day of the week for the first timepoint on the shared time + axis (0=Monday, 6=Sunday, ISO convention). Required when + ``day_of_week_rv`` was set at construction. Use + ``model.compute_first_day_dow(obs_start_dow)`` to convert + from the day of the week of the first observation. Returns ------- @@ -451,6 +523,8 @@ def sample( `predicted` (predicted counts before noise, shape: n_total x n_subpops). """ predicted_counts = self._predicted_obs(infections) + if self.day_of_week_rv is not None and first_day_dow is not None: + predicted_counts = self._apply_day_of_week(predicted_counts, first_day_dow) if self.right_truncation_rv is not None and right_truncation_offset is not None: predicted_counts = self._apply_right_truncation( predicted_counts, right_truncation_offset diff --git a/test/test_observation_counts.py b/test/test_observation_counts.py index 0190d429..717478e6 100644 --- a/test/test_observation_counts.py +++ b/test/test_observation_counts.py @@ -576,5 +576,231 @@ def test_counts_by_subpop_2d_broadcasting(self): assert jnp.allclose(result.predicted[:, 0], result.predicted[:, 1]) +class TestDayOfWeek: + """Test day-of-week multiplicative adjustment in count observations.""" + + def test_no_dow_rv_unchanged(self, simple_delay_pmf): + """Test that day_of_week_rv=None ignores first_day_dow.""" + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 0.01), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + ) + infections = jnp.ones(20) * 1000 + + with numpyro.handlers.seed(rng_seed=42): + result = process.sample(infections=infections, obs=None, first_day_dow=3) + + assert jnp.allclose(result.predicted, 10.0) + + def test_dow_rv_without_offset_unchanged(self, simple_delay_pmf): + """Test that first_day_dow=None skips adjustment.""" + dow_effect = jnp.array([2.0, 0.5, 0.5, 0.5, 0.5, 1.5, 1.5]) + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 0.01), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones(20) * 1000 + + with numpyro.handlers.seed(rng_seed=42): + result = process.sample(infections=infections, obs=None, first_day_dow=None) + + assert jnp.allclose(result.predicted, 10.0) + + def test_uniform_dow_effect_unchanged(self, simple_delay_pmf): + """Test that uniform effect [1,1,...,1] leaves predictions unchanged.""" + dow_effect = jnp.ones(7) + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones(14) * 100 + + with numpyro.handlers.seed(rng_seed=42): + result = process.sample(infections=infections, obs=None, first_day_dow=0) + + assert jnp.allclose(result.predicted, 100.0) + + def test_dow_effect_scales_predictions(self, simple_delay_pmf): + """Test that known day-of-week effects produce correct per-day scaling. + + With constant infections of 100, ascertainment 1.0, no delay, + and first_day_dow=0 (Monday), element i of predicted should + equal 100 * dow_effect[i % 7]. + """ + dow_effect = jnp.array([2.0, 1.5, 1.0, 1.0, 0.5, 0.5, 0.5]) + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones(14) * 100 + + with numpyro.handlers.seed(rng_seed=42): + result = process.sample(infections=infections, obs=None, first_day_dow=0) + + assert jnp.isclose(result.predicted[0], 200.0) + assert jnp.isclose(result.predicted[1], 150.0) + assert jnp.isclose(result.predicted[4], 50.0) + assert jnp.isclose(result.predicted[7], 200.0) + + def test_dow_offset_shifts_pattern(self, simple_delay_pmf): + """Test that first_day_dow offsets the weekly pattern correctly. + + Starting on Wednesday (dow=2) means element 0 gets + dow_effect[2], element 1 gets dow_effect[3], etc. + """ + dow_effect = jnp.array([2.0, 1.5, 1.0, 0.8, 0.7, 0.5, 0.5]) + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones(7) * 100 + + with numpyro.handlers.seed(rng_seed=42): + result = process.sample(infections=infections, obs=None, first_day_dow=2) + + assert jnp.isclose(result.predicted[0], 100.0) + assert jnp.isclose(result.predicted[1], 80.0) + assert jnp.isclose(result.predicted[5], 200.0) + + def test_deterministic_site_recorded(self, simple_delay_pmf): + """Test that day_of_week_effect deterministic site is recorded.""" + dow_effect = jnp.ones(7) + process = Counts( + name="ed", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones(10) * 100 + + with numpyro.handlers.seed(rng_seed=42): + with numpyro.handlers.trace() as trace: + process.sample(infections=infections, obs=None, first_day_dow=0) + + assert "ed_day_of_week_effect" in trace + effect = trace["ed_day_of_week_effect"]["value"] + assert effect.shape == (10,) + + def test_counts_by_subpop_2d_broadcasting(self): + """Test day-of-week with CountsBySubpop 2D infections.""" + dow_effect = jnp.array([2.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]) + delay_pmf = jnp.array([1.0]) + process = CountsBySubpop( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + + n_days = 14 + n_subpops = 3 + infections = jnp.ones((n_days, n_subpops)) * 100 + times = jnp.array([0, 1, 7]) + subpop_indices = jnp.array([0, 1, 2]) + + with numpyro.handlers.seed(rng_seed=42): + result = process.sample( + infections=infections, + times=times, + subpop_indices=subpop_indices, + obs=None, + first_day_dow=0, + ) + + assert result.predicted.shape == (n_days, n_subpops) + assert jnp.isclose(result.predicted[0, 0], 200.0) + assert jnp.isclose(result.predicted[1, 0], 100.0) + assert jnp.isclose(result.predicted[7, 0], 200.0) + assert jnp.allclose(result.predicted[:, 0], result.predicted[:, 1]) + + def test_dow_with_right_truncation(self, simple_delay_pmf): + """Test that day-of-week and right-truncation compose correctly. + + Day-of-week is applied first, then right-truncation scales + the adjusted predictions. + """ + dow_effect = jnp.array([2.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]) + rt_pmf = jnp.array([0.2, 0.3, 0.5]) + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + right_truncation_rv=DeterministicPMF("rt_delay", rt_pmf), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones(10) * 100 + + with numpyro.handlers.seed(rng_seed=42): + result = process.sample( + infections=infections, + obs=None, + right_truncation_offset=0, + first_day_dow=0, + ) + + assert jnp.isclose(result.predicted[0], 200.0) + assert jnp.isclose(result.predicted[1], 100.0) + assert result.predicted[-1] < result.predicted[0] + + def test_validate_catches_wrong_shape(self, simple_delay_pmf): + """Test that validate() rejects non-length-7 effect vectors.""" + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 0.01), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", jnp.ones(5)), + ) + with pytest.raises(ValueError, match="must return shape \\(7,\\)"): + process.validate() + + def test_validate_catches_negative_values(self, simple_delay_pmf): + """Test that validate() rejects negative effect values.""" + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 0.01), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable( + "dow", jnp.array([1.0, 1.0, 1.0, -0.5, 1.0, 1.0, 1.0]) + ), + ) + with pytest.raises(ValueError, match="must have non-negative values"): + process.validate() + + def test_invalid_first_day_dow_raises(self, simple_delay_pmf): + """Test that out-of-range first_day_dow raises ValueError.""" + dow_effect = jnp.ones(7) + process = Counts( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", simple_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones(14) * 100 + + with numpyro.handlers.seed(rng_seed=42): + with pytest.raises(ValueError, match="Day-of-week"): + process.sample(infections=infections, obs=None, first_day_dow=7) + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From e9e138d883e650ec4bca6e59c4c78cb45bb9ade9 Mon Sep 17 00:00:00 2001 From: Mitzi Morris Date: Thu, 19 Feb 2026 15:39:26 -0500 Subject: [PATCH 2/7] adding tutorial --- docs/tutorials/.pages | 1 + docs/tutorials/day_of_week_effects.qmd | 414 +++++++++++++++++++++++++ test/test_observation_counts.py | 36 +++ 3 files changed, 451 insertions(+) create mode 100644 docs/tutorials/day_of_week_effects.qmd diff --git a/docs/tutorials/.pages b/docs/tutorials/.pages index 0d261a7a..e615fc3e 100644 --- a/docs/tutorials/.pages +++ b/docs/tutorials/.pages @@ -5,4 +5,5 @@ nav: - observation_processes_measurements.md - latent_hierarchical_infections.md - right_truncation.md + - day_of_week_effects.md - periodic_effects.md diff --git a/docs/tutorials/day_of_week_effects.qmd b/docs/tutorials/day_of_week_effects.qmd new file mode 100644 index 00000000..3a9aa9ff --- /dev/null +++ b/docs/tutorials/day_of_week_effects.qmd @@ -0,0 +1,414 @@ +--- +title: Day-of-week effects for count data +format: + gfm: + code-fold: true +engine: jupyter +jupyter: + jupytext: + text_representation: + extension: .qmd + format_name: quarto + format_version: '1.0' + jupytext_version: 1.18.1 + kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +```{python} +# | label: setup +# | output: false +import jax.numpy as jnp +import numpy as np +import numpyro +import pandas as pd + +from pyrenew.observation import Counts, NegativeBinomialNoise, PoissonNoise +from pyrenew.deterministic import DeterministicVariable, DeterministicPMF +from pyrenew import datasets + +import plotnine as p9 +from plotnine.exceptions import PlotnineWarning +import warnings + +warnings.filterwarnings("ignore", category=PlotnineWarning) + +from _tutorial_theme import theme_tutorial +``` + +Many health surveillance signals exhibit strong day-of-week patterns. +Emergency department visits and hospital admissions tend to be higher on weekdays and lower on weekends, driven by staffing, patient behavior, and reporting practices. +Ignoring this weekly periodicity forces the noise model to absorb systematic variation, inflating dispersion estimates and obscuring the underlying epidemic trend. + +PyRenew models day-of-week effects as a **multiplicative adjustment** applied to predicted counts after the delay convolution and ascertainment scaling: + +$$\lambda(t) = d_{w(t)} \cdot \alpha \sum_{s} I(t-s)\,\pi(s)$$ + +where $d_{w(t)}$ is the day-of-week multiplier for the weekday of timepoint $t$, $\alpha$ is the ascertainment rate, and $\pi(s)$ is the delay PMF. +The effect vector $\mathbf{d} = (d_0, d_1, \ldots, d_6)$ has one entry per day (0=Monday through 6=Sunday, ISO convention). +An effect of 1.0 means no adjustment for that day. +When the effects sum to 7.0, the average daily multiplier is 1.0, preserving weekly totals and keeping the ascertainment rate directly interpretable as the fraction of infections observed. + +## Defining a day-of-week effect + +A typical pattern for ED visits might show weekday effects above 1.0 and weekend effects below 1.0: + +```{python} +# | label: define-dow-effect +dow_values = jnp.array([1.20, 1.15, 1.10, 1.05, 1.00, 0.75, 0.75]) +day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + +print(f"Day-of-week effects: {np.round(np.array(dow_values), 2)}") +print(f"Sum: {float(jnp.sum(dow_values)):.2f}") +``` + +```{python} +# | label: plot-dow-effect +dow_df = pd.DataFrame({"day": day_names, "effect": np.array(dow_values)}) +dow_df["day"] = pd.Categorical( + dow_df["day"], categories=day_names, ordered=True +) + +( + p9.ggplot(dow_df, p9.aes(x="day", y="effect")) + + p9.geom_col(fill="steelblue", alpha=0.7, color="black") + + p9.geom_hline(yintercept=1.0, linetype="dashed", color="grey") + + p9.labs( + x="Day of Week", + y="Multiplicative Effect", + title="Day-of-Week Effect Vector", + ) + + theme_tutorial +) +``` + +Values above the dashed line (1.0) increase predicted counts for that day; values below decrease them. +Monday at 1.20 means 20% more counts than an average day; Saturday and Sunday at 0.75 mean 25% fewer. + +## Observation process with and without day-of-week effects + +We construct two `Counts` observation processes using the same delay distribution and ascertainment rate. +The only difference is whether `day_of_week_rv` is provided. + +```{python} +# | label: create-processes +hosp_delay_pmf = jnp.array( + datasets.load_infection_admission_interval()["probability_mass"].to_numpy() +) +delay_rv = DeterministicPMF("inf_to_hosp_delay", hosp_delay_pmf) +ihr_rv = DeterministicVariable("ihr", 0.01) +concentration_rv = DeterministicVariable("concentration", 20.0) + +process_no_dow = Counts( + name="hosp_no_dow", + ascertainment_rate_rv=ihr_rv, + delay_distribution_rv=delay_rv, + noise=NegativeBinomialNoise(concentration_rv), +) + +process_with_dow = Counts( + name="hosp_dow", + ascertainment_rate_rv=ihr_rv, + delay_distribution_rv=delay_rv, + noise=NegativeBinomialNoise(concentration_rv), + day_of_week_rv=DeterministicVariable("dow_effect", dow_values), +) +``` + +We simulate a growing epidemic and generate predicted counts from both processes. +The `first_day_dow` parameter tells PyRenew which day of the week corresponds to element 0 of the time axis. +Here we set `first_day_dow=0` (Monday). + +```{python} +# | label: simulate-and-sample +day_one = process_no_dow.lookback_days() +n_total = 130 +infections = 5000.0 * jnp.exp(0.03 * jnp.arange(n_total)) + +with numpyro.handlers.seed(rng_seed=0): + result_no_dow = process_no_dow.sample(infections=infections, obs=None) +with numpyro.handlers.seed(rng_seed=0): + result_with_dow = process_with_dow.sample( + infections=infections, obs=None, first_day_dow=0 + ) +``` + +```{python} +# | label: plot-predicted-comparison +n_plot_days = n_total - day_one +pred_rows = [] +for i in range(n_plot_days): + day_idx = day_one + i + pred_rows.append( + { + "day": i, + "admissions": float(result_no_dow.predicted[day_idx]), + "type": "No day-of-week effect", + } + ) + pred_rows.append( + { + "day": i, + "admissions": float(result_with_dow.predicted[day_idx]), + "type": "With day-of-week effect", + } + ) +pred_df = pd.DataFrame(pred_rows) +pred_df["type"] = pd.Categorical( + pred_df["type"], + categories=["No day-of-week effect", "With day-of-week effect"], + ordered=True, +) + +( + p9.ggplot( + pred_df, p9.aes(x="day", y="admissions", color="type", linetype="type") + ) + + p9.geom_line(size=1) + + p9.scale_color_manual(values=["steelblue", "#e41a1c"]) + + p9.scale_linetype_manual(values=["solid", "dashed"]) + + p9.labs( + x="Day", + y="Predicted Admissions", + title="Predicted Admissions:\nWith vs. Without Day-of-Week Effect", + color="", + linetype="", + ) + + theme_tutorial +) +``` + +Without the day-of-week effect the predicted curve is smooth. +With it, the curve oscillates with a 7-day period — dipping on weekends and rising on weekdays — while following the same overall trend. + +## Effect of the offset + +The `first_day_dow` parameter aligns the weekly pattern to the calendar. +Changing it shifts which days receive which multiplier. +Here we compare starting on Monday vs. Wednesday: + +```{python} +# | label: offset-comparison +with numpyro.handlers.seed(rng_seed=0): + result_monday = process_with_dow.sample( + infections=infections, obs=None, first_day_dow=0 + ) +with numpyro.handlers.seed(rng_seed=0): + result_wednesday = process_with_dow.sample( + infections=infections, obs=None, first_day_dow=2 + ) +``` + +```{python} +# | label: plot-offset-comparison +offset_rows = [] +for i in range(21): + day_idx = day_one + i + offset_rows.append( + { + "day": i, + "admissions": float(result_monday.predicted[day_idx]), + "offset": "first_day_dow=0 (Monday)", + } + ) + offset_rows.append( + { + "day": i, + "admissions": float(result_wednesday.predicted[day_idx]), + "offset": "first_day_dow=2 (Wednesday)", + } + ) +offset_df = pd.DataFrame(offset_rows) +offset_df["offset"] = pd.Categorical( + offset_df["offset"], + categories=[ + "first_day_dow=0 (Monday)", + "first_day_dow=2 (Wednesday)", + ], + ordered=True, +) + +( + p9.ggplot( + offset_df, + p9.aes(x="day", y="admissions", color="offset"), + ) + + p9.geom_line(size=1) + + p9.geom_point(size=2) + + p9.scale_color_manual(values=["steelblue", "#e41a1c"]) + + p9.labs( + x="Day", + y="Predicted Admissions", + title="Effect of first_day_dow on Weekly Pattern Alignment", + color="", + ) + + theme_tutorial +) +``` + +The two curves have the same shape but are phase-shifted: their weekend dips fall on different days. +Getting `first_day_dow` right matters — a misaligned offset would attribute Monday's high to Sunday or vice versa. + +When using `MultiSignalModel`, the shared time axis starts `n_init` days before the first observation. +The convenience method `model.compute_first_day_dow(obs_start_dow)` converts the known day of the week of the first observation to the correct offset for element 0 of the time axis. + +## Sampled observations + +Day-of-week effects shape the noise draws, not just the predicted means. +The noise model samples from a distribution centered on the adjusted predictions, so sampled observations inherit the weekly pattern. + +```{python} +# | label: sample-noisy +n_samples = 30 +noisy_results = [] +for seed in range(n_samples): + with numpyro.handlers.seed(rng_seed=seed): + result_no = process_no_dow.sample(infections=infections, obs=None) + with numpyro.handlers.seed(rng_seed=seed): + result_yes = process_with_dow.sample( + infections=infections, obs=None, first_day_dow=0 + ) + for i in range(n_plot_days): + day_idx = day_one + i + noisy_results.append( + { + "day": i, + "admissions": float(result_no.observed[day_idx]), + "type": "No day-of-week effect", + "sample": seed, + } + ) + noisy_results.append( + { + "day": i, + "admissions": float(result_yes.observed[day_idx]), + "type": "With day-of-week effect", + "sample": seed, + } + ) +``` + +```{python} +# | label: plot-noisy +noisy_df = pd.DataFrame(noisy_results) +mean_df = noisy_df.groupby(["day", "type"])["admissions"].mean().reset_index() + +( + p9.ggplot(noisy_df, p9.aes(x="day", y="admissions")) + + p9.geom_line( + p9.aes(group="sample"), alpha=0.15, size=0.4, color="steelblue" + ) + + p9.geom_line( + data=mean_df, + mapping=p9.aes(x="day", y="admissions"), + color="#e41a1c", + size=1.2, + ) + + p9.facet_wrap("~ type", ncol=1) + + p9.labs( + x="Day", + y="Hospital Admissions", + title="Sampled Observations:\nWith vs. Without Day-of-Week Effect", + ) + + theme_tutorial +) +``` + +The top panel shows smooth variation around the trend. +The bottom panel shows systematic weekly oscillation in both the mean (red) and individual samples (blue) — the weekend dips are visible even through the noise. + +## Composing with right-truncation + +Day-of-week effects and right-truncation are independent adjustments that compose naturally. +Day-of-week is applied first (adjusting the expected counts for reporting patterns), then right-truncation scales down recent counts for incomplete reporting: + +$$\lambda(t) = F(k_t) \cdot d_{w(t)} \cdot \alpha \sum_s I(t-s)\,\pi(s)$$ + +```{python} +# | label: compose-with-truncation +reporting_delay_pmf = jnp.array([0.4, 0.3, 0.15, 0.08, 0.04, 0.02, 0.01]) + +process_both = Counts( + name="hosp_both", + ascertainment_rate_rv=ihr_rv, + delay_distribution_rv=delay_rv, + noise=NegativeBinomialNoise(concentration_rv), + day_of_week_rv=DeterministicVariable("dow_effect", dow_values), + right_truncation_rv=DeterministicPMF( + "reporting_delay", reporting_delay_pmf + ), +) + +with numpyro.handlers.seed(rng_seed=0): + result_both = process_both.sample( + infections=infections, + obs=None, + first_day_dow=0, + right_truncation_offset=0, + ) +``` + +```{python} +# | label: plot-composed +compose_rows = [] +for i in range(n_plot_days): + day_idx = day_one + i + compose_rows.append( + { + "day": i, + "admissions": float(result_with_dow.predicted[day_idx]), + "type": "Day-of-week only", + } + ) + compose_rows.append( + { + "day": i, + "admissions": float(result_both.predicted[day_idx]), + "type": "Day-of-week + right-truncation", + } + ) +compose_df = pd.DataFrame(compose_rows) +compose_df["type"] = pd.Categorical( + compose_df["type"], + categories=["Day-of-week only", "Day-of-week + right-truncation"], + ordered=True, +) + +( + p9.ggplot( + compose_df, + p9.aes(x="day", y="admissions", color="type", linetype="type"), + ) + + p9.geom_line(size=1) + + p9.scale_color_manual(values=["steelblue", "#e41a1c"]) + + p9.scale_linetype_manual(values=["solid", "dashed"]) + + p9.labs( + x="Day", + y="Predicted Admissions", + title="Day-of-Week Effect Composed with Right-Truncation", + color="", + linetype="", + ) + + theme_tutorial +) +``` + +The two curves agree in the early period. +Near the right edge, right-truncation pulls the curve downward on top of the weekly oscillation. +Each adjustment operates on its own concern — weekly reporting patterns vs. incomplete recent data — and they combine multiplicatively without interfering. + +## Summary + +Day-of-week adjustment is enabled by passing a `day_of_week_rv` at construction time and a `first_day_dow` at sample time. + +| Parameter | Where | Purpose | +|-----------|-------|---------| +| `day_of_week_rv` | Constructor | 7-element multiplicative effect vector (0=Mon, 6=Sun) | +| `first_day_dow` | `sample()` | Day of the week for element 0 of the time axis | + +When either is `None`, the adjustment is disabled and the process behaves identically to one without day-of-week effects. + +The effect vector can be supplied as a fixed `DeterministicVariable` from empirical data, or as a stochastic `RandomVariable` (e.g., a scaled Dirichlet prior) to infer the weekly pattern from data. +Effects summing to 7.0 preserve weekly totals and keep the ascertainment rate interpretable; other sums rescale overall predicted counts. diff --git a/test/test_observation_counts.py b/test/test_observation_counts.py index 717478e6..c627aa67 100644 --- a/test/test_observation_counts.py +++ b/test/test_observation_counts.py @@ -653,6 +653,42 @@ def test_dow_effect_scales_predictions(self, simple_delay_pmf): assert jnp.isclose(result.predicted[4], 50.0) assert jnp.isclose(result.predicted[7], 200.0) + def test_dow_effect_with_multiday_delay(self, short_delay_pmf): + """Test that DOW ratios are correct with a multi-day delay PMF. + + With a 2-day delay, the first element is NaN (init period). + Post-init predicted values should satisfy: + predicted_with_dow[t] / predicted_no_dow[t] == dow_effect[t % 7]. + """ + dow_effect = jnp.array([2.0, 1.5, 1.0, 1.0, 0.5, 0.5, 0.5]) + process_no_dow = Counts( + name="base", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", short_delay_pmf), + noise=PoissonNoise(), + ) + process_with_dow = Counts( + name="dow", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", short_delay_pmf), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones(21) * 100 + + with numpyro.handlers.seed(rng_seed=42): + result_no = process_no_dow.sample(infections=infections, obs=None) + with numpyro.handlers.seed(rng_seed=42): + result_yes = process_with_dow.sample( + infections=infections, obs=None, first_day_dow=0 + ) + + day_one = 1 + for t in range(day_one, 14): + expected_ratio = float(dow_effect[t % 7]) + actual_ratio = float(result_yes.predicted[t] / result_no.predicted[t]) + assert jnp.isclose(actual_ratio, expected_ratio, atol=1e-5) + def test_dow_offset_shifts_pattern(self, simple_delay_pmf): """Test that first_day_dow offsets the weekly pattern correctly. From 3c28f76ca70370b8a25b448a4eeb4ccd0da80271 Mon Sep 17 00:00:00 2001 From: Mitzi Morris Date: Tue, 24 Feb 2026 15:52:21 -0500 Subject: [PATCH 3/7] more unit tests --- test/test_pyrenew_builder.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/test_pyrenew_builder.py b/test/test_pyrenew_builder.py index 885cff83..0ae1c5a9 100644 --- a/test/test_pyrenew_builder.py +++ b/test/test_pyrenew_builder.py @@ -414,6 +414,21 @@ def test_pad_observations_prepends_nans(self, simple_builder): # Integer input should be converted to float assert jnp.issubdtype(padded.dtype, jnp.floating) + @pytest.mark.parametrize( + "obs_start_dow, expected", + [ + (0, (0 - 3 % 7) % 7), + (3, (3 - 3 % 7) % 7), + (6, (6 - 3 % 7) % 7), + ], + ) + def test_compute_first_day_dow( + self, simple_builder, obs_start_dow, expected + ): + """Test that compute_first_day_dow offsets by n_initialization_points.""" + model = simple_builder.build() + assert model.compute_first_day_dow(obs_start_dow) == expected + def test_shift_times_adds_offset(self, simple_builder): """Test that shift_times shifts by n_initialization_points.""" model = simple_builder.build() From 9f85da5f89d6763dc196c9944065b9f012fa6718 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:52:49 +0000 Subject: [PATCH 4/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_pyrenew_builder.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/test_pyrenew_builder.py b/test/test_pyrenew_builder.py index 0ae1c5a9..6540fdb3 100644 --- a/test/test_pyrenew_builder.py +++ b/test/test_pyrenew_builder.py @@ -422,9 +422,7 @@ def test_pad_observations_prepends_nans(self, simple_builder): (6, (6 - 3 % 7) % 7), ], ) - def test_compute_first_day_dow( - self, simple_builder, obs_start_dow, expected - ): + def test_compute_first_day_dow(self, simple_builder, obs_start_dow, expected): """Test that compute_first_day_dow offsets by n_initialization_points.""" model = simple_builder.build() assert model.compute_first_day_dow(obs_start_dow) == expected From 0245f834aa9069c6fcc5abaaf1b04cd1c7591d83 Mon Sep 17 00:00:00 2001 From: Mitzi Morris Date: Wed, 25 Feb 2026 22:43:08 -0500 Subject: [PATCH 5/7] changes per code review --- pyrenew/model/multisignal_model.py | 2 +- pyrenew/observation/count_observations.py | 20 ++++++++++----- pyrenew/time.py | 31 +++++++++++++++++++++++ test/test_observation_counts.py | 9 +++---- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/pyrenew/model/multisignal_model.py b/pyrenew/model/multisignal_model.py index 9a32d1dc..dcd50fc2 100644 --- a/pyrenew/model/multisignal_model.py +++ b/pyrenew/model/multisignal_model.py @@ -152,7 +152,7 @@ def compute_first_day_dow(self, obs_start_dow: int) -> int: Day of the week for element 0 of the shared time axis. """ n_init = self.latent.n_initialization_points - return (obs_start_dow - n_init % 7) % 7 + return (obs_start_dow - n_init) % 7 def shift_times(self, times: jnp.ndarray) -> jnp.ndarray: """ diff --git a/pyrenew/observation/count_observations.py b/pyrenew/observation/count_observations.py index b585b7ab..a05c2fc5 100644 --- a/pyrenew/observation/count_observations.py +++ b/pyrenew/observation/count_observations.py @@ -11,13 +11,12 @@ import jax.numpy as jnp from jax.typing import ArrayLike -from pyrenew.arrayutils import tile_until_n from pyrenew.convolve import compute_prop_already_reported from pyrenew.metaclass import RandomVariable from pyrenew.observation.base import BaseObservationProcess from pyrenew.observation.noise import CountNoise from pyrenew.observation.types import ObservationSample -from pyrenew.time import validate_dow +from pyrenew.time import get_sequential_day_of_week_indices class _CountBase(BaseObservationProcess): @@ -236,10 +235,11 @@ def _apply_day_of_week( ArrayLike Adjusted predicted counts, same shape as input. """ - validate_dow(first_day_dow, "first_day_dow") dow_effect = self.day_of_week_rv() n_timepoints = predicted.shape[0] - daily_effect = tile_until_n(dow_effect, n_timepoints, offset=first_day_dow) + daily_effect = dow_effect[ + get_sequential_day_of_week_indices(first_day_dow, n_timepoints) + ] self._deterministic("day_of_week_effect", daily_effect) if predicted.ndim == 2: daily_effect = daily_effect[:, None] @@ -358,7 +358,11 @@ def sample( `predicted` (predicted counts before noise, shape: n_total). """ predicted_counts = self._predicted_obs(infections) - if self.day_of_week_rv is not None and first_day_dow is not None: + if self.day_of_week_rv is not None: + if first_day_dow is None: + raise ValueError( + "first_day_dow is required when day_of_week_rv is set." + ) predicted_counts = self._apply_day_of_week(predicted_counts, first_day_dow) if self.right_truncation_rv is not None and right_truncation_offset is not None: predicted_counts = self._apply_right_truncation( @@ -523,7 +527,11 @@ def sample( `predicted` (predicted counts before noise, shape: n_total x n_subpops). """ predicted_counts = self._predicted_obs(infections) - if self.day_of_week_rv is not None and first_day_dow is not None: + if self.day_of_week_rv is not None: + if first_day_dow is None: + raise ValueError( + "first_day_dow is required when day_of_week_rv is set." + ) predicted_counts = self._apply_day_of_week(predicted_counts, first_day_dow) if self.right_truncation_rv is not None and right_truncation_offset is not None: predicted_counts = self._apply_right_truncation( diff --git a/pyrenew/time.py b/pyrenew/time.py index 70803597..8d9357c3 100644 --- a/pyrenew/time.py +++ b/pyrenew/time.py @@ -53,6 +53,37 @@ def validate_dow(day_of_week: int, variable_name: str) -> None: return None +def get_sequential_day_of_week_indices( + first_day_dow: int, n_timepoints: int +) -> jnp.ndarray: + """ + Return day-of-week indices for a sequence of consecutive days. + + Parameters + ---------- + first_day_dow + Day of the week for the first timepoint + (0=Monday, 6=Sunday, ISO convention). + n_timepoints + Number of consecutive days. + + Returns + ------- + jnp.ndarray + Array of shape (n_timepoints,) with values in {0, ..., 6}. + + Raises + ------ + ValueError + If ``first_day_dow`` is not in {0, ..., 6} or + ``n_timepoints`` is negative. + """ + validate_dow(first_day_dow, "first_day_dow") + if n_timepoints < 0: + raise ValueError("n_timepoints must be non-negative.") + return jnp.remainder(jnp.arange(n_timepoints) + first_day_dow, 7) + + def convert_date(date: dt.datetime | dt.date | np.datetime64) -> dt.date: """Normalize a date-like object to a python ``datetime.date``. diff --git a/test/test_observation_counts.py b/test/test_observation_counts.py index c627aa67..687a7ce6 100644 --- a/test/test_observation_counts.py +++ b/test/test_observation_counts.py @@ -594,8 +594,8 @@ def test_no_dow_rv_unchanged(self, simple_delay_pmf): assert jnp.allclose(result.predicted, 10.0) - def test_dow_rv_without_offset_unchanged(self, simple_delay_pmf): - """Test that first_day_dow=None skips adjustment.""" + def test_dow_rv_without_offset_raises(self, simple_delay_pmf): + """Test that first_day_dow=None raises when day_of_week_rv is set.""" dow_effect = jnp.array([2.0, 0.5, 0.5, 0.5, 0.5, 1.5, 1.5]) process = Counts( name="test", @@ -607,9 +607,8 @@ def test_dow_rv_without_offset_unchanged(self, simple_delay_pmf): infections = jnp.ones(20) * 1000 with numpyro.handlers.seed(rng_seed=42): - result = process.sample(infections=infections, obs=None, first_day_dow=None) - - assert jnp.allclose(result.predicted, 10.0) + with pytest.raises(ValueError, match="first_day_dow is required"): + process.sample(infections=infections, obs=None, first_day_dow=None) def test_uniform_dow_effect_unchanged(self, simple_delay_pmf): """Test that uniform effect [1,1,...,1] leaves predictions unchanged.""" From 43dd10938d4d8eabf7816daed76b73fee9e951ca Mon Sep 17 00:00:00 2001 From: Mitzi Morris Date: Wed, 25 Feb 2026 23:01:34 -0500 Subject: [PATCH 6/7] changes per code reivew; more unit tests --- pyrenew/observation/count_observations.py | 2 +- test/test_observation_counts.py | 26 +++++++++++++++++- test/test_time.py | 32 +++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/pyrenew/observation/count_observations.py b/pyrenew/observation/count_observations.py index a05c2fc5..80026061 100644 --- a/pyrenew/observation/count_observations.py +++ b/pyrenew/observation/count_observations.py @@ -236,11 +236,11 @@ def _apply_day_of_week( Adjusted predicted counts, same shape as input. """ dow_effect = self.day_of_week_rv() + self._deterministic("day_of_week_effect", dow_effect) n_timepoints = predicted.shape[0] daily_effect = dow_effect[ get_sequential_day_of_week_indices(first_day_dow, n_timepoints) ] - self._deterministic("day_of_week_effect", daily_effect) if predicted.ndim == 2: daily_effect = daily_effect[:, None] return predicted * daily_effect diff --git a/test/test_observation_counts.py b/test/test_observation_counts.py index 687a7ce6..b65db304 100644 --- a/test/test_observation_counts.py +++ b/test/test_observation_counts.py @@ -729,7 +729,7 @@ def test_deterministic_site_recorded(self, simple_delay_pmf): assert "ed_day_of_week_effect" in trace effect = trace["ed_day_of_week_effect"]["value"] - assert effect.shape == (10,) + assert effect.shape == (7,) def test_counts_by_subpop_2d_broadcasting(self): """Test day-of-week with CountsBySubpop 2D infections.""" @@ -836,6 +836,30 @@ def test_invalid_first_day_dow_raises(self, simple_delay_pmf): with pytest.raises(ValueError, match="Day-of-week"): process.sample(infections=infections, obs=None, first_day_dow=7) + def test_counts_by_subpop_dow_without_offset_raises(self): + """Test that first_day_dow=None raises for CountsBySubpop with day_of_week_rv.""" + dow_effect = jnp.ones(7) + process = CountsBySubpop( + name="test", + ascertainment_rate_rv=DeterministicVariable("ihr", 1.0), + delay_distribution_rv=DeterministicPMF("delay", jnp.array([1.0])), + noise=PoissonNoise(), + day_of_week_rv=DeterministicVariable("dow", dow_effect), + ) + infections = jnp.ones((14, 2)) * 100 + times = jnp.array([0, 1]) + subpop_indices = jnp.array([0, 1]) + + with numpyro.handlers.seed(rng_seed=42): + with pytest.raises(ValueError, match="first_day_dow is required"): + process.sample( + infections=infections, + times=times, + subpop_indices=subpop_indices, + obs=None, + first_day_dow=None, + ) + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/test/test_time.py b/test/test_time.py index 8ca09525..a25bcd82 100644 --- a/test/test_time.py +++ b/test/test_time.py @@ -144,6 +144,38 @@ def test_align_observation_times_and_first_week(): assert ptime.get_first_week_on_or_after_t0(-1) >= 0 +def test_get_sequential_day_of_week_indices_basic() -> None: + """Test that get_sequential_day_of_week_indices returns correct cycling indices.""" + result = ptime.get_sequential_day_of_week_indices(0, 10) + expected = jnp.array([0, 1, 2, 3, 4, 5, 6, 0, 1, 2]) + assert jnp.array_equal(result, expected) + + +def test_get_sequential_day_of_week_indices_offset() -> None: + """Test that first_day_dow offsets the index sequence correctly.""" + result = ptime.get_sequential_day_of_week_indices(3, 7) + expected = jnp.array([3, 4, 5, 6, 0, 1, 2]) + assert jnp.array_equal(result, expected) + + +def test_get_sequential_day_of_week_indices_zero_length() -> None: + """Test that n_timepoints=0 returns an empty array.""" + result = ptime.get_sequential_day_of_week_indices(0, 0) + assert result.shape == (0,) + + +def test_get_sequential_day_of_week_indices_negative_raises() -> None: + """Test that negative n_timepoints raises ValueError.""" + with pytest.raises(ValueError, match="n_timepoints must be non-negative"): + ptime.get_sequential_day_of_week_indices(0, -1) + + +def test_get_sequential_day_of_week_indices_invalid_dow_raises() -> None: + """Test that invalid first_day_dow raises ValueError.""" + with pytest.raises(ValueError, match="Day-of-week"): + ptime.get_sequential_day_of_week_indices(7, 10) + + def test_validate_dow() -> None: """ Test that validate_dow raises appropriate From 2ac769ab368c73597cec3a6645177aa1be9901f7 Mon Sep 17 00:00:00 2001 From: Mitzi Morris Date: Thu, 26 Feb 2026 13:22:06 -0500 Subject: [PATCH 7/7] changes per code review --- test/test_pyrenew_builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_pyrenew_builder.py b/test/test_pyrenew_builder.py index 6540fdb3..74769411 100644 --- a/test/test_pyrenew_builder.py +++ b/test/test_pyrenew_builder.py @@ -417,9 +417,9 @@ def test_pad_observations_prepends_nans(self, simple_builder): @pytest.mark.parametrize( "obs_start_dow, expected", [ - (0, (0 - 3 % 7) % 7), - (3, (3 - 3 % 7) % 7), - (6, (6 - 3 % 7) % 7), + (0, (0 - 3) % 7), + (3, (3 - 3) % 7), + (6, (6 - 3) % 7), ], ) def test_compute_first_day_dow(self, simple_builder, obs_start_dow, expected):