From 67de0c40ea0cf6897f1273fe55dbe5afa734b551 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Fri, 12 Jun 2026 16:54:29 -0700 Subject: [PATCH 1/3] fix: support non-finite floats in JSON I/O under both JSON.jl v0.21 and v1 JSON.jl v1 defaults to strict RFC 8259: JSON.parse throws on NaN/Infinity/ -Infinity tokens and JSON.json throws when writing non-finite floats, whereas v0.21 parsed those tokens by default and wrote them as null. This broke FUSE act_json/ini_json tests once the ecosystem resolved JSON v1, because IMASdd environments write parameters JSON containing bare NaN tokens (via its AbstractFloat lowering) that jstr2par could no longer parse. - jstr2par: pass allownan=true to JSON.parse (no-op on v0.21, required on v1) so IMAS-convention non-finite tokens parse on both JSON versions - par2jstr: under JSON v1 call JSON.json(data; pretty=indent, allownan=true) since the pre-1.0 compat method JSON.json(data, indent) silently drops keyword arguments and throws on non-finite floats; v0.21 keeps its legacy behavior (non-finite -> null) - add test/test_json.jl covering token parsing, non-finite write/roundtrip, and file I/O on both JSON versions --- src/io.jl | 11 ++++- test/runtests.jl | 2 + test/test_json.jl | 110 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 test/test_json.jl diff --git a/src/io.jl b/src/io.jl index 929f08f..34e9b72 100644 --- a/src/io.jl +++ b/src/io.jl @@ -224,7 +224,8 @@ end Loads AbstractParameters from JSON string """ function jstr2par(json_string::String, par::AbstractParameters) - data = JSON.parse(json_string; dicttype=OrderedCollections.OrderedDict) + # allownan=true: accept NaN/Infinity/-Infinity tokens (v0.21 default; JSON v1 defaults to strict) + data = JSON.parse(json_string; dicttype=OrderedCollections.OrderedDict, allownan=true) data = replace_colon_strings_to_symbols(data) dict2par!(data, par) setup_parameters!(par) @@ -239,7 +240,13 @@ Returns JSON serialization of AbstractParameters function par2jstr(@nospecialize(par::AbstractParameters); indent::Int=1, kw...) data = par2dict(par) data = replace_symbols_to_colon_strings(data) - return JSON.json(data, indent; kw...) + @static if pkgversion(JSON) < v"1" + return JSON.json(data, indent; kw...) # writes non-finite floats as `null` + else + # the `json(data, indent)` compat method drops kwargs; allownan=true writes + # non-finite floats as NaN/Infinity/-Infinity tokens (v1 throws on them by default) + return JSON.json(data; pretty=indent, allownan=true, kw...) + end end # ==== # diff --git a/test/runtests.jl b/test/runtests.jl index d72b7e3..f19dd30 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -313,6 +313,8 @@ end @test diff(ini, ini2) === false end +include("test_json.jl") + @testset "yaml_save_load" begin ini = ParametersInits() ini.equilibrium.R0 = 1000.0 diff --git a/test/test_json.jl b/test/test_json.jl new file mode 100644 index 0000000..1d42902 --- /dev/null +++ b/test/test_json.jl @@ -0,0 +1,110 @@ +# JSON serialization of non-finite floats across JSON.jl v0.21 and v1. +# v1 throws on NaN/Infinity/-Infinity (parse and write) unless allownan=true, +# while v0.21 parsed them by default and wrote them as `null`. + +using SimulationParameters +using Test + +import SimulationParameters: JSON + +const JSON_IS_V1 = pkgversion(JSON) >= v"1" + +Base.@kwdef mutable struct JSONTestParameters__phys{T<:Real} <: AbstractParameters{T} + _parent::WeakRef = WeakRef(nothing) + _name::Symbol = :phys + nan_scalar::Entry{T} = Entry{T}("-", "scalar holding NaN") + inf_scalar::Entry{T} = Entry{T}("-", "scalar holding Inf") + ninf_scalar::Entry{T} = Entry{T}("-", "scalar holding -Inf") + finite_scalar::Entry{T} = Entry{T}("-", "scalar holding a finite value") + mixed_vector::Entry{Vector{Float64}} = Entry{Vector{Float64}}("-", "vector with non-finite elements") +end + +mutable struct JSONTestParameters{T<:Real} <: AbstractParameters{T} + _parent::WeakRef + _name::Symbol + phys::JSONTestParameters__phys{T} +end + +function JSONTestParameters() + par = JSONTestParameters{Float64}(WeakRef(nothing), :jsontest, JSONTestParameters__phys{Float64}()) + setup_parameters!(par) + return par +end + +@testset "json_nonfinite" begin + @info "json_nonfinite testset running with JSON.jl v$(pkgversion(JSON))" + + @testset "parse non-finite tokens" begin + # IMAS-convention JSON, as written by FUSE/IMASdd environments + json_string = """ + { + "phys": { + "nan_scalar": NaN, + "inf_scalar": Infinity, + "ninf_scalar": -Infinity, + "finite_scalar": 1.5, + "mixed_vector": [1.0, NaN, Infinity, -Infinity, 2.5] + } + } + """ + par = jstr2par(json_string, JSONTestParameters()) + @test isnan(par.phys.nan_scalar) + @test par.phys.inf_scalar == Inf + @test par.phys.ninf_scalar == -Inf + @test par.phys.finite_scalar == 1.5 + @test isequal(par.phys.mixed_vector, [1.0, NaN, Inf, -Inf, 2.5]) + end + + @testset "write non-finite values" begin + par = JSONTestParameters() + par.phys.nan_scalar = NaN + par.phys.inf_scalar = Inf + par.phys.ninf_scalar = -Inf + par.phys.finite_scalar = 1.5 + par.phys.mixed_vector = [1.0, NaN, Inf, -Inf, 2.5] + + # must not throw on either JSON version + json_string = par2jstr(par) + @test json_string isa String + + if JSON_IS_V1 + # v1 writes IMAS-convention tokens, so the roundtrip preserves values + @test contains(json_string, "NaN") + @test contains(json_string, "Infinity") + par2 = jstr2par(json_string, JSONTestParameters()) + @test isnan(par2.phys.nan_scalar) + @test par2.phys.inf_scalar == Inf + @test par2.phys.ninf_scalar == -Inf + @test par2.phys.finite_scalar == 1.5 + @test isequal(par2.phys.mixed_vector, [1.0, NaN, Inf, -Inf, 2.5]) + else + # v0.21 writes non-finite floats as null; on load a null scalar is skipped (stays missing) + @test contains(json_string, "null") + par_scalar = JSONTestParameters() + par_scalar.phys.nan_scalar = NaN + par_scalar.phys.finite_scalar = 1.5 + par2 = jstr2par(par2jstr(par_scalar), JSONTestParameters()) + @test ismissing(par2.phys, :nan_scalar) + @test par2.phys.finite_scalar == 1.5 + end + end + + @testset "par2json/json2par file roundtrip" begin + par = JSONTestParameters() + par.phys.finite_scalar = 2.5 + if JSON_IS_V1 + par.phys.nan_scalar = NaN + end + filename = tempname() * ".json" + try + par2json(par, filename) + par2 = json2par(filename, JSONTestParameters()) + @test par2.phys.finite_scalar == 2.5 + if JSON_IS_V1 + @test isnan(par2.phys.nan_scalar) + end + finally + isfile(filename) && rm(filename) + end + end +end From 0d7be932e6d293b3925e13b58195435208cbe794 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Fri, 12 Jun 2026 18:39:39 -0700 Subject: [PATCH 2/3] fix: decode integer parameters parsed as Float64 by JSON v1 allownan JSON v1 with allownan=true parses all numbers as Float64 by design (JuliaIO/JSON.jl#397), so Entry{Int} values came back as e.g. 101.0 and setproperty! threw (FUSE ini_json/act_json failures). string_decode_value now coerces scalar floats to the parameter's declared Integer type. --- src/io.jl | 3 +++ test/test_json.jl | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/io.jl b/src/io.jl index 34e9b72..ad02e4f 100644 --- a/src/io.jl +++ b/src/io.jl @@ -589,6 +589,9 @@ function string_decode_value(par::AbstractParameters, field::Symbol, value::Any) value = tuple(map((x, target_type) -> convert(target_type, eval(x)), expr.args, tp.parameters)...) elseif tp <: Bool && typeof(value) <: Int value = Bool(value) + elseif tp <: Integer && typeof(value) <: AbstractFloat + # JSON v1 with allownan=true parses all numbers as Float64 (JuliaIO/JSON.jl#397) + value = tp(value) end return value end diff --git a/test/test_json.jl b/test/test_json.jl index 1d42902..5cbcad2 100644 --- a/test/test_json.jl +++ b/test/test_json.jl @@ -17,6 +17,8 @@ Base.@kwdef mutable struct JSONTestParameters__phys{T<:Real} <: AbstractParamete ninf_scalar::Entry{T} = Entry{T}("-", "scalar holding -Inf") finite_scalar::Entry{T} = Entry{T}("-", "scalar holding a finite value") mixed_vector::Entry{Vector{Float64}} = Entry{Vector{Float64}}("-", "vector with non-finite elements") + int_scalar::Entry{Int} = Entry{Int}("-", "integer scalar") + int_vector::Entry{Vector{Int}} = Entry{Vector{Int}}("-", "integer vector") end mutable struct JSONTestParameters{T<:Real} <: AbstractParameters{T} @@ -43,7 +45,9 @@ end "inf_scalar": Infinity, "ninf_scalar": -Infinity, "finite_scalar": 1.5, - "mixed_vector": [1.0, NaN, Infinity, -Infinity, 2.5] + "mixed_vector": [1.0, NaN, Infinity, -Infinity, 2.5], + "int_scalar": 101, + "int_vector": [1, 2, 3] } } """ @@ -53,6 +57,10 @@ end @test par.phys.ninf_scalar == -Inf @test par.phys.finite_scalar == 1.5 @test isequal(par.phys.mixed_vector, [1.0, NaN, Inf, -Inf, 2.5]) + # JSON v1 with allownan=true parses all numbers as Float64 (JuliaIO/JSON.jl#397): + # integer parameters must still decode to their declared type + @test par.phys.int_scalar === 101 + @test par.phys.int_vector == [1, 2, 3] && par.phys.int_vector isa Vector{Int} end @testset "write non-finite values" begin @@ -62,6 +70,8 @@ end par.phys.ninf_scalar = -Inf par.phys.finite_scalar = 1.5 par.phys.mixed_vector = [1.0, NaN, Inf, -Inf, 2.5] + par.phys.int_scalar = 101 + par.phys.int_vector = [1, 2, 3] # must not throw on either JSON version json_string = par2jstr(par) @@ -77,15 +87,19 @@ end @test par2.phys.ninf_scalar == -Inf @test par2.phys.finite_scalar == 1.5 @test isequal(par2.phys.mixed_vector, [1.0, NaN, Inf, -Inf, 2.5]) + @test par2.phys.int_scalar === 101 + @test par2.phys.int_vector == [1, 2, 3] && par2.phys.int_vector isa Vector{Int} else # v0.21 writes non-finite floats as null; on load a null scalar is skipped (stays missing) @test contains(json_string, "null") par_scalar = JSONTestParameters() par_scalar.phys.nan_scalar = NaN par_scalar.phys.finite_scalar = 1.5 + par_scalar.phys.int_scalar = 101 par2 = jstr2par(par2jstr(par_scalar), JSONTestParameters()) @test ismissing(par2.phys, :nan_scalar) @test par2.phys.finite_scalar == 1.5 + @test par2.phys.int_scalar === 101 end end From b27260d3e839eb51b501d7074c93d858f744b12b Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Sat, 13 Jun 2026 11:37:16 -0700 Subject: [PATCH 3/3] fix: decode JSON v1 Float64 to any declared Real type, not just Integer JSON v1 with allownan=true parses every number as Float64 (JuliaIO/JSON.jl#397). The previous fix only coerced Entry{Integer} scalars, so Float32 fields and Union eltypes like Entry{Union{Int64,Vector}} (e.g. act.ActorCoreRadHeatFlux.levels) still threw MethodError on load. string_decode_value now coerces a scalar number to the lone Real member of the declared type, and the vector branch uses convert.(etp, value) so untyped Vector members (eltype Any) no longer hit the missing Any(x) constructor. Adds regression tests covering Int, Float32, and Union{Int,Vector} (scalar and vector) round-trips, mirroring FUSE's ini_json/act_json pattern. --- src/io.jl | 14 +++++++++---- test/test_json.jl | 51 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/io.jl b/src/io.jl index ad02e4f..f2a777a 100644 --- a/src/io.jl +++ b/src/io.jl @@ -576,7 +576,9 @@ function string_decode_value(par::AbstractParameters, field::Symbol, value::Any) if tp <: AbstractArray{Symbol} && typeof(value) <: AbstractArray{String} value = Symbol.([(startswith(x, ":") ? x[2:end] : x) for x in value ]) else - value = etp.(value) + # `convert`, not `etp(x)`: `Any(x)` has no constructor (untyped Vector → eltype Any), + # while convert still coerces Float64 back to declared element types. + value = convert.(etp, value) end else value = Vector{etp}() @@ -589,9 +591,13 @@ function string_decode_value(par::AbstractParameters, field::Symbol, value::Any) value = tuple(map((x, target_type) -> convert(target_type, eval(x)), expr.args, tp.parameters)...) elseif tp <: Bool && typeof(value) <: Int value = Bool(value) - elseif tp <: Integer && typeof(value) <: AbstractFloat - # JSON v1 with allownan=true parses all numbers as Float64 (JuliaIO/JSON.jl#397) - value = tp(value) + elseif typeof(value) <: Real + # JSON v1 allownan parses every number as Float64 (JuliaIO/JSON.jl#397); coerce back to the + # declared scalar Real type — `tp` itself, or the lone Real member of a Union (e.g. Union{Int,Vector}). + real_targets = filter(t -> t <: Real, collect(Base.uniontypes(tp))) + if length(real_targets) == 1 && !(typeof(value) <: only(real_targets)) + value = convert(only(real_targets), value) + end end return value end diff --git a/test/test_json.jl b/test/test_json.jl index 5cbcad2..5894ef7 100644 --- a/test/test_json.jl +++ b/test/test_json.jl @@ -19,6 +19,10 @@ Base.@kwdef mutable struct JSONTestParameters__phys{T<:Real} <: AbstractParamete mixed_vector::Entry{Vector{Float64}} = Entry{Vector{Float64}}("-", "vector with non-finite elements") int_scalar::Entry{Int} = Entry{Int}("-", "integer scalar") int_vector::Entry{Vector{Int}} = Entry{Vector{Int}}("-", "integer vector") + f32_scalar::Entry{Float32} = Entry{Float32}("-", "Float32 scalar (narrower than JSON's Float64)") + f32_vector::Entry{Vector{Float32}} = Entry{Vector{Float32}}("-", "Float32 vector") + union_scalar::Entry{Union{Int64,Vector}} = Entry{Union{Int64,Vector}}("-", "Int-or-Vector (cf. act.ActorCoreRadHeatFlux.levels)") + union_vector::Entry{Union{Int64,Vector}} = Entry{Union{Int64,Vector}}("-", "Int-or-Vector holding a vector") end mutable struct JSONTestParameters{T<:Real} <: AbstractParameters{T} @@ -47,7 +51,10 @@ end "finite_scalar": 1.5, "mixed_vector": [1.0, NaN, Infinity, -Infinity, 2.5], "int_scalar": 101, - "int_vector": [1, 2, 3] + "int_vector": [1, 2, 3], + "f32_scalar": 1.25, + "f32_vector": [1.25, 2.5], + "union_scalar": 20 } } """ @@ -61,6 +68,13 @@ end # integer parameters must still decode to their declared type @test par.phys.int_scalar === 101 @test par.phys.int_vector == [1, 2, 3] && par.phys.int_vector isa Vector{Int} + # more generally, ANY Real field narrower than JSON's Float64 (e.g. Float32) must + # decode to its declared type, not just Integer + @test par.phys.f32_scalar === 1.25f0 + @test par.phys.f32_vector == Float32[1.25, 2.5] && par.phys.f32_vector isa Vector{Float32} + # Union eltype: a scalar Int written for an Int-or-Vector field must decode to Int64, + # not Float64 (reproduces FUSE act.ActorCoreRadHeatFlux.levels::Union{Int64, Vector}) + @test par.phys.union_scalar === 20 end @testset "write non-finite values" begin @@ -72,6 +86,8 @@ end par.phys.mixed_vector = [1.0, NaN, Inf, -Inf, 2.5] par.phys.int_scalar = 101 par.phys.int_vector = [1, 2, 3] + par.phys.f32_scalar = 1.25f0 + par.phys.f32_vector = Float32[1.25, 2.5] # must not throw on either JSON version json_string = par2jstr(par) @@ -89,6 +105,8 @@ end @test isequal(par2.phys.mixed_vector, [1.0, NaN, Inf, -Inf, 2.5]) @test par2.phys.int_scalar === 101 @test par2.phys.int_vector == [1, 2, 3] && par2.phys.int_vector isa Vector{Int} + @test par2.phys.f32_scalar === 1.25f0 + @test par2.phys.f32_vector == Float32[1.25, 2.5] && par2.phys.f32_vector isa Vector{Float32} else # v0.21 writes non-finite floats as null; on load a null scalar is skipped (stays missing) @test contains(json_string, "null") @@ -121,4 +139,35 @@ end isfile(filename) && rm(filename) end end + + @testset "full-tree JSON roundtrip (FUSE ini_json/act_json pattern)" begin + # Mirrors FUSE's test_ini_act_save_load act_json/ini_json: + # str = SimulationParameters.par2jstr(par) + # par2 = SimulationParameters.jstr2par(str, fresh()) + # Populating a tree with the types that broke FUSE lets SimulationParameters catch the + # JSON-v1 Float64-flattening regressions (Int, Float32, Union{Int,Vector}) in its own + # ~1-minute test suite, instead of only in FUSE's hours-long CI. + par = JSONTestParameters() + par.phys.finite_scalar = 1.5 + par.phys.int_scalar = 101 + par.phys.int_vector = [1, 2, 3] + par.phys.f32_scalar = 1.25f0 + par.phys.f32_vector = Float32[1.25, 2.5] + par.phys.union_scalar = 20 # Int side of Union{Int64, Vector} + par.phys.union_vector = [4, 5, 6] # Vector side of the same field + if JSON_IS_V1 + par.phys.nan_scalar = NaN + par.phys.mixed_vector = [1.0, NaN, Inf, -Inf, 2.5] + end + + # the round-trip must not throw, and must preserve each declared type + par2 = jstr2par(par2jstr(par), JSONTestParameters()) + @test par2.phys.finite_scalar === 1.5 + @test par2.phys.int_scalar === 101 + @test par2.phys.int_vector == [1, 2, 3] && par2.phys.int_vector isa Vector{Int} + @test par2.phys.f32_scalar === 1.25f0 + @test par2.phys.f32_vector == Float32[1.25, 2.5] && par2.phys.f32_vector isa Vector{Float32} + @test par2.phys.union_scalar === 20 + @test par2.phys.union_vector == [4, 5, 6] && par2.phys.union_vector isa Vector + end end