From 89048dbc16e6e674bfa9b0153dc7a3d6c2a64d26 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 20 Jan 2026 14:42:10 -0700 Subject: [PATCH 1/8] 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 5a88a340905e70f0aa80164abc84821cf4005969 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 10 Mar 2026 20:54:28 -0600 Subject: [PATCH 2/8] 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 b02e7d5c2ad685ccc36e34e014a6ca3c9d585650 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 17 Mar 2026 13:24:23 -0600 Subject: [PATCH 3/8] 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 333a6a877..59c471883 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 165c5c68d3cfc426e6505437b457938cabb7df55 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 18 Mar 2026 10:06:33 -0600 Subject: [PATCH 4/8] 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 59c471883..0453cb620 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 5bd63e40ad7155c5c1cc71802a65f52e64befcdd Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 27 Mar 2026 14:49:04 -0600 Subject: [PATCH 5/8] 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 7394454ac8aa68e384937f1edbac6927f902f861 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 27 Mar 2026 15:23:17 -0600 Subject: [PATCH 6/8] 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 1e89b78ecad5a687802038270e0191a2a8c0b2fd Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Mon, 30 Mar 2026 17:24:48 -0600 Subject: [PATCH 7/8] 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 0a3b486ca1139dbd0f808c4a3b9dcbfdd80b306d Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 15 Apr 2026 16:33:16 -0600 Subject: [PATCH 8/8] 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)