From 1c6ff55a547c29e96ee7be43ffa368fb50c9a351 Mon Sep 17 00:00:00 2001 From: Art Diky Date: Wed, 15 Apr 2026 13:28:11 -0700 Subject: [PATCH 1/4] Added ParameterSet for data-driven design parameterization Introduce `ParameterSet`, a mutable nested dictionary wrapper that provides dot-access syntax for reading and writing design parameters. Supports `global` and `components` namespaces, programmatic construction, and optional YAML serialization via a package extension. Key changes: - Add `ParameterSet` type with `resolve` and `leaf_params` utilities - Add `ParameterSetYAMLExt` weak dep extension for YAML load/save - Integrate `parameter_set` into SchematicDrivenLayout and `@component` - Add comprehensive API reference documentation - Register YAML as optional dependency in Project.toml --- Project.toml | 3 + docs/make.jl | 1 + docs/src/reference/index.md | 1 + docs/src/reference/parameter_set.md | 280 ++++++++++++++++++ ext/ParameterSetYAMLExt.jl | 20 ++ src/DeviceLayout.jl | 3 + src/parameter_set.jl | 139 +++++++++ src/schematics/SchematicDrivenLayout.jl | 4 + .../components/builtin_components.jl | 6 +- src/schematics/components/components.jl | 27 ++ .../components/composite_components.jl | 2 +- src/schematics/schematics.jl | 25 +- test/runtests.jl | 3 +- test/test_parameter_set.jl | 184 ++++++++++++ 14 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 docs/src/reference/parameter_set.md create mode 100644 ext/ParameterSetYAMLExt.jl create mode 100644 src/parameter_set.jl create mode 100644 test/test_parameter_set.jl diff --git a/Project.toml b/Project.toml index 87fc21e43..821cb120d 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" 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..cf508ffb4 --- /dev/null +++ b/docs/src/reference/parameter_set.md @@ -0,0 +1,280 @@ +# 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 using Pair syntax +ps.components.capacitor = ("finger_length" => 150) +ps.components.capacitor.finger_width = 5 +ps.components.capacitor.finger_gap = 3 +ps.components.capacitor.finger_count = 6 + +ps.components.junction = ("w_jj" => 1) +ps.components.junction.h_jj = 1 +``` + +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 + finger_width: 5 + finger_gap: 3 + finger_count: 6 + junction: + w_jj: 1 + h_jj: 1 +``` + +```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 +``` + +Or use `resolve` with a dot-separated address: + +```julia +resolve(ps, "components.capacitor.finger_length") # => 150 +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, finger_width = 5, finger_gap = 3, 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 +``` + +A full example with simple components: + +```julia +# Load parameters +ps = ParameterSet() +ps.components.cap1 = ("finger_length" => 150) +ps.components.cap1.finger_count = 6 +ps.components.cap2 = ("finger_length" => 200) +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. + +Consider a composite transmon with island and junction subcomponents: + +```julia +@compdef struct SimpleTransmon <: CompositeComponent + name = "transmon" + cap_width = 24μm + cap_length = 520μm + cap_gap = 30μm + junction_gap = 12μm + w_jj = 1μm + h_jj = 1μm +end +``` + +Define the parameter set with nested component trees — including subcomponent +parameters under the transmon namespace: + +```julia +ps = ParameterSet() + +# Top-level transmon parameters +ps.components.transmon = ("cap_width" => 24) +ps.components.transmon.cap_length = 520 +ps.components.transmon.cap_gap = 30 +ps.components.transmon.junction_gap = 12 + +# Subcomponent parameters nested under transmon +ps.components.transmon.island = ("cap_width" => 24) +ps.components.transmon.island.cap_length = 520 +ps.components.transmon.island.cap_gap = 30 +ps.components.transmon.island.junction_gap = 12 + +ps.components.transmon.junction = ("w_jj" => 1) +ps.components.transmon.junction.h_jj = 1 +ps.components.transmon.junction.h_ground_island = 12 +``` + +Inside `_build_subcomponents`, use the graph's `parameter_set` to create +subcomponents from their respective parameter subtrees: + +```julia +function SchematicDrivenLayout._build_subcomponents(tr::SimpleTransmon) + ps = parameter_set(tr._graph) + + if !isnothing(ps) + # Create subcomponents from parameter set subtrees + @component island = create_component( + ExampleRectangleIsland, ps, "components.transmon.island" + ) + @component junction = create_component( + ExampleSimpleJunction, ps, "components.transmon.junction" + ) + else + # Fallback to direct parameter forwarding + @component island = ExampleRectangleIsland( + cap_width=tr.cap_width, cap_length=tr.cap_length, + cap_gap=tr.cap_gap, junction_gap=tr.junction_gap, + ) + @component junction = ExampleSimpleJunction( + w_jj=tr.w_jj, h_jj=tr.h_jj, + h_ground_island=tr.junction_gap, + ) + end + + return (island, junction) +end +``` + +When a `ParameterSet` is attached, each subcomponent is instantiated from its +own subtree (`"components.transmon.island"`, `"components.transmon.junction"`). +The fallback path preserves backward compatibility for cases where no +`ParameterSet` is provided. + +Create the top-level graph and the 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) +ps.components.qubit.cap_gap = 20 + +# Nothing accessed yet +isempty(ps.accessed) # => true + +# Read a parameter +ps.components.qubit.cap_width # => 300 +"cap_width" in ps.accessed # => true + +# Tracking is shared across scoped views +sub = ps.components.qubit +sub.cap_gap # => 20 +"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..0b8388d06 --- /dev/null +++ b/ext/ParameterSetYAMLExt.jl @@ -0,0 +1,20 @@ +module ParameterSetYAMLExt + +import DeviceLayout: ParameterSet +import YAML + +""" + ParameterSet(path::String) + +Load a `ParameterSet` from a YAML file at `path`. + +Requires `YAML.jl` to be loaded (`using YAML`). +""" +function ParameterSet(path::String) + data = YAML.load_file(path; dicttype=Dict{String, Any}) + ps = ParameterSet(data) + # Replace with path-aware instance (ParameterSet is immutable, path is set to "" by Dict ctor) + return DeviceLayout.ParameterSet(path, ps.data, ps.accessed) +end + +end # module ParameterSetYAMLExt diff --git a/src/DeviceLayout.jl b/src/DeviceLayout.jl index 560eff75b..36da9361e 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 + include("schematics/SchematicDrivenLayout.jl") export SchematicDrivenLayout diff --git a/src/parameter_set.jl b/src/parameter_set.jl new file mode 100644 index 000000000..97c214d55 --- /dev/null +++ b/src/parameter_set.jl @@ -0,0 +1,139 @@ +""" + 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 + +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(data::Dict{String, Any}) + _ensure_required_namespaces!(data) + return ParameterSet("", data, Set{String}()) +end + +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) + haskey(d, key) || error("ParameterSet has no key :$s. Available keys: $(keys(d))") + + 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), ", ")))") + +""" + resolve(ps::ParameterSet, address::String) + +Navigate a dot-separated address within the `ParameterSet`. + +Returns the value at the address — either a scoped `ParameterSet` (if the value is a `Dict`) +or a leaf value. + +# Examples + +```julia +ps = ParameterSet( + Dict{String, Any}( + "global" => Dict{String, Any}(), + "components" => + Dict{String, Any}("qubit" => Dict{String, Any}("cap_width" => 300)) + ) +) +resolve(ps, "components.qubit.cap_width") # => 300 +resolve(ps, "components.qubit") # => ParameterSet scoped to qubit +``` +""" +function resolve(ps::ParameterSet, address::String) + current = ps + for seg in split(address, '.') + current = getproperty(current, Symbol(seg)) + end + return current +end + +""" + leaf_params(ps::ParameterSet) + leaf_params(d::Dict) + +Extract non-`Dict` entries as a `NamedTuple` (the "leaf" parameters at this level). +`Dict` entries (namespace segments) are excluded. + +# Examples + +```julia +ps = ParameterSet( + Dict{String, Any}( + "global" => Dict{String, Any}(), + "components" => Dict{String, Any}("cap_width" => 300, "cap_gap" => 20) + ) +) +leaf_params(ps.components) # => (cap_width = 300, cap_gap = 20) +``` +""" +function leaf_params(d::Dict) + pairs_list = [Symbol(k) => v for (k, v) in d if !(v isa Dict)] + isempty(pairs_list) && return (;) + return NamedTuple(pairs_list) +end + +leaf_params(ps::ParameterSet) = leaf_params(getfield(ps, :data)) diff --git a/src/schematics/SchematicDrivenLayout.jl b/src/schematics/SchematicDrivenLayout.jl index 18259d909..84f3cc0a3 100644 --- a/src/schematics/SchematicDrivenLayout.jl +++ b/src/schematics/SchematicDrivenLayout.jl @@ -30,9 +30,11 @@ import DeviceLayout: GeometryStructure, Hook, Meta, + ParameterSet, PointHook, Transformation, UPREFERRED +import DeviceLayout: resolve, leaf_params import DeviceLayout: attach!, autofill!, @@ -104,12 +106,14 @@ export @component, origin, parameters, parameter_names, + parameter_set, plan, position_dependent_replace!, rem_node!, replace_component!, route!, set_parameters +export ParameterSet export ProcessTechnology, SimulationTarget, ArtworkTarget, SolidModelTarget export base_variant, flipchip!, map_metadata!, @composite_variant, @variant diff --git a/src/schematics/components/builtin_components.jl b/src/schematics/components/builtin_components.jl index e2c4f3650..e78e43284 100644 --- a/src/schematics/components/builtin_components.jl +++ b/src/schematics/components/builtin_components.jl @@ -222,7 +222,7 @@ struct BasicCompositeComponent{T} <: AbstractCompositeComponent{T} _schematic::Schematic{T} _hooks::Dict{Symbol, Union{Hook, Vector{<:Hook}}} function BasicCompositeComponent(g::SchematicGraph; coordtype=typeof(1.0UPREFERRED)) - newg = SchematicGraph(name(g)) + newg = SchematicGraph(g) add_graph!(newg, g; id_prefix="") sch = Schematic{coordtype}(newg; log_dir=nothing) sch.coordinate_system.name = uniquename(name(g)) @@ -239,7 +239,9 @@ function (cc::BasicCompositeComponent)( ) length(param_sets) == 0 && (param_sets = Tuple(repeat([(;)], length(components(cc))))) idx_param_values = [(decompose_basic_composite(k)..., v) for (k, v) in kwargs] - cc2 = BasicCompositeComponent(SchematicGraph(compname); coordtype=coordinatetype(cc)) + ps = cc.graph.parameter_set + new_g = isnothing(ps) ? SchematicGraph(compname) : SchematicGraph(compname, ps) + cc2 = BasicCompositeComponent(new_g; coordtype=coordinatetype(cc)) g2 = graph(cc2) add_graph!(g2, cc.graph; id_prefix="") for (idx, p) in enumerate(param_sets) diff --git a/src/schematics/components/components.jl b/src/schematics/components/components.jl index 35f8aaac2..10e6ac702 100644 --- a/src/schematics/components/components.jl +++ b/src/schematics/components/components.jl @@ -51,6 +51,33 @@ function create_component( return (T)(; p...) end +""" + create_component(::Type{T}, ps::ParameterSet, address::String) where {T <: AbstractComponent} + +Create an instance of type `T` using parameters from a `ParameterSet` at the given `address`. + +The address is resolved within the `ParameterSet` to locate a parameter subtree. +Leaf parameters (non-`Dict` values) at that level are extracted and merged with +`default_parameters(T)` via recursive merge. + +Consumed parameters are tracked in `ps.accessed`. +""" +function create_component( + ::Type{T}, + ps::ParameterSet, + address::String +) where {T <: AbstractComponent} + sub = resolve(ps, address) + kw = leaf_params(sub) + # Track accessed parameter paths + for k in keys(kw) + if k in parameter_names(T) + push!(ps.accessed, address * "." * String(k)) + end + end + return create_component(T; pairs(kw)...) +end + """ (c::AbstractComponent)( name::String=name(c), diff --git a/src/schematics/components/composite_components.jl b/src/schematics/components/composite_components.jl index 50155d034..fb7acd1ae 100644 --- a/src/schematics/components/composite_components.jl +++ b/src/schematics/components/composite_components.jl @@ -273,7 +273,7 @@ function DeviceLayout.flatten(g::SchematicGraph; depth=-1) end function _flatten(g::SchematicGraph, depth) - g2 = SchematicGraph(g.name) + g2 = SchematicGraph(g) for (k, v) in g.namecounter g2.namecounter[k] = v end diff --git a/src/schematics/schematics.jl b/src/schematics/schematics.jl index f5fcc3f95..da082ea6f 100644 --- a/src/schematics/schematics.jl +++ b/src/schematics/schematics.jl @@ -52,17 +52,37 @@ struct SchematicGraph <: AbstractMetaGraph{Int} namecounter::Dict{String, Int} node_dict::Dict{Symbol, ComponentNode} nodes::Vector{ComponentNode} + parameter_set::Union{Nothing, ParameterSet} end SchematicGraph(name::String) = SchematicGraph( name, MetaGraph(), Dict{String, Int}(), Dict{Symbol, ComponentNode}(), - ComponentNode[] + ComponentNode[], + nothing ) +SchematicGraph(name::String, ps::ParameterSet) = SchematicGraph( + name, + MetaGraph(), + Dict{String, Int}(), + Dict{Symbol, ComponentNode}(), + ComponentNode[], + ps +) + +""" + SchematicGraph(g::SchematicGraph) + +Copy constructor — creates a new empty `SchematicGraph` with the same name +and `ParameterSet` as `g`. +""" +SchematicGraph(g::SchematicGraph) = + isnothing(g.parameter_set) ? SchematicGraph(g.name) : + SchematicGraph(g.name, g.parameter_set) function Base.getproperty(g::SchematicGraph, s::Symbol) - if s in (:name, :graph, :namecounter, :node_dict, :nodes) + if s in (:name, :graph, :namecounter, :node_dict, :nodes, :parameter_set) return getfield(g, s) elseif s in keys(getfield(g, :node_dict)) return getfield(g, :node_dict)[s] @@ -77,6 +97,7 @@ MetaGraphs.weighttype(g::SchematicGraph) = MetaGraphs.weighttype(g.graph) nodes(g::SchematicGraph) = g.nodes components(g::SchematicGraph) = component.(nodes(g)) name(g::SchematicGraph) = g.name +parameter_set(g::SchematicGraph) = g.parameter_set """ indexof(n::ComponentNode, g::SchematicGraph) diff --git a/test/runtests.jl b/test/runtests.jl index 11fdc5b2e..2a2c122eb 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -40,4 +40,5 @@ using TestItemRunner end end -@run_package_tests +@run_package_tests filter = + ti -> (isempty(ARGS) || any(arg -> occursin(arg, ti.name), ARGS)) diff --git a/test/test_parameter_set.jl b/test/test_parameter_set.jl new file mode 100644 index 000000000..2b92191a3 --- /dev/null +++ b/test/test_parameter_set.jl @@ -0,0 +1,184 @@ +@testitem "ParameterSet" setup = [CommonTestSetup] begin + using DeviceLayout: ParameterSet, resolve, leaf_params + using DeviceLayout.SchematicDrivenLayout: SchematicGraph + + @testset "Construction" begin + # Empty ParameterSet has required namespaces + ps = ParameterSet() + @test haskey(ps.data, "global") + @test haskey(ps.data, "components") + @test ps.data["global"] isa Dict + @test ps.data["components"] isa Dict + @test ps.path == "" + + # From Dict — required namespaces are added if missing + ps = ParameterSet(Dict{String, Any}("custom" => 42)) + @test haskey(ps.data, "global") + @test haskey(ps.data, "components") + @test ps.data["custom"] == 42 + + # From Dict — existing namespaces are preserved + ps = ParameterSet() + ps.global.version = 1 + ps.components.qubit = ("cap_width" => 300) + @test ps.global.version == 1 + @test ps.components.qubit.cap_width == 300 + end + + @testset "Dot access" begin + ps = ParameterSet() + ps.global.version = 1 + ps.components.qubit = ("cap_width" => 300) + ps.components.qubit.cap_gap = 20 + + # Namespace access returns scoped ParameterSet + qubit_ps = ps.components.qubit + @test qubit_ps isa ParameterSet + + # Leaf access returns value + @test ps.components.qubit.cap_width == 300 + @test ps.components.qubit.cap_gap == 20 + @test ps.global.version == 1 + + # Error on missing key + @test_throws ErrorException ps.nonexistent + @test_throws ErrorException ps.components.qubit.missing_param + end + + @testset "resolve" begin + ps = ParameterSet() + ps.components.qubit = ("cap_width" => 300) + + # Resolve to subtree + qubit_ps = resolve(ps, "components.qubit") + @test qubit_ps isa ParameterSet + + # Resolve to leaf + @test resolve(ps, "components.qubit.cap_width") == 300 + + # Resolve to namespace + comp_ps = resolve(ps, "components") + @test comp_ps isa ParameterSet + end + + @testset "leaf_params" begin + ps = ParameterSet() + ps.components.cap_width = 300 + ps.components.cap_gap = 20 + ps.components.junction = ("width" => 200) + + # Extracts only non-Dict entries + lp = leaf_params(ps.components) + @test :cap_width in keys(lp) + @test :cap_gap in keys(lp) + @test !(:junction in keys(lp)) + @test lp.cap_width == 300 + @test lp.cap_gap == 20 + + # Empty dict returns empty NamedTuple + lp_empty = leaf_params(ps.global) + @test lp_empty == (;) + end + + @testset "Access tracking" begin + ps = ParameterSet() + ps.components.qubit = ("cap_width" => 300) + ps.components.qubit.cap_gap = 20 + + # Tracked set is initially empty + @test isempty(ps.accessed) + + # Accessing a leaf tracks it + _ = ps.components.qubit.cap_width + @test "cap_width" in ps.accessed + + # Tracking is shared across scoped views + sub = ps.components.qubit + _ = sub.cap_gap + @test "cap_gap" in ps.accessed + end + + @testset "Dot-access mutation" begin + ps = ParameterSet() + + # Set a leaf value + ps.global.version = 1 + @test ps.global.version == 1 + + # Set a new namespace + ps.components.qubit = ("cap_width" => 300) + @test ps.components.qubit.cap_width == 300 + + # Overwrite a leaf value + ps.components.qubit.cap_width = 500 + @test ps.components.qubit.cap_width == 500 + end + + @testset "propertynames" begin + ps = ParameterSet() + ps.extra = 42 + pnames = propertynames(ps) + @test :global in pnames + @test :components in pnames + @test :extra in pnames + end + + @testset "show" begin + ps = ParameterSet() + io = IOBuffer() + show(io, ps) + s = String(take!(io)) + @test contains(s, "ParameterSet") + @test contains(s, "global") + @test contains(s, "components") + end +end + +@testitem "SchematicGraph with ParameterSet" setup = [CommonTestSetup] begin + using DeviceLayout: ParameterSet + using DeviceLayout.SchematicDrivenLayout: SchematicGraph + + @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 +end From 6c2c017fc3793219aa7adf767e7f7bb8015d4ced Mon Sep 17 00:00:00 2001 From: Art Diky Date: Wed, 15 Apr 2026 14:04:48 -0700 Subject: [PATCH 2/4] docs: simplify ParameterSet examples and composite component pattern Replace Pair syntax with dot-access for setting initial parameters, rewrite composite component section to show cleaner pattern where subcomponent parameters live in the ParameterSet rather than the composite struct, remove fallback dual-path (if/else isnothing) code, and update YAML examples to match the simplified structure. --- docs/src/reference/parameter_set.md | 90 ++++++++++++----------------- src/parameter_set.jl | 5 +- test/test_parameter_set.jl | 48 +++++++++++++-- 3 files changed, 85 insertions(+), 58 deletions(-) diff --git a/docs/src/reference/parameter_set.md b/docs/src/reference/parameter_set.md index cf508ffb4..30450436b 100644 --- a/docs/src/reference/parameter_set.md +++ b/docs/src/reference/parameter_set.md @@ -1,10 +1,10 @@ # 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. +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 +- **`global`** - parameters shared across the design (e.g., version, process node) +- **`components`** - per-component parameter trees ## Tutorial: Using External Parameters @@ -31,12 +31,12 @@ ps.global.version = 1 ps.global.process_node = "fab_v3" # Define component parameters using Pair syntax -ps.components.capacitor = ("finger_length" => 150) +ps.components.capacitor.finger_length = 150 ps.components.capacitor.finger_width = 5 ps.components.capacitor.finger_gap = 3 ps.components.capacitor.finger_count = 6 -ps.components.junction = ("w_jj" => 1) +ps.components.junction.w_jj = 1 ps.components.junction.h_jj = 1 ``` @@ -137,9 +137,9 @@ A full example with simple components: ```julia # Load parameters ps = ParameterSet() -ps.components.cap1 = ("finger_length" => 150) +ps.components.cap1.finger_length = 150 ps.components.cap1.finger_count = 6 -ps.components.cap2 = ("finger_length" => 200) +ps.components.cap2.finger_length = 200 ps.components.cap2.finger_count = 8 # Create graph with parameter set @@ -162,80 +162,64 @@ 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. -Consider a composite transmon with island and junction subcomponents: +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" - cap_width = 24μm - cap_length = 520μm - cap_gap = 30μm - junction_gap = 12μm - w_jj = 1μm - h_jj = 1μm + junction_gap = 12μm # shared: controls both island gap and junction height end ``` -Define the parameter set with nested component trees — including subcomponent -parameters under the transmon namespace: +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() -# Top-level transmon parameters -ps.components.transmon = ("cap_width" => 24) -ps.components.transmon.cap_length = 520 -ps.components.transmon.cap_gap = 30 ps.components.transmon.junction_gap = 12 -# Subcomponent parameters nested under transmon -ps.components.transmon.island = ("cap_width" => 24) +ps.components.transmon.island.cap_width = 24 ps.components.transmon.island.cap_length = 520 ps.components.transmon.island.cap_gap = 30 -ps.components.transmon.island.junction_gap = 12 -ps.components.transmon.junction = ("w_jj" => 1) +ps.components.transmon.junction.w_jj = 1 ps.components.transmon.junction.h_jj = 1 -ps.components.transmon.junction.h_ground_island = 12 ``` -Inside `_build_subcomponents`, use the graph's `parameter_set` to create -subcomponents from their respective parameter subtrees: +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) - if !isnothing(ps) - # Create subcomponents from parameter set subtrees - @component island = create_component( - ExampleRectangleIsland, ps, "components.transmon.island" - ) - @component junction = create_component( - ExampleSimpleJunction, ps, "components.transmon.junction" - ) - else - # Fallback to direct parameter forwarding - @component island = ExampleRectangleIsland( - cap_width=tr.cap_width, cap_length=tr.cap_length, - cap_gap=tr.cap_gap, junction_gap=tr.junction_gap, - ) - @component junction = ExampleSimpleJunction( - w_jj=tr.w_jj, h_jj=tr.h_jj, - h_ground_island=tr.junction_gap, - ) - end + @component island = create_component( + ExampleRectangleIsland, ps, "components.transmon.island" + ) + # Forward shared parameter to island + island = set_parameters(island; junction_gap=tr.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=tr.junction_gap) return (island, junction) end ``` -When a `ParameterSet` is attached, each subcomponent is instantiated from its -own subtree (`"components.transmon.island"`, `"components.transmon.junction"`). -The fallback path preserves backward compatibility for cases where no -`ParameterSet` is provided. +`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 the composite component: +Create the top-level graph and composite component: ```julia g = SchematicGraph("chip", ps) @@ -244,7 +228,7 @@ transmon = create_component(SimpleTransmon, ps, "components.transmon") transmon_node = add_node!(g, transmon) ``` -The `ParameterSet` is preserved when graphs are copied — for example, inside +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. @@ -255,7 +239,7 @@ of unused or missing parameters: ```julia ps = ParameterSet() -ps.components.qubit = ("cap_width" => 300) +ps.components.qubit.cap_width = 300 ps.components.qubit.cap_gap = 20 # Nothing accessed yet diff --git a/src/parameter_set.jl b/src/parameter_set.jl index 97c214d55..b347f57a6 100644 --- a/src/parameter_set.jl +++ b/src/parameter_set.jl @@ -53,7 +53,10 @@ function Base.getproperty(ps::ParameterSet, s::Symbol) d = getfield(ps, :data) key = String(s) - haskey(d, key) || error("ParameterSet has no key :$s. Available keys: $(keys(d))") + if !haskey(d, key) + # Auto-vivify: create intermediate namespace so chained dot-access works + d[key] = Dict{String, Any}() + end val = d[key] if val isa Dict diff --git a/test/test_parameter_set.jl b/test/test_parameter_set.jl index 2b92191a3..d0e77f073 100644 --- a/test/test_parameter_set.jl +++ b/test/test_parameter_set.jl @@ -40,9 +40,9 @@ @test ps.components.qubit.cap_gap == 20 @test ps.global.version == 1 - # Error on missing key - @test_throws ErrorException ps.nonexistent - @test_throws ErrorException ps.components.qubit.missing_param + # Missing key auto-vivifies an empty namespace + @test ps.nonexistent isa ParameterSet + @test ps.components.qubit.missing_ns isa ParameterSet end @testset "resolve" begin @@ -112,6 +112,11 @@ # Overwrite a leaf value ps.components.qubit.cap_width = 500 @test ps.components.qubit.cap_width == 500 + + # Chained auto-vivification for deep paths + ps2 = ParameterSet() + ps2.components.transmon.island.cap_length = 520 + @test ps2.components.transmon.island.cap_length == 520 end @testset "propertynames" begin @@ -136,7 +141,8 @@ end @testitem "SchematicGraph with ParameterSet" setup = [CommonTestSetup] begin using DeviceLayout: ParameterSet - using DeviceLayout.SchematicDrivenLayout: SchematicGraph + using DeviceLayout.SchematicDrivenLayout: + SchematicGraph, parameter_set, create_component @testset "Default constructor" begin g = SchematicGraph("test") @@ -181,4 +187,38 @@ end # 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 From 14a5fb056672704f0799cec39741c27f17a02afd Mon Sep 17 00:00:00 2001 From: Art Diky Date: Wed, 15 Apr 2026 14:52:23 -0700 Subject: [PATCH 3/4] feat: add MissingNamespace and ParameterKeyError for ParameterSet Replace silent auto-vivification of missing keys with a MissingNamespace sentinel that supports chained dot-writes but throws a descriptive ParameterKeyError when used as a value. Add tree-style pretty printing, a validate() function to detect unaccessed parameters, and comprehensive tests for the new error handling and display behavior. Update docs to use explicit parameter set paths for shared parameter forwarding. --- docs/src/reference/parameter_set.md | 6 +- src/DeviceLayout.jl | 2 +- src/parameter_set.jl | 156 +++++++++++++++++++++++++++- test/test_parameter_set.jl | 37 ++++++- 4 files changed, 192 insertions(+), 9 deletions(-) diff --git a/docs/src/reference/parameter_set.md b/docs/src/reference/parameter_set.md index 30450436b..dcbad973b 100644 --- a/docs/src/reference/parameter_set.md +++ b/docs/src/reference/parameter_set.md @@ -202,14 +202,14 @@ function SchematicDrivenLayout._build_subcomponents(tr::SimpleTransmon) @component island = create_component( ExampleRectangleIsland, ps, "components.transmon.island" ) - # Forward shared parameter to island - island = set_parameters(island; junction_gap=tr.junction_gap) + # 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=tr.junction_gap) + junction = set_parameters(junction; h_ground_island=ps.components.transmon.junction_gap) return (island, junction) end diff --git a/src/DeviceLayout.jl b/src/DeviceLayout.jl index 36da9361e..39e4a7d6f 100644 --- a/src/DeviceLayout.jl +++ b/src/DeviceLayout.jl @@ -617,7 +617,7 @@ export PolyText, scripted_demo include("parameter_set.jl") -export ParameterSet +export ParameterSet, MissingNamespace, ParameterKeyError include("schematics/SchematicDrivenLayout.jl") export SchematicDrivenLayout diff --git a/src/parameter_set.jl b/src/parameter_set.jl index b347f57a6..2b11dd153 100644 --- a/src/parameter_set.jl +++ b/src/parameter_set.jl @@ -30,6 +30,91 @@ mutable struct ParameterSet 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}) @@ -54,8 +139,7 @@ function Base.getproperty(ps::ParameterSet, s::Symbol) d = getfield(ps, :data) key = String(s) if !haskey(d, key) - # Auto-vivify: create intermediate namespace so chained dot-access works - d[key] = Dict{String, Any}() + return MissingNamespace(d, key, getfield(ps, :accessed)) end val = d[key] @@ -84,6 +168,74 @@ 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, "
    ") + for (k, v) in sort(collect(d); by=first) + if v isa Dict{String, Any} + print(io, "
  • ", k, "") + _show_tree_html(io, v) + print(io, "
  • ") + else + print(io, "
  • ", k, " = ", repr(v), "
  • ") + end + end + return print(io, "
