Skip to content

[DNMY] Per-type service models and merged sparse/dense reserve containers - #141

Open
rodrigomha wants to merge 10 commits into
mainfrom
rh/dev_service_refactor
Open

[DNMY] Per-type service models and merged sparse/dense reserve containers#141
rodrigomha wants to merge 10 commits into
mainfrom
rh/dev_service_refactor

Conversation

@rodrigomha

@rodrigomha rodrigomha commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Domain-neutral IOM changes backing POM's per-type service-model refactor: one ServiceModel per service type (not per service name), with merged sparse/dense reserve containers keyed by an added service axis.

Rebased on latest main (includes #142); tests pass against the latest IS4 (v3.6.0).

What changed

Per-type ServiceModel API + per-service sparse variables

  • ServiceModel now covers every service of its type; service variables are sparse containers with the service name as an added axis: reserve award is 3D (service, device, time), requirement-side is 2D (service, time).
  • Removed the vestigial aggregated_service_model attribute and the unused NO_SERVICE_NAME_PROVIDED const.

sparse_variable_key_type trait (extension seam)

  • New sparse_variable_key_type(::Type{<:SparseVariableType}) returns the auto-created container's key tuple, defaulting to the 3D device-offer shape (device, segment, time). _get_pwl_variables_container builds the empty SparseAxisArray from it.
  • Downstream packages override it for their own sparse variable types - POM uses this for a 4D (service, device, segment, time) per-device reserve-offer block variable. No behavior change for existing sparse variable types (default returns the prior 3-tuple).

Allocation / clarity cleanups

  • get_contributing_devices_map(m, name) uses a plain 3-arg get with a shared empty-map const (no anonymous closure, no per-call empty-Dict allocation on the hit path).
  • Service dual key uses constraint_type directly (get_entry_type(key) was redundant); reworded the stale "grouped construction" dual comment (no grouping remains under per-type).
  • Trimmed the verbose allocation-rationale comments on the service-model accessors to short functional descriptions.

Deferred follow-up

  • Type-stability: ServiceModel.contributing_devices_map and DeviceModel.services have abstract element types, so per-type service construction dynamic-dispatches at build time. Tracked in POM issue #216 (the fix needs an IOM struct-typing pass here, coordinated with the POM adoption).

Testing

Full IOM suite green under julia --project=test test/runtests.jl with IS4 v3.6.0.

Consumer

Backs POM #206, which now consolidates the full service refactor (Phases A+B+C — the previously stacked POM #207 and #210 have been folded into #206). The sparse_variable_key_type override is used there by the per-device reserve-offer (service bids) work. Merge order: this PR (or its content) settles first, then POM #206 repoints its [sources] off the rh/dev_service_refactor branch.


# Service model
#
# Services of the same type now share merged constraint containers keyed by

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm thinking of cleaning up these comments, but I will leave them for now for the review, but everything referring to how it was before I plan to eventually remove it

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Performance Results
Main

Network: 10 nodes, 13 edges, 3 cost segments
Generators: 5, Demands: 5
Loss coefficients (a, b, c) per generator:
  n1: a=0.007287  b=0.005968  c=0.003850
  n2: a=0.001094  b=0.009002  c=0.007028
  n3: a=0.007897  b=0.008552  c=0.003001
  n4: a=0.008179  b=0.002530  c=0.009637
  n5: a=0.009778  b=0.009934  c=0.009099

Solver logs: /home/runner/work/InfrastructureOptimizationModels.jl/InfrastructureOptimizationModels.jl/main/test/performance/logs/solver_2026-07-31T02-02-04.log

==============================================================================================================================================
Bilinear Approximation Benchmarks
  Refinement = depth for all methods
==============================================================================================================================================
Method          R   Vars Constrs   Bins    Objective   Gap(%) MIPGap(%)     LowerBnd  rmse δbi   max δbi   rmse δq    max δq  build_t  solve_t
----------------------------------------------------------------------------------------------------------------------------------------------
NLP (Ipopt)     -     40     105      0     0.956760        -         -            -  0.00e+00  0.00e+00  0.00e+00  0.00e+00   0.0322   0.0049

NLP (Uno)       -     40     105      0     0.956760   0.0000         -            -  0.00e+00  0.00e+00  0.00e+00  0.00e+00   0.0014   0.0013

Bin2+sSOS       4    190     535      0     1.180700  23.4061    0.0000     1.180700  8.07e-02  1.41e-01  2.49e+01  5.48e+01   0.0059   1.8423
Bin2+sSOS       6    250     655      0     1.098175  14.7806    0.0000     1.098175  1.26e-01  2.86e-01  5.47e+00  9.07e+00   0.0063   7.7746
Bin2+sSOS       8    310     775      0     1.053069  10.0661    0.0088     1.052976  8.62e-02  2.43e-01  4.18e+01  9.32e+01   0.0065  27.5742

Bin2+mSOS       4    310     805    120     1.180700  23.4061    0.0000     1.180700  8.07e-02  1.41e-01  2.49e+01  5.48e+01   0.0025   1.7921
Bin2+mSOS       6    430    1045    180     1.098175  14.7806    0.0092     1.098074  1.26e-01  2.86e-01  5.47e+00  9.07e+00   0.0026   7.7012
Bin2+mSOS       8    550    1285    240     1.053069  10.0661    0.0045     1.053021  8.62e-02  2.43e-01  4.18e+01  9.32e+01   0.0027  18.4738

Bin2+Saw        4    310    1075    120     0.985823   3.0376    0.0000     0.985823  1.08e-01  2.49e-01  1.11e+02  2.44e+02   0.0033   5.9980
Bin2+Saw        6    430    1495    180     0.958677   0.2004    0.0077     0.958603  7.52e-02  1.68e-01  8.94e+01  1.71e+02   0.0039  13.6278
Bin2+Saw        8    550    1915    240     0.956996   0.0247    0.0072     0.956927  7.52e-02  1.68e-01  1.37e+02  2.09e+02   0.0045  51.8368

HybS+sSOS       4    310    1165      0     0.812261  15.1030    0.0000     0.812261  5.52e-01  1.00e+00  2.04e+02  2.97e+02   0.0073   2.7457
HybS+sSOS       6    410    1545      0     0.891208   6.8515    0.0000     0.891208  5.49e-01  1.00e+00  3.69e+02  7.09e+02   0.0076   5.0475
HybS+sSOS       8    510    1925      0     0.934789   2.2965    0.0087     0.934707  5.48e-01  1.00e+00  3.47e+02  5.22e+02   0.0083  15.9599

HybS+mSOS       4    390    1345     80     0.812261  15.1030    0.0046     0.812224  5.52e-01  1.00e+00  2.04e+02  2.97e+02   0.0035   2.9977
HybS+mSOS       6    530    1805    120     0.891208   6.8515    0.0000     0.891208  5.49e-01  1.00e+00  3.69e+02  7.09e+02   0.0039   6.3964
HybS+mSOS       8    670    2265    160     0.934789   2.2965    0.0047     0.934744  5.48e-01  1.00e+00  3.47e+02  5.22e+02   0.0045  16.5423

HybS+Saw        4    390    1525     80     0.951869   0.5113    0.0021     0.951849  5.48e-01  1.00e+00  2.16e+03  4.23e+03   0.0039  13.9081
HybS+Saw        6    530    2105    120     0.956386   0.0391    0.0099     0.956291  5.48e-01  1.00e+00  4.57e+02  7.57e+02   0.0047  34.3598
HybS+Saw        8    670    2685    160     0.956734   0.0027    0.0014     0.956721  5.48e-01  1.00e+00  6.08e+00  1.07e+01   0.0058 109.2557

DNMDT           4    395    1640     80     0.954878   0.1967    0.0004     0.954874  1.39e-03  3.13e-03  4.70e-04  1.05e-03   0.0032   3.9689
DNMDT           6    550    2315    120     0.956636   0.0130    0.0098     0.956541  6.75e-05  1.57e-04  4.26e+03  9.54e+03   0.0037  17.6451
DNMDT           8    705    2990    160     0.956754   0.0007    0.0098     0.956660  4.62e-06  1.14e-05  9.42e+01  1.49e+02   0.0041  49.7213

==============================================================================================================================================

This branch

Network: 10 nodes, 13 edges, 3 cost segments
Generators: 5, Demands: 5
Loss coefficients (a, b, c) per generator:
  n1: a=0.007287  b=0.005968  c=0.003850
  n2: a=0.001094  b=0.009002  c=0.007028
  n3: a=0.007897  b=0.008552  c=0.003001
  n4: a=0.008179  b=0.002530  c=0.009637
  n5: a=0.009778  b=0.009934  c=0.009099

Solver logs: /home/runner/work/InfrastructureOptimizationModels.jl/InfrastructureOptimizationModels.jl/branch/test/performance/logs/solver_2026-07-31T02-10-18.log

==============================================================================================================================================
Bilinear Approximation Benchmarks
  Refinement = depth for all methods
==============================================================================================================================================
Method          R   Vars Constrs   Bins    Objective   Gap(%) MIPGap(%)     LowerBnd  rmse δbi   max δbi   rmse δq    max δq  build_t  solve_t
----------------------------------------------------------------------------------------------------------------------------------------------
NLP (Ipopt)     -     40     105      0     0.956760        -         -            -  0.00e+00  0.00e+00  0.00e+00  0.00e+00   0.0325   0.0050

NLP (Uno)       -     40     105      0     0.956760   0.0000         -            -  0.00e+00  0.00e+00  0.00e+00  0.00e+00   0.0014   0.0012

Bin2+sSOS       4    190     535      0     1.180700  23.4061    0.0000     1.180700  8.07e-02  1.41e-01  2.49e+01  5.48e+01   0.0058   1.8272
Bin2+sSOS       6    250     655      0     1.098175  14.7806    0.0000     1.098175  1.26e-01  2.86e-01  5.47e+00  9.07e+00   0.0063   7.7267
Bin2+sSOS       8    310     775      0     1.053069  10.0661    0.0088     1.052976  8.62e-02  2.43e-01  4.18e+01  9.32e+01   0.0065  27.4703

Bin2+mSOS       4    310     805    120     1.180700  23.4061    0.0000     1.180700  8.07e-02  1.41e-01  2.49e+01  5.48e+01   0.0024   1.7811
Bin2+mSOS       6    430    1045    180     1.098175  14.7806    0.0092     1.098074  1.26e-01  2.86e-01  5.47e+00  9.07e+00   0.0025   7.6647
Bin2+mSOS       8    550    1285    240     1.053069  10.0661    0.0045     1.053021  8.62e-02  2.43e-01  4.18e+01  9.32e+01   0.0027  18.4674

Bin2+Saw        4    310    1075    120     0.985823   3.0376    0.0000     0.985823  1.08e-01  2.49e-01  1.11e+02  2.44e+02   0.0033   5.9928
Bin2+Saw        6    430    1495    180     0.958677   0.2004    0.0077     0.958603  7.52e-02  1.68e-01  8.94e+01  1.71e+02   0.0039  13.6310
Bin2+Saw        8    550    1915    240     0.956996   0.0247    0.0072     0.956927  7.52e-02  1.68e-01  1.37e+02  2.09e+02   0.0045  51.7981

HybS+sSOS       4    310    1165      0     0.812261  15.1030    0.0000     0.812261  5.52e-01  1.00e+00  2.04e+02  2.97e+02   0.0075   2.7523
HybS+sSOS       6    410    1545      0     0.891208   6.8515    0.0000     0.891208  5.49e-01  1.00e+00  3.69e+02  7.09e+02   0.0079   5.0470
HybS+sSOS       8    510    1925      0     0.934789   2.2965    0.0087     0.934707  5.48e-01  1.00e+00  3.47e+02  5.22e+02   0.0084  15.9073

HybS+mSOS       4    390    1345     80     0.812261  15.1030    0.0046     0.812224  5.52e-01  1.00e+00  2.04e+02  2.97e+02   0.0037   2.9842
HybS+mSOS       6    530    1805    120     0.891208   6.8515    0.0000     0.891208  5.49e-01  1.00e+00  3.69e+02  7.09e+02   0.0041   6.3834
HybS+mSOS       8    670    2265    160     0.934789   2.2965    0.0047     0.934744  5.48e-01  1.00e+00  3.47e+02  5.22e+02   0.0048  16.5113

HybS+Saw        4    390    1525     80     0.951869   0.5113    0.0021     0.951849  5.48e-01  1.00e+00  2.16e+03  4.23e+03   0.0041  13.8777
HybS+Saw        6    530    2105    120     0.956386   0.0391    0.0099     0.956291  5.48e-01  1.00e+00  4.57e+02  7.57e+02   0.0048  34.3602

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

Refactors the service-model layer to be per service type (one ServiceModel per service type) and updates reserve-style variables/constraints to use merged sparse/dense containers, enabling shared sparse (service, device, time) storage. This is the upstream half of a coordinated breaking change intended to be consumed by a follow-up migration in downstream packages.

Changes:

  • Replace tuple-keyed ServicesModelContainer with Dict{Symbol, ServiceModel} keyed by Symbol(service_type), and remove service_name / get_service_name from ServiceModel.
  • Introduce sparse 3D container support for EmulationModelStore.write_output! (with a new round-trip unit test).
  • Update service variable/dual container construction to reflect merged container strategy (including dual assignment mirroring existing constraint containers).

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/test_emulation_model_store.jl Adds a sparse 3D write/read round-trip test to validate emulation store behavior for reserve-style containers.
src/operation/problem_template.jl Updates ServicesModelContainer type alias to the new per-type Dict{Symbol, ServiceModel} shape.
src/operation/emulation_model_store.jl Adds write_output! support for SparseAxisArray by flattening leading tuple axes into encoded columns.
src/InfrastructureOptimizationModels.jl Removes get_service_name from exports to match API removal.
src/core/service_model.jl Removes per-service-name ServiceModel identity; introduces per-service nested contributing-device mapping and new accessors.
src/core/definitions.jl Removes the vestigial NO_SERVICE_NAME_PROVIDED constant.
src/common_models/add_variable.jl Updates service variable creation to use a merged sparse (service, device, time) container.
src/common_models/add_constraint_dual.jl Updates service dual assignment to mirror merged constraint containers rather than per-service meta axes.

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

Comment thread src/common_models/add_variable.jl

@luke-kiernan luke-kiernan 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.

This area of the code is a can of worms when it comes to type stability. Not really a new issue, but still worth at least adding a function barrier and some type labels.

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)

