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, "") +end + +""" + 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)) + +""" + 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/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..bd143f659 --- /dev/null +++ b/test/test_parameter_set.jl @@ -0,0 +1,367 @@ +@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 + + # 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 + 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 + + # 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 + 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() + 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