diff --git a/Project.toml b/Project.toml index 4419271f..85e7ea94 100644 --- a/Project.toml +++ b/Project.toml @@ -48,4 +48,4 @@ TimerOutputs = "~0.5" julia = "^1.10" [sources] -InfrastructureSystems = {url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl", rev = "IS4"} +InfrastructureSystems = {url = "https://github.com/NREL-Sienna/InfrastructureSystems.jl.git", rev = "lk/units-domain-agnostic-is4"} diff --git a/scripts/units_dispatch_profile.jl b/scripts/units_dispatch_profile.jl new file mode 100644 index 00000000..3ef6340f --- /dev/null +++ b/scripts/units_dispatch_profile.jl @@ -0,0 +1,145 @@ +""" +Profile the cost-coefficient conversion path under each of the 3 unit systems +to validate (or refute) the claims in IOM issue #90. + +Two questions: + +1. **Static**: where does Julia's compiler still emit dynamic dispatch on the + cost-objective path? Run `JET.@report_opt` on the workload — every entry + it lists is a real failure of inference. Re-run after each rung of the + ladder ((4) Union field, (6) parameterized component) to measure delta. + +2. **Wall-time**: does the entry-point dispatch actually cost noticeable + time relative to JuMP variable/constraint construction? Run `@btime` on + the workload — if dispatch is <1% of total, the architectural rungs are + premature. + +Usage: + # JET and BenchmarkTools are not in test/Project.toml — add them first: + julia --project=test -e 'using Pkg; Pkg.add(["JET", "BenchmarkTools"])' + julia --project=test scripts/units_dispatch_profile.jl + +The script uses the test mocks (`MockThermalGen`, `MockSystem`, etc.) so it +runs without a PSY system. Workload size is small by default — bump +`N_COMPONENTS` for more realistic measurement. +""" + +using InfrastructureOptimizationModels +using InfrastructureSystems +using JuMP +using Dates +using InteractiveUtils +using BenchmarkTools +using JET + +const IOM = InfrastructureOptimizationModels +const IS = InfrastructureSystems +const ISOPT = InfrastructureSystems.Optimization + +# --------------------------------------------------------------------------- +# Bootstrap test mocks +# --------------------------------------------------------------------------- +const TEST_DIR = joinpath(@__DIR__, "..", "test") +include(joinpath(TEST_DIR, "mocks/mock_optimizer.jl")) +include(joinpath(TEST_DIR, "mocks/mock_system.jl")) +include(joinpath(TEST_DIR, "mocks/mock_components.jl")) +include(joinpath(TEST_DIR, "mocks/mock_time_series.jl")) +include(joinpath(TEST_DIR, "mocks/mock_services.jl")) +include(joinpath(TEST_DIR, "mocks/mock_container.jl")) +include(joinpath(TEST_DIR, "mocks/constructors.jl")) +include(joinpath(TEST_DIR, "test_utils/test_types.jl")) + +IOM.objective_function_multiplier(::Type{TestCostVariable}, ::Type{TestFormulation}) = 1.0 + +# --------------------------------------------------------------------------- +# Workload: build container, add a cost term per (component, unit-system) cell +# --------------------------------------------------------------------------- +const N_COMPONENTS = 50 +const TIME_STEPS = 1:24 + +function make_container(devices) + sys = MockSystem(100.0) + settings = IOM.Settings(sys; horizon = Dates.Hour(length(TIME_STEPS)), + resolution = Dates.Hour(1)) + container = IOM.OptimizationContainer(sys, settings, JuMP.Model(), MockDeterministic) + IOM.set_time_steps!(container, TIME_STEPS) + names = [get_name(d) for d in devices] + var_container = IOM.add_variable_container!( + container, TestCostVariable, MockThermalGen, names, TIME_STEPS, + ) + jm = IOM.get_jump_model(container) + for d in devices, t in TIME_STEPS + var_container[get_name(d), t] = JuMP.@variable(jm, base_name = "x_$(get_name(d))_$t") + end + return container +end + +# Build N components, evenly distributed across the 3 unit systems. The cost +# *curves* are concretely typed (CostCurve{LinearCurve, NaturalUnit/SU/DU}) 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()) + for i in 1:N_COMPONENTS + u = units[mod1(i, 3)] + push!(devices, make_mock_thermal("g$i"; base_power = 50.0 + i)) + push!(curves, IS.CostCurve(IS.LinearCurve(30.0 + i / 10), u)) + end + return devices, curves +end + +function add_all_costs!(container, devices, curves) + for (d, c) in zip(devices, curves) + IOM.add_variable_cost_to_objective!( + container, TestCostVariable, d, c, TestFormulation, + ) + end + return nothing +end + +# --------------------------------------------------------------------------- +# Run profile +# --------------------------------------------------------------------------- + +function main() + devices, curves = make_workload() + + # Warm up the JIT before measuring. + let c = make_container(devices) + add_all_costs!(c, devices, curves) + end + + println("\n=== JET.@report_opt: dynamic dispatch sites in cost-objective path ===") + let c = make_container(devices) + # report_opt walks the call graph and lists every site where Julia's + # compiler couldn't fully infer types — the actionable list for rung 4/6. + rep = @report_opt add_all_costs!(c, devices, curves) + show(stdout, MIME"text/plain"(), rep) + println() + end + + # Benchmark: build a fresh container + add all costs. The container build is + # the same work each iteration, so deltas across rungs reflect the cost-path + # change. (To isolate cost-path more precisely, use @profile on a longer run.) + println("\n=== @btime build + add_all_costs! (N=$N_COMPONENTS, T=$(length(TIME_STEPS))) ===") + @btime begin + c = make_container($devices) + add_all_costs!(c, $devices, $curves) + end + + # Single-component @code_warntype at the entry point — useful when iterating + # on which call sites remain type-unstable. Comment out for batch runs. + println("\n=== @code_warntype add_variable_cost_to_objective! (single call) ===") + let c = make_container(devices) + d, cc = devices[1], curves[1] + InteractiveUtils.@code_warntype IOM.add_variable_cost_to_objective!( + c, TestCostVariable, d, cc, TestFormulation, + ) + end + + return nothing +end + +main() diff --git a/src/objective_function/cost_term_helpers.jl b/src/objective_function/cost_term_helpers.jl index 2f3a85fe..63dabd77 100644 --- a/src/objective_function/cost_term_helpers.jl +++ b/src/objective_function/cost_term_helpers.jl @@ -138,7 +138,7 @@ function add_proportional_cost_invariant!( ::Type{T}, component::C, cost_term::Float64, - power_units::IS.UnitSystem, + power_units::IS.AbstractUnitSystem, multiplier::Float64 = 1.0, ) where {T <: VariableType, C <: IS.InfrastructureSystemsComponent} iszero(cost_term) && return diff --git a/src/objective_function/value_curve_cost.jl b/src/objective_function/value_curve_cost.jl index d06e855a..09d3f173 100644 --- a/src/objective_function/value_curve_cost.jl +++ b/src/objective_function/value_curve_cost.jl @@ -257,7 +257,7 @@ function _add_ts_incremental_pwl_cost!( component::C, ::Type{T}, ::Type{U}, - power_units::IS.UnitSystem, + power_units::IS.AbstractUnitSystem, device_base_power::Float64, ) where { D <: OfferDirection, @@ -329,7 +329,7 @@ function _fill_pwl_data_from_arrays!( point_axis::UnitRange{Int64}, name::String, time::Int, - power_units::IS.UnitSystem, + power_units::IS.AbstractUnitSystem, model_base_power::Float64, device_base_power::Float64, ) diff --git a/src/utils/component_utils.jl b/src/utils/component_utils.jl index 1d240e6b..6979aa32 100644 --- a/src/utils/component_utils.jl +++ b/src/utils/component_utils.jl @@ -79,164 +79,60 @@ end ################################################## """ -Obtain proportional (marginal or slope) cost data in system base per unit -depending on the specified power units +Proportional (slope) cost coefficient normalized to system base. """ -function get_proportional_cost_per_system_unit( +get_proportional_cost_per_system_unit( cost_term::Float64, - unit_system::IS.UnitSystem, + unit_system::IS.AbstractUnitSystem, system_base_power::Float64, device_base_power::Float64, +) = IS.convert_cost_coefficient( + cost_term, unit_system, IS.SU, + system_base_power, device_base_power, ) - return _get_proportional_cost_per_system_unit( - cost_term, - Val{unit_system}(), - system_base_power, - device_base_power, - ) -end - -function _get_proportional_cost_per_system_unit( - cost_term::Float64, - ::Val{IS.UnitSystem.SYSTEM_BASE}, - system_base_power::Float64, - device_base_power::Float64, -) - return cost_term -end - -function _get_proportional_cost_per_system_unit( - cost_term::Float64, - ::Val{IS.UnitSystem.DEVICE_BASE}, - system_base_power::Float64, - device_base_power::Float64, -) - return cost_term * (system_base_power / device_base_power) -end - -function _get_proportional_cost_per_system_unit( - cost_term::Float64, - ::Val{IS.UnitSystem.NATURAL_UNITS}, - system_base_power::Float64, - device_base_power::Float64, -) - return cost_term * system_base_power -end """ -Obtain quadratic cost data in system base per unit -depending on the specified power units +Quadratic cost coefficient normalized to system base. """ -function get_quadratic_cost_per_system_unit( - cost_term::Float64, - unit_system::IS.UnitSystem, - system_base_power::Float64, - device_base_power::Float64, -) - return _get_quadratic_cost_per_system_unit( - cost_term, - Val{unit_system}(), - system_base_power, - device_base_power, - ) -end - -function _get_quadratic_cost_per_system_unit( - cost_term::Float64, - ::Val{IS.UnitSystem.SYSTEM_BASE}, # SystemBase Unit - system_base_power::Float64, - device_base_power::Float64, -) - return cost_term -end - -function _get_quadratic_cost_per_system_unit( +get_quadratic_cost_per_system_unit( cost_term::Float64, - ::Val{IS.UnitSystem.DEVICE_BASE}, # DeviceBase Unit + unit_system::IS.AbstractUnitSystem, 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, ) - return cost_term * (system_base_power / device_base_power)^2 -end - -function _get_quadratic_cost_per_system_unit( - cost_term::Float64, - ::Val{IS.UnitSystem.NATURAL_UNITS}, # Natural Units - system_base_power::Float64, - device_base_power::Float64, -) - return cost_term * system_base_power^2 -end """ -Obtain the normalized PiecewiseLinear cost data in system base per unit -depending on the specified power units. - -Note that the costs (y-axis) are always in \$/h so -they do not require transformation +PiecewiseLinearData normalized to system base. x-coords (power) rescale as +power values; y-coords (\$/h) are invariant under power-base changes. """ function get_piecewise_pointcurve_per_system_unit( cost_component::IS.PiecewiseLinearData, - unit_system::IS.UnitSystem, + unit_system::IS.AbstractUnitSystem, system_base_power::Float64, device_base_power::Float64, ) - return _get_piecewise_pointcurve_per_system_unit( - cost_component, - Val{unit_system}(), - system_base_power, - device_base_power, + x_ratio = IS.convert_cost_coefficient( + 1.0, unit_system, IS.SU, + system_base_power, device_base_power, -1, ) -end - -function _get_piecewise_pointcurve_per_system_unit( - cost_component::IS.PiecewiseLinearData, - ::Val{IS.UnitSystem.SYSTEM_BASE}, - system_base_power::Float64, - device_base_power::Float64, -) - return cost_component -end - -function _get_piecewise_pointcurve_per_system_unit( - cost_component::IS.PiecewiseLinearData, - ::Val{IS.UnitSystem.DEVICE_BASE}, - system_base_power::Float64, - device_base_power::Float64, -) - points = cost_component.points - points_normalized = Vector{NamedTuple{(:x, :y)}}(undef, length(points)) - for (ix, point) in enumerate(points) - points_normalized[ix] = - (x = point.x * (device_base_power / system_base_power), y = point.y) - end - return IS.PiecewiseLinearData(points_normalized) -end - -function _get_piecewise_pointcurve_per_system_unit( - cost_component::IS.PiecewiseLinearData, - ::Val{IS.UnitSystem.NATURAL_UNITS}, - system_base_power::Float64, - device_base_power::Float64, -) points = cost_component.points points_normalized = Vector{NamedTuple{(:x, :y)}}(undef, length(points)) for (ix, point) in enumerate(points) - points_normalized[ix] = (x = point.x / system_base_power, y = point.y) + points_normalized[ix] = (x = point.x * x_ratio, y = point.y) end return IS.PiecewiseLinearData(points_normalized) end """ -Obtain the normalized PiecewiseStepData in system base per unit depending on the specified -power units. - -Note that the costs (y-axis) are in \$/MWh, \$/(sys pu h) or \$/(device pu h), so they also -require transformation. +PiecewiseStepData normalized to system base. x-coords rescale as power +values; y-coords are \$ per unit of x and rescale by the inverse ratio. """ function get_piecewise_curve_per_system_unit( cost_component::IS.PiecewiseStepData, - unit_system::IS.UnitSystem, + unit_system::IS.AbstractUnitSystem, system_base_power::Float64, device_base_power::Float64, ) @@ -254,52 +150,19 @@ end function get_piecewise_curve_per_system_unit( x_coords::AbstractVector, y_coords::AbstractVector, - unit_system::IS.UnitSystem, + unit_system::IS.AbstractUnitSystem, system_base_power::Float64, device_base_power::Float64, ) - return _get_piecewise_curve_per_system_unit( - x_coords, - y_coords, - Val{unit_system}(), - system_base_power, - device_base_power, + x_ratio = IS.convert_cost_coefficient( + 1.0, unit_system, IS.SU, + system_base_power, device_base_power, -1, ) -end - -function _get_piecewise_curve_per_system_unit( - x_coords::AbstractVector, - y_coords::AbstractVector, - ::Val{IS.UnitSystem.SYSTEM_BASE}, - system_base_power::Float64, - device_base_power::Float64, -) - return x_coords, y_coords -end - -function _get_piecewise_curve_per_system_unit( - x_coords::AbstractVector, - y_coords::AbstractVector, - ::Val{IS.UnitSystem.DEVICE_BASE}, - system_base_power::Float64, - device_base_power::Float64, -) - ratio = device_base_power / system_base_power - x_coords_normalized = x_coords .* ratio - y_coords_normalized = y_coords ./ ratio - return x_coords_normalized, y_coords_normalized -end - -function _get_piecewise_curve_per_system_unit( - x_coords::AbstractVector, - y_coords::AbstractVector, - ::Val{IS.UnitSystem.NATURAL_UNITS}, - system_base_power::Float64, - device_base_power::Float64, -) - x_coords_normalized = x_coords ./ system_base_power - y_coords_normalized = y_coords .* system_base_power - return x_coords_normalized, y_coords_normalized + y_ratio = IS.convert_cost_coefficient( + 1.0, unit_system, IS.SU, + system_base_power, device_base_power, 1, + ) + return x_coords .* x_ratio, y_coords .* y_ratio end is_time_variant(x) = IS.is_time_series_backed(x) diff --git a/test/Project.toml b/test/Project.toml index fed99d0f..0198acc3 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -31,7 +31,7 @@ UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05" [sources] InfrastructureOptimizationModels = {path = ".."} -InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} +InfrastructureSystems = {rev = "lk/units-domain-agnostic-is4", url = "https://github.com/NREL-Sienna/InfrastructureSystems.jl.git"} [compat] HiGHS = "1" diff --git a/test/test_linear_curve.jl b/test/test_linear_curve.jl index b83661e0..72236db8 100644 --- a/test/test_linear_curve.jl +++ b/test/test_linear_curve.jl @@ -72,7 +72,7 @@ end TestActivePowerVariable, device, 25.0, - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), ) expected_coef = 25.0 * 100.0 * 1.0 @@ -102,7 +102,7 @@ end TestActivePowerVariable, device, 20.0, - IS.UnitSystem.SYSTEM_BASE, + IS.SystemBaseUnit(), 2.0, ) @@ -127,7 +127,7 @@ end TestActivePowerVariable, device, 0.0, - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), ) # Should have zero coefficients since cost_term is zero @@ -154,7 +154,7 @@ end # Cost: 30 $/MWh in natural units (MW) cost_curve = IS.CostCurve( IS.LinearCurve(30.0), - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -190,7 +190,7 @@ end # Cost: 30 $/p.u.h in system base units cost_curve = IS.CostCurve( IS.LinearCurve(30.0), - IS.UnitSystem.SYSTEM_BASE, + IS.SystemBaseUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -227,7 +227,7 @@ end # Cost: 30 $/p.u.h in device base units cost_curve = IS.CostCurve( IS.LinearCurve(30.0), - IS.UnitSystem.DEVICE_BASE, + IS.DeviceBaseUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -265,7 +265,7 @@ end # Cost: 20 $/MWh in natural units cost_curve = IS.CostCurve( IS.LinearCurve(20.0), - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -306,7 +306,7 @@ end # Total cost: 8.0 * 5.0 = 40.0 $/MWh fuel_curve = IS.FuelCurve( IS.LinearCurve(8.0), # MMBTU/MWh - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), 5.0, # $/MMBTU ) @@ -390,7 +390,7 @@ end ) fuel_curve = IS.FuelCurve( IS.LinearCurve(proportional_term), - IS.UnitSystem.SYSTEM_BASE, # already normalized + IS.SystemBaseUnit(), # already normalized ts_key, ) diff --git a/test/test_piecewise_linear.jl b/test/test_piecewise_linear.jl index e6c3dff2..f9c576b6 100644 --- a/test/test_piecewise_linear.jl +++ b/test/test_piecewise_linear.jl @@ -89,7 +89,7 @@ function setup_pwl_test(; device_base_power = 100.0, resolution = Dates.Hour(1), points = CONVEX_PWL_POINTS, - unit_system = IS.UnitSystem.NATURAL_UNITS, + unit_system = IS.NaturalUnit(), fuel_cost = nothing, # If set, creates FuelCurve instead of CostCurve ) # When fuel_cost is provided, the device's operation_cost must also have it @@ -296,7 +296,7 @@ end (; container, device, cost_curve) = setup_pwl_test(; device_base_power = 50.0, points = natural_points, - unit_system = IS.UnitSystem.NATURAL_UNITS, + unit_system = IS.NaturalUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -338,7 +338,7 @@ end (; container, device, cost_curve) = setup_pwl_test(; device_base_power = 50.0, points = system_base_points, - unit_system = IS.UnitSystem.SYSTEM_BASE, + unit_system = IS.SystemBaseUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -473,7 +473,7 @@ end ) cost_curve = IS.CostCurve( incremental_curve, # Already an IncrementalCurve - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -810,7 +810,7 @@ end ) fuel_curve = IS.FuelCurve( incremental_curve, - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), fuel_cost, ) op_cost = MockOperationCost(0.0, false, fuel_cost) diff --git a/test/test_pwl_methods.jl b/test/test_pwl_methods.jl index 317ece11..87538031 100644 --- a/test/test_pwl_methods.jl +++ b/test/test_pwl_methods.jl @@ -462,7 +462,7 @@ end # Fuel cost: 3.0 $/MMBTU fuel_curve = IS.FuelCurve( IS.PiecewisePointCurve([(0.0, 0.0), (50.0, 300.0), (100.0, 700.0)]), - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), 3.0, # $/MMBTU ) diff --git a/test/test_quadratic_curve.jl b/test/test_quadratic_curve.jl index d90719f1..671d26b4 100644 --- a/test/test_quadratic_curve.jl +++ b/test/test_quadratic_curve.jl @@ -237,7 +237,7 @@ end # Cost: quadratic=0.5, linear=20 in natural units (MW) cost_curve = IS.CostCurve( IS.QuadraticCurve(0.5, 20.0, 0.0), # (quadratic, linear, constant) - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -279,7 +279,7 @@ end # Cost in system base units - no conversion needed cost_curve = IS.CostCurve( IS.QuadraticCurve(2.0, 30.0, 0.0), - IS.UnitSystem.SYSTEM_BASE, + IS.SystemBaseUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -318,7 +318,7 @@ end cost_curve = IS.CostCurve( IS.QuadraticCurve(1.0, 20.0, 0.0), - IS.UnitSystem.DEVICE_BASE, + IS.DeviceBaseUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -359,7 +359,7 @@ end cost_curve = IS.CostCurve( IS.QuadraticCurve(2.0, 40.0, 0.0), - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), ) InfrastructureOptimizationModels.add_variable_cost_to_objective!( @@ -435,7 +435,7 @@ end # Fuel cost: 4.0 $/MMBTU fuel_curve = IS.FuelCurve( IS.QuadraticCurve(0.02, 7.0, 0.0), # (quadratic, linear, constant) - IS.UnitSystem.NATURAL_UNITS, + IS.NaturalUnit(), 4.0, # $/MMBTU ) diff --git a/test/test_ts_value_curve_objective.jl b/test/test_ts_value_curve_objective.jl index fe816dae..99d80391 100644 --- a/test/test_ts_value_curve_objective.jl +++ b/test/test_ts_value_curve_objective.jl @@ -26,7 +26,7 @@ end # Helper to create a CostCurve{TimeSeriesPiecewiseIncrementalCurve} function _make_ts_incremental_cost_curve(; - power_units::IS.UnitSystem = IS.UnitSystem.NATURAL_UNITS, + power_units::IS.AbstractUnitSystem = IS.NaturalUnit(), ) key = _make_forecast_key("test_forecast") ii_key = _make_forecast_key("initial_input") @@ -355,13 +355,13 @@ end raw_breakpoints = [0.0, 50.0, 100.0] for (unit_system, expected_slope_factor, expected_bp_factor) in [ - (IS.UnitSystem.NATURAL_UNITS, system_base, 1.0 / system_base), + (IS.NaturalUnit(), system_base, 1.0 / system_base), ( - IS.UnitSystem.DEVICE_BASE, + IS.DeviceBaseUnit(), 1.0 / (device_base / system_base), device_base / system_base, ), - (IS.UnitSystem.SYSTEM_BASE, 1.0, 1.0), + (IS.SystemBaseUnit(), 1.0, 1.0), ] @testset "$unit_system" begin container = make_test_container(time_steps; base_power = system_base)