Skip to content

Add non-contiguous timestamp support (multiple time slices)#110

Open
sriharisundar wants to merge 7 commits into
mainfrom
ssh/noncontiguousTS
Open

Add non-contiguous timestamp support (multiple time slices)#110
sriharisundar wants to merge 7 commits into
mainfrom
ssh/noncontiguousTS

Conversation

@sriharisundar

Copy link
Copy Markdown
Member

Summary

Allows a SystemModel time axis to consist of multiple contiguous StepRange "slices" with gaps between them (e.g. a representative summer week + winter week), instead of a single contiguous range.

Design

  • New SlicedTimestamps <: AbstractVector{ZonedDateTime} stores a Vector{StepRange} of slices but presents a flat length-N view (lazy indexing), so the SequentialMonteCarlo engine and all result indexing are unchanged. Per-sample state (storage SoC, Markov availability) carries across slice boundaries as if adjacent.
  • SystemModel.timestamps field widened from StepRange{ZonedDateTime,T} to AbstractVector{ZonedDateTime}. New constructors accept a Vector{StepRange} of slices.
  • Fully backward compatible: passing a single StepRange works exactly as before (still stored as a compact StepRange, no materialization).
  • Result struct timestamps fields widened accordingly.

File format

  • PRASFiles persists/reconstructs slices via new HDF5 metadata n_slices, slice_start_timestamps, slice_lengths — written only for multi-slice systems, so existing single-slice .pras files are byte-identical and pre-0.9 files still read.

Versioning

  • Bumped PRASCore, PRASFiles, PRASCapacityCredits, and PRAS to 0.9.0 and widened inter-package compat pins (registrable in dependency order).

Tests

  • PRASCore 513/513 (incl. new multi-slice construction/printing testset)
  • PRASFiles 32/32 (incl. new file round-trip + assess-across-the-gap testset)
  • PRASCapacityCredits 4/4

🤖 Generated with Claude Code

@sriharisundar sriharisundar requested a review from akrivi June 12, 2026 19:36
@codecov-commenter

codecov-commenter commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.81250% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.17%. Comparing base (c793303) to head (9be8097).

Files with missing lines Patch % Lines
PRASCore.jl/src/Systems/utils.jl 73.52% 9 Missing ⚠️
PRASCore.jl/src/Systems/SystemModel.jl 87.50% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #110      +/-   ##
==========================================
+ Coverage   83.16%   83.17%   +0.01%     
==========================================
  Files          45       46       +1     
  Lines        2429     2484      +55     
==========================================
+ Hits         2020     2066      +46     
- Misses        409      418       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds support for non-contiguous timestamp axes by representing a system’s time domain as multiple contiguous StepRange slices with gaps, while preserving existing “flat” indexing semantics across the concatenated timesteps. This spans core modeling (SystemModel + new SlicedTimestamps), result timestamp typing, and PRAS file read/write support for persisting slice metadata.