") +end + """ resolve(ps::ParameterSet, address::String) diff --git a/test/test_parameter_set.jl b/test/test_parameter_set.jl index d0e77f073..2c44b1754 100644 --- a/test/test_parameter_set.jl +++ b/test/test_parameter_set.jl @@ -40,9 +40,12 @@ @test ps.components.qubit.cap_gap == 20 @test ps.global.version == 1 - # Missing key auto-vivifies an empty namespace - @test ps.nonexistent isa ParameterSet - @test ps.components.qubit.missing_ns isa ParameterSet + # Reading a missing key shows error (no exception from show) + @test contains(string(ps.nonexistent), "ParameterKeyError") + @test contains(string(ps.components.qubit.missing_param), "ParameterKeyError") + + # Using a missing key as a value throws + @test_throws DeviceLayout.ParameterKeyError iterate(ps.nonexistent) end @testset "resolve" begin @@ -130,12 +133,40 @@ @testset "show" begin ps = ParameterSet() + ps.components.qubit.cap_width = 300 + + # Compact show (single line) io = IOBuffer() show(io, ps) s = String(take!(io)) @test contains(s, "ParameterSet") @test contains(s, "global") @test contains(s, "components") + + # text/plain — indented tree (top namespaces indented) + io = IOBuffer() + show(io, MIME("text/plain"), ps) + s = String(take!(io)) + @test contains(s, "ParameterSet") + @test contains(s, " components") + @test contains(s, " qubit") + @test contains(s, " cap_width = 300") + + # text/markdown — nested list + io = IOBuffer() + show(io, MIME("text/markdown"), ps) + s = String(take!(io)) + @test contains(s, "**ParameterSet**") + @test contains(s, "- **components**") + @test contains(s, " - cap_width = `300`") + + # text/html — nested
    + io = IOBuffer() + show(io, MIME("text/html"), ps) + s = String(take!(io)) + @test contains(s, "ParameterSet") + @test contains(s, "qubit") + @test contains(s, "cap_width = 300") end end From 479d4dc5d6a397f97ad3f05cc87cae96c3e76c72 Mon Sep 17 00:00:00 2001 From: Art Diky Date: Wed, 15 Apr 2026 16:09:37 -0700 Subject: [PATCH 4/4] feat: add unit-aware YAML parsing for ParameterSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support parsing unit-suffixed values (e.g., "150μm") in YAML files loaded into ParameterSet. Values with recognized Unitful suffixes are automatically converted to proper quantities. Update documentation examples to use unitful parameters and add comprehensive tests for YAML round-tripping with units. --- Project.toml | 3 +- docs/src/reference/parameter_set.md | 54 +++++++------- ext/ParameterSetYAMLExt.jl | 68 +++++++++++++++-- src/parameter_set.jl | 25 ++++++- test/test_parameter_set.jl | 112 ++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 38 deletions(-) diff --git a/Project.toml b/Project.toml index 821cb120d..339f18d20 100644 --- a/Project.toml +++ b/Project.toml @@ -86,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/src/reference/parameter_set.md b/docs/src/reference/parameter_set.md index dcbad973b..634f368e3 100644 --- a/docs/src/reference/parameter_set.md +++ b/docs/src/reference/parameter_set.md @@ -30,14 +30,14 @@ ps = ParameterSet() ps.global.version = 1 ps.global.process_node = "fab_v3" -# Define component parameters using Pair syntax -ps.components.capacitor.finger_length = 150 -ps.components.capacitor.finger_width = 5 -ps.components.capacitor.finger_gap = 3 +# 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 -ps.components.junction.h_jj = 1 +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: @@ -50,13 +50,13 @@ global: components: capacitor: - finger_length: 150 - finger_width: 5 - finger_gap: 3 + finger_length: 150μm + finger_width: 5μm + finger_gap: 3μm finger_count: 6 junction: - w_jj: 1 - h_jj: 1 + w_jj: 1μm + h_jj: 1μm ``` ```julia @@ -71,13 +71,13 @@ 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 +ps.components.capacitor.finger_length # => 150μm ``` Or use `resolve` with a dot-separated address: ```julia -resolve(ps, "components.capacitor.finger_length") # => 150 +resolve(ps, "components.capacitor.finger_length") # => 150μm resolve(ps, "components.capacitor") # => scoped ParameterSet ``` @@ -85,7 +85,7 @@ Extract all leaf parameters at a level as a `NamedTuple`: ```julia leaf_params(ps.components.capacitor) -# => (finger_length = 150, finger_width = 5, finger_gap = 3, finger_count = 6) +# => (finger_length = 150μm, finger_width = 5μm, finger_gap = 3μm, finger_count = 6) ``` ### Simple Components with ParameterSet @@ -129,7 +129,7 @@ in the graph can access it: g = SchematicGraph("my_design", ps) # The parameter set is accessible from the graph -g.parameter_set.components.capacitor.finger_length # => 150 +g.parameter_set.components.capacitor.finger_length # => 150μm ``` A full example with simple components: @@ -137,9 +137,9 @@ A full example with simple components: ```julia # Load parameters ps = ParameterSet() -ps.components.cap1.finger_length = 150 +ps.components.cap1.finger_length = 150μm ps.components.cap1.finger_count = 6 -ps.components.cap2.finger_length = 200 +ps.components.cap2.finger_length = 200μm ps.components.cap2.finger_count = 8 # Create graph with parameter set @@ -180,14 +180,14 @@ subcomponents in `_build_subcomponents`: ```julia ps = ParameterSet() -ps.components.transmon.junction_gap = 12 +ps.components.transmon.junction_gap = 12μm -ps.components.transmon.island.cap_width = 24 -ps.components.transmon.island.cap_length = 520 -ps.components.transmon.island.cap_gap = 30 +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 -ps.components.transmon.junction.h_jj = 1 +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 @@ -239,19 +239,19 @@ of unused or missing parameters: ```julia ps = ParameterSet() -ps.components.qubit.cap_width = 300 -ps.components.qubit.cap_gap = 20 +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 +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 +sub.cap_gap # => 20μm "cap_gap" in ps.accessed # => true ``` diff --git a/ext/ParameterSetYAMLExt.jl b/ext/ParameterSetYAMLExt.jl index 0b8388d06..e63b4a42f 100644 --- a/ext/ParameterSetYAMLExt.jl +++ b/ext/ParameterSetYAMLExt.jl @@ -1,20 +1,74 @@ module ParameterSetYAMLExt import DeviceLayout: ParameterSet +import DeviceLayout import YAML +import Unitful """ - ParameterSet(path::String) + _parse_units!(data::Dict{String, Any}) -Load a `ParameterSet` from a YAML file at `path`. +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} -Requires `YAML.jl` to be loaded (`using YAML`). +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) - data = YAML.load_file(path; dicttype=Dict{String, Any}) - ps = ParameterSet(data) - # Replace with path-aware instance (ParameterSet is immutable, path is set to "" by Dict ctor) - return DeviceLayout.ParameterSet(path, ps.data, ps.accessed) + 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/parameter_set.jl b/src/parameter_set.jl index 2b11dd153..1b8c06d56 100644 --- a/src/parameter_set.jl +++ b/src/parameter_set.jl @@ -126,11 +126,11 @@ function _ensure_required_namespaces!(data::Dict{String, Any}) return data end -function ParameterSet(data::Dict{String, Any}) +function ParameterSet(path::String, data::Dict{String, Any}) _ensure_required_namespaces!(data) - return ParameterSet("", data, Set{String}()) + 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) @@ -292,3 +292,22 @@ function leaf_params(d::Dict) end leaf_params(ps::ParameterSet) = leaf_params(getfield(ps, :data)) + +""" + save_parameter_set(path::String, ps::ParameterSet) + save_parameter_set(io::IO, ps::ParameterSet) + +Save a `ParameterSet` to a YAML file at `path` or write YAML to an `IO` stream. + +`Unitful.Quantity` values are serialized as `""` (e.g. `"150μm"`) +for lossless round-tripping. + +Requires `YAML.jl` to be loaded (`using YAML`). + + ParameterSet(io::IO) + +Load a `ParameterSet` from a YAML IO stream. + +Requires `YAML.jl` to be loaded (`using YAML`). +""" +function save_parameter_set end diff --git a/test/test_parameter_set.jl b/test/test_parameter_set.jl index 2c44b1754..bd143f659 100644 --- a/test/test_parameter_set.jl +++ b/test/test_parameter_set.jl @@ -253,3 +253,115 @@ end @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