Time Stepping

This page explains how time stepping works in SimOptDecisions.jl, including the TimeStep struct, helper methods, and how to work with different time representations.

The TimeStep Struct

When your callbacks receive a time parameter t, it’s wrapped in a TimeStep struct:

struct TimeStep{V}
    t::Int   # 1-based index into time_axis
    val::V   # Actual time value (Int, Float64, Date, etc.)
end

This design separates the index (for array access) from the value (for domain logic):

function SimOptDecisions.run_timestep(state, action, t::TimeStep, config, scenario, rng)
    # Use t.t for array indexing
    price = scenario.prices[t.t]

    # Use t.val for domain logic (e.g., if time_axis returns years)
    discount = 1 / (1 + rate)^t.val

    # ...
end

Position Helpers

Use is_first and is_last to check position in the sequence:

using SimOptDecisions

# Check if first timestep
is_first(ts)  # true if ts.t == 1

# Check if last timestep (pass the time axis or its length)
is_last(ts, times)      # true if ts.t == length(times)
is_last(ts, n::Integer) # true if ts.t == n

Example usage:

function SimOptDecisions.run_timestep(state, action, t::TimeStep, config, scenario, rng)
    # Use config.horizon directly for efficiency (avoids calling time_axis each timestep)

    if is_first(t)
        # First timestep: apply initial setup costs
        setup_cost = config.setup_cost
    else
        setup_cost = 0.0
    end

    if is_last(t, config.horizon)
        # Last timestep: apply terminal value
        terminal_value = state.value * config.salvage_rate
    else
        terminal_value = 0.0
    end

    # ...
end

The time_axis Callback

The time_axis callback defines what time points your simulation iterates over:

function SimOptDecisions.time_axis(config::MyConfig, scenario::MyScenario)
    return 1:config.horizon
end

Requirements

  • Must return an iterable with a defined length()
  • Must have a concrete element type (not Any)

Supported Types

Integer ranges (most common):

time_axis(config, scenario) = 1:config.horizon
time_axis(config, scenario) = 1:100

Date ranges:

using Dates
time_axis(config, scenario) = Date(2020):Year(1):Date(2100)
time_axis(config, scenario) = Date(2020, 1, 1):Month(1):Date(2025, 12, 1)

Float ranges:

time_axis(config, scenario) = 0.0:0.1:10.0

Vectors:

time_axis(config, scenario) = [2020, 2025, 2030, 2050, 2100]  # irregular spacing

TimeSeriesParameter

For time-varying data in your scenarios, use TimeSeriesParameter instead of plain vectors:

struct ClimateScenario{T<:AbstractFloat} <: AbstractScenario
    temperatures::TimeSeriesParameter{T}  # Not Vector{T}
end

# Create from a vector
scenario = ClimateScenario(TimeSeriesParameter([20.0, 21.0, 22.0, ...]))

TimeSeriesParameter provides safe indexing that works with both integers and TimeStep:

function SimOptDecisions.run_timestep(state, action, t::TimeStep, config, scenario, rng)
    # Both work:
    temp = scenario.temperatures[t]     # Index with TimeStep
    temp = scenario.temperatures[t.t]   # Index with integer

    # Bounds checking with helpful error messages
    # scenario.temperatures[100]  # Throws TimeSeriesParameterBoundsError if out of range
end

The timeindex Utility

The framework uses timeindex internally to iterate over time axes. You can also use it directly:

using SimOptDecisions

times = 1:5
for ts in timeindex(times)
    println("Step $(ts.t), value $(ts.val)")
    if is_last(ts, times)
        println("Done!")
    end
end

Output:

Step 1, value 1
Step 2, value 2
Step 3, value 3
Step 4, value 4
Step 5, value 5
Done!

Discounting

The discount_factor helper computes standard exponential discounting:

discount_factor(rate, t)  # Returns 1 / (1 + rate)^t

Example:

function SimOptDecisions.run_timestep(state, action, t::TimeStep, config, scenario, rng)
    cost = compute_cost(state, action)
    discounted_cost = cost * discount_factor(config.discount_rate, t.t)
    return (new_state, (cost=cost, discounted_cost=discounted_cost))
end

Common Patterns

Conditional Logic by Time

function SimOptDecisions.run_timestep(state, action, t::TimeStep, config, scenario, rng)
    # Different behavior in first vs later timesteps
    if is_first(t)
        # Initial investment/setup
        capital_cost = action.investment
    else
        capital_cost = 0.0
    end

    # Behavior that depends on the actual time value
    if t.val >= 2050  # Assuming time_axis returns years
        carbon_price = config.future_carbon_price
    else
        carbon_price = config.current_carbon_price
    end
end

Accessing Previous/Future Information

The scenario contains all exogenous information for the entire trajectory:

function SimOptDecisions.run_timestep(state, action, t::TimeStep, config, scenario, rng)
    current_demand = scenario.demand[t]

    # Look ahead (if your policy needs it)
    if t.t < length(scenario.demand)
        next_demand = scenario.demand[t.t + 1]
    end
end

Variable-Length Simulations

If simulation length depends on the scenario:

function SimOptDecisions.time_axis(config::MyConfig, scenario::MyScenario)
    return 1:scenario.horizon  # Each scenario can have different length
end