diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a19ade077..000000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -CHANGELOG.md merge=union diff --git a/CHANGELOG.md b/CHANGELOG.md index 547b3edd6..a6bab5d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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 `WithDirection <: GeometryEntityStyle` to annotate geometry entities with a direction (CCW from +x in local frame). The direction transforms with the entity under rotations and reflections, allowing extraction of the final global direction for use in simulation configuration. + ## 1.14.0 (2026-05-28) - Added `ParameterSet`, a nested dictionary wrapper with dot-access for reading and diff --git a/docs/src/examples/singletransmon.md b/docs/src/examples/singletransmon.md index 146a47d31..68e90358b 100644 --- a/docs/src/examples/singletransmon.md +++ b/docs/src/examples/singletransmon.md @@ -61,14 +61,14 @@ function compute_eigenfrequencies( total_length=5000μm ) # Construct the SolidModel - @time "SolidModel + Meshing" sm = single_transmon( + @time "SolidModel + Meshing" schematic, sm = single_transmon( save_mesh=true; cap_length=cap_length, total_length=total_length, mesh_order=mesh_order ) # Assemble the configuration - @time "Configuration" config = configfile(sm; palace_build, solver_order=solver_order) + @time "Configuration" config = configfile(schematic, sm; palace_build, solver_order=solver_order) # Call Palace @time "Palace" freqs = palace_job(config; palace_build, np) return freqs @@ -90,7 +90,7 @@ then call `single_transmon()`. This will construct the schematic and render the ```julia using DeviceLayout include("examples/SingleTransmon/SingleTransmon.jl") -sm = SingleTransmon.single_transmon() +schematic, sm = SingleTransmon.single_transmon() SolidModels.gmsh.fltk.run() # Opens Gmsh GUI ``` diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index 3eda018c4..8a6c6afd9 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -60,6 +60,7 @@ DeviceLayout.OptionalStyle DeviceLayout.optional_entity DeviceLayout.ToTolerance + DeviceLayout.WithDirection ``` ### [GeometryStructure](@id api-geometrystructure) diff --git a/examples/SingleTransmon/SingleTransmon.jl b/examples/SingleTransmon/SingleTransmon.jl index 1906d90de..c9f407416 100644 --- a/examples/SingleTransmon/SingleTransmon.jl +++ b/examples/SingleTransmon/SingleTransmon.jl @@ -108,12 +108,12 @@ function single_transmon(; csport = CoordinateSystem(uniquename("port"), nm) render!( csport, - only_simulated(centered(Rectangle(cpw_width, cpw_width))), + only_simulated(WithDirection(centered(Rectangle(cpw_width, cpw_width)))), LayerVocabulary.PORT ) # Attach with port center `cpw_width` from the end (instead of `cpw_width/2`) to avoid corner effects attach!(p_readout, sref(csport), cpw_width, i=1) # @ start - attach!(p_readout, sref(csport), readout_length / 2 - cpw_width, i=2) # @ end + attach!(p_readout, sref(csport, rot=180°), readout_length / 2 - cpw_width, i=2) # @ end end #### Build schematic graph @@ -192,16 +192,17 @@ function single_transmon(; flatten!(c) save(joinpath(@__DIR__, "single_transmon.gds"), c) end - return sm + return floorplan, sm end """ - configfile(sm::SolidModel; palace_build=nothing, solver_order=2, amr=0, wave_ports=false) + configfile(sch::Schematic, sm::SolidModel; palace_build=nothing, solver_order=2, amr=0, wave_ports=false) Given a `SolidModel`, assemble a dictionary defining a configuration file for use within Palace. - - `sm`: The `SolidModel`from which to construct the configuration file + - `sch`: The `Schematic` corresponding to the model, which associates ports with design intent + - `sm`: The `SolidModel` for which to construct the configuration file - `palace_build = nothing`: Path to a Palace build directory, used to perform validation of the configuration file. If not present, no validation is performed. - `solver_order = 2`: Finite element order (degree) for the solver. Palace supports arbitrary @@ -209,6 +210,7 @@ Palace. - `amr = 0`: Maximum number of adaptive mesh refinement (AMR) iterations. """ function configfile( + sch::Schematic, sm::SolidModel; palace_build=nothing, solver_order=2, @@ -216,6 +218,8 @@ function configfile( wave_ports=false ) attributes = SolidModels.attributes(sm) + port_dirs = ExamplePDK.port_directions(sch, layer(LayerVocabulary.PORT)) + lumped_dirs = ExamplePDK.port_directions(sch, layer(LayerVocabulary.LUMPED_ELEMENT)) config = Dict( "Problem" => Dict( @@ -275,13 +279,13 @@ function configfile( "Index" => 1, "Attributes" => [attributes["port_1"]], "R" => 50, - "Direction" => "+X" + "Direction" => port_dirs[1] ), Dict( "Index" => 2, "Attributes" => [attributes["port_2"]], "R" => 50, - "Direction" => "+X" + "Direction" => port_dirs[2] ) ) )..., @@ -290,7 +294,7 @@ function configfile( "Attributes" => [attributes["lumped_element"]], "L" => 14.860e-9, "C" => 5.5e-15, - "Direction" => "+Y" + "Direction" => lumped_dirs[1] ) ] ), @@ -388,14 +392,14 @@ function compute_eigenfrequencies( total_length=5000μm ) # Construct the SolidModel - @time "SolidModel + Meshing" sm = single_transmon( + @time "SolidModel + Meshing" floorplan, sm = single_transmon( save_mesh=true; cap_length=cap_length, total_length=total_length, mesh_order=mesh_order ) # Assemble the configuration - @time "Configuration" config = configfile(sm; palace_build, solver_order=solver_order) + @time "Configuration" config = configfile(floorplan, sm; palace_build, solver_order=solver_order) # Call Palace @time "Palace" freqs = palace_job(config; palace_build, np) return freqs diff --git a/src/DeviceLayout.jl b/src/DeviceLayout.jl index 2fef9d2f7..3939202fc 100644 --- a/src/DeviceLayout.jl +++ b/src/DeviceLayout.jl @@ -218,7 +218,8 @@ export transform, # Entity styles include("styles.jl") -export OptionalStyle, ToTolerance, optional_entity, MeshSized, meshsized_entity, styled +export OptionalStyle, + ToTolerance, WithDirection, optional_entity, MeshSized, meshsized_entity, styled """ abstract type GeometryStructure{S} <: AbstractGeometry{S} diff --git a/src/schematics/ExamplePDK/components/SimpleJunctions/SimpleJunctions.jl b/src/schematics/ExamplePDK/components/SimpleJunctions/SimpleJunctions.jl index afdcfc558..cceda07b8 100644 --- a/src/schematics/ExamplePDK/components/SimpleJunctions/SimpleJunctions.jl +++ b/src/schematics/ExamplePDK/components/SimpleJunctions/SimpleJunctions.jl @@ -73,7 +73,7 @@ function SchematicDrivenLayout._geometry!(cs::CoordinateSystem, jj::ExampleSimpl top_lead = Align.above(Rectangle(w_jj, (h_ground_island - h_jj) / 2), jj_rect; centered=true) bot_lead = Align.below(top_lead, jj_rect) - place!(cs, only_simulated(jj_rect), LUMPED_ELEMENT) + place!(cs, only_simulated(WithDirection(jj_rect, 90°)), LUMPED_ELEMENT) place!(cs, MeshSized(2 * w_jj)(only_simulated(top_lead)), METAL_POSITIVE) place!(cs, MeshSized(2 * w_jj)(only_simulated(bot_lead)), METAL_POSITIVE) # artwork geometry diff --git a/src/schematics/ExamplePDK/utils.jl b/src/schematics/ExamplePDK/utils.jl index 2698b2c30..cc524a3ec 100644 --- a/src/schematics/ExamplePDK/utils.jl +++ b/src/schematics/ExamplePDK/utils.jl @@ -231,3 +231,66 @@ end @deprecate filter_params filter_parameters # For backward compatibility # (No one should be using methods from ExamplePDK but just in case) + +""" + port_directions(sch::Schematic, ly::Symbol) -> Dict{Int, Union{String, Vector{Float64}}} + +For every entity on layer `ly` in `sch.coordinate_system` that has been indexed +(i.e., `layerindex(metadata) != 0`) AND carries a [`WithDirection`](@ref) style in +its wrapper chain, return a dictionary mapping `layerindex(metadata) -> direction config value` suitable for Palace's `LumpedPort`/`WavePort` `Direction` field. + +Direction config value is a string `"+X"`, `"-X"`, `"+Y"`, or `"-Y"` for axis-aligned orientations +(within `atol=1e-3` degrees of the nearest axis), or a unit vector `[dx, dy, 0.0]::Vector{Float64}` +for arbitrary orientations. + +Must be called AFTER indexing has run. Typical usage is after `render!(sm, sch, target)` or `Cell(sch, target)` for a target whose `indexed_layers(target)` +includes `ly`. If no entities on `ly` are indexed or none carry `WithDirection`, +returns an empty `Dict`. This function does NOT call `index_layer!` itself. + +# Example + +```julia +render!(sm, sch, target) +dirs = port_directions(sch, :lumped_element) +# Dict(1 => "+Y", 2 => "-X") +``` + +See also: [`WithDirection`](@ref). +""" +function port_directions(sch::Schematic, ly::Symbol) + dirs = Dict{Int, Union{String, Vector{Float64}}}() + # Traverse all reachable coordinate systems in the schematic (the schematic's + # own `coordinate_system` plus every reference descendant). `index_layer!` + # places indexed entities onto per-node coordsyses, recording the node for + # each index in `sch.index_dict[ly]` and setting in-component indices to 0. + # Each `(cs, trans)` pair includes the accumulated reference transform; + # applying it makes the returned direction reflect the entity's global orientation. + for (cs, trans) in DeviceLayout.traversal(sch.coordinate_system) + for (el, m) in zip(elements(cs), element_metadata(cs)) + layer(m) == ly || continue + idx = layerindex(m) + idx == 0 && continue + dir = DeviceLayout._extract_direction(el) + dir === nothing && continue + haskey(dirs, idx) && + error("Repeated index $idx. Before calling `port_directions`, \ +layer $ly should be indexed by rendering with a target whose `indexed_layers` \ +include $ly (or indexed directly with `index_layer!`)") + dirs[idx] = _direction_config(rotated_direction(dir, trans)) + end + end + return dirs +end + +# Format a direction angle (CCW from +X, in degrees) as a Palace-compatible +# `Direction` config value. Axis-aligned directions return one of "+X", "-X", "+Y", +# "-Y"; off-axis returns a unit-vector [dx, dy, 0.0]. Input is normalized modulo 360°. +function _direction_config(angle; atol=1e-3) + a_deg = mod(DeviceLayout.ustrip(°, angle), 360.0) + abs(a_deg - 0.0) < atol && return "+X" + abs(a_deg - 90.0) < atol && return "+Y" + abs(a_deg - 180.0) < atol && return "-X" + abs(a_deg - 270.0) < atol && return "-Y" + abs(a_deg - 360.0) < atol && return "+X" + return [cos(angle), sin(angle), 0.0] +end diff --git a/src/styles.jl b/src/styles.jl index e14eac044..5af422f05 100644 --- a/src/styles.jl +++ b/src/styles.jl @@ -350,3 +350,52 @@ struct ToTolerance{T <: Coordinate} <: GeometryEntityStyle end to_polygons(ent::GeometryEntity, sty::ToTolerance; kwargs...) = to_polygons(ent; merge((; kwargs...), (; atol=sty.atol))...) + +""" + struct WithDirection <: GeometryEntityStyle + direction::typeof(1.0°) + end + WithDirection(direction=0°) + +Style that annotates a `GeometryEntity` with a direction angle (CCW from the +X axis +in the entity's local frame) for use in simulation configuration. For example, a +lumped-port rectangle can carry its electrical orientation so that Palace's +`LumpedPort`/`WavePort` `Direction` field can be populated after rendering. + +Rendering is unaffected: `to_polygons` and `to_primitives` pass through to the underlying entity. +The direction transforms with the entity under rotation or reflection via +`transform(sty::WithDirection, f::Transformation) = WithDirection(rotated_direction(sty.direction, f))`, +so after `plan!`/`build!`/`index_layer!` the carried angle describes the global +orientation. + +If an angle is given without units, it is assumed to be in radians. +The stored angle is **not** automatically normalized to `[0°, 360°)`. +""" +struct WithDirection <: GeometryEntityStyle + direction::typeof(1.0°) + # Constrain the inner constructor to a Number so WithDirection(::GeometryEntity) + # routes via the generic `(T::Type{<:GeometryEntityStyle})(x::GeometryEntity, args...)` + # fallback instead of colliding with this constructor (silences Aqua). + WithDirection(direction::Number) = new(uconvert(°, direction)) +end +# Default constructor — no-arg form is unambiguous. +WithDirection() = WithDirection(0°) + +to_polygons(ent::GeometryEntity, ::WithDirection; kwargs...) = to_polygons(ent; kwargs...) + +transform(sty::WithDirection, f::Transformation) = + WithDirection(rotated_direction(sty.direction, f)) + +# Walk through any nesting of StyledEntity wrappers and return the `direction` +# angle of the first `WithDirection` style encountered (from outside in), or `nothing` if no +# `WithDirection` is present. Handles nesting like +# WithDirection(MeshSized(only_simulated(rect))) and the reverse. +_extract_direction(::DeviceLayout.GeometryEntity) = nothing +function _extract_direction(ent::DeviceLayout.StyledEntity) + return _extract_direction(ent.ent) +end +function _extract_direction( + ent::DeviceLayout.StyledEntity{T, U, WithDirection} +) where {T, U <: GeometryEntity{T}} + return ent.sty.direction +end diff --git a/test/test_entity.jl b/test/test_entity.jl index c9220d77e..05d380ff8 100644 --- a/test/test_entity.jl +++ b/test/test_entity.jl @@ -156,3 +156,122 @@ Paths.TaperCPW{typeof(1.0μm)}(30μm, 10μm, 40μm, 10μm, 10μm) end end + +@testitem "WithDirection" setup = [CommonTestSetup] begin + ## Construction + @test WithDirection().direction == 0° + # Numeric argument converts to degrees + @test WithDirection(pi / 4).direction ≈ 45° + ## Transformation + sty = WithDirection(90°) + @test transform(sty, Rotation(90°)).direction == 180° + @test transform(sty, Rotation(270°)).direction % 360° == 0° + @test isapprox_angle(transform(sty, XReflection()).direction, 270°) + @test transform(WithDirection(), XReflection()).direction == 0° + @test transform(WithDirection(), ScaledIsometry(nothing, 90°, true, 1.0)).direction == + 90° + @test isapprox_angle( + transform(sty, ScaledIsometry(nothing, 90°, true, 1.0)).direction, + 0° + ) + @test transform(sty, Transformations.IdentityTransformation()).direction == 90° + ## Rendering + rect = Rectangle(2μm, 3μm) + @test to_polygons(sty(rect)) == to_polygons(rect) + + @testset "port_directions" begin + # Not currently API functionality but worth testing alongside WithDirection + using DeviceLayout.SchematicDrivenLayout + import DeviceLayout: _extract_direction + import .SchematicDrivenLayout.ExamplePDK: port_directions, _direction_config + ## Direction extraction + rect = Rectangle(2μm, 3μm) + @test to_polygons(sty(rect)) == to_polygons(rect) + opt = optional_entity(rect, :foo; default=true) + msz = meshsized_entity(opt, 0.5μm) + wd_outer = WithDirection(90°)(msz) + wd_inner = WithDirection(45°)(rect) + msz2 = meshsized_entity(wd_inner, 0.5μm) + opt2 = optional_entity(msz2, :foo; default=true) + # Extract direction + @test _extract_direction(wd_outer) == 90° + @test _extract_direction(wd_inner) == 45° + @test _extract_direction(opt2) == 45° + @test _extract_direction(opt) === nothing + @test _extract_direction(rect) === nothing + # If multiple WithDirection layers exist, outer wins (expected behavior, not a contract) + double = WithDirection(0°)(WithDirection(90°)(rect)) + @test _extract_direction(double) == 0° + ## _direction_config + @test _direction_config(0°) == "+X" + @test _direction_config(90°) == "+Y" + @test _direction_config(180°) == "-X" + @test _direction_config(270°) == "-Y" + + # Normalization: 360° → +X, -90° → -Y, 450° → +Y + @test _direction_config(360°) == "+X" + @test _direction_config(-90°) == "-Y" + @test _direction_config(450°) == "+Y" + + # Off-axis: [dx, dy, 0.0] format + s45 = _direction_config(45°) + @test s45 ≈ [cos(45°), sin(45°), 0.0] + + ## Port directions + # Within atol tolerance → still +X + @test _direction_config(0.0005°) == "+X" + @test _direction_config(-0.0005°) == "+X" + @test _direction_config(359.9995°) == "+X" + # Place three rectangles directly on the schematic's top-level coordsys. + # Two of them carry WithDirection; one is bare. + g = SchematicGraph("test-g") + sch = plan(g) + rect1 = centered(Rectangle(1μm, 1μm)) + rect2 = centered(Rectangle(1μm, 1μm)) + rect3 = centered(Rectangle(1μm, 1μm)) + place!(sch, WithDirection(0°)(rect1), SemanticMeta(:myport)) + place!(sch, WithDirection(90°)(rect2), SemanticMeta(:myport)) + place!(sch, rect3, SemanticMeta(:myport)) # bare, no direction + # Needs to be indexed first + @test_throws "Repeated index" port_directions(sch, :myport) + + SchematicDrivenLayout.index_layer!(sch, :myport) + dirs = port_directions(sch, :myport) + # Indexed entities 1 and 2 have directions; entity 3 is bare so no entry. + @test length(dirs) == 2 + @test dirs[1] == "+X" + @test dirs[2] == "+Y" + @test !haskey(dirs, 3) + + g = SchematicGraph("test-g-empty") + sch = plan(g) + rect = centered(Rectangle(1μm, 1μm)) + place!(sch, rect, SemanticMeta(:bareport)) # no WithDirection + SchematicDrivenLayout.index_layer!(sch, :bareport) + dirs = port_directions(sch, :bareport) + @test isempty(dirs) + # Entities with explicit index=0 (e.g. inner component entities after a + # Phase-2 `index_layer!` flatten-and-clear pass) must be skipped by + # port_directions. + g = SchematicGraph("test-g-zeroidx") + sch = plan(g) + rect = centered(Rectangle(1μm, 1μm)) + place!(sch, WithDirection(0°)(rect), SemanticMeta(:zly, index=0)) + dirs = port_directions(sch, :zly) + @test isempty(dirs) + + # SchematicGraph with component and rotated sub-cs + cs = CoordinateSystem("comp") + cs2 = CoordinateSystem("subcs") + place!(cs2, WithDirection(0°)(rect1), SemanticMeta(:myport)) + addref!(cs, sref(cs2, rot=90°)) + comp = BasicComponent(cs) + g = SchematicGraph("test") + add_node!(g, comp) + sch = plan(g) + SchematicDrivenLayout.index_layer!(sch, :myport) + dirs = port_directions(sch, :myport) + @test length(dirs) == 1 + @test dirs[1] == "+Y" + end +end