Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7451554
Implementation of the filtering and forecasting pipeline subsystem.
JeffreyCovington Jul 8, 2025
bce9d91
Updated type annotations. Added ABC to abstract classes.
JeffreyCovington Feb 18, 2026
5bde966
Added override decorator as appropriate. Updated operations on dictio…
JeffreyCovington Feb 18, 2026
38d4996
Added validation upon construction for only using a `Gaussian` likeli…
JeffreyCovington Feb 18, 2026
2c63513
Renamed likelihoods to be more explicit. Reparameterized the Gaussian…
JeffreyCovington Feb 19, 2026
177a276
Updated type annotations. Added ABC to abstract classes.
JeffreyCovington Feb 18, 2026
5c03a53
Added override decorator as appropriate. Updated operations on dictio…
JeffreyCovington Feb 18, 2026
02e772f
Added validation upon construction for only using a `Gaussian` likeli…
JeffreyCovington Feb 18, 2026
f490cf9
Renamed likelihoods to be more explicit. Reparameterized the Gaussian…
JeffreyCovington Feb 19, 2026
b959342
Merge branch 'Forecasting-Code-Cleanup' of https://github.com/NAU-CCL…
JeffreyCovington Mar 25, 2026
fb84578
Updated line formatting.
JeffreyCovington Mar 25, 2026
e4b5f2d
Updated documentation.
JeffreyCovington Apr 2, 2026
633cc99
Changed validation of dynamic parameters to work properly with array…
JeffreyCovington Apr 2, 2026
54dc719
Changed the `posterior_values` of the particle filter and EnKF to hav…
JeffreyCovington Apr 2, 2026
40230fa
Forecasting code cleanup (#293)
Averydx May 20, 2026
c7485af
EnKF refactor (#294)
JeffreyCovington May 21, 2026
51715b2
Messaging and refactor of pipeline simulators. (#295)
JeffreyCovington Jun 1, 2026
6932d56
Reorganized internal forecasting utility functions.
JeffreyCovington Jun 3, 2026
859e97e
Updated typing in FilterOutput.
JeffreyCovington Jun 9, 2026
2fe5fba
Added support for movement data by visit or home node in pipeline sim…
JeffreyCovington Jun 9, 2026
f364ae3
Typing and doc improvements in the munge.
Averydx Jun 10, 2026
1a51d45
modified: tests/fast/tools/data_test.py
Averydx Jun 10, 2026
fae74b3
modified: epymorph/forecasting/munge_realizations.py
Averydx Jun 10, 2026
6b71f82
Updated typing, documentation, and naming conventions for pipeline pl…
JeffreyCovington Jun 29, 2026
2ba1322
Fixed incorrect import.
JeffreyCovington Jun 29, 2026
a96133b
Adjusted typing for plotting on multiple matplotlib axes.
JeffreyCovington Jun 30, 2026
d6d2b76
Updated documentation and typing.
JeffreyCovington Jun 30, 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
33 changes: 33 additions & 0 deletions epymorph/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,26 @@ class ADRIOProgress(NamedTuple):
"""If complete, how long did the ADRIO take overall (in seconds)?"""


###################
# Pipeline Events #
###################


class OnPipelineStart(NamedTuple):
name: str
"""Name of the simulator class."""

rume: RUME

num_realizations: int

total_progress: int


class OnPipelineProgress(NamedTuple):
progress: int


############
# EventBus #
############
Expand Down Expand Up @@ -183,6 +203,15 @@ class EventBus(metaclass=Singleton):
on_adrio_progress: Event[ADRIOProgress]
"""Event fires when an ADRIO is fetching data."""

# Pipeline Events
on_pipeline_start: Event[OnPipelineStart]
"""Event fires at the start of a PipelineSimulator."""

on_pipeline_progress: Event[OnPipelineProgress]

on_pipeline_finish: Event[None]
"""Event fires after a PipelineSimulator is complete."""

def __init__(self):
# SimulationEvents
self.on_start = Event()
Expand All @@ -194,3 +223,7 @@ def __init__(self):
self.on_movement_finish = Event()
# AdrioEvents
self.on_adrio_progress = Event()
# PipelineEvents
self.on_pipeline_start = Event()
self.on_pipeline_progress = Event()
self.on_pipeline_finish = Event()
155 changes: 122 additions & 33 deletions epymorph/forecasting/dynamic_params.py
Comment thread
JavadocMD marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Components for initializing, propagating, and transforming unknown parameters."""

from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Tuple, final
from dataclasses import dataclass
from typing import Self, final

import numpy as np
import scipy as sp
Expand All @@ -12,69 +15,95 @@
from epymorph.params import ParamFunction, ResultDType


class Prior:
@dataclass(frozen=True)
class Prior(ABC):
"""
Abstract class representing the prior distribution of an unknown parameter.
"""

@abstractmethod
def sample(self, size: Tuple[int], rng: np.random.Generator):
def sample(self, size: tuple[int], rng: np.random.Generator) -> NDArray[np.float64]:
"""
Sample values from the prior distribution.

Parameters
----------
size:
The shape of the resulting array of values.
rng:
The random number generator to use.

Returns
-------
:
An array of random values sampled from the distribution.
"""


@dataclass(frozen=True)
class UniformPrior(Prior):
"""
A uniform prior distribution.

Parameters
----------
lower :
The lower bound of the uniform distribution.
upper :
The upper bound of the uniform distribution. Must be greater than or equal to
the lower bound.
"""

lower: float
lower: float | NDArray[np.float64]
"""
The lower bound of the uniform distribution.
"""

upper: float
upper: float | NDArray[np.float64]
"""
The upper bound of the uniform distribution.
"""

def __init__(self, lower: float, upper: float):
self.lower = lower
self.upper = upper
def __post_init__(self):
if np.any(self.lower > self.upper):
raise ValueError("The lower bound cannot be greater than the upper bound.")

def sample(self, size: Tuple[int], rng: np.random.Generator):
"""
Sample an array of uniform random variates.
"""
@override
def sample(self, size: tuple[int], rng: np.random.Generator) -> NDArray[np.float64]:
return sp.stats.uniform.rvs(
loc=self.lower, scale=(self.upper - self.lower), size=size, random_state=rng
)


@dataclass(frozen=True)
class GaussianPrior(Prior):
"""
A Gaussian prior distribution.

Parameters
----------
mean:
The mean of the Gaussian distribution.
standard_deviation:
The standard deviation of the Gaussian distribution. Must be non-negative.
"""

mean: float
mean: float | NDArray[np.float64]
"""
The mean of the Gaussian distribution.
"""

standard_deviation: float
standard_deviation: float | NDArray[np.float64]
"""
The standard deviation of the Gaussian distribution.
"""

def __init__(self, mean: float, standard_deviation: float):
self.mean = mean
self.standard_deviation = standard_deviation
def __post_init__(self):
if np.any(self.standard_deviation < 0):
raise ValueError("The standard deviation must be non-negative.")

def sample(self, size: Tuple[int], rng: np.random.Generator):
"""
Sample an array of Gaussian random variates.
"""
@override
def sample(self, size: tuple[int], rng: np.random.Generator) -> NDArray[np.float64]:
return sp.stats.norm.rvs(
loc=self.mean, scale=self.standard_deviation, size=size, random_state=rng
)
Expand All @@ -88,10 +117,16 @@ class ParamFunctionDynamics(ParamFunction[ResultDType], ABC):

_initial: NDArray[ResultDType] | None = None

def with_initial(self, initial: NDArray[ResultDType]):
def with_initial(self, initial: NDArray[ResultDType]) -> Self:
"""
Add an initial value so that this parameter is suitable for evaluation from
within a RUME.

Parameters
----------
initial:
An array of shape (N,) where N is the number of nodes containing the initial
values.
"""
clone = deepcopy(self)
setattr(clone, "_initial", initial)
Expand All @@ -113,23 +148,50 @@ def _evaluate_from_initial(
) -> NDArray[ResultDType]:
"""
Produce a trajectory matching the attribute requirements from the initial value.

Parameters
----------
initial:
An array of shape (N,) where N is the number of nodes containing the initial
values.
"""


class OrnsteinUhlenbeck(ParamFunctionDynamics[np.float64]):
"""
Model the time dependence of an unknown parameter as an Ornstein-Uhlenbeck process.
The process is independent for each node.
The process is independent for each node. The process is parameterized in terms of
the resulting stationary distribution.

Parameters
----------
damping:
The damping of the process. Must be positive.
mean:
The mean of the stationary distribution.
standard_deviation:
The standard deviation of the stationary distribution. Must be non-negative.
"""

damping: float | NDArray[np.float64]
mean: float | NDArray[np.float64]
standard_deviation: float | NDArray[np.float64]

requirements = ()

def __init__(self, damping: float, mean: float, standard_deviation: float):
if np.any(damping <= 0):
raise ValueError("Damping must be positive.")
self.damping = damping
self.mean = mean
if np.any(standard_deviation < 0):
raise ValueError("Standard deviation must be non-negative.")
self.standard_deviation = standard_deviation

def _evaluate_from_initial(self, initial):
@override
def _evaluate_from_initial(
self, initial: NDArray[np.float64]
) -> NDArray[np.float64]:
result = np.zeros((self.time_frame.days, self.scope.nodes), np.float64)
previous = initial

Expand Down Expand Up @@ -166,7 +228,10 @@ class Static(ParamFunctionDynamics[np.float64]):
def __init__(self):
pass

def _evaluate_from_initial(self, initial: NDArray[np.float64]):
@override
def _evaluate_from_initial(
self, initial: NDArray[np.float64]
) -> NDArray[np.float64]:
result = np.zeros((self.time_frame.days, self.scope.nodes), np.float64)
result[...] = initial.copy()
return result
Expand All @@ -176,20 +241,33 @@ class BrownianMotion(ParamFunctionDynamics[np.float64]):
"""
Model the time dependence of an unknown parameter as Brownian motion. The Brownian
motion for each node is independent.

Parameters
----------
volatility:
The volatility of the Brownian motion. Must be non-negative.
"""

volatility: float | NDArray[np.float64]
"""The volatility of the Brownian motion."""

requirements = ()

def __init__(self, voliatility: float):
self.voliatility = voliatility
def __init__(self, volatility: float):
if np.any(volatility < 0):
raise ValueError("The volatility must be non-negative.")
self.volatility = volatility

def _evaluate_from_initial(self, initial: NDArray[np.float64]):
@override
def _evaluate_from_initial(
self, initial: NDArray[np.float64]
) -> NDArray[np.float64]:
result = np.zeros((self.time_frame.days, self.scope.nodes), np.float64)
previous = initial
voliatility = self.voliatility
voliatility = Shapes.TxN.adapt(self.dim, np.array(voliatility))
volatility = self.volatility
volatility = Shapes.TxN.adapt(self.dim, np.array(volatility))
for i_day in range(self.time_frame.days):
current = previous + voliatility[i_day, ...] * self.rng.normal(
current = previous + volatility[i_day, ...] * self.rng.normal(
size=self.scope.nodes
)
result[i_day, ...] = current
Expand All @@ -209,13 +287,17 @@ class ExponentialTransform(ParamFunction[np.float64]):
requirement.
"""

_value_req: AttributeDef
"""The name of the attribute to take the exponential of."""

@property
def requirements(self):
def requirements(self) -> tuple[AttributeDef]:
return (self._value_req,)

def __init__(self, other: str):
self._value_req = AttributeDef(other, float, Shapes.TxN)

@override
def evaluate(self) -> NDArray[np.float64]:
return np.exp(self.data(self._value_req))

Expand All @@ -234,13 +316,20 @@ class ShiftTransform(ParamFunction[np.float64]):
requirement.
"""

_first_req: AttributeDef
"""The name of the first attribute to take the sum of."""

_second_req: AttributeDef
"""The name of the second attribute to take the sum of."""

@property
def requirements(self):
def requirements(self) -> tuple[AttributeDef, AttributeDef]:
return (self._first_req, self._second_req)

def __init__(self, first: str, second: str):
self._first_req = AttributeDef(first, float, Shapes.TxN)
self._second_req = AttributeDef(second, float, Shapes.TxN)

@override
def evaluate(self) -> NDArray[np.float64]:
return np.add(self.data(self._first_req), self.data(self._second_req))
Loading
Loading