diff --git a/PRAS.jl/Project.toml b/PRAS.jl/Project.toml index 54eaf0a7..d5683f39 100644 --- a/PRAS.jl/Project.toml +++ b/PRAS.jl/Project.toml @@ -6,7 +6,7 @@ authors = [ "Julian Florez ", "Gord Stephen " ] -version = "0.8.0" +version = "0.9.0" [deps] PRASCapacityCredits = "2e1a2ed5-e89d-4cd3-bc86-c0e88a73d3a3" @@ -15,9 +15,9 @@ PRASFiles = "a2806276-6d43-4ef5-91c0-491704cd7cf1" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [compat] -PRASCapacityCredits = "0.8.0" -PRASCore = "0.8.0" -PRASFiles = "0.8.0" +PRASCapacityCredits = "0.9" +PRASCore = "0.9" +PRASFiles = "0.9" Reexport = "1" julia = "1.10" diff --git a/PRAS.jl/examples/pras_walkthrough.jl b/PRAS.jl/examples/pras_walkthrough.jl index 40b5fc53..1e29a6b3 100644 --- a/PRAS.jl/examples/pras_walkthrough.jl +++ b/PRAS.jl/examples/pras_walkthrough.jl @@ -35,10 +35,21 @@ sys["2"] # We can visualize a time series of the regional load in region "2": region_2_load = sys.regions.load[sys["2"].index,:] -plot(sys.timestamps, region_2_load, - xlabel="Time", ylabel="Region 2 load ($(powerunit))", +plot(sys.timestamps, region_2_load, + xlabel="Time", ylabel="Region 2 load ($(powerunit))", legend=false) +# !!! tip "Get the time axis from `sys.timestamps`, don't rebuild it" +# The plot above passes `sys.timestamps` directly, which is exactly right: +# it is the true length-`N` vector of timestamps. If you need a plain +# `Vector` (e.g. for a `DataFrame` or to align an external series), use +# `collect(sys.timestamps)`. Either way, do **not** reconstruct the axis from +# `first(sys.timestamps)` and `length(sys.timestamps)` with a fixed timestep +# (e.g. `first(sys.timestamps) .+ (0:length(sys.timestamps)-1) .* timestep`): +# that assumes evenly spaced timestamps with no gaps, which is wrong for a +# non-contiguous (multi-slice) system -- it fills the gaps between slices +# with times that are not actually in the system. + # We can find more information about all the Generators in the system by # retriving the `generators` in the SystemModel: system_generators = sys.generators diff --git a/PRASCapacityCredits.jl/Project.toml b/PRASCapacityCredits.jl/Project.toml index c1de0454..203e87d3 100644 --- a/PRASCapacityCredits.jl/Project.toml +++ b/PRASCapacityCredits.jl/Project.toml @@ -1,7 +1,7 @@ name = "PRASCapacityCredits" uuid = "2e1a2ed5-e89d-4cd3-bc86-c0e88a73d3a3" authors = ["Gord Stephen "] -version = "0.8.0" +version = "0.9.0" [deps] Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" @@ -9,7 +9,7 @@ PRASCore = "c5c32b99-e7c3-4530-a685-6f76e19f7fe2" [compat] Distributions = "0.25" -PRASCore = "0.7 - 0.8" +PRASCore = "0.7 - 0.9" julia = "1.10" [extras] diff --git a/PRASCore.jl/Project.toml b/PRASCore.jl/Project.toml index 0bcf4300..48a356e1 100644 --- a/PRASCore.jl/Project.toml +++ b/PRASCore.jl/Project.toml @@ -6,7 +6,7 @@ authors = [ "Julian Florez ", "Gord Stephen " ] -version = "0.8.1" +version = "0.9.0" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" diff --git a/PRASCore.jl/src/Results/DemandResponseAvailability.jl b/PRASCore.jl/src/Results/DemandResponseAvailability.jl index fce28d8e..2e9a2408 100644 --- a/PRASCore.jl/src/Results/DemandResponseAvailability.jl +++ b/PRASCore.jl/src/Results/DemandResponseAvailability.jl @@ -55,7 +55,7 @@ accumulatortype(::DemandResponseAvailability) = DRAvailabilityAccumulator struct DemandResponseAvailabilityResult{N,L,T<:Period} <: AbstractAvailabilityResult{N,L,T} demandresponses::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} available::Array{Bool,3} diff --git a/PRASCore.jl/src/Results/DemandResponseEnergy.jl b/PRASCore.jl/src/Results/DemandResponseEnergy.jl index 0480d688..56dbdd03 100644 --- a/PRASCore.jl/src/Results/DemandResponseEnergy.jl +++ b/PRASCore.jl/src/Results/DemandResponseEnergy.jl @@ -61,7 +61,7 @@ struct DemandResponseEnergyResult{N,L,T<:Period,E<:EnergyUnit} <: AbstractEnergy nsamples::Union{Int,Nothing} demandresponses::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} energy_mean::Matrix{Float64} diff --git a/PRASCore.jl/src/Results/DemandResponseEnergySamples.jl b/PRASCore.jl/src/Results/DemandResponseEnergySamples.jl index a9646ef5..ccadcc33 100644 --- a/PRASCore.jl/src/Results/DemandResponseEnergySamples.jl +++ b/PRASCore.jl/src/Results/DemandResponseEnergySamples.jl @@ -57,7 +57,7 @@ accumulatortype(::DemandResponseEnergySamples) = DemandResponseEnergySamplesAccu struct DemandResponseEnergySamplesResult{N,L,T<:Period,E<:EnergyUnit} <: AbstractEnergyResult{N,L,T} demandresponses::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} energy::Array{Int,3} diff --git a/PRASCore.jl/src/Results/Flow.jl b/PRASCore.jl/src/Results/Flow.jl index 4ea28e7d..ede46b10 100644 --- a/PRASCore.jl/src/Results/Flow.jl +++ b/PRASCore.jl/src/Results/Flow.jl @@ -68,7 +68,7 @@ struct FlowResult{N,L,T<:Period,P<:PowerUnit} <: AbstractFlowResult{N,L,T} nsamples::Union{Int,Nothing} interfaces::Vector{Pair{String,String}} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} flow_mean::Matrix{Float64} diff --git a/PRASCore.jl/src/Results/FlowSamples.jl b/PRASCore.jl/src/Results/FlowSamples.jl index ba94f5cc..dfe059eb 100644 --- a/PRASCore.jl/src/Results/FlowSamples.jl +++ b/PRASCore.jl/src/Results/FlowSamples.jl @@ -64,7 +64,7 @@ accumulatortype(::FlowSamples) = FlowSamplesAccumulator struct FlowSamplesResult{N,L,T<:Period,P<:PowerUnit} <: AbstractFlowResult{N,L,T} interfaces::Vector{Pair{String,String}} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} flow::Array{Int,3} diff --git a/PRASCore.jl/src/Results/GeneratorAvailability.jl b/PRASCore.jl/src/Results/GeneratorAvailability.jl index 1728a7fd..67f0541d 100644 --- a/PRASCore.jl/src/Results/GeneratorAvailability.jl +++ b/PRASCore.jl/src/Results/GeneratorAvailability.jl @@ -56,7 +56,7 @@ accumulatortype(::GeneratorAvailability) = GenAvailabilityAccumulator struct GeneratorAvailabilityResult{N,L,T<:Period} <: AbstractAvailabilityResult{N,L,T} generators::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} available::Array{Bool,3} diff --git a/PRASCore.jl/src/Results/GeneratorStorageAvailability.jl b/PRASCore.jl/src/Results/GeneratorStorageAvailability.jl index ba040873..67207431 100644 --- a/PRASCore.jl/src/Results/GeneratorStorageAvailability.jl +++ b/PRASCore.jl/src/Results/GeneratorStorageAvailability.jl @@ -56,7 +56,7 @@ accumulatortype(::GeneratorStorageAvailability) = GenStorAvailabilityAccumulator struct GeneratorStorageAvailabilityResult{N,L,T<:Period} <: AbstractAvailabilityResult{N,L,T} generatorstorages::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} available::Array{Bool,3} diff --git a/PRASCore.jl/src/Results/GeneratorStorageEnergy.jl b/PRASCore.jl/src/Results/GeneratorStorageEnergy.jl index c943ec02..af3d2f14 100644 --- a/PRASCore.jl/src/Results/GeneratorStorageEnergy.jl +++ b/PRASCore.jl/src/Results/GeneratorStorageEnergy.jl @@ -64,7 +64,7 @@ struct GeneratorStorageEnergyResult{N,L,T<:Period,E<:EnergyUnit} <: AbstractEner nsamples::Union{Int,Nothing} generatorstorages::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} energy_mean::Matrix{Float64} diff --git a/PRASCore.jl/src/Results/GeneratorStorageEnergySamples.jl b/PRASCore.jl/src/Results/GeneratorStorageEnergySamples.jl index 0273c12e..20d53c98 100644 --- a/PRASCore.jl/src/Results/GeneratorStorageEnergySamples.jl +++ b/PRASCore.jl/src/Results/GeneratorStorageEnergySamples.jl @@ -59,7 +59,7 @@ accumulatortype(::GeneratorStorageEnergySamples) = GenStorageEnergySamplesAccumu struct GeneratorStorageEnergySamplesResult{N,L,T<:Period,E<:EnergyUnit} <: AbstractEnergyResult{N,L,T} generatorstorages::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} energy::Array{Int,3} diff --git a/PRASCore.jl/src/Results/LineAvailability.jl b/PRASCore.jl/src/Results/LineAvailability.jl index f03c9ed9..bd6dcd0f 100644 --- a/PRASCore.jl/src/Results/LineAvailability.jl +++ b/PRASCore.jl/src/Results/LineAvailability.jl @@ -27,7 +27,7 @@ struct LineAvailability <: ResultSpec end struct LineAvailabilityResult{N,L,T<:Period} <: AbstractAvailabilityResult{N,L,T} lines::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} available::Array{Bool,3} diff --git a/PRASCore.jl/src/Results/Shortfall.jl b/PRASCore.jl/src/Results/Shortfall.jl index 30c22bf7..81468a79 100644 --- a/PRASCore.jl/src/Results/Shortfall.jl +++ b/PRASCore.jl/src/Results/Shortfall.jl @@ -137,7 +137,7 @@ struct ShortfallResult{N, L, T <: Period, E <: EnergyUnit, S} <: AbstractShortfallResult{N, L, T} nsamples::Union{Int, Nothing} regions::Regions - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} eventperiod_mean::Float64 eventperiod_std::Float64 @@ -164,7 +164,7 @@ struct ShortfallResult{N, L, T <: Period, E <: EnergyUnit, S} <: function ShortfallResult{N,L,T,E,S}( nsamples::Union{Int,Nothing}, regions::Regions, - timestamps::StepRange{ZonedDateTime,T}, + timestamps::AbstractVector{ZonedDateTime}, eventperiod_mean::Float64, eventperiod_std::Float64, eventperiod_region_mean::Vector{Float64}, diff --git a/PRASCore.jl/src/Results/ShortfallSamples.jl b/PRASCore.jl/src/Results/ShortfallSamples.jl index 0b9ac463..f0172fa6 100644 --- a/PRASCore.jl/src/Results/ShortfallSamples.jl +++ b/PRASCore.jl/src/Results/ShortfallSamples.jl @@ -79,7 +79,7 @@ accumulatortype(::S) where { struct ShortfallSamplesResult{N,L,T<:Period,P<:PowerUnit,E<:EnergyUnit, S} <: AbstractShortfallResult{N,L,T} regions::Regions{N,P} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} shortfall::Array{Int,3} # r x t x s diff --git a/PRASCore.jl/src/Results/StorageAvailability.jl b/PRASCore.jl/src/Results/StorageAvailability.jl index 46b5aa27..f5090618 100644 --- a/PRASCore.jl/src/Results/StorageAvailability.jl +++ b/PRASCore.jl/src/Results/StorageAvailability.jl @@ -55,7 +55,7 @@ accumulatortype(::StorageAvailability) = StorAvailabilityAccumulator struct StorageAvailabilityResult{N,L,T<:Period} <: AbstractAvailabilityResult{N,L,T} storages::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} available::Array{Bool,3} diff --git a/PRASCore.jl/src/Results/StorageEnergy.jl b/PRASCore.jl/src/Results/StorageEnergy.jl index 206e708b..829bb2f8 100644 --- a/PRASCore.jl/src/Results/StorageEnergy.jl +++ b/PRASCore.jl/src/Results/StorageEnergy.jl @@ -64,7 +64,7 @@ struct StorageEnergyResult{N,L,T<:Period,E<:EnergyUnit} <: AbstractEnergyResult{ nsamples::Union{Int,Nothing} storages::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} energy_mean::Matrix{Float64} diff --git a/PRASCore.jl/src/Results/StorageEnergySamples.jl b/PRASCore.jl/src/Results/StorageEnergySamples.jl index 688f5ef6..6661c5c9 100644 --- a/PRASCore.jl/src/Results/StorageEnergySamples.jl +++ b/PRASCore.jl/src/Results/StorageEnergySamples.jl @@ -57,7 +57,7 @@ accumulatortype(::StorageEnergySamples) = StorageEnergySamplesAccumulator struct StorageEnergySamplesResult{N,L,T<:Period,E<:EnergyUnit} <: AbstractEnergyResult{N,L,T} storages::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} energy::Array{Int,3} diff --git a/PRASCore.jl/src/Results/Surplus.jl b/PRASCore.jl/src/Results/Surplus.jl index 6c1e065b..46f26e90 100644 --- a/PRASCore.jl/src/Results/Surplus.jl +++ b/PRASCore.jl/src/Results/Surplus.jl @@ -61,7 +61,7 @@ struct SurplusResult{N,L,T<:Period,P<:PowerUnit} <: AbstractSurplusResult{N,L,T} nsamples::Union{Int,Nothing} regions::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} surplus_mean::Matrix{Float64} diff --git a/PRASCore.jl/src/Results/SurplusSamples.jl b/PRASCore.jl/src/Results/SurplusSamples.jl index 2d5b02f1..489aba8b 100644 --- a/PRASCore.jl/src/Results/SurplusSamples.jl +++ b/PRASCore.jl/src/Results/SurplusSamples.jl @@ -57,7 +57,7 @@ accumulatortype(::SurplusSamples) = SurplusSamplesAccumulator struct SurplusSamplesResult{N,L,T<:Period,P<:PowerUnit} <: AbstractSurplusResult{N,L,T} regions::Vector{String} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} surplus::Array{Int,3} diff --git a/PRASCore.jl/src/Results/Utilization.jl b/PRASCore.jl/src/Results/Utilization.jl index ec5b2a4e..0d3a98d2 100644 --- a/PRASCore.jl/src/Results/Utilization.jl +++ b/PRASCore.jl/src/Results/Utilization.jl @@ -77,7 +77,7 @@ struct UtilizationResult{N,L,T<:Period} <: AbstractUtilizationResult{N,L,T} nsamples::Union{Int,Nothing} interfaces::Vector{Pair{String,String}} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} utilization_mean::Matrix{Float64} diff --git a/PRASCore.jl/src/Results/UtilizationSamples.jl b/PRASCore.jl/src/Results/UtilizationSamples.jl index b56410dc..e6a8e3e2 100644 --- a/PRASCore.jl/src/Results/UtilizationSamples.jl +++ b/PRASCore.jl/src/Results/UtilizationSamples.jl @@ -74,7 +74,7 @@ accumulatortype(::UtilizationSamples) = UtilizationSamplesAccumulator struct UtilizationSamplesResult{N,L,T<:Period} <: AbstractUtilizationResult{N,L,T} interfaces::Vector{Pair{String,String}} - timestamps::StepRange{ZonedDateTime,T} + timestamps::AbstractVector{ZonedDateTime} utilization::Array{Float64,3} diff --git a/PRASCore.jl/src/Systems/SystemModel.jl b/PRASCore.jl/src/Systems/SystemModel.jl index 65f7c926..35198acc 100644 --- a/PRASCore.jl/src/Systems/SystemModel.jl +++ b/PRASCore.jl/src/Systems/SystemModel.jl @@ -23,7 +23,9 @@ details on components of a system model. - `region_genstor_idxs`: Mapping of hybrid resources to their respective regions - `lines`: Collection of transmission lines connecting regions (Type - [Lines](@ref)) - `interface_line_idxs`: Mapping of transmission lines to interfaces -- `timestamps`: Time range for the simulation period +- `timestamps`: Time axis for the simulation period. Either a contiguous + `StepRange` of timestamps (single time slice) or a non-contiguous axis built + from multiple slices (see the slice-vector constructor below) - `attrs`: Dictionary of system metadata and attributes # Constructors @@ -42,6 +44,20 @@ Create a single-node system model with specified generators, storage, and load p timestamps::StepRange{DateTime}, [attrs]) Create a system model with `DateTime` timestamps (will be converted to UTC time zone). + + SystemModel(regions, interfaces, generators, region_gen_idxs, storages, region_stor_idxs, + generatorstorages, region_genstor_idxs, lines, interface_line_idxs, + slices::Vector{<:StepRange{ZonedDateTime}}, [attrs]) + +Create a system model with a **non-contiguous** time axis. Each element of +`slices` is a contiguous `StepRange` of timestamps, and the gaps between slices +make the overall axis non-contiguous (e.g. a representative summer week plus a +representative winter week). All slices must share the same timestep and be +strictly ordered and non-overlapping; the total number of timesteps across all +slices must equal `N`. + +The time axis must be one of these forms: a flat `Vector` of individual +timestamps is not accepted. """ struct SystemModel{N, L, T <: Period, P <: PowerUnit, E <: EnergyUnit} regions::Regions{N, P} @@ -62,7 +78,11 @@ struct SystemModel{N, L, T <: Period, P <: PowerUnit, E <: EnergyUnit} lines::Lines{N,L,T,P} interface_line_idxs::Vector{UnitRange{Int}} - timestamps::StepRange{ZonedDateTime,T} + # Either a contiguous `StepRange` (single time slice) or a `SlicedTimestamps` + # (multiple non-contiguous slices). Both behave as a flat length-N vector. + # Kept as a concrete 2-member Union (not `AbstractVector`) so the field stays + # type-stable via union splitting. + timestamps::Union{StepRange{ZonedDateTime,T}, SlicedTimestamps{T}} attrs::Dict{String, String} @@ -75,7 +95,7 @@ struct SystemModel{N, L, T <: Period, P <: PowerUnit, E <: EnergyUnit} 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}}, - timestamps::StepRange{ZonedDateTime,T}, + timestamps::Union{StepRange{ZonedDateTime,T}, SlicedTimestamps{T}}, attrs::Dict{String, String}=Dict{String, String}() ) where {N,L,T<:Period,P<:PowerUnit,E<:EnergyUnit} @@ -98,7 +118,9 @@ struct SystemModel{N, L, T <: Period, P <: PowerUnit, E <: EnergyUnit} 1 <= interfaces.regions_from[i] < interfaces.regions_to[i] <= n_regions for i in 1:n_interfaces) - @assert step(timestamps) == T(L) + # `timestep` returns the uniform step `T(L)` for both a contiguous + # `StepRange` and a non-contiguous `SlicedTimestamps`. + @assert timestep(timestamps) == T(L) @assert length(timestamps) == N new{N,L,T,P,E}( @@ -121,7 +143,7 @@ function SystemModel{}( 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}}, - timestamps::StepRange{ZonedDateTime,T}, + timestamps::Union{StepRange{ZonedDateTime,T}, SlicedTimestamps{T}}, attrs::Dict{String, String}=Dict{String, String}() ) where {N,L,T<:Period,P<:PowerUnit,E<:EnergyUnit} @@ -134,7 +156,56 @@ function SystemModel{}( lines, interface_line_idxs, timestamps, attrs) end - + +# Non-contiguous time axis constructors: accept a vector of slices, where each +# slice is itself a contiguous `StepRange` and the gaps between slices make the +# overall axis non-contiguous. The slices are wrapped in a `SlicedTimestamps` +# before forwarding to the base constructors. The total number of timesteps +# across all slices must equal N. + +# - demand responses included +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 + +# - no demand responses +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 + # No time zone constructor - demand responses included function SystemModel( regions::Regions{N,P}, interfaces::Interfaces{N,P}, @@ -297,12 +368,21 @@ function Base.show(io::IO, ::MIME"text/plain", sys::SystemModel{N,L,T,P,E}) wher println(io, " DemandResponses: $(length(sys.demandresponses)) units") println(io, " Lines: $(length(sys.lines))") println(io, "\nTime series:") - println(io, " Start time: $(first(sys.timestamps))") - println(io, " Resolution: $L $time_unit") - println(io, " Number of time steps: $(N)") - println(io, " End time: $(last(sys.timestamps))") + if sys.timestamps isa SlicedTimestamps + slices = sys.timestamps.slices + println(io, " Resolution: $L $time_unit") + println(io, " Number of time steps: $(N) (across $(length(slices)) slices)") + for (k, slice) in enumerate(slices) + println(io, " Slice $k: $(first(slice)) → $(last(slice)) ($(length(slice)) steps)") + end + else + println(io, " Start time: $(first(sys.timestamps))") + println(io, " Resolution: $L $time_unit") + println(io, " Number of time steps: $(N)") + println(io, " End time: $(last(sys.timestamps))") + end println(io, " Time zone: $(TimeZone(first(sys.timestamps)))") - + # Format attributes as key-value pairs sys_attributes = sys.attrs if !isempty(sys_attributes) diff --git a/PRASCore.jl/src/Systems/Systems.jl b/PRASCore.jl/src/Systems/Systems.jl index 405e9a80..63a28f07 100644 --- a/PRASCore.jl/src/Systems/Systems.jl +++ b/PRASCore.jl/src/Systems/Systems.jl @@ -29,6 +29,7 @@ export include("units.jl") include("collections.jl") include("assets.jl") +include("utils.jl") include("SystemModel.jl") include("TestData.jl") diff --git a/PRASCore.jl/src/Systems/utils.jl b/PRASCore.jl/src/Systems/utils.jl new file mode 100644 index 00000000..9864ae3d --- /dev/null +++ b/PRASCore.jl/src/Systems/utils.jl @@ -0,0 +1,94 @@ +""" + SlicedTimestamps{T<:Period} <: AbstractVector{ZonedDateTime} + +A non-contiguous time axis for a [`SystemModel`](@ref), made up of one or more +contiguous `StepRange` "slices" with gaps between them (e.g. a representative +summer week plus a representative winter week). + +The slices are stored as-is in `slices`, preserving their structure for file I/O +and display. Because `SlicedTimestamps` subtypes `AbstractVector{ZonedDateTime}`, +it also behaves as a flat, length-`N` vector of timestamps (where `N` is the total +number of timesteps across all slices) — `length`, integer indexing, iteration, +`findfirst`, etc. all operate on the concatenated `1:N` sequence. This lets the +simulation engine and result objects treat the time axis exactly as they would a +single contiguous range. + +All slices must share the same step (the timestep `T(L)`) and be strictly ordered +and non-overlapping. + +# Fields +- `slices`: the contiguous `StepRange` slices, in order +- `offsets`: cumulative timestep counts, `offsets[k]` is the number of timesteps + preceding slice `k`; length `length(slices)+1` with `offsets[end] == N` +""" +struct SlicedTimestamps{T<:Period} <: AbstractVector{ZonedDateTime} + slices::Vector{StepRange{ZonedDateTime,T}} + offsets::Vector{Int} + + function SlicedTimestamps(slices::Vector{StepRange{ZonedDateTime,T}}) where {T<:Period} + + n = length(slices) + n > 0 || throw(ArgumentError("SlicedTimestamps requires at least one slice")) + + Δ = step(first(slices)) + for (k, slice) in enumerate(slices) + length(slice) > 0 || + throw(ArgumentError("Timestamp slice $k is empty")) + step(slice) == Δ || + throw(ArgumentError( + "All timestamp slices must share the same step; slice $k has " * + "step $(step(slice)) but slice 1 has step $Δ")) + end + + # Slices must be strictly ordered and non-overlapping + for k in 1:(n - 1) + first(slices[k + 1]) > last(slices[k]) || + throw(ArgumentError( + "Timestamp slices must be strictly ordered and non-overlapping: " * + "slice $(k + 1) starts at $(first(slices[k + 1])), which is not " * + "after the end of slice $k at $(last(slices[k]))")) + end + + offsets = Vector{Int}(undef, n + 1) + offsets[1] = 0 + for k in 1:n + offsets[k + 1] = offsets[k] + length(slices[k]) + end + + new{T}(slices, offsets) + + end + +end + +Base.size(ts::SlicedTimestamps) = (ts.offsets[end],) + +Base.IndexStyle(::Type{<:SlicedTimestamps}) = IndexLinear() + +function Base.getindex(ts::SlicedTimestamps, i::Int) + @boundscheck checkbounds(ts, i) + k = searchsortedlast(ts.offsets, i - 1) + return @inbounds ts.slices[k][i - ts.offsets[k]] +end + +Base.:(==)(x::SlicedTimestamps, y::SlicedTimestamps) = x.slices == y.slices + +Base.show(io::IO, ts::SlicedTimestamps) = show(io, ts.slices) + +function Base.show(io::IO, ::MIME"text/plain", ts::SlicedTimestamps) + print(io, "[") + for (k, s) in enumerate(ts.slices) + k == 1 || print(io, ",\n ") + show(io, s) + end + print(io, "]") +end + +""" + timestep(ts) -> Period + +The (uniform) timestep of a time axis, i.e. `T(L)`. Works for both a contiguous +`StepRange` and a [`SlicedTimestamps`](@ref). +""" +timestep(ts::SlicedTimestamps) = step(first(ts.slices)) +timestep(ts::AbstractRange{ZonedDateTime}) = step(ts) diff --git a/PRASCore.jl/test/Systems/SystemModel.jl b/PRASCore.jl/test/Systems/SystemModel.jl index 0bf5f577..17a81a1b 100644 --- a/PRASCore.jl/test/Systems/SystemModel.jl +++ b/PRASCore.jl/test/Systems/SystemModel.jl @@ -141,5 +141,93 @@ @test_throws "Asset type Lines is not supported. Supported types are: Generators, Storages, GeneratorStorages" multi_region_sys_with_attrs["Region A", Lines] end + + @testset "Non-contiguous (multi-slice) SystemModel" begin + + # Two 5-hour slices (summer + winter) -> 10 timesteps total = N + slices = [ + ZonedDateTime(2020, 1, 1, 0, tz):Hour(1):ZonedDateTime(2020, 1, 1, 4, tz), + ZonedDateTime(2020, 6, 1, 0, tz):Hour(1):ZonedDateTime(2020, 6, 1, 4, tz), + ] + + sys = SystemModel( + regions, interfaces, + generators, gen_regions, storages, stor_regions, + generatorstorages, genstor_regions, + demandresponses, dr_regions, + lines, line_interfaces, + slices) + + @test sys isa SystemModel + @test sys.timestamps isa SlicedTimestamps + @test length(sys.timestamps) == 10 + + # Flat 1:N indexing concatenates the slices + @test sys.timestamps[1] == first(slices[1]) + @test sys.timestamps[5] == last(slices[1]) + @test sys.timestamps[6] == first(slices[2]) + @test sys.timestamps[10] == last(slices[2]) + @test collect(sys.timestamps) == vcat(collect.(slices)...) + + # Lookups by timestamp still work across the gap + @test findfirst(==(first(slices[2])), sys.timestamps) == 6 + + # Overlapping / out-of-order slices are rejected + overlapping = [ + ZonedDateTime(2020, 1, 1, 0, tz):Hour(1):ZonedDateTime(2020, 1, 1, 4, tz), + ZonedDateTime(2020, 1, 1, 3, tz):Hour(1):ZonedDateTime(2020, 1, 1, 7, tz), + ] + @test_throws ArgumentError SystemModel( + regions, interfaces, + generators, gen_regions, storages, stor_regions, + generatorstorages, genstor_regions, + demandresponses, dr_regions, + lines, line_interfaces, + overlapping) + + # Total slice length not matching N is rejected + wrong_length = [ + ZonedDateTime(2020, 1, 1, 0, tz):Hour(1):ZonedDateTime(2020, 1, 1, 4, tz), + ZonedDateTime(2020, 6, 1, 0, tz):Hour(1):ZonedDateTime(2020, 6, 1, 3, tz), + ] + @test_throws AssertionError SystemModel( + regions, interfaces, + generators, gen_regions, storages, stor_regions, + generatorstorages, genstor_regions, + demandresponses, dr_regions, + lines, line_interfaces, + wrong_length) + + io = IOBuffer() + show(io, "text/plain", sys) + text = String(take!(io)) + @test occursin("across 2 slices", text) + @test occursin("Slice 1:", text) + @test occursin("Slice 2:", text) + end + + @testset "Mismatched timestep period type" begin + + # The system assets use Hour units (T = Hour, L = 1). A contiguous range + # whose step is Minute(60) is *equal* in value to Hour(1) -- so it would + # pass the `timestep(timestamps) == T(L)` assertion -- but has a different + # period type. It must be rejected at dispatch (clean MethodError) rather + # than constructing or failing opaquely when assigned into the T-tied + # timestamps field. + minute_timestamps = + ZonedDateTime(2020, 1, 1, 0, tz):Minute(60):ZonedDateTime(2020, 1, 1, 9, tz) + + @test step(minute_timestamps) == Hour(1) # equal value... + @test step(minute_timestamps) isa Minute # ...but different period type + @test length(minute_timestamps) == 10 # matches N, so length check would pass + + @test_throws MethodError SystemModel( + regions, interfaces, + generators, gen_regions, storages, stor_regions, + generatorstorages, genstor_regions, + demandresponses, dr_regions, + lines, line_interfaces, + minute_timestamps) + end end diff --git a/PRASCore.jl/test/runtests.jl b/PRASCore.jl/test/runtests.jl index 1e6c1f3c..1097d7ba 100644 --- a/PRASCore.jl/test/runtests.jl +++ b/PRASCore.jl/test/runtests.jl @@ -5,7 +5,7 @@ using Test using TimeZones import PRASCore.Results: MeanEstimate, ReliabilityMetric -import PRASCore.Systems: TestData, Regions +import PRASCore.Systems: TestData, Regions, SlicedTimestamps withinrange(x::ReliabilityMetric, y::Real, n::Real) = isapprox(val(x), y, atol=n*stderror(x)) diff --git a/PRASFiles.jl/Project.toml b/PRASFiles.jl/Project.toml index ba238a50..926cf1a1 100644 --- a/PRASFiles.jl/Project.toml +++ b/PRASFiles.jl/Project.toml @@ -6,7 +6,7 @@ authors = [ "Julian Florez " ] -version = "0.8.0" +version = "0.9.0" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" @@ -21,7 +21,7 @@ TimeZones = "f269a46b-ccf7-5d73-abea-4c690281aa53" Dates = "1" HDF5 = "0.16,0.17" JSON3 = "1.14" -PRASCore = "0.8.0" +PRASCore = "0.9" StatsBase = "0.34" StructTypes = "1.11" TimeZones = "1" diff --git a/PRASFiles.jl/src/PRASFiles.jl b/PRASFiles.jl/src/PRASFiles.jl index 1baed2da..0abc0218 100644 --- a/PRASFiles.jl/src/PRASFiles.jl +++ b/PRASFiles.jl/src/PRASFiles.jl @@ -2,7 +2,8 @@ module PRASFiles import PRASCore.Systems: SystemModel, Regions, Interfaces, Generators, Storages, GeneratorStorages, DemandResponses, Lines, - timeunits, powerunits, energyunits, unitsymbol + timeunits, powerunits, energyunits, unitsymbol, + SlicedTimestamps import PRASCore.Results: EUE, LOLE, NEUE, ShortfallResult, ShortfallSamplesResult, AbstractShortfallResult, Result import StatsBase: mean diff --git a/PRASFiles.jl/src/Systems/read.jl b/PRASFiles.jl/src/Systems/read.jl index d49f26cc..bdf3d0a8 100644 --- a/PRASFiles.jl/src/Systems/read.jl +++ b/PRASFiles.jl/src/Systems/read.jl @@ -20,7 +20,9 @@ function SystemModel(inputfile::String) # Determine the appropriate version of the importer to use return if (0,5,0) <= version < (0,8,0) systemmodel_0_5(f) - elseif (0,8,0) <= version < (0,9,0) + elseif (0,8,0) <= version < (0,10,0) + # v0.9.x adds optional non-contiguous time-slice metadata, which the + # 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.") @@ -53,8 +55,19 @@ function _systemmodel_core(f::File) type_params = (N,L,T,P,E) timestep = T(L) - end_timestamp = start_timestamp + (N-1)*timestep - timestamps = StepRange(start_timestamp, timestep, end_timestamp) + + 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 + end_timestamp = start_timestamp + (N-1)*timestep + timestamps = StepRange(start_timestamp, timestep, end_timestamp) + end has_regions = haskey(f, "regions") has_generators = haskey(f, "generators") @@ -386,7 +399,8 @@ function read_attrs(f::File) metadata = attributes(f) reqd_attrs_keys = ["pras_dataversion", "start_timestamp", "timestep_count", - "timestep_length", "timestep_unit", "power_unit", "energy_unit"] + "timestep_length", "timestep_unit", "power_unit", "energy_unit", + "n_slices", "slice_start_timestamps", "slice_lengths"] addl_attrs_keys = setdiff(keys(metadata), reqd_attrs_keys) diff --git a/PRASFiles.jl/src/Systems/write.jl b/PRASFiles.jl/src/Systems/write.jl index 1b3cb8c0..2c23189a 100644 --- a/PRASFiles.jl/src/Systems/write.jl +++ b/PRASFiles.jl/src/Systems/write.jl @@ -72,9 +72,19 @@ function process_metadata!( attrs["power_unit"] = unitsymbol(P) attrs["energy_unit"] = unitsymbol(E) - attrs["start_timestamp"] = string(sys.timestamps.start); + attrs["start_timestamp"] = string(first(sys.timestamps)); attrs["pras_dataversion"] = "v" * string(pkgversion(PRASFiles)); + # 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 diff --git a/PRASFiles.jl/test/runtests.jl b/PRASFiles.jl/test/runtests.jl index 2249c6dd..45b94d0d 100644 --- a/PRASFiles.jl/test/runtests.jl +++ b/PRASFiles.jl/test/runtests.jl @@ -3,6 +3,8 @@ using PRASFiles using Test using JSON3 +import PRASCore.Systems: SlicedTimestamps + @testset verbose=true "PRASFiles" begin @testset "Roundtrip .pras files to/from disk" begin @@ -30,6 +32,51 @@ using JSON3 end + @testset "Non-contiguous time slices" begin + + path = dirname(@__FILE__) + + # Rebuild the toy model with a non-contiguous time axis: split its N + # timesteps into two slices separated by a large gap. Asset data is + # unchanged; only the timestamps are relabeled. + toy = PRASFiles.toymodel() + N = length(toy.timestamps) + Δ = step(toy.timestamps) + n1 = N ÷ 2 + t0 = first(toy.timestamps) + slice1 = t0:Δ:(t0 + (n1 - 1) * Δ) + s2 = t0 + (N + 50) * Δ + slice2 = s2:Δ:(s2 + (N - n1 - 1) * Δ) + + noncontig = SystemModel( + toy.regions, toy.interfaces, + toy.generators, toy.region_gen_idxs, + toy.storages, toy.region_stor_idxs, + toy.generatorstorages, toy.region_genstor_idxs, + toy.demandresponses, toy.region_dr_idxs, + toy.lines, toy.interface_line_idxs, + [slice1, slice2], toy.attrs) + + @test noncontig.timestamps isa SlicedTimestamps + @test length(noncontig.timestamps) == N + + # Round-trip through a .pras file preserves the slices + savemodel(noncontig, path * "/toy_noncontig.pras") + rt = SystemModel(path * "/toy_noncontig.pras") + @test rt.timestamps isa SlicedTimestamps + @test noncontig == rt + # Slice metadata must not leak into user attributes + @test !haskey(PRASFiles.read_attrs(path * "/toy_noncontig.pras"), "n_slices") + + # assess runs over the concatenated timesteps and results index across the gap + sf = assess(noncontig, + SequentialMonteCarlo(samples=10, threaded=false, seed=1), + Shortfall())[1] + @test length(sf.timestamps) == N + @test sf[first(slice2)] !== nothing + + end + @testset "Run RTS-GMLC" begin assess(PRASFiles.rts_gmlc(), SequentialMonteCarlo(samples=100), Shortfall()) diff --git a/docs/src/PRAS/sysmodelspec.md b/docs/src/PRAS/sysmodelspec.md index 3bcafb77..aff7148c 100644 --- a/docs/src/PRAS/sysmodelspec.md +++ b/docs/src/PRAS/sysmodelspec.md @@ -43,6 +43,39 @@ inter-annual variability of annual risk metrics using only the built-in methods -- while this is also possible with a single multi-year run, it requires some additional post-processing work. +The time periods modelled need not be one continuous block. A `SystemModel` +can instead be built over a **non-contiguous** time axis made up of several +contiguous *slices* separated by gaps -- for example a representative summer +week and a representative winter week analyzed together. Each slice is supplied +as a contiguous `StepRange` of timestamps, and the constructor that accepts a +vector of such ranges assembles them into a single axis: + +```julia +summer = ZonedDateTime(2024, 7, 1, tz"UTC"):Hour(1):ZonedDateTime(2024, 7, 7, 23, tz"UTC") +winter = ZonedDateTime(2024, 1, 1, tz"UTC"):Hour(1):ZonedDateTime(2024, 1, 7, 23, tz"UTC") +# slices must share the same timestep and be strictly ordered and non-overlapping +sys = SystemModel(regions, interfaces, generators, region_gen_idxs, storages, + region_stor_idxs, generatorstorages, region_genstor_idxs, + lines, interface_line_idxs, [winter, summer]) +``` + +All time-varying resource data is then provided along the concatenated axis, +whose total length is the sum of the slice lengths. Systems with a single slice +behave, and are stored on disk, exactly as a contiguous system; the on-disk +representation of the slices is described in the `.pras` file format's +[Non-contiguous time axis](@ref) attributes. + +!!! tip "Get the time axis from `sys.timestamps`, don't rebuild it" + `sys.timestamps` is the true length-`N` vector of timestamps and can be used + directly -- for plotting, indexing, or lookups. When you need a plain + `Vector` (to build a `DataFrame`, align an external series, or post-process + results), use `collect(sys.timestamps)`. Do **not** reconstruct the axis from + `first(sys.timestamps)` and `length(sys.timestamps)` with a fixed timestep + (e.g. `first(sys.timestamps) .+ (0:length(sys.timestamps)-1) .* timestep`): + that assumes evenly spaced timestamps with no gaps and silently produces + *wrong* times for a non-contiguous system, filling in the gaps between + slices with times that are not actually in the system. + PRAS represents a power system as one or more **regions**, each containing zero or more **generators**, **storages**,**generator-storages**, and **demand responses**. **Interfaces** contain **lines** and diff --git a/docs/src/SystemModel_HDF5_spec.md b/docs/src/SystemModel_HDF5_spec.md index 898818c5..e4710864 100644 --- a/docs/src/SystemModel_HDF5_spec.md +++ b/docs/src/SystemModel_HDF5_spec.md @@ -56,8 +56,22 @@ These attributes are mandatory: - `power_unit`, providing the units for power-related data - `energy_unit`, providing the units for energy-related data -There can be any number of optional attributes which need to also be defined as -key-value pairs and both the key and value are strings of characters. +These attributes are optional and, when present, describe a **non-contiguous** +time axis (a system whose timesteps are split into multiple contiguous slices +with gaps between them, e.g. a representative summer week plus a representative +winter week): + + - `n_slices`, the number of contiguous time slices + - `slice_start_timestamps`, the starting timestamp of each slice + in ISO-8601 format + - `slice_lengths`, the number of timesteps in each slice + +These three attributes are written together or not at all, and only for systems +with more than one slice. When they are absent the time axis is a single +contiguous range, as in earlier versions of this specification. + +There can be any number of additional optional attributes which need to also be +defined as key-value pairs and both the key and value are strings of characters. Each of the required attributes and their contents are explained in more detail below. @@ -89,6 +103,10 @@ of the file's root group labelled `start_timestamp`, as a single `2020-12-31T23:59:59-07:00`, providing year, month, day, hour, minute, second, and timezone offset from UTC (in that order). +For a non-contiguous time axis (see [Non-contiguous time axis](@ref)), +`start_timestamp` is the first timestamp of the first slice, and equals the first +entry of `slice_start_timestamps`. + #### `timestep_count` The total number of timesteps in the simulation should be stored in an @@ -97,6 +115,10 @@ integer. The attribute value should match the number of rows (in C/HDF5 row-major format) in each property dataset in the various resource and resource collection groups. +For a non-contiguous time axis, this is the *total* number of timesteps across +all slices, i.e. the sum of `slice_lengths`. The property datasets are stored as +a single flat axis of this length, with the slices concatenated in order. + #### `timestep_length` The length of a single timestep (in terms of the units defined by @@ -138,6 +160,40 @@ take: - `GWh` indicates power data is in units of gigawatt-hours - `TWh` indicates power data is in units of terawatt-hours +### Non-contiguous time axis + +By default a PRAS system has a single, contiguous time axis: `timestep_count` +evenly spaced timesteps starting at `start_timestamp`. A system may instead use a +**non-contiguous** axis made up of several contiguous *slices* separated by gaps +(for example a representative summer week and a representative winter week +analyzed together). All slices share the same timestep (`timestep_length` / +`timestep_unit`); only the gaps between them break contiguity. + +A non-contiguous axis is described by three optional root-group attributes, which +are written together and only when there is more than one slice. When they are +absent (the common case), readers must treat the axis as a single contiguous +range and behave exactly as for earlier versions of this specification — this is +what keeps single-slice `.pras` files readable by older PRAS versions. + +#### `n_slices` + +The number of contiguous time slices, stored as a single integer. Present only +when greater than 1. + +#### `slice_start_timestamps` + +The starting timestamp of each slice, stored as a one-dimensional array of +`n_slices` ISO-8601 ASCII strings in the same format as `start_timestamp`. The +slices must be strictly ordered and non-overlapping, so +`slice_start_timestamps[1]` equals `start_timestamp`. + +#### `slice_lengths` + +The number of timesteps in each slice, stored as a one-dimensional array of +`n_slices` integers. Their sum must equal `timestep_count`. The end timestamp of +slice `k` is `slice_start_timestamps[k] + (slice_lengths[k] - 1)` timesteps; the +flat property-dataset axis is the slices concatenated in order. + ### Resource / resource collection data The file may define the following six diff --git a/docs/src/changelog.md b/docs/src/changelog.md index d6d7ed37..ee27a6e6 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -1,5 +1,15 @@ # Changelog +## [0.9.0] + +- Add support for non-contiguous time axes: a `SystemModel` can be built from + multiple contiguous timestamp slices (e.g. representative weeks) separated by + gaps. See the [System Model Specification](@ref system_specification) and the + `.pras` file format's [Non-contiguous time axis](@ref) attributes. +- `.pras` files remain backward compatible: single-slice systems are written and + read exactly as before, and the v0.8 reader transparently reads v0.9 files + (falling back to a contiguous axis when the slice metadata is absent). + ## [0.8.0], 2025 - October - Add a demand response component which can model shift and shed type DR devices