diff --git a/src/io.jl b/src/io.jl index 929f08f..f2a777a 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 # ==== # @@ -569,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}() @@ -582,6 +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 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/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..5894ef7 --- /dev/null +++ b/test/test_json.jl @@ -0,0 +1,173 @@ +# 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") + 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} + _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], + "int_scalar": 101, + "int_vector": [1, 2, 3], + "f32_scalar": 1.25, + "f32_vector": [1.25, 2.5], + "union_scalar": 20 + } + } + """ + 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]) + # 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} + # 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 + 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] + 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) + @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]) + @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") + 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 + + @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 + + @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