@luke-kiernan luke-kiernan Jul 21, 2026

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.

Do a type stability audit on these nested loops. Thoughts:

  • duals::Vector{DataType}, so all the compiler knows is constraint_type::DataType. Can we give it something more specific?
  • similar considerations for key
  • introduce a function barrier?
  • get_entry_type(key): I suspect that's just constraint_type

Other ways to make this more type stable and compiler-friendly....maintain a list of all meta's for each (constraint_type, component_type) combination? Then we could loop over those (a compile time value) and skip those that aren't present, instead of accumulating the ones that are present via _existing_constraint_keys (a runtime value).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right about the get_entry_type. I will fix that.

I ask Claude about the instability and the warntype and:

@code_warntype does show constraint_type::DataType, key::ConstraintKey, existing::Any, dual_key::Any — but I tried a barrier parameterized on the concrete (constraint_type, component_type) and key/existing/dual_key stay Any. The roots are structural: constraints is an OrderedDict{ConstraintKey, JuMPArray} with abstract key/value types, and the ConstraintKey constructor doesn't infer concretely — so a barrier localizes the constraint_type dispatch but doesn't reach stability, and the Dense-vs-Sparse dispatch that actually matters is already a multiple-dispatch barrier. Since this runs once per build over a handful of dual types/keys, I'd propose taking the constraint_type cleanup now and skipping the meta-registry.

I agree with Claude here, the effort is larger than the sparse refactoring here. Are you ok with this @jd-lara?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is not a performance critical operation, it is more of a convenience and for now works correctly.

Comment thread src/core/service_model.jl
Dict{DataType, Vector{<:IS.InfrastructureSystemsComponent}}(),
)
# All contributing devices across ALL services (flatten the nested map).
get_contributing_devices(m::ServiceModel) =

@luke-kiernan luke-kiernan Jul 21, 2026

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.

Flattening makes this heterogeneous--a mix of different subtypes of IS.InfrastructureSystemsComponent--hence type unstable to iterate over. There are ways around this...but it'd add complexity. You'd be limited to map, foreach, etc. (for loops would remain unstable) and we might need to add one or two @generated functions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is true if a service have different types for contributing devices (e.g. Hydro + Thermal). However, if all the contributing devices are the same type, then it narrows to a concrete Vector{CommonType} (e.g. Vector{ThermalStandard}) and iterates stably.

I asked Claude about this and basically:

only mixed-type reserves give an abstract join, and even then it's build-time and JuMP-@variable-dominated (dispatch ≈ 1.6% of per-device cost, ~60× under the allocation).

After some discussion I think this is a temporary solution so we don't implement ton of complexity here

Two spots flatten the device map for no good reason, and fixing them is pure allocation removal with no behavior or stability question:

  • services_constructor.jl:37,77 — these guard construction with isempty(get_contributing_devices(service_model)), which builds a full flattened vector of every contributing device across all services just to ask "is it empty?". Replace with an emptiness check on the nested map itself (it's empty iff the map has no entries) — no allocation.
  • problem_template.jl:303 (_modify_device_model!) — this only needs the set of device types (Set{DataType}(typeof.(devices))) to wire the service into the right DeviceModels. It can read the map's keys directly instead of flattening and re-deriving typeof on each element.

These touch neither the hot loops nor the container shapes, so there's no test risk. They're worth doing regardless of what we decide about the stability fix.

What you want is to modify our flatten map and use the map per type as follows:

The deferred form iterates the per-type groups the map already holds, so each group is a runtime-concrete vector and the barrier (add_service_variables!) specializes per group:

for (device_type, devices) in get_contributing_devices_map(model, service_name)  # devices::Vector{ThermalStandard}, etc.
    add_service_variables!(container, ActivePowerReserveVariable, service, devices, F)  # D binds concretely
end

I also asked Claude about why this is a major change and basically:

  1. It changes the container merge cadence. add_service_variables! is currently called once per service and appends one slice to the shared sparse (service, device, time) container via lazy_container_addition!. Per-group, it's called once per device type per service, appending finer slices. That's semantically fine (each call fills its own (service, device, t) keys), but it changes the create/append sequence and has to be re-verified against the existing tests — it's a behavior-adjacent change, not a comment tweak.
  2. It's not one call site. The same flatten-then-loop shape feeds _sum_service_reserves (inside RequirementConstraint) and the ParticipationFraction / ramp / reserve-power builders. To actually recover stability you'd convert all of them to accumulate per group into the shared expression/constraint. That's a handful of reserve builders touched, so more surface and more test risk.
  3. The payoff is small and conditional. The common case (single contributing-device type per reserve) is already stable — the flatten narrows to a concrete Vector{ThermalStandard} and add_service_variables! is already a barrier. The per-group change only helps mixed-type reserves, and even there it's build-time-only, with dynamic dispatch measured at ~1.6% of per-device cost (JuMP @variable allocation dominates ~60×).

So: real stability improvement, but touching several builders + the container merge cadence for a marginal, build-time-only, mixed-reserve-only gain. That ratio says "tracked follow-up," not "block this PR." I'd file it as an issue (and it slots naturally next to the interface-migration follow-ups, since it's the same "iterate the map by type" shape).

I agree that this is better it could be changing too much the structure that we have for slicing sparse containers. What do you think @jd-lara

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if we want to fix this to be properly type stable we need to change PSY to have a mapping by type correctly. I think we need to get this logic correct and then make the performance improvement work. I prefer we make it work and open an issue

@jd-lara

jd-lara commented Jul 22, 2026

Copy link
Copy Markdown
Member

@luke-kiernan I think you have valid points, but we need to make the container refactoring work correctly which a massive change and then we can address the heterogeneous types in the services problem

@luke-kiernan

Copy link
Copy Markdown
Collaborator

I think you have valid points, but we need to make the container refactoring work correctly which a massive change and then we can address the heterogeneous types in the services problem

Sounds good. Yeah it's not directly in a hot path.

@rodrigomha rodrigomha changed the title Per-type service models and merged sparse/dense reserve containers [DNMY] Per-type service models and merged sparse/dense reserve containers Jul 23, 2026
Comment thread src/core/service_model.jl Outdated
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need the anonymous function here? This seems an odd way to create an empty dictionary

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I asked Claude the same, and basically told me that is like a micro optimization to avoid allocating. I asked specifically and showed me that you have less allocations. This was done when the const empty dict was used to avoid allocating an empty dict.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here is the answer, so it seems that for this case we don't need to use the anonymous function. I will revert for having more simplicity in the code.


get with eager vs. lazy default in Julia

The core reason: get(collection, key, default) evaluates default eagerly, before the lookup happens — every single call, whether the key is found or not. get(f, collection, key) (the anonymous-function form) only calls f when the key is missing — it's lazy.

# Eager: Dict() gets allocated on EVERY call, even on a hit
get(m.contributing_devices_map, service_name, Dict())

# Lazy: closure only runs on a miss
get(() -> Dict(), m.contributing_devices_map, service_name)

This matters a lot when the default is expensive to construct — a new Dict(), a computed value, a function call, string interpolation, etc. In a hot loop, the eager version allocates garbage on every hit even though you throw it away immediately.

In this specific case

_EMPTY_CONTRIBUTING_DEVICES_MAP looks like a preallocated global constant, not something built fresh each call. So:

get(m.contributing_devices_map, service_name, _EMPTY_CONTRIBUTING_DEVICES_MAP)

just evaluates a variable reference — no allocation either way. And () -> _EMPTY_CONTRIBUTING_DEVICES_MAP captures nothing, so Julia compiles it to a stateless singleton closure with no extra allocation either.

Takeaway

For this exact line, the anonymous-function version isn't meaningfully faster — the "optimization" advice is more relevant when the default is constructed on the spot (e.g., Dict(), a comprehension, string building). That said, it's a reasonable defensive habit: if someone later changes the default to something more expensive, the closure form protects you automatically without needing to remember to change call sites.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

it seems WAY easier to use a haskey than defining an anonymous function like that. It feels very pythonic as an approach. Use a closure like that

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.
…dundant 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.
…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).
@rodrigomha
rodrigomha force-pushed the rh/dev_service_refactor branch from e82ddf1 to 12b3835 Compare July 30, 2026 17:04
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.
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.

4 participants