From 802821a1de90a9e885b46de7fe786d7d8f537307 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 20 Jan 2026 14:42:10 -0700 Subject: [PATCH 01/16] Add units-aware getters/setters infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add units.jl with Unitful integration, RelativeQuantity type for DU/SU - Define custom Mvar and MVA units for reactive/apparent power - Update generate_structs.jl template to generate two-method accessors: - Default accessor returns natural units (MW, Mvar, etc.) - Optional second argument accepts explicit unit (DU, SU, MW, etc.) - Add get_natural_unit() to map conversion types to appropriate units (reactive power fields → Mvar, other power fields → MW) - Export unit types and Unitful re-exports from main module - Register custom Unitful units in __init__() Co-Authored-By: Claude Opus 4.5 --- Project.toml | 2 + src/InfrastructureSystems.jl | 13 ++++ src/units.jl | 118 ++++++++++++++++++++++++++++++++++ src/utils/generate_structs.jl | 44 +++++++++++-- 4 files changed, 173 insertions(+), 4 deletions(-) diff --git a/Project.toml b/Project.toml index 3f89ed780..2043d7ee0 100644 --- a/Project.toml +++ b/Project.toml @@ -5,6 +5,7 @@ version = "3.4.1" [deps] CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" @@ -60,6 +61,7 @@ Tables = "^1.11" TerminalLoggers = "~0.1" TimeSeries = "^0.24, ^0.25" TimerOutputs = "^0.5" +Unitful = "^1.12" UUIDs = "1" YAML = "~0.4" julia = "^1.6" diff --git a/src/InfrastructureSystems.jl b/src/InfrastructureSystems.jl index bbf1fa251..c9c749e8a 100644 --- a/src/InfrastructureSystems.jl +++ b/src/InfrastructureSystems.jl @@ -8,6 +8,12 @@ export PiecewisePointCurve, PiecewiseIncrementalCurve, PiecewiseAverageCurve export TimeSeriesLinearCurve, TimeSeriesQuadraticCurve, TimeSeriesPiecewisePointCurve export TimeSeriesPiecewiseIncrementalCurve, TimeSeriesPiecewiseAverageCurve +# Unit types for explicit units in getters/setters +export MW, Mvar, MVA, kV, OHMS, SIEMENS +export DU, SU, DeviceBaseUnit, SystemBaseUnit +export AbstractRelativeUnit, RelativeQuantity +export ustrip + import Base: @kwdef import CSV import DataFrames @@ -208,4 +214,11 @@ include("function_data/make_convex.jl") include("deprecated.jl") include("Optimization/Optimization.jl") include("Simulation/Simulation.jl") + +# Register custom Unitful units (Mvar, MVA) so conversions work properly +# TODO: cleaner way? seems like overkill to register the whole module. +function __init__() + Unitful.register(@__MODULE__) +end + end # module diff --git a/src/units.jl b/src/units.jl index 42090f1f2..c5816f5f6 100644 --- a/src/units.jl +++ b/src/units.jl @@ -2,3 +2,121 @@ time_period_conversion(time_period::Union{Dates.TimePeriod, Dates.DatePeriod}) = convert(Dates.Millisecond, time_period) time_period_conversion(time_periods::Dict{String, <:Dates.Period}) = convert(Dict{String, Dates.Millisecond}, time_periods) + +############################### +# Power Systems Unit Types +############################### + +using Unitful: @u_str, @unit, Quantity, Units, uconvert +import Unitful: ustrip +import Unitful + +# Define power system-specific units (same dimension as MW, different display) +# These are registered with Unitful in __init__() below +@unit Mvar "Mvar" Mvar 1u"MW" false +@unit MVA "MVA" MVA 1u"MW" false + +# Re-export common Unitful units for power systems +const MW = u"MW" +const kV = u"kV" +const OHMS = u"Ω" +const SIEMENS = u"S" + +# Note: Unitful.register() is called in InfrastructureSystems.__init__() + +# Relative unit types (for per-unit values) +abstract type AbstractRelativeUnit end + +""" +Device base per-unit. Values are normalized to the device's own base power. +""" +struct DeviceBaseUnit <: AbstractRelativeUnit end + +""" +System base per-unit. Values are normalized to the system's base power. +""" +struct SystemBaseUnit <: AbstractRelativeUnit end + +const DU = DeviceBaseUnit() +const SU = SystemBaseUnit() + +""" + RelativeQuantity{T<:Number, U<:AbstractRelativeUnit} <: Number + +A quantity with relative (per-unit) units, either device base (DU) or system base (SU). + +# Examples +```julia +0.6 * DU # 0.6 per-unit on device base +0.3 * SU # 0.3 per-unit on system base +``` +""" +struct RelativeQuantity{T <: Number, U <: AbstractRelativeUnit} <: Number + value::T + unit::U +end + +# Construction via multiplication +Base.:*(a::Number, b::AbstractRelativeUnit) = RelativeQuantity(a, b) +Base.:*(b::AbstractRelativeUnit, a::Number) = RelativeQuantity(a, b) + +# Arithmetic operations - same unit type only +Base.:+(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + RelativeQuantity(a.value + b.value, a.unit) +Base.:-(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + RelativeQuantity(a.value - b.value, a.unit) +Base.:-(a::RelativeQuantity{T, U}) where {T, U} = + RelativeQuantity(-a.value, a.unit) + +# Scalar multiplication/division +Base.:*(a::Number, b::RelativeQuantity{T, U}) where {T, U} = + RelativeQuantity(a * b.value, b.unit) +Base.:*(a::RelativeQuantity{T, U}, b::Number) where {T, U} = + RelativeQuantity(a.value * b, a.unit) +Base.:/(a::RelativeQuantity{T, U}, b::Number) where {T, U} = + RelativeQuantity(a.value / b, a.unit) + +# Comparisons +Base.:(==)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + a.value == b.value +Base.:(<)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + a.value < b.value +Base.:(<=)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + a.value <= b.value +Base.isless(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + isless(a.value, b.value) +Base.isapprox( + a::RelativeQuantity{T, U}, + b::RelativeQuantity{S, U}; + kwargs..., +) where {T, S, U} = + isapprox(a.value, b.value; kwargs...) + +# Value extraction +""" + ustrip(q::RelativeQuantity) + +Extract the numeric value from a RelativeQuantity. +""" +ustrip(q::RelativeQuantity) = q.value + +# Type conversions +Base.convert(::Type{RelativeQuantity{T, U}}, q::RelativeQuantity{S, U}) where {T, S, U} = + RelativeQuantity(convert(T, q.value), q.unit) +Base.promote_rule( + ::Type{RelativeQuantity{T, U}}, + ::Type{RelativeQuantity{S, U}}, +) where {T, S, U} = + RelativeQuantity{promote_type(T, S), U} + +# Display +Base.show(io::IO, q::RelativeQuantity{T, DeviceBaseUnit}) where {T} = + print(io, q.value, " DU") +Base.show(io::IO, q::RelativeQuantity{T, SystemBaseUnit}) where {T} = + print(io, q.value, " SU") +Base.show(io::IO, ::DeviceBaseUnit) = print(io, "DU") +Base.show(io::IO, ::SystemBaseUnit) = print(io, "SU") + +# Zero/one for numeric operations +Base.zero(::Type{RelativeQuantity{T, U}}) where {T, U} = RelativeQuantity(zero(T), U()) +Base.one(::Type{RelativeQuantity{T, U}}) where {T, U} = RelativeQuantity(one(T), U()) diff --git a/src/utils/generate_structs.jl b/src/utils/generate_structs.jl index 5dd0552d5..5705cefdd 100644 --- a/src/utils/generate_structs.jl +++ b/src/utils/generate_structs.jl @@ -1,6 +1,26 @@ import Mustache +# Map conversion_unit to the default natural Unitful unit for getters +const NATURAL_UNIT_MAP = Dict{String, String}( + ":mva" => "MW", # Power → MW (default for :mva) + ":ohm" => "OHMS", # Impedance → Ohms + ":siemens" => "SIEMENS", # Admittance → Siemens +) + +# Determine the natural unit based on conversion_unit and field name +function get_natural_unit(conversion_unit::String, field_name::String) + if conversion_unit == ":mva" + # Reactive power fields use Mvar instead of MW + if occursin("reactive", lowercase(field_name)) + return "Mvar" + else + return "MW" + end + end + return get(NATURAL_UNIT_MAP, conversion_unit, "MW") +end + const STRUCT_TEMPLATE = """ #= This file is auto-generated. Do not edit. @@ -63,13 +83,26 @@ end {{/has_null_values}} {{#accessors}} +{{#needs_conversion}} +{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`. Returns natural units ({{natural_unit}}) by default.\"\"\"{{/create_docstring}} +{{accessor}}(value::{{struct_name}}) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), {{natural_unit}}) +{{accessor}}(value::{{struct_name}}, units) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), units) +{{/needs_conversion}} +{{^needs_conversion}} {{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`.\"\"\"{{/create_docstring}} -{{accessor}}(value::{{struct_name}}) = {{#needs_conversion}}get_value(value, Val(:{{name}}), Val({{conversion_unit}})){{/needs_conversion}}{{^needs_conversion}}value.{{name}}{{/needs_conversion}} +{{accessor}}(value::{{struct_name}}) = value.{{name}} +{{/needs_conversion}} {{/accessors}} {{#setters}} +{{#needs_conversion}} +{{#create_docstring}}\"\"\"Set [`{{struct_name}}`](@ref) `{{name}}`. Value must have units (e.g., `30.0MW`, `0.5DU`).\"\"\"{{/create_docstring}} +{{setter}}(value::{{struct_name}}, val) = value.{{name}} = set_value(value, Val(:{{name}}), val, Val({{conversion_unit}})) +{{/needs_conversion}} +{{^needs_conversion}} {{#create_docstring}}\"\"\"Set [`{{struct_name}}`](@ref) `{{name}}`.\"\"\"{{/create_docstring}} -{{setter}}(value::{{struct_name}}, val) = value.{{name}} = {{#needs_conversion}}set_value(value, Val(:{{name}}), val, Val({{conversion_unit}})){{/needs_conversion}}{{^needs_conversion}}val{{/needs_conversion}} +{{setter}}(value::{{struct_name}}, val) = value.{{name}} = val +{{/needs_conversion}} {{/setters}} {{#custom_code}} @@ -140,6 +173,8 @@ function generate_structs(directory, data::Vector; print_results = true) end accessor_name = accessor_module * "get_" * param["name"] setter_name = accessor_module * "set_" * param["name"] * "!" + conversion_unit = get(param, "conversion_unit", "nothing") + natural_unit = get_natural_unit(conversion_unit, param["name"]) push!( accessors, Dict( @@ -147,7 +182,8 @@ function generate_structs(directory, data::Vector; print_results = true) "accessor" => accessor_name, "create_docstring" => create_docstring, "needs_conversion" => get(param, "needs_conversion", false), - "conversion_unit" => get(param, "conversion_unit", "nothing"), + "conversion_unit" => conversion_unit, + "natural_unit" => natural_unit, ), ) include_setter = !get(param, "exclude_setter", false) @@ -160,7 +196,7 @@ function generate_structs(directory, data::Vector; print_results = true) "data_type" => param["data_type"], "create_docstring" => create_docstring, "needs_conversion" => get(param, "needs_conversion", false), - "conversion_unit" => get(param, "conversion_unit", "nothing"), + "conversion_unit" => conversion_unit, ), ) end From 0c768d7e72bd784212a06658f6fd409d06b2cd0a Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 10 Mar 2026 20:54:28 -0600 Subject: [PATCH 02/16] adjust struct generation --- src/utils/generate_structs.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/generate_structs.jl b/src/utils/generate_structs.jl index 5705cefdd..f1f2b1023 100644 --- a/src/utils/generate_structs.jl +++ b/src/utils/generate_structs.jl @@ -84,8 +84,8 @@ end {{/has_null_values}} {{#accessors}} {{#needs_conversion}} -{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`. Returns natural units ({{natural_unit}}) by default.\"\"\"{{/create_docstring}} -{{accessor}}(value::{{struct_name}}) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), {{natural_unit}}) +{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`. Returns value in the system's unit setting (natural units by default).\"\"\"{{/create_docstring}} +{{accessor}}(value::{{struct_name}}) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), _get_system_units(value, Val({{conversion_unit}}))) {{accessor}}(value::{{struct_name}}, units) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), units) {{/needs_conversion}} {{^needs_conversion}} From 58ea095b3c128dd6b5ba07bdf39b131a06cf3d46 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 17 Mar 2026 13:24:23 -0600 Subject: [PATCH 03/16] tests now pass --- src/cost_aliases.jl | 17 +++++++++++------ src/units.jl | 8 ++++---- src/value_curve.jl | 9 +++------ test/test_cost_functions.jl | 4 ++-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/cost_aliases.jl b/src/cost_aliases.jl index c42144322..bfc1ca5df 100644 --- a/src/cost_aliases.jl +++ b/src/cost_aliases.jl @@ -29,6 +29,7 @@ curve = LinearCurve(50.0, 100.0) const LinearCurve = InputOutputCurve{LinearFunctionData} is_cost_alias(::Union{LinearCurve, Type{LinearCurve}}) = true +simple_type_name(::LinearCurve) = "LinearCurve" InputOutputCurve{LinearFunctionData}(proportional_term::Real) = InputOutputCurve(LinearFunctionData(proportional_term)) @@ -44,7 +45,7 @@ get_constant_term(vc::LinearCurve) = get_constant_term(get_function_data(vc)) Base.show(io::IO, vc::LinearCurve) = if isnothing(get_input_at_zero(vc)) - print(io, "$(typeof(vc))($(get_proportional_term(vc)), $(get_constant_term(vc)))") + print(io, "LinearCurve($(get_proportional_term(vc)), $(get_constant_term(vc)))") else Base.show_default(io, vc) end @@ -67,6 +68,7 @@ curve = QuadraticCurve(0.002, 25.0, 150.0) const QuadraticCurve = InputOutputCurve{QuadraticFunctionData} is_cost_alias(::Union{QuadraticCurve, Type{QuadraticCurve}}) = true +simple_type_name(::QuadraticCurve) = "QuadraticCurve" InputOutputCurve{QuadraticFunctionData}(quadratic_term, proportional_term, constant_term) = InputOutputCurve( @@ -86,7 +88,7 @@ Base.show(io::IO, vc::QuadraticCurve) = if isnothing(get_input_at_zero(vc)) print( io, - "$(typeof(vc))($(get_quadratic_term(vc)), $(get_proportional_term(vc)), $(get_constant_term(vc)))", + "QuadraticCurve($(get_quadratic_term(vc)), $(get_proportional_term(vc)), $(get_constant_term(vc)))", ) else Base.show_default(io, vc) @@ -112,6 +114,7 @@ curve = PiecewisePointCurve([(100.0, 400.0), (200.0, 900.0), (300.0, 1500.0)]) const PiecewisePointCurve = InputOutputCurve{PiecewiseLinearData} is_cost_alias(::Union{PiecewisePointCurve, Type{PiecewisePointCurve}}) = true +simple_type_name(::PiecewisePointCurve) = "PiecewisePointCurve" InputOutputCurve{PiecewiseLinearData}(points::Vector) = InputOutputCurve(PiecewiseLinearData(points)) @@ -131,7 +134,7 @@ get_slopes(vc::PiecewisePointCurve) = get_slopes(get_function_data(vc)) # Here we manually circumvent the @NamedTuple{x::Float64, y::Float64} type annotation, but we keep things looking like named tuples Base.show(io::IO, vc::PiecewisePointCurve) = if isnothing(get_input_at_zero(vc)) - print(io, "$(typeof(vc))([$(join(get_points(vc), ", "))])") + print(io, "PiecewisePointCurve([$(join(get_points(vc), ", "))])") else Base.show_default(io, vc) end @@ -159,6 +162,7 @@ curve = PiecewiseIncrementalCurve(500.0, [100.0, 150.0, 200.0], [30.0, 35.0]) const PiecewiseIncrementalCurve = IncrementalCurve{PiecewiseStepData} is_cost_alias(::Union{PiecewiseIncrementalCurve, Type{PiecewiseIncrementalCurve}}) = true +simple_type_name(::PiecewiseIncrementalCurve) = "PiecewiseIncrementalCurve" IncrementalCurve{PiecewiseStepData}(initial_input, x_coords::Vector, slopes::Vector) = IncrementalCurve(PiecewiseStepData(x_coords, slopes), initial_input) @@ -181,9 +185,9 @@ Base.show(io::IO, vc::PiecewiseIncrementalCurve) = print( io, if isnothing(get_input_at_zero(vc)) - "$(typeof(vc))($(get_initial_input(vc)), $(get_x_coords(vc)), $(get_slopes(vc)))" + "PiecewiseIncrementalCurve($(get_initial_input(vc)), $(get_x_coords(vc)), $(get_slopes(vc)))" else - "$(typeof(vc))($(get_input_at_zero(vc)), $(get_initial_input(vc)), $(get_x_coords(vc)), $(get_slopes(vc)))" + "PiecewiseIncrementalCurve($(get_input_at_zero(vc)), $(get_initial_input(vc)), $(get_x_coords(vc)), $(get_slopes(vc)))" end, ) @@ -202,6 +206,7 @@ input). If your data gives incremental/marginal rates instead, use const PiecewiseAverageCurve = AverageRateCurve{PiecewiseStepData} is_cost_alias(::Union{PiecewiseAverageCurve, Type{PiecewiseAverageCurve}}) = true +simple_type_name(::PiecewiseAverageCurve) = "PiecewiseAverageCurve" AverageRateCurve{PiecewiseStepData}(initial_input, x_coords::Vector, y_coords::Vector) = AverageRateCurve(PiecewiseStepData(x_coords, y_coords), initial_input) @@ -216,7 +221,7 @@ Base.show(io::IO, vc::PiecewiseAverageCurve) = if isnothing(get_input_at_zero(vc)) print( io, - "$(typeof(vc))($(get_initial_input(vc)), $(get_x_coords(vc)), $(get_average_rates(vc)))", + "PiecewiseAverageCurve($(get_initial_input(vc)), $(get_x_coords(vc)), $(get_average_rates(vc)))", ) else Base.show_default(io, vc) diff --git a/src/units.jl b/src/units.jl index c5816f5f6..71eadd8d3 100644 --- a/src/units.jl +++ b/src/units.jl @@ -68,12 +68,12 @@ Base.:-(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = Base.:-(a::RelativeQuantity{T, U}) where {T, U} = RelativeQuantity(-a.value, a.unit) -# Scalar multiplication/division -Base.:*(a::Number, b::RelativeQuantity{T, U}) where {T, U} = +# Scalar multiplication/division (use Real to avoid ambiguity with Unitful types) +Base.:*(a::Real, b::RelativeQuantity{T, U}) where {T, U} = RelativeQuantity(a * b.value, b.unit) -Base.:*(a::RelativeQuantity{T, U}, b::Number) where {T, U} = +Base.:*(a::RelativeQuantity{T, U}, b::Real) where {T, U} = RelativeQuantity(a.value * b, a.unit) -Base.:/(a::RelativeQuantity{T, U}, b::Number) where {T, U} = +Base.:/(a::RelativeQuantity{T, U}, b::Real) where {T, U} = RelativeQuantity(a.value / b, a.unit) # Comparisons diff --git a/src/value_curve.jl b/src/value_curve.jl index 81efebbff..3b73246fc 100644 --- a/src/value_curve.jl +++ b/src/value_curve.jl @@ -272,12 +272,9 @@ end IncrementalCurve(data::AverageRateCurve) = IncrementalCurve(InputOutputCurve(data)) # PRINTING -"Whether there is a cost alias for the instance or type under consideration" -is_cost_alias(::Union{ValueCurve, Type{<:ValueCurve}}) = false - -# For cost aliases, return the alias name; otherwise, return the type name without the parameter -simple_type_name(curve::ValueCurve) = - string(is_cost_alias(curve) ? typeof(curve) : nameof(typeof(curve))) +# typeof() can't recover const alias names, so we use nameof for non-aliases +# and override in cost_aliases.jl for each alias. +simple_type_name(curve::ValueCurve) = string(nameof(typeof(curve))) function Base.show(io::IO, ::MIME"text/plain", curve::InputOutputCurve) print(io, simple_type_name(curve)) diff --git a/test/test_cost_functions.jl b/test/test_cost_functions.jl index 7a66b2612..8070d0f3c 100644 --- a/test/test_cost_functions.jl +++ b/test/test_cost_functions.jl @@ -287,9 +287,9 @@ end IS.FuelCurve(IS.InputOutputCurve(IS.LinearFunctionData(0.0, 0.0)), 0.0) @test repr(cc) == sprint(show, cc) == - "InfrastructureSystems.CostCurve{QuadraticCurve}(QuadraticCurve(1.0, 2.0, 3.0), InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2, LinearCurve(0.0, 0.0))" + "InfrastructureSystems.CostCurve{InfrastructureSystems.QuadraticCurve}(QuadraticCurve(1.0, 2.0, 3.0), InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2, LinearCurve(0.0, 0.0))" @test repr(fc) == sprint(show, fc) == - "InfrastructureSystems.FuelCurve{QuadraticCurve}(QuadraticCurve(1.0, 2.0, 3.0), InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2, 4.0, LinearCurve(0.0, 0.0), LinearCurve(0.0, 0.0))" + "InfrastructureSystems.FuelCurve{InfrastructureSystems.QuadraticCurve}(QuadraticCurve(1.0, 2.0, 3.0), InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2, 4.0, LinearCurve(0.0, 0.0), LinearCurve(0.0, 0.0))" @test sprint(show, "text/plain", cc) == sprint(show, "text/plain", cc; context = :compact => false) == "CostCurve:\n value_curve: QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0\n power_units: InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2\n vom_cost: LinearCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 0.0 x + 0.0" From 78c2cd382696891561370cce9eb0ff5c9c7939fd Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 18 Mar 2026 10:06:33 -0600 Subject: [PATCH 04/16] Fix CostCurve/FuelCurve repr tests for CI compatibility The type parameter in repr() may or may not be module-qualified depending on what names are in scope, which differs between local and CI environments. Use occursin checks instead of exact string matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/test_cost_functions.jl | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/test_cost_functions.jl b/test/test_cost_functions.jl index 8070d0f3c..99ecef7d8 100644 --- a/test/test_cost_functions.jl +++ b/test/test_cost_functions.jl @@ -286,10 +286,16 @@ end @test zero(IS.FuelCurve) == IS.FuelCurve(IS.InputOutputCurve(IS.LinearFunctionData(0.0, 0.0)), 0.0) - @test repr(cc) == sprint(show, cc) == - "InfrastructureSystems.CostCurve{InfrastructureSystems.QuadraticCurve}(QuadraticCurve(1.0, 2.0, 3.0), InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2, LinearCurve(0.0, 0.0))" - @test repr(fc) == sprint(show, fc) == - "InfrastructureSystems.FuelCurve{InfrastructureSystems.QuadraticCurve}(QuadraticCurve(1.0, 2.0, 3.0), InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2, 4.0, LinearCurve(0.0, 0.0), LinearCurve(0.0, 0.0))" + # repr and sprint(show, ...) must agree; the type parameter may or may not + # be module-qualified depending on what's in scope, so check key content. + @test repr(cc) == sprint(show, cc) + @test occursin("CostCurve", repr(cc)) + @test occursin("QuadraticCurve(1.0, 2.0, 3.0)", repr(cc)) + @test occursin("LinearCurve(0.0, 0.0)", repr(cc)) + @test repr(fc) == sprint(show, fc) + @test occursin("FuelCurve", repr(fc)) + @test occursin("QuadraticCurve(1.0, 2.0, 3.0)", repr(fc)) + @test occursin("4.0", repr(fc)) @test sprint(show, "text/plain", cc) == sprint(show, "text/plain", cc; context = :compact => false) == "CostCurve:\n value_curve: QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0\n power_units: InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2\n vom_cost: LinearCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 0.0 x + 0.0" From 129d9fde94d6cd05a708cfd058287f09d71c6272 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 27 Mar 2026 14:49:04 -0600 Subject: [PATCH 05/16] Add exclude_getter support to struct generation When exclude_getter is true, generates an internal _get_ prefixed accessor instead of the public get_ accessor. Suppresses the public getter export while keeping setter exports (since exclude_setter means hand-written, not nonexistent). Used by PSY to make get_base_power return unitful values while keeping _get_base_power as raw Float64 for internal conversion machinery. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/generate_structs.jl | 49 ++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/utils/generate_structs.jl b/src/utils/generate_structs.jl index f1f2b1023..b0cdb382f 100644 --- a/src/utils/generate_structs.jl +++ b/src/utils/generate_structs.jl @@ -175,17 +175,36 @@ function generate_structs(directory, data::Vector; print_results = true) setter_name = accessor_module * "set_" * param["name"] * "!" conversion_unit = get(param, "conversion_unit", "nothing") natural_unit = get_natural_unit(conversion_unit, param["name"]) - push!( - accessors, - Dict( - "name" => param["name"], - "accessor" => accessor_name, - "create_docstring" => create_docstring, - "needs_conversion" => get(param, "needs_conversion", false), - "conversion_unit" => conversion_unit, - "natural_unit" => natural_unit, - ), - ) + include_getter = !get(param, "exclude_getter", false) + if include_getter + push!( + accessors, + Dict( + "name" => param["name"], + "accessor" => accessor_name, + "create_docstring" => create_docstring, + "needs_conversion" => get(param, "needs_conversion", false), + "conversion_unit" => conversion_unit, + "natural_unit" => natural_unit, + ), + ) + else + # When public getter is excluded, generate an internal _get_ accessor + # that returns the raw field value (Float64). Used for fields like + # base_power where the public getter is hand-written with units. + internal_name = "_get_" * param["name"] + push!( + accessors, + Dict( + "name" => param["name"], + "accessor" => internal_name, + "create_docstring" => false, + "needs_conversion" => false, + "conversion_unit" => "nothing", + "natural_unit" => "", + ), + ) + end include_setter = !get(param, "exclude_setter", false) if include_setter push!( @@ -201,7 +220,13 @@ function generate_structs(directory, data::Vector; print_results = true) ) end if field["name"] != "internal" && accessor_module == "" - push!(unique_accessor_functions, accessor_name) + if include_getter + push!(unique_accessor_functions, accessor_name) + end + # Always export setter name even if exclude_setter is true, + # because exclude_setter means "hand-written elsewhere" not "nonexistent". + # Only suppress getter export when exclude_getter is true (meaning + # the public getter is hand-written with a different signature, e.g. unitful). push!(unique_setter_functions, setter_name) end From 788600230d43facd91d05563ffee346a42007641 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 27 Mar 2026 15:23:17 -0600 Subject: [PATCH 06/16] Use DEFAULT_UNITS in generated 1-arg getter template The 1-arg getter now uses DEFAULT_UNITS (a constant) instead of the stateful _get_system_units, eliminating type instability and enabling full compiler optimization of the getter path. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/generate_structs.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/generate_structs.jl b/src/utils/generate_structs.jl index b0cdb382f..a0b35cd16 100644 --- a/src/utils/generate_structs.jl +++ b/src/utils/generate_structs.jl @@ -84,8 +84,8 @@ end {{/has_null_values}} {{#accessors}} {{#needs_conversion}} -{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`. Returns value in the system's unit setting (natural units by default).\"\"\"{{/create_docstring}} -{{accessor}}(value::{{struct_name}}) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), _get_system_units(value, Val({{conversion_unit}}))) +{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`. Returns value in DEFAULT_UNITS (system base per-unit).\"\"\"{{/create_docstring}} +{{accessor}}(value::{{struct_name}}) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), DEFAULT_UNITS) {{accessor}}(value::{{struct_name}}, units) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), units) {{/needs_conversion}} {{^needs_conversion}} From d8be86c6be71171f4eac679d4910912fc3455c53 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Mon, 30 Mar 2026 17:24:48 -0600 Subject: [PATCH 07/16] Use PowerSystemsUnits.jl for unit types and conversions Replace inline units.jl with using/re-exporting from PowerSystemsUnits. Keep time_period_conversion in units.jl (unrelated to unit types). Co-Authored-By: Claude Opus 4.6 (1M context) --- Project.toml | 6 +- src/InfrastructureSystems.jl | 17 ++--- src/units.jl | 118 ----------------------------------- 3 files changed, 14 insertions(+), 127 deletions(-) diff --git a/Project.toml b/Project.toml index 2043d7ee0..0f3d054f3 100644 --- a/Project.toml +++ b/Project.toml @@ -5,7 +5,6 @@ version = "3.4.1" [deps] CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" -Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" @@ -18,6 +17,7 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" Mustache = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +PowerSystemsUnits = "68e3e300-ad78-400a-9448-9f67be5a6d39" PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" @@ -32,6 +32,7 @@ TerminalLoggers = "5d786b92-1e48-4d6f-9151-6b4477ca9bed" TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" YAML = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" [compat] @@ -61,7 +62,8 @@ Tables = "^1.11" TerminalLoggers = "~0.1" TimeSeries = "^0.24, ^0.25" TimerOutputs = "^0.5" -Unitful = "^1.12" UUIDs = "1" +PowerSystemsUnits = "^0.1" +Unitful = "^1.12" YAML = "~0.4" julia = "^1.6" diff --git a/src/InfrastructureSystems.jl b/src/InfrastructureSystems.jl index c9c749e8a..1c7511860 100644 --- a/src/InfrastructureSystems.jl +++ b/src/InfrastructureSystems.jl @@ -8,11 +8,18 @@ export PiecewisePointCurve, PiecewiseIncrementalCurve, PiecewiseAverageCurve export TimeSeriesLinearCurve, TimeSeriesQuadraticCurve, TimeSeriesPiecewisePointCurve export TimeSeriesPiecewiseIncrementalCurve, TimeSeriesPiecewiseAverageCurve -# Unit types for explicit units in getters/setters +# Re-export unit types from PowerSystemsUnits +using PowerSystemsUnits export MW, Mvar, MVA, kV, OHMS, SIEMENS -export DU, SU, DeviceBaseUnit, SystemBaseUnit +export DU, SU, NU, DeviceBaseUnit, SystemBaseUnit, NaturalUnit export AbstractRelativeUnit, RelativeQuantity export ustrip +export UnitCategory, + PowerCategory, ImpedanceCategory, AdmittanceCategory, + VoltageCategory, CurrentCategory +export POWER, IMPEDANCE, ADMITTANCE, VOLTAGE, CURRENT +export convert_units, base_value, system_base_value, natural_unit, DEFAULT_UNITS +export get_device_base_power, get_system_base_power, get_base_voltage import Base: @kwdef import CSV @@ -215,10 +222,6 @@ include("deprecated.jl") include("Optimization/Optimization.jl") include("Simulation/Simulation.jl") -# Register custom Unitful units (Mvar, MVA) so conversions work properly -# TODO: cleaner way? seems like overkill to register the whole module. -function __init__() - Unitful.register(@__MODULE__) -end +# Custom Unitful units (Mvar, MVA) are registered by PowerSystemsUnits.__init__ end # module diff --git a/src/units.jl b/src/units.jl index 71eadd8d3..42090f1f2 100644 --- a/src/units.jl +++ b/src/units.jl @@ -2,121 +2,3 @@ time_period_conversion(time_period::Union{Dates.TimePeriod, Dates.DatePeriod}) = convert(Dates.Millisecond, time_period) time_period_conversion(time_periods::Dict{String, <:Dates.Period}) = convert(Dict{String, Dates.Millisecond}, time_periods) - -############################### -# Power Systems Unit Types -############################### - -using Unitful: @u_str, @unit, Quantity, Units, uconvert -import Unitful: ustrip -import Unitful - -# Define power system-specific units (same dimension as MW, different display) -# These are registered with Unitful in __init__() below -@unit Mvar "Mvar" Mvar 1u"MW" false -@unit MVA "MVA" MVA 1u"MW" false - -# Re-export common Unitful units for power systems -const MW = u"MW" -const kV = u"kV" -const OHMS = u"Ω" -const SIEMENS = u"S" - -# Note: Unitful.register() is called in InfrastructureSystems.__init__() - -# Relative unit types (for per-unit values) -abstract type AbstractRelativeUnit end - -""" -Device base per-unit. Values are normalized to the device's own base power. -""" -struct DeviceBaseUnit <: AbstractRelativeUnit end - -""" -System base per-unit. Values are normalized to the system's base power. -""" -struct SystemBaseUnit <: AbstractRelativeUnit end - -const DU = DeviceBaseUnit() -const SU = SystemBaseUnit() - -""" - RelativeQuantity{T<:Number, U<:AbstractRelativeUnit} <: Number - -A quantity with relative (per-unit) units, either device base (DU) or system base (SU). - -# Examples -```julia -0.6 * DU # 0.6 per-unit on device base -0.3 * SU # 0.3 per-unit on system base -``` -""" -struct RelativeQuantity{T <: Number, U <: AbstractRelativeUnit} <: Number - value::T - unit::U -end - -# Construction via multiplication -Base.:*(a::Number, b::AbstractRelativeUnit) = RelativeQuantity(a, b) -Base.:*(b::AbstractRelativeUnit, a::Number) = RelativeQuantity(a, b) - -# Arithmetic operations - same unit type only -Base.:+(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = - RelativeQuantity(a.value + b.value, a.unit) -Base.:-(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = - RelativeQuantity(a.value - b.value, a.unit) -Base.:-(a::RelativeQuantity{T, U}) where {T, U} = - RelativeQuantity(-a.value, a.unit) - -# Scalar multiplication/division (use Real to avoid ambiguity with Unitful types) -Base.:*(a::Real, b::RelativeQuantity{T, U}) where {T, U} = - RelativeQuantity(a * b.value, b.unit) -Base.:*(a::RelativeQuantity{T, U}, b::Real) where {T, U} = - RelativeQuantity(a.value * b, a.unit) -Base.:/(a::RelativeQuantity{T, U}, b::Real) where {T, U} = - RelativeQuantity(a.value / b, a.unit) - -# Comparisons -Base.:(==)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = - a.value == b.value -Base.:(<)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = - a.value < b.value -Base.:(<=)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = - a.value <= b.value -Base.isless(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = - isless(a.value, b.value) -Base.isapprox( - a::RelativeQuantity{T, U}, - b::RelativeQuantity{S, U}; - kwargs..., -) where {T, S, U} = - isapprox(a.value, b.value; kwargs...) - -# Value extraction -""" - ustrip(q::RelativeQuantity) - -Extract the numeric value from a RelativeQuantity. -""" -ustrip(q::RelativeQuantity) = q.value - -# Type conversions -Base.convert(::Type{RelativeQuantity{T, U}}, q::RelativeQuantity{S, U}) where {T, S, U} = - RelativeQuantity(convert(T, q.value), q.unit) -Base.promote_rule( - ::Type{RelativeQuantity{T, U}}, - ::Type{RelativeQuantity{S, U}}, -) where {T, S, U} = - RelativeQuantity{promote_type(T, S), U} - -# Display -Base.show(io::IO, q::RelativeQuantity{T, DeviceBaseUnit}) where {T} = - print(io, q.value, " DU") -Base.show(io::IO, q::RelativeQuantity{T, SystemBaseUnit}) where {T} = - print(io, q.value, " SU") -Base.show(io::IO, ::DeviceBaseUnit) = print(io, "DU") -Base.show(io::IO, ::SystemBaseUnit) = print(io, "SU") - -# Zero/one for numeric operations -Base.zero(::Type{RelativeQuantity{T, U}}) where {T, U} = RelativeQuantity(zero(T), U()) -Base.one(::Type{RelativeQuantity{T, U}}) where {T, U} = RelativeQuantity(one(T), U()) From 2f579497fc2409f4f10e96dd46c2e7d0125e4f25 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 15 Apr 2026 16:33:16 -0600 Subject: [PATCH 08/16] put back default `is_cost_alias` (lost in rebase) --- src/cost_aliases.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cost_aliases.jl b/src/cost_aliases.jl index bfc1ca5df..552a0d3bb 100644 --- a/src/cost_aliases.jl +++ b/src/cost_aliases.jl @@ -8,8 +8,8 @@ # methods being defined for all the `ValueCurve{FunctionData}` types, not just the ones we # have here nicely packaged and presented to the user. -# Default `is_cost_alias` is defined in value_curve.jl so it's available to -# time_series_value_curve.jl show methods (included before this file). +"Whether there is a cost alias for the instance or type under consideration" +is_cost_alias(::Union{ValueCurve, Type{<:ValueCurve}}) = false """ LinearCurve(proportional_term::Float64) From 344d40aa5f7167c3b6bf65a6fe0c22b33b3f1afa Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 17 Apr 2026 10:12:48 -0600 Subject: [PATCH 09/16] Make IS domain-agnostic for the units system - Purge power-domain knowledge from generate_structs template (NATURAL_UNIT_MAP / get_natural_unit removed); template emits untyped `units` parameter for downstream packages to specialize. - Declare bare `get_value` / `set_value` interface; methods are now provided by domain packages (e.g. PowerSystems). - Drop PowerSystemsUnits and Unitful deps so IS has no knowledge of power-system-specific unit types. Co-Authored-By: Claude Opus 4.7 (1M context) --- Project.toml | 4 ---- src/InfrastructureSystems.jl | 21 +++++++-------------- src/utils/generate_structs.jl | 32 +++----------------------------- 3 files changed, 10 insertions(+), 47 deletions(-) diff --git a/Project.toml b/Project.toml index 0f3d054f3..3f89ed780 100644 --- a/Project.toml +++ b/Project.toml @@ -17,7 +17,6 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" Mustache = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" -PowerSystemsUnits = "68e3e300-ad78-400a-9448-9f67be5a6d39" PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" @@ -32,7 +31,6 @@ TerminalLoggers = "5d786b92-1e48-4d6f-9151-6b4477ca9bed" TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" -Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" YAML = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" [compat] @@ -63,7 +61,5 @@ TerminalLoggers = "~0.1" TimeSeries = "^0.24, ^0.25" TimerOutputs = "^0.5" UUIDs = "1" -PowerSystemsUnits = "^0.1" -Unitful = "^1.12" YAML = "~0.4" julia = "^1.6" diff --git a/src/InfrastructureSystems.jl b/src/InfrastructureSystems.jl index 1c7511860..f0e7e5ac3 100644 --- a/src/InfrastructureSystems.jl +++ b/src/InfrastructureSystems.jl @@ -8,18 +8,13 @@ export PiecewisePointCurve, PiecewiseIncrementalCurve, PiecewiseAverageCurve export TimeSeriesLinearCurve, TimeSeriesQuadraticCurve, TimeSeriesPiecewisePointCurve export TimeSeriesPiecewiseIncrementalCurve, TimeSeriesPiecewiseAverageCurve -# Re-export unit types from PowerSystemsUnits -using PowerSystemsUnits -export MW, Mvar, MVA, kV, OHMS, SIEMENS -export DU, SU, NU, DeviceBaseUnit, SystemBaseUnit, NaturalUnit -export AbstractRelativeUnit, RelativeQuantity -export ustrip -export UnitCategory, - PowerCategory, ImpedanceCategory, AdmittanceCategory, - VoltageCategory, CurrentCategory -export POWER, IMPEDANCE, ADMITTANCE, VOLTAGE, CURRENT -export convert_units, base_value, system_base_value, natural_unit, DEFAULT_UNITS -export get_device_base_power, get_system_base_power, get_base_voltage +# Units interface: declared here, methods implemented by domain packages +# (e.g., PowerSystems.jl provides power-domain `get_value`/`set_value` methods). +"Get a field value with optional unit conversion. Methods are provided by domain packages." +function get_value end +"Set a field value with optional unit conversion. Methods are provided by domain packages." +function set_value end +export get_value, set_value import Base: @kwdef import CSV @@ -222,6 +217,4 @@ include("deprecated.jl") include("Optimization/Optimization.jl") include("Simulation/Simulation.jl") -# Custom Unitful units (Mvar, MVA) are registered by PowerSystemsUnits.__init__ - end # module diff --git a/src/utils/generate_structs.jl b/src/utils/generate_structs.jl index a0b35cd16..61706d02b 100644 --- a/src/utils/generate_structs.jl +++ b/src/utils/generate_structs.jl @@ -1,26 +1,6 @@ import Mustache -# Map conversion_unit to the default natural Unitful unit for getters -const NATURAL_UNIT_MAP = Dict{String, String}( - ":mva" => "MW", # Power → MW (default for :mva) - ":ohm" => "OHMS", # Impedance → Ohms - ":siemens" => "SIEMENS", # Admittance → Siemens -) - -# Determine the natural unit based on conversion_unit and field name -function get_natural_unit(conversion_unit::String, field_name::String) - if conversion_unit == ":mva" - # Reactive power fields use Mvar instead of MW - if occursin("reactive", lowercase(field_name)) - return "Mvar" - else - return "MW" - end - end - return get(NATURAL_UNIT_MAP, conversion_unit, "MW") -end - const STRUCT_TEMPLATE = """ #= This file is auto-generated. Do not edit. @@ -84,8 +64,8 @@ end {{/has_null_values}} {{#accessors}} {{#needs_conversion}} -{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`. Returns value in DEFAULT_UNITS (system base per-unit).\"\"\"{{/create_docstring}} -{{accessor}}(value::{{struct_name}}) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), DEFAULT_UNITS) +{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`.\"\"\"{{/create_docstring}} +{{accessor}}(value::{{struct_name}}) = get_value(value, Val(:{{name}}), Val({{conversion_unit}})) {{accessor}}(value::{{struct_name}}, units) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), units) {{/needs_conversion}} {{^needs_conversion}} @@ -96,7 +76,7 @@ end {{#setters}} {{#needs_conversion}} -{{#create_docstring}}\"\"\"Set [`{{struct_name}}`](@ref) `{{name}}`. Value must have units (e.g., `30.0MW`, `0.5DU`).\"\"\"{{/create_docstring}} +{{#create_docstring}}\"\"\"Set [`{{struct_name}}`](@ref) `{{name}}`.\"\"\"{{/create_docstring}} {{setter}}(value::{{struct_name}}, val) = value.{{name}} = set_value(value, Val(:{{name}}), val, Val({{conversion_unit}})) {{/needs_conversion}} {{^needs_conversion}} @@ -174,7 +154,6 @@ function generate_structs(directory, data::Vector; print_results = true) accessor_name = accessor_module * "get_" * param["name"] setter_name = accessor_module * "set_" * param["name"] * "!" conversion_unit = get(param, "conversion_unit", "nothing") - natural_unit = get_natural_unit(conversion_unit, param["name"]) include_getter = !get(param, "exclude_getter", false) if include_getter push!( @@ -185,13 +164,9 @@ function generate_structs(directory, data::Vector; print_results = true) "create_docstring" => create_docstring, "needs_conversion" => get(param, "needs_conversion", false), "conversion_unit" => conversion_unit, - "natural_unit" => natural_unit, ), ) else - # When public getter is excluded, generate an internal _get_ accessor - # that returns the raw field value (Float64). Used for fields like - # base_power where the public getter is hand-written with units. internal_name = "_get_" * param["name"] push!( accessors, @@ -201,7 +176,6 @@ function generate_structs(directory, data::Vector; print_results = true) "create_docstring" => false, "needs_conversion" => false, "conversion_unit" => "nothing", - "natural_unit" => "", ), ) end From 1237cd514fc9ce63332e0c1018712ad613859be8 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 17 Apr 2026 14:56:36 -0600 Subject: [PATCH 10/16] Move relative-unit primitives into IS, require explicit units on getters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New relative_units.jl vendors AbstractRelativeUnit, DU/SU/NU, and RelativeQuantity; power-specific types stay in PSY. - Template drops the 1-arg getter emission — callers must specify units. Emits a `display_units_arg(f, ::Type{T})` trait so consumers can dispatch on whether a (getter, struct) pair takes units. - `_make_time_array` now calls `multiplier(owner, SU)` so `scaling_factor_multiplier = get_max_active_power` keeps working. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/InfrastructureSystems.jl | 1 + src/relative_units.jl | 126 ++++++++++++++++++++++++++++++++++ src/time_series_interface.jl | 5 +- src/utils/generate_structs.jl | 4 +- src/utils/print_pt_v2.jl | 7 +- src/utils/print_pt_v3.jl | 7 +- src/utils/test.jl | 4 ++ test/test_relative_units.jl | 37 ++++++++++ 8 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 src/relative_units.jl create mode 100644 test/test_relative_units.jl diff --git a/src/InfrastructureSystems.jl b/src/InfrastructureSystems.jl index f0e7e5ac3..b61c5913d 100644 --- a/src/InfrastructureSystems.jl +++ b/src/InfrastructureSystems.jl @@ -142,6 +142,7 @@ end get_internal(value::InfrastructureSystemsComponent) = value.internal include("common.jl") +include("relative_units.jl") include("random_seed.jl") include("utils/timers.jl") include("utils/assert_op.jl") diff --git a/src/relative_units.jl b/src/relative_units.jl new file mode 100644 index 000000000..564781620 --- /dev/null +++ b/src/relative_units.jl @@ -0,0 +1,126 @@ +############################### +# Relative (per-unit) markers and RelativeQuantity wrapper. +# +# These types are domain-agnostic — they express "device base" / "system base" +# / "natural unit" without assuming any particular physical domain. Downstream +# packages (e.g. PowerSystems) attach domain-specific meaning via categories +# and conversions. +############################### + +""" +Supertype of per-unit (relative) unit markers. +""" +abstract type AbstractRelativeUnit end + +""" +Device base per-unit. Values are normalized to the component's own base. +""" +struct DeviceBaseUnit <: AbstractRelativeUnit end + +""" +System base per-unit. Values are normalized to the system's base. +""" +struct SystemBaseUnit <: AbstractRelativeUnit end + +""" +Natural units. When used as a target, returns the value with the +domain-appropriate unit attached (e.g. MW for power, Ω for impedance). +Deliberately *not* `<: AbstractRelativeUnit` — "convert to NU" yields a +`Unitful.Quantity`, not a `RelativeQuantity`. +""" +struct NaturalUnit end + +const DU = DeviceBaseUnit() +const SU = SystemBaseUnit() +const NU = NaturalUnit() + +""" + RelativeQuantity{T<:Number, U<:AbstractRelativeUnit} <: Number + +A quantity tagged with a per-unit marker. + +# Examples +```julia +0.6 * DU # 0.6 per-unit on device base +0.3 * SU # 0.3 per-unit on system base +``` +""" +struct RelativeQuantity{T <: Number, U <: AbstractRelativeUnit} <: Number + value::T + unit::U +end + +# Construction via multiplication +Base.:*(a::Number, b::AbstractRelativeUnit) = RelativeQuantity(a, b) +Base.:*(b::AbstractRelativeUnit, a::Number) = RelativeQuantity(a, b) + +# Arithmetic — same unit type only +Base.:+(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + RelativeQuantity(a.value + b.value, a.unit) +Base.:-(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + RelativeQuantity(a.value - b.value, a.unit) +Base.:-(a::RelativeQuantity{T, U}) where {T, U} = RelativeQuantity(-a.value, a.unit) + +# Scalar mul/div (Real to avoid ambiguity with unit-bearing types) +Base.:*(a::Real, b::RelativeQuantity{T, U}) where {T, U} = + RelativeQuantity(a * b.value, b.unit) +Base.:*(a::RelativeQuantity{T, U}, b::Real) where {T, U} = + RelativeQuantity(a.value * b, a.unit) +Base.:/(a::RelativeQuantity{T, U}, b::Real) where {T, U} = + RelativeQuantity(a.value / b, a.unit) + +# Comparisons +Base.:(==)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + a.value == b.value +Base.:(<)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + a.value < b.value +Base.:(<=)(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + a.value <= b.value +Base.isless(a::RelativeQuantity{T, U}, b::RelativeQuantity{S, U}) where {T, S, U} = + isless(a.value, b.value) +Base.isapprox( + a::RelativeQuantity{T, U}, + b::RelativeQuantity{S, U}; + kwargs..., +) where {T, S, U} = isapprox(a.value, b.value; kwargs...) + +""" + ustrip(q::RelativeQuantity) + +Extract the numeric value from a `RelativeQuantity`. +""" +ustrip(q::RelativeQuantity) = q.value + +# Type conversions +Base.convert(::Type{RelativeQuantity{T, U}}, q::RelativeQuantity{S, U}) where {T, S, U} = + RelativeQuantity(convert(T, q.value), q.unit) +Base.promote_rule( + ::Type{RelativeQuantity{T, U}}, + ::Type{RelativeQuantity{S, U}}, +) where {T, S, U} = RelativeQuantity{promote_type(T, S), U} + +# Display +Base.show(io::IO, q::RelativeQuantity{T, DeviceBaseUnit}) where {T} = + print(io, q.value, " DU") +Base.show(io::IO, q::RelativeQuantity{T, SystemBaseUnit}) where {T} = + print(io, q.value, " SU") +Base.show(io::IO, ::DeviceBaseUnit) = print(io, "DU") +Base.show(io::IO, ::SystemBaseUnit) = print(io, "SU") +Base.show(io::IO, ::NaturalUnit) = print(io, "NU") + +Base.zero(::Type{RelativeQuantity{T, U}}) where {T, U} = RelativeQuantity(zero(T), U()) +Base.one(::Type{RelativeQuantity{T, U}}) where {T, U} = RelativeQuantity(one(T), U()) + +""" + display_units_arg(f, ::Type{T}) -> Union{AbstractRelativeUnit, Missing} + +Trait returning the units argument a getter `f` expects when called on a +component of type `T` for display/tabular output, or `missing` if the getter +takes no units argument. Keyed on both function and type because the same +getter name can appear on both unit-bearing and non-unit-bearing structs +(e.g. `get_b` on `Line` vs. `DynamicExponentialLoad`). Downstream packages +set this per-struct (typically via the struct-generator template); consumers +like `show_components` dispatch on the result to avoid runtime method +introspection. +""" +display_units_arg(_, ::Type) = missing diff --git a/src/time_series_interface.jl b/src/time_series_interface.jl index 462f2b396..05ecfe040 100644 --- a/src/time_series_interface.jl +++ b/src/time_series_interface.jl @@ -950,7 +950,10 @@ function _make_time_array(owner, time_series, start_time, len, ignore_scaling_fa return ta end - return ta .* multiplier(owner) + # Scaling-factor multipliers (e.g. `get_max_active_power`) are unit-aware + # accessors from downstream packages; pass `SU` so the result is in the + # system base that consumers of the time series expect. + return ta .* multiplier(owner, SU) end """ diff --git a/src/utils/generate_structs.jl b/src/utils/generate_structs.jl index 61706d02b..84d64bc02 100644 --- a/src/utils/generate_structs.jl +++ b/src/utils/generate_structs.jl @@ -64,9 +64,9 @@ end {{/has_null_values}} {{#accessors}} {{#needs_conversion}} -{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`.\"\"\"{{/create_docstring}} -{{accessor}}(value::{{struct_name}}) = get_value(value, Val(:{{name}}), Val({{conversion_unit}})) +{{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`. The `units` argument is required (e.g. `SU`, `DU`, `MW`, or `Float64`).\"\"\"{{/create_docstring}} {{accessor}}(value::{{struct_name}}, units) = get_value(value, Val(:{{name}}), Val({{conversion_unit}}), units) +InfrastructureSystems.display_units_arg(::typeof({{accessor}}), ::Type{ {{struct_name}} }) = InfrastructureSystems.SU {{/needs_conversion}} {{^needs_conversion}} {{#create_docstring}}\"\"\"Get [`{{struct_name}}`](@ref) `{{name}}`.\"\"\"{{/create_docstring}} diff --git a/src/utils/print_pt_v2.jl b/src/utils/print_pt_v2.jl index abf293a87..d8a3fd53e 100644 --- a/src/utils/print_pt_v2.jl +++ b/src/utils/print_pt_v2.jl @@ -148,7 +148,12 @@ function show_components( val = summary(val) elseif hasproperty(parent, getter_name) getter_func = Base.getproperty(parent, getter_name) - val = getter_func(component) + arg = display_units_arg(getter_func, typeof(component)) + val = if ismissing(arg) + getter_func(component) + else + getter_func(component, arg) + end end data[i, j] = val j += 1 diff --git a/src/utils/print_pt_v3.jl b/src/utils/print_pt_v3.jl index 464c34bf4..e9d0ce8bf 100644 --- a/src/utils/print_pt_v3.jl +++ b/src/utils/print_pt_v3.jl @@ -129,7 +129,12 @@ function show_components( val = summary(val) elseif hasproperty(parent, getter_name) getter_func = Base.getproperty(parent, getter_name) - val = getter_func(component) + arg = display_units_arg(getter_func, typeof(component)) + val = if ismissing(arg) + getter_func(component) + else + getter_func(component, arg) + end end data[i, j] = val j += 1 diff --git a/src/utils/test.jl b/src/utils/test.jl index 72e4bfba4..a14195daa 100644 --- a/src/utils/test.jl +++ b/src/utils/test.jl @@ -45,7 +45,11 @@ end get_internal(component::TestComponent) = component.internal get_internal(component::AdditionalTestComponent) = component.internal get_val(component::TestComponent) = component.val +# 2-arg form so this getter can be used as a `scaling_factor_multiplier` +# (which `_make_time_array` invokes with a units marker). +get_val(component::TestComponent, _) = component.val get_val2(component::TestComponent) = component.val2 +get_val2(component::TestComponent, _) = component.val2 supports_time_series(::TestComponent) = true supports_time_series(::AdditionalTestComponent) = true supports_time_series(::SimpleTestComponent) = false diff --git a/test/test_relative_units.jl b/test/test_relative_units.jl new file mode 100644 index 000000000..e27dc64f5 --- /dev/null +++ b/test/test_relative_units.jl @@ -0,0 +1,37 @@ +@testset "RelativeQuantity construction and arithmetic" begin + a = 0.6 * IS.DU + b = 0.4 * IS.DU + @test a isa IS.RelativeQuantity{Float64, IS.DeviceBaseUnit} + @test IS.ustrip(a + b) ≈ 1.0 + @test IS.ustrip(a - b) ≈ 0.2 + @test IS.ustrip(-a) ≈ -0.6 + # scalar multiplication dispatches differently on each side + @test IS.ustrip(2.0 * a) ≈ 1.2 + @test IS.ustrip(a * 2.0) ≈ 1.2 + @test IS.ustrip(a / 2.0) ≈ 0.3 +end + +@testset "RelativeQuantity comparisons" begin + @test 0.6 * IS.DU < 0.7 * IS.DU + @test 0.6 * IS.DU <= 0.6 * IS.DU + @test isapprox(0.6 * IS.DU, 0.60000001 * IS.DU; atol = 1e-6) + @test isless(0.6 * IS.DU, 0.7 * IS.DU) +end + +@testset "DU and SU cannot be mixed" begin + @test_throws Exception 0.6 * IS.DU + 0.4 * IS.SU + @test_throws Exception 0.6 * IS.DU == 0.4 * IS.SU +end + +@testset "RelativeQuantity zero and one" begin + @test zero(IS.RelativeQuantity{Float64, IS.DeviceBaseUnit}) == 0.0 * IS.DU + @test one(IS.RelativeQuantity{Float64, IS.DeviceBaseUnit}) == 1.0 * IS.DU +end + +@testset "RelativeQuantity display" begin + @test sprint(show, 0.6 * IS.DU) == "0.6 DU" + @test sprint(show, 0.3 * IS.SU) == "0.3 SU" + @test sprint(show, IS.DU) == "DU" + @test sprint(show, IS.SU) == "SU" + @test sprint(show, IS.NU) == "NU" +end From 456df73e9d5910fde316a6ac9d5b355a5265a28c Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 21 Apr 2026 10:25:41 -0600 Subject: [PATCH 11/16] Export hand-written accessors when exclude_getter is set Mirror the existing exclude_setter behavior so the public name is registered for export even when the generator emits a _-prefixed internal accessor. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils/generate_structs.jl | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/utils/generate_structs.jl b/src/utils/generate_structs.jl index 84d64bc02..f29727fa6 100644 --- a/src/utils/generate_structs.jl +++ b/src/utils/generate_structs.jl @@ -194,13 +194,10 @@ function generate_structs(directory, data::Vector; print_results = true) ) end if field["name"] != "internal" && accessor_module == "" - if include_getter - push!(unique_accessor_functions, accessor_name) - end - # Always export setter name even if exclude_setter is true, - # because exclude_setter means "hand-written elsewhere" not "nonexistent". - # Only suppress getter export when exclude_getter is true (meaning - # the public getter is hand-written with a different signature, e.g. unitful). + # exclude_getter/exclude_setter mean "hand-written elsewhere" (e.g. + # unit-aware accessors with different signatures), not "nonexistent" — + # always export the public name. + push!(unique_accessor_functions, accessor_name) push!(unique_setter_functions, setter_name) end From f0bf72c9389abc99dbef33364e9a2043a87720de Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 24 Apr 2026 14:50:10 -0600 Subject: [PATCH 12/16] Use simple_type_name for TimeSeries*Curve show output Add simple_type_name overrides for the five TimeSeries*Curve aliases and use them in Base.show instead of typeof(vc), so output is e.g. TimeSeriesLinearCurve(...) rather than the full parametric TimeSeriesInputOutputCurve{TimeSeriesFunctionData{LinearFunctionData}}(...). Brings these aliases to parity with the non-TS aliases. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cost_aliases.jl | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/cost_aliases.jl b/src/cost_aliases.jl index 552a0d3bb..4faabf83a 100644 --- a/src/cost_aliases.jl +++ b/src/cost_aliases.jl @@ -242,6 +242,7 @@ const TimeSeriesLinearCurve = TimeSeriesInputOutputCurve{TimeSeriesFunctionData{LinearFunctionData}} is_cost_alias(::Union{TimeSeriesLinearCurve, Type{TimeSeriesLinearCurve}}) = true +simple_type_name(::TimeSeriesLinearCurve) = "TimeSeriesLinearCurve" TimeSeriesInputOutputCurve{TimeSeriesFunctionData{LinearFunctionData}}( key::TimeSeriesKey, @@ -249,7 +250,7 @@ TimeSeriesInputOutputCurve{TimeSeriesFunctionData{LinearFunctionData}}( Base.show(io::IO, vc::TimeSeriesLinearCurve) = if isnothing(get_input_at_zero(vc)) - print(io, "$(typeof(vc))($(_ts_key_repr(get_time_series_key(vc))))") + print(io, "$(simple_type_name(vc))($(_ts_key_repr(get_time_series_key(vc))))") else Base.show_default(io, vc) end @@ -264,6 +265,7 @@ const TimeSeriesQuadraticCurve = TimeSeriesInputOutputCurve{TimeSeriesFunctionData{QuadraticFunctionData}} is_cost_alias(::Union{TimeSeriesQuadraticCurve, Type{TimeSeriesQuadraticCurve}}) = true +simple_type_name(::TimeSeriesQuadraticCurve) = "TimeSeriesQuadraticCurve" TimeSeriesInputOutputCurve{TimeSeriesFunctionData{QuadraticFunctionData}}( key::TimeSeriesKey, @@ -271,7 +273,7 @@ TimeSeriesInputOutputCurve{TimeSeriesFunctionData{QuadraticFunctionData}}( Base.show(io::IO, vc::TimeSeriesQuadraticCurve) = if isnothing(get_input_at_zero(vc)) - print(io, "$(typeof(vc))($(_ts_key_repr(get_time_series_key(vc))))") + print(io, "$(simple_type_name(vc))($(_ts_key_repr(get_time_series_key(vc))))") else Base.show_default(io, vc) end @@ -288,6 +290,7 @@ const TimeSeriesPiecewisePointCurve = is_cost_alias( ::Union{TimeSeriesPiecewisePointCurve, Type{TimeSeriesPiecewisePointCurve}}, ) = true +simple_type_name(::TimeSeriesPiecewisePointCurve) = "TimeSeriesPiecewisePointCurve" TimeSeriesInputOutputCurve{TimeSeriesFunctionData{PiecewiseLinearData}}( key::TimeSeriesKey, @@ -295,7 +298,7 @@ TimeSeriesInputOutputCurve{TimeSeriesFunctionData{PiecewiseLinearData}}( Base.show(io::IO, vc::TimeSeriesPiecewisePointCurve) = if isnothing(get_input_at_zero(vc)) - print(io, "$(typeof(vc))($(_ts_key_repr(get_time_series_key(vc))))") + print(io, "$(simple_type_name(vc))($(_ts_key_repr(get_time_series_key(vc))))") else Base.show_default(io, vc) end @@ -315,6 +318,8 @@ is_cost_alias( Type{TimeSeriesPiecewiseIncrementalCurve}, }, ) = true +simple_type_name(::TimeSeriesPiecewiseIncrementalCurve) = + "TimeSeriesPiecewiseIncrementalCurve" TimeSeriesIncrementalCurve{TimeSeriesFunctionData{PiecewiseStepData}}( key::TimeSeriesKey, @@ -335,9 +340,9 @@ Base.show(io::IO, vc::TimeSeriesPiecewiseIncrementalCurve) = print( io, if isnothing(get_input_at_zero(vc)) - "$(typeof(vc))($(_ts_key_repr(get_time_series_key(vc))), $(_ts_key_repr(get_initial_input(vc))))" + "$(simple_type_name(vc))($(_ts_key_repr(get_time_series_key(vc))), $(_ts_key_repr(get_initial_input(vc))))" else - "$(typeof(vc))($(_ts_key_repr(get_time_series_key(vc))), $(_ts_key_repr(get_initial_input(vc))), $(_ts_key_repr(get_input_at_zero(vc))))" + "$(simple_type_name(vc))($(_ts_key_repr(get_time_series_key(vc))), $(_ts_key_repr(get_initial_input(vc))), $(_ts_key_repr(get_input_at_zero(vc))))" end, ) @@ -356,6 +361,7 @@ is_cost_alias( Type{TimeSeriesPiecewiseAverageCurve}, }, ) = true +simple_type_name(::TimeSeriesPiecewiseAverageCurve) = "TimeSeriesPiecewiseAverageCurve" TimeSeriesAverageRateCurve{TimeSeriesFunctionData{PiecewiseStepData}}( key::TimeSeriesKey, @@ -376,7 +382,7 @@ Base.show(io::IO, vc::TimeSeriesPiecewiseAverageCurve) = if isnothing(get_input_at_zero(vc)) print( io, - "$(typeof(vc))($(_ts_key_repr(get_time_series_key(vc))), $(_ts_key_repr(get_initial_input(vc))))", + "$(simple_type_name(vc))($(_ts_key_repr(get_time_series_key(vc))), $(_ts_key_repr(get_initial_input(vc))))", ) else Base.show_default(io, vc) From 845e656cf1977c31611ce4ae010065cdd4f415b0 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 24 Apr 2026 14:51:35 -0600 Subject: [PATCH 13/16] Parameterize CostCurve/FuelCurve on unit-system type Add U <: AbstractUnitSystem as a second type parameter on ProductionVariableCostCurve, replacing the runtime power_units field. get_power_units returns U(). Eliminates Val-wrapping in downstream unit-dependent dispatch. Introduces AbstractUnitSystem as a common supertype of AbstractRelativeUnit and NaturalUnit. Constructors default U = NaturalUnit; serialize/deserialize encode U under "power_units". Co-Authored-By: Claude Opus 4.7 (1M context) --- src/production_variable_cost_curve.jl | 240 ++++++++++++++++++------- src/relative_units.jl | 14 +- test/test_cost_functions.jl | 22 ++- test/test_make_convex.jl | 20 +-- test/test_time_series_function_data.jl | 4 +- 5 files changed, 204 insertions(+), 96 deletions(-) diff --git a/src/production_variable_cost_curve.jl b/src/production_variable_cost_curve.jl index 154457ce1..474b9802f 100644 --- a/src/production_variable_cost_curve.jl +++ b/src/production_variable_cost_curve.jl @@ -1,21 +1,27 @@ """ -Supertype for production variable cost curve representations, parameterized by -a [`ValueCurve`](@ref) type. +Supertype for production variable cost curve representations. + +Parameterized by a [`ValueCurve`](@ref) type `T` and an +[`AbstractUnitSystem`](@ref) type `U`. `U` is a compile-time marker for the +`power_units` of the x-axis; it replaces the old `power_units::UnitSystem` +runtime field. This lets unit-dependent operations dispatch directly on the +type parameter rather than going through a stateful runtime check plus +`Val`-wrapping, eliminating a class of type instabilities downstream. Concrete subtypes include [`CostCurve`](@ref) and [`FuelCurve`](@ref). """ -abstract type ProductionVariableCostCurve{T <: ValueCurve} end - -serialize(val::ProductionVariableCostCurve) = serialize_struct(val) -deserialize(T::Type{<:ProductionVariableCostCurve}, val::Dict) = - deserialize_struct(T, val) +abstract type ProductionVariableCostCurve{T <: ValueCurve, U <: AbstractUnitSystem} end "Get the underlying `ValueCurve` representation of this `ProductionVariableCostCurve`" get_value_curve(cost::ProductionVariableCostCurve) = cost.value_curve "Get the variable operation and maintenance cost in currency/(power_units h)" get_vom_cost(cost::ProductionVariableCostCurve) = cost.vom_cost -"Get the units for the x-axis of the curve" -get_power_units(cost::ProductionVariableCostCurve) = cost.power_units +""" +Get the units marker for the x-axis of the curve as an instance of the +second type parameter (e.g. `NaturalUnit()`, `SystemBaseUnit()`, +`DeviceBaseUnit()`). +""" +get_power_units(::ProductionVariableCostCurve{T, U}) where {T, U} = U() "Get the `FunctionData` representation of this `ProductionVariableCostCurve`'s `ValueCurve`" get_function_data(cost::ProductionVariableCostCurve) = get_function_data(get_value_curve(cost)) @@ -57,34 +63,59 @@ Base.hash(a::ProductionVariableCostCurve, h::UInt) = hash_from_fields(a, h) $(TYPEDEF) $(TYPEDFIELDS) + CostCurve(value_curve) + CostCurve(value_curve, power_units) + CostCurve(value_curve, vom_cost) CostCurve(value_curve, power_units, vom_cost) CostCurve(; value_curve, power_units, vom_cost) Direct representation of the variable operation cost of a power plant in currency. Composed of a [`ValueCurve`](@ref) that may represent input-output, incremental, or average rate -data. The default units for the x-axis are MW and can be specified with -`power_units`. +data. The x-axis units are encoded as the second type parameter `U <: AbstractUnitSystem`; +`power_units` at construction is the singleton instance `U()` (default `NaturalUnit()`). """ -@kwdef struct CostCurve{T <: ValueCurve} <: ProductionVariableCostCurve{T} +struct CostCurve{T <: ValueCurve, U <: AbstractUnitSystem} <: + ProductionVariableCostCurve{T, U} "The underlying `ValueCurve` representation of this `ProductionVariableCostCurve`" value_curve::T - "(default: natural units (MW)) The units for the x-axis of the curve" - power_units::UnitSystem = UnitSystem.NATURAL_UNITS "(default of 0) Additional proportional Variable Operation and Maintenance Cost in \$/(power_unit h), represented as a [`LinearCurve`](@ref)" - vom_cost::LinearCurve = LinearCurve(0.0) + vom_cost::LinearCurve + + CostCurve{T, U}(value_curve::T, vom_cost::LinearCurve) where {T, U} = + new{T, U}(value_curve, vom_cost) end -CostCurve(value_curve) = CostCurve(; value_curve) -CostCurve(value_curve, vom_cost::LinearCurve) = - CostCurve(; value_curve, vom_cost = vom_cost) -CostCurve(value_curve, power_units::UnitSystem) = - CostCurve(; value_curve, power_units = power_units) +CostCurve{T, U}(; + value_curve::T, + vom_cost::LinearCurve = LinearCurve(0.0), +) where {T, U} = CostCurve{T, U}(value_curve, vom_cost) + +# Outer constructors — default U = NaturalUnit when not specified +CostCurve(value_curve::T) where {T <: ValueCurve} = + CostCurve{T, NaturalUnit}(; value_curve) +CostCurve(value_curve::T, vom_cost::LinearCurve) where {T <: ValueCurve} = + CostCurve{T, NaturalUnit}(; value_curve, vom_cost) +CostCurve( + value_curve::T, + power_units::U, +) where {T <: ValueCurve, U <: AbstractUnitSystem} = + CostCurve{T, U}(; value_curve) +CostCurve( + value_curve::T, + power_units::U, + vom_cost::LinearCurve, +) where {T <: ValueCurve, U <: AbstractUnitSystem} = + CostCurve{T, U}(; value_curve, vom_cost) -Base.:(==)(a::CostCurve, b::CostCurve) = - (get_value_curve(a) == get_value_curve(b)) && - (get_power_units(a) == get_power_units(b)) && - (get_vom_cost(a) == get_vom_cost(b)) +# Keyword-based constructor exposing `power_units`, replacing the former field default +function CostCurve(; + value_curve, + power_units::AbstractUnitSystem = NaturalUnit(), + vom_cost::LinearCurve = LinearCurve(0.0), +) + return CostCurve{typeof(value_curve), typeof(power_units)}(; value_curve, vom_cost) +end "Get a `CostCurve` representing zero variable cost" Base.zero(::Union{CostCurve, Type{CostCurve}}) = CostCurve(zero(ValueCurve)) @@ -93,81 +124,105 @@ Base.zero(::Union{CostCurve, Type{CostCurve}}) = CostCurve(zero(ValueCurve)) $(TYPEDEF) $(TYPEDFIELDS) - FuelCurve(value_curve, power_units, fuel_cost, startup_fuel_offtake, vom_cost) FuelCurve(value_curve, fuel_cost) FuelCurve(value_curve, fuel_cost, startup_fuel_offtake, vom_cost) FuelCurve(value_curve, power_units, fuel_cost) + FuelCurve(value_curve, power_units, fuel_cost, startup_fuel_offtake, vom_cost) FuelCurve(; value_curve, power_units, fuel_cost, startup_fuel_offtake, vom_cost) Representation of the variable operation cost of a power plant in terms of fuel (MBTU, liters, m^3, etc.), coupled with a conversion factor between fuel and currency. Composed of a [`ValueCurve`](@ref) that may represent input-output, incremental, or average rate data. -The default units for the x-axis are MW and can be specified with `power_units`. +The x-axis units are encoded as the second type parameter `U <: AbstractUnitSystem`; +`power_units` at construction is the singleton instance `U()` (default `NaturalUnit()`). """ -@kwdef struct FuelCurve{T <: ValueCurve} <: ProductionVariableCostCurve{T} +struct FuelCurve{T <: ValueCurve, U <: AbstractUnitSystem} <: + ProductionVariableCostCurve{T, U} "The underlying `ValueCurve` representation of this `ProductionVariableCostCurve`" value_curve::T - "(default: natural units (MW)) The units for the x-axis of the curve" - power_units::UnitSystem = UnitSystem.NATURAL_UNITS "Either a fixed value for fuel cost or the [`TimeSeriesKey`](@ref) to a fuel cost time series" fuel_cost::Union{Float64, TimeSeriesKey} "(default of 0) Fuel consumption at the unit startup proceedure. Additional cost to the startup costs and related only to the initial fuel required to start the unit. represented as a [`LinearCurve`](@ref)" - startup_fuel_offtake::LinearCurve = LinearCurve(0.0) + startup_fuel_offtake::LinearCurve "(default of 0) Additional proportional Variable Operation and Maintenance Cost in \$/(power_unit h) represented as a [`LinearCurve`](@ref)" - vom_cost::LinearCurve = LinearCurve(0.0) + vom_cost::LinearCurve + + FuelCurve{T, U}( + value_curve::T, + fuel_cost::Union{Float64, TimeSeriesKey}, + startup_fuel_offtake::LinearCurve, + vom_cost::LinearCurve, + ) where {T, U} = + new{T, U}(value_curve, fuel_cost, startup_fuel_offtake, vom_cost) end -function FuelCurve( - value_curve::ValueCurve, - power_units::UnitSystem, - fuel_cost::Real, +FuelCurve{T, U}(; + value_curve::T, + fuel_cost::Union{Float64, TimeSeriesKey}, + startup_fuel_offtake::LinearCurve = LinearCurve(0.0), + vom_cost::LinearCurve = LinearCurve(0.0), +) where {T, U} = + FuelCurve{T, U}(value_curve, fuel_cost, startup_fuel_offtake, vom_cost) + +# Outer constructors — mirror the CostCurve style +FuelCurve(value_curve::T, fuel_cost::Real) where {T <: ValueCurve} = + FuelCurve{T, NaturalUnit}(; value_curve, fuel_cost = Float64(fuel_cost)) +FuelCurve(value_curve::T, fuel_cost::TimeSeriesKey) where {T <: ValueCurve} = + FuelCurve{T, NaturalUnit}(; value_curve, fuel_cost) + +FuelCurve( + value_curve::T, + fuel_cost::Union{Real, TimeSeriesKey}, startup_fuel_offtake::LinearCurve, vom_cost::LinearCurve, +) where {T <: ValueCurve} = FuelCurve{T, NaturalUnit}(; + value_curve, + fuel_cost = fuel_cost isa Real ? Float64(fuel_cost) : fuel_cost, + startup_fuel_offtake, + vom_cost, ) - return FuelCurve( - value_curve, - power_units, - Float64(fuel_cost), - startup_fuel_offtake, - vom_cost, - ) -end - -function FuelCurve(value_curve, fuel_cost) - FuelCurve(; value_curve, fuel_cost) -end -function FuelCurve( +FuelCurve( + value_curve::T, + power_units::U, + fuel_cost::Union{Real, TimeSeriesKey}, +) where {T <: ValueCurve, U <: AbstractUnitSystem} = FuelCurve{T, U}(; value_curve, - fuel_cost::Union{Float64, TimeSeriesKey}, + fuel_cost = fuel_cost isa Real ? Float64(fuel_cost) : fuel_cost, +) + +FuelCurve( + value_curve::T, + power_units::U, + fuel_cost::Union{Real, TimeSeriesKey}, startup_fuel_offtake::LinearCurve, vom_cost::LinearCurve, +) where {T <: ValueCurve, U <: AbstractUnitSystem} = FuelCurve{T, U}(; + value_curve, + fuel_cost = fuel_cost isa Real ? Float64(fuel_cost) : fuel_cost, + startup_fuel_offtake, + vom_cost, ) - return FuelCurve(; - value_curve, - fuel_cost, - startup_fuel_offtake = startup_fuel_offtake, - vom_cost = vom_cost, - ) -end -function FuelCurve( +# Keyword-based constructor exposing `power_units` +function FuelCurve(; value_curve, - power_units::UnitSystem, - fuel_cost::Union{Float64, TimeSeriesKey}, + power_units::AbstractUnitSystem = NaturalUnit(), + fuel_cost::Union{Real, TimeSeriesKey}, + startup_fuel_offtake::LinearCurve = LinearCurve(0.0), + vom_cost::LinearCurve = LinearCurve(0.0), ) - FuelCurve(; value_curve, power_units = power_units, fuel_cost = fuel_cost) + fc = fuel_cost isa Real ? Float64(fuel_cost) : fuel_cost + return FuelCurve{typeof(value_curve), typeof(power_units)}(; + value_curve, + fuel_cost = fc, + startup_fuel_offtake, + vom_cost, + ) end -Base.:(==)(a::FuelCurve, b::FuelCurve) = - (get_value_curve(a) == get_value_curve(b)) && - (get_power_units(a) == get_power_units(b)) && - (get_fuel_cost(a) == get_fuel_cost(b)) && - (get_startup_fuel_offtake(a) == get_startup_fuel_offtake(b)) && - (get_vom_cost(a) == get_vom_cost(b)) - "Get a `FuelCurve` representing zero fuel usage and zero fuel cost" Base.zero(::Union{FuelCurve, Type{FuelCurve}}) = FuelCurve(zero(ValueCurve), 0.0) @@ -186,6 +241,51 @@ is_time_series_backed(cost::FuelCurve) = is_time_series_backed(get_value_curve(cost)) || is_time_series_backed(get_fuel_cost(cost)) +# ── Serialization ───────────────────────────────────────────────────────────── +# The U type parameter has no corresponding field, so we serialize it under the +# conventional "power_units" key (preserving the field name from the previous +# schema) and reconstruct it at deserialize time. + +_unit_system_instance(name::AbstractString) = + _unit_system_instance(Symbol(name)) +function _unit_system_instance(name::Symbol) + T = getproperty(@__MODULE__, name) + T <: AbstractUnitSystem || + throw(ArgumentError("$name is not a subtype of AbstractUnitSystem")) + return T() +end + +function serialize(val::ProductionVariableCostCurve) + data = serialize_struct(val) + data["power_units"] = string(nameof(typeof(get_power_units(val)))) + return data +end + +function deserialize(::Type{CostCurve}, data::Dict) + vc_data = data["value_curve"] + vc_type = get_type_from_serialization_data(vc_data) + value_curve = deserialize(vc_type, vc_data) + vom_cost = deserialize(LinearCurve, data["vom_cost"]) + power_units = _unit_system_instance(data["power_units"]) + return CostCurve(value_curve, power_units, vom_cost) +end + +function deserialize(::Type{FuelCurve}, data::Dict) + vc_data = data["value_curve"] + vc_type = get_type_from_serialization_data(vc_data) + value_curve = deserialize(vc_type, vc_data) + startup = deserialize(LinearCurve, data["startup_fuel_offtake"]) + vom = deserialize(LinearCurve, data["vom_cost"]) + fuel_cost_raw = data["fuel_cost"] + fuel_cost = if fuel_cost_raw isa Dict + deserialize(TimeSeriesKey, fuel_cost_raw) + else + Float64(fuel_cost_raw) + end + power_units = _unit_system_instance(data["power_units"]) + return FuelCurve(value_curve, power_units, fuel_cost, startup, vom) +end + Base.show(io::IO, m::MIME"text/plain", curve::ProductionVariableCostCurve) = (get(io, :compact, false)::Bool ? _show_compact : _show_expanded)(io, m, curve) @@ -193,7 +293,7 @@ Base.show(io::IO, m::MIME"text/plain", curve::ProductionVariableCostCurve) = function _show_compact(io::IO, ::MIME"text/plain", curve::CostCurve) print( io, - "$(nameof(typeof(curve))) with power_units $(curve.power_units), vom_cost $(curve.vom_cost), and value_curve:\n ", + "$(nameof(typeof(curve))) with power_units $(get_power_units(curve)), vom_cost $(curve.vom_cost), and value_curve:\n ", ) vc_printout = sprint(show, "text/plain", curve.value_curve; context = io) # Capture the value_curve `show` so we can indent it print(io, replace(vc_printout, "\n" => "\n ")) @@ -202,7 +302,7 @@ end function _show_compact(io::IO, ::MIME"text/plain", curve::FuelCurve) print( io, - "$(nameof(typeof(curve))) with power_units $(curve.power_units), fuel_cost $(curve.fuel_cost), startup_fuel_offtake $(curve.startup_fuel_offtake), vom_cost $(curve.vom_cost), and value_curve:\n ", + "$(nameof(typeof(curve))) with power_units $(get_power_units(curve)), fuel_cost $(curve.fuel_cost), startup_fuel_offtake $(curve.startup_fuel_offtake), vom_cost $(curve.vom_cost), and value_curve:\n ", ) vc_printout = sprint(show, "text/plain", curve.value_curve; context = io) print(io, replace(vc_printout, "\n" => "\n ")) @@ -216,4 +316,6 @@ function _show_expanded(io::IO, ::MIME"text/plain", curve::ProductionVariableCos replace(sprint(show, "text/plain", val; context = io), "\n" => "\n ") print(io, "\n $(field_name): $val_printout") end + # Surface the type-parameter `power_units` even though it isn't a field + print(io, "\n power_units: $(get_power_units(curve))") end diff --git a/src/relative_units.jl b/src/relative_units.jl index 564781620..d7570cb1c 100644 --- a/src/relative_units.jl +++ b/src/relative_units.jl @@ -7,10 +7,17 @@ # and conversions. ############################### +""" +Supertype for all unit-system markers (relative and natural). Used as the +`U` type parameter on `ProductionVariableCostCurve` and related parametric +types so that the unit system can be dispatched on at compile time. +""" +abstract type AbstractUnitSystem end + """ Supertype of per-unit (relative) unit markers. """ -abstract type AbstractRelativeUnit end +abstract type AbstractRelativeUnit <: AbstractUnitSystem end """ Device base per-unit. Values are normalized to the component's own base. @@ -26,9 +33,10 @@ struct SystemBaseUnit <: AbstractRelativeUnit end Natural units. When used as a target, returns the value with the domain-appropriate unit attached (e.g. MW for power, Ω for impedance). Deliberately *not* `<: AbstractRelativeUnit` — "convert to NU" yields a -`Unitful.Quantity`, not a `RelativeQuantity`. +`Unitful.Quantity`, not a `RelativeQuantity` — but it is a peer under +`AbstractUnitSystem`. """ -struct NaturalUnit end +struct NaturalUnit <: AbstractUnitSystem end const DU = DeviceBaseUnit() const SU = SystemBaseUnit() diff --git a/test/test_cost_functions.jl b/test/test_cost_functions.jl index 99ecef7d8..22290eade 100644 --- a/test/test_cost_functions.jl +++ b/test/test_cost_functions.jl @@ -298,25 +298,23 @@ end @test occursin("4.0", repr(fc)) @test sprint(show, "text/plain", cc) == sprint(show, "text/plain", cc; context = :compact => false) == - "CostCurve:\n value_curve: QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0\n power_units: InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2\n vom_cost: LinearCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 0.0 x + 0.0" + "CostCurve:\n value_curve: QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0\n vom_cost: LinearCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 0.0 x + 0.0\n power_units: NU" @test sprint(show, "text/plain", fc) == sprint(show, "text/plain", fc; context = :compact => false) == - "FuelCurve:\n value_curve: QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0\n power_units: InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2\n fuel_cost: 4.0\n startup_fuel_offtake: LinearCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 0.0 x + 0.0\n vom_cost: LinearCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 0.0 x + 0.0" + "FuelCurve:\n value_curve: QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0\n fuel_cost: 4.0\n startup_fuel_offtake: LinearCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 0.0 x + 0.0\n vom_cost: LinearCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 0.0 x + 0.0\n power_units: NU" @test sprint(show, "text/plain", cc; context = :compact => true) == - "CostCurve with power_units InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2, vom_cost LinearCurve(0.0, 0.0), and value_curve:\n QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0" + "CostCurve with power_units NU, vom_cost LinearCurve(0.0, 0.0), and value_curve:\n QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0" @test sprint(show, "text/plain", fc; context = :compact => true) == - "FuelCurve with power_units InfrastructureSystems.UnitSystemModule.UnitSystem.NATURAL_UNITS = 2, fuel_cost 4.0, startup_fuel_offtake LinearCurve(0.0, 0.0), vom_cost LinearCurve(0.0, 0.0), and value_curve:\n QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0" + "FuelCurve with power_units NU, fuel_cost 4.0, startup_fuel_offtake LinearCurve(0.0, 0.0), vom_cost LinearCurve(0.0, 0.0), and value_curve:\n QuadraticCurve (a type of InfrastructureSystems.InputOutputCurve) where function is: f(x) = 1.0 x^2 + 2.0 x + 3.0" - @test IS.get_power_units(cc) == IS.UnitSystem.NATURAL_UNITS - @test IS.get_power_units(fc) == IS.UnitSystem.NATURAL_UNITS + @test IS.get_power_units(cc) == IS.NaturalUnit() + @test IS.get_power_units(fc) == IS.NaturalUnit() @test IS.get_power_units( - IS.CostCurve(zero(IS.InputOutputCurve), IS.UnitSystem.SYSTEM_BASE), - ) == - IS.UnitSystem.SYSTEM_BASE + IS.CostCurve(zero(IS.InputOutputCurve), IS.SystemBaseUnit()), + ) == IS.SystemBaseUnit() @test IS.get_power_units( - IS.FuelCurve(zero(IS.InputOutputCurve), IS.UnitSystem.DEVICE_BASE, 1.0), - ) == - IS.UnitSystem.DEVICE_BASE + IS.FuelCurve(zero(IS.InputOutputCurve), IS.DeviceBaseUnit(), 1.0), + ) == IS.DeviceBaseUnit() @test IS.get_vom_cost(cc) == IS.LinearCurve(0.0) @test IS.get_vom_cost(fc) == IS.LinearCurve(0.0) diff --git a/test/test_make_convex.jl b/test/test_make_convex.jl index 68016cd09..e7361293e 100644 --- a/test/test_make_convex.jl +++ b/test/test_make_convex.jl @@ -198,14 +198,14 @@ vom_cost = IS.LinearCurve(0.5) cost_curve_convex = IS.CostCurve(; value_curve = ioc_convex, - power_units = IS.UnitSystem.NATURAL_UNITS, + power_units = IS.NaturalUnit(), vom_cost = vom_cost, ) result = IS.increasing_curve_convex_approximation(cost_curve_convex) @test result isa IS.CostCurve @test IS.is_convex(result) - @test IS.get_power_units(result) == IS.UnitSystem.NATURAL_UNITS + @test IS.get_power_units(result) == IS.NaturalUnit() @test IS.get_vom_cost(result) == vom_cost # Non-convex CostCurve - should be convexified @@ -218,14 +218,14 @@ vom_cost_2 = IS.LinearCurve(1.0) cost_curve_concave = IS.CostCurve(; value_curve = ioc_concave, - power_units = IS.UnitSystem.SYSTEM_BASE, + power_units = IS.SystemBaseUnit(), vom_cost = vom_cost_2, ) result_concave = IS.increasing_curve_convex_approximation(cost_curve_concave) @test result_concave isa IS.CostCurve @test IS.is_convex(result_concave) - @test IS.get_power_units(result_concave) == IS.UnitSystem.SYSTEM_BASE + @test IS.get_power_units(result_concave) == IS.SystemBaseUnit() @test IS.get_vom_cost(result_concave) == vom_cost_2 # Invalid CostCurve (not strictly increasing) - should throw error @@ -256,7 +256,7 @@ vom_cost = IS.LinearCurve(0.5) fuel_curve_convex = IS.FuelCurve(; value_curve = ioc_convex, - power_units = IS.UnitSystem.NATURAL_UNITS, + power_units = IS.NaturalUnit(), fuel_cost = 25.0, vom_cost = vom_cost, ) @@ -264,7 +264,7 @@ result = IS.increasing_curve_convex_approximation(fuel_curve_convex) @test result isa IS.FuelCurve @test IS.is_convex(result) - @test IS.get_power_units(result) == IS.UnitSystem.NATURAL_UNITS + @test IS.get_power_units(result) == IS.NaturalUnit() @test result.fuel_cost == 25.0 @test IS.get_vom_cost(result) == vom_cost @@ -278,7 +278,7 @@ vom_cost_2 = IS.LinearCurve(1.0) fuel_curve_concave = IS.FuelCurve(; value_curve = ioc_concave, - power_units = IS.UnitSystem.SYSTEM_BASE, + power_units = IS.SystemBaseUnit(), fuel_cost = 30.0, vom_cost = vom_cost_2, ) @@ -286,7 +286,7 @@ result_concave = IS.increasing_curve_convex_approximation(fuel_curve_concave) @test result_concave isa IS.FuelCurve @test IS.is_convex(result_concave) - @test IS.get_power_units(result_concave) == IS.UnitSystem.SYSTEM_BASE + @test IS.get_power_units(result_concave) == IS.SystemBaseUnit() @test result_concave.fuel_cost == 30.0 @test IS.get_vom_cost(result_concave) == vom_cost_2 @@ -295,7 +295,7 @@ inc_convex = IS.IncrementalCurve(psd_convex, 0.0) fuel_curve_inc = IS.FuelCurve(; value_curve = inc_convex, - power_units = IS.UnitSystem.NATURAL_UNITS, + power_units = IS.NaturalUnit(), fuel_cost = 20.0, ) @@ -315,7 +315,7 @@ ioc_invalid = IS.InputOutputCurve(pld_invalid) fuel_curve_invalid = IS.FuelCurve(; value_curve = ioc_invalid, - power_units = IS.UnitSystem.NATURAL_UNITS, + power_units = IS.NaturalUnit(), fuel_cost = 25.0, ) diff --git a/test/test_time_series_function_data.jl b/test/test_time_series_function_data.jl index cc9987eef..cb724e018 100644 --- a/test/test_time_series_function_data.jl +++ b/test/test_time_series_function_data.jl @@ -386,9 +386,9 @@ end IS.TimeSeriesLinearFunctionData(forecast_key), ) # CostCurve preserves value_curve and accepts power_units - cc = IS.CostCurve(ts_io, IS.UnitSystem.SYSTEM_BASE) + cc = IS.CostCurve(ts_io, IS.SystemBaseUnit()) @test IS.get_value_curve(cc) === ts_io - @test IS.get_power_units(cc) == IS.UnitSystem.SYSTEM_BASE + @test IS.get_power_units(cc) == IS.SystemBaseUnit() # FuelCurve preserves fuel_cost fc = IS.FuelCurve(ts_io, 5.0) From 7fbf43617875fe33524dfc967286027e03eac392 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 24 Apr 2026 18:59:57 -0600 Subject: [PATCH 14/16] Add AnyCostCurve/AnyFuelCurve aliases; accept legacy enum names - AnyCostCurve{T}, AnyFuelCurve{T}, AnyProductionVariableCostCurve{T}: existential UnionAll aliases for downstream code at isa sites or method signatures where the U parameter is irrelevant. - _unit_system_instance now accepts both new type-name strings ("NaturalUnit") and legacy enum-value strings ("NATURAL_UNITS") so existing serialized fixtures keep deserializing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/production_variable_cost_curve.jl | 35 ++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/production_variable_cost_curve.jl b/src/production_variable_cost_curve.jl index 474b9802f..89e21234d 100644 --- a/src/production_variable_cost_curve.jl +++ b/src/production_variable_cost_curve.jl @@ -12,6 +12,13 @@ Concrete subtypes include [`CostCurve`](@ref) and [`FuelCurve`](@ref). """ abstract type ProductionVariableCostCurve{T <: ValueCurve, U <: AbstractUnitSystem} end +""" +`ProductionVariableCostCurve{T}` with any unit system. Use at `isa` sites or in +docstrings where the curve's unit-system parameter doesn't matter. +""" +const AnyProductionVariableCostCurve{T} = + ProductionVariableCostCurve{T, U} where {U <: AbstractUnitSystem} + "Get the underlying `ValueCurve` representation of this `ProductionVariableCostCurve`" get_value_curve(cost::ProductionVariableCostCurve) = cost.value_curve "Get the variable operation and maintenance cost in currency/(power_units h)" @@ -120,6 +127,12 @@ end "Get a `CostCurve` representing zero variable cost" Base.zero(::Union{CostCurve, Type{CostCurve}}) = CostCurve(zero(ValueCurve)) +""" +`CostCurve{T}` with any unit system. Equivalent to `CostCurve{T, U} where U`; +use at `isa` sites where the unit-system parameter doesn't matter. +""" +const AnyCostCurve{T} = CostCurve{T, U} where {U <: AbstractUnitSystem} + """ $(TYPEDEF) $(TYPEDFIELDS) @@ -226,6 +239,12 @@ end "Get a `FuelCurve` representing zero fuel usage and zero fuel cost" Base.zero(::Union{FuelCurve, Type{FuelCurve}}) = FuelCurve(zero(ValueCurve), 0.0) +""" +`FuelCurve{T}` with any unit system. Equivalent to `FuelCurve{T, U} where U`; +use at `isa` sites where the unit-system parameter doesn't matter. +""" +const AnyFuelCurve{T} = FuelCurve{T, U} where {U <: AbstractUnitSystem} + "Get the fuel cost or the name of the fuel cost time series" get_fuel_cost(cost::FuelCurve) = cost.fuel_cost "Get the function for the fuel consumption at startup" @@ -246,10 +265,18 @@ is_time_series_backed(cost::FuelCurve) = # conventional "power_units" key (preserving the field name from the previous # schema) and reconstruct it at deserialize time. -_unit_system_instance(name::AbstractString) = - _unit_system_instance(Symbol(name)) -function _unit_system_instance(name::Symbol) - T = getproperty(@__MODULE__, name) +# Accept both new (type-name) and legacy (UnitSystem enum-value-name) encodings so +# existing serialized fixtures keep deserializing. +_unit_system_instance(name::AbstractString) = _unit_system_instance(String(name)) +function _unit_system_instance(name::String) + if name in ("NATURAL_UNITS", "NaturalUnit") + return NaturalUnit() + elseif name in ("SYSTEM_BASE", "SystemBaseUnit") + return SystemBaseUnit() + elseif name in ("DEVICE_BASE", "DeviceBaseUnit") + return DeviceBaseUnit() + end + T = getproperty(@__MODULE__, Symbol(name)) T <: AbstractUnitSystem || throw(ArgumentError("$name is not a subtype of AbstractUnitSystem")) return T() From e319211e4fa6b9cd7c51b513b755dd012e62c12f Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Mon, 27 Apr 2026 11:18:57 -0600 Subject: [PATCH 15/16] Add convert_cost_coefficient for unit-system cost conversion Cost coefficients convert as the inverse of the power-value ratio (squared for quadratic terms). Bases pass in as Float64 args so IS needs no component interface stubs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/relative_units.jl | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/relative_units.jl b/src/relative_units.jl index d7570cb1c..9ea59f70e 100644 --- a/src/relative_units.jl +++ b/src/relative_units.jl @@ -119,6 +119,37 @@ Base.show(io::IO, ::NaturalUnit) = print(io, "NU") Base.zero(::Type{RelativeQuantity{T, U}}) where {T, U} = RelativeQuantity(zero(T), U()) Base.one(::Type{RelativeQuantity{T, U}}) where {T, U} = RelativeQuantity(one(T), U()) +""" + convert_cost_coefficient(value, U_from, U_to, + system_base_power, device_base_power, + exponent::Int = 1) → Float64 + +Convert a cost coefficient (e.g. \$/MW for `exponent=1`, \$/MW² for +`exponent=2`) between unit systems. The conversion ratio is the inverse of +the corresponding power-value ratio raised to `exponent`, since if +`obj = c · x_from` and `x_from = r · x_to`, then the equivalent coefficient +under `x_to` is `c · r`. +""" +convert_cost_coefficient( + value::Float64, + U_from::AbstractUnitSystem, + U_to::AbstractUnitSystem, + system_base_power::Float64, + device_base_power::Float64, + exponent::Int = 1, +) = + value * _cost_coeff_ratio(U_from, U_to, system_base_power, device_base_power)^exponent + +_cost_coeff_ratio(::SystemBaseUnit, ::SystemBaseUnit, _, _) = 1.0 +_cost_coeff_ratio(::DeviceBaseUnit, ::DeviceBaseUnit, _, _) = 1.0 +_cost_coeff_ratio(::NaturalUnit, ::NaturalUnit, _, _) = 1.0 +_cost_coeff_ratio(::DeviceBaseUnit, ::SystemBaseUnit, sb, db) = sb / db +_cost_coeff_ratio(::SystemBaseUnit, ::DeviceBaseUnit, sb, db) = db / sb +_cost_coeff_ratio(::NaturalUnit, ::SystemBaseUnit, sb, _) = sb +_cost_coeff_ratio(::SystemBaseUnit, ::NaturalUnit, sb, _) = 1 / sb +_cost_coeff_ratio(::NaturalUnit, ::DeviceBaseUnit, _, db) = db +_cost_coeff_ratio(::DeviceBaseUnit, ::NaturalUnit, _, db) = 1 / db + """ display_units_arg(f, ::Type{T}) -> Union{AbstractRelativeUnit, Missing} From 414048d55ae6b6736349647969f976a2f8f8d825 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Mon, 27 Apr 2026 14:27:17 -0600 Subject: [PATCH 16/16] Test convert_cost_coefficient Covers identity, all 6 cross-system conversions, exponent for quadratic, round-trip identity, and the negative-exponent (power-value-ratio) case used by IOM piecewise x-coord rescaling. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/test_relative_units.jl | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/test_relative_units.jl b/test/test_relative_units.jl index e27dc64f5..4cae6bfff 100644 --- a/test/test_relative_units.jl +++ b/test/test_relative_units.jl @@ -35,3 +35,45 @@ end @test sprint(show, IS.SU) == "SU" @test sprint(show, IS.NU) == "NU" end + +@testset "convert_cost_coefficient" begin + sb, db = 100.0, 50.0 + @testset "identity (same unit system)" begin + for U in (IS.SU, IS.DU, IS.NU) + @test IS.convert_cost_coefficient(2.5, U, U, sb, db) == 2.5 + @test IS.convert_cost_coefficient(2.5, U, U, sb, db, 2) == 2.5 + end + end + + @testset "DU ↔ SU (linear)" begin + @test IS.convert_cost_coefficient(2.0, IS.DU, IS.SU, sb, db) ≈ 2.0 * sb / db + @test IS.convert_cost_coefficient(2.0, IS.SU, IS.DU, sb, db) ≈ 2.0 * db / sb + end + + @testset "NU ↔ {SU, DU} (linear)" begin + @test IS.convert_cost_coefficient(2.0, IS.NU, IS.SU, sb, db) ≈ 2.0 * sb + @test IS.convert_cost_coefficient(2.0, IS.SU, IS.NU, sb, db) ≈ 2.0 / sb + @test IS.convert_cost_coefficient(2.0, IS.NU, IS.DU, sb, db) ≈ 2.0 * db + @test IS.convert_cost_coefficient(2.0, IS.DU, IS.NU, sb, db) ≈ 2.0 / db + end + + @testset "exponent (quadratic)" begin + @test IS.convert_cost_coefficient(2.0, IS.DU, IS.SU, sb, db, 2) ≈ 2.0 * (sb / db)^2 + @test IS.convert_cost_coefficient(2.0, IS.NU, IS.SU, sb, db, 2) ≈ 2.0 * sb^2 + end + + @testset "round-trip is identity (linear and quadratic)" begin + for (Ua, Ub) in ((IS.DU, IS.SU), (IS.NU, IS.SU), (IS.NU, IS.DU)) + for k in (1, 2) + forward = IS.convert_cost_coefficient(2.0, Ua, Ub, sb, db, k) + back = IS.convert_cost_coefficient(forward, Ub, Ua, sb, db, k) + @test back ≈ 2.0 + end + end + end + + @testset "negative exponent inverts linear ratio (used for piecewise x-coords)" begin + @test IS.convert_cost_coefficient(2.0, IS.DU, IS.SU, sb, db, -1) ≈ 2.0 * db / sb + @test IS.convert_cost_coefficient(2.0, IS.NU, IS.SU, sb, db, -1) ≈ 2.0 / sb + end +end