Changes:

  • Introduces SlicedTimestamps <: AbstractVector{ZonedDateTime} and adds SystemModel constructors that accept a Vector{StepRange} of slices.
  • Updates PRASFiles HDF5 metadata handling to optionally persist and reconstruct multi-slice timestamp axes.
  • Widens result timestamp fields to support non-contiguous axes and adds tests + version bumps to 0.9.0 across packages.

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
PRASFiles.jl/test/runtests.jl Adds a non-contiguous slice round-trip + assess-across-gap testset.
PRASFiles.jl/src/Systems/write.jl Writes slice metadata (n_slices, slice_*) for multi-slice systems.
PRASFiles.jl/src/Systems/read.jl Reads optional slice metadata and rebuilds SlicedTimestamps; broadens supported file-version range.
PRASFiles.jl/src/PRASFiles.jl Imports SlicedTimestamps from PRASCore for I/O logic.
PRASFiles.jl/Project.toml Bumps PRASFiles to 0.9.0 and widens PRASCore compat to 0.9.
PRASCore.jl/test/Systems/SystemModel.jl Adds SystemModel multi-slice construction/validation/printing tests.
PRASCore.jl/src/Systems/utils.jl Introduces SlicedTimestamps and timestep(...) helper(s).
PRASCore.jl/src/Systems/Systems.jl Exports/includes the new sliced-timestamp utilities.
PRASCore.jl/src/Systems/SystemModel.jl Widens timestamps storage type and adds slice-based constructors; updates display and timestep assertion.
PRASCore.jl/src/Results/UtilizationSamples.jl Widens result timestamps field type.
PRASCore.jl/src/Results/Utilization.jl Widens result timestamps field type.
PRASCore.jl/src/Results/SurplusSamples.jl Widens result timestamps field type.
PRASCore.jl/src/Results/Surplus.jl Widens result timestamps field type.
PRASCore.jl/src/Results/StorageEnergySamples.jl Widens result timestamps field type.
PRASCore.jl/src/Results/StorageEnergy.jl Widens result timestamps field type.
PRASCore.jl/src/Results/StorageAvailability.jl Widens result timestamps field type.
PRASCore.jl/src/Results/ShortfallSamples.jl Widens result timestamps field type.
PRASCore.jl/src/Results/Shortfall.jl Widens result timestamps field type and constructor signature.
PRASCore.jl/src/Results/LineAvailability.jl Widens result timestamps field type.
PRASCore.jl/src/Results/GeneratorStorageEnergySamples.jl Widens result timestamps field type.
PRASCore.jl/src/Results/GeneratorStorageEnergy.jl Widens result timestamps field type.
PRASCore.jl/src/Results/GeneratorStorageAvailability.jl Widens result timestamps field type.
PRASCore.jl/src/Results/GeneratorAvailability.jl Widens result timestamps field type.
PRASCore.jl/src/Results/FlowSamples.jl Widens result timestamps field type.
PRASCore.jl/src/Results/Flow.jl Widens result timestamps field type.
PRASCore.jl/src/Results/DemandResponseEnergySamples.jl Widens result timestamps field type.
PRASCore.jl/src/Results/DemandResponseEnergy.jl Widens result timestamps field type.
PRASCore.jl/src/Results/DemandResponseAvailability.jl Widens result timestamps field type.
PRASCore.jl/Project.toml Bumps PRASCore to 0.9.0.
PRASCapacityCredits.jl/Project.toml Bumps PRASCapacityCredits to 0.9.0 and widens PRASCore compat through 0.9.
PRAS.jl/Project.toml Bumps umbrella PRAS to 0.9.0 and pins component packages to 0.9.
Comments suppressed due to low confidence (1)

PRASFiles.jl/src/Systems/write.jl:92

  • User-defined sys.attrs are written after the reserved PRAS metadata attributes. With multi-slice support this now includes n_slices/slice_* keys; if a user supplies any attribute with the same key, it will overwrite the reserved metadata and can make the file unreadable (e.g., n_slices becomes a string, causing parse failures on load). Guard against collisions so reserved metadata cannot be overridden by user attributes.
    # Non-contiguous time axis: persist per-slice (start, length). Written only
    # for multi-slice systems so single-slice .pras files stay byte-identical and
    # remain readable by older PRAS versions.
    if sys.timestamps isa SlicedTimestamps
        slices = sys.timestamps.slices
        attrs["n_slices"] = length(slices)
        attrs["slice_start_timestamps"] = [string(first(s)) for s in slices]
        attrs["slice_lengths"] = [length(s) for s in slices]
    end

    # Existing system attributes
    sys_attributes = sys.attrs
    for (key, value) in sys_attributes
        attrs[key] = value
    end

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +82 to +83
timestep(ts::SlicedTimestamps) = step(first(ts.slices))
timestep(ts::AbstractRange{ZonedDateTime}) = step(ts)
Comment on lines +65 to 68
# Either a contiguous `StepRange` (single time slice) or a `SlicedTimestamps`
# (multiple non-contiguous slices). Both behave as a flat length-N vector.
timestamps::AbstractVector{ZonedDateTime}

@hsunnrel hsunnrel requested a review from Copilot June 12, 2026 21:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

PRASFiles.jl/test/runtests.jl:1

  • This test writes toy_noncontig.pras into the repository test directory and does not clean it up, which can leave artifacts locally and can create collisions in parallel test runs/CI caching. Use mktempdir()/tempname() (and joinpath) and ensure the file is removed after the test (e.g., with a try/finally) so the test is hermetic.

Comment thread PRASCore.jl/src/Systems/SystemModel.jl Outdated
Comment thread PRASCore.jl/src/Systems/SystemModel.jl Outdated
Comment thread PRASFiles.jl/src/Systems/read.jl
Comment thread PRASCore.jl/src/Results/Shortfall.jl
Comment thread PRASCore.jl/src/Results/Shortfall.jl

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 5 comments.

