Skip to content

Improve date handling for SimulationFunctions #264

@JavadocMD

Description

@JavadocMD

SimulationFunctions that are defined as functions of time currently operate on the simulation day index. That is, in $f(t)$, $t$ is a monotonically-increasing integer in the range 0 up until the number of total days being simulated. Notably the index ignores the calendar dates associated with the simulation. This works for many kinds of simulation functions (movement clauses for instance), but causes difficulties for others. Especially in multi-stage pipeline situations like are currently in development.

For example, defining a parameter as a sine wave whose value fluctuates seasonally over the calendar year is more difficult than we would like. A naive implementation might look like this:

from typing_extensions import override
from epymorph.kit import *
import numpy as np
import matplotlib.pyplot as plt


class SeasonalWaveA(ParamFunctionTime[np.float64]):
    @override
    def evaluate1(self, day: int) -> float:
        return np.sin(2 * np.pi * day / 365)


def f_a(time_frame, start_date_index=0):
    """Eval function for time_frame and return (x_values, y_values) for plotting."""
    t = np.arange(start_date_index, start_date_index + time_frame.days)
    ft = SeasonalWaveA().with_context(time_frame=time_frame).evaluate()
    return t, ft


tf1 = TimeFrame.rangex("2020-01-01", "2020-04-01")
tf2 = TimeFrame.rangex("2020-04-01", "2020-07-01")

# Plot the function over the first time frame (blue)
plt.plot(*f_a(tf1))
# Plot the function over the second time frame (orange)
plt.plot(*f_a(tf2, tf1.days))
plt.title("Problematic Example A")
plt.show()
Image

Evaluating the functions over different time frames does not produce a consistent seasonal pattern as we might hope because, during evaluation, the day index always starts from 0.

Fixing this requires encoding explicit calendar date handling into the function logic:

from datetime import date


class SeasonalWaveB(ParamFunctionTime[np.float64]):
    def __init__(self, time_zero: date):
        self.time_zero = time_zero

    @override
    def evaluate1(self, day: int) -> float:
        offset = (self.time_frame.start_date - self.time_zero).days
        return np.sin(2 * np.pi * (offset + day) / 365)


def f_b(time_frame, time_zero: date, start_date_index=0):
    """Eval function for time_frame and return (x_values, y_values) for plotting."""
    t = np.arange(start_date_index, start_date_index + time_frame.days)
    ft = SeasonalWaveB(time_zero).with_context(time_frame=time_frame).evaluate()
    return t, ft


tf1 = TimeFrame.rangex("2020-01-01", "2020-04-01")
tf2 = TimeFrame.rangex("2020-04-01", "2020-07-01")

# Plot the function over the first time frame (blue)
plt.plot(*f_b(tf1, tf1.start_date))
# Plot the function over the second time frame (orange)
plt.plot(*f_b(tf2, tf1.start_date, tf1.days))
plt.title("Nicely Continuous Example B")
plt.show()
Image

We now get seasonal behavior regardless of the time frame evaluated, however this implementation is verbose and requires us to provide a construction-time parameter which might be prone to user error.

This task is to investigate improvements to the way dates are handled for these use-cases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions