From 1b41a3d41c801148a2fe5b18464c77433bc69901 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Wed, 9 Sep 2026 09:52:30 -0600 Subject: [PATCH 1/4] Add share_template_references! hook to the template deep copy Template types can share objects with the caller's original by reference after the build-time copy: objects a build mutates that callers then inspect on the instance they passed in. The default shares nothing. The network-data sharing that must happen before deepcopy is unchanged, now under _deepcopy_sharing_network_data. --- src/operation/problem_template.jl | 20 ++++++++- test/InfrastructureOptimizationModelsTests.jl | 1 + test/test_problem_template.jl | 43 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 test/test_problem_template.jl diff --git a/src/operation/problem_template.jl b/src/operation/problem_template.jl index 8b8039e1..4af94ac9 100644 --- a/src/operation/problem_template.jl +++ b/src/operation/problem_template.jl @@ -135,11 +135,29 @@ function finalize_template!(template::AbstractProblemTemplate, args...) ) end +""" + share_template_references!(template_::AbstractProblemTemplate, template::AbstractProblemTemplate) + +Extension point for template types whose build-time copy must share some objects with +the caller's `template` by reference instead of by `deepcopy` — for instance objects a +build mutates that callers then inspect on the instance they passed in. The model +constructors call it on the fresh copy `template_`; the default shares nothing. +""" +function share_template_references!(::AbstractProblemTemplate, ::AbstractProblemTemplate) + return +end + +function _deepcopy_template(template::AbstractProblemTemplate) + template_ = _deepcopy_sharing_network_data(template) + share_template_references!(template_, template) + return template_ +end + # Deep-copy a template while sharing the network source and derived data by reference: # both can hold PNM matrices whose solver caches carry raw factorization handles and # deliberately error on deepcopy (PNM #312). Sharing is safe because instantiation # replaces the copy's `network_data` outright rather than mutating the original's. -function _deepcopy_template(template::AbstractProblemTemplate) +function _deepcopy_sharing_network_data(template::AbstractProblemTemplate) network_model = get_network_model(template) network_model === nothing && return deepcopy(template) source = network_model.network_source diff --git a/test/InfrastructureOptimizationModelsTests.jl b/test/InfrastructureOptimizationModelsTests.jl index ad28b334..890cc378 100644 --- a/test/InfrastructureOptimizationModelsTests.jl +++ b/test/InfrastructureOptimizationModelsTests.jl @@ -115,6 +115,7 @@ function run_tests() # TODO outputs_by_time.jl # TODO service_model.jl include(joinpath(TEST_DIR, "test_settings.jl")) + include(joinpath(TEST_DIR, "test_problem_template.jl")) # standard_variables_expressions.jl: low complexity # time_series_parameter_types.jl: low complexity diff --git a/test/test_problem_template.jl b/test/test_problem_template.jl new file mode 100644 index 00000000..b0b98ff2 --- /dev/null +++ b/test/test_problem_template.jl @@ -0,0 +1,43 @@ +""" +Unit tests for the template deep-copy path and its `share_template_references!` hook. +""" + +using Test +using InfrastructureOptimizationModels + +# Minimal templates: one shares a field through the hook, one relies on the default. +mutable struct SharingTestTemplate <: IOM.AbstractProblemTemplate + shared::Vector{Int} + cloned::Vector{Int} +end +IOM.get_network_model(::SharingTestTemplate) = nothing +function IOM.share_template_references!( + template_::SharingTestTemplate, + template::SharingTestTemplate, +) + template_.shared = template.shared + return +end + +mutable struct DefaultCopyTestTemplate <: IOM.AbstractProblemTemplate + data::Vector{Int} +end +IOM.get_network_model(::DefaultCopyTestTemplate) = nothing + +@testset "Template deep copy" begin + @testset "share_template_references! runs on the copy" begin + template = SharingTestTemplate([1], [2]) + template_ = IOM._deepcopy_template(template) + @test template_ !== template + @test template_.shared === template.shared + @test template_.cloned == template.cloned + @test template_.cloned !== template.cloned + end + + @testset "default hook shares nothing" begin + template = DefaultCopyTestTemplate([1]) + template_ = IOM._deepcopy_template(template) + @test template_.data == template.data + @test template_.data !== template.data + end +end From ec55a6bf101fa94961a317247d98628194a3ed30 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Thu, 10 Sep 2026 11:01:44 -0600 Subject: [PATCH 2/4] Follow the DeviceBaseUnit -> ComponentBaseUnit rename InfrastructureSystems#634 renamed the per-unit marker: `IS.DU`/ `IS.DeviceBaseUnit` are now `IS.CU`/`IS.ComponentBaseUnit`, with no shims. Seven sites, all constructing the marker to parameterize a cost curve in the value-curve tests, plus the units tuple in the dispatch-profile script. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LP6eB1zx4eyE3hd7tpvSue --- scripts/units_dispatch_profile.jl | 4 ++-- test/test_linear_curve.jl | 4 ++-- test/test_quadratic_curve.jl | 4 ++-- test/test_ts_value_curve_objective.jl | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/units_dispatch_profile.jl b/scripts/units_dispatch_profile.jl index 3ef6340f..0aa20730 100644 --- a/scripts/units_dispatch_profile.jl +++ b/scripts/units_dispatch_profile.jl @@ -75,13 +75,13 @@ function make_container(devices) end # Build N components, evenly distributed across the 3 unit systems. The cost -# *curves* are concretely typed (CostCurve{LinearCurve, NaturalUnit/SU/DU}) but +# *curves* are concretely typed (CostCurve{LinearCurve, NaturalUnit/SU/CU}) but # the abstractly-typed `MockThermalGen` storage means upstream callers see a # UnionAll. This is the realistic shape of consuming code. function make_workload() devices = MockThermalGen[] curves = Any[] # heterogeneous on U, simulates abstract field upstream - units = (IS.NaturalUnit(), IS.SystemBaseUnit(), IS.DeviceBaseUnit()) + units = (IS.NaturalUnit(), IS.SystemBaseUnit(), IS.ComponentBaseUnit()) for i in 1:N_COMPONENTS u = units[mod1(i, 3)] push!(devices, make_mock_thermal("g$i"; base_power = 50.0 + i)) diff --git a/test/test_linear_curve.jl b/test/test_linear_curve.jl index 0443fd7d..8398b30a 100644 --- a/test/test_linear_curve.jl +++ b/test/test_linear_curve.jl @@ -232,7 +232,7 @@ end # Cost: 30 $/p.u.h in device base units cost_curve = IS.CostCurve( IS.LinearCurve(30.0), - IS.DeviceBaseUnit(), + IS.ComponentBaseUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -312,7 +312,7 @@ end ), DEVICE_BASE = IS.CostCurve( IS.LinearCurve(rate * device_base), - IS.DeviceBaseUnit(), + IS.ComponentBaseUnit(), ), ) diff --git a/test/test_quadratic_curve.jl b/test/test_quadratic_curve.jl index 48aefb81..cf0b33e1 100644 --- a/test/test_quadratic_curve.jl +++ b/test/test_quadratic_curve.jl @@ -318,7 +318,7 @@ end cost_curve = IS.CostCurve( IS.QuadraticCurve(1.0, 20.0, 0.0), - IS.DeviceBaseUnit(), + IS.ComponentBaseUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -404,7 +404,7 @@ end ), DEVICE_BASE = IS.CostCurve( IS.QuadraticCurve(a * device_base^2, b * device_base, 0.0), - IS.DeviceBaseUnit(), + IS.ComponentBaseUnit(), ), ) diff --git a/test/test_ts_value_curve_objective.jl b/test/test_ts_value_curve_objective.jl index b835c5ee..0d8b3103 100644 --- a/test/test_ts_value_curve_objective.jl +++ b/test/test_ts_value_curve_objective.jl @@ -351,7 +351,7 @@ end for (unit_system, expected_slope_factor, expected_bp_factor) in [ (IS.NaturalUnit(), system_base, 1.0 / system_base), ( - IS.DeviceBaseUnit(), + IS.ComponentBaseUnit(), 1.0 / (device_base / system_base), device_base / system_base, ), From 344ac618ce4f13932c6f78b349a4906857493dc7 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Mon, 14 Sep 2026 10:02:13 -0700 Subject: [PATCH 3/4] Derive the cost-coefficient ratio to the system base ourselves InfrastructureSystems 2a2494ec moved the unit-system base arithmetic out of convert_cost_coefficient, which now takes a bare x-axis ratio, so every cost path failed on the IS4 head with a MethodError. _system_base_ratio derives the ratio the way IS used to (SU 1, CU sb/db, NU sb) and the four per-system-unit helpers pass it through; a unit test pins the three ratios for each helper. --- src/utils/component_utils.jl | 41 +++++--- test/InfrastructureOptimizationModelsTests.jl | 1 + test/test_cost_unit_conversion.jl | 97 +++++++++++++++++++ 3 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 test/test_cost_unit_conversion.jl diff --git a/src/utils/component_utils.jl b/src/utils/component_utils.jl index f45e118c..8b537acc 100644 --- a/src/utils/component_utils.jl +++ b/src/utils/component_utils.jl @@ -73,6 +73,22 @@ end ########### Cost Function Utilities ############## ################################################## +""" + _system_base_ratio(unit_system, system_base_power, device_base_power) -> Float64 + +The x-axis ratio `x_from = ratio * x_su` from `unit_system` to the system base, the +base arithmetic `IS.convert_cost_coefficient` no longer resolves itself. +""" +_system_base_ratio(::IS.SystemBaseUnit, ::Float64, ::Float64) = 1.0 +_system_base_ratio( + ::IS.ComponentBaseUnit, + system_base_power::Float64, + device_base_power::Float64, +) = + system_base_power / device_base_power +_system_base_ratio(::IS.NaturalUnit, system_base_power::Float64, ::Float64) = + system_base_power + """ Proportional (slope) cost coefficient normalized to system base. """ @@ -82,8 +98,8 @@ get_proportional_cost_per_system_unit( system_base_power::Float64, device_base_power::Float64, ) = IS.convert_cost_coefficient( - cost_term, unit_system, IS.SU, - system_base_power, device_base_power, + cost_term, + _system_base_ratio(unit_system, system_base_power, device_base_power), ) """ @@ -95,8 +111,9 @@ get_quadratic_cost_per_system_unit( system_base_power::Float64, device_base_power::Float64, ) = IS.convert_cost_coefficient( - cost_term, unit_system, IS.SU, - system_base_power, device_base_power, 2, + cost_term, + _system_base_ratio(unit_system, system_base_power, device_base_power), + 2, ) """ @@ -110,8 +127,9 @@ function get_piecewise_pointcurve_per_system_unit( device_base_power::Float64, ) x_ratio = IS.convert_cost_coefficient( - 1.0, unit_system, IS.SU, - system_base_power, device_base_power, -1, + 1.0, + _system_base_ratio(unit_system, system_base_power, device_base_power), + -1, ) points = cost_component.points points_normalized = similar(points) @@ -165,14 +183,9 @@ function get_piecewise_curve_per_system_unit( system_base_power::Float64, device_base_power::Float64, ) - x_ratio = IS.convert_cost_coefficient( - 1.0, unit_system, IS.SU, - system_base_power, device_base_power, -1, - ) - y_ratio = IS.convert_cost_coefficient( - 1.0, unit_system, IS.SU, - system_base_power, device_base_power, 1, - ) + ratio = _system_base_ratio(unit_system, system_base_power, device_base_power) + x_ratio = IS.convert_cost_coefficient(1.0, ratio, -1) + y_ratio = IS.convert_cost_coefficient(1.0, ratio, 1) return x_coords .* x_ratio, y_coords .* y_ratio end diff --git a/test/InfrastructureOptimizationModelsTests.jl b/test/InfrastructureOptimizationModelsTests.jl index 890cc378..bded2c1c 100644 --- a/test/InfrastructureOptimizationModelsTests.jl +++ b/test/InfrastructureOptimizationModelsTests.jl @@ -122,6 +122,7 @@ function run_tests() # --- objective_function/ subfolder --- # import_export.jl: commented out include(joinpath(TEST_DIR, "test_cost_term_helpers.jl")) + include(joinpath(TEST_DIR, "test_cost_unit_conversion.jl")) include(joinpath(TEST_DIR, "test_linear_curve.jl")) # value_curve_cost.jl: needs more work include(joinpath(TEST_DIR, "test_piecewise_linear.jl")) diff --git a/test/test_cost_unit_conversion.jl b/test/test_cost_unit_conversion.jl new file mode 100644 index 00000000..3f3eb957 --- /dev/null +++ b/test/test_cost_unit_conversion.jl @@ -0,0 +1,97 @@ +""" +Unit tests for the cost-coefficient base conversions in src/utils/component_utils.jl. +`IS.convert_cost_coefficient` takes a bare x-axis ratio; the helpers derive it from the +curve's unit system and the two base powers. +""" + +@testset "Cost coefficient conversion to system base" begin + system_base = 100.0 + device_base = 20.0 + + @testset "proportional term" begin + @test IOM.get_proportional_cost_per_system_unit( + 3.0, + IS.SU, + system_base, + device_base, + ) == 3.0 + # \$/MW -> \$/(system pu MW): one system pu is 100 MW + @test IOM.get_proportional_cost_per_system_unit( + 3.0, + IS.NU, + system_base, + device_base, + ) == 300.0 + # \$/(device pu MW) -> \$/(system pu MW): one system pu is five device pu + @test IOM.get_proportional_cost_per_system_unit( + 3.0, + IS.CU, + system_base, + device_base, + ) == 15.0 + end + + @testset "quadratic term scales with the square of the ratio" begin + @test IOM.get_quadratic_cost_per_system_unit( + 2.0, + IS.SU, + system_base, + device_base, + ) == 2.0 + @test IOM.get_quadratic_cost_per_system_unit( + 2.0, + IS.NU, + system_base, + device_base, + ) == 2.0e4 + @test IOM.get_quadratic_cost_per_system_unit( + 2.0, + IS.CU, + system_base, + device_base, + ) == 50.0 + end + + @testset "point curve rescales x only" begin + curve = IS.PiecewiseLinearData([(x = 0.0, y = 0.0), (x = 20.0, y = 100.0)]) + natural = IOM.get_piecewise_pointcurve_per_system_unit( + curve, + IS.NU, + system_base, + device_base, + ) + @test IS.get_x_coords(natural) == [0.0, 0.2] + @test IS.get_y_coords(natural) == [0.0, 100.0] + device = IOM.get_piecewise_pointcurve_per_system_unit( + curve, + IS.CU, + system_base, + device_base, + ) + @test IS.get_x_coords(device) == [0.0, 4.0] + @test IOM.get_piecewise_pointcurve_per_system_unit( + curve, + IS.SU, + system_base, + device_base, + ) === curve + end + + @testset "step curve rescales x down and y up" begin + curve = IS.PiecewiseStepData([0.0, 20.0], [5.0]) + natural = + IOM.get_piecewise_curve_per_system_unit(curve, IS.NU, system_base, device_base) + @test IS.get_x_coords(natural) == [0.0, 0.2] + @test IS.get_y_coords(natural) == [500.0] + device = + IOM.get_piecewise_curve_per_system_unit(curve, IS.CU, system_base, device_base) + @test IS.get_x_coords(device) == [0.0, 4.0] + @test IS.get_y_coords(device) == [25.0] + @test IOM.get_piecewise_curve_per_system_unit( + curve, + IS.SU, + system_base, + device_base, + ) === curve + end +end From c63ace2c9b3cba3f9999ec7dd790b494ff119a7f Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Mon, 14 Sep 2026 10:13:14 -0700 Subject: [PATCH 4/4] Move the coverage tooling out of the test environment Coverage.jl caps HTTP at 1.x and the OpenAPI.jl 1.x packages need HTTP 2.x, so the test environment has not resolved since the IS4 pin moved, on CI and locally. CI collects coverage through julia-processcoverage and never loaded the package; the local lcov script now runs from scripts/coverage, its own environment. --- scripts/coverage/Project.toml | 7 +++++++ scripts/generate_lcov.jl | 2 +- scripts/test_with_coverage.sh | 6 ++---- test/Project.toml | 2 -- 4 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 scripts/coverage/Project.toml diff --git a/scripts/coverage/Project.toml b/scripts/coverage/Project.toml new file mode 100644 index 00000000..a7b2880f --- /dev/null +++ b/scripts/coverage/Project.toml @@ -0,0 +1,7 @@ +[deps] +Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037" +CoverageTools = "c36e975a-824b-4404-a568-ef97ca766997" + +[compat] +Coverage = "1" +CoverageTools = "1" diff --git a/scripts/generate_lcov.jl b/scripts/generate_lcov.jl index d16dd23a..0092a990 100644 --- a/scripts/generate_lcov.jl +++ b/scripts/generate_lcov.jl @@ -5,7 +5,7 @@ # # Usage: # 1. Run tests with coverage: julia --project=. -e 'using TestEnv; TestEnv.activate(); include("test/load_tests.jl"); InfrastructureOptimizationModelsTests.run_tests()' -# 2. Generate lcov: julia --project=. -e 'using TestEnv; TestEnv.activate(); include("scripts/generate_lcov.jl")' +# 2. Generate lcov: julia --project=scripts/coverage -e 'using Pkg; Pkg.instantiate(); include("scripts/generate_lcov.jl")' using CoverageTools using Coverage diff --git a/scripts/test_with_coverage.sh b/scripts/test_with_coverage.sh index f32ac543..251ccee2 100755 --- a/scripts/test_with_coverage.sh +++ b/scripts/test_with_coverage.sh @@ -14,9 +14,7 @@ julia --project=. --code-coverage -e ' ' echo "==> Generating lcov.info..." -julia --project=. -e ' - using TestEnv; TestEnv.activate() - include("scripts/generate_lcov.jl") -' +# Coverage.jl caps HTTP at 1.x while the OpenAPI packages need 2.x, so it has its own env. +julia --project=scripts/coverage -e 'using Pkg; Pkg.instantiate(); include("scripts/generate_lcov.jl")' echo "==> Done. lcov.info written to $(pwd)/lcov.info" diff --git a/test/Project.toml b/test/Project.toml index c84ef001..87fc4ac4 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -2,8 +2,6 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" ArgParse = "c7e460c6-2fb9-53a9-8c5b-16f535851c63" CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" -Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037" -CoverageTools = "c36e975a-824b-4404-a568-ef97ca766997" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" DataFramesMeta = "1313f7d8-7da2-5740-9ea0-a2ca25f37964" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"