Comment on lines +81 to +86
if sys.timestamps isa SlicedTimestamps
slices = sys.timestamps.slices
attrs["n_slices"] = length(slices)
attrs["slice_start_timestamps"] = [string(first(s)) for s in slices]
attrs["slice_lengths"] = [length(s) for s in slices]
end
Comment on lines +59 to +67
if haskey(metadata, "n_slices") && Int(read(metadata["n_slices"])) > 1
# Non-contiguous time axis: rebuild each slice from its (start, length).
slice_starts = ZonedDateTime.(read(metadata["slice_start_timestamps"]),
dateformat"yyyy-mm-ddTHH:MM:SSz")
slice_lengths = Int.(read(metadata["slice_lengths"]))
slices = [StepRange(s, timestep, s + (len - 1) * timestep)
for (s, len) in zip(slice_starts, slice_lengths)]
timestamps = SlicedTimestamps(slices)
else
# 0.8 reader handles (it falls back to a contiguous range when absent).
systemmodel_0_8(f)
else
error("PRAS file format $versionstring not supported by this version of PRASBase.")
Comment on lines +167 to +186
function SystemModel(
regions::Regions{N,P}, interfaces::Interfaces{N,P},
generators::Generators{N,L,T,P}, region_gen_idxs::Vector{UnitRange{Int}},
storages::Storages{N,L,T,P,E}, region_stor_idxs::Vector{UnitRange{Int}},
generatorstorages::GeneratorStorages{N,L,T,P,E}, region_genstor_idxs::Vector{UnitRange{Int}},
demandresponses::DemandResponses{N,L,T,P,E}, region_dr_idxs::Vector{UnitRange{Int}},
lines::Lines{N,L,T,P}, interface_line_idxs::Vector{UnitRange{Int}},
slices::Vector{<:StepRange{ZonedDateTime}},
attrs::Dict{String, String}=Dict{String, String}()
) where {N,L,T<:Period,P<:PowerUnit,E<:EnergyUnit}

return SystemModel(
regions, interfaces,
generators, region_gen_idxs,
storages, region_stor_idxs,
generatorstorages, region_genstor_idxs,
demandresponses, region_dr_idxs,
lines, interface_line_idxs,
SlicedTimestamps(collect(slices)), attrs)
end
Comment on lines +189 to +207
function SystemModel(
regions::Regions{N,P}, interfaces::Interfaces{N,P},
generators::Generators{N,L,T,P}, region_gen_idxs::Vector{UnitRange{Int}},
storages::Storages{N,L,T,P,E}, region_stor_idxs::Vector{UnitRange{Int}},
generatorstorages::GeneratorStorages{N,L,T,P,E}, region_genstor_idxs::Vector{UnitRange{Int}},
lines::Lines{N,L,T,P}, interface_line_idxs::Vector{UnitRange{Int}},
slices::Vector{<:StepRange{ZonedDateTime}},
attrs::Dict{String, String}=Dict{String, String}()
) where {N,L,T<:Period,P<:PowerUnit,E<:EnergyUnit}

return SystemModel(
regions, interfaces,
generators, region_gen_idxs,
storages, region_stor_idxs,
generatorstorages, region_genstor_idxs,
DemandResponses{N,L,T,P,E}(), repeat([1:0],length(regions)),
lines, interface_line_idxs,
SlicedTimestamps(collect(slices)), attrs)
end
Allow a SystemModel time axis to consist of multiple contiguous StepRange
"slices" with gaps between them (e.g. a summer week + a winter week),
instead of a single contiguous range.

- New SlicedTimestamps <: AbstractVector{ZonedDateTime} stores a vector of
  StepRange slices but presents a flat length-N view, so the simulation
  engine and result indexing are unchanged. Per-sample state carries across
  slice boundaries as if adjacent.
- SystemModel.timestamps field widened to AbstractVector{ZonedDateTime};
  new constructors accept a Vector{StepRange} of slices. Passing a single
  StepRange is fully backward compatible (still stored as a StepRange).
- Results timestamp fields widened accordingly.
- PRASFiles: persist/reconstruct slices via n_slices, slice_start_timestamps,
  and slice_lengths HDF5 metadata, written only for multi-slice systems so
  existing single-slice .pras files stay byte-identical. Reader remains
  backward compatible with pre-0.9 files.
- Bump PRASCore, PRASFiles, PRASCapacityCredits, and PRAS to 0.9.0 and
  widen inter-package compat pins.
- Add tests for multi-slice construction, file round-trip, and assess.
Make SlicedTimestamps internal (qualified import in PRASFiles/tests
still works) so it no longer trips Documenter's missing-docs check.
Add a Base.show that displays the underlying StepRange slices, one
per line in the REPL, instead of dumping every timestamp.
Narrow the base SystemModel constructors' timestamps argument to
Union{StepRange{ZonedDateTime}, SlicedTimestamps} so a flat
Vector{ZonedDateTime} fails cleanly at dispatch with a MethodError
listing valid signatures, instead of an opaque timestep() error.

Document the non-contiguous slice-vector constructor and the
flat-vector restriction in the SystemModel docstring.
Restore the concrete field type lost when the axis was broadened for
non-contiguous support: use a 2-member Union of concrete types,
Union{StepRange{ZonedDateTime,T}, SlicedTimestamps{T}}, instead of
AbstractVector{ZonedDateTime}. The compiler union-splits it, so
sys.timestamps access stays type-stable without adding a struct type
parameter.
SlicedTimestamps is no longer exported, so `using PRASCore` does not
bring it into scope. The non-contiguous roundtrip tests referenced it
unqualified and errored on CI. Add the qualified import, matching the
fix already applied to the PRASCore test suite.
The base constructors accepted Union{StepRange{ZonedDateTime},
SlicedTimestamps} untied to T, so a range whose step equals T(L) but
has a different period type (e.g. Minute(60) vs Hour(1)) passed the
timestep assertion and then failed opaquely when assigned into the
T-tied timestamps field. Tighten both signatures to
Union{StepRange{ZonedDateTime,T}, SlicedTimestamps{T}} so the mismatch
is a clean dispatch-time MethodError, consistent with the field type.

Add a test covering a Minute(60)-stepped range against an Hour-unit
system.
Describe the optional n_slices/slice_start_timestamps/slice_lengths
HDF5 attributes and their backward-compat rules in the .pras format
spec, add a user-facing slice-vector constructor example to the system
model specification, and add a 0.9.0 changelog entry.

Add a note in the system model spec and the PRAS walkthrough advising
that the time axis comes from sys.timestamps (used directly or via
collect), and must not be reconstructed from first + length + step,
which fills the gaps of a non-contiguous axis with nonexistent times.

@akrivi akrivi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks great, Hari!
I left a couple comments but I think the main remaining question is how we want the non-contiguous slices to behave in the simulation. Since slices represent calendar gaps, I would expect each slice to be simulated independently, with state evolving within a slice but not carrying over between slices (for example, a slice in July should not inherit SOC from the previous slice in January).
In that case, I think we would additionally need the following modifications in the Simulations scripts:

  • We should make slice boundaries explicit in the simulation loop. To achieve that, at each slice start we should reinitialize availability, storage and generator-storage SOC, demand-response debt/payback state, line availability and transition schedules. Also at each slice end, we need to settle or reset any remaining DR obligations.

  • We should use deterministic slice-local RNG state so that slices remain reproducible and isolated from unrelated changes to earlier slices.

Happy to discuss this more!


end

# Single-node constructor - demand responses included

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we also support non-contiguous timestamps in the single-node constructors? Currently, both the demand response and non-demand-response constructors only accept a StepRange.

"step $(step(slice)) but slice 1 has step $Δ"))
end

# Slices must be strictly ordered and non-overlapping

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we also reject adjacent slices without timestamp gaps and prompt the user to use a single StepRange instead?

lines, line_interfaces,
minute_timestamps)
end
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we also add a small deterministic simulation test with non-contiguous time slices verifying that EUE and LOLE aggregate only the modeled N timesteps and not the total elapsed calendar time between slice endpoints?

@patrickbrown4

Copy link
Copy Markdown

Looks great, Hari! I left a couple comments but I think the main remaining question is how we want the non-contiguous slices to behave in the simulation. Since slices represent calendar gaps, I would expect each slice to be simulated independently, with state evolving within a slice but not carrying over between slices (for example, a slice in July should not inherit SOC from the previous slice in January). In that case, I think we would additionally need the following modifications in the Simulations scripts:

  • We should make slice boundaries explicit in the simulation loop. To achieve that, at each slice start we should reinitialize availability, storage and generator-storage SOC, demand-response debt/payback state, line availability and transition schedules. Also at each slice end, we need to settle or reset any remaining DR obligations.
  • We should use deterministic slice-local RNG state so that slices remain reproducible and isolated from unrelated changes to earlier slices.

FWIW, in ReEDS, the two things we use noncontiguous timestamps for (both because of data limitations) are:

  1. We drop Dec 31 on leap years
  2. We jump from the end of 2013 to the beginning of 2016

For the first, at least for our application, we wouldn't want to reinitialize all of the SOCs just because we're missing a day of data. For the second, I'm not sure which is better (either way, forcing storage to always start empty is a problem for long-duration storage). But it seems like users can already implement something like the second by just setting up multiple independent PRAS runs over different time intervals.

I guess it depends which you expect to be the more common use case. If it's the first (handling missing days or hours here and there), it'd probably be safest to just act like the data gap didn't happen and keep SOC etc continuous. (That's what we would do with leap years in ReEDS.) If the latter, then it's not really clear which is better; if you're skipping a full year (from Dec 31 2013 to Jan 1 2015), and you have LDES, then it seems safer to keep things continuous; if you're skipping something on the order of months, then I'm not sure there's a great solution either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants