From af038ffbff23f44bb4131c05c417a9d84060bf52 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Mon, 20 Jul 2026 22:52:25 -0400 Subject: [PATCH 01/10] Per-type ServiceModel API and per-service sparse service variables --- src/InfrastructureOptimizationModels.jl | 1 - src/common_models/add_constraint_dual.jl | 35 ++++------- src/common_models/add_variable.jl | 43 +++++++------- src/core/service_model.jl | 76 +++++++++--------------- src/operation/emulation_model_store.jl | 22 +++++++ src/operation/problem_template.jl | 2 +- test/test_emulation_model_store.jl | 27 +++++++++ 7 files changed, 112 insertions(+), 94 deletions(-) diff --git a/src/InfrastructureOptimizationModels.jl b/src/InfrastructureOptimizationModels.jl index 18c0374..d98a6fb 100644 --- a/src/InfrastructureOptimizationModels.jl +++ b/src/InfrastructureOptimizationModels.jl @@ -297,7 +297,6 @@ export get_parameter_array export get_network_reduction export get_multiplier_array export get_parameter_column_refs -export get_service_name export get_default_time_series_type export add_expression_container! diff --git a/src/common_models/add_constraint_dual.jl b/src/common_models/add_constraint_dual.jl index 19832dc..7691702 100644 --- a/src/common_models/add_constraint_dual.jl +++ b/src/common_models/add_constraint_dual.jl @@ -30,40 +30,31 @@ function add_constraint_dual!( end # Service model +# +# Services of the same type now share merged constraint containers keyed by +# `(constraint_type, service_type)` with empty meta, so the dual mirrors the existing +# constraint container exactly (as the device/network paths do) rather than building a +# per-service `[service_name]` axis. Guarded against re-creation because grouped +# construction may call this once per formulation group that shares a service type. function add_constraint_dual!( container::OptimizationContainer, sys::IS.InfrastructureSystemsContainer, model::ServiceModel{T, D}, ) where {T <: IS.InfrastructureSystemsComponent, D <: AbstractServiceFormulation} if !isempty(get_duals(model)) - service = get_available_components(model, sys) + time_steps = get_time_steps(container) for constraint_type in get_duals(model) - assign_dual_variable!(container, constraint_type, service, D) + for key in _existing_constraint_keys(container, constraint_type, T) + dual_key = ConstraintKey(get_entry_type(key), T, key.meta) + haskey(get_duals(container), dual_key) && continue + existing = get_constraint(container, key) + _assign_dual_from_existing!(container, key, existing, T, time_steps) + end end end return end -# service formulation -function assign_dual_variable!( - container::OptimizationContainer, - constraint_type::Type{<:ConstraintType}, - service::D, - ::Type{<:AbstractServiceFormulation}, -) where {D <: IS.InfrastructureSystemsComponent} - time_steps = get_time_steps(container) - service_name = IS.get_name(service) - add_dual_container!( - container, - constraint_type, - D, - [service_name], - time_steps; - meta = service_name, - ) - return -end - _existing_constraint_keys( container::OptimizationContainer, ::Type{T}, diff --git a/src/common_models/add_variable.jl b/src/common_models/add_variable.jl index 90dcd77..0d39fa1 100644 --- a/src/common_models/add_variable.jl +++ b/src/common_models/add_variable.jl @@ -77,7 +77,14 @@ function add_variables!( end """ -Add variables to the OptimizationContainer for a service. +Add variables to the OptimizationContainer for a single service and its contributing +devices. + +All services of a given `(VariableType, ServiceType)` share a single sparse container +keyed by `(service_name, device_name, time)`, rather than one dense container per +service disambiguated by a `meta = service_name` field. The container is created lazily +on the first service of a type, and each subsequent call appends that service's slice, so +separate formulation groups sharing a service type append to the same container. """ function add_service_variables!( container::OptimizationContainer, @@ -94,37 +101,29 @@ function add_service_variables!( @assert !isempty(contributing_devices) time_steps = get_time_steps(container) settings = get_settings(container) - binary = get_variable_binary(T, U, F) - - variable = add_variable_container!( - container, - T, - U, - IS.get_name(service), - [IS.get_name(d) for d in contributing_devices], - time_steps, + s_name = IS.get_name(service) + device_names = [IS.get_name(d) for d in contributing_devices] + variable = lazy_container_addition!( + container, T, U, [s_name], device_names, time_steps; sparse = true, ) - + jump_model = get_jump_model(container) for t in time_steps, d in contributing_devices name = IS.get_name(d) - variable[name, t] = JuMP.@variable( - get_jump_model(container), - base_name = "$(T)_$(U)_$(IS.get_name(service))_{$(name), $(t)}", - binary = binary + var = JuMP.@variable( + jump_model, + base_name = "$(T)_$(U)_{$(s_name), $(name), $(t)}", + binary = binary, ) - + variable[(s_name, name, t)] = var ub = get_variable_upper_bound(T, service, d, F) - ub !== nothing && JuMP.set_upper_bound(variable[name, t], ub) - + ub !== nothing && JuMP.set_upper_bound(var, ub) lb = get_variable_lower_bound(T, service, d, F) - lb !== nothing && !binary && JuMP.set_lower_bound(variable[name, t], lb) - + lb !== nothing && !binary && JuMP.set_lower_bound(var, lb) if get_warm_start(settings) init = get_variable_warm_start_value(T, d, F) - init !== nothing && JuMP.set_start_value(variable[name, t], init) + init !== nothing && JuMP.set_start_value(var, init) end end - return end diff --git a/src/core/service_model.jl b/src/core/service_model.jl index 4bb7d0b..b406603 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -13,10 +13,10 @@ function _check_service_formulation(::Type{D}) where {D} end """ -Establishes the model for a particular service specified by type. The optional -`service_name` positional argument assigns the model to a service with that name in the -template. Uses the keyword argument `feedforwards` to enable passing values between -operation models at simulation time. +Establishes the model for all services of a particular type. A `ServiceModel` represents +every service of its type in the system; it has no single service name. Uses the keyword +argument `feedforwards` to enable passing values between operation models at simulation +time. # Arguments @@ -34,42 +34,43 @@ reserves = ServiceModel(PSY.VariableReserve{PSY.ReserveUp}, RangeReserve) mutable struct ServiceModel{D <: IS.InfrastructureSystemsComponent, B} # Heterogeneous by design: concrete Vector of the abstract type, not a UnionAll field. feedforwards::Vector{AbstractAffectFeedforward} - service_name::String use_slacks::Bool duals::Vector{DataType} time_series_names::Dict{Type{<:TimeSeriesParameter}, String} attributes::Dict{String, Any} + # Per service: service name -> device type -> contributing devices. contributing_devices_map::Dict{ - Type{<:IS.InfrastructureSystemsComponent}, - Vector{<:IS.InfrastructureSystemsComponent}, + String, + Dict{DataType, Vector{<:IS.InfrastructureSystemsComponent}}, } subsystem::Union{Nothing, String} # Maps outage UUIDs to monitored components grouped by device type. PNM indexes DF matrices with UUIDs. outages::Dict{Base.UUID, Dict{DataType, Set{String}}} function ServiceModel( ::Type{D}, - ::Type{B}, - service_name::String; + ::Type{B}; use_slacks = false, feedforwards = Vector{AbstractAffectFeedforward}(), duals = Vector{DataType}(), time_series_names = get_default_time_series_names(D, B), attributes = Dict{String, Any}(), contributing_devices_map = Dict{ - Type{<:IS.InfrastructureSystemsComponent}, - Vector{<:IS.InfrastructureSystemsComponent}, + String, + Dict{DataType, Vector{<:IS.InfrastructureSystemsComponent}}, }(), ) where {D <: IS.InfrastructureSystemsComponent, B} attributes_for_model = get_default_attributes(D, B) for (k, v) in attributes attributes_for_model[k] = v end + if !haskey(attributes_for_model, "aggregated_service_model") + push!(attributes_for_model, "aggregated_service_model" => true) + end _check_service_formulation(D) _check_service_formulation(B) new{D, B}( convert(Vector{AbstractAffectFeedforward}, feedforwards), - service_name, use_slacks, duals, time_series_names, @@ -88,53 +89,32 @@ get_formulation( ::ServiceModel{D, B}, ) where {D <: IS.InfrastructureSystemsComponent, B} = B get_feedforwards(m::ServiceModel) = m.feedforwards -get_service_name(m::ServiceModel) = m.service_name get_use_slacks(m::ServiceModel) = m.use_slacks get_duals(m::ServiceModel) = m.duals get_time_series_names(m::ServiceModel) = m.time_series_names get_attributes(m::ServiceModel) = m.attributes get_attribute(m::ServiceModel, key::String) = get(m.attributes, key, nothing) +# Whole nested map: service name -> device type -> contributing devices. get_contributing_devices_map(m::ServiceModel) = m.contributing_devices_map -get_contributing_devices_map(m::ServiceModel, key) = - get(m.contributing_devices_map, key, nothing) +# One service's inner `Dict{DataType, Vector}` (empty Dict if the service is absent). +get_contributing_devices_map(m::ServiceModel, service_name::AbstractString) = + get( + m.contributing_devices_map, + service_name, + Dict{DataType, Vector{<:IS.InfrastructureSystemsComponent}}(), + ) +# All contributing devices across ALL services (flatten the nested map). get_contributing_devices(m::ServiceModel) = - [z for x in values(m.contributing_devices_map) for z in x] + [z for inner in values(m.contributing_devices_map) for x in values(inner) for z in x] +# One service's contributing devices (flattened Vector). +get_contributing_devices(m::ServiceModel, service_name::AbstractString) = + [z for x in values(get_contributing_devices_map(m, service_name)) for z in x] get_subsystem(m::ServiceModel) = m.subsystem get_outages(m::ServiceModel) = m.outages set_subsystem!(m::ServiceModel, id::String) = m.subsystem = id -function ServiceModel( - service_type::Type{D}, - formulation_type::Type{B}; - use_slacks = false, - feedforwards = Vector{AbstractAffectFeedforward}(), - duals = Vector{DataType}(), - time_series_names = get_default_time_series_names(D, B), - attributes = get_default_attributes(D, B), -) where {D <: IS.InfrastructureSystemsComponent, B} - # If more attributes are used later, move free form string to const and organize - # attributes - attributes_for_model = get_default_attributes(D, B) - for (k, v) in attributes - attributes_for_model[k] = v - end - if !haskey(attributes_for_model, "aggregated_service_model") - push!(attributes_for_model, "aggregated_service_model" => true) - end - return ServiceModel( - service_type, - formulation_type, - NO_SERVICE_NAME_PROVIDED; - use_slacks, - feedforwards, - duals, - time_series_names, - attributes = attributes_for_model, - ) -end - -function set_model!(dict::Dict, key::Tuple{String, Symbol}, model::ServiceModel) +function set_model!(dict::Dict, key::Symbol, model::ServiceModel) if haskey(dict, key) @warn "Overwriting $(key) existing model" end @@ -146,6 +126,6 @@ function set_model!( dict::Dict, model::ServiceModel{D, B}, ) where {D <: IS.InfrastructureSystemsComponent, B} - set_model!(dict, (get_service_name(model), Symbol(D)), model) + set_model!(dict, Symbol(D), model) return end diff --git a/src/operation/emulation_model_store.jl b/src/operation/emulation_model_store.jl index 5e7a1fc..92ed0d1 100644 --- a/src/operation/emulation_model_store.jl +++ b/src/operation/emulation_model_store.jl @@ -109,6 +109,28 @@ function write_output!( return end +# Sparse containers (e.g. reserve variables keyed `(service, device, t)`) are stored as +# 2D dense with the non-time tuple flattened into encoded `"a__b"` columns, matching the +# storage `initialize_storage!` allocated via `get_column_names_from_axis_array`. Columns +# are ordered by their encoded string so the flattened matrix aligns with the +# pre-allocated dataset's rows (`set_value!` copies positionally). +function write_output!( + store::EmulationModelStore, + name::Symbol, + key::OptimizationContainerKey, + index::EmulationModelIndexType, + update_timestamp::Dates.DateTime, + array::SparseAxisArray{T, N, K}, +) where {T, N, K <: NTuple{N, Any}} + tuple_columns = unique!([k[1:(N - 1)] for k in keys(array.data)]) + sort!(tuple_columns; by = encode_tuple_to_column) + matrix = _to_matrix(array, tuple_columns) + columns = encode_tuple_to_column.(tuple_columns) + dense = DenseAxisArray(permutedims(matrix), columns, 1:size(matrix, 1)) + write_output!(store, name, key, index, update_timestamp, dense) + return +end + function read_outputs( store::EmulationModelStore{InMemoryDataset}, key::OptimizationContainerKey; diff --git a/src/operation/problem_template.jl b/src/operation/problem_template.jl index 2e4b1f7..75b5932 100644 --- a/src/operation/problem_template.jl +++ b/src/operation/problem_template.jl @@ -1,6 +1,6 @@ const DevicesModelContainer = Dict{Symbol, DeviceModel} -const ServicesModelContainer = Dict{Tuple{String, Symbol}, ServiceModel} +const ServicesModelContainer = Dict{Symbol, ServiceModel} abstract type AbstractProblemTemplate end diff --git a/test/test_emulation_model_store.jl b/test/test_emulation_model_store.jl index ff0ff68..7fa6b4c 100644 --- a/test/test_emulation_model_store.jl +++ b/test/test_emulation_model_store.jl @@ -23,3 +23,30 @@ DatasetContainer fields (Task 2.10). @test isempty(store) @test isempty(IOM.list_keys(store, IOM.VariableType)) end + +@testset "EmulationModelStore sparse 3D container write/read round trip" begin + # Reserve-style containers are sparse and keyed `(service, device, time)`; the store + # flattens the leading dims to encoded `"service__device"` columns. This mirrors the + # DecisionModelStore path but the EmulationModelStore write must line the flattened + # rows up with the pre-allocated dataset (`set_value!` copies positionally). + store = IOM.EmulationModelStore() + key = IOM.VariableKey(TestVariableType, MockComponentType) + values = Dict( + ("s1", "d1", 1) => 1.0, + ("s1", "d2", 1) => 2.0, + ("s2", "d1", 1) => 3.0, + ) + sparse = JuMP.Containers.SparseAxisArray(values) + # Storage is pre-allocated exactly as initialize_storage! would, from the encoded + # column names. + cols = IOM.get_column_names_from_axis_array(key, sparse)[1] + storage = DenseAxisArray(fill(NaN, length(cols), 1), cols, 1:1) + IOM.set_dataset!(store.data_container, key, IOM.InMemoryDataset(storage)) + + IOM.write_output!(store, :variables, key, 1, Dates.DateTime(2024, 1, 1), sparse) + out = IOM.read_outputs(store, key) + + @test out["s1__d1", 1] == 1.0 + @test out["s1__d2", 1] == 2.0 + @test out["s2__d1", 1] == 3.0 +end From 6a593480aad611a0d7539762732cef600a422c69 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Tue, 21 Jul 2026 07:31:45 -0400 Subject: [PATCH 02/10] Remove vestigial aggregated_service_model attribute and unused NO_SERVICE_NAME_PROVIDED const --- src/core/definitions.jl | 1 - src/core/service_model.jl | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/core/definitions.jl b/src/core/definitions.jl index 6fe453f..ee97ae6 100644 --- a/src/core/definitions.jl +++ b/src/core/definitions.jl @@ -127,7 +127,6 @@ const M_VALUE = 1e6 # λ = 0.5 is optimal per Beach et al. (2024), Remark 1. const DNMDT_LAMBDA = 0.5 -const NO_SERVICE_NAME_PROVIDED = "" const UPPER_BOUND = "ub" const LOWER_BOUND = "lb" const MAX_OPTIMIZE_TRIES = 2 diff --git a/src/core/service_model.jl b/src/core/service_model.jl index b406603..47342b0 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -63,9 +63,6 @@ mutable struct ServiceModel{D <: IS.InfrastructureSystemsComponent, B} for (k, v) in attributes attributes_for_model[k] = v end - if !haskey(attributes_for_model, "aggregated_service_model") - push!(attributes_for_model, "aggregated_service_model" => true) - end _check_service_formulation(D) _check_service_formulation(B) From 3c3ade4d0694eabb4442f7f488e706b8e7b311e7 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Tue, 21 Jul 2026 08:28:41 -0400 Subject: [PATCH 03/10] update comment --- src/core/service_model.jl | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/service_model.jl b/src/core/service_model.jl index 47342b0..c4cbd3e 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -14,9 +14,8 @@ end """ Establishes the model for all services of a particular type. A `ServiceModel` represents -every service of its type in the system; it has no single service name. Uses the keyword -argument `feedforwards` to enable passing values between operation models at simulation -time. +every service of its type in the system. Uses the keyword argument `feedforwards` to +enable passing values between operation models at simulation time. # Arguments From 4b5943bda054065747128097fc13dc6bf8f0190c Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Tue, 21 Jul 2026 10:55:34 -0400 Subject: [PATCH 04/10] Reword stale 'grouped construction' dual comment (no grouping remains under per-type) --- src/common_models/add_constraint_dual.jl | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/common_models/add_constraint_dual.jl b/src/common_models/add_constraint_dual.jl index 7691702..4115675 100644 --- a/src/common_models/add_constraint_dual.jl +++ b/src/common_models/add_constraint_dual.jl @@ -31,11 +31,10 @@ end # Service model # -# Services of the same type now share merged constraint containers keyed by +# Services of the same type share merged constraint containers keyed by # `(constraint_type, service_type)` with empty meta, so the dual mirrors the existing # constraint container exactly (as the device/network paths do) rather than building a -# per-service `[service_name]` axis. Guarded against re-creation because grouped -# construction may call this once per formulation group that shares a service type. +# per-service `[service_name]` axis. The `haskey` check keeps dual creation idempotent. function add_constraint_dual!( container::OptimizationContainer, sys::IS.InfrastructureSystemsContainer, From 5e8455dd00d0f300e4e88b0c9b273c22ac1c35bc Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Wed, 22 Jul 2026 00:19:52 -0400 Subject: [PATCH 05/10] Use constraint_type directly in service dual key (get_entry_type(key) is redundant) --- src/common_models/add_constraint_dual.jl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/common_models/add_constraint_dual.jl b/src/common_models/add_constraint_dual.jl index 4115675..6e5d241 100644 --- a/src/common_models/add_constraint_dual.jl +++ b/src/common_models/add_constraint_dual.jl @@ -44,7 +44,9 @@ function add_constraint_dual!( time_steps = get_time_steps(container) for constraint_type in get_duals(model) for key in _existing_constraint_keys(container, constraint_type, T) - dual_key = ConstraintKey(get_entry_type(key), T, key.meta) + # `_existing_constraint_keys` filters on `get_entry_type(key) === constraint_type`, + # so `constraint_type` is exactly the key's entry type (avoids re-deriving it). + dual_key = ConstraintKey(constraint_type, T, key.meta) haskey(get_duals(container), dual_key) && continue existing = get_constraint(container, key) _assign_dual_from_existing!(container, key, existing, T, time_steps) From 17c257eb13ba3292da17eab16ecebacece4d4154 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Wed, 22 Jul 2026 07:48:36 -0400 Subject: [PATCH 06/10] Avoid per-call empty-Dict allocation in get_contributing_devices_map The 3-arg get(dict, key, default) evaluates its default eagerly, so the per-service map accessor allocated a throwaway empty Dict on every call, including the hit path. Return a shared const empty sentinel via the lazy get(f, dict, key) form instead; the accessor is read-only for all callers (only the no-arg whole-map form is mutated via get!), so sharing is safe. Also flag the two flattening get_contributing_devices methods with a TODO: flattening across multiple contributing device types widens the element type and costs downstream type stability; revisit by iterating the concretely-typed per-device-type map groups. --- src/core/service_model.jl | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/core/service_model.jl b/src/core/service_model.jl index c4cbd3e..2696212 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -92,17 +92,26 @@ get_attributes(m::ServiceModel) = m.attributes get_attribute(m::ServiceModel, key::String) = get(m.attributes, key, nothing) # Whole nested map: service name -> device type -> contributing devices. get_contributing_devices_map(m::ServiceModel) = m.contributing_devices_map -# One service's inner `Dict{DataType, Vector}` (empty Dict if the service is absent). +# Shared empty sentinel returned when a service has no entry in the map. The 3-arg +# `get(dict, key, default)` evaluates `default` eagerly, so building the empty Dict inline +# would allocate a throwaway Dict on every call, including the (common) hit path. This +# accessor is read-only for all callers (only the no-arg whole-map form is mutated, via +# `get!`), so a single shared const is safe to hand back on the miss path. +const _EMPTY_CONTRIBUTING_DEVICES_MAP = + Dict{DataType, Vector{<:IS.InfrastructureSystemsComponent}}() +# One service's inner `Dict{DataType, Vector}` (shared empty Dict if the service is absent). get_contributing_devices_map(m::ServiceModel, service_name::AbstractString) = - get( - m.contributing_devices_map, - service_name, - Dict{DataType, Vector{<:IS.InfrastructureSystemsComponent}}(), - ) + get(() -> _EMPTY_CONTRIBUTING_DEVICES_MAP, m.contributing_devices_map, service_name) # All contributing devices across ALL services (flatten the nested map). +# TODO(services stability): flattening across device types yields a Vector whose element +# type widens to the abstract common ancestor when a service has more than one contributing +# device type, so downstream builders lose type stability. Revisit by iterating the +# per-(device type) map groups (each concretely typed) instead of flattening. get_contributing_devices(m::ServiceModel) = [z for inner in values(m.contributing_devices_map) for x in values(inner) for z in x] # One service's contributing devices (flattened Vector). +# TODO(services stability): same multi-device-type widening as the all-services flatten +# above; revisit to iterate the concretely-typed per-device-type map groups. get_contributing_devices(m::ServiceModel, service_name::AbstractString) = [z for x in values(get_contributing_devices_map(m, service_name)) for z in x] get_subsystem(m::ServiceModel) = m.subsystem From 4069022715a488a4f70fefba78f4e849a6eddecf Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Sat, 25 Jul 2026 18:02:48 -0700 Subject: [PATCH 07/10] Use plain 3-arg get for per-service contributing_devices_map (drop redundant closure) The lazy-default closure guarded against an allocation that cannot happen: the empty default is already a const, so the 3-arg get references it rather than rebuilding it. Confirmed 0 alloc on hit and miss paths. --- src/core/service_model.jl | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/core/service_model.jl b/src/core/service_model.jl index 2696212..eadf0a6 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -92,16 +92,15 @@ get_attributes(m::ServiceModel) = m.attributes get_attribute(m::ServiceModel, key::String) = get(m.attributes, key, nothing) # Whole nested map: service name -> device type -> contributing devices. get_contributing_devices_map(m::ServiceModel) = m.contributing_devices_map -# Shared empty sentinel returned when a service has no entry in the map. The 3-arg -# `get(dict, key, default)` evaluates `default` eagerly, so building the empty Dict inline -# would allocate a throwaway Dict on every call, including the (common) hit path. This -# accessor is read-only for all callers (only the no-arg whole-map form is mutated, via -# `get!`), so a single shared const is safe to hand back on the miss path. +# Shared empty sentinel returned when a service has no entry in the map. Read-only for all +# callers (only the no-arg whole-map form is mutated, via `get!`), so handing back one shared +# const on the miss path is safe. As a const it is referenced, not rebuilt, so the 3-arg +# `get` below stays allocation-free on both the hit and miss paths. const _EMPTY_CONTRIBUTING_DEVICES_MAP = Dict{DataType, Vector{<:IS.InfrastructureSystemsComponent}}() # One service's inner `Dict{DataType, Vector}` (shared empty Dict if the service is absent). get_contributing_devices_map(m::ServiceModel, service_name::AbstractString) = - get(() -> _EMPTY_CONTRIBUTING_DEVICES_MAP, m.contributing_devices_map, service_name) + get(m.contributing_devices_map, service_name, _EMPTY_CONTRIBUTING_DEVICES_MAP) # All contributing devices across ALL services (flatten the nested map). # TODO(services stability): flattening across device types yields a Vector whose element # type widens to the abstract common ancestor when a service has more than one contributing From 12b3835f2386fb8cf309bfea99d44e4154662262 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Sun, 26 Jul 2026 11:24:49 -0700 Subject: [PATCH 08/10] Add extendable sparse_variable_key_type trait for SparseVariableType containers The SparseVariableType auto-created container was hardcoded to the 3D device-offer PWL key `(device_name, segment, time)`. Introduce `sparse_variable_key_type(::Type{<:SparseVariableType})` returning that tuple by default, and have `_get_pwl_variables_container` build the empty SparseAxisArray from it. Downstream packages override the trait for variable types that need a different key shape - e.g. a per-service reserve offer keyed `(service_name, device_name, segment, time)`. No behavior change for existing sparse variable types (default returns the prior 3-tuple). --- src/core/optimization_container.jl | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/core/optimization_container.jl b/src/core/optimization_container.jl index 2fad5b6..696ab10 100644 --- a/src/core/optimization_container.jl +++ b/src/core/optimization_container.jl @@ -731,9 +731,18 @@ function add_variable_container!( return _add_container!(container, T, U, JuMP.VariableRef, sparse, axs...; meta = meta) end -function _get_pwl_variables_container() - contents = Dict{Tuple{String, Int, Int}, JuMP.VariableRef}() - return SparseAxisArray(contents) +""" +Key tuple type for the empty `SparseAxisArray` auto-created for a `SparseVariableType`. + +Defaults to the 3D device-offer PWL shape `(device_name, segment, time)`. A variable type +that needs an extra axis - e.g. a per-service reserve offer keyed +`(service_name, device_name, segment, time)` - overrides this method to widen the key. Downstream +packages extend it for their own sparse variable types. +""" +sparse_variable_key_type(::Type{<:SparseVariableType}) = Tuple{String, Int, Int} + +function _get_pwl_variables_container(::Type{T}) where {T <: SparseVariableType} + return SparseAxisArray(Dict{sparse_variable_key_type(T), JuMP.VariableRef}()) end function add_variable_container!( @@ -746,7 +755,7 @@ function add_variable_container!( U <: Union{IS.InfrastructureSystemsComponent, IS.InfrastructureSystemsContainer}, } var_key = VariableKey(T, U, meta) - _assign_container!(container.variables, var_key, _get_pwl_variables_container()) + _assign_container!(container.variables, var_key, _get_pwl_variables_container(T)) return container.variables[var_key] end From 3960e3ba57fa861e5b473a4cd7cf0c58c2cda7f1 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 30 Jul 2026 12:11:48 -0700 Subject: [PATCH 09/10] update comment of getter --- src/core/service_model.jl | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/core/service_model.jl b/src/core/service_model.jl index eadf0a6..2d875b0 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -92,13 +92,10 @@ get_attributes(m::ServiceModel) = m.attributes get_attribute(m::ServiceModel, key::String) = get(m.attributes, key, nothing) # Whole nested map: service name -> device type -> contributing devices. get_contributing_devices_map(m::ServiceModel) = m.contributing_devices_map -# Shared empty sentinel returned when a service has no entry in the map. Read-only for all -# callers (only the no-arg whole-map form is mutated, via `get!`), so handing back one shared -# const on the miss path is safe. As a const it is referenced, not rebuilt, so the 3-arg -# `get` below stays allocation-free on both the hit and miss paths. +# Returned for a service with no entry in the map. Callers treat it as read-only (shared). const _EMPTY_CONTRIBUTING_DEVICES_MAP = Dict{DataType, Vector{<:IS.InfrastructureSystemsComponent}}() -# One service's inner `Dict{DataType, Vector}` (shared empty Dict if the service is absent). +# One service's inner `device type -> devices` map (the empty const if the service is absent). get_contributing_devices_map(m::ServiceModel, service_name::AbstractString) = get(m.contributing_devices_map, service_name, _EMPTY_CONTRIBUTING_DEVICES_MAP) # All contributing devices across ALL services (flatten the nested map). From 8ec2575503f2bed5b5afc1d9030a58b04299d40a Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 30 Jul 2026 18:56:08 -0700 Subject: [PATCH 10/10] Fix docs build: pin IS4 in docs env, warnonly on cross-references The Documentation CI failed (red on main too) for two reasons: - docs/Project.toml had no [sources], so the docs env resolved InfrastructureSystems from the registry, which lacks InfrastructureMatrices (IOM imports it) -> IOM failed to precompile. Pin InfrastructureSystems to IS4, matching the root and test envs. - After that, makedocs terminated on :cross_references: explanation/reference pages @ref symbols moved to PowerOperationsModels in the IOM/POM split (#104). warnonly on :cross_references only, so the site builds while missing-docstring and doctest checks stay strict. --- docs/Project.toml | 3 +++ docs/make.jl | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/docs/Project.toml b/docs/Project.toml index 96574cc..b00841e 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -8,6 +8,9 @@ InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" +[sources] +InfrastructureSystems = {url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl", rev = "IS4"} + [compat] Documenter = "^1.0" julia = "^1.10" diff --git a/docs/make.jl b/docs/make.jl index bb21310..5609bf2 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -36,6 +36,11 @@ makedocs( pages = Any[p for p in pages], draft = false, plugins = [links], + # Pre-existing broken `@ref` links (also red on `main`): the explanation/reference pages + # reference symbols that were moved to PowerOperationsModels in the IOM/POM split (#104). + # Downgrade only the cross-reference failures to warnings so the site builds; every other + # category (missing docstrings, doctests, ...) still fails the build. + warnonly = [:cross_references], ) deploydocs(