diff --git a/Project.toml b/Project.toml index 87fc21e43..339f18d20 100644 --- a/Project.toml +++ b/Project.toml @@ -40,8 +40,10 @@ gmsh_jll = "630162c2-fc9b-58b3-9910-8442a8a132e6" [weakdeps] GraphMakie = "1ecd5474-83a3-4783-bb4f-06765db800d2" +YAML = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" [extensions] +ParameterSetYAMLExt = "YAML" SchematicGraphMakieExt = "GraphMakie" [compat] @@ -76,6 +78,7 @@ StaticArrays = "1" TestItemRunner = "1.1.0" ThreadSafeDicts = "0.1" Unitful = "1.2" +YAML = "0.4" gmsh_jll = "4.13" julia = "1.10" @@ -83,6 +86,7 @@ julia = "1.10" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a" +YAML = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" [targets] -test = ["Aqua", "Test", "TestItemRunner"] +test = ["Aqua", "Test", "TestItemRunner", "YAML"] diff --git a/docs/make.jl b/docs/make.jl index 443593edd..ae86a8082 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -78,6 +78,7 @@ makedocs( "Geometry API Reference" => "reference/api.md", "Path API Reference" => "reference/path_api.md", "Schematic API Reference" => "reference/schematic_api.md", + "ParameterSet API Reference" => "reference/parameter_set.md", "Shape Reference" => "reference/shapes.md" ], "FAQ/Troubleshooting" => "how_to/faq.md" diff --git a/docs/src/reference/index.md b/docs/src/reference/index.md index 699387d2a..600d3fa9c 100644 --- a/docs/src/reference/index.md +++ b/docs/src/reference/index.md @@ -28,6 +28,7 @@ Docstrings are split up over a few pages: - [Geometry-Level Layout API](api.md) - [Path API](path_api.md) - [Schematic-Driven Design API](schematic_api.md) +- [ParameterSet API Reference](parameter_set.md) - [Shape Library](shapes.md) Or jump straight to a particular topic: diff --git a/docs/src/reference/parameter_set.md b/docs/src/reference/parameter_set.md new file mode 100644 index 000000000..634f368e3 --- /dev/null +++ b/docs/src/reference/parameter_set.md @@ -0,0 +1,264 @@ +# ParameterSet + +A `ParameterSet` is a mutable parameter source for data-driven design. It holds a nested dictionary of parameters - typically loaded from a YAML file - and provides dot-access syntax for reading and writing values. + +Every `ParameterSet` contains two required top-level namespaces: +- **`global`** - parameters shared across the design (e.g., version, process node) +- **`components`** - per-component parameter trees + +## Tutorial: Using External Parameters + +This tutorial shows how to drive a design from a `ParameterSet` instead of +hardcoding parameter values. We start with simple components, then move to +composite components. + +### Setup + +```julia +using DeviceLayout, .PreferredUnits +using DeviceLayout.SchematicDrivenLayout +``` + +### Creating a ParameterSet + +The simplest way is to build one programmatically: + +```julia +ps = ParameterSet() + +# Set global metadata +ps.global.version = 1 +ps.global.process_node = "fab_v3" + +# Define component parameters with units +ps.components.capacitor.finger_length = 150μm +ps.components.capacitor.finger_width = 5μm +ps.components.capacitor.finger_gap = 3μm +ps.components.capacitor.finger_count = 6 + +ps.components.junction.w_jj = 1μm +ps.components.junction.h_jj = 1μm +``` + +If you have the `YAML` package installed, you can load directly from a file: + +```yaml +# design_params.yaml +global: + version: 1 + process_node: fab_v3 + +components: + capacitor: + finger_length: 150μm + finger_width: 5μm + finger_gap: 3μm + finger_count: 6 + junction: + w_jj: 1μm + h_jj: 1μm +``` + +```julia +using YAML # activates the ParameterSetYAMLExt extension +ps = ParameterSet("design_params.yaml") +``` + +### Reading Parameters + +Use dot syntax to navigate the hierarchy: + +```julia +ps.global.version # => 1 +ps.components.capacitor # => ParameterSet scoped to capacitor subtree +ps.components.capacitor.finger_length # => 150μm +``` + +Or use `resolve` with a dot-separated address: + +```julia +resolve(ps, "components.capacitor.finger_length") # => 150μm +resolve(ps, "components.capacitor") # => scoped ParameterSet +``` + +Extract all leaf parameters at a level as a `NamedTuple`: + +```julia +leaf_params(ps.components.capacitor) +# => (finger_length = 150μm, finger_width = 5μm, finger_gap = 3μm, finger_count = 6) +``` + +### Simple Components with ParameterSet + +Suppose you have a component defined with `@compdef`: + +```julia +@compdef struct MyCapacitor <: Component + name = "capacitor" + finger_length = 100μm + finger_width = 5μm + finger_gap = 3μm + finger_count::Int = 4 +end +``` + +You can instantiate it from the `ParameterSet` using `create_component`: + +```julia +cap = create_component(MyCapacitor, ps, "components.capacitor") +``` + +This resolves `"components.capacitor"` in the parameter set, extracts leaf +parameters, and passes them as keyword arguments to the `MyCapacitor` +constructor. Parameters not present in the `ParameterSet` keep their defaults. + +Consumed parameters are tracked in `ps.accessed`, which is useful for auditing +which parameters were actually used: + +```julia +ps.accessed +# => Set(["components.capacitor.finger_length", "components.capacitor.finger_width", ...]) +``` + +### Attaching ParameterSet to a SchematicGraph + +Pass the `ParameterSet` when creating a `SchematicGraph` so that all components +in the graph can access it: + +```julia +g = SchematicGraph("my_design", ps) + +# The parameter set is accessible from the graph +g.parameter_set.components.capacitor.finger_length # => 150μm +``` + +A full example with simple components: + +```julia +# Load parameters +ps = ParameterSet() +ps.components.cap1.finger_length = 150μm +ps.components.cap1.finger_count = 6 +ps.components.cap2.finger_length = 200μm +ps.components.cap2.finger_count = 8 + +# Create graph with parameter set +g = SchematicGraph("two_caps", ps) + +# Create components from parameter set +@component cap1 = create_component(MyCapacitor, ps, "components.cap1") +@component cap2 = create_component(MyCapacitor, ps, "components.cap2") + +# Build schematic +cap1_node = add_node!(g, cap1) +cap2_node = fuse!(g, cap1_node => :p1, cap2 => :p0) + +sch = plan(g; log_dir=nothing) +``` + +### Composite Components with ParameterSet + +For composite components, the `ParameterSet` propagates through the graph +hierarchy. When you attach a `ParameterSet` to a top-level `SchematicGraph`, it +is available inside `_build_subcomponents` via the graph. + +With a `ParameterSet`, subcomponent parameters live in the parameter set +rather than in the composite struct. The composite only declares parameters +that are shared across multiple subcomponents: + +```julia +@compdef struct SimpleTransmon <: CompositeComponent + name = "transmon" + junction_gap = 12μm # shared: controls both island gap and junction height +end +``` + +Define the parameter set with a namespace per subcomponent. Note that +`junction_gap` only appears on the composite - it will be forwarded to +subcomponents in `_build_subcomponents`: + +```julia +ps = ParameterSet() + +ps.components.transmon.junction_gap = 12μm + +ps.components.transmon.island.cap_width = 24μm +ps.components.transmon.island.cap_length = 520μm +ps.components.transmon.island.cap_gap = 30μm + +ps.components.transmon.junction.w_jj = 1μm +ps.components.transmon.junction.h_jj = 1μm +``` + +Inside `_build_subcomponents`, use `parameter_set(g)` to access the graph's +`ParameterSet`, then `create_component` to instantiate each subcomponent from +its subtree. The shared `junction_gap` is read from the composite instance and +forwarded to both subcomponents under their respective parameter names: + +```julia +function SchematicDrivenLayout._build_subcomponents(tr::SimpleTransmon) + ps = parameter_set(tr._graph) + + @component island = create_component( + ExampleRectangleIsland, ps, "components.transmon.island" + ) + # Forward shared parameter from parameter set to island + island = set_parameters(island; junction_gap=ps.components.transmon.junction_gap) + + @component junction = create_component( + ExampleSimpleJunction, ps, "components.transmon.junction" + ) + # Forward shared parameter under the subcomponent's own name + junction = set_parameters(junction; h_ground_island=ps.components.transmon.junction_gap) + + return (island, junction) +end +``` + +`create_component(T, ps, address)` resolves the address, extracts leaf +parameters via `leaf_params`, and passes them as keyword arguments to the +component constructor. Parameters not in the `ParameterSet` keep their defaults. + +Create the top-level graph and composite component: + +```julia +g = SchematicGraph("chip", ps) + +transmon = create_component(SimpleTransmon, ps, "components.transmon") +transmon_node = add_node!(g, transmon) +``` + +The `ParameterSet` is preserved when graphs are copied - for example, inside +`BasicCompositeComponent` or during `_flatten` operations. This means +subcomponents at any depth can access the same parameter set. + +### Access Tracking + +The `accessed` field tracks which leaf parameters were read, enabling auditing +of unused or missing parameters: + +```julia +ps = ParameterSet() +ps.components.qubit.cap_width = 300μm +ps.components.qubit.cap_gap = 20μm + +# Nothing accessed yet +isempty(ps.accessed) # => true + +# Read a parameter +ps.components.qubit.cap_width # => 300μm +"cap_width" in ps.accessed # => true + +# Tracking is shared across scoped views +sub = ps.components.qubit +sub.cap_gap # => 20μm +"cap_gap" in ps.accessed # => true +``` + +## API Reference + +```@docs +DeviceLayout.ParameterSet +DeviceLayout.resolve +DeviceLayout.leaf_params +``` diff --git a/ext/ParameterSetYAMLExt.jl b/ext/ParameterSetYAMLExt.jl new file mode 100644 index 000000000..e63b4a42f --- /dev/null +++ b/ext/ParameterSetYAMLExt.jl @@ -0,0 +1,74 @@ +module ParameterSetYAMLExt + +import DeviceLayout: ParameterSet +import DeviceLayout +import YAML +import Unitful + +""" + _parse_units!(data::Dict{String, Any}) + +Recursively walk a parsed YAML dict. String values parseable by `Unitful.uparse` +(e.g. `"150μm"`) are converted to `Unitful.Quantity` values. +""" +function _parse_units!(data::Dict{String, Any}) + for (k, v) in data + if v isa Dict{String, Any} + _parse_units!(v) + elseif v isa AbstractString + try + data[k] = Unitful.uparse(v) + catch + # not a valid unit expression, keep as-is + end + end + end + return data +end + +""" + _serialize_units(data::Dict{String, Any}) -> Dict{String, Any} + +Return a deep copy of `data` with `Unitful.Quantity` values converted to +strings like `"150μm"` (no space, round-trips through `Unitful.uparse`). +""" +function _serialize_units(data::Dict{String, Any}) + out = Dict{String, Any}() + for (k, v) in data + if v isa Dict{String, Any} + out[k] = _serialize_units(v) + elseif v isa Unitful.Quantity + out[k] = "$(Unitful.ustrip(v))$(Unitful.unit(v))" + else + out[k] = v + end + end + return out +end + +function ParameterSet(io::IO, path::String="") + data = YAML.load(io; dicttype=Dict{String, Any}) + _parse_units!(data) + return DeviceLayout.ParameterSet(path, data) +end + +function ParameterSet(path::String) + return open(path) do io + DeviceLayout.ParameterSet(io, path) + end +end + +function DeviceLayout.save_parameter_set(io::IO, ps::ParameterSet) + data = _serialize_units(getfield(ps, :data)) + YAML.write(io, data) + return io +end + +function DeviceLayout.save_parameter_set(path::String, ps::ParameterSet) + open(path, "w") do io + DeviceLayout.save_parameter_set(io, ps) + end + return path +end + +end # module ParameterSetYAMLExt diff --git a/src/DeviceLayout.jl b/src/DeviceLayout.jl index 560eff75b..39e4a7d6f 100644 --- a/src/DeviceLayout.jl +++ b/src/DeviceLayout.jl @@ -616,6 +616,9 @@ export PolyText, referenced_characters_demo, scripted_demo +include("parameter_set.jl") +export ParameterSet, MissingNamespace, ParameterKeyError + include("schematics/SchematicDrivenLayout.jl") export SchematicDrivenLayout diff --git a/src/parameter_set.jl b/src/parameter_set.jl new file mode 100644 index 000000000..1b8c06d56 --- /dev/null +++ b/src/parameter_set.jl @@ -0,0 +1,313 @@ +""" + ParameterSet + +Mutable parameter source, typically loaded from a YAML file. + +The internal dict uses a namespace convention: `String` keys are namespace segments +(navigate deeper into the hierarchy), non-`Dict` values are leaf parameters. + +Supports dot access for both reading and writing: + +```julia +ps.components.qubit.cap_width # read +ps.components.qubit.cap_width = 350 # write +``` + +Every `ParameterSet` contains two required top-level namespaces: + + - `"global"` — parameters shared across the design + - `"components"` — per-component parameter trees + +# Fields + + - `path::String`: source file path (empty string if constructed from a Dict) + - `data::Dict{String, Any}`: nested parameter dictionary + - `accessed::Set{String}`: tracks which parameter paths were consumed (for auditing) +""" +mutable struct ParameterSet + path::String + data::Dict{String, Any} + accessed::Set{String} +end + +""" + ParameterKeyError <: Exception + +Thrown when reading a non-existent key from a `ParameterSet`. +""" +struct ParameterKeyError <: Exception + key::String + path::String +end + +function Base.showerror(io::IO, e::ParameterKeyError) + print(io, "ParameterKeyError: ParameterSet has no key :$(e.key)") + if !isempty(e.path) + print(io, " at path \"$(e.path)\"") + end +end + +""" + MissingNamespace + +Returned when accessing a non-existent key on a `ParameterSet`. + +Supports chained dot-writes (auto-vivifying intermediate namespaces) but shows +a `ParameterKeyError` when used as a value. Check with `x isa MissingNamespace`. +""" +struct MissingNamespace + parent # ::Union{Dict{String, Any}, MissingNamespace} + key::String + accessed::Set{String} +end + +function _namespace_path(d::MissingNamespace) + if d.parent isa MissingNamespace + return _namespace_path(d.parent) * "." * d.key + end + return d.key +end + +function _missing_error(d::MissingNamespace) + throw(ParameterKeyError(d.key, _namespace_path(d))) +end + +function _materialize!(d::MissingNamespace) + parent_dict = if d.parent isa Dict{String, Any} + d.parent + else + _materialize!(d.parent) + end + if !haskey(parent_dict, d.key) + parent_dict[d.key] = Dict{String, Any}() + end + return parent_dict[d.key] +end + +function Base.getproperty(d::MissingNamespace, s::Symbol) + s in (:parent, :key, :accessed) && return getfield(d, s) + return MissingNamespace(d, String(s), getfield(d, :accessed)) +end + +function Base.setproperty!(d::MissingNamespace, s::Symbol, value) + s in (:parent, :key, :accessed) && return setfield!(d, s, value) + materialized = _materialize!(d) + if value isa Pair + value = Dict{String, Any}(String(value.first) => value.second) + end + materialized[String(s)] = value + return value +end + +function Base.show(io::IO, ::MIME"text/plain", d::MissingNamespace) + path = _namespace_path(d) + printstyled(io, "ParameterKeyError: "; bold=true, color=:red) + return print(io, "ParameterSet has no key :$(getfield(d, :key)) at path \"$path\"") +end + +Base.show(io::IO, d::MissingNamespace) = print( + io, + "ParameterKeyError: ParameterSet has no key :$(getfield(d, :key)) at path \"$(_namespace_path(d))\"" +) + +# Throw on any attempt to use MissingNamespace as a value +Base.convert(::Type{T}, d::MissingNamespace) where {T <: Number} = _missing_error(d) +Base.iterate(d::MissingNamespace) = _missing_error(d) +Base.length(d::MissingNamespace) = _missing_error(d) + +const _REQUIRED_NAMESPACES = ("global", "components") + +function _ensure_required_namespaces!(data::Dict{String, Any}) + for ns in _REQUIRED_NAMESPACES + if !haskey(data, ns) + data[ns] = Dict{String, Any}() + end + end + return data +end + +function ParameterSet(path::String, data::Dict{String, Any}) + _ensure_required_namespaces!(data) + return ParameterSet(path, data, Set{String}()) +end +ParameterSet(data::Dict{String, Any}) = ParameterSet("", data) +ParameterSet() = ParameterSet(Dict{String, Any}()) + +function Base.getproperty(ps::ParameterSet, s::Symbol) + s in (:path, :data, :accessed) && return getfield(ps, s) + + d = getfield(ps, :data) + key = String(s) + if !haskey(d, key) + return MissingNamespace(d, key, getfield(ps, :accessed)) + end + + val = d[key] + if val isa Dict + return ParameterSet(getfield(ps, :path), val, getfield(ps, :accessed)) + end + # Track leaf access + push!(getfield(ps, :accessed), key) + return val +end + +function Base.setproperty!(ps::ParameterSet, s::Symbol, value) + s in (:path, :data, :accessed) && return setfield!(ps, s, value) + d = getfield(ps, :data) + if value isa Pair + value = Dict{String, Any}(String(value.first) => value.second) + end + d[String(s)] = value + return value +end + +function Base.propertynames(ps::ParameterSet) + return Symbol.(keys(getfield(ps, :data))) +end + +Base.show(io::IO, ps::ParameterSet) = + print(io, "ParameterSet($(length(ps.data)) keys: $(join(keys(ps.data), ", ")))") + +function _show_tree(io::IO, d::Dict{String, Any}, indent::Int) + for (k, v) in sort(collect(d); by=first) + if v isa Dict{String, Any} + print(io, " "^indent, k, "\n") + _show_tree(io, v, indent + 2) + else + print(io, " "^indent, k, " = ", repr(v), "\n") + end + end +end + +function Base.show(io::IO, ::MIME"text/plain", ps::ParameterSet) + path = getfield(ps, :path) + d = getfield(ps, :data) + if !isempty(path) + println(io, "ParameterSet (", path, ")") + else + println(io, "ParameterSet") + end + return _show_tree(io, d, 2) +end + +function _show_tree_md(io::IO, d::Dict{String, Any}, depth::Int) + prefix = " "^depth * "- " + for (k, v) in sort(collect(d); by=first) + if v isa Dict{String, Any} + print(io, prefix, "**", k, "**\n") + _show_tree_md(io, v, depth + 1) + else + print(io, prefix, k, " = `", repr(v), "`\n") + end + end +end + +function Base.show(io::IO, ::MIME"text/markdown", ps::ParameterSet) + path = getfield(ps, :path) + if !isempty(path) + println(io, "**ParameterSet** (", path, ")\n") + else + println(io, "**ParameterSet**\n") + end + return _show_tree_md(io, getfield(ps, :data), 0) +end + +function Base.show(io::IO, ::MIME"text/html", ps::ParameterSet) + path = getfield(ps, :path) + if !isempty(path) + print(io, "ParameterSet (", path, ")") + else + print(io, "ParameterSet") + end + return _show_tree_html(io, getfield(ps, :data)) +end + +function _show_tree_html(io::IO, d::Dict{String, Any}) + print(io, "
", repr(v), "300")
+ end
+end
+
+@testitem "SchematicGraph with ParameterSet" setup = [CommonTestSetup] begin
+ using DeviceLayout: ParameterSet
+ using DeviceLayout.SchematicDrivenLayout:
+ SchematicGraph, parameter_set, create_component
+
+ @testset "Default constructor" begin
+ g = SchematicGraph("test")
+ @test g.name == "test"
+ @test g.parameter_set === nothing
+ end
+
+ @testset "Constructor with ParameterSet" begin
+ ps = ParameterSet()
+ ps.global.version = 1
+ ps.components.qubit = ("cap_width" => 300)
+
+ g = SchematicGraph("test", ps)
+ @test g.parameter_set === ps
+ @test g.parameter_set.components.qubit.cap_width == 300
+ @test g.name == "test"
+ end
+
+ @testset "getproperty accesses parameter_set" begin
+ ps = ParameterSet()
+ g = SchematicGraph("test", ps)
+ @test g.parameter_set === ps
+ @test g.parameter_set isa ParameterSet
+ end
+
+ @testset "Copy constructor" begin
+ # Copy with ParameterSet preserves name and parameter_set
+ ps = ParameterSet()
+ ps.components.qubit = ("cap_width" => 300)
+ g = SchematicGraph("original", ps)
+ g_copy = SchematicGraph(g)
+ @test g_copy.name == "original"
+ @test g_copy.parameter_set === ps
+ @test g_copy.parameter_set.components.qubit.cap_width == 300
+
+ # Copy without ParameterSet
+ g_no_ps = SchematicGraph("bare")
+ g_no_ps_copy = SchematicGraph(g_no_ps)
+ @test g_no_ps_copy.name == "bare"
+ @test g_no_ps_copy.parameter_set === nothing
+
+ # Copy creates a fresh graph (independent nodes/edges)
+ @test g_copy !== g
+ end
+
+ @testset "parameter_set function" begin
+ # Returns nothing for graph without ParameterSet
+ g = SchematicGraph("test")
+ @test parameter_set(g) === nothing
+
+ # Returns the ParameterSet for graph with one
+ ps = ParameterSet()
+ ps.components.qubit = ("cap_width" => 300)
+ g = SchematicGraph("test", ps)
+ @test parameter_set(g) === ps
+ @test parameter_set(g).components.qubit.cap_width == 300
+ end
+
+ @testset "create_component with ParameterSet" begin
+ using DeviceLayout.SchematicDrivenLayout: parameters
+ using DeviceLayout.SchematicDrivenLayout.ExamplePDK.Transmons:
+ ExampleRectangleIsland
+
+ ps = ParameterSet()
+ ps.components.island = ("cap_width" => 30)
+ ps.components.island.cap_length = 400
+
+ # Create component from parameter set subtree
+ island = create_component(ExampleRectangleIsland, ps, "components.island")
+ @test island isa ExampleRectangleIsland
+ p = parameters(island)
+ @test p.cap_width == 30
+ @test p.cap_length == 400
+
+ # Accessed parameters are tracked
+ @test "components.island.cap_width" in ps.accessed
+ @test "components.island.cap_length" in ps.accessed
+ end
+end
+
+@testitem "ParameterSet YAML IO" setup = [CommonTestSetup] begin
+ using DeviceLayout: ParameterSet, resolve, leaf_params, save_parameter_set
+ using YAML
+ using Unitful: μm, ustrip, unit
+
+ @testset "save_parameter_set to IO" begin
+ ps = ParameterSet()
+ ps.global.version = 1
+ ps.components.cap.finger_length = 150μm
+ ps.components.cap.finger_count = 6
+
+ io = IOBuffer()
+ save_parameter_set(io, ps)
+ yaml_str = String(take!(io))
+
+ # Unitful quantities serialized as quoted unit strings
+ @test contains(yaml_str, "finger_length: \"150μm\"") ||
+ contains(yaml_str, "finger_length: \"150.0μm\"")
+ # Plain numbers stay as numbers
+ @test contains(yaml_str, "finger_count: 6")
+ @test contains(yaml_str, "version: 1")
+ end
+
+ @testset "ParameterSet from IO" begin
+ yaml_str = """
+ global:
+ version: 2
+ components:
+ qubit:
+ cap_width: 300μm
+ cap_gap: 20μm
+ finger_count: 4
+ """
+ io = IOBuffer(yaml_str)
+ ps = ParameterSet(io)
+
+ @test ps.global.version == 2
+ @test ps.components.qubit.cap_width == 300μm
+ @test ps.components.qubit.cap_gap == 20μm
+ @test ps.components.qubit.finger_count == 4
+ end
+
+ @testset "IO round-trip with Unitful" begin
+ ps = ParameterSet()
+ ps.global.process_node = "fab_v3"
+ ps.components.jj.w_jj = 1μm
+ ps.components.jj.h_jj = 0.5μm
+ ps.components.jj.count = 2
+
+ # Write
+ io = IOBuffer()
+ save_parameter_set(io, ps)
+ yaml_bytes = take!(io)
+
+ # Read back
+ ps2 = ParameterSet(IOBuffer(yaml_bytes))
+
+ @test ps2.global.process_node == "fab_v3"
+ @test ps2.components.jj.w_jj == 1μm
+ @test ps2.components.jj.h_jj == 0.5μm
+ @test ps2.components.jj.count == 2
+ end
+
+ @testset "ParameterSet from IO with path" begin
+ yaml_str = """
+ global:
+ version: 1
+ components:
+ res:
+ length: 500μm
+ """
+ io = IOBuffer(yaml_str)
+ ps = ParameterSet(io, "my_design.yaml")
+
+ @test ps.path == "my_design.yaml"
+ @test ps.components.res.length == 500μm
+ end
+
+ @testset "File round-trip" begin
+ ps = ParameterSet()
+ ps.global.version = 1
+ ps.components.cap.width = 150μm
+ ps.components.cap.gap = 3μm
+ ps.components.cap.count = 6
+
+ path = joinpath(tdir, "test_ps.yaml")
+ save_parameter_set(path, ps)
+
+ ps2 = ParameterSet(path)
+ @test ps2.path == path
+ @test ps2.global.version == 1
+ @test ps2.components.cap.width == 150μm
+ @test ps2.components.cap.gap == 3μm
+ @test ps2.components.cap.count == 6
+ end
+
+ @testset "Nested namespaces round-trip" begin
+ ps = ParameterSet()
+ ps.components.transmon.island.cap_length = 520μm
+ ps.components.transmon.island.cap_width = 24μm
+ ps.components.transmon.junction.w_jj = 1μm
+
+ io = IOBuffer()
+ save_parameter_set(io, ps)
+ ps2 = ParameterSet(IOBuffer(take!(io)))
+
+ @test ps2.components.transmon.island.cap_length == 520μm
+ @test ps2.components.transmon.island.cap_width == 24μm
+ @test ps2.components.transmon.junction.w_jj == 1μm
+ end
+end