diff --git a/CHANGELOG.md b/CHANGELOG.md index 02de5528c..fe3e97e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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)` 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! diff --git a/examples/DemoQPU17/solidmodel.jl b/examples/DemoQPU17/solidmodel.jl new file mode 100644 index 000000000..68bbd5c38 --- /dev/null +++ b/examples/DemoQPU17/solidmodel.jl @@ -0,0 +1,386 @@ +# QPU17 SolidModel +# +# 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 +using .ExamplePDK.LayerVocabulary +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] + lumped_elements = [("lumped_element_$i", 2) for i = 1:34] + append!(target.rendering_options.retained_physical_groups, ports, lumped_elements) +end + +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, palace_build) + 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, palace_build) + 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, 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") + 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 diff --git a/src/schematics/ExamplePDK/utils.jl b/src/schematics/ExamplePDK/utils.jl index 2698b2c30..6b84f27a1 100644 --- a/src/schematics/ExamplePDK/utils.jl +++ b/src/schematics/ExamplePDK/utils.jl @@ -68,12 +68,16 @@ 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_gap = 5μm + foot_length = 5μm + 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)) + place!(cs, rect_bridge, LayerVocabulary.BRIDGE) + place!(cs, rect_scaffold, LayerVocabulary.BRIDGE_BASE) return cs end diff --git a/src/solidmodels/postrender.jl b/src/solidmodels/postrender.jl index 70b867a4e..eb70413e5 100644 --- a/src/solidmodels/postrender.jl +++ b/src/solidmodels/postrender.jl @@ -1011,44 +1011,55 @@ 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}; + 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. 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`, 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 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. -# 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 + - 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) """ -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])) - -function connected_components(dim::Integer, tags::Vector{Int32}) +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, + non_boundary_contact_tol=0.0 +) 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}}() @@ -1085,6 +1096,43 @@ 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 && detect_non_boundary_contacts + 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 # Only single-parent edges are problematic + owner_idx = ps[1] + ebbox = gmsh.model.getBoundingBox(dim - 1, btag) + 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([elem.mbr.low..., elem.mbr.high...], ebbox, pad=0.0) || + continue + _curve_lies_on_face(btag, ftag; tol=non_boundary_contact_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 +1147,172 @@ function connected_components(dim::Integer, tags::Vector{Int32}) return collect(values(components)) 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]) +end + +# 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 + 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 + 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 > tol2 && return false + gmsh.model.isInside(2, face_tag, uv, true) > 0 || return false + end + return true +end + +""" + 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 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 + 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. + - `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 + + 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, + 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, + non_boundary_contact_tol + ) + 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..756b2f840 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 @@ -40,6 +40,69 @@ @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) + 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] + + # Topology only: ground plane (tag 1) is disconnected from each leg face. + 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; detect_non_boundary_contacts=true) + @test length(result_geom) == 1 + end + + @testset "staple bridge connects" begin + cs = DeviceLayout.SchematicDrivenLayout.ExamplePDK.bridge_geometry( + Paths.CPW(10e3nm, 6e3nm) + ) + place!(cs, centered(Rectangle(1e6nm, 1e6nm)), :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"], + 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 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 new file mode 100644 index 000000000..38f5d5aff --- /dev/null +++ b/test/test_port_connectivity.jl @@ -0,0 +1,163 @@ +@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 = 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 = 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 = 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 = + 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 = 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 = 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 = 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 = check_port_connectivity(sm, ["port_absent"], ["metal"]; dim=3) + @test result == Dict("port_absent" => :missing) + end +end