From 424a7df8b2e7c7ae42f8b060a2e82b1870660b37 Mon Sep 17 00:00:00 2001 From: Greg Peairs Date: Wed, 13 May 2026 03:22:40 +0200 Subject: [PATCH 01/15] Add check_port_connectivity Wraps the public `connected_components` from PR #190 to classify lumped ports as `:open`, `:short`, or `:floating` depending on how many disconnected metal components they bridge. Three-valued `Dict{String, Symbol}` return from `check_port_connectivity`. Also fixes connected_components(dim, tags) n==1 shape inconsistency. Classify one-boundary-touching-metal ports as floating Remove is_port_open and redundant tests Run formatter Don't export connected_components or check_port_connectivity --- src/solidmodels/postrender.jl | 109 ++++++++++++++++++- test/test_connected_components.jl | 2 +- test/test_port_connectivity.jl | 172 ++++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 test/test_port_connectivity.jl diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 70b867a4e..1b27a0cb7 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1048,7 +1048,7 @@ connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2) = function connected_components(dim::Integer, tags::Vector{Int32}) n = length(tags) isempty(tags) && return Vector{Tuple{Int32, Int32}}[] - n == 1 && return [(Int32(dim), only(tags))] + n == 1 && return [[(Int32(dim), only(tags))]] # Build adjacency: map boundary entities to parent entity indices boundary_to_parents = Dict{Int32, Vector{Int}}() @@ -1099,6 +1099,113 @@ function connected_components(dim::Integer, tags::Vector{Int32}) return collect(values(components)) end +""" + check_port_connectivity(sm::SolidModel, port_names, metal_groups; dim=2) + -> Dict{String, Symbol} + +Classify each port in `port_names` by its connectivity to the metal regions defined by +`metal_groups`. Returns a `Dict` mapping each port name (as `String`) to one of: + + - `:short` — at least two of the port's boundary entities touch metal, and every + metal-touching boundary lands on the same connected metal component. You can + trace a path from one terminal of the port to another through entities in + `metal_groups`, which would make a short circuit at DC. + - `:open` — the port's metal-touching boundaries land on two or more disconnected + metal components. There is no path through entities in `metal_groups` from one + terminal of the port to the other, which would make an open circuit at DC. + - `:floating` — fewer than two of the port's boundary entities touch metal. The + port has at most one terminal connected to metal; if used as a Palace lumped + port, this is generally a configuration error. + - `:missing` — the named port group does not exist in `sm` or is empty. + +Wave ports (2D exterior surface ports) are not handled specially; the `dim=2` path can still +classify them algorithmically but the results are generally not electrically meaningful. + +# Arguments + + - `sm::SolidModel`: a rendered solid model. Gmsh must be synchronized (the function + calls `SolidModels._synchronize!` defensively). + - `port_names::AbstractVector{<:Union{AbstractString, Symbol}}`: names of port + physical groups. + - `metal_groups::AbstractVector{<:Union{AbstractString, Symbol}}`: names of metal + physical groups. All listed groups are fed into a single "metal" connectivity + question. + +# Keyword arguments + + - `dim=2`: dimension of port and metal groups. `3` is appropriate for volumetric lumped + ports in a 3D model; `2` would be used for surfaces. + +# Algorithm + + 1. Compute connected components of the metal groups once via + [`connected_components`](@ref). + 2. Build a reverse map `entity tag → component index`. + 3. For each port, find its boundary entities (via `gmsh.model.getBoundary`) at + dimension `dim - 1`, then look up adjacent entities at dimension `dim` + (via `gmsh.model.getAdjacencies`). Count both the number of port boundary + entities that touch metal and the number of distinct metal components reached. + A port with fewer than two metal-touching boundaries is `:floating`; otherwise + it is `:short` (one component) or `:open` (multiple). + +See also [`connected_components`](@ref). +""" +function check_port_connectivity(sm::SolidModel, port_names, metal_groups; dim::Integer=2) + SolidModels._synchronize!(sm) + + # Build connected-components tag → component-index map. + tag_to_comp = Dict{Int32, Int}() + if !isempty(metal_groups) + comps = connected_components(sm, metal_groups, dim) + for (ci, comp_dimtags) in enumerate(comps) + for (_, tag) in comp_dimtags + tag_to_comp[tag] = ci + end + end + end + + results = Dict{String, Symbol}() + for pn in port_names + pn_s = string(pn) + if !SolidModels.hasgroup(sm, pn_s, dim) + results[pn_s] = :missing + continue + end + port_tags = SolidModels.entitytags(sm[pn_s, dim]) + if isempty(port_tags) + results[pn_s] = :missing + continue + end + # Boundary faces of the port volume(s). + port_dimtags = Tuple{Int32, Int32}[(Int32(dim), t) for t in port_tags] + # getBoundary(dimtags, combined, oriented, recursive) + boundary = gmsh.model.getBoundary(port_dimtags, false, false, false) + touched = Set{Int}() + n_touching_boundaries = 0 + for (bd, bt) in boundary + # `bt` may be signed (Gmsh convention); use absolute value as the tag. + upward, _ = gmsh.model.getAdjacencies(bd, abs(bt)) + comps_here = Set{Int}() + for neighbor in upward + # Skip the port's own volumes. + (neighbor in port_tags) && continue + ci = get(tag_to_comp, neighbor, 0) + ci == 0 && continue + push!(comps_here, ci) + end + if !isempty(comps_here) + n_touching_boundaries += 1 + union!(touched, comps_here) + end + end + # A Palace lumped port needs two terminals on metal; a single metal-touching + # boundary is treated as :floating regardless of how many components it reaches. + results[pn_s] = + n_touching_boundaries < 2 ? :floating : length(touched) == 1 ? :short : :open + end + return results +end + """ check_overlap(sm::SolidModel) diff --git a/test/test_connected_components.jl b/test/test_connected_components.jl index d032ac520..2c1f635b1 100644 --- a/test/test_connected_components.jl +++ b/test/test_connected_components.jl @@ -24,7 +24,7 @@ tags = Int32[1] result = connected_components(3, tags) @test length(result) == 1 - @test result[1] == (Int32(3), Int32(1)) + @test result[1] == [(Int32(3), Int32(1))] end @testset "two disconnected volumes" begin diff --git a/test/test_port_connectivity.jl b/test/test_port_connectivity.jl new file mode 100644 index 000000000..63ef95689 --- /dev/null +++ b/test/test_port_connectivity.jl @@ -0,0 +1,172 @@ +@testitem "Port connectivity" setup = [CommonTestSetup] begin + import DeviceLayout.SolidModels: connected_components, check_port_connectivity + + gmsh = SolidModels.gmsh + + # Helper: initialize a fresh Gmsh model for each testset + function fresh_model(name="porttest") + sm = SolidModel(name; overwrite=true) + gmsh.option.setNumber("General.Verbosity", 0) + return sm + end + + # Helper: name a physical group at a given dimension from raw dimtags + function name_group!(sm, gname, dim, tags) + sm[gname] = [(Int32(dim), Int32(t)) for t in tags] + return sm + end + + @testset "open port bridges two disconnected metals" begin + sm = fresh_model("open_port") + # Two disjoint metal cubes at x=0..1 and x=3..4, plus a port bridging them at x=1..3. + # Fragment to make them share boundary faces where they touch. + gmsh.model.occ.addBox(0, 0, 0, 1, 1, 1) # metal A (will be tag 1) + gmsh.model.occ.addBox(3, 0, 0, 1, 1, 1) # metal B (will be tag 2) + gmsh.model.occ.addBox(1, 0, 0, 2, 1, 1) # port spans x=1..3 (will be tag 3) + gmsh.model.occ.fragment([(3, 1), (3, 2), (3, 3)], []) + gmsh.model.occ.synchronize() + # After fragment, the three boxes retain tags 1,2,3 because they do not overlap; + # they just share faces. Register physical groups. + name_group!(sm, "metal_A", 3, [1]) + name_group!(sm, "metal_B", 3, [2]) + name_group!(sm, "port_1", 3, [3]) + + result = DeviceLayout.check_port_connectivity( + sm, + ["port_1"], + ["metal_A", "metal_B"]; + dim=3 + ) + @test result == Dict("port_1" => :open) + end + + @testset "short port touches only one metal" begin + sm = fresh_model("short_port") + # 2D U-shaped metal_A with a port nested in the opening: the port shares + # two edges (top and bottom) with metal_A's connected component → :short. + gmsh.model.occ.addRectangle(0, 0, 0, 3, 1) # 1: metal A bottom strip + gmsh.model.occ.addRectangle(2, 1, 0, 1, 1) # 2: metal A right connector (joins top↔bottom) + gmsh.model.occ.addRectangle(0, 2, 0, 3, 1) # 3: metal A top strip + gmsh.model.occ.addRectangle(0, 1, 0, 1, 1) # 4: port (touches A on top and bottom) + gmsh.model.occ.fragment([(2, 1), (2, 2), (2, 3), (2, 4)], []) + gmsh.model.occ.synchronize() + name_group!(sm, "metal_A", 2, [1, 2, 3]) + name_group!(sm, "port_1", 2, [4]) + + result = DeviceLayout.check_port_connectivity(sm, ["port_1"], ["metal_A"]; dim=2) + @test result == Dict("port_1" => :short) + end + + @testset "floating port touches no metal" begin + sm = fresh_model("floating_port") + gmsh.model.occ.addBox(0, 0, 0, 1, 1, 1) # metal A + gmsh.model.occ.addBox(5, 5, 5, 1, 1, 1) # port, far away from metal + gmsh.model.occ.synchronize() + name_group!(sm, "metal_A", 3, [1]) + name_group!(sm, "port_1", 3, [2]) + + result = DeviceLayout.check_port_connectivity(sm, ["port_1"], ["metal_A"]; dim=3) + @test result == Dict("port_1" => :floating) + end + + @testset "cross-layer via: port bridging via stack reads as :short" begin + # Flip-chip-style fixture: metal L1 at z=0, via column in the middle, metal L2 at z=3. + # The port spans the full z-height alongside the stack, so it touches L1 and L2 on + # two distinct boundary faces. Asked about L1+via+L2 (one component via the via), + # this is :short. Asked about only L1, only one boundary touches metal → :floating. + sm = fresh_model("via_stack") + gmsh.model.occ.addBox(0, 0, 0, 2, 2, 1) # metal L1 at z=[0,1] + gmsh.model.occ.addBox(0.5, 0.5, 1, 1, 1, 1) # via at z=[1,2] + gmsh.model.occ.addBox(0, 0, 2, 2, 2, 1) # metal L2 at z=[2,3] + gmsh.model.occ.addBox(2, 0, 0, 1, 2, 3) # port at x=[2,3], z=[0,3] (shares +x faces with L1 and L2) + gmsh.model.occ.fragment([(3, 1), (3, 2), (3, 3), (3, 4)], []) + gmsh.model.occ.synchronize() + name_group!(sm, "metal_L1", 3, [1]) + name_group!(sm, "via", 3, [2]) + name_group!(sm, "metal_L2", 3, [3]) + name_group!(sm, "port_1", 3, [4]) + + # Unioning L1+via+L2 as metal: port touches both L1 and L2, which are joined + # into one component through the via, so this is :short. + result = DeviceLayout.check_port_connectivity( + sm, + ["port_1"], + ["metal_L1", "via", "metal_L2"]; + dim=3 + ) + @test result == Dict("port_1" => :short) + + # Asking only about metal_L1: only one boundary face touches metal → :floating. + result2 = DeviceLayout.check_port_connectivity(sm, ["port_1"], ["metal_L1"]; dim=3) + @test result2 == Dict("port_1" => :floating) + end + + @testset "batch: mixed open / short / floating in one call" begin + sm = fresh_model("batch") + # metal A is a U-shape (three boxes sharing faces, one connected component) + # surrounding port_short on -x, +x, and -y. metal B is a separate box at x=[3,4]. + # port_open at x=[1,3] bridges metal A's right arm and metal B → :open. + # port_short sits inside metal A's U and touches metal A on multiple faces but + # all reach the same component → :short. + # port_floating at x=[10,11] is isolated. + gmsh.model.occ.addBox(-2, 0, 0, 1, 1, 1) # 1: metal A left arm (x=[-2,-1]) + gmsh.model.occ.addBox(0, 0, 0, 1, 1, 1) # 2: metal A right arm (x=[0,1]) + gmsh.model.occ.addBox(-2, -1, 0, 3, 1, 1) # 3: metal A connector (x=[-2,1], y=[-1,0]) joins the arms + gmsh.model.occ.addBox(3, 0, 0, 1, 1, 1) # 4: metal B + gmsh.model.occ.addBox(1, 0, 0, 2, 1, 1) # 5: port_open (connects A and B) + gmsh.model.occ.addBox(-1, 0, 0, 1, 1, 1) # 6: port_short (sits inside metal A's U) + gmsh.model.occ.addBox(10, 0, 0, 1, 1, 1) # 7: port_floating + gmsh.model.occ.fragment( + [(3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (3, 6), (3, 7)], + [] + ) + gmsh.model.occ.synchronize() + name_group!(sm, "metal_A", 3, [1, 2, 3]) + name_group!(sm, "metal_B", 3, [4]) + name_group!(sm, "port_open", 3, [5]) + name_group!(sm, "port_short", 3, [6]) + name_group!(sm, "port_floating", 3, [7]) + + result = DeviceLayout.check_port_connectivity( + sm, + ["port_open", "port_short", "port_floating"], + ["metal_A", "metal_B"]; + dim=3 + ) + @test result["port_open"] === :open + @test result["port_short"] === :short + @test result["port_floating"] === :floating + @test length(result) == 3 + end + + @testset "symbol vs string keys" begin + sm = fresh_model("symkeys") + # U-shape metal sandwiches the port, so the port has two metal-touching + # boundaries on the same component → :short. + gmsh.model.occ.addBox(0, 0, 0, 1, 1, 1) # 1: metal left arm + gmsh.model.occ.addBox(2, 0, 0, 1, 1, 1) # 2: metal right arm + gmsh.model.occ.addBox(0, -1, 0, 3, 1, 1) # 3: metal connector + gmsh.model.occ.addBox(1, 0, 0, 1, 1, 1) # 4: port between the arms + gmsh.model.occ.fragment([(3, 1), (3, 2), (3, 3), (3, 4)], []) + gmsh.model.occ.synchronize() + name_group!(sm, "metal", 3, [1, 2, 3]) + name_group!(sm, "p1", 3, [4]) + + # Pass Symbols for port_names and metal_groups + result = DeviceLayout.check_port_connectivity(sm, [:p1], [:metal]; dim=3) + # Keys always returned as String + @test haskey(result, "p1") + @test result["p1"] === :short + end + + @testset "missing port name → :missing" begin + sm = fresh_model("missing_port") + gmsh.model.occ.addBox(0, 0, 0, 1, 1, 1) + gmsh.model.occ.synchronize() + name_group!(sm, "metal", 3, [1]) + # "port_absent" was never registered + + result = DeviceLayout.check_port_connectivity(sm, ["port_absent"], ["metal"]; dim=3) + @test result == Dict("port_absent" => :missing) + end +end From 2e31aa91c56858040330c815a93e41dc8233668a Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Tue, 19 May 2026 15:39:10 +0200 Subject: [PATCH 02/15] Add QPU17 SolidModel example --- examples/DemoQPU17/solidmodel.jl | 125 +++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 examples/DemoQPU17/solidmodel.jl diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl new file mode 100644 index 000000000..9d72bc0f3 --- /dev/null +++ b/examples/DemoQPU17/solidmodel.jl @@ -0,0 +1,125 @@ +# QPU17 SolidModel +include("DemoQPU17.jl") +schematic, artwork = DemoQPU17.qpu17_demo(savegds=true) + +using DeviceLayout, + .SchematicDrivenLayout, .PreferredUnits, .SchematicDrivenLayout.ExamplePDK +using .ExamplePDK.LayerVocabulary +place!(schematic.coordinate_system, bounds(schematic.coordinate_system), SIMULATED_AREA) +target = ExamplePDK.SINGLECHIP_SOLIDMODEL_TARGET +if length(target.rendering_options.retained_physical_groups) < 10 + ports = [("port_$i", 2) for i = 1:42] + lumped_elements = [("lumped_element_$i", 2) for i = 1:34] + append!(target.rendering_options.retained_physical_groups, ports, lumped_elements) +end +# # This is fine for geometry but broke meshing the one try I gave it +# empty!(target.bounding_layers) # Model includes everything, no need to intersect with bounding box +# # But then we have to make "exterior_boundary" ourselves +# push!(target.postrenderer, ("exterior_boundary", SolidModels.get_boundary, ("simulated_area_extrusion", 3))) + +sm = SolidModel("demo"; overwrite=true) +SolidModels.gmsh.option.set_number("General.Verbosity", 2) +@time render!(sm, schematic, target) + +DeviceLayout.save("qpu17.xao", sm) +# Verify port connectivity +conn = SolidModels.check_port_connectivity(sm, ["port_$i" for i=1:42], ["metal"]; dim=2) +for i = 1:42 + port = "port_$i" + component_node = schematic.index_dict[:port][i] + role = split(component_node.component.name, "_")[2] + @show conn[port] + if role == "XY" || role == "RO" + @assert conn[port] == :open + elseif role == "Z" + @assert conn[port] == :short + else + error("Invalid port role") + end +end + +SolidModels.mesh_order(1) +SolidModels.gmsh.model.mesh.generate(3) +# Make verbose and optimize if only to show element quality / warnings +SolidModels.gmsh.option.set_number("General.Verbosity", 5) +SolidModels.gmsh.model.mesh.optimize() +meshfile = joinpath(@__DIR__, "qpu17_order$mesh_order.msh2") +save(meshfile, sm) + +# Config +attributes = SolidModels.attributes(sm) +config = Dict( + "Problem" => Dict("Type" => "Eigenmode", "Verbose" => 2, "Output" => "postpro"), + "Model" => Dict( + "Mesh" => meshfile, + "L0" => 1e-6, # um is Palace default; record it anyway + "Refinement" => Dict( + "MaxIts" => 0 # Increase to enable AMR + ) + ), + "Domains" => Dict( + "Materials" => [ + Dict( + # Vaccuum + "Attributes" => [attributes["vacuum"]], + "Permeability" => 1.0, + "Permittivity" => 1.0 + ), + Dict( + # Sapphire + "Attributes" => [attributes["substrate"]], + "Permeability" => [0.99999975, 0.99999975, 0.99999979], + "Permittivity" => [9.3, 9.3, 11.5], + "LossTan" => [3.0e-5, 3.0e-5, 8.6e-5], + "MaterialAxes" => [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]] + ) + ] + ), + "Boundaries" => Dict( + "PEC" => Dict( + "Attributes" => [attributes["metal"], attributes["exterior_boundary"]] + ), + "LumpedPort" => [] + ), + "Solver" => Dict( + "Order" => 2, + "Eigenmode" => Dict("N" => 2, "Tol" => 1.0e-6, "Target" => 2, "Save" => 2), + "Linear" => Dict("Type" => "Default", "Tol" => 1.0e-7, "MaxIts" => 500) + ) +) + +for i = 1:42 + node = schematic.index_dict[:port][i] + dirs = Dict(0.0° => "+X", 90.0° => "+Y", 180.0° => "-X", 270.0° => "-Y") + dir = dirs[rem(rotation(transformation(schematic, node)), 360°, RoundDown)] + push!( + config["Boundaries"]["LumpedPort"], + Dict( + "Index" => i, + "Attributes" => [attributes["port_$i"]], + "R" => 50, + "Direction" => dir + ) + ) +end + +for i = 1:34 + node = schematic.index_dict[:lumped_element][i] + dirs = Dict(0.0° => "+Y", 180.0° => "-Y") + dir = dirs[rem(rotation(transformation(schematic, node)), 360°, RoundDown)] + push!( + config["Boundaries"]["LumpedPort"], + Dict( + "Index" => 42 + i, + "Attributes" => [attributes["lumped_element_$i"]], + "L" => 28.0e-9 + i * 0.05e-9, + "C" => 2.75e-15, + "Direction" => dir + ) + ) +end + +using JSON +open(joinpath(@__DIR__, "config.json"), "w") do f + return JSON.print(f, config) +end \ No newline at end of file From 927c251b30209ac642b7290e54c2f3379d70e09d Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Tue, 19 May 2026 18:34:21 +0200 Subject: [PATCH 03/15] Fix connected_components bug for 1d edge embedded in 2d interior --- examples/DemoQPU17/solidmodel.jl | 21 +++++++- src/solidmodels/postrender.jl | 88 +++++++++++++++++++++++++------ test/test_connected_components.jl | 34 ++++++++++++ test/test_port_connectivity.jl | 16 +++--- 4 files changed, 135 insertions(+), 24 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index 9d72bc0f3..35a57a65e 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -122,4 +122,23 @@ end using JSON open(joinpath(@__DIR__, "config.json"), "w") do f return JSON.print(f, config) -end \ No newline at end of file +end + +sm = SolidModel("test2", overwrite=true) +gmsh.model.occ.addRectangle(-10, -10, 0, 20, 20) +gmsh.model.occ.addRectangle(0, 0, 1, 1, 1) +gmsh.model.occ.addPoint(0, 0, 0) +gmsh.model.occ.addPoint(0, 1, 0) +gmsh.model.occ.addPoint(1, 1, 0) +gmsh.model.occ.addPoint(1, 0, 0) +l1 = gmsh.model.occ.addLine(9, 10) +l2 = gmsh.model.occ.addLine(11, 12) +gmsh.model.occ.fragment([(1, l1), (1, l2)], [(2, 1)]) +gmsh.model.occ.synchronize() +gmsh.model.getAdjacencies(2,1) # Just the four outer edges +ext = gmsh.model.occ.extrude([(1, 9), (1, 10)], 0.0, 0.0, 1.0) +gmsh.model.occ.fragment(ext, [(2, 1)]) +gmsh.model.occ.synchronize() +gmsh.model.getAdjacencies(2,1) # Still just the four outer edges + +(Int32[], Int32[17, 19, 20, 18]) \ No newline at end of file diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 1b27a0cb7..19e3f3910 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1011,25 +1011,27 @@ function remove_group!(group::PhysicalGroup; recursive=true, remove_entities=tru end """ - connected_components(dim::Int, tags::Vector{Int32}) - connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2) - connected_components(sm::SolidModel, groups, dim=2) + connected_components(dim::Int, tags::Vector{Int32}; geometric_tol=1e-6) + connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; geometric_tol=1e-6) + connected_components(sm::SolidModel, groups, dim=2; geometric_tol=1e-6) Find connected components among SolidModel entities at dimension `dim` with the given `tags` or physical group names. Two entities are connected if they share any boundary entity (dimension `dim - 1`). Uses union-find with path compression on the adjacency graph from `gmsh.model.getAdjacencies`. +For `dim == 2`, also unites entities that share a "stray" 1D entity that lies in the +interior of a 2D entity without being one of its topological boundary curves. This is +necessary because OpenCascade's global fragment leaves curves that are coincident with +a face's interior geometrically embedded but topologically detached, so `getAdjacencies` +does not see the connection (a typical case is the foot edge of a staple air-bridge leg +landing on a ground plane). A 1D entity is considered to lie on a 2D entity's interior +if `getClosestPoint` returns a distance ≤ `geometric_tol` (in `STP_UNIT`) for every +sampled parametric point on the curve. Set `geometric_tol = 0` to disable. + Returns a `Vector{Vector{Tuple{Int32, Int32}}}` where each inner vector contains the entity dimtags of one connected component. -# Algorithm - - 1. Query downward adjacencies (boundary entities) for each tag via getAdjacencies - 2. Build a mapping from boundary tags to parent entity indices - 3. Use union-find to merge entities sharing boundaries - 4. Collect and return connected component groups - # Notes - Requires Gmsh model to be synchronized before calling @@ -1037,15 +1039,19 @@ of one connected component. - For dim=3 (volumes): shares boundary surfaces (dim=2) - For dim=2 (surfaces): shares boundary curves (dim=1) """ -function connected_components(sm::SolidModel, groups, dim=2) +function connected_components(sm::SolidModel, groups, dim=2; kwargs...) tags = reduce(vcat, [entitytags(sm[name, dim]) for name in groups], init=Int32[]) unique!(tags) - return connected_components(dim, tags) + return connected_components(dim, tags; kwargs...) end -connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2) = - connected_components(dim, entitytags(sm[group, dim])) +connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; kwargs...) = + connected_components(dim, entitytags(sm[group, dim]); kwargs...) -function connected_components(dim::Integer, tags::Vector{Int32}) +function connected_components( + dim::Integer, + tags::Vector{Int32}; + geometric_tol::Real=1e-6 +) n = length(tags) isempty(tags) && return Vector{Tuple{Int32, Int32}}[] n == 1 && return [[(Int32(dim), only(tags))]] @@ -1085,6 +1091,31 @@ function connected_components(dim::Integer, tags::Vector{Int32}) end end + # Geometric augmentation: connect entities through stray (dim-1) entities that lie + # on the interior of another entity's geometry without being its topological boundary. + # Only the dim=2 / dim-1=1 case (curve in face) is handled — this catches the + # staple-bridge foot landing on an interior of a metal plane. For dim=3, "face inside + # volume interior" is not a typical Palace configuration so we skip it. + if dim == 2 && geometric_tol > 0 + tag_to_idx = Dict(t => i for (i, t) in enumerate(tags)) + bbox_cache = Dict{Int32, NTuple{6, Float64}}() + get_bbox(d, t) = get!(bbox_cache, t) do + return gmsh.model.getBoundingBox(d, t) + end + for (btag, ps) in boundary_to_parents + length(ps) == 1 || continue + owner_idx = ps[1] + ebbox = gmsh.model.getBoundingBox(dim - 1, btag) + for (j, ftag) in enumerate(tags) + j == owner_idx && continue + find(j) == find(owner_idx) && continue + _bbox_overlaps(get_bbox(dim, ftag), ebbox; pad=geometric_tol) || continue + _curve_lies_on_face(btag, ftag; tol=geometric_tol) || continue + unite(owner_idx, j) + end + end + end + # Collect components components = Dict{Int, Vector{Tuple{Int32, Int32}}}() for (i, tag) in enumerate(tags) @@ -1099,6 +1130,33 @@ function connected_components(dim::Integer, tags::Vector{Int32}) return collect(values(components)) end +# Axis-aligned bbox overlap test. `bbox` is gmsh's (xmin, ymin, zmin, xmax, ymax, zmax). +function _bbox_overlaps(a, b; pad::Real=0.0) + return (a[1] - pad <= b[4]) && (b[1] - pad <= a[4]) && + (a[2] - pad <= b[5]) && (b[2] - pad <= a[5]) && + (a[3] - pad <= b[6]) && (b[3] - pad <= a[6]) +end + +# Sample a 1D entity (curve) at `n_samples` parametric points and test whether each +# sample lies on the 2D entity (face) within `tol`. Uses `gmsh.model.getClosestPoint` +# which returns the point on the face nearest to the query — exactly zero distance +# means the sample is geometrically embedded in the face. +function _curve_lies_on_face(curve_tag::Integer, face_tag::Integer; tol, n_samples::Int=5) + tmin, tmax = gmsh.model.getParametrizationBounds(1, curve_tag) + isempty(tmin) && return false + params = collect(range(Float64(tmin[1]), Float64(tmax[1]); length=n_samples)) + xyz = gmsh.model.getValue(1, curve_tag, params) # flat [x1,y1,z1,x2,y2,z2,...] + tol2 = Float64(tol)^2 + for k = 1:n_samples + px, py, pz = xyz[3k - 2], xyz[3k - 1], xyz[3k] + closest, _ = gmsh.model.getClosestPoint(2, face_tag, [px, py, pz]) + d2 = + (closest[1] - px)^2 + (closest[2] - py)^2 + (closest[3] - pz)^2 + d2 > tol2 && return false + end + return true +end + """ check_port_connectivity(sm::SolidModel, port_names, metal_groups; dim=2) -> Dict{String, Symbol} diff --git a/test/test_connected_components.jl b/test/test_connected_components.jl index 2c1f635b1..ad52cfd59 100644 --- a/test/test_connected_components.jl +++ b/test/test_connected_components.jl @@ -40,6 +40,40 @@ @test sizes == [1, 1] end + @testset "stray edge embedded in face interior connects two faces" begin + # Two coplanar surfaces that share no topological boundary in gmsh's adjacency + # graph, but are bridged geometrically by 1D edges lying in the interior of + # one and on the boundary of the other. Mirrors the staple-airbridge foot + # edges landing on a ground plane: OCC's global fragment leaves these curves + # geometrically embedded but topologically detached, so getAdjacencies returns + # only the ground plane's outer rectangle. Geometry matches the minimal + # reproduction observed empirically — the second loose-rectangle is irrelevant + # but kept to match the exact tag layout the reproduction relies on. + fresh_model("stray_edge") + gmsh.model.occ.addRectangle(-10, -10, 0, 20, 20) + gmsh.model.occ.addRectangle(0, 0, 1, 1, 1) + gmsh.model.occ.addPoint(0, 0, 0) + gmsh.model.occ.addPoint(0, 1, 0) + gmsh.model.occ.addPoint(1, 1, 0) + gmsh.model.occ.addPoint(1, 0, 0) + l1 = gmsh.model.occ.addLine(9, 10) + l2 = gmsh.model.occ.addLine(11, 12) + ext = gmsh.model.occ.extrude([(1, 9), (1, 10)], 0.0, 0.0, 1.0) + gmsh.model.occ.fragment([(1, l1), (1, l2)], [(2, 1), (2, 2), (2, 3), (2, 4)]) + gmsh.model.occ.synchronize() + leg_faces = Int32[dt[2] for dt in ext if dt[1] == 2] + tags = Int32[1; 2; leg_faces] + + # Topology only: ground plane (tag 1) is disconnected from each leg face. + result_topo = connected_components(2, tags; geometric_tol=0.0) + @test length(result_topo) == 2 + + # Geometric augmentation: the foot edges lie in the ground plane's interior + # and are boundary edges of the leg faces → all united into 1 component. + result_geom = connected_components(2, tags; geometric_tol=1e-6) + @test length(result_geom) == 1 + end + @testset "shared-boundary volumes via fragment" begin fresh_model("shared") # Two overlapping boxes — fragment will create shared boundary surfaces diff --git a/test/test_port_connectivity.jl b/test/test_port_connectivity.jl index 63ef95689..56344def7 100644 --- a/test/test_port_connectivity.jl +++ b/test/test_port_connectivity.jl @@ -31,7 +31,7 @@ name_group!(sm, "metal_B", 3, [2]) name_group!(sm, "port_1", 3, [3]) - result = DeviceLayout.check_port_connectivity( + result = check_port_connectivity( sm, ["port_1"], ["metal_A", "metal_B"]; @@ -53,7 +53,7 @@ name_group!(sm, "metal_A", 2, [1, 2, 3]) name_group!(sm, "port_1", 2, [4]) - result = DeviceLayout.check_port_connectivity(sm, ["port_1"], ["metal_A"]; dim=2) + result = check_port_connectivity(sm, ["port_1"], ["metal_A"]; dim=2) @test result == Dict("port_1" => :short) end @@ -65,7 +65,7 @@ name_group!(sm, "metal_A", 3, [1]) name_group!(sm, "port_1", 3, [2]) - result = DeviceLayout.check_port_connectivity(sm, ["port_1"], ["metal_A"]; dim=3) + result = check_port_connectivity(sm, ["port_1"], ["metal_A"]; dim=3) @test result == Dict("port_1" => :floating) end @@ -88,7 +88,7 @@ # Unioning L1+via+L2 as metal: port touches both L1 and L2, which are joined # into one component through the via, so this is :short. - result = DeviceLayout.check_port_connectivity( + result = check_port_connectivity( sm, ["port_1"], ["metal_L1", "via", "metal_L2"]; @@ -97,7 +97,7 @@ @test result == Dict("port_1" => :short) # Asking only about metal_L1: only one boundary face touches metal → :floating. - result2 = DeviceLayout.check_port_connectivity(sm, ["port_1"], ["metal_L1"]; dim=3) + result2 = check_port_connectivity(sm, ["port_1"], ["metal_L1"]; dim=3) @test result2 == Dict("port_1" => :floating) end @@ -127,7 +127,7 @@ name_group!(sm, "port_short", 3, [6]) name_group!(sm, "port_floating", 3, [7]) - result = DeviceLayout.check_port_connectivity( + result = check_port_connectivity( sm, ["port_open", "port_short", "port_floating"], ["metal_A", "metal_B"]; @@ -153,7 +153,7 @@ name_group!(sm, "p1", 3, [4]) # Pass Symbols for port_names and metal_groups - result = DeviceLayout.check_port_connectivity(sm, [:p1], [:metal]; dim=3) + result = check_port_connectivity(sm, [:p1], [:metal]; dim=3) # Keys always returned as String @test haskey(result, "p1") @test result["p1"] === :short @@ -166,7 +166,7 @@ name_group!(sm, "metal", 3, [1]) # "port_absent" was never registered - result = DeviceLayout.check_port_connectivity(sm, ["port_absent"], ["metal"]; dim=3) + result = check_port_connectivity(sm, ["port_absent"], ["metal"]; dim=3) @test result == Dict("port_absent" => :missing) end end From aa114e793f696d5e4c330ed03793db7877a23dc3 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Tue, 19 May 2026 20:05:18 +0200 Subject: [PATCH 04/15] Connectivity check performance improvements and fixes --- examples/DemoQPU17/solidmodel.jl | 29 +++++------------------------ src/solidmodels/postrender.jl | 30 +++++++++++++++++------------- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index 35a57a65e..bc9e5c6f3 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -19,24 +19,24 @@ end sm = SolidModel("demo"; overwrite=true) SolidModels.gmsh.option.set_number("General.Verbosity", 2) -@time render!(sm, schematic, target) +render!(sm, schematic, target) # ~30 min DeviceLayout.save("qpu17.xao", sm) -# Verify port connectivity +# Verify port connectivity (~2 min) conn = SolidModels.check_port_connectivity(sm, ["port_$i" for i=1:42], ["metal"]; dim=2) for i = 1:42 port = "port_$i" component_node = schematic.index_dict[:port][i] role = split(component_node.component.name, "_")[2] - @show conn[port] if role == "XY" || role == "RO" - @assert conn[port] == :open + @assert conn[port] == :open "$role port $i is $(conn[port]); should be :open" elseif role == "Z" - @assert conn[port] == :short + @assert conn[port] == :short "$role port $i is $(conn[port]); should be :short" else error("Invalid port role") end end +println("All flux ports are `:short`, and all charge and readout ports are `:open`") SolidModels.mesh_order(1) SolidModels.gmsh.model.mesh.generate(3) @@ -123,22 +123,3 @@ using JSON open(joinpath(@__DIR__, "config.json"), "w") do f return JSON.print(f, config) end - -sm = SolidModel("test2", overwrite=true) -gmsh.model.occ.addRectangle(-10, -10, 0, 20, 20) -gmsh.model.occ.addRectangle(0, 0, 1, 1, 1) -gmsh.model.occ.addPoint(0, 0, 0) -gmsh.model.occ.addPoint(0, 1, 0) -gmsh.model.occ.addPoint(1, 1, 0) -gmsh.model.occ.addPoint(1, 0, 0) -l1 = gmsh.model.occ.addLine(9, 10) -l2 = gmsh.model.occ.addLine(11, 12) -gmsh.model.occ.fragment([(1, l1), (1, l2)], [(2, 1)]) -gmsh.model.occ.synchronize() -gmsh.model.getAdjacencies(2,1) # Just the four outer edges -ext = gmsh.model.occ.extrude([(1, 9), (1, 10)], 0.0, 0.0, 1.0) -gmsh.model.occ.fragment(ext, [(2, 1)]) -gmsh.model.occ.synchronize() -gmsh.model.getAdjacencies(2,1) # Still just the four outer edges - -(Int32[], Int32[17, 19, 20, 18]) \ No newline at end of file diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 19e3f3910..85960e381 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1109,7 +1109,7 @@ function connected_components( for (j, ftag) in enumerate(tags) j == owner_idx && continue find(j) == find(owner_idx) && continue - _bbox_overlaps(get_bbox(dim, ftag), ebbox; pad=geometric_tol) || continue + _bbox_contains(get_bbox(dim, ftag), ebbox; pad=geometric_tol) || continue _curve_lies_on_face(btag, ftag; tol=geometric_tol) || continue unite(owner_idx, j) end @@ -1130,29 +1130,33 @@ function connected_components( return collect(values(components)) end -# Axis-aligned bbox overlap test. `bbox` is gmsh's (xmin, ymin, zmin, xmax, ymax, zmax). -function _bbox_overlaps(a, b; pad::Real=0.0) - return (a[1] - pad <= b[4]) && (b[1] - pad <= a[4]) && - (a[2] - pad <= b[5]) && (b[2] - pad <= a[5]) && - (a[3] - pad <= b[6]) && (b[3] - pad <= a[6]) +# Axis-aligned bbox containment test (a contains b). `bbox` is gmsh's (xmin, ymin, zmin, xmax, ymax, zmax). +function _bbox_contains(a, b; pad::Real=0.0) + return (a[1] - pad <= b[1]) && (b[4] - pad <= a[4]) && + (a[2] - pad <= b[2]) && (b[5] - pad <= a[5]) && + (a[3] - pad <= b[3]) && (b[6] - pad <= a[6]) end # Sample a 1D entity (curve) at `n_samples` parametric points and test whether each -# sample lies on the 2D entity (face) within `tol`. Uses `gmsh.model.getClosestPoint` -# which returns the point on the face nearest to the query — exactly zero distance -# means the sample is geometrically embedded in the face. -function _curve_lies_on_face(curve_tag::Integer, face_tag::Integer; tol, n_samples::Int=5) +# sample lies on the 2D entity (face) within `tol`. Two filters: (1) `getClosestPoint` +# distance ≤ tol confirms the sample is on the face's underlying surface (an infinite +# plane for a planar face — does NOT respect trim curves / holes); (2) batched +# `isInside` in parametric uv-space confirms the sample is on the *trimmed* portion +# of the face. The parametric form of `isInside` skips an internal world→parametric +# reprojection, which is the slow part on large CPW-style faces. +function _curve_lies_on_face(curve_tag::Integer, face_tag::Integer; tol, n_samples::Int=2) tmin, tmax = gmsh.model.getParametrizationBounds(1, curve_tag) isempty(tmin) && return false params = collect(range(Float64(tmin[1]), Float64(tmax[1]); length=n_samples)) xyz = gmsh.model.getValue(1, curve_tag, params) # flat [x1,y1,z1,x2,y2,z2,...] tol2 = Float64(tol)^2 for k = 1:n_samples - px, py, pz = xyz[3k - 2], xyz[3k - 1], xyz[3k] - closest, _ = gmsh.model.getClosestPoint(2, face_tag, [px, py, pz]) + p = @view xyz[3k - 2:3k] + closest, uv = gmsh.model.getClosestPoint(2, face_tag, p) d2 = - (closest[1] - px)^2 + (closest[2] - py)^2 + (closest[3] - pz)^2 + (closest[1] - p[1])^2 + (closest[2] - p[2])^2 + (closest[3] - p[3])^2 d2 > tol2 && return false + gmsh.model.isInside(2, face_tag, uv, true) > 0 || return false end return true end From 9a0665cba667537e020fa26408a25cdf6e08493b Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Tue, 19 May 2026 20:35:17 +0200 Subject: [PATCH 05/15] Break down mesh timings in QPU17 SolidModel example --- examples/DemoQPU17/solidmodel.jl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index bc9e5c6f3..9ef0569de 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -19,11 +19,11 @@ end sm = SolidModel("demo"; overwrite=true) SolidModels.gmsh.option.set_number("General.Verbosity", 2) -render!(sm, schematic, target) # ~30 min +@time render!(sm, schematic, target) # ~30 min DeviceLayout.save("qpu17.xao", sm) # Verify port connectivity (~2 min) -conn = SolidModels.check_port_connectivity(sm, ["port_$i" for i=1:42], ["metal"]; dim=2) +@time conn = SolidModels.check_port_connectivity(sm, ["port_$i" for i=1:42], ["metal"]; dim=2) for i = 1:42 port = "port_$i" component_node = schematic.index_dict[:port][i] @@ -39,10 +39,10 @@ end println("All flux ports are `:short`, and all charge and readout ports are `:open`") SolidModels.mesh_order(1) -SolidModels.gmsh.model.mesh.generate(3) -# Make verbose and optimize if only to show element quality / warnings SolidModels.gmsh.option.set_number("General.Verbosity", 5) -SolidModels.gmsh.model.mesh.optimize() +@time SolidModels.gmsh.model.mesh.generate(1) +@time SolidModels.gmsh.model.mesh.generate(2) +@time SolidModels.gmsh.model.mesh.generate(3) meshfile = joinpath(@__DIR__, "qpu17_order$mesh_order.msh2") save(meshfile, sm) From f69a9d05391692256072afd8e4557747337bad43 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Tue, 19 May 2026 20:36:35 +0200 Subject: [PATCH 06/15] Run formatter --- src/solidmodels/postrender.jl | 27 +++++++++++++-------------- test/test_connected_components.jl | 2 +- test/test_port_connectivity.jl | 15 +++------------ 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 85960e381..88a6ae5fe 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1047,11 +1047,7 @@ end connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; kwargs...) = connected_components(dim, entitytags(sm[group, dim]); kwargs...) -function connected_components( - dim::Integer, - tags::Vector{Int32}; - geometric_tol::Real=1e-6 -) +function connected_components(dim::Integer, tags::Vector{Int32}; geometric_tol::Real=1e-6) n = length(tags) isempty(tags) && return Vector{Tuple{Int32, Int32}}[] n == 1 && return [[(Int32(dim), only(tags))]] @@ -1099,9 +1095,10 @@ function connected_components( if dim == 2 && geometric_tol > 0 tag_to_idx = Dict(t => i for (i, t) in enumerate(tags)) bbox_cache = Dict{Int32, NTuple{6, Float64}}() - get_bbox(d, t) = get!(bbox_cache, t) do - return gmsh.model.getBoundingBox(d, t) - end + get_bbox(d, t) = + get!(bbox_cache, t) do + return gmsh.model.getBoundingBox(d, t) + end for (btag, ps) in boundary_to_parents length(ps) == 1 || continue owner_idx = ps[1] @@ -1132,9 +1129,12 @@ end # Axis-aligned bbox containment test (a contains b). `bbox` is gmsh's (xmin, ymin, zmin, xmax, ymax, zmax). function _bbox_contains(a, b; pad::Real=0.0) - return (a[1] - pad <= b[1]) && (b[4] - pad <= a[4]) && - (a[2] - pad <= b[2]) && (b[5] - pad <= a[5]) && - (a[3] - pad <= b[3]) && (b[6] - pad <= a[6]) + return (a[1] - pad <= b[1]) && + (b[4] - pad <= a[4]) && + (a[2] - pad <= b[2]) && + (b[5] - pad <= a[5]) && + (a[3] - pad <= b[3]) && + (b[6] - pad <= a[6]) end # Sample a 1D entity (curve) at `n_samples` parametric points and test whether each @@ -1151,10 +1151,9 @@ function _curve_lies_on_face(curve_tag::Integer, face_tag::Integer; tol, n_sampl xyz = gmsh.model.getValue(1, curve_tag, params) # flat [x1,y1,z1,x2,y2,z2,...] tol2 = Float64(tol)^2 for k = 1:n_samples - p = @view xyz[3k - 2:3k] + p = @view xyz[(3k - 2):(3k)] closest, uv = gmsh.model.getClosestPoint(2, face_tag, p) - d2 = - (closest[1] - p[1])^2 + (closest[2] - p[2])^2 + (closest[3] - p[3])^2 + d2 = (closest[1] - p[1])^2 + (closest[2] - p[2])^2 + (closest[3] - p[3])^2 d2 > tol2 && return false gmsh.model.isInside(2, face_tag, uv, true) > 0 || return false end diff --git a/test/test_connected_components.jl b/test/test_connected_components.jl index ad52cfd59..b0e888670 100644 --- a/test/test_connected_components.jl +++ b/test/test_connected_components.jl @@ -65,7 +65,7 @@ tags = Int32[1; 2; leg_faces] # Topology only: ground plane (tag 1) is disconnected from each leg face. - result_topo = connected_components(2, tags; geometric_tol=0.0) + result_topo = connected_components(2, tags; geometric_tol=0.0) # tol=0.0 turns off augmentation @test length(result_topo) == 2 # Geometric augmentation: the foot edges lie in the ground plane's interior diff --git a/test/test_port_connectivity.jl b/test/test_port_connectivity.jl index 56344def7..38f5d5aff 100644 --- a/test/test_port_connectivity.jl +++ b/test/test_port_connectivity.jl @@ -31,12 +31,7 @@ name_group!(sm, "metal_B", 3, [2]) name_group!(sm, "port_1", 3, [3]) - result = check_port_connectivity( - sm, - ["port_1"], - ["metal_A", "metal_B"]; - dim=3 - ) + result = check_port_connectivity(sm, ["port_1"], ["metal_A", "metal_B"]; dim=3) @test result == Dict("port_1" => :open) end @@ -88,12 +83,8 @@ # Unioning L1+via+L2 as metal: port touches both L1 and L2, which are joined # into one component through the via, so this is :short. - result = check_port_connectivity( - sm, - ["port_1"], - ["metal_L1", "via", "metal_L2"]; - dim=3 - ) + result = + check_port_connectivity(sm, ["port_1"], ["metal_L1", "via", "metal_L2"]; dim=3) @test result == Dict("port_1" => :short) # Asking only about metal_L1: only one boundary face touches metal → :floating. From 2d86e52f5b7ff28763c22ed1915a5c52d677c10a Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Thu, 21 May 2026 14:00:04 +0200 Subject: [PATCH 07/15] Add control rectangle to ExamplePDK bridge to avoid staple connectivity computation --- examples/DemoQPU17/solidmodel.jl | 7 +++++-- src/schematics/ExamplePDK/utils.jl | 19 ++++++++++++++----- src/solidmodels/postrender.jl | 25 +++++++++++-------------- test/test_connected_components.jl | 4 ++-- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index 9ef0569de..14cfb909d 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -5,6 +5,8 @@ schematic, artwork = DemoQPU17.qpu17_demo(savegds=true) using DeviceLayout, .SchematicDrivenLayout, .PreferredUnits, .SchematicDrivenLayout.ExamplePDK using .ExamplePDK.LayerVocabulary +using FileIO + place!(schematic.coordinate_system, bounds(schematic.coordinate_system), SIMULATED_AREA) target = ExamplePDK.SINGLECHIP_SOLIDMODEL_TARGET if length(target.rendering_options.retained_physical_groups) < 10 @@ -38,8 +40,9 @@ for i = 1:42 end println("All flux ports are `:short`, and all charge and readout ports are `:open`") -SolidModels.mesh_order(1) -SolidModels.gmsh.option.set_number("General.Verbosity", 5) +mesh_order = 1 +SolidModels.mesh_order(mesh_order) +SolidModels.gmsh.option.set_number("General.Verbosity", 3) @time SolidModels.gmsh.model.mesh.generate(1) @time SolidModels.gmsh.model.mesh.generate(2) @time SolidModels.gmsh.model.mesh.generate(3) diff --git a/src/schematics/ExamplePDK/utils.jl b/src/schematics/ExamplePDK/utils.jl index 2698b2c30..e2433e972 100644 --- a/src/schematics/ExamplePDK/utils.jl +++ b/src/schematics/ExamplePDK/utils.jl @@ -68,12 +68,21 @@ Return a `CoordinateSystem` with a simple scaffolded bridge that spans `style`. function bridge_geometry(style::Paths.SimpleCPW) cs = CoordinateSystem(uniquename("bridge")) h_ground_ground = 2 * Paths.extent(style) - place!(cs, centered(Rectangle(10μm, h_ground_ground + 20μm)), LayerVocabulary.BRIDGE) - place!( - cs, - centered(Rectangle(16μm, h_ground_ground + 10μm)), - LayerVocabulary.BRIDGE_BASE + bridge_width = 10μm + scaffold_width = 16μm + scaffold_margin = 5μm + foot_margin = 5μm + rect_bridge = centered( + Rectangle(bridge_width, h_ground_ground + 2 * (scaffold_margin + foot_margin)) ) + rect_scaffold = + centered(Rectangle(scaffold_width, h_ground_ground + 2 * scaffold_margin)) + place!(cs, rect_bridge, LayerVocabulary.BRIDGE) + place!(cs, rect_scaffold, LayerVocabulary.BRIDGE_BASE) + # Mesh/conformality control -- avoid stray 1D "staple" attachment points + rect_control = intersect2d(rect_bridge, rect_scaffold) + place!(cs, only_solidmodel(rect_control), LayerVocabulary.MESH_CONTROL) + place!(cs, only_solidmodel(rect_control), LayerVocabulary.MESH_CONTROL) return cs end diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 88a6ae5fe..115609ef8 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1011,9 +1011,9 @@ function remove_group!(group::PhysicalGroup; recursive=true, remove_entities=tru end """ - connected_components(dim::Int, tags::Vector{Int32}; geometric_tol=1e-6) - connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; geometric_tol=1e-6) - connected_components(sm::SolidModel, groups, dim=2; geometric_tol=1e-6) + connected_components(dim::Int, tags::Vector{Int32}; staple_tol=1e-6) + connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; staple_tol=1e-6) + connected_components(sm::SolidModel, groups, dim=2; staple_tol=1e-6) Find connected components among SolidModel entities at dimension `dim` with the given `tags` or physical group names. @@ -1022,12 +1022,10 @@ Uses union-find with path compression on the adjacency graph from `gmsh.model.ge For `dim == 2`, also unites entities that share a "stray" 1D entity that lies in the interior of a 2D entity without being one of its topological boundary curves. This is -necessary because OpenCascade's global fragment leaves curves that are coincident with -a face's interior geometrically embedded but topologically detached, so `getAdjacencies` -does not see the connection (a typical case is the foot edge of a staple air-bridge leg -landing on a ground plane). A 1D entity is considered to lie on a 2D entity's interior -if `getClosestPoint` returns a distance ≤ `geometric_tol` (in `STP_UNIT`) for every -sampled parametric point on the curve. Set `geometric_tol = 0` to disable. +necessary even after embedding with `fragment` because OpenCascade's `getAdjacencies` +does not see the connection (a typical case is the foot edge of a "staple" air-bridge leg +landing on a ground plane). Checking stray 1D entities can be relatively slow if they exist, so +it's better to add dummy 2D entities that attach to them. Set `staple_tol=0` to disable. Returns a `Vector{Vector{Tuple{Int32, Int32}}}` where each inner vector contains the entity dimtags of one connected component. @@ -1047,7 +1045,7 @@ end connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; kwargs...) = connected_components(dim, entitytags(sm[group, dim]); kwargs...) -function connected_components(dim::Integer, tags::Vector{Int32}; geometric_tol::Real=1e-6) +function connected_components(dim::Integer, tags::Vector{Int32}; staple_tol=1e-6) n = length(tags) isempty(tags) && return Vector{Tuple{Int32, Int32}}[] n == 1 && return [[(Int32(dim), only(tags))]] @@ -1092,8 +1090,7 @@ function connected_components(dim::Integer, tags::Vector{Int32}; geometric_tol:: # Only the dim=2 / dim-1=1 case (curve in face) is handled — this catches the # staple-bridge foot landing on an interior of a metal plane. For dim=3, "face inside # volume interior" is not a typical Palace configuration so we skip it. - if dim == 2 && geometric_tol > 0 - tag_to_idx = Dict(t => i for (i, t) in enumerate(tags)) + if dim == 2 && staple_tol > 0 bbox_cache = Dict{Int32, NTuple{6, Float64}}() get_bbox(d, t) = get!(bbox_cache, t) do @@ -1106,8 +1103,8 @@ function connected_components(dim::Integer, tags::Vector{Int32}; geometric_tol:: for (j, ftag) in enumerate(tags) j == owner_idx && continue find(j) == find(owner_idx) && continue - _bbox_contains(get_bbox(dim, ftag), ebbox; pad=geometric_tol) || continue - _curve_lies_on_face(btag, ftag; tol=geometric_tol) || continue + _bbox_contains(get_bbox(dim, ftag), ebbox; pad=staple_tol) || continue + _curve_lies_on_face(btag, ftag; tol=staple_tol) || continue unite(owner_idx, j) end end diff --git a/test/test_connected_components.jl b/test/test_connected_components.jl index b0e888670..5587c0157 100644 --- a/test/test_connected_components.jl +++ b/test/test_connected_components.jl @@ -65,12 +65,12 @@ tags = Int32[1; 2; leg_faces] # Topology only: ground plane (tag 1) is disconnected from each leg face. - result_topo = connected_components(2, tags; geometric_tol=0.0) # tol=0.0 turns off augmentation + result_topo = connected_components(2, tags; staple_tol=0.0) # tol=0.0 turns off augmentation @test length(result_topo) == 2 # Geometric augmentation: the foot edges lie in the ground plane's interior # and are boundary edges of the leg faces → all united into 1 component. - result_geom = connected_components(2, tags; geometric_tol=1e-6) + result_geom = connected_components(2, tags; staple_tol=1e-6) @test length(result_geom) == 1 end From 5538b87c9c31fd0c891acfa4f44a728afcaa0432 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Mon, 1 Jun 2026 12:52:38 +0200 Subject: [PATCH 08/15] WIP exploring staple connectivity --- examples/DemoQPU17/solidmodel.jl | 11 ++++++----- src/schematics/ExamplePDK/utils.jl | 9 ++++----- test/test_connected_components.jl | 22 +++++++++++++++++++--- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index 14cfb909d..cf806a97f 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -14,17 +14,18 @@ if length(target.rendering_options.retained_physical_groups) < 10 lumped_elements = [("lumped_element_$i", 2) for i = 1:34] append!(target.rendering_options.retained_physical_groups, ports, lumped_elements) end -# # This is fine for geometry but broke meshing the one try I gave it -# empty!(target.bounding_layers) # Model includes everything, no need to intersect with bounding box -# # But then we have to make "exterior_boundary" ourselves -# push!(target.postrenderer, ("exterior_boundary", SolidModels.get_boundary, ("simulated_area_extrusion", 3))) sm = SolidModel("demo"; overwrite=true) SolidModels.gmsh.option.set_number("General.Verbosity", 2) -@time render!(sm, schematic, target) # ~30 min +@time render!(sm, schematic, target) # ~30 min => 1 hr with mesh control on bridges DeviceLayout.save("qpu17.xao", sm) # Verify port connectivity (~2 min) +# < 1s without staple detection +# But still >2 min with mesh control on bridges (only staples are from intersection XSTY), why? +# The bridges shouldn't need any isInside checks in that case because the bounding box checks fail +# So maybe those are expensive? Need to investigate, may still be room for improvement +# Otherwise might just leave QPU17 without the mesh control polygons @time conn = SolidModels.check_port_connectivity(sm, ["port_$i" for i=1:42], ["metal"]; dim=2) for i = 1:42 port = "port_$i" diff --git a/src/schematics/ExamplePDK/utils.jl b/src/schematics/ExamplePDK/utils.jl index e2433e972..f43585ea8 100644 --- a/src/schematics/ExamplePDK/utils.jl +++ b/src/schematics/ExamplePDK/utils.jl @@ -70,19 +70,18 @@ function bridge_geometry(style::Paths.SimpleCPW) h_ground_ground = 2 * Paths.extent(style) bridge_width = 10μm scaffold_width = 16μm - scaffold_margin = 5μm - foot_margin = 5μm + scaffold_gap = 5μm + foot_length = 5μm rect_bridge = centered( - Rectangle(bridge_width, h_ground_ground + 2 * (scaffold_margin + foot_margin)) + Rectangle(bridge_width, h_ground_ground + 2 * (scaffold_gap + foot_length)) ) rect_scaffold = - centered(Rectangle(scaffold_width, h_ground_ground + 2 * scaffold_margin)) + centered(Rectangle(scaffold_width, h_ground_ground + 2 * scaffold_gap)) place!(cs, rect_bridge, LayerVocabulary.BRIDGE) place!(cs, rect_scaffold, LayerVocabulary.BRIDGE_BASE) # Mesh/conformality control -- avoid stray 1D "staple" attachment points rect_control = intersect2d(rect_bridge, rect_scaffold) place!(cs, only_solidmodel(rect_control), LayerVocabulary.MESH_CONTROL) - place!(cs, only_solidmodel(rect_control), LayerVocabulary.MESH_CONTROL) return cs end diff --git a/test/test_connected_components.jl b/test/test_connected_components.jl index 5587c0157..ed56fa246 100644 --- a/test/test_connected_components.jl +++ b/test/test_connected_components.jl @@ -52,6 +52,7 @@ fresh_model("stray_edge") gmsh.model.occ.addRectangle(-10, -10, 0, 20, 20) gmsh.model.occ.addRectangle(0, 0, 1, 1, 1) + gmsh.model.occ.addRectangle(0, 0, 0, 1, 1) gmsh.model.occ.addPoint(0, 0, 0) gmsh.model.occ.addPoint(0, 1, 0) gmsh.model.occ.addPoint(1, 1, 0) @@ -59,10 +60,10 @@ l1 = gmsh.model.occ.addLine(9, 10) l2 = gmsh.model.occ.addLine(11, 12) ext = gmsh.model.occ.extrude([(1, 9), (1, 10)], 0.0, 0.0, 1.0) - gmsh.model.occ.fragment([(1, l1), (1, l2)], [(2, 1), (2, 2), (2, 3), (2, 4)]) + frag, _ = gmsh.model.occ.fragment([(1, l1), (1, l2)], [(2, 1), (2, 2), (2, 3), (2, 4), (2, 5)]) gmsh.model.occ.synchronize() - leg_faces = Int32[dt[2] for dt in ext if dt[1] == 2] - tags = Int32[1; 2; leg_faces] + leg_faces = Int32[dt[2] for dt in frag if dt[1] == 2] + tags = leg_faces # Topology only: ground plane (tag 1) is disconnected from each leg face. result_topo = connected_components(2, tags; staple_tol=0.0) # tol=0.0 turns off augmentation @@ -74,6 +75,21 @@ @test length(result_geom) == 1 end + @testset "staple bridge connects" setup = [CommonTestSetup] begin + cs = DeviceLayout.SchematicDrivenLayout.ExamplePDK.bridge_geometry(Paths.CPW(10μm, 6μm)) + place!(cs, centered(Rectangle(1mm, 1mm)), :gnd) + sm = SolidModel("test"; overwrite=true) + render!(sm, cs; postrender_ops=[ + SolidModels.staple_bridge_postrendering(; + base="bridge_base", + bridge="bridge", + bridge_height=10μm # Exaggerated, for visualization + )...,], solidmodel=true) + @test length(connected_components(sm, ["bridge_metal", "gnd"])) == 1 + # Works even without stapling + @test length(connected_components(sm, ["bridge_metal", "gnd"], staple_tol=0)) == 1 + end + @testset "shared-boundary volumes via fragment" begin fresh_model("shared") # Two overlapping boxes — fragment will create shared boundary surfaces From 36a2721e626cfc56d5b8e8d0c2f577782bd11c37 Mon Sep 17 00:00:00 2001 From: Greg Peairs Date: Mon, 1 Jun 2026 20:14:17 +0200 Subject: [PATCH 09/15] Revert mesh/conformality control on bridge since it increases render time too much --- examples/DemoQPU17/solidmodel.jl | 2 +- src/schematics/ExamplePDK/utils.jl | 6 +----- src/solidmodels/postrender.jl | 31 ++++++++++++++++++++++-------- test/test_connected_components.jl | 27 ++++++++++++++++++-------- 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index cf806a97f..3770794fe 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -64,7 +64,7 @@ config = Dict( "Domains" => Dict( "Materials" => [ Dict( - # Vaccuum + # Vacuum "Attributes" => [attributes["vacuum"]], "Permeability" => 1.0, "Permittivity" => 1.0 diff --git a/src/schematics/ExamplePDK/utils.jl b/src/schematics/ExamplePDK/utils.jl index f43585ea8..6b84f27a1 100644 --- a/src/schematics/ExamplePDK/utils.jl +++ b/src/schematics/ExamplePDK/utils.jl @@ -75,13 +75,9 @@ function bridge_geometry(style::Paths.SimpleCPW) rect_bridge = centered( Rectangle(bridge_width, h_ground_ground + 2 * (scaffold_gap + foot_length)) ) - rect_scaffold = - centered(Rectangle(scaffold_width, h_ground_ground + 2 * scaffold_gap)) + rect_scaffold = centered(Rectangle(scaffold_width, h_ground_ground + 2 * scaffold_gap)) place!(cs, rect_bridge, LayerVocabulary.BRIDGE) place!(cs, rect_scaffold, LayerVocabulary.BRIDGE_BASE) - # Mesh/conformality control -- avoid stray 1D "staple" attachment points - rect_control = intersect2d(rect_bridge, rect_scaffold) - place!(cs, only_solidmodel(rect_control), LayerVocabulary.MESH_CONTROL) return cs end diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 115609ef8..94c70d0c0 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1091,19 +1091,34 @@ function connected_components(dim::Integer, tags::Vector{Int32}; staple_tol=1e-6 # staple-bridge foot landing on an interior of a metal plane. For dim=3, "face inside # volume interior" is not a typical Palace configuration so we skip it. if dim == 2 && staple_tol > 0 - bbox_cache = Dict{Int32, NTuple{6, Float64}}() - get_bbox(d, t) = - get!(bbox_cache, t) do - return gmsh.model.getBoundingBox(d, t) - end + bbox_tree = RTree{Float64, 3}(Tuple{Int, Int32}) + function convertel(enumtag) + bbox = gmsh.model.get_bounding_box(dim, enumtag[2]) + return SpatialIndexing.SpatialElem( + SpatialIndexing.Rect((bbox[1:3]...,), (bbox[4:6]...,)), + nothing, + enumtag + ) + end + SpatialIndexing.load!(bbox_tree, enumerate(tags); convertel=convertel) + for (btag, ps) in boundary_to_parents - length(ps) == 1 || continue + length(ps) == 1 || continue # Only single-parent edges are problematic owner_idx = ps[1] ebbox = gmsh.model.getBoundingBox(dim - 1, btag) - for (j, ftag) in enumerate(tags) + candidates = SpatialIndexing.intersects_with( + bbox_tree, + SpatialIndexing.Rect((ebbox[1:3]...,), (ebbox[4:6]...,)) + ) + for elem in candidates + j, ftag = elem.val j == owner_idx && continue find(j) == find(owner_idx) && continue - _bbox_contains(get_bbox(dim, ftag), ebbox; pad=staple_tol) || continue + _bbox_contains( + [elem.mbr.low..., elem.mbr.high...], + ebbox; + pad=staple_tol + ) || continue _curve_lies_on_face(btag, ftag; tol=staple_tol) || continue unite(owner_idx, j) end diff --git a/test/test_connected_components.jl b/test/test_connected_components.jl index ed56fa246..339e3b7c4 100644 --- a/test/test_connected_components.jl +++ b/test/test_connected_components.jl @@ -60,7 +60,10 @@ l1 = gmsh.model.occ.addLine(9, 10) l2 = gmsh.model.occ.addLine(11, 12) ext = gmsh.model.occ.extrude([(1, 9), (1, 10)], 0.0, 0.0, 1.0) - frag, _ = gmsh.model.occ.fragment([(1, l1), (1, l2)], [(2, 1), (2, 2), (2, 3), (2, 4), (2, 5)]) + frag, _ = gmsh.model.occ.fragment( + [(1, l1), (1, l2)], + [(2, 1), (2, 2), (2, 3), (2, 4), (2, 5)] + ) gmsh.model.occ.synchronize() leg_faces = Int32[dt[2] for dt in frag if dt[1] == 2] tags = leg_faces @@ -76,15 +79,23 @@ end @testset "staple bridge connects" setup = [CommonTestSetup] begin - cs = DeviceLayout.SchematicDrivenLayout.ExamplePDK.bridge_geometry(Paths.CPW(10μm, 6μm)) + cs = DeviceLayout.SchematicDrivenLayout.ExamplePDK.bridge_geometry( + Paths.CPW(10μm, 6μm) + ) place!(cs, centered(Rectangle(1mm, 1mm)), :gnd) sm = SolidModel("test"; overwrite=true) - render!(sm, cs; postrender_ops=[ - SolidModels.staple_bridge_postrendering(; - base="bridge_base", - bridge="bridge", - bridge_height=10μm # Exaggerated, for visualization - )...,], solidmodel=true) + render!( + sm, + cs; + postrender_ops=[ + SolidModels.staple_bridge_postrendering(; + base="bridge_base", + bridge="bridge", + bridge_height=10μm # Exaggerated, for visualization + )... + ], + solidmodel=true + ) @test length(connected_components(sm, ["bridge_metal", "gnd"])) == 1 # Works even without stapling @test length(connected_components(sm, ["bridge_metal", "gnd"], staple_tol=0)) == 1 From da49b7a23387bfc70e8fc193513ccaf30faef4e3 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Mon, 1 Jun 2026 20:51:08 +0200 Subject: [PATCH 10/15] Rename staple detection to detect_non_boundary_contacts, off by default --- examples/DemoQPU17/solidmodel.jl | 164 +++++++++++++++--------------- src/solidmodels/postrender.jl | 22 ++-- test/test_connected_components.jl | 22 ++-- 3 files changed, 103 insertions(+), 105 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index 3770794fe..7713d8b5f 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -41,89 +41,89 @@ for i = 1:42 end println("All flux ports are `:short`, and all charge and readout ports are `:open`") -mesh_order = 1 -SolidModels.mesh_order(mesh_order) -SolidModels.gmsh.option.set_number("General.Verbosity", 3) -@time SolidModels.gmsh.model.mesh.generate(1) -@time SolidModels.gmsh.model.mesh.generate(2) -@time SolidModels.gmsh.model.mesh.generate(3) -meshfile = joinpath(@__DIR__, "qpu17_order$mesh_order.msh2") -save(meshfile, sm) +# mesh_order = 1 +# SolidModels.mesh_order(mesh_order) +# SolidModels.gmsh.option.set_number("General.Verbosity", 3) +# @time SolidModels.gmsh.model.mesh.generate(1) +# @time SolidModels.gmsh.model.mesh.generate(2) +# @time SolidModels.gmsh.model.mesh.generate(3) +# meshfile = joinpath(@__DIR__, "qpu17_order$mesh_order.msh2") +# save(meshfile, sm) -# Config -attributes = SolidModels.attributes(sm) -config = Dict( - "Problem" => Dict("Type" => "Eigenmode", "Verbose" => 2, "Output" => "postpro"), - "Model" => Dict( - "Mesh" => meshfile, - "L0" => 1e-6, # um is Palace default; record it anyway - "Refinement" => Dict( - "MaxIts" => 0 # Increase to enable AMR - ) - ), - "Domains" => Dict( - "Materials" => [ - Dict( - # Vacuum - "Attributes" => [attributes["vacuum"]], - "Permeability" => 1.0, - "Permittivity" => 1.0 - ), - Dict( - # Sapphire - "Attributes" => [attributes["substrate"]], - "Permeability" => [0.99999975, 0.99999975, 0.99999979], - "Permittivity" => [9.3, 9.3, 11.5], - "LossTan" => [3.0e-5, 3.0e-5, 8.6e-5], - "MaterialAxes" => [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]] - ) - ] - ), - "Boundaries" => Dict( - "PEC" => Dict( - "Attributes" => [attributes["metal"], attributes["exterior_boundary"]] - ), - "LumpedPort" => [] - ), - "Solver" => Dict( - "Order" => 2, - "Eigenmode" => Dict("N" => 2, "Tol" => 1.0e-6, "Target" => 2, "Save" => 2), - "Linear" => Dict("Type" => "Default", "Tol" => 1.0e-7, "MaxIts" => 500) - ) -) +# # Config +# attributes = SolidModels.attributes(sm) +# config = Dict( +# "Problem" => Dict("Type" => "Eigenmode", "Verbose" => 2, "Output" => "postpro"), +# "Model" => Dict( +# "Mesh" => meshfile, +# "L0" => 1e-6, # um is Palace default; record it anyway +# "Refinement" => Dict( +# "MaxIts" => 0 # Increase to enable AMR +# ) +# ), +# "Domains" => Dict( +# "Materials" => [ +# Dict( +# # Vacuum +# "Attributes" => [attributes["vacuum"]], +# "Permeability" => 1.0, +# "Permittivity" => 1.0 +# ), +# Dict( +# # Sapphire +# "Attributes" => [attributes["substrate"]], +# "Permeability" => [0.99999975, 0.99999975, 0.99999979], +# "Permittivity" => [9.3, 9.3, 11.5], +# "LossTan" => [3.0e-5, 3.0e-5, 8.6e-5], +# "MaterialAxes" => [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]] +# ) +# ] +# ), +# "Boundaries" => Dict( +# "PEC" => Dict( +# "Attributes" => [attributes["metal"], attributes["exterior_boundary"]] +# ), +# "LumpedPort" => [] +# ), +# "Solver" => Dict( +# "Order" => 2, +# "Eigenmode" => Dict("N" => 2, "Tol" => 1.0e-6, "Target" => 2, "Save" => 2), +# "Linear" => Dict("Type" => "Default", "Tol" => 1.0e-7, "MaxIts" => 500) +# ) +# ) -for i = 1:42 - node = schematic.index_dict[:port][i] - dirs = Dict(0.0° => "+X", 90.0° => "+Y", 180.0° => "-X", 270.0° => "-Y") - dir = dirs[rem(rotation(transformation(schematic, node)), 360°, RoundDown)] - push!( - config["Boundaries"]["LumpedPort"], - Dict( - "Index" => i, - "Attributes" => [attributes["port_$i"]], - "R" => 50, - "Direction" => dir - ) - ) -end +# for i = 1:42 +# node = schematic.index_dict[:port][i] +# dirs = Dict(0.0° => "+X", 90.0° => "+Y", 180.0° => "-X", 270.0° => "-Y") +# dir = dirs[rem(rotation(transformation(schematic, node)), 360°, RoundDown)] +# push!( +# config["Boundaries"]["LumpedPort"], +# Dict( +# "Index" => i, +# "Attributes" => [attributes["port_$i"]], +# "R" => 50, +# "Direction" => dir +# ) +# ) +# end -for i = 1:34 - node = schematic.index_dict[:lumped_element][i] - dirs = Dict(0.0° => "+Y", 180.0° => "-Y") - dir = dirs[rem(rotation(transformation(schematic, node)), 360°, RoundDown)] - push!( - config["Boundaries"]["LumpedPort"], - Dict( - "Index" => 42 + i, - "Attributes" => [attributes["lumped_element_$i"]], - "L" => 28.0e-9 + i * 0.05e-9, - "C" => 2.75e-15, - "Direction" => dir - ) - ) -end +# for i = 1:34 +# node = schematic.index_dict[:lumped_element][i] +# dirs = Dict(0.0° => "+Y", 180.0° => "-Y") +# dir = dirs[rem(rotation(transformation(schematic, node)), 360°, RoundDown)] +# push!( +# config["Boundaries"]["LumpedPort"], +# Dict( +# "Index" => 42 + i, +# "Attributes" => [attributes["lumped_element_$i"]], +# "L" => 28.0e-9 + i * 0.05e-9, +# "C" => 2.75e-15, +# "Direction" => dir +# ) +# ) +# end -using JSON -open(joinpath(@__DIR__, "config.json"), "w") do f - return JSON.print(f, config) -end +# using JSON +# open(joinpath(@__DIR__, "config.json"), "w") do f +# return JSON.print(f, config) +# end diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 94c70d0c0..312b21066 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1011,21 +1011,21 @@ function remove_group!(group::PhysicalGroup; recursive=true, remove_entities=tru end """ - connected_components(dim::Int, tags::Vector{Int32}; staple_tol=1e-6) - connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; staple_tol=1e-6) - connected_components(sm::SolidModel, groups, dim=2; staple_tol=1e-6) + connected_components(dim::Int, tags::Vector{Int32}; detect_non_boundary_contacts=false) + connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; detect_non_boundary_contacts=false) + connected_components(sm::SolidModel, groups, dim=2; detect_non_boundary_contacts=false) Find connected components among SolidModel entities at dimension `dim` with the given `tags` or physical group names. Two entities are connected if they share any boundary entity (dimension `dim - 1`). Uses union-find with path compression on the adjacency graph from `gmsh.model.getAdjacencies`. -For `dim == 2`, also unites entities that share a "stray" 1D entity that lies in the -interior of a 2D entity without being one of its topological boundary curves. This is +For `dim == 2`, set `detect_non_boundary_contacts=true` to unite entities that share a "stray" +1D entity that lies in the interior of a 2D entity without being one of its topological boundary curves. This is necessary even after embedding with `fragment` because OpenCascade's `getAdjacencies` does not see the connection (a typical case is the foot edge of a "staple" air-bridge leg landing on a ground plane). Checking stray 1D entities can be relatively slow if they exist, so -it's better to add dummy 2D entities that attach to them. Set `staple_tol=0` to disable. +it may be preferable to add dummy 2D entities that attach to them. Returns a `Vector{Vector{Tuple{Int32, Int32}}}` where each inner vector contains the entity dimtags of one connected component. @@ -1045,7 +1045,7 @@ end connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; kwargs...) = connected_components(dim, entitytags(sm[group, dim]); kwargs...) -function connected_components(dim::Integer, tags::Vector{Int32}; staple_tol=1e-6) +function connected_components(dim::Integer, tags::Vector{Int32}; detect_non_boundary_contacts=false) n = length(tags) isempty(tags) && return Vector{Tuple{Int32, Int32}}[] n == 1 && return [[(Int32(dim), only(tags))]] @@ -1090,7 +1090,7 @@ function connected_components(dim::Integer, tags::Vector{Int32}; staple_tol=1e-6 # Only the dim=2 / dim-1=1 case (curve in face) is handled — this catches the # staple-bridge foot landing on an interior of a metal plane. For dim=3, "face inside # volume interior" is not a typical Palace configuration so we skip it. - if dim == 2 && staple_tol > 0 + if dim == 2 && detect_non_boundary_contacts bbox_tree = RTree{Float64, 3}(Tuple{Int, Int32}) function convertel(enumtag) bbox = gmsh.model.get_bounding_box(dim, enumtag[2]) @@ -1116,10 +1116,10 @@ function connected_components(dim::Integer, tags::Vector{Int32}; staple_tol=1e-6 find(j) == find(owner_idx) && continue _bbox_contains( [elem.mbr.low..., elem.mbr.high...], - ebbox; - pad=staple_tol + ebbox, + pad=0.0 ) || continue - _curve_lies_on_face(btag, ftag; tol=staple_tol) || continue + _curve_lies_on_face(btag, ftag; tol=0.0) || continue unite(owner_idx, j) end end diff --git a/test/test_connected_components.jl b/test/test_connected_components.jl index 339e3b7c4..d4b3e8f08 100644 --- a/test/test_connected_components.jl +++ b/test/test_connected_components.jl @@ -52,7 +52,6 @@ fresh_model("stray_edge") gmsh.model.occ.addRectangle(-10, -10, 0, 20, 20) gmsh.model.occ.addRectangle(0, 0, 1, 1, 1) - gmsh.model.occ.addRectangle(0, 0, 0, 1, 1) gmsh.model.occ.addPoint(0, 0, 0) gmsh.model.occ.addPoint(0, 1, 0) gmsh.model.occ.addPoint(1, 1, 0) @@ -62,27 +61,26 @@ ext = gmsh.model.occ.extrude([(1, 9), (1, 10)], 0.0, 0.0, 1.0) frag, _ = gmsh.model.occ.fragment( [(1, l1), (1, l2)], - [(2, 1), (2, 2), (2, 3), (2, 4), (2, 5)] + [(2, 1), (2, 2), (2, 3), (2, 4)] ) gmsh.model.occ.synchronize() - leg_faces = Int32[dt[2] for dt in frag if dt[1] == 2] - tags = leg_faces + tags = Int32[dt[2] for dt in frag if dt[1] == 2] # Topology only: ground plane (tag 1) is disconnected from each leg face. - result_topo = connected_components(2, tags; staple_tol=0.0) # tol=0.0 turns off augmentation + result_topo = connected_components(2, tags) # default: no augmentation @test length(result_topo) == 2 # Geometric augmentation: the foot edges lie in the ground plane's interior # and are boundary edges of the leg faces → all united into 1 component. - result_geom = connected_components(2, tags; staple_tol=1e-6) + result_geom = connected_components(2, tags; detect_non_boundary_contacts=true) @test length(result_geom) == 1 end - @testset "staple bridge connects" setup = [CommonTestSetup] begin + @testset "staple bridge connects" begin cs = DeviceLayout.SchematicDrivenLayout.ExamplePDK.bridge_geometry( - Paths.CPW(10μm, 6μm) + Paths.CPW(10e3nm, 6e3nm) ) - place!(cs, centered(Rectangle(1mm, 1mm)), :gnd) + place!(cs, centered(Rectangle(1e6nm, 1e6nm)), :gnd) sm = SolidModel("test"; overwrite=true) render!( sm, @@ -96,9 +94,9 @@ ], solidmodel=true ) - @test length(connected_components(sm, ["bridge_metal", "gnd"])) == 1 - # Works even without stapling - @test length(connected_components(sm, ["bridge_metal", "gnd"], staple_tol=0)) == 1 + @test length(connected_components(sm, ["bridge_metal", "gnd"], detect_non_boundary_contacts=true)) == 1 + # Does not work without stapling + @test length(connected_components(sm, ["bridge_metal", "gnd"])) == 2 end @testset "shared-boundary volumes via fragment" begin From b78f5df699dea1189787918c244a31992d70da1e Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Mon, 1 Jun 2026 20:58:46 +0200 Subject: [PATCH 11/15] Add staple keyword to check_port_connectivity --- examples/DemoQPU17/solidmodel.jl | 9 +++------ src/solidmodels/postrender.jl | 26 ++++++++++++++++++-------- test/test_connected_components.jl | 14 +++++++++----- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index 7713d8b5f..6d602dc6b 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -21,12 +21,9 @@ SolidModels.gmsh.option.set_number("General.Verbosity", 2) DeviceLayout.save("qpu17.xao", sm) # Verify port connectivity (~2 min) -# < 1s without staple detection -# But still >2 min with mesh control on bridges (only staples are from intersection XSTY), why? -# The bridges shouldn't need any isInside checks in that case because the bounding box checks fail -# So maybe those are expensive? Need to investigate, may still be room for improvement -# Otherwise might just leave QPU17 without the mesh control polygons -@time conn = SolidModels.check_port_connectivity(sm, ["port_$i" for i=1:42], ["metal"]; dim=2) +# < 1s without staple detection, ~30 seconds with +@time conn = SolidModels.check_port_connectivity(sm, + ["port_$i" for i=1:42], ["metal"]; dim=2, detect_non_boundary_contacts=true) for i = 1:42 port = "port_$i" component_node = schematic.index_dict[:port][i] diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 312b21066..718f738f6 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1045,7 +1045,11 @@ end connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; kwargs...) = connected_components(dim, entitytags(sm[group, dim]); kwargs...) -function connected_components(dim::Integer, tags::Vector{Int32}; detect_non_boundary_contacts=false) +function connected_components( + dim::Integer, + tags::Vector{Int32}; + detect_non_boundary_contacts=false +) n = length(tags) isempty(tags) && return Vector{Tuple{Int32, Int32}}[] n == 1 && return [[(Int32(dim), only(tags))]] @@ -1114,11 +1118,8 @@ function connected_components(dim::Integer, tags::Vector{Int32}; detect_non_boun j, ftag = elem.val j == owner_idx && continue find(j) == find(owner_idx) && continue - _bbox_contains( - [elem.mbr.low..., elem.mbr.high...], - ebbox, - pad=0.0 - ) || continue + _bbox_contains([elem.mbr.low..., elem.mbr.high...], ebbox, pad=0.0) || + continue _curve_lies_on_face(btag, ftag; tol=0.0) || continue unite(owner_idx, j) end @@ -1208,6 +1209,9 @@ classify them algorithmically but the results are generally not electrically mea - `dim=2`: dimension of port and metal groups. `3` is appropriate for volumetric lumped ports in a 3D model; `2` would be used for surfaces. + - `detect_non_boundary_contacts=false`: If `true` and `dim == 2`, then `connected_components` + finds non-conformal contacts (1D edges in the interior of 2D surfaces, like the foot edge + of a "staple" air-bridge leg landing on a ground plane) and treats them as connections # Algorithm @@ -1223,13 +1227,19 @@ classify them algorithmically but the results are generally not electrically mea See also [`connected_components`](@ref). """ -function check_port_connectivity(sm::SolidModel, port_names, metal_groups; dim::Integer=2) +function check_port_connectivity( + sm::SolidModel, + port_names, + metal_groups; + dim::Integer=2, + detect_non_boundary_contacts=false +) SolidModels._synchronize!(sm) # Build connected-components tag → component-index map. tag_to_comp = Dict{Int32, Int}() if !isempty(metal_groups) - comps = connected_components(sm, metal_groups, dim) + comps = connected_components(sm, metal_groups, dim; detect_non_boundary_contacts) for (ci, comp_dimtags) in enumerate(comps) for (_, tag) in comp_dimtags tag_to_comp[tag] = ci diff --git a/test/test_connected_components.jl b/test/test_connected_components.jl index d4b3e8f08..756b2f840 100644 --- a/test/test_connected_components.jl +++ b/test/test_connected_components.jl @@ -59,10 +59,8 @@ l1 = gmsh.model.occ.addLine(9, 10) l2 = gmsh.model.occ.addLine(11, 12) ext = gmsh.model.occ.extrude([(1, 9), (1, 10)], 0.0, 0.0, 1.0) - frag, _ = gmsh.model.occ.fragment( - [(1, l1), (1, l2)], - [(2, 1), (2, 2), (2, 3), (2, 4)] - ) + frag, _ = + gmsh.model.occ.fragment([(1, l1), (1, l2)], [(2, 1), (2, 2), (2, 3), (2, 4)]) gmsh.model.occ.synchronize() tags = Int32[dt[2] for dt in frag if dt[1] == 2] @@ -94,7 +92,13 @@ ], solidmodel=true ) - @test length(connected_components(sm, ["bridge_metal", "gnd"], detect_non_boundary_contacts=true)) == 1 + @test length( + connected_components( + sm, + ["bridge_metal", "gnd"], + detect_non_boundary_contacts=true + ) + ) == 1 # Does not work without stapling @test length(connected_components(sm, ["bridge_metal", "gnd"])) == 2 end From 9b690fc9fff62d8b0950fc98b4d4a514e760dac7 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Thu, 11 Jun 2026 04:15:44 +0200 Subject: [PATCH 12/15] Refactor QPU17 SolidModel script --- examples/DemoQPU17/solidmodel.jl | 482 ++++++++++++++++++++++++------- 1 file changed, 371 insertions(+), 111 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index 6d602dc6b..7438cdf86 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -1,13 +1,36 @@ # QPU17 SolidModel -include("DemoQPU17.jl") -schematic, artwork = DemoQPU17.qpu17_demo(savegds=true) +# +# To run this script: +# +# Open a Julia REPL in this directory, then: +# ] activate . +# ] instantiate # precompiles dependencies (slow the first time) +# include("solidmodel.jl") # defines the functions below and builds the schematic/target +# +# Then drive the simulation interactively: +# sm = qpu17_solidmodel(schematic, target) # render geometry (~30 min) +# conn = verify_port_connectivity(sm, schematic) # check + assert port shorts/opens +# DeviceLayout.save(joinpath(@__DIR__, "qpu17.xao"), sm) # optional: save geometry +# +# Mesh, then build a Palace config (pick one simulation type) and optionally run it: +# meshes = mesh_family(sm; scales=[1.0], order=1) # order=2 for driven +# config = eigenmode_configfile(sm, schematic; mesh_file=meshes[1]) # eigenmode, or: +# # config = driven_configfile(sm; mesh_file=meshes[1]) # driven sweep +# palace_job(config; palace_build=..., np=..., nt=...) using DeviceLayout, - .SchematicDrivenLayout, .PreferredUnits, .SchematicDrivenLayout.ExamplePDK + .SchematicDrivenLayout, + .PreferredUnits, + .SchematicDrivenLayout.ExamplePDK using .ExamplePDK.LayerVocabulary -using FileIO +using FileIO, JSON + +include("DemoQPU17.jl") + +schematic, artwork = DemoQPU17.qpu17_demo(savegds=true) place!(schematic.coordinate_system, bounds(schematic.coordinate_system), SIMULATED_AREA) + target = ExamplePDK.SINGLECHIP_SOLIDMODEL_TARGET if length(target.rendering_options.retained_physical_groups) < 10 ports = [("port_$i", 2) for i = 1:42] @@ -15,112 +38,349 @@ if length(target.rendering_options.retained_physical_groups) < 10 append!(target.rendering_options.retained_physical_groups, ports, lumped_elements) end -sm = SolidModel("demo"; overwrite=true) -SolidModels.gmsh.option.set_number("General.Verbosity", 2) -@time render!(sm, schematic, target) # ~30 min => 1 hr with mesh control on bridges - -DeviceLayout.save("qpu17.xao", sm) -# Verify port connectivity (~2 min) -# < 1s without staple detection, ~30 seconds with -@time conn = SolidModels.check_port_connectivity(sm, - ["port_$i" for i=1:42], ["metal"]; dim=2, detect_non_boundary_contacts=true) -for i = 1:42 - port = "port_$i" - component_node = schematic.index_dict[:port][i] - role = split(component_node.component.name, "_")[2] - if role == "XY" || role == "RO" - @assert conn[port] == :open "$role port $i is $(conn[port]); should be :open" - elseif role == "Z" - @assert conn[port] == :short "$role port $i is $(conn[port]); should be :short" - else - error("Invalid port role") +function qpu17_solidmodel(schematic, target) + sm = SolidModel("demo"; overwrite=true) + SolidModels.gmsh.option.set_number("General.Verbosity", 2) + @time "Rendering to SolidModel" render!(sm, schematic, target) # 10-30 min depending on hardware + # Use SolidModels.gmsh to reach any command in the gmsh Julia API; `mesh_family` + # generates and saves meshes once the geometry has been rendered. + return sm +end + +""" + verify_port_connectivity(sm::SolidModel, schematic; n_ports=42) -> Dict + +Check 2D connectivity of each `port_i` to `metal` and assert it matches the port's role: +flux (`Z`) ports must be `:short`; charge (`XY`) and readout (`RO`) ports must be `:open`. +Returns the connectivity dict from `SolidModels.check_port_connectivity`. +""" +function verify_port_connectivity(sm::SolidModel, schematic; n_ports=42) + # Fast without staple detection, but then air bridges don't connect + # ~1 minute with staples (non-boundary contacts) + @time conn = SolidModels.check_port_connectivity( + sm, + ["port_$i" for i = 1:n_ports], + ["metal"]; + dim=2, + detect_non_boundary_contacts=true + ) + for i = 1:n_ports + port = "port_$i" + component_node = schematic.index_dict[:port][i] + role = split(component_node.component.name, "_")[2] + if role == "XY" || role == "RO" + @assert conn[port] == :open "$role port $i is $(conn[port]); should be :open" + elseif role == "Z" + @assert conn[port] == :short "$role port $i is $(conn[port]); should be :short" + else + error("Invalid port role") + end + end + println("All flux ports are `:short`, and all charge and readout ports are `:open`") + return conn +end + +""" + driven_configfile(sm::SolidModel, schematic; kwargs...) -> Dict + +Assemble a Palace configuration dictionary for a **driven** simulation of the QPU17 model. +The result is directly `JSON.print`-able into a `config.json` that Palace accepts. + +Modelled on `SingleTransmon.configfile`, adapted for the QPU17 physical-group layout: + + - 42 `port_i` 2D groups (50 Ω lumped ports; exactly one is the driven excitation) + - 34 `lumped_element_j` 2D groups (LC lumped elements representing junctions) + - `vacuum`/`substrate` 3D materials, `metal` PEC, `exterior_boundary` first-order absorbing + +# Keyword arguments + + - `palace_build = nothing`: path to a Palace build. When supplied, the function imports + `JSONSchema` and validates the config against `\$palace_build/bin/schema/config-schema.json`. + (`JSONSchema` is not a hard dep of this example — add it to the Project if you want validation.) + - `solver_order = 2`: FE order. + - `amr = 0`: adaptive mesh refinement iterations. + - `excitation_port = 1`: which `port_i` gets `Excitation = true`. Every other port is a 50 Ω + passive termination. + - `min_freq_ghz`, `max_freq_ghz`, `freq_step_ghz`: driven-sweep bounds (GHz). + - `save_step = 10`: Paraview output cadence. + - `n_ports = 42`, `n_lumped_elements = 34`: must match the counts in `retained_physical_groups`. + - `lumped_L`, `lumped_C`: per-junction inductance/capacitance. The defaults mirror the + SingleTransmon values, split into two junctions for a SQUID — + override per-junction by editing the returned dict if needed. + - `port_R = 50`: termination impedance for 50 Ω ports. + - `lumped_direction = "+Y"`: lumped element orientation. (Lumped port directions are computed from the schematic.) + - `mesh_file`: path to the `.msh2` written by `mesh_family`. +""" +function driven_configfile( + sm::SolidModel, + schematic; + palace_build=nothing, + solver_order=2, + amr=0, + excitation_port=1, + min_freq_ghz=4.0, + max_freq_ghz=8.0, + freq_step_ghz=0.05, + save_step=10, + n_ports=42, + n_lumped_elements=34, + lumped_L=14.860e-9 * 2, + lumped_C=5.5e-15 / 2, + port_R=50, + lumped_direction="+Y", + mesh_file=joinpath(@__DIR__, "qpu17.msh2") +) + attributes = SolidModels.attributes(sm) + config = base_config(sm, schematic; + mesh_file, amr, n_ports, n_lumped_elements, + lumped_L, lumped_C, port_R, lumped_direction) + + # Build LumpedPort entries: driven 50Ω ports first (one excited), then LC junctions. + lumped_ports = config["Boundaries"]["LumpedPort"] + for i = 1:n_ports + lumped_ports[i]["Excitation"] = i == excitation_port + end + config["Problem"]["Type"] = "Driven" + config["Solver"] = Dict( + "Order" => solver_order, + "Driven" => Dict( + "MinFreq" => min_freq_ghz, + "MaxFreq" => max_freq_ghz, + "FreqStep" => freq_step_ghz, + "SaveStep" => save_step + ), + "Linear" => + Dict("Type" => "Default", "Tol" => 1.0e-7, "MaxIts" => 500) + ) + + if !isnothing(palace_build) + validate_schema(config) + end + + return config +end + +""" + eigenmode_configfile(sm::SolidModel, schematic; kwargs...) -> Dict + +Assemble a Palace **eigenmode** configuration for the QPU17 model. Port and lumped-element +directions are read from each component's placement in `schematic` (unlike [`configfile`](@ref), +which uses a single fixed direction for the driven sweep), and `exterior_boundary` is treated +as PEC here rather than absorbing. + +# Keyword arguments + + - `palace_build = nothing`: path to a Palace build. When supplied, the function imports + `JSONSchema` and validates the config against `\$palace_build/bin/schema/config-schema.json`. + (`JSONSchema` is not a hard dep of this example — add it to the Project if you want validation.) + - `mesh_file`: path to the `.msh2` to simulate (e.g. an entry returned by `mesh_family`). + - `solver_order = 2`: FE order. + - `n_modes = 2`: number of eigenmodes to solve for. + - `amr = 0`: adaptive mesh refinement iterations. + - `n_ports = 42`, `n_lumped_elements = 34`: must match the counts in `retained_physical_groups`. + - `lumped_L`, `lumped_C`: per-junction inductance/capacitance. The defaults mirror the + SingleTransmon values, split into two junctions for a SQUID — + override per-junction by editing the returned dict if needed. + - `port_R = 50`: termination impedance for 50 Ω ports. + - `lumped_direction = "+Y"`: lumped element orientation. (Lumped port directions are computed from the schematic.) +""" +function eigenmode_configfile( + sm::SolidModel, + schematic; + palace_build=nothing, + mesh_file=joinpath(@__DIR__, "qpu17.msh2"), + solver_order=2, + n_modes=2, + amr=0, + n_ports=42, + n_lumped_elements=34, + lumped_L=14.860e-9 * 2, + lumped_C=5.5e-15 / 2, + port_R=50, + lumped_direction="+Y", +) + config = base_config(sm, schematic; + mesh_file, amr, n_ports, n_lumped_elements, + lumped_L, lumped_C, port_R, lumped_direction) + config["Problem"]["Type"] = "Eigenmode" + + config["Solver"] = Dict( + "Order" => solver_order, + "Eigenmode" => + Dict("N" => n_modes, "Tol" => 1.0e-6, "Target" => 2, "Save" => n_modes), + "Linear" => + Dict("Type" => "Default", "Tol" => 1.0e-7, "MaxIts" => 500) + ) + + if !isnothing(palace_build) + validate_schema(config) + end + + return config +end + +function base_config(sm, schematic; + mesh_file=joinpath(@__DIR__, "qpu17.msh2"), + amr=0, + n_ports=42, + n_lumped_elements=34, + lumped_L=14.860e-9 * 2, + lumped_C=5.5e-15 / 2, + port_R=50, + lumped_direction="+Y", +) + attributes = SolidModels.attributes(sm) + lumped_ports = Dict[] + for i = 1:n_ports + node = schematic.index_dict[:port][i] + dirs = Dict(0.0° => "+X", 90.0° => "+Y", + 180.0° => "-X", 270.0° => "-Y") + dir = dirs[rem( + rotation(transformation(schematic, node)), 360°, RoundDown)] + push!( + lumped_ports, + Dict( + "Index" => i, + "Attributes" => [attributes["port_$i"]], + "R" => port_R, + "Direction" => dir + ) + ) + end + for j = 1:n_lumped_elements + push!( + lumped_ports, + Dict( + "Index" => n_ports + j, + "Attributes" => [attributes["lumped_element_$j"]], + "L" => lumped_L + j*0.05e-9, # Stagger to avoid degeneracy + "C" => lumped_C, + "Direction" => lumped_direction + ) + ) + end + config = Dict( + "Problem" => Dict( + "Type" => "Driven", + "Verbose" => 2, + "Output" => joinpath(@__DIR__, "postpro/qpu17") + ), + "Model" => Dict( + "Mesh" => mesh_file, + "L0" => 1e-6, # µm is Palace's default length unit; record it anyway + "Refinement" => Dict("MaxIts" => amr) + ), + "Domains" => Dict( + "Materials" => [ + Dict( + # Vacuum + "Attributes" => [attributes["vacuum"]], + "Permeability" => 1.0, + "Permittivity" => 1.0 + ), + Dict( + # Sapphire (values match SingleTransmon example) + "Attributes" => [attributes["substrate"]], + "Permeability" => [0.99999975, 0.99999975, 0.99999979], + "Permittivity" => [9.3, 9.3, 11.5], + "LossTan" => [3.0e-5, 3.0e-5, 8.6e-5], + "MaterialAxes" => + [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]] + ) + ], + "Postprocessing" => Dict( + "Energy" => [Dict("Index" => 1,"Attributes" => [attributes["substrate"]])] + ) + ), + "Boundaries" => Dict( + "PEC" => Dict("Attributes" => [attributes["metal"]]), + "Absorbing" => + Dict("Attributes" => [attributes["exterior_boundary"]], + "Order" => 1), + "LumpedPort" => lumped_ports + ), + ) + return config +end + +function validate_schema(config) + # Lazy-load JSONSchema so the example stays usable without it as a hard dep. + @eval Main import JSONSchema + schema_dir = joinpath(palace_build, "bin", "schema") + schema = Main.JSONSchema.Schema( + JSON.parsefile(joinpath(schema_dir, "config-schema.json")); + parent_dir=schema_dir + ) + Main.JSONSchema.validate(schema, config) +end + +""" + palace_job(config::Dict; palace_build=nothing, np=0, nt=1) -> Nothing + +Write `config` to `config.json` next to this script, and optionally invoke Palace. +Mirrors `SingleTransmon.palace_job` but without the post-run eigenmode-CSV parsing +(driven sweeps produce `port-S.csv` etc. — inspect those yourself in `postpro/qpu17`). +""" +function palace_job(config::Dict; palace_build=nothing, np=0, nt=1) + cfg_path = joinpath(@__DIR__, "config.json") + println("Writing configuration file to $cfg_path") + open(cfg_path, "w") do f + return JSON.print(f, config, 2) + end + + if np > 0 && !isnothing(palace_build) + println("Running Palace: stdout -> log.out, stderr -> err.out") + withenv("PATH" => "$(ENV["PATH"]):$palace_build/bin") do + return run( + pipeline( + ignorestatus(`palace -np $np -nt $nt $cfg_path`), + stdout=joinpath(@__DIR__, "log.out"), + stderr=joinpath(@__DIR__, "err.out") + ) + ) + end + end + return nothing +end + +""" + mesh_family(sm::SolidModel; scales=[1.0, 0.5, 0.25], basename="qpu17", order=2) + -> Vector{String} + +Generate a sequence of meshes on the already-rendered `sm`, one per entry of `scales`, by +setting `SolidModels.mesh_scale(s)`, clearing any existing mesh, and regenerating 1D/2D/3D +at the requested element `order`. Each mesh is saved to `\$basename.h\$i.msh2` next to this +script, where `i` is the index into `scales` (0-based). + +Returns the vector of absolute filenames, in the same order as `scales`, suitable for +passing to `configfile(sm; mesh_file=...)` or `eigenmode_configfile(sm, schematic; mesh_file=...)`. + +The size-field callback reads `mesh_scale()` at evaluation time (see +`src/solidmodels/render.jl`), so changing the scale between runs does not require +re-rendering the geometry — only clearing + regenerating the mesh. +""" +function mesh_family( + sm::SolidModel; + scales=[1.0, 0.5, 0.25], + basename="qpu17", + order=2 +) + # Quadratic elements, with high-order optimization enabled (default). + SolidModels.mesh_order(order) + SolidModels.mesh_grading_default(0.75) + + files = String[] + for (i, s) in enumerate(scales) + label = "h$(i - 1), scale=$s" + SolidModels.mesh_scale(s) + SolidModels.gmsh.model.mesh.clear() + @time "Generating 1D Mesh ($label)" SolidModels.gmsh.model.mesh.generate(1) + @time "Generating 2D Mesh ($label)" SolidModels.gmsh.model.mesh.generate(2) + @time "Generating 3D Mesh ($label)" SolidModels.gmsh.model.mesh.generate(3) + + path = joinpath(@__DIR__, "$(basename).h$(i - 1).msh2") + @time "Saving $path" save(path, sm) + push!(files, path) end + return files end -println("All flux ports are `:short`, and all charge and readout ports are `:open`") - -# mesh_order = 1 -# SolidModels.mesh_order(mesh_order) -# SolidModels.gmsh.option.set_number("General.Verbosity", 3) -# @time SolidModels.gmsh.model.mesh.generate(1) -# @time SolidModels.gmsh.model.mesh.generate(2) -# @time SolidModels.gmsh.model.mesh.generate(3) -# meshfile = joinpath(@__DIR__, "qpu17_order$mesh_order.msh2") -# save(meshfile, sm) - -# # Config -# attributes = SolidModels.attributes(sm) -# config = Dict( -# "Problem" => Dict("Type" => "Eigenmode", "Verbose" => 2, "Output" => "postpro"), -# "Model" => Dict( -# "Mesh" => meshfile, -# "L0" => 1e-6, # um is Palace default; record it anyway -# "Refinement" => Dict( -# "MaxIts" => 0 # Increase to enable AMR -# ) -# ), -# "Domains" => Dict( -# "Materials" => [ -# Dict( -# # Vacuum -# "Attributes" => [attributes["vacuum"]], -# "Permeability" => 1.0, -# "Permittivity" => 1.0 -# ), -# Dict( -# # Sapphire -# "Attributes" => [attributes["substrate"]], -# "Permeability" => [0.99999975, 0.99999975, 0.99999979], -# "Permittivity" => [9.3, 9.3, 11.5], -# "LossTan" => [3.0e-5, 3.0e-5, 8.6e-5], -# "MaterialAxes" => [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]] -# ) -# ] -# ), -# "Boundaries" => Dict( -# "PEC" => Dict( -# "Attributes" => [attributes["metal"], attributes["exterior_boundary"]] -# ), -# "LumpedPort" => [] -# ), -# "Solver" => Dict( -# "Order" => 2, -# "Eigenmode" => Dict("N" => 2, "Tol" => 1.0e-6, "Target" => 2, "Save" => 2), -# "Linear" => Dict("Type" => "Default", "Tol" => 1.0e-7, "MaxIts" => 500) -# ) -# ) - -# for i = 1:42 -# node = schematic.index_dict[:port][i] -# dirs = Dict(0.0° => "+X", 90.0° => "+Y", 180.0° => "-X", 270.0° => "-Y") -# dir = dirs[rem(rotation(transformation(schematic, node)), 360°, RoundDown)] -# push!( -# config["Boundaries"]["LumpedPort"], -# Dict( -# "Index" => i, -# "Attributes" => [attributes["port_$i"]], -# "R" => 50, -# "Direction" => dir -# ) -# ) -# end - -# for i = 1:34 -# node = schematic.index_dict[:lumped_element][i] -# dirs = Dict(0.0° => "+Y", 180.0° => "-Y") -# dir = dirs[rem(rotation(transformation(schematic, node)), 360°, RoundDown)] -# push!( -# config["Boundaries"]["LumpedPort"], -# Dict( -# "Index" => 42 + i, -# "Attributes" => [attributes["lumped_element_$i"]], -# "L" => 28.0e-9 + i * 0.05e-9, -# "C" => 2.75e-15, -# "Direction" => dir -# ) -# ) -# end - -# using JSON -# open(joinpath(@__DIR__, "config.json"), "w") do f -# return JSON.print(f, config) -# end From f2cc4b1de0c1d92173ae4e780a8798dea3a21019 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Thu, 11 Jun 2026 19:54:14 +0200 Subject: [PATCH 13/15] Add changelog and API reference for port connectivity --- CHANGELOG.md | 2 ++ docs/src/reference/api.md | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02de5528c..c9f536173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The format of this changelog is based on - Added `SolidModels.populate_size_fields!(cs::AbstractCoordinateSystem)` so the size-field control points can be built from a `Schematic` (or any coordinate system) with no `SolidModel` and no geometry kernel. + - Added `SolidModels.check_port_connectivity`, using `SolidModels.connected_components` to report ports as `:open`, `:short`, `:floating`, or `:missing` + - Added `detect_non_boundary_contacts=false` keyword argument to `SolidModels.connected_components`; when `true`, 1d edges embedded in the interior of 2D surfaces (like the feet of staple air bridges) will be treated as connecting - Renamed `ExamplePDK` component parameters to follow the component style guide (`_` naming, `_count`/`_trace`/`_radius`/`_gap` suffixes, no `w_`/`h_`/`l_`/`n_` prefixes or non-searchable names) and added length-type diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index 3eda018c4..3b60d352e 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -299,6 +299,7 @@ See [Shapes](./shapes.md). ```@docs SolidModels.box_selection + SolidModels.check_port_connectivity SolidModels.connected_components SolidModels.difference_geom! SolidModels.extrude_z! From 8256bdb7f13ede4d9184729938ee2f9c03f4494c Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Fri, 12 Jun 2026 22:48:27 +0200 Subject: [PATCH 14/15] Fix config validation arguments, expose contact tolerance --- examples/DemoQPU17/solidmodel.jl | 6 ++-- src/solidmodels/postrender.jl | 50 +++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl index 7438cdf86..68bbd5c38 100644 --- a/examples/DemoQPU17/solidmodel.jl +++ b/examples/DemoQPU17/solidmodel.jl @@ -154,7 +154,7 @@ function driven_configfile( ) if !isnothing(palace_build) - validate_schema(config) + validate_schema(config, palace_build) end return config @@ -213,7 +213,7 @@ function eigenmode_configfile( ) if !isnothing(palace_build) - validate_schema(config) + validate_schema(config, palace_build) end return config @@ -303,7 +303,7 @@ function base_config(sm, schematic; return config end -function validate_schema(config) +function validate_schema(config, palace_build) # Lazy-load JSONSchema so the example stays usable without it as a hard dep. @eval Main import JSONSchema schema_dir = joinpath(palace_build, "bin", "schema") diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 718f738f6..eb70413e5 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1011,9 +1011,11 @@ function remove_group!(group::PhysicalGroup; recursive=true, remove_entities=tru end """ - connected_components(dim::Int, tags::Vector{Int32}; detect_non_boundary_contacts=false) - connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; detect_non_boundary_contacts=false) - connected_components(sm::SolidModel, groups, dim=2; detect_non_boundary_contacts=false) + connected_components(dim::Int, tags::Vector{Int32}; + detect_non_boundary_contacts=false, + non_boundary_contact_tol=0.0) + connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; kwargs...) + connected_components(sm::SolidModel, groups, dim=2; kwargs...) Find connected components among SolidModel entities at dimension `dim` with the given `tags` or physical group names. @@ -1025,7 +1027,9 @@ For `dim == 2`, set `detect_non_boundary_contacts=true` to unite entities that s necessary even after embedding with `fragment` because OpenCascade's `getAdjacencies` does not see the connection (a typical case is the foot edge of a "staple" air-bridge leg landing on a ground plane). Checking stray 1D entities can be relatively slow if they exist, so -it may be preferable to add dummy 2D entities that attach to them. +it may be preferable to add dummy 2D entities that attach to them. Tolerance (in microns) +for determining whether a 1D entity lies in a 2D entity is controlled +by the `non_boundary_contact_tol` keyword. Returns a `Vector{Vector{Tuple{Int32, Int32}}}` where each inner vector contains the entity dimtags of one connected component. @@ -1033,6 +1037,8 @@ of one connected component. # Notes - Requires Gmsh model to be synchronized before calling + - Requires groups to consist of non-overlapping Boolean fragments with shared edges + (as guaranteed by `render!`) - Works for any dimension ≥ 1 (uses dim - 1 boundary adjacencies) - For dim=3 (volumes): shares boundary surfaces (dim=2) - For dim=2 (surfaces): shares boundary curves (dim=1) @@ -1048,7 +1054,8 @@ connected_components(sm::SolidModel, group::Union{String, Symbol}, dim=2; kwargs function connected_components( dim::Integer, tags::Vector{Int32}; - detect_non_boundary_contacts=false + detect_non_boundary_contacts=false, + non_boundary_contact_tol=0.0 ) n = length(tags) isempty(tags) && return Vector{Tuple{Int32, Int32}}[] @@ -1120,7 +1127,7 @@ function connected_components( find(j) == find(owner_idx) && continue _bbox_contains([elem.mbr.low..., elem.mbr.high...], ebbox, pad=0.0) || continue - _curve_lies_on_face(btag, ftag; tol=0.0) || continue + _curve_lies_on_face(btag, ftag; tol=non_boundary_contact_tol) || continue unite(owner_idx, j) end end @@ -1150,13 +1157,15 @@ function _bbox_contains(a, b; pad::Real=0.0) (b[6] - pad <= a[6]) end -# Sample a 1D entity (curve) at `n_samples` parametric points and test whether each +# Sample a 1D entity (curve) at `n_samples` parametric points and test whether the # sample lies on the 2D entity (face) within `tol`. Two filters: (1) `getClosestPoint` # distance ≤ tol confirms the sample is on the face's underlying surface (an infinite # plane for a planar face — does NOT respect trim curves / holes); (2) batched # `isInside` in parametric uv-space confirms the sample is on the *trimmed* portion # of the face. The parametric form of `isInside` skips an internal world→parametric # reprojection, which is the slow part on large CPW-style faces. +# Default samples only the 2 endpoints (fragmented geometry is expected for checking +# connectivity, so only endpoints are needed). function _curve_lies_on_face(curve_tag::Integer, face_tag::Integer; tol, n_samples::Int=2) tmin, tmax = gmsh.model.getParametrizationBounds(1, curve_tag) isempty(tmin) && return false @@ -1174,11 +1183,17 @@ function _curve_lies_on_face(curve_tag::Integer, face_tag::Integer; tol, n_sampl end """ - check_port_connectivity(sm::SolidModel, port_names, metal_groups; dim=2) - -> Dict{String, Symbol} + check_port_connectivity(sm::SolidModel, port_names, metal_groups; + dim=2, + detect_non_boundary_contacts=false, + non_boundary_contact_tol=0.0) -> Dict{String, Symbol} -Classify each port in `port_names` by its connectivity to the metal regions defined by -`metal_groups`. Returns a `Dict` mapping each port name (as `String`) to one of: +Classify each lumped port in `port_names` by whether its two terminals are connected through entities in `metal_groups`. + +Ports are assumed to be rectangular, with exactly two opposite metal-touching sides. +Invalid ports may be misclassified. + +Returns a `Dict` mapping each port name (as `String`) to one of: - `:short` — at least two of the port's boundary entities touch metal, and every metal-touching boundary lands on the same connected metal component. You can @@ -1212,6 +1227,8 @@ classify them algorithmically but the results are generally not electrically mea - `detect_non_boundary_contacts=false`: If `true` and `dim == 2`, then `connected_components` finds non-conformal contacts (1D edges in the interior of 2D surfaces, like the foot edge of a "staple" air-bridge leg landing on a ground plane) and treats them as connections + - `non_boundary_contact_tol=0.0`: Tolerance in microns for determining whether a 1D edge + lies in a 2D entity # Algorithm @@ -1232,14 +1249,21 @@ function check_port_connectivity( port_names, metal_groups; dim::Integer=2, - detect_non_boundary_contacts=false + detect_non_boundary_contacts=false, + non_boundary_contact_tol=0.0 ) SolidModels._synchronize!(sm) # Build connected-components tag → component-index map. tag_to_comp = Dict{Int32, Int}() if !isempty(metal_groups) - comps = connected_components(sm, metal_groups, dim; detect_non_boundary_contacts) + comps = connected_components( + sm, + metal_groups, + dim; + detect_non_boundary_contacts, + non_boundary_contact_tol + ) for (ci, comp_dimtags) in enumerate(comps) for (_, tag) in comp_dimtags tag_to_comp[tag] = ci From ea9045d1c478b95c645b0bd384537cdb2d69111e Mon Sep 17 00:00:00 2001 From: Greg Peairs Date: Mon, 15 Jun 2026 09:18:58 -0700 Subject: [PATCH 15/15] Fix changelog --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f536173..fe3e97e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,16 @@ The format of this changelog is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + + - Added `SolidModels.check_port_connectivity`, using `SolidModels.connected_components` to report ports as `:open`, `:short`, `:floating`, or `:missing` + - Added `detect_non_boundary_contacts=false` keyword argument to `SolidModels.connected_components`; when `true`, 1D edges embedded in the interior of 2D surfaces (like the feet of staple air bridges) will be treated as connecting + ## 1.15.0 (2026-06-14) - Added `SolidModels.populate_size_fields!(cs::AbstractCoordinateSystem)` so the size-field control points can be built from a `Schematic` (or any coordinate system) with no `SolidModel` and no geometry kernel. - - Added `SolidModels.check_port_connectivity`, using `SolidModels.connected_components` to report ports as `:open`, `:short`, `:floating`, or `:missing` - - Added `detect_non_boundary_contacts=false` keyword argument to `SolidModels.connected_components`; when `true`, 1d edges embedded in the interior of 2D surfaces (like the feet of staple air bridges) will be treated as connecting - Renamed `ExamplePDK` component parameters to follow the component style guide (`_` naming, `_count`/`_trace`/`_radius`/`_gap` suffixes, no `w_`/`h_`/`l_`/`n_` prefixes or non-searchable names) and added length-type