diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index e723821..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,856 +0,0 @@ -# Migration Guide - -This guide covers changes between the `master` branch and the current tree. -Both `master` and the current tree report `masque.__version__ == '3.4'`; the -version string has not yet been bumped for these changes. - -Most downstream changes are in `masque/builder/*`, but there are a few other -API changes that may require code updates. - -## Routing API: renamed and consolidated - -The routing helpers were consolidated into a single implementation in -`masque/builder/pather.py`. - -The biggest migration point is that the old routing verbs were renamed: - -| Old API | New API | -| --- | --- | -| `Pather.path(...)` | `Pather.trace(...)` | -| `Pather.path_to(...)` | `Pather.trace_to(...)` | -| `Pather.mpath(...)` | `Pather.trace(...)` / `Pather.trace_to(...)` with multiple ports | -| `Pather.pathS(...)` | `Pather.jog(...)` | -| `Pather.pathU(...)` | `Pather.uturn(...)` | -| `Pather.path_into(...)` | `Pather.trace_into(...)` | -| `Pather.path_from(src, dst)` | `Pather.at(src).trace_into(dst)` | -| `RenderPather.path(...)` | `Pather(..., render='deferred').trace(...)` | -| `RenderPather.path_to(...)` | `Pather(..., render='deferred').trace_to(...)` | -| `RenderPather.mpath(...)` | `Pather(..., render='deferred').trace(...)` / `Pather(..., render='deferred').trace_to(...)` | -| `RenderPather.pathS(...)` | `Pather(..., render='deferred').jog(...)` | -| `RenderPather.pathU(...)` | `Pather(..., render='deferred').uturn(...)` | -| `RenderPather.path_into(...)` | `Pather(..., render='deferred').trace_into(...)` | -| `RenderPather.path_from(src, dst)` | `Pather(..., render='deferred').at(src).trace_into(dst)` | - -There are also new convenience wrappers: - -- `straight(...)` for `trace_to(..., ccw=None, ...)` -- `ccw(...)` for `trace_to(..., ccw=True, ...)` -- `cw(...)` for `trace_to(..., ccw=False, ...)` -- `jog(...)` for S-bends -- `uturn(...)` for U-bends - -Important: `Pather.path()` is no longer the routing API. It now forwards to -`Pattern.path()` and creates a geometric `Path` element. Any old routing code -that still calls `pather.path(...)` must be renamed. - -### Common rewrites - -```python -# old -pather.path('VCC', False, 6_000) -pather.path_to('VCC', None, x=0) -pather.mpath(['GND', 'VCC'], True, xmax=-10_000, spacing=5_000) -pather.pathS('VCC', offset=-2_000, length=8_000) -pather.pathU('VCC', offset=4_000, length=5_000) -pather.path_into('src', 'dst') -pather.path_from('src', 'dst') - -# new -pather.cw('VCC', 6_000) -pather.straight('VCC', x=0) -pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) -pather.jog('VCC', offset=-2_000, length=8_000) -pather.uturn('VCC', offset=4_000, length=5_000) -pather.trace_into('src', 'dst') -pather.at('src').trace_into('dst') -``` - -If you prefer the more explicit spelling, `trace(...)` and `trace_to(...)` -remain the underlying primitives: - -```python -pather.trace('VCC', False, 6_000) -pather.trace_to('VCC', None, x=0) -``` - -## `PortPather` and `.at(...)` - -Routing can now be written in a fluent style via `.at(...)`, which returns a -`PortPather`. - -```python -(rpather.at('VCC') - .trace(False, length=6_000) - .trace_to(None, x=0) -) -``` - -This is additive, not required for migration. Existing code can stay with the -non-fluent `Pather` methods after renaming the verbs above. - -Old `PortPather` helper names were also cleaned up: - -| Old API | New API | -| --- | --- | -| `save_copy(...)` | `mark(...)` | -| `rename_to(...)` | `rename(...)` | - -Example: - -```python -# old -pp.save_copy('branch') -pp.rename_to('feed') - -# new -pp.mark('branch') -pp.rename('feed') -``` - -## Imports and module layout - -`Pather` now provides the remaining builder/routing surface in -`masque/builder/pather.py`. The old module files -`masque/builder/builder.py` and `masque/builder/renderpather.py` were removed. - -Update imports like this: - -```python -# old -from masque.builder.builder import Builder -from masque.builder.renderpather import RenderPather - -# new -from masque.builder import Pather - -builder = Pather(...) -deferred = Pather(..., render='deferred') -``` - -The new `Pather` remains importable from both `masque` and `masque.builder`. -The removed `Builder` and `RenderPather` names are no longer exported from -either location. - -`Pather` now defaults to `render='auto'`, so plain construction replaces the -old `Builder` behavior. Use `Pather(..., render='deferred')` where you -previously used `RenderPather`. - -## `SimpleTool` was removed and `AutoTool` registration changed - -`SimpleTool` is no longer exported. `AutoTool` remains, but its old public -descriptor classes and constructor-oriented configuration were replaced by -registration methods. Use it for generated straights and S-bends and reusable -bends, U-turns, and transitions. - -### Old `AutoTool` - -```python -from masque.builder import AutoTool - -tool = AutoTool( - straights=[ - AutoTool.Straight('m1wire', make_straight, 'input', 'output'), - ], - bends=[ - AutoTool.Bend(lib.abstract('bend'), 'input', 'output'), - ], - sbends=[], - transitions={ - ('m2wire', 'm1wire'): AutoTool.Transition( - lib.abstract('via'), 'top', 'bottom' - ), - }, - default_out_ptype='m1wire', -) -``` - -### New `AutoTool` - -```python -from masque.builder import AutoTool - -tool = ( - AutoTool() - .add_straight(make_straight, 'm1wire', 'input') - .add_bend(lib.abstract('bend'), 'input', 'output', clockwise=True) - .add_transition(lib.abstract('via'), 'top', 'bottom') -) -``` - -The key differences are: - -- `SimpleTool` was removed; use `AutoTool` or implement the new `Tool` - primitive-offer interface -- `AutoTool.Straight(...)` -> `add_straight(fn, ptype, in_name)` -- `AutoTool.Bend(...)` -> `add_bend(abstract, in_name, out_name)` -- `AutoTool.SBend(...)` -> `add_sbend(fn, ptype, in_name, out_name)` -- reusable native U-turns can be registered with `add_uturn(...)` -- transitions are registered with `add_transition(abstract, external_port, internal_port)` -- transitions are bidirectional by default; pass `one_way=True` to inhibit the reverse adapter - -Primitive costs are now explicit and independent of `AutoTool` registration -order. Every `add_*()` method accepts `cost=`, as do the concrete offer -factories. A numeric value scales the default geometric cost; for example, -`cost=2` makes a primitive twice as expensive. A callable receives the -canonical primitive parameter and local endpoint and returns the complete -cost: - -```python -tool.add_straight(make_straight, 'm1wire', 'input', cost=1.5) -tool.add_sbend( - make_sbend, - 'm1wire', - 'input', - 'output', - cost=lambda jog, endpoint: abs(jog) + 2 * abs(endpoint.x), -) -``` - -`PrimitiveOffer.priority_bias` was removed; custom offers should use `cost` -instead. Exact equal-cost candidates still use deterministic discovery order -as the final tie-break, but registration order no longer changes their -reported cost. - -For two-port primitives, `AutoTool` can infer omitted port names and, for -generated straight/S-bend primitives, omitted ptype metadata by sampling an -in-domain example. Supply those values explicitly when generation requires -route-specific keyword arguments, because metadata inference does not receive -`tool_options`. - -## Custom `Tool` subclasses - -If you maintain your own `Tool` subclass, the interface changed: - -- `primitive_offers()` is now the planning boundary -- `render()` consumes committed primitive render tokens -- `Tool.path(...)`, `traceL()`, `traceS()`, `traceU()`, `planL()`, - `planS()`, and `planU()` are no longer part of the public `Tool` API - -In practice, a minimal old implementation like: - -```python -class MyTool(Tool): - def path(self, ccw, length, **kwargs): - ... -``` - -should now become: - -```python -from collections.abc import Sequence -from typing import Any - -from masque import Port -from masque.builder import RenderStep, StraightOffer, Tool - - -class MyTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): - if kind != 'straight': - return () - - def endpoint(length): - ptype = out_ptype or in_ptype - return Port((length, 0), rotation=3.141592653589793, ptype=ptype) - - def commit(length): - return {'length': length} - - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=out_ptype or in_ptype, - endpoint_planner=endpoint, - commit_planner=commit, - ),) - - def render(self, batch: Sequence[RenderStep]): - ... -``` - -If a tool does not provide a primitive kind, return `()` for that kind. `Pather` -will compose available primitive offers where the route family allows it. - -### Primitive offers - -Tools describe legal routing primitives through `Tool.primitive_offers()`. -`Pather` composes those primitive offers to implement `trace()`, `jog()`, -`uturn()`, and `trace_into()`. - -For custom tools, construct the concrete offer class that matches the primitive -you are exposing: - -- `StraightOffer` for non-turning length-parameterized primitives -- `BendOffer` for single-turn length-parameterized primitives -- `SOffer` for S-like jog-parameterized primitives -- `UOffer` for U-like jog-parameterized primitives - -`PrimitiveOffer` is the shared base type used for generic annotations and -common callback behavior. It is not the normal class users should instantiate. -The concrete offer classes carry the semantic fields (`length_domain`, -`jog_domain`, `ccw`) so tools do not need to encode primitive identity in -strings. Each concrete offer now also exposes a class-level canonical `kind`. -Tools must return the matching offer kind for the discovery query; for example, -`primitive_offers('s', ...)` must return only `SOffer` instances. Mismatches are -reported as fatal `ToolContractError`s rather than silently ignored. - -`RenderStep` now stores that same canonical kind. Code that constructs render -steps directly must use the kind rather than a legacy opcode: - -```python -# old -RenderStep('L', tool, start, end, data) - -# new -RenderStep('straight', tool, start, end, data) -``` - -Use `'bend'`, `'s'`, `'u'`, or `'plug'` for the other step types. The read-only -`RenderStep.opcode` and `PrimitiveOffer.opcode` properties remain available for -code that only consumes steps, but opcodes are now derived rather than stored. - -Minimal straight-only example: - -```python -from collections.abc import Sequence -from typing import Literal - -from masque import Port -from masque.builder import RenderStep, StraightOffer, Tool - - -class MyTool(Tool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype=None, - out_ptype=None, - **kwargs, - ): - if kind != 'straight': - return () - - def endpoint(length): - ptype = out_ptype or in_ptype - return Port((length, 0), rotation=3.141592653589793, ptype=ptype) - - def commit(length): - return {'length': length} - - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=out_ptype or in_ptype, - endpoint_planner=endpoint, - commit_planner=commit, - ),) - - def render(self, batch: Sequence[RenderStep]): - ... -``` - -Routing entry points now name every supported route argument explicitly. -Custom per-route Tool values must be placed under `tool_options`: - -```python -pather.jog('A', 4, length=10, tool_options={'process_corner': 'slow'}) -``` - -The mapping is unpacked only for `Tool.primitive_offers()`. Route arguments do -not leak into that namespace, and tool options are not forwarded to -`Tool.render()`. An offer that needs route-specific render behavior must capture -the selected value in its `commit()` result (`RenderStep.data`). `AutoTool` -does this automatically for generated straight and S-bend primitives: the -mapping is deep-copied per offer discovery and passed to its generator as -keyword arguments during rendering. Consequently every AutoTool option value -must be deep-copyable, and later mutation of nested caller-owned values does -not affect a pending route. AutoTool options must not change generated -ports or endpoint geometry. `PathTool` defines no tool options and rejects -nonempty mappings. - -AutoTool does not validate generator keyword signatures during planning. A bad -keyword therefore raises when the generator runs, normally during rendering. -Generators that require route options must provide explicit port metadata at -registration; S-bend generators must also provide an explicit `endpoint`. - -Primitive offers are local planning objects: - -- `endpoint_at(parameter)` returns the local output `Port` -- `cost_at(parameter)` returns an additive scalar route-selection cost -- `bbox_at(parameter)` returns local primitive bounds when a footprint hook is supplied -- `parameterized_bbox` may carry opaque future-router footprint metadata -- `commit(parameter)` returns opaque render data consumed later by `render()` -- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton -- domains are validated when an offer is constructed, not when it is first evaluated -- selected parameter values must be finite; domains may use infinite open bounds but not `NaN` -- straight/bend domains require a finite nonnegative minimum; fixed singleton domains must be finite -- `None` and `"unk"` ptypes are wildcards; concrete ptype mismatches reject an offer - -`AutoTool.add_straight(length_range=...)` and -`AutoTool.add_sbend(jog_range=...)` now validate their ranges before metadata -inference. `jog_range` is an absolute-magnitude range and must have a finite, -nonnegative lower bound; AutoTool creates the corresponding positive and -negative S offers itself. Invalid ranges now raise immediately instead of -registering no offers. - -`ToolContractError` is exported from `masque` and `masque.builder`. It marks a -broken Tool contract—such as an endpoint ptype that disagrees with its offer, -an invalid evaluated cost, or rendered output that disagrees with the planned -endpoint—and is fatal to route fallback. Ordinary `BuildError` raised by a -planning callback remains a recoverable candidate rejection. Rendered output -rotations are compared modulo one full turn; a port facing exactly backward is -no longer accepted as equivalent. A `None` rotation remains an explicit -wildcard. - -Custom Tool authors can exercise discovery, offer callbacks, committed data, -and one-step rendering without depending on pytest: - -```python -from masque.builder import ToolContractCase, validate_tool_contract - -validate_tool_contract(my_tool, ( - ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)), - ToolContractCase('bend', in_ptype='wire', ccw=False), - ToolContractCase('bend', in_ptype='wire', ccw=True), - ToolContractCase('s', in_ptype='wire', require_offers=False), -)) -``` - -Cases derive representative parameters from each returned offer domain and -may add explicit probes. Empty discovery is an error unless -`require_offers=False`; bbox support is required only with `check_bbox=True`. -Validation returns normally on success and otherwise raises an -`ExceptionGroup` of contextual `ToolContractError`s. - -Run this validation during Tool development, testing, or application startup. -It is the comprehensive semantic preflight for custom Tools; those checks are -intentionally not repeated for every offer evaluation in the routing hot path. - -Positional routing bounds (`p`, `pos`, `position`, `x`, and `y`) now require a -nearly Manhattan input-port direction. Arbitrarily angled ports remain valid -for non-positional/extension routing. - -Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype -adapters. Requested `out_ptype` constrains only the final route endpoint; any -intermediate ptypes are chosen by the route solver. - -`Tool` subclasses must override `primitive_offers()` and return `()` themselves -for recognized unsupported kinds. There is no route-level `plan*()` fallback. -Omitted-length S/U behavior comes from direct `SOffer` and `UOffer` endpoint -domains or from composed straight/bend primitives. - -Offer constructors accept split `endpoint_planner` and `commit_planner` -callbacks. Provide both callbacks or override the offer methods in a subclass; -partial callback configurations are rejected during offer construction. - -When writing direct primitive offers, declare the actual endpoint ptype -produced by the offer if it can differ from the requested value; `Pather` -validates evaluated endpoints against the declared offer ptype. - -Stable imports for custom tool authors live in `masque.builder`. The -`masque.builder.planner` module is an internal planner implementation; do not -import it from user code. - -`trace_into()` uses the same primitive-offer route selection and defaults to -the minimal main-route bend count required by the endpoint relationship: zero -for a straight, one for a quarter-turn, and two for S- and U-like connections. -Ptype adapters do not consume this bend budget. Set -`plan_options={'bend_policy': 'flexible'}` to search bounded route topologies -with up to four bend roles, including dogleg and loop-like fallbacks. -Bend-family requests then search one-bend routes before three-bend routes; -other families search zero-to-two-bend routes before four-bend routes. The -first band with a legal route wins. Within that band, candidates are ordered -by total primitive-offer cost, adapter count, step count, and deterministic -discovery order. The default planner's `strategy` option affects only that -final discovery-order tie-break and is also supplied through `plan_options`. - -`plan_options` is reserved for planner-specific per-route policy, while -`tool_options` is forwarded only to `Tool.primitive_offers()`. For example: - -```python -pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'}) -``` - -Explicit-length `jog()` routes may also be satisfied by composing a straight -primitive before or after an omitted-length native S primitive. `uturn()` routes -may compose a straight primitive before an omitted-length native U primitive. -These compositions are used when they are the lowest-cost legal route for the -explicit request. - -`AutoTool` can attach `bbox_at()` hooks to its primitive offers by rendering the -selected primitive into a temporary pattern and measuring it. If the rendered -primitive contains reusable refs, pass the source library as `bbox_library=...`; -normal routing does not require this. - -### Omitted-length routing - -Single-port omitted-length calls now evaluate legal primitive routes at their -minimum legal length-like parameter, or at their intrinsic endpoint length when -the requested offset fixes the primitive geometry. Cost then selects among -those minimum-length candidates: - -```python -pather.trace('A', None) # minimum straight-like route -pather.jog('A', offset=2) # minimum S-like route for that offset -pather.uturn('A', offset=4) # minimum U-like route for that offset -``` - -For U-turns, use explicit `length=0` to request the old zero-public-length -shape: - -```python -pather.uturn('A', offset=4, length=0) -``` - -## Transform semantics changed - -The other major user-visible change is that `mirror()` and `rotate()` are now -treated more consistently as intrinsic transforms on low-level objects. - -The practical migration rule is: - -- use `mirror()` / `rotate()` when you want to change the object relative to its - own origin -- use `flip_across(...)`, `rotate_around(...)`, or container-level transforms - when you want to move the object in its parent coordinate system - -### Example: `Port` - -Old behavior: - -```python -port.mirror(0) # changed both offset and orientation -``` - -New behavior: - -```python -port.mirror(0) # changes orientation only -port.flip_across(axis=0) # old "mirror in the parent pattern" behavior -``` - -### What to audit - -Check code that calls: - -- `Port.mirror(...)` -- `Ref.rotate(...)` -- `Ref.mirror(...)` -- `Label.rotate_around(...)` / `Label.mirror(...)` -- `Abstract.mirror_port_offsets(...)` / `Abstract.mirror_ports(...)` - -If that code expected offsets or repetition grids to move automatically, it -needs updating. For whole-pattern transforms, prefer calling `Pattern.mirror()` -or `Pattern.rotate_around(...)` at the container level. - -`Abstract.mirror_port_offsets()` and `Abstract.mirror_ports()` were removed. -Use `Abstract.mirror(axis)` to mirror both port locations and orientations. If -you intentionally need only one half of that operation, update the individual -ports explicitly with `Port.flip_across(...)` or `Port.mirror(...)`. - -## Library hierarchy and graph behavior - -`masque/library.py` was split into the `masque.library` package. Imports from -the public module remain stable: - -```python -from masque import Library, LazyLibrary -# or -from masque.library import Library, LazyLibrary -``` - -Code importing the implementation file itself must move to the public package; -do not depend on the new internal `base`, `mapping`, or `lazy` module paths. - -Hierarchy helpers now handle dangling references explicitly. The following -methods accept `dangling='error' | 'ignore' | 'include'` and default to -`'error'`: - -- `child_graph()` -- `parent_graph()` -- `child_order()` -- `find_refs_local()` -- `find_refs_global()` -- `prune_empty()` - -On `master`, graph construction could expose missing targets implicitly or -fail later with a `KeyError`. If dangling refs are intentional, choose the -behavior explicitly, for example: - -```python -graph = library.child_graph(dangling='include') -order = library.child_order(dangling='ignore') -``` - -Graph cycles and invalid hierarchy states are now reported as `LibraryError` -with context. Audit code that caught `KeyError` or `graphlib.CycleError` from -these helpers. - -Invalid `dangling=` strings now raise `ValueError`; they no longer fall through -to the `include` behavior. Empty lists stored in `Pattern.refs` are consistently -treated as absent references by hierarchy and geometry traversal. Code that -uses a `defaultdict` lookup such as `pattern.refs[name]` without appending a -`Ref` will therefore not create an edge or force that target to be loaded. - -`Library.add()` now resolves the full name plan and remaps references before it -starts inserting cells. Name-resolution and preparation failures no longer -leave partially-added cells behind. A `rename_theirs` callback now receives an -`INameView` containing both existing names and names reserved earlier in the -same addition. Use membership, iteration, `len()`, or `get_name()`; the callback -argument is not the destination object and does not support pattern lookup or -mapping helpers such as `keys()` and `items()`. Update callback annotations from -`ILibraryView` to `INameView`. Custom `_merge()` implementations remain -responsible for their own rollback if they fail during the final commit. - -Reference transforms returned by `find_refs_local()` and -`find_refs_global()` are now Nx5 arrays. The fifth column is the cumulative -scale, so rows have the form `(x, y, rotation, mirrored, scale)` rather than the -old Nx4 form. - -`BuildReport` mapping fields are now defensively copied and read-only. Copy a -field to a new `dict` before adding or removing report entries. - -`PortLoadView`, `LayerMappedView`, `OverlayLibrary`, and source-backed outputs -returned by `LibraryBuilder.build()` borrow their sources. They do not close -source resources, and the views are not context managers. Keep each lazy -source open and unchanged until every borrowing view or overlay is finished, -then close the owning source explicitly. An eager `build(output='library')` -result is detached and does not need its sources afterward. - -Lazy GDS sources no longer provide `with_ports_from_data()` or -`with_port_overrides()` convenience methods. Construct the generic view -directly instead: `PortLoadView(source, layers=...)` imports port data, and -`PortLoadView(source, ports=..., replace=...)` applies explicit overrides. - -`LayerMappedView(source, map_layer)` lazily remaps shape and label layers while -leaving the source untouched. Its default `copy_through=False` forces mapped -serialization of every cell. Set `copy_through=True` only when source-aware -writers may copy untouched cells unchanged and intentionally skip their layer -mapping; persistently accessed cells are mapped and no longer copied through. - -`preflight_source_aware(...)` preserves that per-cell provenance. It returns a -borrowing `OverlayLibrary`, skips source-backed cells completely, and applies -pattern sorting, named-layer validation, safe empty-cell pruning, and -repeated-shape wrapping only to cells without reusable source provenance. -Library name order is retained because sorting source-backed cells would -require loading them. Always use the returned overlay for writing, and keep the -original lazy source open until the write finishes. The comprehensive -`preflight(...)` operation continues to materialize every cell when sorting is -enabled. - -Generic borrowing views no longer expose GDS-specific `raw_struct_bytes()`, -`can_copy_raw_struct()`, or forwarded `library_info` attributes. Keep the -owning GDS source or the metadata returned by `readfile()` when direct access is -needed. `IBorrowing.source_cell()` now provides format-neutral per-cell -provenance; GDS writers use it internally to preserve safe raw copy-through. - -Read-only `subtree()` results are now borrowed lazy views rather than eager -`LibraryView` snapshots. Creating one no longer loads its reachable patterns, -and the view preserves source ordering, hierarchy metadata, and lazy GDS -copy-through. Mutable `Library`, `LazyLibrary`, and `OverlayLibrary` subtrees -return the same writable type as their source. Overlay subtrees retain their -source layers and lazy GDS capabilities. Mutable subtree containers are -structurally independent, but already-materialized patterns remain shared. -Keep borrowed sources open and structurally unchanged for the subtree's -lifetime. - -`ILibrary` no longer inherits `collections.abc.MutableMapping`. It remains a -readable `Mapping` with explicit insert-only item assignment and deletion, but -generic mutation helpers such as `update()`, `setdefault()`, `pop()`, -`popitem()`, and `clear()` are no longer supplied. Use `add()`, `rename()`, -`delete()`, item insertion, and item deletion so library name and reference -invariants remain explicit. - -Library-level `referenced_patterns()` and `dangling_refs()` now report only -named cell targets and return `set[str]`. Populated refs whose target is `None` -are ignored, matching `child_graph()` and recursive geometry behavior; -`Pattern.referenced_patterns()` continues to report `None` locally. - -Port-importing views now always process detached patterns, including when the -raw source cell was already cached. Code can safely retain and compare raw and -processed views without port overrides leaking back into the raw pattern. -Once a processed cell is persistently materialized, lazy GDS writers no longer -copy the raw source structure for that cell, so later mutations to the returned -`Pattern` are serialized. Non-persistent materialization does not mark the cell -as changed. - -Underscore-prefixed declarations work through the attribute authoring surface: -`builder.cells._helper = pattern` now declares `_helper`. Only the view's exact -internal `_library` attribute is reserved. - -Recursive geometry operations now reject cyclic reference hierarchies with a -contextual `PatternError` instead of eventually leaking `RecursionError`. This -applies to bounds calculation, flattened layer polygon extraction, and -visualization as well as the existing flattening checks. - -`LibraryBuilder.validate(names=...)` now accepts either one string or a sequence -of strings. Non-string roots raise `TypeError`, and duplicate roots are reduced -to their first occurrence. A recipe may build a different `LibraryBuilder`, but -calling `build()` or `validate()` recursively on its own active builder now -raises `BuildError` before starting another session. - -`LibraryBuilder` is an authoring registry, not an `ILibraryView` or mapping. -Use membership, iteration, `keys()`, `get_name()`, assignment, and deletion to -manage declarations, then use the library returned by `build()` for reads and -hierarchy operations. Recipes that need an active library must receive the -builder-owned placeholder as a direct argument: - -```python -def make_top(lib: ILibrary) -> Pattern: - return Pather(library=lib, ports='device').pattern - -builder.cells.top = cell(make_top)(builder.library) -builder.cells.device = cell(factory)(hole_lib=builder.library) -``` - -The builder-owned `builder.library` placeholder is read-only. Only direct -positional and keyword values equal to it are substituted; placeholders nested -inside containers are not interpreted. A placeholder from another builder is -rejected when the recipe is assigned. - -`IMaterializable` now identifies libraries which support explicit -`materialize()` and `materialize_many()` operations. `LibraryBuilder.add()` -borrows these marked inputs, while ordinary mappings and `ILibraryView` -instances are copied eagerly. Use `add_source()` to force borrowing of an -unmarked view. Wrapping a materializable library in `LibraryView` intentionally -erases the marker. - -`IBorrowing` separately identifies composite views which retain direct source -libraries and expose them through `borrowed_sources()`. Keep those sources open -and unchanged for the lifetime of the borrowing view. Owner libraries such as -`LazyLibrary` and the lazy GDS readers are materializable but do not implement -`IBorrowing`. Borrowing views may expose unchanged per-cell layout provenance -through `source_cell()`; format writers decide whether that provenance permits -raw copy-through. Raw GDS structure access remains confined to GDS sources and -writers. - -`LibraryBuilder`, `OverlayLibrary`, `PortLoadView`, and `LayerMappedView` are new -additive library implementations. `LibraryBuilder` supports declarative `@cell` -recipes and dependency-aware builds; `OverlayLibrary` composes source libraries -without eagerly copying all patterns; `PortLoadView` loads port metadata from -labels and/or explicit mappings; `LayerMappedView` remaps shape and label layers -on detached materialization. - -Flattening with `flatten_ports=True` now rejects repeated refs whose target has -ports, because expanding them would create duplicate port names. Resolve the -repetition and assign unique port names before flattening, or use -`flatten_ports=False`. - -## GDSII module and lazy-loading changes - -`masque.file.gdsii` changed from a module into a package. The eager klamath -API remains available at the old import path, so ordinary `read`, `readfile`, -`write`, and `writefile` calls do not need to change: - -```python -from masque.file import gdsii -library, info = gdsii.readfile('layout.gds') -``` - -The old `gdsii.load_library()` and `gdsii.load_libraryfile()` entry points were -removed. Use the source-backed lazy reader instead: - -```python -# old -library, info = gdsii.load_libraryfile('layout.gds') - -# new -from masque.file.gdsii import lazy - -library, info = lazy.readfile('layout.gds') -try: - pattern = library['TOP'] -finally: - library.close() -``` - -`lazy.read(stream)` and `lazy.readfile(path, use_mmap=...)` return a read-only -`GdsLibrarySource`. It owns file resources when it opens them and also supports -the context-manager protocol. The old `full_load` and `postprocess` arguments -are gone; materialize/copy the desired cells and post-process them explicitly. - -An optional Arrow/native backend is available through -`masque.file.gdsii.arrow` and `masque.file.gdsii.lazy_arrow`; install the new -`arrow` extra to use it. These modules are additive and are not a transparent -replacement unless their additional dependencies and native library are -available. - -GDS writing now has one entry point for eager and lazy libraries. Use -`masque.file.gdsii.write()` or `masque.file.gdsii.writefile()` regardless of -which reader produced the library. The `lazy` and `lazy_arrow` modules no -longer re-export writer functions. Source-backed libraries continue to infer -their header metadata and copy untouched structures directly. - -## Shape construction and geometry additions - -The public `raw=True` constructor shortcut was removed from `Arc`, `Circle`, -`Ellipse`, `Path`, `Polygon`, `PolyCollection`, and `Text`. Call their normal -constructors without `raw`; `_from_raw()` is an internal fast path and is not a -compatibility API. - -```python -# old -polygon = Polygon(vertices, raw=True) - -# new -polygon = Polygon(vertices) -``` - -`Arc` radii must now be strictly positive rather than merely non-negative. -`Arc.angle_ref` is additive and defaults to `Arc.AngleRef.Center`, preserving -the previous center-referenced angle interpretation. - -`RectCollection` is a new shape for batches of axis-aligned rectangles and is -exported from both `masque` and `masque.shapes`. `Polygon.boolean()` and the -top-level `masque.boolean()` helper are also new; install the `boolean` extra -for their `pyclipper` dependency. - -## Other user-facing changes - -### File writers - -SVG writing no longer polygonizes or flattens caller-owned patterns in place; -it works from detached copies. `svg.writefile(..., annotate_ports=True)` can -add port arrows. DXF writing now expands shape repetitions into individual DXF -entities, so callers no longer need to wrap repeated shapes solely for DXF -output. - -### DXF environments - -If you install the DXF extra, the supported `ezdxf` baseline moved from -`~=1.0.2` to `~=1.4`. Any pinned environments should be updated accordingly. - -### Optional dependency names - -The misspelled `manhatanize_slow` extra was corrected to -`manhattanize_slow`. A separate `manhattanize` extra now installs the -scikit-image implementation. The `arrow` and `boolean` extras are also new. - -### New exports - -These are additive, but available now from `masque` and `masque.builder`: - -- from `masque`: `RectCollection`, `boolean`, `OverlayLibrary`, - `PortLoadView`, `LayerMappedView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`, - `BuildReport`, `CellProvenance`, and `cell` -- from `masque.builder`: `CostCallable`, `RenderStepKind`, the concrete - primitive-offer classes, structured route error/status types, - `ToolContractCase`, and `validate_tool_contract` - -## Minimal migration checklist - -If your code uses the routing stack, do these first: - -1. Replace `path`/`path_to`/`mpath`/`path_into` calls with - `trace`/`trace_to`/multi-port `trace`/`trace_into`. -2. Replace `SimpleTool` and old `AutoTool` descriptor construction with the - new `AutoTool.add_*()` methods. -3. Fix imports that still reference `masque.builder.builder` or - `masque.builder.renderpather`. -4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`. -5. Move lazy GDS calls from `gdsii.load_library*()` to - `masque.file.gdsii.lazy`. -6. Remove `raw=True` from public shape constructors. - -If your code only uses `Pattern`, `Library`, `place()`, and `plug()` without the -routing helpers, audit transforms, dangling-reference graph calls, raw shape -construction, and any stale imports. diff --git a/README.md b/README.md index 7060cd7..128a8c5 100644 --- a/README.md +++ b/README.md @@ -3,278 +3,49 @@ Masque is a Python module for designing lithography masks. The general idea is to implement something resembling the GDSII file-format, but -with some vectorized element types (eg. circles, not just polygons) and the ability -to output to multiple formats. +with some vectorized element types (eg. circles, not just polygons), better support for +E-beam doses, and the ability to output to multiple formats. - [Source repository](https://mpxd.net/code/jan/masque) - [PyPI](https://pypi.org/project/masque) -- [Github mirror](https://github.com/anewusername/masque) ## Installation Requirements: -* python >= 3.11 +* python >= 3.8 * numpy -* klamath (used for GDSII i/o) - -Optional requirements: -* `ezdxf` (DXF i/o): ezdxf -* `oasis` (OASIS i/o): fatamorgana -* `svg` (SVG output): svgwrite -* `visualization` (shape plotting): matplotlib -* `text` (`Text` shape): matplotlib, freetype +* klamath (used for `gdsii` i/o and library management) +* matplotlib (optional, used for `visualization` functions and `text`) +* ezdxf (optional, used for `dxf` i/o) +* fatamorgana (optional, used for `oasis` i/o) +* svgwrite (optional, used for `svg` output) +* freetype (optional, used for `text`) Install with pip: ```bash -pip install 'masque[oasis,dxf,svg,visualization,text]' +pip3 install 'masque[visualization,oasis,dxf,svg,text]' ``` -## Overview - -A layout consists of a hierarchy of `Pattern`s stored in a single `Library`. -Each `Pattern` can contain `Ref`s pointing at other patterns, `Shape`s, `Label`s, and `Port`s. - - -Library / Pattern hierarchy: -``` - +-----------------------------------------------------------------------+ - | Library | - | | - | Name: "MyChip" ...> Name: "Transistor" | - | +---------------------------+ : +---------------------------+ | - | | [Pattern] | : | [Pattern] | | - | | | : | | | - | | shapes: {...} | : | shapes: { | | - | | ports: {...} | : | "Si": [, ...] | | - | | | : | "M1": [, ...]}| | - | | refs: | : | ports: {G, S, D} | | - | | "Transistor": [Ref, Ref]|..: +---------------------------+ | - | +---------------------------+ | - | | - | # (`refs` keys resolve to Patterns within the Library) | - +-----------------------------------------------------------------------+ +Alternatively, install from git +```bash +pip3 install git+https://mpxd.net/code/jan/masque.git@release ``` - -Pattern internals: -``` - +---------------------------------------------------------------+ - | [Pattern] | - | | - | shapes: { | - | (1, 0): [Polygon, Circle, ...], # Geometry by layer | - | (2, 0): [Path, ...] | - | "M1" : [Path, ...] | - | "M2" : [Polygon, ...] | - | } | - | | - | refs: { # Key sets target name, Ref sets transform | - | "my_cell": [ | - | Ref(offset=(0,0), rotation=0), | - | Ref(offset=(10,0), rotation=R90, repetition=Grid(...)) | - | ] | - | } | - | | - | ports: { | - | "in": Port(offset=(0,0), rotation=0, ptype="M1"), | - | "out": Port(offset=(10,0), rotation=R180, ptype="wg") | - | } | - | | - +---------------------------------------------------------------+ -``` +## Translation +- `Pattern`: OASIS or GDS "Cell", DXF "Block" +- `SubPattern`: GDS "AREF/SREF", OASIS "Placement" +- `Shape`: OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline" +- `repetition`: OASIS "repetition". GDS "AREF" is a `SubPattern` combined with a `Grid` repetition. +- `Label`: OASIS, GDS, DXF "Text". +- `annotation`: OASIS or GDS "property" -`masque` departs from several "classic" GDSII paradigms: -- A `Pattern` object does not store its own name. A name is only assigned when the pattern is placed - into a `Library`, which is effectively a name->`Pattern` mapping. -- Layer info for `Shape`ss and `Label`s is not stored in the individual shape and label objects. - Instead, the layer is determined by the key for the container dict (e.g. `pattern.shapes[layer]`). - * This simplifies many common tasks: filtering `Shape`s by layer, remapping layers, and checking if - a layer is empty. - * Technically, this allows reusing the same shape or label object across multiple layers. This isn't - part of the standard workflow since a mixture of single-use and multi-use shapes could be confusing. - * This is similar to the approach used in [KLayout](https://www.klayout.de) -- `Ref` target names are also determined in the key of the container dict (e.g. `pattern.refs[target_name]`). - * This similarly simplifies filtering `Ref`s by target name, updating to a new target, and checking - if a given `Pattern` is referenced. -- `Pattern` names are set by their containing `Library` and are not stored in the `Pattern` objects. - * This guarantees that there are no duplicate pattern names within any given `Library`. - * Likewise, enumerating all the names (and all the `Pattern`s) in a `Library` is straightforward. -- Each `Ref`, `Shape`, or `Label` can be repeated multiple times by attaching a `repetition` object to it. - * This is similar to how OASIS reptitions are handled, and provides extra flexibility over the GDSII - approach of only allowing arrays through AREF (`Ref` + `repetition`). -- `Label`s do not have an orientation or presentation - * This is in line with how they are used in practice, and how they are represented in OASIS. -- Non-polygonal `Shape`s are allowed. For example, elliptical arcs are a basic shape type. - * This enables compatibility with OASIS (e.g. circles) and other formats. - * `Shape`s provide a `.to_polygons()` method for GDSII compatibility. -- Most coordinate values are stored as 64-bit floats internally. - * 1 earth radii in nanometers (6e15) is still represented without approximation (53 bit mantissa -> 2^53 > 9e15) - * Operations that would otherwise clip/round on are still represented approximately. - * Memory usage is usually dominated by other Python overhead. -- `Pattern` objects also contain `Port` information, which can be used to "snap" together - multiple sub-components by matching up the requested port offsets and rotations. - * Port rotations are defined as counter-clockwise angles from the +x axis. - * Ports point into the interior of their associated device. - * Port rotations may be `None` in the case of non-oriented ports. - * Ports have a `ptype` string which is compared in order to catch mismatched connections at build time. - * Ports can be exported into/imported from `Label`s stored directly in the layout, - editable from standard tools (e.g. KLayout). A default format is provided. +## TODO -In one important way, `masque` stays very orthodox: -References are accomplished by listing the target's name, not its `Pattern` object. - -- The main downside of this is that any operations that traverse the hierarchy require - both the `Pattern` and the `Library` which is contains its reference targets. -- This guarantees that names within a `Library` remain unique at all times. - * Since this can be tedious in cases where you don't actually care about the name of a - pattern, patterns whose names start with `SINGLE_USE_PREFIX` (default: an underscore) - may be silently renamed in order to maintain uniqueness. - See `masque.library.SINGLE_USE_PREFIX`, `masque.library._rename_patterns()`, - and `ILibrary.add()` for more details. -- Having all patterns accessible through the `Library` avoids having to perform a - tree traversal for every operation which needs to touch all `Pattern` objects - (e.g. deleting a layer everywhere or scaling all patterns). -- Since `Pattern` doesn't know its own name, you can't create a reference by passing in - a `Pattern` object -- you need to know its name. -- You *can* reference a `Pattern` before it is created, so long as you have already decided - on its name. -- Functions like `Pattern.place()` and `Pattern.plug()` need to receive a pattern's name - in order to create a reference, but they also need to access the pattern's ports. - * One way to provide this data is through an `Abstract`, generated via - `Library.abstract()` or through a `Library.abstract_view()`. - * Another way is use `Pather.place()` or `Pather.plug()`, which automatically creates - an `Abstract` from its internally-referenced `Library`. - - -## Glossary -- `Library`: A collection of named cells. OASIS or GDS "library" or file. -- `Tree`: Any `{name: pattern}` mapping which has only one topcell. -- `Pattern`: A collection of geometry, text labels, and reference to other patterns. - OASIS or GDS "Cell", DXF "Block". -- `Ref`: A reference to another pattern. GDS "AREF/SREF", OASIS "Placement". -- `Shape`: Individual geometric entity. OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline". -- `repetition`: Repetition operation. OASIS "repetition". - GDS "AREF" is a `Ref` combined with a `Grid` repetition. -- `Label`: Text label. Not rendered into geometry. OASIS, GDS, DXF "Text". -- `annotation`: Additional metadata. OASIS or GDS "property". - - -## Syntax, shorthand, and design patterns -Most syntax and behavior should follow normal python conventions. -There are a few exceptions, either meant to catch common mistakes or to provide a shorthand for common operations: - -### `Library` objects don't allow overwriting already-existing patterns -```python3 -library['mycell'] = pattern0 -library['mycell'] = pattern1 # Error! 'mycell' already exists and can't be overwritten -del library['mycell'] # We can explicitly delete it -library['mycell'] = pattern1 # And now it's ok to assign a new value -library.delete('mycell') # This also deletes all refs pointing to 'mycell' by default -``` - -### Insert a newly-made hierarchical pattern (with children) into a layout -```python3 -# Let's say we have a function which returns a new library containing one topcell (and possibly children) -tree = make_tree(...) - -# To reference this cell in our layout, we have to add all its children to our `library` first: -top_name = tree.top() # get the name of the topcell -name_mapping = library.add(tree) # add all patterns from `tree`, renaming eligible conflicting patterns -new_name = name_mapping.get(top_name, top_name) # get the new name for the cell (in case it was auto-renamed) -my_pattern.ref(new_name, ...) # instantiate the cell - -# This can be accomplished as follows -new_name = library << tree # Add `tree` into `library` and return the top cell's new name -my_pattern.ref(new_name, ...) # instantiate the cell - -# In practice, you may do lots of -my_pattern.ref(lib << make_tree(...), ...) - -# With a `Pather` and `place()`/`plug()` the `lib <<` portion can be implicit: -my_builder = Pather(library=lib, ...) -... -my_builder.place(make_tree(...)) -``` - -We can also use this shorthand to quickly add and reference a single flat (as yet un-named) pattern: -```python3 -anonymous_pattern = Pattern(...) -my_pattern.ref(lib << {'_tentative_name': anonymous_pattern}, ...) -``` - -### Place a hierarchical pattern into a layout, preserving its port info -```python3 -# As above, we have a function that makes a new library containing one topcell (and possibly children) -tree = make_tree(...) - -# We need to go get its port info to `place()` it into our existing layout, -new_name = library << tree # Add the tree to the library and return its name (see `<<` above) -abstract = library.abstract(tree) # An `Abstract` stores a pattern's name and its ports (but no geometry) -my_pattern.place(abstract, ...) - -# With shorthand, -abstract = library <= tree -my_pattern.place(abstract, ...) - -# or -my_pattern.place(library << make_tree(...), ...) -``` - - -### Quickly add geometry, labels, or refs: -Adding elements can be overly verbose: -```python3 -my_pattern.shapes[layer].append(Polygon(vertices, ...)) -my_pattern.labels[layer] += [Label('my text')] -my_pattern.refs[target_name].append(Ref(offset=..., ...)) -``` - -There is shorthand for the most common elements: -```python3 -my_pattern.polygon(layer=layer, vertices=vertices, ...) -my_pattern.rect(layer=layer, xctr=..., xmin=..., ymax=..., ly=...) # rectangle; pick 4 of 6 constraints -my_pattern.rect(layer=layer, ymin=..., ymax=..., xctr=..., lx=...) -my_pattern.path(...) -my_pattern.label(layer, 'my_text') -my_pattern.ref(target_name, offset=..., ...) -``` - -### Accessing ports -```python3 -# Square brackets pull from the underlying `.ports` dict: -assert pattern['input'] is pattern.ports['input'] - -# And you can use them to read multiple ports at once: -assert pattern[('input', 'output')] == { - 'input': pattern.ports['input'], - 'output': pattern.ports['output'], - } - -# But you shouldn't use them for anything except reading -pattern['input'] = Port(...) # Error! -has_input = ('input' in pattern) # Error! -``` - -### Building patterns -```python3 -library = Library(...) -my_pattern_name, my_pattern = library.mkpat(some_name_generator()) -... -def _make_my_subpattern() -> str: - # This function can draw from the outer scope (e.g. `library`) but will not pollute the outer scope - # (e.g. the variable `subpattern` will not be accessible from outside the function; you must load it - # from within `library`). - subpattern_name, subpattern = library.mkpat(...) - subpattern.rect(...) - ... - return subpattern_name -my_pattern.ref(_make_my_subpattern(), offset=..., ...) -``` - - -## Development - -Project-level planned work is tracked in [TODO.md](TODO.md). +* Better interface for polygon operations (e.g. with `pyclipper`) + - de-embedding + - boolean ops +* Construct polygons from bitmap using `skimage.find_contours` +* Deal with shape repetitions for dxf, svg diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py index 57b170c..0c34cce 100644 --- a/examples/ellip_grating.py +++ b/examples/ellip_grating.py @@ -2,33 +2,29 @@ import numpy -from masque.file import gdsii -from masque import Arc, Pattern +import masque +import masque.file.klamath +from masque import shapes -def main() -> None: - pat = Pattern() - layer = (0, 0) - pat.shapes[layer].extend([ - Arc( +def main(): + pat = masque.Pattern(name='ellip_grating') + for rmin in numpy.arange(10, 15, 0.5): + pat.shapes.append(shapes.Arc( radii=(rmin, rmin), width=0.1, angles=(-numpy.pi/4, numpy.pi/4), - ) - for rmin in numpy.arange(10, 15, 0.5)] - ) + layer=(0, 0), + )) - pat.label(string='grating centerline', offset=(1, 0), layer=(1, 2)) + pat.labels.append(masque.Label(string='grating centerline', offset=(1, 0), layer=(1, 2))) pat.scale_by(1000) pat.visualize() + pat2 = pat.copy() + pat2.name = 'grating2' - lib = { - 'ellip_grating': pat, - 'grating2': pat.copy(), - } - - gdsii.writefile(lib, 'out.gds.gz', meters_per_unit=1e-9, logical_units_per_unit=1e-3) + masque.file.klamath.writefile((pat, pat2), 'out.gds.gz', 1e-9, 1e-3) if __name__ == '__main__': diff --git a/examples/nested_poly_test.py b/examples/nested_poly_test.py deleted file mode 100644 index 60e0a3e..0000000 --- a/examples/nested_poly_test.py +++ /dev/null @@ -1,27 +0,0 @@ -from pyclipper import ( - Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO, - ) -p = Pyclipper() -p.AddPaths([ - [(-10, -10), (-10, 10), (-9, 10), (-9, -10)], - [(-10, 10), (10, 10), (10, 9), (-10, 9)], - [(10, 10), (10, -10), (9, -10), (9, 10)], - [(10, -10), (-10, -10), (-10, -9), (10, -9)], - ], PT_SUBJECT, closed=True) -#p.Execute2? -#p.Execute? -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) - -p = Pyclipper() -p.AddPaths([ - [(-10, -10), (-10, 10), (-9, 10), (-9, -10)], - [(-10, 10), (10, 10), (10, 9), (-10, 9)], - [(10, 10), (10, -10), (9, -10), (9, 10)], - [(10, -10), (-10, -10), (-10, -9), (10, -9)], - ], PT_SUBJECT, closed=True) -r = p.Execute2(CT_UNION, PFT_NONZERO, PFT_NONZERO) - -#r.Childs - diff --git a/examples/pic2mask.py b/examples/pic2mask.py deleted file mode 100644 index 0e2516a..0000000 --- a/examples/pic2mask.py +++ /dev/null @@ -1,43 +0,0 @@ -# pip install pillow scikit-image -# or -# sudo apt install python3-pil python3-skimage - -from PIL import Image -from skimage.measure import find_contours -from matplotlib import pyplot -import numpy - -from masque import Pattern, Polygon -from masque.file.gdsii import writefile - -# -# Read the image into a numpy array -# -im = Image.open('./Desktop/Camera/IMG_20220626_091101.jpg') - -aa = numpy.array(im.convert(mode='L').getdata()).reshape(im.height, im.width) - -threshold = (aa.max() - aa.min()) / 2 - -# -# Find edge contours and plot them -# -contours = find_contours(aa, threshold) - -pyplot.imshow(aa) -for contour in contours: - pyplot.plot(contour[:, 1], contour[:, 0], linewidth=2) -pyplot.show(block=False) - -# -# Create the layout from the contours -# -pat = Pattern() -pat.shapes[(0, 0)].extend([ - Polygon(vertices=vv) for vv in contours if len(vv) < 1_000 - ]) - -lib = {} -lib['my_mask_name'] = pat - -writefile(lib, 'test_contours.gds', meters_per_unit=1e-9) diff --git a/examples/profile_gdsii_readers.py b/examples/profile_gdsii_readers.py deleted file mode 100644 index 0eb05a5..0000000 --- a/examples/profile_gdsii_readers.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import argparse -import importlib -import json -import time -from pathlib import Path -from typing import Any - -from masque import LibraryError - - -READERS: dict[str, tuple[str, tuple[str, ...]]] = { - 'gdsii': ('masque.file.gdsii', ('readfile',)), - 'gdsii_arrow': ('masque.file.gdsii.arrow', ('readfile', 'arrow_import', 'arrow_convert')), - } - - -def _summarize_library(path: Path, elapsed_s: float, info: dict[str, object], lib: object) -> dict[str, object]: - assert hasattr(lib, '__len__') - assert hasattr(lib, 'tops') - tops = lib.tops() # type: ignore[no-any-return, attr-defined] - try: - unique_top = lib.top() # type: ignore[no-any-return, attr-defined] - except LibraryError: - unique_top = None - - return { - 'path': str(path), - 'elapsed_s': elapsed_s, - 'library_name': info['name'], - 'cell_count': len(lib), # type: ignore[arg-type] - 'topcells': tops, - 'topcell': unique_top, - } - - -def _summarize_arrow_import(path: Path, elapsed_s: float, arrow_arr: Any) -> dict[str, object]: - libarr = arrow_arr[0] - return { - 'path': str(path), - 'elapsed_s': elapsed_s, - 'arrow_rows': len(arrow_arr), - 'library_name': libarr['lib_name'].as_py(), - 'cell_count': len(libarr['cells']), - 'layer_count': len(libarr['layers']), - } - - -def _profile_stage(module: Any, stage: str, path: Path) -> dict[str, object]: - start = time.perf_counter() - - if stage == 'readfile': - lib, info = module.readfile(path) - elapsed_s = time.perf_counter() - start - return _summarize_library(path, elapsed_s, info, lib) - - if stage == 'arrow_import': - if hasattr(module, 'readfile_arrow'): - libarr, _info = module.readfile_arrow(path) - elapsed_s = time.perf_counter() - start - return { - 'path': str(path), - 'elapsed_s': elapsed_s, - 'arrow_rows': 1, - 'library_name': libarr['lib_name'].as_py(), - 'cell_count': len(libarr['cells']), - 'layer_count': len(libarr['layers']), - } - - arrow_arr = module._read_to_arrow(path) - elapsed_s = time.perf_counter() - start - return _summarize_arrow_import(path, elapsed_s, arrow_arr) - - if stage == 'arrow_convert': - arrow_arr = module._read_to_arrow(path) - libarr = arrow_arr[0] - start = time.perf_counter() - lib, info = module.read_arrow(libarr) - elapsed_s = time.perf_counter() - start - return _summarize_library(path, elapsed_s, info, lib) - - raise ValueError(f'Unsupported stage {stage!r}') - - -def build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description='Profile GDS readers with a stable end-to-end workload.') - parser.add_argument('--reader', choices=sorted(READERS), required=True) - parser.add_argument('--stage', default='readfile') - parser.add_argument('--path', type=Path, required=True) - parser.add_argument('--warmup', type=int, default=1) - parser.add_argument('--repeat', type=int, default=1) - parser.add_argument('--output-json', type=Path) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_arg_parser() - args = parser.parse_args(argv) - - module_name, stages = READERS[args.reader] - if args.stage not in stages: - parser.error(f'reader {args.reader!r} only supports stages: {", ".join(stages)}') - - module = importlib.import_module(module_name) - path = args.path.expanduser().resolve() - - for _ in range(args.warmup): - _profile_stage(module, args.stage, path) - - runs = [] - for _ in range(args.repeat): - runs.append(_profile_stage(module, args.stage, path)) - - payload = { - 'reader': args.reader, - 'stage': args.stage, - 'warmup': args.warmup, - 'repeat': args.repeat, - 'runs': runs, - } - rendered = json.dumps(payload, indent=2, sort_keys=True) - if args.output_json is not None: - args.output_json.parent.mkdir(parents=True, exist_ok=True) - args.output_json.write_text(rendered + '\n') - print(rendered) - return 0 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/examples/test_rep.py b/examples/test_rep.py index d25fb55..042e1af 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -1,138 +1,103 @@ -from pprint import pprint -from pathlib import Path - import numpy from numpy import pi import masque -from masque import Pattern, Ref, Arc, Library +import masque.file.gdsii +import masque.file.klamath +import masque.file.dxf +import masque.file.oasis +from masque import shapes, Pattern, SubPattern from masque.repetition import Grid -from masque.file import gdsii, dxf, oasis + +from pprint import pprint - -def main() -> None: - lib = Library() - - cell_name = 'ellip_grating' - pat = masque.Pattern() - - layer = (0, 0) +def main(): + pat = masque.Pattern(name='ellip_grating') for rmin in numpy.arange(10, 15, 0.5): - pat.shapes[layer].append(Arc( + pat.shapes.append(shapes.Arc( radii=(rmin, rmin), width=0.1, - angles=(0 * -pi/4, pi/4), + angles=(0*-numpy.pi/4, numpy.pi/4), annotations={'1': ['blah']}, - )) + )) pat.scale_by(1000) # pat.visualize() - lib[cell_name] = pat - print(f'\nAdded {cell_name}:') + pat2 = pat.copy() + pat2.name = 'grating2' + + pat3 = Pattern('sref_test') + pat3.subpatterns = [ + SubPattern(pat, offset=(1e5, 3e5), annotations={'4': ['Hello I am the base subpattern']}), + SubPattern(pat, offset=(2e5, 3e5), rotation=pi/3), + SubPattern(pat, offset=(3e5, 3e5), rotation=pi/2), + SubPattern(pat, offset=(4e5, 3e5), rotation=pi), + SubPattern(pat, offset=(5e5, 3e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(True, False), offset=(1e5, 4e5)), + SubPattern(pat, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3), + SubPattern(pat, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2), + SubPattern(pat, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi), + SubPattern(pat, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(False, True), offset=(1e5, 5e5)), + SubPattern(pat, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3), + SubPattern(pat, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2), + SubPattern(pat, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi), + SubPattern(pat, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(True, True), offset=(1e5, 6e5)), + SubPattern(pat, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3), + SubPattern(pat, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2), + SubPattern(pat, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi), + SubPattern(pat, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2), + ] + + pprint(pat3) + pprint(pat3.subpatterns) pprint(pat.shapes) - new_name = lib.get_name(cell_name) - lib[new_name] = pat.copy() - print(f'\nAdded a copy of {cell_name} as {new_name}') - - pat3 = Pattern() - pat3.refs[cell_name] = [ - Ref(offset=(1e5, 3e5), annotations={'4': ['Hello I am the base Ref']}), - Ref(offset=(2e5, 3e5), rotation=pi/3), - Ref(offset=(3e5, 3e5), rotation=pi/2), - Ref(offset=(4e5, 3e5), rotation=pi), - Ref(offset=(5e5, 3e5), rotation=3*pi/2), - Ref(mirrored=True, offset=(1e5, 4e5)), - Ref(mirrored=True, offset=(2e5, 4e5), rotation=pi/3), - Ref(mirrored=True, offset=(3e5, 4e5), rotation=pi/2), - Ref(mirrored=True, offset=(4e5, 4e5), rotation=pi), - Ref(mirrored=True, offset=(5e5, 4e5), rotation=3*pi/2), - Ref(offset=(1e5, 5e5)).mirror_target(1), - Ref(offset=(2e5, 5e5), rotation=pi/3).mirror_target(1), - Ref(offset=(3e5, 5e5), rotation=pi/2).mirror_target(1), - Ref(offset=(4e5, 5e5), rotation=pi).mirror_target(1), - Ref(offset=(5e5, 5e5), rotation=3*pi/2).mirror_target(1), - Ref(offset=(1e5, 6e5)).mirror2d_target(True, True), - Ref(offset=(2e5, 6e5), rotation=pi/3).mirror2d_target(True, True), - Ref(offset=(3e5, 6e5), rotation=pi/2).mirror2d_target(True, True), - Ref(offset=(4e5, 6e5), rotation=pi).mirror2d_target(True, True), - Ref(offset=(5e5, 6e5), rotation=3*pi/2).mirror2d_target(True, True), + rep = Grid(a_vector=[1e4, 0], + b_vector=[0, 1.5e4], + a_count=3, + b_count=2,) + pat4 = Pattern('aref_test') + pat4.subpatterns = [ + SubPattern(pat, repetition=rep, offset=(1e5, 3e5)), + SubPattern(pat, repetition=rep, offset=(2e5, 3e5), rotation=pi/3), + SubPattern(pat, repetition=rep, offset=(3e5, 3e5), rotation=pi/2), + SubPattern(pat, repetition=rep, offset=(4e5, 3e5), rotation=pi), + SubPattern(pat, repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(1e5, 4e5)), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(1e5, 5e5)), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(1e5, 6e5)), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2), ] - lib['sref_test'] = pat3 - print('\nAdded sref_test:') - pprint(pat3) - pprint(pat3.refs) + folder = 'layouts/' + masque.file.klamath.writefile((pat, pat2, pat3, pat4), folder + 'rep.gds.gz', 1e-9, 1e-3) - rep = Grid( - a_vector=[1e4, 0], - b_vector=[0, 1.5e4], - a_count=3, - b_count=2, - ) - pat4 = Pattern() - pat4.refs[cell_name] = [ - Ref(repetition=rep, offset=(1e5, 3e5)), - Ref(repetition=rep, offset=(2e5, 3e5), rotation=pi/3), - Ref(repetition=rep, offset=(3e5, 3e5), rotation=pi/2), - Ref(repetition=rep, offset=(4e5, 3e5), rotation=pi), - Ref(repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2), - Ref(repetition=rep, mirrored=True, offset=(1e5, 4e5)), - Ref(repetition=rep, mirrored=True, offset=(2e5, 4e5), rotation=pi/3), - Ref(repetition=rep, mirrored=True, offset=(3e5, 4e5), rotation=pi/2), - Ref(repetition=rep, mirrored=True, offset=(4e5, 4e5), rotation=pi), - Ref(repetition=rep, mirrored=True, offset=(5e5, 4e5), rotation=3*pi/2), - Ref(repetition=rep, offset=(1e5, 5e5)).mirror_target(1), - Ref(repetition=rep, offset=(2e5, 5e5), rotation=pi/3).mirror_target(1), - Ref(repetition=rep, offset=(3e5, 5e5), rotation=pi/2).mirror_target(1), - Ref(repetition=rep, offset=(4e5, 5e5), rotation=pi).mirror_target(1), - Ref(repetition=rep, offset=(5e5, 5e5), rotation=3*pi/2).mirror_target(1), - Ref(repetition=rep, offset=(1e5, 6e5)).mirror2d_target(True, True), - Ref(repetition=rep, offset=(2e5, 6e5), rotation=pi/3).mirror2d_target(True, True), - Ref(repetition=rep, offset=(3e5, 6e5), rotation=pi/2).mirror2d_target(True, True), - Ref(repetition=rep, offset=(4e5, 6e5), rotation=pi).mirror2d_target(True, True), - Ref(repetition=rep, offset=(5e5, 6e5), rotation=3*pi/2).mirror2d_target(True, True), - ] + cells = list(masque.file.klamath.readfile(folder + 'rep.gds.gz')[0].values()) + masque.file.klamath.writefile(cells, folder + 'rerep.gds.gz', 1e-9, 1e-3) - lib['aref_test'] = pat4 - print('\nAdded aref_test') - - folder = Path('./layouts/') - folder.mkdir(exist_ok=True) - print(f'...writing files to {folder}...') - - gds1 = folder / 'rep.gds.gz' - gds2 = folder / 'rerep.gds.gz' - print(f'Initial write to {gds1}') - gdsii.writefile(lib, gds1, 1e-9, 1e-3) - - print(f'Read back and rewrite to {gds2}') - readback_lib, _info = gdsii.readfile(gds1) - gdsii.writefile(readback_lib, gds2, 1e-9, 1e-3) - - dxf1 = folder / 'rep.dxf.gz' - dxf2 = folder / 'rerep.dxf.gz' - print(f'Write aref_test to {dxf1}') - dxf.writefile(lib, 'aref_test', dxf1) - - print(f'Read back and rewrite to {dxf2}') - dxf_lib, _info = dxf.readfile(dxf1) - print(Library(dxf_lib)) - dxf.writefile(dxf_lib, 'Model', dxf2) + masque.file.dxf.writefile(pat4, folder + 'rep.dxf.gz') + dxf, info = masque.file.dxf.readfile(folder + 'rep.dxf.gz') + masque.file.dxf.writefile(dxf, folder + 'rerep.dxf.gz') layer_map = {'base': (0,0), 'mylabel': (1,2)} - oas1 = folder / 'rep.oas' - oas2 = folder / 'rerep.oas' - print(f'Write lib to {oas1}') - oasis.writefile(lib, oas1, 1000, layer_map=layer_map) - - print(f'Read back and rewrite to {oas2}') - oas_lib, oas_info = oasis.readfile(oas1) - oasis.writefile(oas_lib, oas2, 1000, layer_map=layer_map) - - print('OASIS info:') - pprint(oas_info) + masque.file.oasis.writefile((pat, pat2, pat3, pat4), folder + 'rep.oas.gz', 1000, layer_map=layer_map) + oas, info = masque.file.oasis.readfile(folder + 'rep.oas.gz') + masque.file.oasis.writefile(list(oas.values()), folder + 'rerep.oas.gz', 1000, layer_map=layer_map) + print(info) if __name__ == '__main__': diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 7aee3f7..e69de29 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -1,54 +0,0 @@ -masque Tutorial -=============== - -These examples are meant to be read roughly in order. - -- Start with `basic_shapes.py` for the core `Pattern` / GDS concepts. -- Then read `devices.py` and `library.py` for hierarchical composition and libraries. -- Read the `pather*` tutorials separately when you want routing helpers. - -Contents --------- - -- [basic_shapes](basic_shapes.py): - * Draw basic geometry - * Export to GDS -- [devices](devices.py) - * Build hierarchical photonic-crystal example devices - * Reference other patterns - * Add ports to a pattern - * Use `Pather` to snap ports together into a circuit - * Check for dangling references -- [library](library.py) - * Continue from `devices.py` by declaring a mixed library with `LibraryBuilder` - * Import source-backed GDS cells and register python-generated recipes together - * Call `build()` to produce a normal library and report for downstream `Pather` usage and writing - * Explore alternate ways of specifying a pattern for `.plug()` and `.place()` -- [pather](pather.py) - * Use `Pather` to route individual wires and wire bundles - * Define a custom `Tool` that exposes primitive routing offers - * Use primitive offers to automatically transition between path types -- [renderpather](renderpather.py) - * Use `Pather(render='deferred')` and `PathTool` to build a layout similar to the one in [pather](pather.py), - but using `Path` shapes instead of `Polygon`s. -- [port_pather](port_pather.py) - * Use `PortPather` and the `.at()` syntax for more concise routing - * Advanced port manipulation and connections - - -Additionally, [pcgen](pcgen.py) is a utility module used by `devices.py` for generating -photonic-crystal lattices; it is support code rather than a step-by-step tutorial. - - -Running -------- - -Run from inside the examples directory: -```bash -cd examples/tutorial -python3 basic_shapes.py -klayout -e basic_shapes.gds -``` - -Some tutorials depend on outputs from earlier ones. In particular, `library.py` -expects `circuit.gds`, which is generated by `devices.py`. diff --git a/examples/tutorial/basic_shapes.py b/examples/tutorial/basic_shapes.py index d8f7e1e..ccd89ff 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -1,18 +1,21 @@ +from typing import Tuple, Sequence import numpy from numpy import pi -from masque import layer_t, Pattern, Circle, Arc, Ref -from masque.repetition import Grid +from masque import layer_t, Pattern, SubPattern, Label +from masque.shapes import Circle, Arc, Polygon +from masque.builder import Device, Port +from masque.library import Library, DeviceLibrary import masque.file.gdsii # Note that masque units are arbitrary, and are only given # physical significance when writing to a file. -GDS_OPTS = dict( - meters_per_unit = 1e-9, # GDS database unit, 1 nanometer - logical_units_per_unit = 1e-3, # GDS display unit, 1 micron - ) +GDS_OPTS = { + 'meters_per_unit': 1e-9, # GDS database unit, 1 nanometer + 'logical_units_per_unit': 1e-3, # GDS display unit, 1 micron +} def hole( @@ -27,54 +30,14 @@ def hole( layer: Layer to draw the circle on. Returns: - Pattern containing a circle. + Pattern, named `'hole'` """ - pat = Pattern() - pat.shapes[layer].append( - Circle(radius=radius, offset=(0, 0)) - ) + pat = Pattern('hole', shapes=[ + Circle(radius=radius, offset=(0, 0), layer=layer) + ]) return pat -def hole_array( - radius: float, - num_x: int = 5, - num_y: int = 3, - pitch: float = 2000, - layer: layer_t = (1, 0), - ) -> Pattern: - """ - Generate an array of circular holes using `Repetition`. - - Args: - radius: Circle radius. - num_x, num_y: Number of holes in x and y. - pitch: Center-to-center spacing. - layer: Layer to draw the holes on. - - Returns: - Pattern containing a grid of holes. - """ - # First, make a pattern for a single hole - hpat = hole(radius, layer) - - # Now, create a pattern that references it multiple times using a Grid - pat = Pattern() - pat.refs['hole'] = [ - Ref( - offset=(0, 0), - repetition=Grid(a_vector=(pitch, 0), a_count=num_x, - b_vector=(0, pitch), b_count=num_y) - )] - - # We can also add transformed references (rotation, mirroring, etc.) - pat.refs['hole'].append( - Ref(offset=(0, -pitch), rotation=pi / 4, mirrored=True) - ) - - return pat, hpat - - def triangle( radius: float, layer: layer_t = (1, 0), @@ -87,16 +50,17 @@ def triangle( layer: Layer to draw the circle on. Returns: - Pattern containing a triangle + Pattern, named `'triangle'` """ vertices = numpy.array([ (numpy.cos( pi / 2), numpy.sin( pi / 2)), (numpy.cos(pi + pi / 6), numpy.sin(pi + pi / 6)), (numpy.cos( - pi / 6), numpy.sin( - pi / 6)), - ]) * radius + ]) * radius - pat = Pattern() - pat.polygon(layer, vertices=vertices) + pat = Pattern('triangle', shapes=[ + Polygon(offset=(0, 0), layer=layer, vertices=vertices), + ]) return pat @@ -114,44 +78,37 @@ def smile( secondary_layer: Layer to draw eyes and smile on. Returns: - Pattern containing a smiley face + Pattern, named `'smile'` """ # Make an empty pattern - pat = Pattern() + pat = Pattern('smile') # Add all the shapes we want - pat.shapes[layer] += [ - Circle(radius=radius, offset=(0, 0)), # Outer circle - ] - - pat.shapes[secondary_layer] += [ - Circle(radius=radius / 10, offset=(radius / 3, radius / 3)), - Circle(radius=radius / 10, offset=(-radius / 3, radius / 3)), - Arc( - radii=(radius * 2 / 3, radius * 2 / 3), # Underlying ellipse radii + pat.shapes += [ + Circle(radius=radius, offset=(0, 0), layer=layer), # Outer circle + Circle(radius=radius / 10, offset=(radius / 3, radius / 3), layer=secondary_layer), + Circle(radius=radius / 10, offset=(-radius / 3, radius / 3), layer=secondary_layer), + Arc(radii=(radius * 2 / 3, radius * 2 / 3), # Underlying ellipse radii angles=(7 / 6 * pi, 11 / 6 * pi), # Angles limiting the arc width=radius / 10, offset=(0, 0), - ), + layer=secondary_layer), ] return pat def main() -> None: - lib = {} + hole_pat = hole(1000) + smile_pat = smile(1000) + tri_pat = triangle(1000) - lib['hole'] = hole(1000) - lib['smile'] = smile(1000) - lib['triangle'] = triangle(1000) + units_per_meter = 1e-9 + units_per_display_unit = 1e-3 - # Use a Grid to make many holes efficiently - lib['grid'], lib['hole'] = hole_array(1000) + masque.file.gdsii.writefile([hole_pat, tri_pat, smile_pat], 'basic_shapes.gds', **GDS_OPTS) - masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS) - - lib['triangle'].visualize() - lib['grid'].visualize(lib) + smile_pat.visualize() if __name__ == '__main__': diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index 955e786..5cae36e 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -1,22 +1,12 @@ -""" -Tutorial: building hierarchical devices with `Pattern`, `Port`, and `Pather`. - -This file uses photonic-crystal components as the concrete example, so some of -the geometry-generation code is domain-specific. The tutorial value is in the -Masque patterns around it: creating reusable cells, annotating ports, composing -hierarchy with references, and snapping ports together to build a larger circuit. -""" -from collections.abc import Sequence, Mapping +from typing import Tuple, Sequence, Dict import numpy from numpy import pi -from masque import ( - layer_t, Pattern, Ref, Pather, Port, Polygon, - Library, - ) -from masque.utils import ports2data -from masque.file.gdsii import writefile, check_valid_names +from masque import layer_t, Pattern, SubPattern, Label +from masque.shapes import Polygon +from masque.builder import Device, Port, port_utils +from masque.file.gdsii import writefile import pcgen import basic_shapes @@ -27,41 +17,40 @@ LATTICE_CONSTANT = 512 RADIUS = LATTICE_CONSTANT / 2 * 0.75 -def ports_to_data(pat: Pattern) -> Pattern: +def dev2pat(dev: Device) -> Pattern: """ - Bake port information into the pattern. + Bake port information into the device. This places a label at each port location on layer (3, 0) with text content 'name:ptype angle_deg' """ - return ports2data.ports_to_data(pat, layer=(3, 0)) + return port_utils.dev2pat(dev, layer=(3, 0)) -def data_to_ports(lib: Mapping[str, Pattern], name: str, pat: Pattern) -> Pattern: +def pat2dev(pat: Pattern) -> Device: """ - Scan the Pattern to determine port locations. Same port format as `ports_to_data` + Scans the Pattern to determine port locations. Same format as `dev2pat` """ - return ports2data.data_to_ports(layers=[(3, 0)], library=lib, pattern=pat, name=name) + return port_utils.pat2dev(pat, layers=[(3, 0)]) def perturbed_l3( lattice_constant: float, - hole: str, - hole_lib: Mapping[str, Pattern], + hole: Pattern, + trench_dose: float = 1.0, trench_layer: layer_t = (1, 0), shifts_a: Sequence[float] = (0.15, 0, 0.075), shifts_r: Sequence[float] = (1.0, 1.0, 1.0), - xy_size: tuple[int, int] = (10, 10), + xy_size: Tuple[int, int] = (10, 10), perturbed_radius: float = 1.1, trench_width: float = 1200, - ) -> Pattern: + ) -> Device: """ - Generate a `Pattern` representing a perturbed L3 cavity. + Generate a `Device` representing a perturbed L3 cavity. Args: lattice_constant: Distance between nearest neighbor holes - hole: name of a `Pattern` containing a single hole - hole_lib: Library which contains the `Pattern` object for hole. - Necessary because we need to know how big it is... + hole: `Pattern` object containing a single hole + trench_dose: Dose for the trenches. Default 1.0. (Hole dose is 1.0.) trench_layer: Layer for the trenches, default `(1, 0)`. shifts_a: passed to `pcgen.l3_shift`; specifies lattice constant (1 - multiplicative factor) for shifting holes adjacent to @@ -72,277 +61,250 @@ def perturbed_l3( Provided sequence should have same length as `shifts_a`. xy_size: `(x, y)` number of mirror periods in each direction; total size is `2 * n + 1` holes in each direction. Default (10, 10). - perturbed_radius: radius of holes perturbed to form an upwards-directed beam + perturbed_radius: radius of holes perturbed to form an upwards-driected beam (multiplicative factor). Default 1.1. - trench_width: Width of the undercut trenches. Default 1200. + trench width: Width of the undercut trenches. Default 1200. Returns: - `Pattern` object representing the L3 design. + `Device` object representing the L3 design. """ - print('Generating perturbed L3...') - # Get hole positions and radii xyr = pcgen.l3_shift_perturbed_defect(mirror_dims=xy_size, perturbed_radius=perturbed_radius, shifts_a=shifts_a, shifts_r=shifts_r) - # Build the cavity by instancing the supplied `hole` pattern many times. - # Using references keeps the pattern compact even though it contains many holes. - pat = Pattern() - pat.refs[hole] += [ - Ref(scale=r, offset=(lattice_constant * x, - lattice_constant * y)) + # Build L3 cavity, using references to the provided hole pattern + pat = Pattern(f'L3p-a{lattice_constant:g}rp{perturbed_radius:g}') + pat.subpatterns += [ + SubPattern(hole, scale=r, + offset=(lattice_constant * x, + lattice_constant * y)) for x, y, r in xyr] - # Add rectangular undercut aids based on the referenced hole extents. - min_xy, max_xy = pat.get_bounds_nonempty(hole_lib) + # Add rectangular undercut aids + min_xy, max_xy = pat.get_bounds_nonempty() trench_dx = max_xy[0] - min_xy[0] - pat.shapes[trench_layer] += [ - Polygon.rect(ymin=max_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), - Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), + pat.shapes += [ + Polygon.rect(ymin=max_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width, + layer=trench_layer, dose=trench_dose), + Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width, + layer=trench_layer, dose=trench_dose), ] - # Define the interface in Masque terms: two ports at the left/right extents. + # Ports are at outer extents of the device (with y=0) extent = lattice_constant * xy_size[0] - pat.ports = dict( - input=Port((-extent, 0), rotation=0, ptype='pcwg'), - output=Port((extent, 0), rotation=pi, ptype='pcwg'), - ) + ports = { + 'input': Port((-extent, 0), rotation=0, ptype='pcwg'), + 'output': Port((extent, 0), rotation=pi, ptype='pcwg'), + } - ports_to_data(pat) - return pat + return Device(pat, ports) def waveguide( lattice_constant: float, - hole: str, + hole: Pattern, length: int, mirror_periods: int, - ) -> Pattern: + ) -> Device: """ - Generate a `Pattern` representing a photonic crystal line-defect waveguide. + Generate a `Device` representing a photonic crystal line-defect waveguide. Args: lattice_constant: Distance between nearest neighbor holes - hole: name of a `Pattern` containing a single hole + hole: `Pattern` object containing a single hole length: Distance (number of mirror periods) between the input and output ports. Ports are placed at lattice sites. mirror_periods: Number of hole rows on each side of the line defect Returns: - `Pattern` object representing the waveguide. + `Device` object representing the waveguide. """ - # Generate the normalized lattice locations for the line defect. + # Generate hole locations xy = pcgen.waveguide(length=length, num_mirror=mirror_periods) - # Build the pattern by placing repeated references to the same hole cell. - pat = Pattern() - pat.refs[hole] += [ - Ref(offset=(lattice_constant * x, - lattice_constant * y)) - for x, y in xy] + # Build the pattern + pat = Pattern(f'_wg-a{lattice_constant:g}l{length}') + pat.subpatterns += [SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y)) + for x, y in xy] - # Publish the device interface as two ports at the outer edges. + # Ports are at outer edges, with y=0 extent = lattice_constant * length / 2 - pat.ports = dict( - left=Port((-extent, 0), rotation=0, ptype='pcwg'), - right=Port((extent, 0), rotation=pi, ptype='pcwg'), - ) - - ports_to_data(pat) - return pat + ports = { + 'left': Port((-extent, 0), rotation=0, ptype='pcwg'), + 'right': Port((extent, 0), rotation=pi, ptype='pcwg'), + } + return Device(pat, ports) def bend( lattice_constant: float, - hole: str, + hole: Pattern, mirror_periods: int, - ) -> Pattern: + ) -> Device: """ - Generate a `Pattern` representing a 60-degree counterclockwise bend in a photonic crystal + Generate a `Device` representing a 60-degree counterclockwise bend in a photonic crystal line-defect waveguide. Args: lattice_constant: Distance between nearest neighbor holes - hole: name of a `Pattern` containing a single hole + hole: `Pattern` object containing a single hole mirror_periods: Minimum number of mirror periods on each side of the line defect. Returns: - `Pattern` object representing the waveguide bend. + `Device` object representing the waveguide bend. Ports are named 'left' (input) and 'right' (output). """ - # Generate the normalized lattice locations for the bend. + # Generate hole locations xy = pcgen.wgbend(num_mirror=mirror_periods) - # Build the pattern by instancing the shared hole cell. - pat = Pattern() - pat.refs[hole] += [ - Ref(offset=(lattice_constant * x, - lattice_constant * y)) + # Build the pattern + pat= Pattern(f'_wgbend-a{lattice_constant:g}l{mirror_periods}') + pat.subpatterns += [ + SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y)) for x, y in xy] - # Publish the bend interface as two ports. + # Figure out port locations. extent = lattice_constant * mirror_periods - pat.ports = dict( - left=Port((-extent, 0), rotation=0, ptype='pcwg'), - right=Port((extent / 2, - extent * numpy.sqrt(3) / 2), - rotation=pi * 4 / 3, ptype='pcwg'), - ) - ports_to_data(pat) - return pat + ports = { + 'left': Port((-extent, 0), rotation=0, ptype='pcwg'), + 'right': Port((extent / 2, + extent * numpy.sqrt(3) / 2), + rotation=pi * 4 / 3, ptype='pcwg'), + } + return Device(pat, ports) def y_splitter( lattice_constant: float, - hole: str, + hole: Pattern, mirror_periods: int, - ) -> Pattern: + ) -> Device: """ - Generate a `Pattern` representing a photonic crystal line-defect waveguide y-splitter. + Generate a `Device` representing a photonic crystal line-defect waveguide y-splitter. Args: lattice_constant: Distance between nearest neighbor holes - hole: name of a `Pattern` containing a single hole + hole: `Pattern` object containing a single hole mirror_periods: Minimum number of mirror periods on each side of the line defect. Returns: - `Pattern` object representing the y-splitter. + `Device` object representing the y-splitter. Ports are named 'in', 'top', and 'bottom'. """ - # Generate the normalized lattice locations for the splitter. + # Generate hole locations xy = pcgen.y_splitter(num_mirror=mirror_periods) - # Build the pattern by instancing the shared hole cell. - pat = Pattern() - pat.refs[hole] += [ - Ref(offset=(lattice_constant * x, - lattice_constant * y)) + # Build pattern + pat = Pattern(f'_wgsplit_half-a{lattice_constant:g}l{mirror_periods}') + pat.subpatterns += [ + SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y)) for x, y in xy] - # Publish the splitter interface as one input and two outputs. + # Determine port locations extent = lattice_constant * mirror_periods - pat.ports = { + ports = { 'in': Port((-extent, 0), rotation=0, ptype='pcwg'), 'top': Port((extent / 2, extent * numpy.sqrt(3) / 2), rotation=pi * 4 / 3, ptype='pcwg'), 'bot': Port((extent / 2, -extent * numpy.sqrt(3) / 2), rotation=pi * 2 / 3, ptype='pcwg'), } - - ports_to_data(pat) - return pat + return Device(pat, ports) -def main(interactive: bool = True) -> None: - # First make a couple of reusable primitive cells. - shape_lib = { - 'smile': basic_shapes.smile(RADIUS), - 'hole': basic_shapes.hole(RADIUS), - } +def main(interactive: bool = True): + # Generate some basic hole patterns + smile = basic_shapes.smile(RADIUS) + hole = basic_shapes.hole(RADIUS) - # Then build a small library of higher-level devices from those primitives. + # Build some devices a = LATTICE_CONSTANT + wg10 = waveguide(lattice_constant=a, hole=hole, length=10, mirror_periods=5).rename('wg10') + wg05 = waveguide(lattice_constant=a, hole=hole, length=5, mirror_periods=5).rename('wg05') + wg28 = waveguide(lattice_constant=a, hole=hole, length=28, mirror_periods=5).rename('wg28') + bend0 = bend(lattice_constant=a, hole=hole, mirror_periods=5).rename('bend0') + ysplit = y_splitter(lattice_constant=a, hole=hole, mirror_periods=5).rename('ysplit') + l3cav = perturbed_l3(lattice_constant=a, hole=smile, xy_size=(4, 10)).rename('l3cav') # uses smile :) - devices = {} - devices['wg05'] = waveguide(lattice_constant=a, hole='hole', length=5, mirror_periods=5) - devices['wg10'] = waveguide(lattice_constant=a, hole='hole', length=10, mirror_periods=5) - devices['wg28'] = waveguide(lattice_constant=a, hole='hole', length=28, mirror_periods=5) - devices['wg90'] = waveguide(lattice_constant=a, hole='hole', length=90, mirror_periods=5) - devices['bend0'] = bend(lattice_constant=a, hole='hole', mirror_periods=5) - devices['ysplit'] = y_splitter(lattice_constant=a, hole='hole', mirror_periods=5) - devices['l3cav'] = perturbed_l3(lattice_constant=a, hole='smile', hole_lib=shape_lib, xy_size=(4, 10)) # uses smile :) - - # Turn the device mapping into a `Library`. - # That gives us convenience helpers for hierarchy inspection and abstract views. - lib = Library(devices) + # Autogenerate port labels so that GDS will also contain port data + for device in [wg10, wg05, wg28, l3cav, ysplit, bend0]: + dev2pat(device) # # Build a circuit # - # Create a `Pather`, and register the resulting top cell as "my_circuit". - circ = Pather(library=lib, name='my_circuit') + circ = Device(name='my_circuit', ports={}) - # Start by placing a waveguide and renaming its ports to match the circuit-level - # names we want to use while assembling the design. - circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'}) + # Start by placing a waveguide. Call its ports "in" and "signal". + circ.place(wg10, offset=(0, 0), port_map={'left': 'in', 'right': 'signal'}) - # Extend the signal path by attaching another waveguide. - # Because `wg10` only has one unattached port left after the plug, Masque can - # infer that it should keep the name `signal`. - circ.plug('wg10', {'signal': 'left'}) - - # We could have done the following instead: - # circ_pat = Pattern() - # lib['my_circuit'] = circ_pat - # circ_pat.place(lib.abstract('wg10'), ...) - # circ_pat.plug(lib.abstract('wg10'), ...) - # but `Pather` removes some repeated `lib.abstract(...)` boilerplate and keeps - # the assembly code focused on port-level intent. + # Extend the signal path by attaching the "left" port of a waveguide. + # Since there is only one other port ("right") on the waveguide we + # are attaching (wg10), it automatically inherits the name "signal". + circ.plug(wg10, {'signal': 'left'}) # Attach a y-splitter to the signal path. # Since the y-splitter has 3 ports total, we can't auto-inherit the # port name, so we have to specify what we want to name the unattached # ports. We can call them "signal1" and "signal2". - circ.plug('ysplit', {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'}) + circ.plug(ysplit, {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'}) # Add a waveguide to both signal ports, inheriting their names. - circ.plug('wg05', {'signal1': 'left'}) - circ.plug('wg05', {'signal2': 'left'}) + circ.plug(wg05, {'signal1': 'left'}) + circ.plug(wg05, {'signal2': 'left'}) - # Add a bend to both branches. - # Our bend primitive is defined with a specific orientation, so choosing which - # port to plug determines whether the path turns clockwise or counterclockwise. - # We could also mirror one instance instead of using opposite ports. - circ.plug('bend0', {'signal1': 'right'}) - circ.plug('bend0', {'signal2': 'left'}) + # Add a bend to both ports. + # Our bend's ports "left" and "right" refer to the original counterclockwise + # orientation. We want the bends to turn in opposite directions, so we attach + # the "right" port to "signal1" to bend clockwise, and the "left" port + # to "signal2" to bend counterclockwise. + # We could also use `mirrored=(True, False)` to mirror one of the devices + # and then use same device port on both paths. + circ.plug(bend0, {'signal1': 'right'}) + circ.plug(bend0, {'signal2': 'left'}) # We add some waveguides and a cavity to "signal1". - circ.plug('wg10', {'signal1': 'left'}) - circ.plug('l3cav', {'signal1': 'input'}) - circ.plug('wg10', {'signal1': 'left'}) + circ.plug(wg10, {'signal1': 'left'}) + circ.plug(l3cav, {'signal1': 'input'}) + circ.plug(wg10, {'signal1': 'left'}) - # `signal2` gets a single waveguide of equivalent overall length. - circ.plug('wg28', {'signal2': 'left'}) + # "signal2" just gets a single of equivalent length + circ.plug(wg28, {'signal2': 'left'}) - # Now bend both branches back towards each other. - circ.plug('bend0', {'signal1': 'right'}) - circ.plug('bend0', {'signal2': 'left'}) - circ.plug('wg05', {'signal1': 'left'}) - circ.plug('wg05', {'signal2': 'left'}) + # Now we bend both waveguides back towards each other + circ.plug(bend0, {'signal1': 'right'}) + circ.plug(bend0, {'signal2': 'left'}) + circ.plug(wg05, {'signal1': 'left'}) + circ.plug(wg05, {'signal2': 'left'}) - # To join the branches, attach a second y-junction. - # This succeeds only if both chosen ports agree on the same translation and - # rotation for the inserted device; otherwise Masque raises an exception. - circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'}) + # To join the waveguides, we attach a second y-junction. + # We plug "signal1" into the "bot" port, and "signal2" into the "top" port. + # The remaining port gets named "signal_out". + # This operation would raise an exception if the ports did not line up + # correctly (i.e. they required different rotations or translations of the + # y-junction device). + circ.plug(ysplit, {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'}) # Finally, add some more waveguide to "signal_out". - circ.plug('wg10', {'signal_out': 'left'}) + circ.plug(wg10, {'signal_out': 'left'}) - # Bake the top-level port metadata into labels so it survives GDS export. - # These labels appear on the circuit cell; individual child devices keep their - # own port labels in their own cells. - ports_to_data(circ.pattern) - - # Check if we forgot to include any patterns... ooops! - if dangling := lib.dangling_refs(): - print('Warning: The following patterns are referenced, but not present in the' - f' library! {dangling}') - print('We\'ll solve this by merging in shape_lib, which contains those shapes...') - - lib.add(shape_lib) - assert not lib.dangling_refs() - - # We can visualize the design directly, though opening the written GDS is often easier. + # We can visualize the design. Usually it's easier to just view the GDS. if interactive: print('Visualizing... this step may be slow') - circ.pattern.visualize(lib) + circ.pattern.visualize() - # Write out only the subtree reachable from our top cell. - subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use - check_valid_names(subtree.keys()) - writefile(subtree, 'circuit.gds', **GDS_OPTS) + # We can also add text labels for our circuit's ports. + # They will appear at the uppermost hierarchy level, while the individual + # device ports will appear further down, in their respective cells. + dev2pat(circ) + + # Write out to GDS + writefile(circ.pattern, 'circuit.gds', **GDS_OPTS) if __name__ == '__main__': diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index 5650540..2dc2af6 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,133 +1,137 @@ -""" -Tutorial: authoring a mixed library with `LibraryBuilder`. - -This example assumes you have already read `devices.py` and generated the -`circuit.gds` file it writes. The goal here is not the photonic-crystal geometry -itself, but rather how Masque lets you combine imported GDS cells with -python-generated recipes, then turn that declaration set into a normal library -for downstream assembly and writing. -""" -from typing import Any +from typing import Tuple, Sequence, Callable from pprint import pformat +import numpy +from numpy import pi -from masque import ILibrary, LibraryBuilder, Pather, Pattern, PortLoadView, cell -from masque.file.gdsii import writefile -from masque.file.gdsii.lazy import readfile +from masque.builder import Device +from masque.library import Library, LibDeviceLibrary +from masque.file.gdsii import writefile, load_libraryfile +import pcgen import basic_shapes import devices +from devices import pat2dev, dev2pat from basic_shapes import GDS_OPTS -def make_mixed_waveguide(lib: ILibrary) -> Pattern: - """ - Recipe which assembles imported and generated cells behind the builder API. - """ - circ = Pather(library=lib, ports='tri_l3cav') - - # First way to specify what we are plugging in: request an explicit abstract. - circ.plug(lib.abstract('wg10'), {'input': 'right'}) - - # Second way: use an AbstractView, which behaves like a mapping of names - # to abstracts. - abstracts = lib.abstract_view() - circ.plug(abstracts['wg10'], {'output': 'left'}) - - # Third way: let Pather resolve a pattern name through its own library. - circ.plug('tri_wg10', {'input': 'right'}) - circ.plug('tri_wg10', {'output': 'left'}) - - return circ.pattern - - def main() -> None: - builder = LibraryBuilder() - cells = builder.cells - - # Naming and export policy can be bound once without changing individual - # declarations. For example, a foundry-specific group could use: - # - # cells = builder.cells.prefixed( - # 'foundry_', - # facade=lambda proxy: devices.ports_to_data(proxy), - # ) - # - # Dynamic names use the same view: cells[generated_name] = cell(factory)(...) + # Define a `Library`-backed `DeviceLibrary`, which provides lazy evaluation + # for device generation code and lazy-loading of GDS contents. + device_lib = LibDeviceLibrary() # # Load some devices from a GDS file # - # Scan circuit.gds and prepare to lazy-load its contents. Port labels are - # imported on first materialization, but the raw source remains untouched - # until we build the final library. - gds_lib, _properties = readfile('circuit.gds') - builder.add_source(PortLoadView(gds_lib, layers=[(3, 0)], max_depth=1)) + # Scan circuit.gds and prepare to lazy-load its contents + pattern_lib, _properties = load_libraryfile('circuit.gds', tag='mycirc01') + + # Add it into the device library by providing a way to read port info + # This maintains the lazy evaluation from above, so no patterns + # are actually read yet. + device_lib.add_library(pattern_lib, pat2dev=pat2dev) + + print('Devices loaded from GDS into library:\n' + pformat(list(device_lib.keys()))) - print('Registered imported cells:\n' + pformat(list(gds_lib.keys()))) # - # Register some new devices, this time from python code rather than GDS. + # Add some new devices to the library, this time from python code rather than GDS # - cells.triangle = basic_shapes.triangle(devices.RADIUS) - opts: dict[str, Any] = dict( - lattice_constant=devices.LATTICE_CONSTANT, - hole='triangle', - ) + a = devices.LATTICE_CONSTANT + tri = basic_shapes.triangle(devices.RADIUS) - cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts) - cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts) - cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts) - cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts) - cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts) - cells.tri_l3cav = cell(devices.perturbed_l3)( - xy_size=(4, 10), - **opts, - hole_lib=builder.library, - ) - cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder.library) + # Convenience function for adding devices + # This is roughly equivalent to + # `device_lib[name] = lambda: dev2pat(fn())` + # but it also guarantees that the resulting pattern is named `name`. + def add(name: str, fn: Callable[[], Device]) -> None: + device_lib.add_device(name=name, fn=fn, dev2pat=dev2pat) + + # Triangle-based variants. These are defined here, but they won't run until they're + # retrieved from the library. + add('tri_wg10', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=10, mirror_periods=5)) + add('tri_wg05', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=5, mirror_periods=5)) + add('tri_wg28', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=28, mirror_periods=5)) + add('tri_bend0', lambda: devices.bend(lattice_constant=a, hole=tri, mirror_periods=5)) + add('tri_ysplit', lambda: devices.y_splitter(lattice_constant=a, hole=tri, mirror_periods=5)) + add('tri_l3cav', lambda: devices.perturbed_l3(lattice_constant=a, hole=tri, xy_size=(4, 10))) - print('Declared cells waiting to be built:\n' + pformat(list(builder.keys()))) # - # Build the declaration set into a normal library. + # Build a mixed waveguide with an L3 cavity in the middle # - built, report = builder.build() - print('Built library contains:\n' + pformat(list(built.keys()))) - print('Build dependency graph:\n' + pformat(report.dependency_graph)) + # Immediately start building from an instance of the L3 cavity + circ2 = device_lib['tri_l3cav'].build('mixed_wg_cav') + + print(device_lib['wg10'].ports) + circ2.plug(device_lib['wg10'], {'input': 'right'}) + circ2.plug(device_lib['wg10'], {'output': 'left'}) + circ2.plug(device_lib['tri_wg10'], {'input': 'right'}) + circ2.plug(device_lib['tri_wg10'], {'output': 'left'}) + + # Add the circuit to the device library. + # It has already been generated, so we can use `set_const` as a shorthand for + # `device_lib['mixed_wg_cav'] = lambda: circ2` + device_lib.set_const(circ2) + # - # Continue designing against the built library. + # Build a device that could plug into our mixed_wg_cav and joins the two ports # - # The built result behaves like a normal mutable library, so downstream code - # can use Pather, abstract views, and writing without going back through the - # builder interface. - circ = Pather.interface(source='mixed_wg_cav', library=built) - circ.plug('tri_bend0', {'input': 'right'}) - circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry - circ.plug('tri_bend0', {'input': 'right'}) - circ.plug('bend0', {'output': 'left'}) - circ.plug('bend0', {'output': 'left'}) - circ.plug('bend0', {'output': 'left'}) - circ.plug('tri_wg10', {'input': 'right'}) - circ.plug('tri_wg28', {'input': 'right'}) - circ.plug('tri_wg10', {'input': 'right', 'output': 'left'}) - built['loop_segment'] = circ.pattern + # We'll be designing against an existing device's interface... + circ3 = circ2.as_interface('loop_segment') + # ... that lets us continue from where we left off. + circ3.plug(device_lib['tri_bend0'], {'input': 'right'}) + circ3.plug(device_lib['tri_bend0'], {'input': 'left'}, mirrored=(True, False)) # mirror since no tri y-symmetry + circ3.plug(device_lib['tri_bend0'], {'input': 'right'}) + circ3.plug(device_lib['bend0'], {'output': 'left'}) + circ3.plug(device_lib['bend0'], {'output': 'left'}) + circ3.plug(device_lib['bend0'], {'output': 'left'}) + circ3.plug(device_lib['tri_wg10'], {'input': 'right'}) + circ3.plug(device_lib['tri_wg28'], {'input': 'right'}) + circ3.plug(device_lib['tri_wg10'], {'input': 'right', 'output': 'left'}) + + device_lib.set_const(circ3) # - # Write all devices into a GDS file. + # Write all devices into a GDS file # - print('Writing library to file...') - writefile(built, 'library.gds', **GDS_OPTS) - # The default build output is an overlay which borrows its lazy sources. - # Close the owning GDS source only after the overlay is no longer needed. - gds_lib.close() + # This line could be slow, since it generates or loads many of the devices + # since they were not all accessed above. + all_device_pats = [dev.pattern for dev in device_lib.values()] + + writefile(all_device_pats, 'library.gds', **GDS_OPTS) if __name__ == '__main__': main() + + +# +#class prout: +# def place( +# self, +# other: Device, +# label_layer: layer_t = 'WATLAYER', +# *, +# port_map: Optional[Dict[str, Optional[str]]] = None, +# **kwargs, +# ) -> 'prout': +# +# Device.place(self, other, port_map=port_map, **kwargs) +# name: Optional[str] +# for name in other.ports: +# if port_map: +# assert(name is not None) +# name = port_map.get(name, name) +# if name is None: +# continue +# self.pattern.labels += [ +# Label(string=name, offset=self.ports[name].offset, layer=layer)] +# return self +# diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py deleted file mode 100644 index c536428..0000000 --- a/examples/tutorial/pather.py +++ /dev/null @@ -1,538 +0,0 @@ -""" -Manual wire routing tutorial: Pather and primitive offers -""" -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Any, Literal - -import numpy -from numpy import pi -from masque import Pather, Library, Pattern, Port, layer_t -from masque.abstract import Abstract -from masque.builder import ( - BendOffer, RenderStep, StraightOffer, Tool, ToolContractCase, validate_tool_contract, - ) -from masque.error import BuildError -from masque.file.gdsii import writefile -from masque.library import ILibrary, SINGLE_USE_PREFIX - -from basic_shapes import GDS_OPTS - -# -# Define some basic wire widths, in nanometers -# M2 is the top metal; M1 is below it and connected with vias on V1 -# -M1_WIDTH = 1000 -V1_WIDTH = 500 -M2_WIDTH = 4000 - -# -# First, we can define some functions for generating our wire geometry -# - -def make_pad() -> Pattern: - """ - Create a pattern with a single rectangle of M2, with a single port on the bottom - - Every pad will be an instance of the same pattern, so we will only call this function once. - """ - pat = Pattern() - pat.rect(layer='M2', xctr=0, yctr=0, lx=3 * M2_WIDTH, ly=4 * M2_WIDTH) - pat.ports['wire_port'] = Port((0, -2 * M2_WIDTH), rotation=pi / 2, ptype='m2wire') - return pat - - -def make_via( - layer_top: layer_t, - layer_via: layer_t, - layer_bot: layer_t, - width_top: float, - width_via: float, - width_bot: float, - ptype_top: str, - ptype_bot: str, - ) -> Pattern: - """ - Generate three concentric squares, on the provided layers - (`layer_top`, `layer_via`, `layer_bot`) and with the provided widths - (`width_top`, `width_via`, `width_bot`). - - Two ports are added, with the provided ptypes (`ptype_top`, `ptype_bot`). - They are placed at the left edge of the top layer and right edge of the - bottom layer, respectively. - - We only have one via type, so we will only call this function once. - """ - pat = Pattern() - pat.rect(layer=layer_via, xctr=0, yctr=0, lx=width_via, ly=width_via) - pat.rect(layer=layer_bot, xctr=0, yctr=0, lx=width_bot, ly=width_bot) - pat.rect(layer=layer_top, xctr=0, yctr=0, lx=width_top, ly=width_top) - pat.ports = { - 'top': Port(offset=(-width_top / 2, 0), rotation=0, ptype=ptype_top), - 'bottom': Port(offset=(width_bot / 2, 0), rotation=pi, ptype=ptype_bot), - } - return pat - - -def make_bend(layer: layer_t, width: float, ptype: str) -> Pattern: - """ - Generate a triangular wire, with ports at the left (input) and bottom (output) edges. - This is effectively a clockwise wire bend. - - Every bend will be the same, so we only need to call this twice (once each for M1 and M2). - We could call it additional times for different wire widths or bend types (e.g. squares). - """ - pat = Pattern() - pat.polygon(layer=layer, vertices=[(0, -width / 2), (0, width / 2), (width, -width / 2)]) - pat.ports = { - 'input': Port(offset=(0, 0), rotation=0, ptype=ptype), - 'output': Port(offset=(width / 2, -width / 2), rotation=pi / 2, ptype=ptype), - } - return pat - - -def make_straight_wire(layer: layer_t, width: float, ptype: str, length: float) -> Pattern: - """ - Generate a straight wire with ports along either end (x=0 and x=length). - - Every waveguide will be single-use, so we'll need to create lots of (mostly unique) - `Pattern`s, and this function will get called very often. - """ - pat = Pattern() - pat.rect(layer=layer, xmin=0, xmax=length, yctr=0, ly=width) - pat.ports = { - 'input': Port(offset=(0, 0), rotation=0, ptype=ptype), - 'output': Port(offset=(length, 0), rotation=pi, ptype=ptype), - } - return pat - - -def map_layer(layer: layer_t) -> layer_t: - """ - Map from a strings to GDS layer numbers - """ - layer_mapping = { - 'M1': (10, 0), - 'M2': (20, 0), - 'V1': (30, 0), - } - if isinstance(layer, str): - return layer_mapping.get(layer, layer) - return layer - - -@dataclass(frozen=True, slots=True) -class WireStraightData: - length: float - out_transition: 'WireTransitionSpec | None' = None - - -@dataclass(frozen=True, slots=True) -class WireBendData: - straight_length: float - ccw: bool - - -@dataclass(frozen=True, slots=True) -class WireTransitionSpec: - abstract: Abstract - in_port_name: str - out_port_name: str - - @property - def in_port(self) -> Port: - return self.abstract.ports[self.in_port_name] - - @property - def out_port(self) -> Port: - return self.abstract.ports[self.out_port_name] - - -@dataclass(frozen=True, slots=True) -class WireTransitionData: - spec: WireTransitionSpec - - -@dataclass -class PrimitiveWireTool(Tool): - """ - Minimal routing tool that exposes local routing primitives directly. - - The high-level `Pather` methods below still decide how to compose straights, - bends, and ptype transitions. This tool only describes which one-step - primitives it can draw and how selected primitives should be rendered. - """ - layer: layer_t - width: float - ptype: str - bend: Abstract - transitions: Sequence[WireTransitionSpec] - - def _straight_pattern(self, length: float) -> Pattern: - return make_straight_wire(layer=self.layer, width=self.width, ptype=self.ptype, length=length) - - @staticmethod - def _transition_length(spec: WireTransitionSpec) -> float | None: - dxy, angle = spec.in_port.measure_travel(spec.out_port) - if angle is None or not numpy.isclose(angle, pi) or not numpy.isclose(dxy[1], 0): - return None - return float(dxy[0]) - - def _transition_offers(self, in_ptype: str | None) -> tuple[StraightOffer, ...]: - offers: list[StraightOffer] = [] - for spec in self.transitions: - if spec.out_port.ptype != self.ptype: - continue - if in_ptype not in (None, 'unk', spec.in_port.ptype): - continue - - length = self._transition_length(spec) - if length is None: - continue - - def endpoint_planner( - parameter: float, - *, - spec: WireTransitionSpec = spec, - length: float = length, - ) -> Port: - _ = parameter - return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype) - - def commit_planner( - parameter: float, - *, - spec: WireTransitionSpec = spec, - ) -> WireTransitionData: - _ = parameter - return WireTransitionData(spec) - - offers.append(StraightOffer( - in_ptype = spec.in_port.ptype, - out_ptype = spec.out_port.ptype, - length_domain = (length, length), - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - )) - return tuple(offers) - - def _out_transition_offers(self, out_ptype: str | None) -> tuple[StraightOffer, ...]: - if out_ptype in ('unk', self.ptype): - return () - - offers: list[StraightOffer] = [] - for spec in self.transitions: - if spec.in_port.ptype != self.ptype: - continue - if out_ptype is not None and spec.out_port.ptype != out_ptype: - continue - - transition_length = self._transition_length(spec) - if transition_length is None: - continue - - def endpoint_planner( - length: float, - *, - spec: WireTransitionSpec = spec, - transition_length: float = transition_length, - ) -> Port: - straight_length = length - transition_length - if straight_length < 0: - raise BuildError( - f'Asked to draw straight path with total length {length:,g}, shorter than required transition: {transition_length:,g}' - ) - return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype) - - def commit_planner( - length: float, - *, - spec: WireTransitionSpec = spec, - transition_length: float = transition_length, - ) -> WireStraightData: - endpoint_planner(length) - return WireStraightData(length - transition_length, spec) - - offers.append(StraightOffer( - in_ptype = self.ptype, - out_ptype = spec.out_port.ptype, - length_domain = (transition_length, numpy.inf), - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - )) - return tuple(offers) - - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, # noqa: ARG002 (Pather validates selected output ptypes) - **kwargs: Any, - ) -> tuple[StraightOffer | BendOffer, ...]: - if kind == 'straight': - tool_options = dict(kwargs) - - def endpoint_planner(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype=self.ptype) - - def commit_planner(length: float) -> WireStraightData: - _ = tool_options - return WireStraightData(length) - - native_offer = StraightOffer( - in_ptype = self.ptype, - out_ptype = self.ptype, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - ) - return (*self._transition_offers(in_ptype), native_offer, *self._out_transition_offers(out_ptype)) - - if kind == 'bend': - ccw = bool(kwargs.pop('ccw')) - bend_forward = self.width / 2 - bend_run = bend_forward if ccw else -bend_forward - bend_rotation = -pi / 2 if ccw else pi / 2 - - def endpoint_planner(length: float) -> Port: - straight_length = length - bend_forward - if straight_length < 0: - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_forward:,g}' - ) - return Port((length, bend_run), rotation=bend_rotation, ptype=self.ptype) - - def commit_planner(length: float) -> WireBendData: - endpoint_planner(length) - return WireBendData(straight_length=length - bend_forward, ccw=ccw) - - return (BendOffer( - in_ptype = self.ptype, - out_ptype = self.ptype, - ccw = ccw, - length_domain = (bend_forward, numpy.inf), - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - ),) - - if kind in ('s', 'u'): - return () - - raise BuildError(f'Unrecognized primitive offer kind {kind!r}') - - def _render_straight(self, tree: ILibrary, port_names: tuple[str, str], data: WireStraightData) -> None: - if numpy.isclose(data.length, 0) and data.out_transition is None: - return - - if not numpy.isclose(data.length, 0): - tree.top_pattern().plug( - self._straight_pattern(data.length), - {port_names[1]: 'input'}, - append=True, - ) - - if data.out_transition is not None: - self._render_transition(tree, port_names, WireTransitionData(data.out_transition)) - - def _render_bend(self, tree: ILibrary, port_names: tuple[str, str], data: WireBendData) -> None: - self._render_straight(tree, port_names, WireStraightData(data.straight_length)) - tree.top_pattern().plug( - self.bend, - {port_names[1]: 'input'}, - mirrored=data.ccw, - ) - - @staticmethod - def _render_transition(tree: ILibrary, port_names: tuple[str, str], data: WireTransitionData) -> None: - tree.top_pattern().plug( - data.spec.abstract, - {port_names[1]: data.spec.in_port_name}, - ) - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - ) -> ILibrary: - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_wire') - pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else self.ptype) - - for step in batch: - assert step.tool == self - if isinstance(step.data, WireTransitionData): - self._render_transition(tree, port_names, step.data) - elif isinstance(step.data, WireStraightData): - self._render_straight(tree, port_names, step.data) - elif isinstance(step.data, WireBendData): - self._render_bend(tree, port_names, step.data) - else: - raise BuildError(f'Unexpected primitive render data {type(step.data)}') - return tree - - -def prepare_tools() -> tuple[Library, Tool, Tool]: - """ - Create some basic library elements and tools for drawing M1 and M2 - """ - # Build some patterns (static cells) using the above functions and store them in a library - library = Library() - library['pad'] = make_pad() - library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH) - library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH) - library['v1_via'] = make_via( - layer_top = 'M2', - layer_via = 'V1', - layer_bot = 'M1', - width_top = M2_WIDTH, - width_via = V1_WIDTH, - width_bot = M1_WIDTH, - ptype_bot = 'm1wire', - ptype_top = 'm2wire', - ) - - # - # Now, define two tools. - # M1_tool will route on M1, using wires with M1_WIDTH. - # M2_tool will route on M2, using wires with M2_WIDTH. - # - # Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers - # directly: it tells `Pather` about native straight/bend primitives and about - # via adapters that can transition between M1 and M2 port types. - # - via = library.abstract('v1_via') - via_transitions = ( - WireTransitionSpec(via, 'top', 'bottom'), - WireTransitionSpec(via, 'bottom', 'top'), - ) - - M1_tool = PrimitiveWireTool( - layer = 'M1', - width = M1_WIDTH, - ptype = 'm1wire', - bend = library.abstract('m1_bend'), - transitions = via_transitions, - ) - - M2_tool = PrimitiveWireTool( - layer = 'M2', - width = M2_WIDTH, - ptype = 'm2wire', - bend = library.abstract('m2_bend'), - transitions = via_transitions, - ) - - # Custom tools can be checked independently of Pather or pytest. Automatic - # probes cover each offer domain; explicit probes can target useful process - # dimensions. Unsupported primitive families opt out with require_offers=False. - for tool in (M1_tool, M2_tool): - validate_tool_contract(tool, ( - ToolContractCase('straight', in_ptype=tool.ptype, probe_parameters=(10_000,)), - ToolContractCase('bend', in_ptype=tool.ptype, ccw=False), - ToolContractCase('bend', in_ptype=tool.ptype, ccw=True), - ToolContractCase('s', in_ptype=tool.ptype, require_offers=False), - ToolContractCase('u', in_ptype=tool.ptype, require_offers=False), - )) - return library, M1_tool, M2_tool - - -# -# Now we can start building up our library (collection of static cells) and pathing tools. -# -# If any of the operations below are confusing, you can cross-reference against the deferred -# `Pather` tutorial, which handles some things more explicitly (e.g. via placement) and simplifies -# others (e.g. geometry definition). -# -def main() -> None: - library, M1_tool, M2_tool = prepare_tools() - - # - # Create a new pather which writes to `library` and uses `M2_tool` as its default tool. - # Then, place some pads and start routing wires! - # - pather = Pather(library, tools=M2_tool) - - # Place two pads, and define their ports as 'VCC' and 'GND' - pather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) - pather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) - # Add some labels to make the pads easier to distinguish - pather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) - pather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) - - # Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False) - # The total distance forward (including the bend's forward component) must be 6um - pather.cw('VCC', 6_000) - - # Now path VCC to x=0. This time, don't include any bend. - # Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction. - pather.straight('VCC', x=0) - - # Path GND forward by 5um, turning clockwise 90 degrees. - pather.cw('GND', 5_000) - - # This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend. - pather.straight('GND', x=pather['VCC'].offset[0]) - - # Now, start using M1_tool for GND. - # Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves. - # If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here - # and achieve the same result without having to define any transitions in M1_tool. - # Note that even though we have changed the tool used for GND, the via doesn't get placed until - # the next time we route GND (the `pather.ccw()` call below). - pather.retool(M1_tool, keys='GND') - - # Bundle together GND and VCC, and path the bundle forward and counterclockwise. - # Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000. - # Other wires in the bundle (in this case VCC) should be spaced at 5_000 pitch (so VCC ends up at x=-5_000) - # - # Since we recently retooled GND, its path starts with a via down to M1 (included in the distance - # calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2. - pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) - - # Now use M1_tool as the default tool for all ports/signals. - # Since VCC does not have an explicitly assigned tool, it will now transition down to M1. - pather.retool(M1_tool) - - # Path the GND + VCC bundle forward and counterclockwise by 90 degrees. - # The total extension (travel distance along the forward direction) for the longest segment (in - # this case the segment being added to GND) should be exactly 50um. - # After turning, the wire pitch should be reduced only 1.2um. - pather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200) - - # Make a U-turn with the bundle and expand back out to 4.5um wire pitch. - # Here, emin specifies the travel distance for the shortest segment. For the first call - # that applies to VCC, and for the second call, that applies to GND; the relative lengths of the - # segments depend on their starting positions and their ordering within the bundle. - pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200) - pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500) - - # Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been - # explicitly assigned a tool. - pather.retool(M2_tool) - - # Now path both ports to x=-28_000. - # With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is - # equivalent. - pather.straight(['GND', 'VCC'], xmin=-28_000) - - # Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1. - # This results in a via at the end of the wire (instead of having one at the start like we got - # when using pather.retool(). - pather.straight('VCC', x=-50_000, out_ptype='m1wire') - - # Now extend GND out to x=-50_000, using M2 for a portion of the path. - # We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice. - with pather.toolctx(M2_tool, keys='GND'): - pather.straight('GND', x=-40_000) - pather.straight('GND', x=-50_000) - - # Save the pather's pattern into our library - library['Pather_and_PrimitiveOffers'] = pather.pattern - - # Convert from text-based layers to numeric layers for GDS, and output the file - library.map_layers(map_layer) - writefile(library, 'pather.gds', **GDS_OPTS) - - -if __name__ == '__main__': - main() diff --git a/examples/tutorial/pcgen.py b/examples/tutorial/pcgen.py index 5c5c31b..aebcc3f 100644 --- a/examples/tutorial/pcgen.py +++ b/examples/tutorial/pcgen.py @@ -2,7 +2,7 @@ Routines for creating normalized 2D lattices and common photonic crystal cavity designs. """ -from collections.abc import Sequence +from typing import Sequence, Tuple import numpy from numpy.typing import ArrayLike, NDArray @@ -29,11 +29,8 @@ def triangular_lattice( Returns: `[[x0, y0], [x1, 1], ...]` denoting lattice sites. """ - sx, sy = numpy.meshgrid( - numpy.arange(dims[0], dtype=float), - numpy.arange(dims[1], dtype=float), - indexing='ij', - ) + sx, sy = numpy.meshgrid(numpy.arange(dims[0], dtype=float), + numpy.arange(dims[1], dtype=float), indexing='ij') sx[sy % 2 == 1] += 0.5 sy *= numpy.sqrt(3) / 2 @@ -50,7 +47,7 @@ def triangular_lattice( elif origin == 'corner': pass else: - raise ValueError(f'Invalid value for `origin`: {origin}') + raise Exception(f'Invalid value for `origin`: {origin}') return xy[xy[:, 0].argsort(), :] @@ -197,12 +194,12 @@ def ln_defect( `[[x0, y0], [x1, y1], ...]` for all the holes """ if defect_length % 2 != 1: - raise ValueError('defect_length must be odd!') - pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims]) + raise Exception('defect_length must be odd!') + p = triangular_lattice([2 * d + 1 for d in mirror_dims]) half_length = numpy.floor(defect_length / 2) hole_nums = numpy.arange(-half_length, half_length + 1) - holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True) - return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :] + holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True) + return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ] def ln_shift_defect( @@ -233,8 +230,8 @@ def ln_shift_defect( # Shift holes # Expand shifts as necessary - tmp_a = numpy.asarray(shifts_a) - tmp_r = numpy.asarray(shifts_r) + tmp_a = numpy.array(shifts_a) + tmp_r = numpy.array(shifts_r) n_shifted = max(tmp_a.size, tmp_r.size) shifts_a = numpy.ones(n_shifted) @@ -248,7 +245,7 @@ def ln_shift_defect( for sign in (-1, 1): x_val = sign * (x_removed + ind + 1) which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0) - xyr[which, :] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) + xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) return xyr @@ -309,7 +306,7 @@ def l3_shift_perturbed_defect( # which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2]) perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2))) - for xy in perturbed_holes: - which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1) - xyr[which, 2] = perturbed_radius + for row in xyr: + if numpy.fabs(row) in perturbed_holes: + row[2] = perturbed_radius return xyr diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py deleted file mode 100644 index 8dabf82..0000000 --- a/examples/tutorial/port_pather.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -PortPather tutorial: Using .at() syntax -""" -from masque import Pather, Pattern, Port, R90 -from masque.file.gdsii import writefile - -from basic_shapes import GDS_OPTS -from pather import map_layer, prepare_tools - - -def main() -> None: - # Reuse the same patterns (pads, bends, vias) and tools as in pather.py - library, M1_tool, M2_tool = prepare_tools() - - # Create a deferred Pather and place some initial pads (same as Pather tutorial) - rpather = Pather(library, tools=M2_tool, render='deferred') - - rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) - rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) - rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) - rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) - - # - # Routing with .at() chaining - # - # The .at(port_name) method returns a PortPather object which wraps the Pather - # and remembers the selected port(s). This allows method chaining. - - # Route VCC: 6um South, then West to x=0. - # (Note: since the port points North into the pad, trace() moves South by default) - (rpather.at('VCC') - .trace(False, length=6_000) # Move South, turn West (Clockwise) - .trace_to(None, x=0) # Continue West to x=0 - ) - - # Route GND: 5um South, then West to match VCC's x-coordinate. - rpather.at('GND').trace(False, length=5_000).trace_to(None, x=rpather['VCC'].x) - - - # - # Tool management and manual plugging - # - # We can use .retool() to change the tool for specific ports. - # We can also use .plug() directly on a PortPather. - - # Manually add a via to GND and switch to M1_tool for subsequent segments - (rpather.at('GND') - .plug('v1_via', 'top') - .retool(M1_tool) # this only retools the 'GND' port - ) - - # We can also pass multiple ports to .at(), and then route them together. - # Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via). - (rpather.at(['GND', 'VCC']) - .trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South - .retool(M1_tool) # Retools both GND and VCC - .set_spacing(1_200) # Default bundle spacing for later bends - .trace(True, emax=50_000) # Turn East, moves 50um extension - .trace(False, emin=1_000) # U-turn back South - .trace(False, emin=2_000, spacing=4_500) # U-turn back West, overriding the default spacing - ) - - # Retool VCC back to M2 and move both to x=-28k - rpather.at('VCC').retool(M2_tool) - rpather.at(['GND', 'VCC']).trace(None, xmin=-28_000) - - # Final segments to -50k - rpather.at('VCC').trace_to(None, x=-50_000, out_ptype='m1wire') - with rpather.at('GND').toolctx(M2_tool): - rpather.at('GND').trace_to(None, x=-40_000) - rpather.at('GND').trace_to(None, x=-50_000) - - - # - # Branching with mark and fork - # - # .mark(new_name) creates a port copy and keeps the original selected. - # .fork(new_name) creates a port copy and selects the new one. - - # Create a tap on GND - (rpather.at('GND') - .trace(None, length=5_000) # Move GND further West - .mark('GND_TAP') # Mark this location for a later branch - .jog(offset=-10_000, length=10_000) # Continue GND with an S-bend - ) - - # Branch VCC and follow the new branch - (rpather.at('VCC') - .trace(None, length=5_000) - .fork('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH' - .trace(True, length=5_000) # VCC_BRANCH turns South - ) - # The original 'VCC' port remains at x=-55k, y=VCC.y - - - # - # Port set management: add, drop, rename, delete - # - - # Route the GND_TAP we saved earlier. - (rpather.at('GND_TAP') - .retool(M1_tool) - .trace(True, length=10_000) # Turn South - .rename('GND_FEED') # Give it a more descriptive name - .retool(M1_tool) # Re-apply tool to the new name - ) - - # We can manage the active set of ports in a PortPather - pp = rpather.at(['VCC_BRANCH', 'GND_FEED']) - pp.select('GND') # Now tracking 3 ports - pp.deselect('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND - pp.trace(None, each=5_000) # Move both 5um forward (length > transition size) - - # We can also delete ports from the pather entirely - rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead) - - - # - # Advanced Connections: trace_into - # - # trace_into routes FROM the selected port TO a target port. - - # Create a destination component - dest_ports = { - 'in_A': Port((0, 0), rotation=R90, ptype='m2wire'), - 'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire') - } - library['dest'] = Pattern(ports=dest_ports) - # Place dest so that its ports are to the West and South of our current wires. - # Rotating by pi/2 makes the ports face West (pointing East). - rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'}) - - # Connect GND_FEED to DEST_A - # Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice. - rpather.at('GND_FEED').trace_into('DEST_A') - - # Connect VCC_BRANCH to DEST_B - rpather.at('VCC_BRANCH').trace_into('DEST_B') - - - # - # Direct Port Transformations and Metadata - # - (rpather.at('GND') - .set_ptype('m1wire') # Change metadata - .translate((1000, 0)) # Shift the port 1um East - .rotate(R90 / 2) # Rotate it 45 degrees - .set_rotation(R90) # Force it to face West - ) - - # Demonstrate .plugged() to acknowledge a manual connection - # (Normally used when you place components so their ports perfectly overlap) - rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2')) - rpather.at('TMP1').plugged('TMP2') # Removes both ports - - - # - # Rendering and Saving - # - # Since routing is deferred, we must call .render() to generate the geometry. - rpather.render() - - library['PortPather_Tutorial'] = rpather.pattern - library.map_layers(map_layer) - writefile(library, 'port_pather.gds', **GDS_OPTS) - print("Tutorial complete. Output written to port_pather.gds") - - -if __name__ == '__main__': - main() diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py deleted file mode 100644 index d048d65..0000000 --- a/examples/tutorial/renderpather.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Manual wire routing tutorial: deferred Pather and PathTool -""" -from masque import Pather, Library -from masque.builder import PathTool -from masque.file.gdsii import writefile - -from basic_shapes import GDS_OPTS -from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via - - -def main() -> None: - # - # To illustrate deferred routing with `Pather`, we use `PathTool` instead - # of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) - # but when used with `Pather(render='deferred')`, it can consolidate multiple routing steps into - # a single `Path` shape. - # - # We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py` - # for more detailed descriptions of the individual pathing steps. - # - - # First, we make a library and generate some of the same patterns as in the pather tutorial - library = Library() - library['pad'] = make_pad() - library['v1_via'] = make_via( - layer_top = 'M2', - layer_via = 'V1', - layer_bot = 'M1', - width_top = M2_WIDTH, - width_via = V1_WIDTH, - width_bot = M1_WIDTH, - ptype_bot = 'm1wire', - ptype_top = 'm2wire', - ) - - # `PathTool` is more limited than `AutoTool`. It only generates one type of shape - # (`Path`), so it only needs to know what layer to draw on, what width to draw with, - # and what port type to present. - M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire') - M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire') - rpather = Pather(tools=M2_ptool, library=library, render='deferred') - - # As in the pather tutorial, we make some pads and labels... - rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) - rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) - rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) - rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) - - # ...and start routing the signals. - rpather.cw('VCC', 6_000) - rpather.straight('VCC', x=0) - rpather.cw('GND', 5_000) - rpather.straight('GND', x=rpather.pattern['VCC'].x) - - # `PathTool` doesn't know how to transition betwen metal layers, so we have to - # `plug` the via into the GND wire ourselves. - rpather.plug('v1_via', {'GND': 'top'}) - rpather.retool(M1_ptool, keys='GND') - rpather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) - - # Same thing on the VCC wire when it goes down to M1. - rpather.plug('v1_via', {'VCC': 'top'}) - rpather.retool(M1_ptool) - rpather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200) - rpather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200) - rpather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500) - - # And again when VCC goes back up to M2. - rpather.plug('v1_via', {'VCC': 'bottom'}) - rpather.retool(M2_ptool) - rpather.straight(['GND', 'VCC'], xmin=-28_000) - - # Finally, since PathTool has no conception of transitions, we can't - # just ask it to transition to an 'm1wire' port at the end of the final VCC segment. - # Instead, we have to calculate the via size ourselves, and adjust the final position - # to account for it. - v1pat = library['v1_via'] - via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x) - - # alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0] - # would take into account the port orientations if we didn't already know they're along x - rpather.straight('VCC', x=-50_000 + via_size) - rpather.plug('v1_via', {'VCC': 'top'}) - - # Render the path we defined - rpather.render() - library['Deferred_Pather_and_PathTool'] = rpather.pattern - - - # Convert from text-based layers to numeric layers for GDS, and output the file - library.map_layers(map_layer) - writefile(library, 'render_pather.gds', **GDS_OPTS) - - -if __name__ == '__main__': - main() diff --git a/masque/__init__.py b/masque/__init__.py index 8c9529b..7881bdb 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -1,16 +1,16 @@ """ masque 2D CAD library - masque is an attempt to make a relatively compact library for designing lithography + masque is an attempt to make a relatively small library for designing lithography masks. The general idea is to implement something resembling the GDSII and OASIS file-formats, - but with some additional vectorized element types (eg. ellipses, not just polygons), and the - ability to interface with multiple file formats. + but with some additional vectorized element types (eg. ellipses, not just polygons), better + support for E-beam doses, and the ability to interface with multiple file formats. `Pattern` is a basic object containing a 2D lithography mask, composed of a list of `Shape` objects, a list of `Label` objects, and a list of references to other `Patterns` (using - `Ref`). + `SubPattern`). - `Ref` provides basic support for nesting `Pattern` objects within each other, by adding + `SubPattern` provides basic support for nesting `Pattern` objects within each other, by adding offset, rotation, scaling, repetition, and other such properties to a Pattern reference. Note that the methods for these classes try to avoid copying wherever possible, so unless @@ -20,91 +20,24 @@ NOTES ON INTERNALS ========================== - Many of `masque`'s classes make use of `__slots__` to make them faster / smaller. - Since `__slots__` doesn't play well with multiple inheritance, often they are left - empty for superclasses and it is the subclass's responsibility to set them correctly. - - File I/O submodules are not imported by `masque.file` to avoid creating hard dependencies - on external file-format reader/writers -- Try to accept the broadest-possible inputs: e.g., don't demand an `ILibraryView` if you - can accept a `Mapping[str, Pattern]` and wrap it in a `LibraryView` internally. + Since `__slots__` doesn't play well with multiple inheritance, the `masque.utils.AutoSlots` + metaclass is used to auto-generate slots based on superclass type annotations. + - File I/O submodules are imported by `masque.file` to avoid creating hard dependencies on + external file-format reader/writers + - Pattern locking/unlocking is quite slow for large hierarchies. + """ -from .utils import ( - layer_t as layer_t, - annotations_t as annotations_t, - SupportsBool as SupportsBool, - ) -from .error import ( - MasqueError as MasqueError, - PatternError as PatternError, - LibraryError as LibraryError, - BuildError as BuildError, - ) -from .shapes import ( - Shape as Shape, - Polygon as Polygon, - RectCollection as RectCollection, - Path as Path, - Circle as Circle, - Arc as Arc, - Ellipse as Ellipse, - ) -from .label import Label as Label -from .ref import Ref as Ref -from .pattern import ( - Pattern as Pattern, - map_layers as map_layers, - map_targets as map_targets, - chain_elements as chain_elements, - ) -from .utils.boolean import boolean as boolean - -from .library import ( - INameView as INameView, - ILibraryView as ILibraryView, - ILibrary as ILibrary, - IBorrowing as IBorrowing, - IMaterializable as IMaterializable, - LibraryView as LibraryView, - Library as Library, - OverlayLibrary as OverlayLibrary, - PortLoadView as PortLoadView, - LayerMappedView as LayerMappedView, - LibraryBuilder as LibraryBuilder, - BuildReport as BuildReport, - CellProvenance as CellProvenance, - LazyLibrary as LazyLibrary, - AbstractView as AbstractView, - TreeView as TreeView, - Tree as Tree, - cell as cell, - ) -from .ports import ( - Port as Port, - PortList as PortList, - ) -from .abstract import Abstract as Abstract -from .builder import ( - Tool as Tool, - ToolContractError as ToolContractError, - Pather as Pather, - RouteError as RouteError, - RouteFailureDetails as RouteFailureDetails, - RouteFailurePolicy as RouteFailurePolicy, - MinimumStatus as MinimumStatus, - RenderStep as RenderStep, - AutoTool as AutoTool, - PathTool as PathTool, - PortPather as PortPather, - ) -from .utils import ( - ports2data as ports2data, - oneshot as oneshot, - R90 as R90, - R180 as R180, - ) +from .error import PatternError, PatternLockedError +from .shapes import Shape +from .label import Label +from .subpattern import SubPattern +from .pattern import Pattern +from .utils import layer_t, annotations_t +from .library import Library, DeviceLibrary __author__ = 'Jan Petykiewicz' -__version__ = '4.0a2' +__version__ = '2.7' version = __version__ # legacy diff --git a/masque/abstract.py b/masque/abstract.py deleted file mode 100644 index d23d7c7..0000000 --- a/masque/abstract.py +++ /dev/null @@ -1,185 +0,0 @@ -from typing import Self -import copy -import logging - -import numpy -from numpy.typing import ArrayLike - -from .ref import Ref -from .ports import PortList, Port -from .utils import rotation_matrix_2d -from .traits import Mirrorable - - -logger = logging.getLogger(__name__) - - -class Abstract(PortList, Mirrorable): - """ - An `Abstract` is a container for a name and associated ports. - - When snapping a sub-component to an existing pattern, only the name (not contained - in a `Pattern` object) and port info is needed, and not the geometry itself. - """ - # Alternate design option: do we want to store a Ref instead of just a name? then we can translate/rotate/mirror... - __slots__ = ('name', '_ports') - - name: str - """ Name of the pattern this device references """ - - _ports: dict[str, Port] - """ Uniquely-named ports which can be used to instances together""" - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, value: dict[str, Port]) -> None: - self._ports = value - - def __init__( - self, - name: str, - ports: dict[str, Port], - ) -> None: - self.name = name - self.ports = copy.deepcopy(ports) - - def __repr__(self) -> str: - s = f' Self: - """ - Translates all ports by the given offset. - - Args: - offset: (x, y) to translate by - - Returns: - self - """ - for port in self.ports.values(): - port.translate(offset) - return self - - def scale_by(self, c: float) -> Self: - """ - Scale this Abstract by the given value - (all port offsets are scaled) - - Args: - c: factor to scale by - - Returns: - self - """ - for port in self.ports.values(): - port.offset *= c - return self - - def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: - """ - Rotate the Abstract around a pivot point. - - Args: - pivot: (x, y) location to rotate around - rotation: Angle to rotate by (counter-clockwise, radians) - - Returns: - self - """ - pivot = numpy.asarray(pivot, dtype=float) - self.translate_ports(-pivot) - self.rotate_ports(rotation) - self.rotate_port_offsets(rotation) - self.translate_ports(+pivot) - return self - - def rotate_port_offsets(self, rotation: float) -> Self: - """ - Rotate the offsets of all ports around (0, 0) - - Args: - rotation: Angle to rotate by (counter-clockwise, radians) - - Returns: - self - """ - for port in self.ports.values(): - port.offset = rotation_matrix_2d(rotation) @ port.offset - return self - - def rotate_ports(self, rotation: float) -> Self: - """ - Rotate each port around its offset (i.e. in place) - - Args: - rotation: Angle to rotate by (counter-clockwise, radians) - - Returns: - self - """ - for port in self.ports.values(): - port.rotate(rotation) - return self - - def mirror(self, axis: int = 0) -> Self: - """ - Mirror the Abstract across an axis through its origin. - - Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). - - Returns: - self - """ - for port in self.ports.values(): - port.flip_across(axis=axis) - return self - - def apply_ref_transform(self, ref: Ref) -> Self: - """ - Apply the transform from a `Ref` to the ports of this `Abstract`. - This changes the port locations to where they would be in the Ref's parent pattern. - - Args: - ref: The ref whose transform should be applied. - - Returns: - self - """ - if ref.mirrored: - self.mirror() - self.rotate_ports(ref.rotation) - self.rotate_port_offsets(ref.rotation) - if ref.scale != 1: - self.scale_by(ref.scale) - self.translate_ports(ref.offset) - return self - - def undo_ref_transform(self, ref: Ref) -> Self: - """ - Apply the inverse transform from a `Ref` to the ports of this `Abstract`. - This changes the port locations to where they would be in the Ref's target (from the parent). - - Args: - ref: The ref whose (inverse) transform should be applied. - - Returns: - self - - # TODO test undo_ref_transform - """ - self.translate_ports(-ref.offset) - if ref.scale != 1: - self.scale_by(1 / ref.scale) - self.rotate_port_offsets(-ref.rotation) - self.rotate_ports(-ref.rotation) - if ref.mirrored: - self.mirror(0) - return self diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index cc0b2aa..0c083b7 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,90 +1,3 @@ -""" -Builder helpers for port-based assembly and primitive-offer routing. - -A routing `Tool` describes the primitive route families it can provide by -returning `PrimitiveOffer` objects. Each offer is a parameterized planning -candidate: it exposes legal parameter domains, endpoint behavior, ptypes, cost, -optional footprint metadata, and a commit hook for producing tool-specific -render data after a concrete parameter has been selected. - -`Pather` owns user-facing route operations such as `trace()`, `jog()`, -`uturn()`, and `trace_into()`. The internal planner resolves each operation into -one or more `SolverRequest`s. This normalization is why the public routing API -can remain a convenient keyword-based interface without making the solver -stringly typed internally. A pure solver search selects a `Candidate`, and a -`RouteLeg` attaches that candidate to its copied source port and Tool. Only -after selection succeeds are the chosen offers materialized through -`offer.commit(parameter)` into a `PreparedRouteResult` containing -`RenderStep.data`. `Pather` then applies that prepared result to its live ports -and pending render queue. - -Selection is pure with respect to caller-owned Pattern, Library, and Pather -state. Tool offer discovery and endpoint/cost/bbox callbacks must likewise be -deterministic and must not mutate that state. `commit()` is the first -selected-offer materialization hook, but it still must not mutate the live -layout. `Tool.render()` is the geometry mutation boundary: later, -`Pather.render()` batches compatible `RenderStep`s and inserts the resulting -geometry into the Pattern and Library. - -`PrimitiveOffer` and `RenderStep.data` are the tool-facing contract. -`RenderStep` is `Pather`'s deferred-render record, and -`masque.builder.planner` is an internal planner implementation rather than a -stable public API. - -Custom Tool authors should run `validate_tool_contract()` as a development or -application-startup preflight. It performs the comprehensive semantic checks -that are intentionally not repeated during route selection, keeping the normal -routing path focused on search rather than contract verification. - -The practical layering is: -- user code drives `Pather` and chooses Tools per port or by default, -- Tools describe local legal motion primitives without touching Pather state, -- the internal router composes those primitives into high-level route shapes, -- Pather applies the prepared result to ports, deferred render queues, and the - target pattern/library. - -`Pather` intentionally remains a Pattern-oriented facade rather than exposing -separate assembly and routing objects: its user model is a working Pattern with -routing tools attached. The ownership phases above are internal boundaries, -not additional objects callers must coordinate. - -Code outside the builder package should prefer the exports here over importing -from `masque.builder.planner`. The planner package is intentionally available -for tests and internal maintenance, but it is not the compatibility boundary -for custom Tools. -""" - -from .pather import ( - Pather as Pather, - PortPather as PortPather, - RouteCompletionCallback as RouteCompletionCallback, -) -from .error import ( - ToolContractError as ToolContractError, - RouteError as RouteError, - RouteFailureDetails as RouteFailureDetails, - RouteOperation as RouteOperation, - RouteFailurePolicy as RouteFailurePolicy, - MinimumStatus as MinimumStatus, -) -from .utils import ell as ell -from .tool_testing import ( - ToolContractCase as ToolContractCase, - validate_tool_contract as validate_tool_contract, -) -from .tools import ( - Tool as Tool, - AutoTool as AutoTool, - PathTool as PathTool, - RenderStep as RenderStep, - RenderStepKind as RenderStepKind, - PrimitiveKind as PrimitiveKind, - CostCallable as CostCallable, - GeneratedEndpointFn as GeneratedEndpointFn, - PrimitiveOffer as PrimitiveOffer, - StraightOffer as StraightOffer, - BendOffer as BendOffer, - SOffer as SOffer, - UOffer as UOffer, - circular_arc_sbend_endpoint as circular_arc_sbend_endpoint, -) +from .devices import Port, Device +from .utils import ell +from .tools import Tool diff --git a/masque/builder/_tolerances.py b/masque/builder/_tolerances.py deleted file mode 100644 index 5f179e3..0000000 --- a/masque/builder/_tolerances.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Shared numeric tolerances for builder geometry and parameter comparisons.""" -from math import isclose, remainder, tau -from typing import Any - -import numpy - - -GEOMETRY_RTOL = 1e-5 -GEOMETRY_ATOL = 1e-8 -DOMAIN_RTOL = 1e-9 -DOMAIN_ATOL = 1e-12 -MANHATTAN_ANGLE_RTOL = 1e-9 -MANHATTAN_ANGLE_ATOL = 1e-9 - - -def scalar_close(a: float, b: float) -> bool: - """Match the solver's existing scalar-comparison behavior.""" - return isclose(float(a), float(b), rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL) - - -def array_close(a: Any, b: Any) -> bool: - """Match NumPy's historical builder geometry-comparison behavior.""" - return bool(numpy.allclose(a, b, rtol=GEOMETRY_RTOL, atol=GEOMETRY_ATOL)) - - -def angles_equal(a: float, b: float) -> bool: - """Return true when two rotations are equal modulo one full turn.""" - delta = remainder(float(a) - float(b), tau) - return isclose(delta, 0.0, rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL) - - -def manhattan_axis(rotation: float) -> int | None: - """Return 0 for horizontal, 1 for vertical, or None for a non-cardinal angle.""" - angle = float(rotation) % (numpy.pi / 2) - if isclose( - angle, - 0.0, - rel_tol=MANHATTAN_ANGLE_RTOL, - abs_tol=MANHATTAN_ANGLE_ATOL, - ) or isclose( - angle, - numpy.pi / 2, - rel_tol=MANHATTAN_ANGLE_RTOL, - abs_tol=MANHATTAN_ANGLE_ATOL, - ): - quarter_turn = round(float(rotation) / (numpy.pi / 2)) - return quarter_turn % 2 - return None diff --git a/masque/builder/devices.py b/masque/builder/devices.py new file mode 100644 index 0000000..b681f33 --- /dev/null +++ b/masque/builder/devices.py @@ -0,0 +1,892 @@ +from typing import Dict, Iterable, List, Tuple, Union, TypeVar, Any, Iterator, Optional, Sequence +from typing import overload, KeysView, ValuesView +import copy +import warnings +import traceback +import logging +from collections import Counter + +import numpy +from numpy import pi +from numpy.typing import ArrayLike, NDArray + +from ..pattern import Pattern +from ..subpattern import SubPattern +from ..traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable +from ..utils import AutoSlots, rotation_matrix_2d +from ..error import DeviceError +from .tools import Tool +from .utils import ell + + +logger = logging.getLogger(__name__) + + +P = TypeVar('P', bound='Port') +D = TypeVar('D', bound='Device') +O = TypeVar('O', bound='Device') + + +class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, metaclass=AutoSlots): + """ + A point at which a `Device` can be snapped to another `Device`. + + Each port has an `offset` ((x, y) position) and may also have a + `rotation` (orientation) and a `ptype` (port type). + + The `rotation` is an angle, in radians, measured counterclockwise + from the +x axis, pointing inwards into the device which owns the port. + The rotation may be set to `None`, indicating that any orientation is + allowed (e.g. for a DC electrical port). It is stored modulo 2pi. + + The `ptype` is an arbitrary string, default of `unk` (unknown). + """ + __slots__ = ('ptype', '_rotation') + + _rotation: Optional[float] + """ radians counterclockwise from +x, pointing into device body. + Can be `None` to signify undirected port """ + + ptype: str + """ Port types must match to be plugged together if both are non-zero """ + + def __init__( + self, + offset: ArrayLike, + rotation: Optional[float], + ptype: str = 'unk', + ) -> None: + self.offset = offset + self.rotation = rotation + self.ptype = ptype + + @property + def rotation(self) -> Optional[float]: + """ Rotation, radians counterclockwise, pointing into device body. Can be None. """ + return self._rotation + + @rotation.setter + def rotation(self, val: float) -> None: + if val is None: + self._rotation = None + else: + if not numpy.size(val) == 1: + raise DeviceError('Rotation must be a scalar') + self._rotation = val % (2 * pi) + + def get_bounds(self): + return numpy.vstack((self.offset, self.offset)) + + def set_ptype(self: P, ptype: str) -> P: + """ Chainable setter for `ptype` """ + self.ptype = ptype + return self + + def mirror(self: P, axis: int) -> P: + self.offset[1 - axis] *= -1 + if self.rotation is not None: + self.rotation *= -1 + self.rotation += axis * pi + return self + + def rotate(self: P, rotation: float) -> P: + if self.rotation is not None: + self.rotation += rotation + return self + + def set_rotation(self: P, rotation: Optional[float]) -> P: + self.rotation = rotation + return self + + def __repr__(self) -> str: + if self.rotation is None: + rot = 'any' + else: + rot = str(numpy.rad2deg(self.rotation)) + return f'<{self.offset}, {rot}, [{self.ptype}]>' + + +class Device(Copyable, Mirrorable): + """ + A `Device` is a combination of a `Pattern` with a set of named `Port`s + which can be used to "snap" devices together to make complex layouts. + + `Device`s can be as simple as one or two ports (e.g. an electrical pad + or wire), but can also be used to build and represent a large routed + layout (e.g. a logical block with multiple I/O connections or even a + full chip). + + For convenience, ports can be read out using square brackets: + - `device['A'] == Port((0, 0), 0)` + - `device[['A', 'B']] == {'A': Port((0, 0), 0), 'B': Port((0, 0), pi)}` + + Examples: Creating a Device + =========================== + - `Device(pattern, ports={'A': port_a, 'C': port_c})` uses an existing + pattern and defines some ports. + + - `Device(name='my_dev_name', ports=None)` makes a new empty pattern with + default ports ('A' and 'B', in opposite directions, at (0, 0)). + + - `my_device.build('my_layout')` makes a new pattern and instantiates + `my_device` in it with offset (0, 0) as a base for further building. + + - `my_device.as_interface('my_component', port_map=['A', 'B'])` makes a new + (empty) pattern, copies over ports 'A' and 'B' from `my_device`, and + creates additional ports 'in_A' and 'in_B' facing in the opposite + directions. This can be used to build a device which can plug into + `my_device` (using the 'in_*' ports) but which does not itself include + `my_device` as a subcomponent. + + Examples: Adding to a Device + ============================ + - `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` + instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B' + of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports + are removed and any unconnected ports from `subdevice` are added to + `my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'. + + - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' + of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, + argument is provided, and the `inherit_name` argument is not explicitly + set to `False`, the unconnected port of `wire` is automatically renamed to + 'myport'. This allows easy extension of existing ports without changing + their names or having to provide `map_out` each time `plug` is called. + + - `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` + instantiates `pad` at the specified (x, y) offset and with the specified + rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is + renamed to 'gnd' so that further routing can use this signal or net name + rather than the port name on the original `pad` device. + """ + __slots__ = ('pattern', 'ports', 'tools', '_dead') + + pattern: Pattern + """ Layout of this device """ + + ports: Dict[str, Port] + """ Uniquely-named ports which can be used to snap to other Device instances""" + + tools: Dict[Optional[str], Tool] + """ + Tool objects are used to dynamically generate new single-use Devices + (e.g wires or waveguides) to be plugged into this device. + """ + + _dead: bool + """ If True, plug()/place() are skipped (for debugging)""" + + def __init__( + self, + pattern: Optional[Pattern] = None, + ports: Optional[Dict[str, Port]] = None, + *, + tools: Union[None, Tool, Dict[Optional[str], Tool]] = None, + name: Optional[str] = None, + ) -> None: + """ + If `ports` is `None`, two default ports ('A' and 'B') are created. + Both are placed at (0, 0) and have default `ptype`, but 'A' has rotation 0 + (attached devices will be placed to the left) and 'B' has rotation + pi (attached devices will be placed to the right). + """ + if pattern is not None: + if name is not None: + raise DeviceError('Only one of `pattern` and `name` may be specified') + self.pattern = pattern + else: + if name is None: + raise DeviceError('Must specify either `pattern` or `name`') + self.pattern = Pattern(name=name) + + if ports is None: + self.ports = { + 'A': Port([0, 0], rotation=0), + 'B': Port([0, 0], rotation=pi), + } + else: + self.ports = copy.deepcopy(ports) + + if tools is None: + self.tools = {} + elif isinstance(tools, Tool): + self.tools = {None: tools} + else: + self.tools = tools + + self._dead = False + + @overload + def __getitem__(self, key: str) -> Port: + pass + + @overload + def __getitem__(self, key: Union[List[str], Tuple[str, ...], KeysView[str], ValuesView[str]]) -> Dict[str, Port]: + pass + + def __getitem__(self, key: Union[str, Iterable[str]]) -> Union[Port, Dict[str, Port]]: + """ + For convenience, ports can be read out using square brackets: + - `device['A'] == Port((0, 0), 0)` + - `device[['A', 'B']] == {'A': Port((0, 0), 0), + 'B': Port((0, 0), pi)}` + """ + if isinstance(key, str): + return self.ports[key] + else: + return {k: self.ports[k] for k in key} + + def rename_ports( + self: D, + mapping: Dict[str, Optional[str]], + overwrite: bool = False, + ) -> D: + """ + Renames ports as specified by `mapping`. + Ports can be explicitly deleted by mapping them to `None`. + + Args: + mapping: Dict of `{'old_name': 'new_name'}` pairs. Names can be mapped + to `None` to perform an explicit deletion. `'new_name'` can also + overwrite an existing non-renamed port to implicitly delete it if + `overwrite` is set to `True`. + overwrite: Allows implicit deletion of ports if set to `True`; see `mapping`. + + Returns: + self + """ + if not overwrite: + duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values()) + if duplicates: + raise DeviceError(f'Unrenamed ports would be overwritten: {duplicates}') + + renamed = {mapping[k]: self.ports.pop(k) for k in mapping.keys()} + if None in renamed: + del renamed[None] + + self.ports.update(renamed) # type: ignore + return self + + def check_ports( + self: D, + other_names: Iterable[str], + map_in: Optional[Dict[str, str]] = None, + map_out: Optional[Dict[str, Optional[str]]] = None, + ) -> D: + """ + Given the provided port mappings, check that: + - All of the ports specified in the mappings exist + - There are no duplicate port names after all the mappings are performed + + Args: + other_names: List of port names being considered for inclusion into + `self.ports` (before mapping) + map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying + port connections between the two devices. + map_out: Dict of `{'old_name': 'new_name'}` mappings, specifying + new names for unconnected `other_names` ports. + + Returns: + self + + Raises: + `DeviceError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `DeviceError` if there are any duplicate names after `map_in` and `map_out` + are applied. + """ + if map_in is None: + map_in = {} + + if map_out is None: + map_out = {} + + other = set(other_names) + + missing_inkeys = set(map_in.keys()) - set(self.ports.keys()) + if missing_inkeys: + raise DeviceError(f'`map_in` keys not present in device: {missing_inkeys}') + + missing_invals = set(map_in.values()) - other + if missing_invals: + raise DeviceError(f'`map_in` values not present in other device: {missing_invals}') + + missing_outkeys = set(map_out.keys()) - other + if missing_outkeys: + raise DeviceError(f'`map_out` keys not present in other device: {missing_outkeys}') + + orig_remaining = set(self.ports.keys()) - set(map_in.keys()) + other_remaining = other - set(map_out.keys()) - set(map_in.values()) + mapped_vals = set(map_out.values()) + mapped_vals.discard(None) + + conflicts_final = orig_remaining & (other_remaining | mapped_vals) + if conflicts_final: + raise DeviceError(f'Device ports conflict with existing ports: {conflicts_final}') + + conflicts_partial = other_remaining & mapped_vals + if conflicts_partial: + raise DeviceError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}') + + map_out_counts = Counter(map_out.values()) + map_out_counts[None] = 0 + conflicts_out = {k for k, v in map_out_counts.items() if v > 1} + if conflicts_out: + raise DeviceError(f'Duplicate targets in `map_out`: {conflicts_out}') + + return self + + def build(self, name: str) -> 'Device': + """ + Begin building a new device around an instance of the current device + (rather than modifying the current device). + + Args: + name: A name for the new device + + Returns: + The new `Device` object. + """ + pat = Pattern(name) + pat.addsp(self.pattern) + new = Device(pat, ports=self.ports, tools=self.tools) + return new + + def as_interface( + self, + name: str, + in_prefix: str = 'in_', + out_prefix: str = '', + port_map: Optional[Union[Dict[str, str], Sequence[str]]] = None + ) -> 'Device': + """ + Begin building a new device based on all or some of the ports in the + current device. Do not include the current device; instead use it + to define ports (the "interface") for the new device. + + The ports specified by `port_map` (default: all ports) are copied to + new device, and additional (input) ports are created facing in the + opposite directions. The specified `in_prefix` and `out_prefix` are + prepended to the port names to differentiate them. + + By default, the flipped ports are given an 'in_' prefix and unflipped + ports keep their original names, enabling intuitive construction of + a device that will "plug into" the current device; the 'in_*' ports + are used for plugging the devices together while the original port + names are used for building the new device. + + Another use-case could be to build the new device using the 'in_' + ports, creating a new device which could be used in place of the + current device. + + Args: + name: Name for the new device + in_prefix: Prepended to port names for newly-created ports with + reversed directions compared to the current device. + out_prefix: Prepended to port names for ports which are directly + copied from the current device. + port_map: Specification for ports to copy into the new device: + - If `None`, all ports are copied. + - If a sequence, only the listed ports are copied + - If a mapping, the listed ports (keys) are copied and + renamed (to the values). + + Returns: + The new device, with an empty pattern and 2x as many ports as + listed in port_map. + + Raises: + `DeviceError` if `port_map` contains port names not present in the + current device. + `DeviceError` if applying the prefixes results in duplicate port + names. + """ + if port_map: + if isinstance(port_map, dict): + missing_inkeys = set(port_map.keys()) - set(self.ports.keys()) + orig_ports = {port_map[k]: v for k, v in self.ports.items() if k in port_map} + else: + port_set = set(port_map) + missing_inkeys = port_set - set(self.ports.keys()) + orig_ports = {k: v for k, v in self.ports.items() if k in port_set} + + if missing_inkeys: + raise DeviceError(f'`port_map` keys not present in device: {missing_inkeys}') + else: + orig_ports = self.ports + + ports_in = {f'{in_prefix}{name}': port.deepcopy().rotate(pi) + for name, port in orig_ports.items()} + ports_out = {f'{out_prefix}{name}': port.deepcopy() + for name, port in orig_ports.items()} + + duplicates = set(ports_out.keys()) & set(ports_in.keys()) + if duplicates: + raise DeviceError(f'Duplicate keys after prefixing, try a different prefix: {duplicates}') + + new = Device(name=name, ports={**ports_in, **ports_out}, tools=self.tools) + return new + + def plug( + self: D, + other: O, + map_in: Dict[str, str], + map_out: Optional[Dict[str, Optional[str]]] = None, + *, + mirrored: Tuple[bool, bool] = (False, False), + inherit_name: bool = True, + set_rotation: Optional[bool] = None, + ) -> D: + """ + Instantiate the device `other` into the current device, connecting + the ports specified by `map_in` and renaming the unconnected + ports specified by `map_out`. + + Examples: + ========= + - `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` + instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B' + of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports + are removed and any unconnected ports from `subdevice` are added to + `my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'. + + - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' + of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, + argument is provided, and the `inherit_name` argument is not explicitly + set to `False`, the unconnected port of `wire` is automatically renamed to + 'myport'. This allows easy extension of existing ports without changing + their names or having to provide `map_out` each time `plug` is called. + + Args: + other: A device to instantiate into the current device. + map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying + port connections between the two devices. + map_out: Dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in `other`. + mirrored: Enables mirroring `other` across the x or y axes prior + to connecting any ports. + inherit_name: If `True`, and `map_in` specifies only a single port, + and `map_out` is `None`, and `other` has only two ports total, + then automatically renames the output port of `other` to the + name of the port from `self` that appears in `map_in`. This + makes it easy to extend a device with simple 2-port devices + (e.g. wires) without providing `map_out` each time `plug` is + called. See "Examples" above for more info. Default `True`. + set_rotation: If the necessary rotation cannot be determined from + the ports being connected (i.e. all pairs have at least one + port with `rotation=None`), `set_rotation` must be provided + to indicate how much `other` should be rotated. Otherwise, + `set_rotation` must remain `None`. + + Returns: + self + + Raises: + `DeviceError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `DeviceError` if there are any duplicate names after `map_in` and `map_out` + are applied. + `DeviceError` if the specified port mapping is not achieveable (the ports + do not line up) + """ + if self._dead: + logger.error('Skipping plug() since device is dead') + return self + + if (inherit_name + and not map_out + and len(map_in) == 1 + and len(other.ports) == 2): + out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values()))) + map_out = {out_port_name: next(iter(map_in.keys()))} + + if map_out is None: + map_out = {} + map_out = copy.deepcopy(map_out) + + self.check_ports(other.ports.keys(), map_in, map_out) + translation, rotation, pivot = self.find_transform(other, map_in, mirrored=mirrored, + set_rotation=set_rotation) + + # get rid of plugged ports + for ki, vi in map_in.items(): + del self.ports[ki] + map_out[vi] = None + + self.place(other, offset=translation, rotation=rotation, pivot=pivot, + mirrored=mirrored, port_map=map_out, skip_port_check=True) + return self + + def place( + self: D, + other: O, + *, + offset: ArrayLike = (0, 0), + rotation: float = 0, + pivot: ArrayLike = (0, 0), + mirrored: Tuple[bool, bool] = (False, False), + port_map: Optional[Dict[str, Optional[str]]] = None, + skip_port_check: bool = False, + ) -> D: + """ + Instantiate the device `other` into the current device, adding its + ports to those of the current device (but not connecting any ports). + + Mirroring is applied before rotation; translation (`offset`) is applied last. + + Examples: + ========= + - `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` + instantiates `pad` at the specified (x, y) offset and with the specified + rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is + renamed to 'gnd' so that further routing can use this signal or net name + rather than the port name on the original `pad` device. + + Args: + other: A device to instantiate into the current device. + offset: Offset at which to place `other`. Default (0, 0). + rotation: Rotation applied to `other` before placement. Default 0. + pivot: Rotation is applied around this pivot point (default (0, 0)). + Rotation is applied prior to translation (`offset`). + mirrored: Whether `other` should be mirrored across the x and y axes. + Mirroring is applied before translation and rotation. + port_map: Dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in `other`. New names can be `None`, which will + delete those ports. + skip_port_check: Can be used to skip the internal call to `check_ports`, + in case it has already been performed elsewhere. + + Returns: + self + + Raises: + `DeviceError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `DeviceError` if there are any duplicate names after `map_in` and `map_out` + are applied. + """ + if self._dead: + logger.error('Skipping place() since device is dead') + return self + + if port_map is None: + port_map = {} + + if not skip_port_check: + self.check_ports(other.ports.keys(), map_in=None, map_out=port_map) + + ports = {} + for name, port in other.ports.items(): + new_name = port_map.get(name, name) + if new_name is None: + continue + ports[new_name] = port + + for name, port in ports.items(): + p = port.deepcopy() + p.mirror2d(mirrored) + p.rotate_around(pivot, rotation) + p.translate(offset) + self.ports[name] = p + + sp = SubPattern(other.pattern, mirrored=mirrored) + sp.rotate_around(pivot, rotation) + sp.translate(offset) + self.pattern.subpatterns.append(sp) + return self + + def find_transform( + self: D, + other: O, + map_in: Dict[str, str], + *, + mirrored: Tuple[bool, bool] = (False, False), + set_rotation: Optional[bool] = None, + ) -> Tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: + """ + Given a device `other` and a mapping `map_in` specifying port connections, + find the transform which will correctly align the specified ports. + + Args: + other: a device + map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying + port connections between the two devices. + mirrored: Mirrors `other` across the x or y axes prior to + connecting any ports. + set_rotation: If the necessary rotation cannot be determined from + the ports being connected (i.e. all pairs have at least one + port with `rotation=None`), `set_rotation` must be provided + to indicate how much `other` should be rotated. Otherwise, + `set_rotation` must remain `None`. + + Returns: + - The (x, y) translation (performed last) + - The rotation (radians, counterclockwise) + - The (x, y) pivot point for the rotation + + The rotation should be performed before the translation. + """ + s_ports = self[map_in.keys()] + o_ports = other[map_in.values()] + + s_offsets = numpy.array([p.offset for p in s_ports.values()]) + o_offsets = numpy.array([p.offset for p in o_ports.values()]) + s_types = [p.ptype for p in s_ports.values()] + o_types = [p.ptype for p in o_ports.values()] + + s_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in s_ports.values()]) + o_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in o_ports.values()]) + s_has_rot = numpy.array([p.rotation is not None for p in s_ports.values()], dtype=bool) + o_has_rot = numpy.array([p.rotation is not None for p in o_ports.values()], dtype=bool) + has_rot = s_has_rot & o_has_rot + + if mirrored[0]: + o_offsets[:, 1] *= -1 + o_rotations *= -1 + if mirrored[1]: + o_offsets[:, 0] *= -1 + o_rotations *= -1 + o_rotations += pi + + type_conflicts = numpy.array([st != ot and st != 'unk' and ot != 'unk' + for st, ot in zip(s_types, o_types)]) + if type_conflicts.any(): + ports = numpy.where(type_conflicts) + msg = 'Ports have conflicting types:\n' + for nn, (k, v) in enumerate(map_in.items()): + if type_conflicts[nn]: + msg += f'{k} | {s_types[nn]}:{o_types[nn]} | {v}\n' + msg = ''.join(traceback.format_stack()) + '\n' + msg + warnings.warn(msg, stacklevel=2) + + rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) + if not has_rot.any(): + if set_rotation is None: + DeviceError('Must provide set_rotation if rotation is indeterminate') + rotations[:] = set_rotation + else: + rotations[~has_rot] = rotations[has_rot][0] + + if not numpy.allclose(rotations[:1], rotations): + rot_deg = numpy.rad2deg(rotations) + msg = f'Port orientations do not match:\n' + for nn, (k, v) in enumerate(map_in.items()): + msg += f'{k} | {rot_deg[nn]:g} | {v}\n' + raise DeviceError(msg) + + pivot = o_offsets[0].copy() + rotate_offsets_around(o_offsets, pivot, rotations[0]) + translations = s_offsets - o_offsets + if not numpy.allclose(translations[:1], translations): + msg = f'Port translations do not match:\n' + for nn, (k, v) in enumerate(map_in.items()): + msg += f'{k} | {translations[nn]} | {v}\n' + raise DeviceError(msg) + + return translations[0], rotations[0], o_offsets[0] + + def translate(self: D, offset: ArrayLike) -> D: + """ + Translate the pattern and all ports. + + Args: + offset: (x, y) distance to translate by + + Returns: + self + """ + self.pattern.translate_elements(offset) + for port in self.ports.values(): + port.translate(offset) + return self + + def rotate_around(self: D, pivot: ArrayLike, angle: float) -> D: + """ + Translate the pattern and all ports. + + Args: + offset: (x, y) distance to translate by + + Returns: + self + """ + self.pattern.rotate_around(pivot, angle) + for port in self.ports.values(): + port.rotate_around(pivot, angle) + return self + + def mirror(self: D, axis: int) -> D: + """ + Translate the pattern and all ports across the specified axis. + + Args: + axis: Axis to mirror across (x=0, y=1) + + Returns: + self + """ + self.pattern.mirror(axis) + for p in self.ports.values(): + p.mirror(axis) + return self + + def set_dead(self: D) -> D: + """ + Disallows further changes through `plug()` or `place()`. + This is meant for debugging: + ``` + dev.plug(a, ...) + dev.set_dead() # added for debug purposes + dev.plug(b, ...) # usually raises an error, but now skipped + dev.plug(c, ...) # also skipped + dev.pattern.visualize() # shows the device as of the set_dead() call + ``` + + Returns: + self + """ + self._dead = True + return self + + def rename(self: D, name: str) -> D: + """ + Renames the pattern and returns the device + + Args: + name: The new name + + Returns: + self + """ + self.pattern.name = name + return self + + def __repr__(self) -> str: + s = f' D: + if keys is None or isinstance(keys, str): + self.tools[keys] = tool + else: + for key in keys: + self.tools[key] = tool + return self + + def path( + self: D, + portspec: str, + ccw: Optional[bool], + length: float, + *, + tool_port_names: Sequence[str] = ('A', 'B'), + **kwargs, + ) -> D: + if self._dead: + logger.error('Skipping path() since device is dead') + return self + + tool = self.tools.get(portspec, self.tools[None]) + in_ptype = self.ports[portspec].ptype + dev = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + return self.plug(dev, {portspec: tool_port_names[0]}) + + def path_to( + self: D, + portspec: str, + ccw: Optional[bool], + position: float, + *, + tool_port_names: Sequence[str] = ('A', 'B'), + **kwargs, + ) -> D: + if self._dead: + logger.error('Skipping path_to() since device is dead') + return self + + port = self.ports[portspec] + x, y = port.offset + if port.rotation is None: + raise DeviceError(f'Port {portspec} has no rotation and cannot be used for path_to()') + + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise DeviceError('path_to was asked to route from non-manhattan port') + + is_horizontal = numpy.isclose(port.rotation % pi, 0) + if is_horizontal: + if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x): + raise DeviceError(f'path_to routing to behind source port: x={x:g} to {position:g}') + length = numpy.abs(position - x) + else: + if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y): + raise DeviceError(f'path_to routing to behind source port: y={y:g} to {position:g}') + length = numpy.abs(position - y) + + return self.path(portspec, ccw, length, tool_port_names=tool_port_names, **kwargs) + + def busL( + self: D, + portspec: Union[str, Sequence[str]], + ccw: Optional[bool], + *, + spacing: Optional[Union[float, ArrayLike]] = None, + set_rotation: Optional[float] = None, + tool_port_names: Sequence[str] = ('A', 'B'), + container_name: str = '_busL', + force_container: bool = False, + **kwargs, + ) -> D: + if self._dead: + logger.error('Skipping busL() since device is dead') + return self + + bound_types = set() + if 'bound_type' in kwargs: + bound_types.add(kwargs['bound_type']) + bound = kwargs['bound'] + for bt in ('emin', 'emax', 'pmin', 'pmax', 'min_past_furthest'): + if bt in kwargs: + bound_types.add(bt) + bound = kwargs[bt] + + if not bound_types: + raise DeviceError('No bound type specified for busL') + elif len(bound_types) > 1: + raise DeviceError(f'Too many bound types specified for busL: {bound_types}') + bound_type = tuple(bound_types)[0] + + if isinstance(portspec, str): + portspec = [portspec] + ports = self[tuple(portspec)] + + extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) + + if len(ports) == 1 and not force_container: + # Not a bus, so having a container just adds noise to the layout + port_name = tuple(portspec)[0] + return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names) + else: + dev = Device(name='', ports=ports, tools=self.tools).as_interface(container_name) + for name, length in extensions.items(): + dev.path(name, ccw, length, tool_port_names=tool_port_names) + return self.plug(dev, {sp: 'in_' + sp for sp in ports.keys()}) # TODO safe to use 'in_'? + + # TODO def path_join() and def bus_join()? + + +def rotate_offsets_around( + offsets: NDArray[numpy.float64], + pivot: NDArray[numpy.float64], + angle: float, + ) -> NDArray[numpy.float64]: + offsets -= pivot + offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T + offsets += pivot + return offsets diff --git a/masque/builder/error.py b/masque/builder/error.py deleted file mode 100644 index eb2b205..0000000 --- a/masque/builder/error.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Public routing failure diagnostics.""" -from typing import Any, Literal -from collections.abc import Mapping -from dataclasses import dataclass -from enum import Enum, auto -from pprint import pformat -from types import MappingProxyType -import traceback - -from ..error import BuildError - - -RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn'] - - -class ToolContractError(BuildError): - """A Tool returned data inconsistent with its routing contract.""" - - -class RouteFailurePolicy(Enum): - """Whether route failure may be recovered through alternate/dead planning. - - `RECOVERABLE` means a caller-controlled fallback may try another planning - branch. `FATAL` marks an invalid request or broken planning contract that - must be reported directly. - """ - - RECOVERABLE = auto() - FATAL = auto() - - -class MinimumStatus(Enum): - """Outcome of preferred-minimum-length diagnosis. - - `NOT_EVALUATED` is used when diagnosis is inapplicable, notably for an - invalid resolved length. `FOUND` carries `minimum_length`; `NO_ROUTE` means - exhaustive planning found no legal unconstrained route; `FAILED` means the - secondary diagnostic calculation itself raised a recoverable error. - """ - - NOT_EVALUATED = auto() - FOUND = auto() - NO_ROUTE = auto() - FAILED = auto() - - -@dataclass(frozen=True, slots=True) -class RouteFailureDetails: - """Structured context for a failed Pather routing request.""" - - operation: RouteOperation - portspec: str - in_ptype: str | None - out_ptype: str | None - request: Mapping[str, Any] - resolved_length: float | None - resolved_jog: float | None - minimum_length: float | None - minimum_status: MinimumStatus - cause: str - minimum_cause: str | None = None - - def __post_init__(self) -> None: - if self.minimum_status is MinimumStatus.FOUND and self.minimum_length is None: - raise BuildError('MinimumStatus.FOUND requires minimum_length') - if self.minimum_status is not MinimumStatus.FOUND and self.minimum_length is not None: - raise BuildError(f'{self.minimum_status} requires minimum_length=None') - object.__setattr__(self, 'request', MappingProxyType(dict(self.request))) - - -class RouteError(BuildError): - """A route-selection failure with structured request and saved call-stack diagnostics.""" - - details: RouteFailureDetails - policy: RouteFailurePolicy - _call_stack: tuple[traceback.FrameSummary, ...] - - def __init__( - self, - details: RouteFailureDetails, - *, - policy: RouteFailurePolicy = RouteFailurePolicy.RECOVERABLE, - ) -> None: - self.details = details - self.policy = policy - if details.minimum_status is MinimumStatus.NOT_EVALUATED: - minimum = 'not evaluated' - elif details.minimum_status is MinimumStatus.FOUND: - assert details.minimum_length is not None - minimum = f'{details.minimum_length:g}' - elif details.minimum_status is MinimumStatus.NO_ROUTE: - minimum = 'unavailable (no legal route exists at any length)' - else: - minimum = 'unavailable (minimum-length calculation failed)' - - lines = [ - f'Unable to plan {details.operation} route for port {details.portspec!r}:', - f' in_ptype: {details.in_ptype!r}', - f' out_ptype: {details.out_ptype!r}', - f' request: {pformat(dict(details.request), compact=True)}', - f' resolved_length: {details.resolved_length!r}', - f' resolved_jog: {details.resolved_jog!r}', - f' preferred_minimum_length: {minimum}', - f' cause: {details.cause}', - ] - if details.minimum_cause is not None: - lines.append(f' minimum_failure: {details.minimum_cause}') - self._call_stack = tuple(traceback.extract_stack()[:-1]) - super().__init__('\n'.join(lines)) diff --git a/masque/builder/logging.py b/masque/builder/logging.py deleted file mode 100644 index e7cfb16..0000000 --- a/masque/builder/logging.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Logging helpers for Pather.""" -from typing import TYPE_CHECKING, Any -from collections.abc import Iterator, Sequence -import logging -import numpy -from contextlib import contextmanager - -if TYPE_CHECKING: - from .pather import Pather - - -def _format_log_args(**kwargs) -> str: - arg_strs = [] - for k, v in kwargs.items(): - if isinstance(v, str | int | float | bool | None): - arg_strs.append(f"{k}={v}") - elif isinstance(v, numpy.ndarray): - arg_strs.append(f"{k}={v.tolist()}") - elif isinstance(v, list | tuple) and len(v) <= 10: - arg_strs.append(f"{k}={v}") - else: - arg_strs.append(f"{k}=...") - return ", ".join(arg_strs) - - -class PatherLogger: - """ - Encapsulates state for Pather diagnostic logging. - """ - debug: bool - indent: int - depth: int - - def __init__(self, debug: bool = False) -> None: - self.debug = debug - self.indent = 0 - self.depth = 0 - - def _log(self, module_name: str, msg: str) -> None: - if self.debug and self.depth <= 1: - log_obj = logging.getLogger(module_name) - log_obj.info(' ' * self.indent + msg) - - @contextmanager - def log_operation( - self, - pather: 'Pather', - op: str, - portspec: str | Sequence[str] | None = None, - **kwargs: Any, - ) -> Iterator[None]: - if not self.debug or self.depth > 0: - self.depth += 1 - try: - yield - finally: - self.depth -= 1 - return - - target = f"({portspec})" if portspec else "" - module_name = pather.__class__.__module__ - self._log(module_name, f"Operation: {op}{target} {_format_log_args(**kwargs)}") - - before_ports = {name: port.copy() for name, port in pather.ports.items()} - self.depth += 1 - self.indent += 1 - - try: - yield - finally: - after_ports = pather.ports - for name in sorted(after_ports.keys()): - if name not in before_ports or after_ports[name] != before_ports[name]: - self._log(module_name, f"Port {name}: {pather.ports[name].describe()}") - for name in sorted(before_ports.keys()): - if name not in after_ports: - self._log(module_name, f"Port {name}: removed") - - self.indent -= 1 - self.depth -= 1 diff --git a/masque/builder/pather.py b/masque/builder/pather.py deleted file mode 100644 index c9bd1d4..0000000 --- a/masque/builder/pather.py +++ /dev/null @@ -1,1800 +0,0 @@ -""" -Unified Pattern assembly and routing (`Pather`). - -`Pather` is the public object that owns layout state. It holds the working -Pattern, Library, per-port Tool assignments, pending `RenderStep`s, and routing -side effects such as plug consumption, port renames, and automatic rendering. The -planner package is intentionally internal: custom route generators should -extend `Tool.primitive_offers()` and `Tool.render()` rather than depending on -planner classes or search details. - -Public routing arguments are explicit. Planner-specific per-route settings -belong in `plan_options`, while custom Tool offer values belong in -`tool_options`. Pather keeps those namespaces separate and forwards Tool -options only to offer discovery. A Tool must capture any selected render-time -value in its offer's committed data. - -Routing is split into four ownership phases: -- snapshot: `Pather` resolves the active Tool for each requested port and - passes copied ports to the planner as `RoutePortContext` values, -- selection/preparation: the planner validates the route mode, selects a legal - primitive-offer composition, commits only the chosen offers into opaque - `RenderStep.data`, and returns prepared actions, -- application: `Pather` appends the prepared steps to its pending queue, - replaces live output ports, consumes plug destinations, applies deferred - trace-thru renames, and batches immediate rendering around the whole route, -- rendering: pending steps are grouped by live port, Tool, and continuity before - `Tool.render()` builds geometry for each compatible batch. - -This split keeps selection failures largely transactional for live Pather -state: unsupported primitive combinations can fail before the Pattern, pending -step queue, or Library are touched. Once prepared actions are applied, plug, -rename, render, or insertion failures may leave partial output; that mutation -boundary belongs to Pather, not to Tool implementations or the route solver. - -Routing policy follows port names. Port-specific Tool assignments and -`PortPather` selections remain attached to their names rather than following a -physical port through arbitrary renames. Pending `RenderStep`s are different: -they snapshot their geometric endpoints when planned. The `_paths` keys are -rendering buckets used for ordering and batching those historical steps, not -identities that can retarget their saved geometry when a name is deleted or -reused. - -While pending steps exist, mutate ports through Pather's methods. Directly -editing `pather.pattern.ports` bypasses the bookkeeping that maintains render -buckets and is unsupported. - -Rendering is not transactional across Pattern and Library mutations. A render -exception is terminal for that Pather: callers may catch it for reporting or -cleanup, but must not retry rendering or continue routing with the same object. -""" -from typing import Self, Any, Literal, Protocol, overload -from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence -import copy -import logging -from collections import defaultdict -from functools import wraps -from pprint import pformat -from contextlib import contextmanager -from itertools import chain -from types import TracebackType -from types import MappingProxyType - -import numpy -from numpy import pi -from numpy.typing import ArrayLike - -from ..pattern import Pattern -from ..library import ILibrary, TreeView, SINGLE_USE_PREFIX -from ..error import BuildError, PortError -from ..ports import PortList, Port -from ..abstract import Abstract -from ..utils import SupportsBool, ptypes_compatible -from .tools import ( - Tool, - RenderStep, - ) -from .planner.interface import ( - PreparedRouteResult, - RoutePortContext, - route_failure_policy, - ) -from .error import RouteError, RouteFailurePolicy, ToolContractError -from .planner import RoutingPlanner -from .planner.bounds import resolved_position_bound -from .logging import PatherLogger -from ._tolerances import angles_equal, array_close - - -logger = logging.getLogger(__name__) -RenderPolicy = Literal['auto', 'immediate', 'deferred', 'warn', 'error', 'ignore'] -RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'warn', 'error', 'ignore') -RESERVED_TOOL_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw')) - - -class RouteCompletionCallback(Protocol): - """Callback invoked after one routing operation updates live Pather state.""" - - def __call__( - self, - pather: 'Pather', - endpoints: Mapping[str, Port], - /, - ) -> None: - """Handle copied final endpoints keyed by their original routed names.""" - ... - - -def _present_route_args(**kwargs: Any) -> dict[str, Any]: - """Return explicitly supplied route arguments for planner validation.""" - return {key: value for key, value in kwargs.items() if value is not None} - - -def _validated_tool_options(tool_options: Mapping[str, Any] | None) -> dict[str, Any]: - """Copy and validate custom planning options before any Tool is queried.""" - if tool_options is None: - return {} - try: - options = dict(tool_options) - except (TypeError, ValueError) as err: - raise BuildError('tool_options must be a mapping with string keys') from err - nonstring = [key for key in options if not isinstance(key, str)] - if nonstring: - raise BuildError(f'tool_options keys must be strings; got {nonstring!r}') - collisions = sorted(RESERVED_TOOL_OPTION_KEYS & options.keys()) - if collisions: - raise BuildError(f'tool_options cannot override Tool arguments: {", ".join(collisions)}') - return options - - -def _validated_plan_options(plan_options: Mapping[str, Any] | None) -> dict[str, Any]: - """Copy generic per-route planner options without interpreting their keys.""" - if plan_options is None: - return {} - try: - options = dict(plan_options) - except (TypeError, ValueError) as err: - raise BuildError('plan_options must be a mapping with string keys') from err - nonstring = [key for key in options if not isinstance(key, str)] - if nonstring: - raise BuildError(f'plan_options keys must be strings; got {nonstring!r}') - return options - - -class Pather(PortList): - """ - A `Pather` is a helper object used for snapping together multiple - lower-level patterns at their `Port`s, and for routing single-use - patterns (e.g. wires or waveguides) between them. - - The `Pather` holds context in the form of a `Library`, its underlying - pattern, and a set of `Tool`s for generating routing segments. - - Routing operations (`trace`, `jog`, `uturn`, etc.) select primitive offers - from the active `Tool` and compose them into `RenderStep`s. By default, - geometry is rendered after each route, unless the `Pather` is used as a - context manager. - - Examples: Creating a Pather - =========================== - - `Pather(library, tools=my_tool)` makes an empty pattern with no ports. - The default routing tool for all ports is set to `my_tool`. - - - `Pather(library, name='mypat')` makes an empty pattern and adds it to - `library` under the name `'mypat'`. - - Examples: Adding to a pattern - ============================= - - `pather.plug(subdevice, {'A': 'C'})` instantiates `subdevice` and - connects port 'A' of the current pattern to port 'C' of `subdevice`. - - - `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend - starting at 'my_port'. If the `Pather` is used as a context manager, - geometry is generated on clean context exit. - - Examples: Route completion - ========================== - Route completion callbacks can add labels or other endpoint annotations - without wrapping the individual routing methods:: - - def label_route_endpoints(pather, endpoints): - for name, port in endpoints.items(): - pather.label('PORT_LABELS', string=name, offset=port.offset) - - pather = Pather( - library, - tools=my_tool, - on_route_complete=label_route_endpoints, - ) - """ - __slots__ = ( - 'pattern', 'library', 'tools', 'planner', '_paths', - 'on_route_complete', '_dead', '_logger', '_render_policy', '_render_append', '_context_depth' - ) - - pattern: Pattern - """ Layout of this device """ - - library: ILibrary - """ Library from which patterns should be referenced """ - - tools: dict[str | None, Tool] - """ - Tool objects used to dynamically generate new routing segments. - A key of `None` indicates the default `Tool`. - - Non-`None` keys are policies attached to port names. Renaming a port does - not transfer its Tool assignment to the new name. - """ - - planner: RoutingPlanner - """ - Stateless route-selection facade. - - Per-solve mutable state belongs in routing search/catalog objects rather - than on the planner instance. - """ - - on_route_complete: RouteCompletionCallback | None - """Optional callback run once after each routing operation updates live state.""" - - _dead: bool - """ If True, geometry generation is skipped (for debugging) """ - - _logger: PatherLogger - """ Handles diagnostic logging of operations """ - - _render_policy: RenderPolicy - """ Routing render behavior """ - - _render_append: bool - """ If True, automatic render calls append geometry instead of adding references """ - - _context_depth: int - """ Number of active context-manager entries """ - - _paths: defaultdict[str, list[RenderStep]] - """ Per-port pending render steps, consumed by `render()` """ - - def _route_context(self, portspec: str) -> RoutePortContext: - """ - Snapshot the live port and selected Tool for planning. - - The port copy lets route-selection failures leave live Pather state - unchanged. Tool lookup prefers a port-specific Tool and falls back to - the `None` default. - """ - tool = self.tools.get(portspec, self.tools.get(None)) - if tool is None: - raise BuildError(f'No tool assigned for port {portspec}') - return RoutePortContext(portspec, self.pattern[portspec].copy(), tool) - - def _route_contexts(self, portspecs: Sequence[str]) -> tuple[RoutePortContext, ...]: - """Snapshot several ports in request order for bundle planning.""" - if not portspecs: - raise BuildError('Routing requires at least one port') - seen: set[str] = set() - duplicates: set[str] = set() - for portspec in portspecs: - if portspec in seen: - duplicates.add(portspec) - seen.add(portspec) - if duplicates: - raise BuildError(f'Routing port names must be unique; got duplicates: {sorted(duplicates)}') - return tuple(self._route_context(portspec) for portspec in portspecs) - - @property - def ports(self) -> dict[str, Port]: - return self.pattern.ports - - @ports.setter - def ports(self, value: dict[str, Port]) -> None: - self.pattern.ports = value - - def __init__( - self, - library: ILibrary, - *, - pattern: Pattern | None = None, - ports: str | Mapping[str, Port] | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - name: str | None = None, - debug: bool = False, - render: RenderPolicy = 'auto', - render_append: bool = True, - planner: RoutingPlanner | None = None, - on_route_complete: RouteCompletionCallback | None = None, - ) -> None: - """ - Args: - library: The library for pattern references and generated segments. - pattern: The pattern to modify. If `None`, a new one is created. - ports: Initial set of ports. May be a string (name in `library`) - or a port mapping. - tools: Tool(s) to use for routing segments. - name: If specified, `library[name]` is set to `self.pattern`. - debug: If True, enables detailed logging. - render: Routing render policy. `'auto'` renders after each route - outside a context manager and defers until clean context exit - inside one. Use `'immediate'` to always render after each route, - `'deferred'` to keep paths pending until `render()` or clean - context exit, `'warn'` to log pending paths on clean context - exit, `'error'` to reject pending paths on clean context exit, - or `'ignore'` to leave pending paths silent. - render_append: If an automatic render is triggered, determines - whether to append geometry or add a reference. - planner: Optional stateless route-selection planner. If omitted, - a new `RoutingPlanner` is used. - on_route_complete: Optional callback invoked once per routing - operation after port updates, plugs, and renames, but before - automatic rendering. It receives this Pather and a read-only, - request-ordered mapping from original routed names to copied - final Ports. Callback exceptions propagate without rollback. - """ - if render not in RENDER_POLICIES: - raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}') - - self._dead = False - self._logger = PatherLogger(debug=debug) - self._render_policy = render - self._render_append = render_append - self._context_depth = 0 - self.library = library - self.pattern = pattern if pattern is not None else Pattern() - self.planner = RoutingPlanner() if planner is None else planner - self.on_route_complete = on_route_complete - self._paths = defaultdict(list) - - if ports is not None: - if self.pattern.ports: - raise BuildError('Ports supplied for pattern with pre-existing ports!') - if isinstance(ports, str): - ports = library.abstract(ports).ports - self.pattern.ports.update(copy.deepcopy(dict(ports))) - - if tools is None: - self.tools = {} - elif isinstance(tools, Tool): - self.tools = {None: tools} - else: - self.tools = dict(tools) - - if name is not None: - library[name] = self.pattern - - def __enter__(self) -> Self: - self._context_depth += 1 - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> bool: - _ = exc_value, traceback - self._context_depth -= 1 - if exc_type is not None or self._context_depth != 0 or not any(self._paths.values()): - return False - - if self._render_policy in ('auto', 'deferred'): - self.render(append=self._render_append) - elif self._render_policy == 'warn': - logger.warning( - 'Pather context exited with %s; call render() or use render="deferred"', - self._pending_render_summary(), - ) - elif self._render_policy == 'error': - raise BuildError(f'Pather context exited with {self._pending_render_summary()}') - return False - - def _pending_render_summary(self) -> str: - ports = [(portspec, len(steps)) for portspec, steps in self._paths.items() if steps] - port_count = len(ports) - step_count = sum(count for _portspec, count in ports) - return ( - f'{step_count} pending render step{"s" if step_count != 1 else ""} ' - f'on {port_count} port{"s" if port_count != 1 else ""}' - ) - - def __repr__(self) -> str: - s = f'' - return s - - # - # Core Pattern Operations (Immediate) - # - def _prepare_breaks(self, names: Iterable[str | None]) -> list[tuple[str, RenderStep]]: - """ Snapshot break markers to be committed after a successful mutation. """ - prepared: list[tuple[str, RenderStep]] = [] - if self._dead: - return prepared - for name in names: - if name is None: - continue - steps = self._paths.get(name) - if not steps: - continue - port = self.ports.get(name, steps[-1].end_port) - prepared.append((name, RenderStep('plug', None, port.copy(), port.copy(), None))) - return prepared - - def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None: - """ Append previously prepared break markers. """ - for name, step in prepared: - self._paths[name].append(step) - - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - **kwargs, - ) -> Self: - with self._logger.log_operation(self, 'plug', list(map_in.keys()), map_out=map_out, **kwargs): - other = self.library.resolve(other, append=kwargs.get('append', False)) - - prepared_breaks: list[tuple[str, RenderStep]] = [] - if not self._dead: - other_ports = other.ports - affected = set(map_in.keys()) - plugged = set(map_in.values()) - for name in other_ports: - if name not in plugged: - new_name = (map_out or {}).get(name, name) - if new_name is not None: - affected.add(new_name) - prepared_breaks = self._prepare_breaks(affected) - elif self._logger.debug: - logger.warning("Skipping geometry for plug() since device is dead") - - self.pattern.plug(other=other, map_in=map_in, map_out=map_out, skip_geometry=self._dead, **kwargs) - self._commit_breaks(prepared_breaks) - return self - - def place( - self, - other: Abstract | str | Pattern | TreeView, - port_map: dict[str, str | None] | None = None, - **kwargs, - ) -> Self: - with self._logger.log_operation(self, 'place', None, port_map=port_map, **kwargs): - other = self.library.resolve(other, append=kwargs.get('append', False)) - - prepared_breaks: list[tuple[str, RenderStep]] = [] - if not self._dead: - other_ports = other.ports - affected = set() - for name in other_ports: - new_name = (port_map or {}).get(name, name) - if new_name is not None: - affected.add(new_name) - prepared_breaks = self._prepare_breaks(affected) - elif self._logger.debug: - logger.warning("Skipping geometry for place() since device is dead") - - self.pattern.place(other=other, port_map=port_map, skip_geometry=self._dead, **kwargs) - self._commit_breaks(prepared_breaks) - return self - - def plugged(self, connections: dict[str, str]) -> Self: - with self._logger.log_operation(self, 'plugged', list(connections.keys()), connections=connections): - prepared_breaks = self._prepare_breaks(chain(connections.keys(), connections.values())) - self.pattern.plugged(connections) - self._commit_breaks(prepared_breaks) - return self - - def rename_ports(self, mapping: dict[str, str | None], overwrite: bool = False) -> Self: - with self._logger.log_operation(self, 'rename_ports', list(mapping.keys()), mapping=mapping, overwrite=overwrite): - winners = self.pattern._rename_ports_impl( - mapping, - overwrite=overwrite or self._dead, - allow_collisions=self._dead, - ) - - moved_steps = {kk: self._paths.pop(kk) for kk in mapping if kk in self._paths} - for kk, steps in moved_steps.items(): - vv = mapping[kk] - # Preserve deferred geometry even if the live port is deleted. - # `render()` can still materialize the saved steps using their stored start/end ports. - # Current semantics intentionally keep deleted ports' queued steps under the old key, - # so if a new live port later reuses that name it does not retarget the old geometry; - # the old and new routes merely share a render bucket until `render()` consumes them. - target = kk if vv is None else vv - if self._dead and vv is not None and winners.get(vv) != kk: - target = kk - self._paths[target].extend(steps) - return self - - def set_dead(self) -> Self: - self._dead = True - return self - - # - # Pattern Wrappers - # - @wraps(Pattern.label) - def label(self, *args, **kwargs) -> Self: - self.pattern.label(*args, **kwargs) - return self - - @wraps(Pattern.ref) - def ref(self, *args, **kwargs) -> Self: - self.pattern.ref(*args, **kwargs) - return self - - @wraps(Pattern.polygon) - def polygon(self, *args, **kwargs) -> Self: - self.pattern.polygon(*args, **kwargs) - return self - - @wraps(Pattern.rect) - def rect(self, *args, **kwargs) -> Self: - self.pattern.rect(*args, **kwargs) - return self - - @wraps(Pattern.path) - def path(self, *args, **kwargs) -> Self: - self.pattern.path(*args, **kwargs) - return self - - def translate(self, offset: ArrayLike) -> Self: - with self._logger.log_operation(self, 'translate', list(self.ports.keys()), offset=offset): - offset_arr = numpy.asarray(offset) - self.pattern.translate_elements(offset_arr) - for steps in self._paths.values(): - for i, step in enumerate(steps): - steps[i] = step.transformed(offset_arr, 0, numpy.zeros(2)) - return self - - def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: - with self._logger.log_operation(self, 'rotate_around', list(self.ports.keys()), pivot=pivot, angle=angle): - pivot_arr = numpy.asarray(pivot) - self.pattern.rotate_around(pivot_arr, angle) - for steps in self._paths.values(): - for i, step in enumerate(steps): - steps[i] = step.transformed(numpy.zeros(2), angle, pivot_arr) - return self - - def mirror(self, axis: int = 0) -> Self: - with self._logger.log_operation(self, 'mirror', list(self.ports.keys()), axis=axis): - self.pattern.mirror(axis) - for steps in self._paths.values(): - for i, step in enumerate(steps): - steps[i] = step.mirrored(axis) - return self - - def mkport(self, name: str, value: Port) -> Self: - with self._logger.log_operation(self, 'mkport', name, value=value): - super().mkport(name, value) - return self - - # - # Routing Logic (Deferred / Incremental) - # - def _notify_route_complete(self, endpoints: Iterable[tuple[str, Port]]) -> None: - """Invoke the configured route callback with isolated endpoint snapshots.""" - callback = self.on_route_complete - if callback is None: - return - snapshots = MappingProxyType({name: port.copy() for name, port in endpoints}) - callback(self, snapshots) - - def _apply_route_result(self, result: PreparedRouteResult) -> None: - """ - Apply every action and deferred rename in a prepared route result. - - Route actions may contain several primitive render steps and port - mutations. Immediate rendering happens once after the whole prepared - result has been applied. - """ - for action in result.actions: - if not action.render_steps: - raise BuildError('Prepared route action has no render steps') - - if not self._dead: - self._paths[action.portspec].extend(action.render_steps) - - self.pattern.ports[action.portspec] = action.final_port.copy() - - if action.plug_into is not None: - self.plugged({action.portspec: action.plug_into}) - for old_name, new_name in result.renames: - self.rename_ports({old_name: new_name}) - self._notify_route_complete( - (action.portspec, action.final_port) for action in result.actions - ) - render_immediately = ( - self._render_policy == 'immediate' - or (self._render_policy == 'auto' and self._context_depth == 0) - ) - if render_immediately and any(self._paths.values()): - self.render(append=self._render_append) - - def _apply_dead_fallback( - self, - portspec: str, - length: float, - jog: float, - ccw: SupportsBool | None, - in_ptype: str, - plug_into: str | None = None, - *, - out_rot: float | None = None, - out_ptype: str | None = None, - ) -> Port: - """ - Move a dead Pather port without generating geometry. - - Dead fallback is only for debugging or dry layout flow. Fatal route - errors bypass it because they indicate an invalid Tool offer contract. - """ - if out_rot is None: - if ccw is None: - out_rot = pi - elif bool(ccw): - out_rot = -pi / 2 - else: - out_rot = pi / 2 - logger.warning(f"Tool planning failed for dead pather. Using dummy extension for {portspec}.") - port = self.pattern[portspec] - port_rot = port.rotation - if port_rot is None: - raise PortError('Ports must have rotation') - out_port = Port((length, jog), rotation=out_rot, ptype=out_ptype or in_ptype) - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - self.pattern.ports[portspec] = out_port - if plug_into is not None: - self.plugged({portspec: plug_into}) - return out_port.copy() - - # - # High-level Routing Methods - # - def trace( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - """ - Route one or more ports using straight segments or single 90-degree bends. - - Provide exactly one routing mode: - - `length` for a single port, - - `each` to extend each selected port independently by the same amount, or - - one bundle bound such as `xmin`, `emax`, or `min_past_furthest`. - - For a single port with no length or bound, legal primitive-offer - candidates are evaluated at their minimum legal length-like parameters, - then cost selects among those minimum-length candidates. `out_ptype`, - when provided, constrains only the final route endpoint. Planner-specific - per-route settings belong in `plan_options`. - - `spacing` and `set_rotation` are only valid when using a bundle bound. - """ - bounds = _present_route_args( - out_ptype=out_ptype, - each=each, - set_rotation=set_rotation, - emin=emin, - emax=emax, - pmin=pmin, - pmax=pmax, - xmin=xmin, - xmax=xmax, - ymin=ymin, - ymax=ymax, - min_past_furthest=min_past_furthest, - ) - plan_opts = _validated_plan_options(plan_options) - tool_opts = _validated_tool_options(tool_options) - with self._logger.log_operation( - self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, - plan_options=plan_opts, tool_options=tool_opts, **bounds, - ): - if isinstance(portspec, str): - portspec = [portspec] - contexts = self._route_contexts(portspec) - try: - result = self.planner.plan_trace_route( - contexts, ccw, length, spacing=spacing, plan_options=plan_opts, - tool_options=tool_opts, **bounds, - ) - except (BuildError, NotImplementedError) as err: - if isinstance(err, RouteError): - err.__traceback__ = None - if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL: - raise - if length is not None and len(contexts) == 1: - context = contexts[0] - endpoint = self._apply_dead_fallback( - context.portspec, - length, - 0, - ccw, - context.port.ptype, - out_ptype = out_ptype, - ) - self._notify_route_complete(((context.portspec, endpoint),)) - return self - if bounds.get('each') is not None: - each = bounds['each'] - endpoints: list[tuple[str, Port]] = [] - for context in contexts: - endpoint = self._apply_dead_fallback( - context.portspec, - each, - 0, - ccw, - context.port.ptype, - out_ptype = out_ptype, - ) - endpoints.append((context.portspec, endpoint)) - self._notify_route_complete(endpoints) - return self - raise - self._apply_route_result(result) - return self - - def trace_to( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - length: float | None = None, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - """ - Route until a single positional bound is reached, or delegate to `trace()` for length/bundle bounds. - - Exactly one of `p`, `pos`, `position`, `x`, or `y` may be used as a positional - bound. Positional bounds are only valid for a single port and may not be combined - with `length`, `spacing`, `each`, or bundle-bound keywords such as `xmin`/`emax`. - - With no positional or bundle bound, single-port `trace_to()` uses the - same omitted minimum-length primitive-offer behavior as `trace()`. - Planner-specific per-route settings belong in `plan_options`. - """ - bounds = _present_route_args( - length=length, - out_ptype=out_ptype, - each=each, - set_rotation=set_rotation, - p=p, - pos=pos, - position=position, - x=x, - y=y, - emin=emin, - emax=emax, - pmin=pmin, - pmax=pmax, - xmin=xmin, - xmax=xmax, - ymin=ymin, - ymax=ymax, - min_past_furthest=min_past_furthest, - ) - plan_opts = _validated_plan_options(plan_options) - tool_opts = _validated_tool_options(tool_options) - with self._logger.log_operation( - self, 'trace_to', portspec, ccw=ccw, spacing=spacing, - plan_options=plan_opts, tool_options=tool_opts, **bounds, - ): - if isinstance(portspec, str): - portspec = [portspec] - contexts = self._route_contexts(portspec) - try: - result = self.planner.plan_trace_to_route( - contexts, ccw, spacing=spacing, plan_options=plan_opts, - tool_options=tool_opts, **bounds, - ) - except (BuildError, NotImplementedError) as err: - if isinstance(err, RouteError): - err.__traceback__ = None - if ( - not self._dead - or len(contexts) != 1 - or route_failure_policy(err) is RouteFailurePolicy.FATAL - ): - raise - if bounds.get('length') is not None: - length = bounds['length'] - else: - resolved = resolved_position_bound( - contexts[0].port, - bounds, - allow_length=False, - ) - if resolved is None: - raise - _key, _value, length = resolved - context = contexts[0] - endpoint = self._apply_dead_fallback( - context.portspec, - length, - 0, - ccw, - context.port.ptype, - out_ptype = out_ptype, - ) - self._notify_route_complete(((context.portspec, endpoint),)) - return self - self._apply_route_result(result) - return self - - def straight( - self, - portspec: str | Sequence[str], - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - return self.trace_to( - portspec, None, length=length, spacing=spacing, plan_options=plan_options, - out_ptype=out_ptype, each=each, set_rotation=set_rotation, - p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, - xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - - def bend( - self, - portspec: str | Sequence[str], - ccw: SupportsBool, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - return self.trace_to( - portspec, ccw, length=length, spacing=spacing, plan_options=plan_options, - out_ptype=out_ptype, each=each, set_rotation=set_rotation, - p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, - xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - - def ccw( - self, - portspec: str | Sequence[str], - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - return self.bend( - portspec, True, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - - def cw( - self, - portspec: str | Sequence[str], - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - return self.bend( - portspec, False, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - - def jog( - self, - portspec: str | Sequence[str], - offset: float, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - """ - Route an S-bend. - - `length` is the along-travel displacement. If omitted and no positional - bound is supplied, a single-port jog evaluates legal S-like candidates - at their minimum legal length or primitive endpoint length for the - requested offset, then cost selects among those candidates. If exactly - one positional bound (`p`, `pos`, `position`, `x`, or `y`) is supplied, - the required travel distance is derived from that bound. - - Multi-port jogs require `spacing`; the innermost first-bend port uses - the base `length` or omitted-length solve, and other ports derive exact - route lengths and offsets from that base route. `out_ptype`, when - provided, constrains only each final route endpoint. Planner-specific - per-route settings belong in `plan_options`. - """ - bounds = _present_route_args(out_ptype=out_ptype, p=p, pos=pos, position=position, x=x, y=y) - plan_opts = _validated_plan_options(plan_options) - tool_opts = _validated_tool_options(tool_options) - with self._logger.log_operation( - self, 'jog', portspec, offset=offset, length=length, spacing=spacing, - plan_options=plan_opts, tool_options=tool_opts, **bounds, - ): - if isinstance(portspec, str): - portspec = [portspec] - contexts = self._route_contexts(portspec) - try: - result = self.planner.plan_jog_route( - contexts, offset, length, spacing=spacing, plan_options=plan_opts, - tool_options=tool_opts, **bounds, - ) - except (BuildError, NotImplementedError) as err: - if isinstance(err, RouteError): - err.__traceback__ = None - if ( - not self._dead - or len(contexts) != 1 - or route_failure_policy(err) is RouteFailurePolicy.FATAL - ): - raise - if numpy.isclose(offset, 0): - if length is None: - raise - context = contexts[0] - endpoint = self._apply_dead_fallback( - context.portspec, - length, - 0, - None, - context.port.ptype, - out_ptype = out_ptype, - ) - self._notify_route_complete(((context.portspec, endpoint),)) - return self - fallback_length = length if length is not None else 0 - context = contexts[0] - endpoint = self._apply_dead_fallback( - context.portspec, - fallback_length, - offset, - None, - context.port.ptype, - out_rot = pi, - out_ptype = out_ptype, - ) - self._notify_route_complete(((context.portspec, endpoint),)) - return self - self._apply_route_result(result) - return self - - def uturn( - self, - portspec: str | Sequence[str], - offset: float, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - """ - Route a U-turn. - - `length` is the along-travel displacement to the final port. If omitted, - legal U-like candidates are evaluated at their minimum legal length or - primitive endpoint length for the requested offset, then cost selects - among those candidates. Multi-port U-turns require nonzero `offset` and - `spacing`; the innermost first-bend port supplies the base route and - other ports derive exact lengths and offsets from it. Use `length=0` to - request the old zero-public-length U-turn shape. Positional and - bundle-bound keywords are not supported for this operation. `out_ptype`, - when provided, constrains only each final route endpoint. Planner-specific - per-route settings belong in `plan_options`. - """ - bounds = _present_route_args(out_ptype=out_ptype) - plan_opts = _validated_plan_options(plan_options) - tool_opts = _validated_tool_options(tool_options) - with self._logger.log_operation( - self, 'uturn', portspec, offset=offset, length=length, spacing=spacing, - plan_options=plan_opts, tool_options=tool_opts, **bounds, - ): - if isinstance(portspec, str): - portspec = [portspec] - contexts = self._route_contexts(portspec) - try: - result = self.planner.plan_uturn_route( - contexts, offset, length, spacing=spacing, plan_options=plan_opts, - tool_options=tool_opts, **bounds, - ) - except (BuildError, NotImplementedError) as err: - if isinstance(err, RouteError): - err.__traceback__ = None - if ( - not self._dead - or len(contexts) != 1 - or length is None - or route_failure_policy(err) is RouteFailurePolicy.FATAL - ): - raise - context = contexts[0] - endpoint = self._apply_dead_fallback( - context.portspec, - length, - offset, - None, - context.port.ptype, - out_rot = 0.0, - out_ptype = out_ptype, - ) - self._notify_route_complete(((context.portspec, endpoint),)) - return self - self._apply_route_result(result) - return self - - def trace_into( - self, - portspec_src: str, - portspec_dst: str, - *, - out_ptype: str | None = None, - plug_destination: bool = True, - thru: str | None = None, - plan_options: Mapping[str, Any] | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - """ - Route one port into another using a bounded primitive-offer selection. - - By default, searches only the exact main-route bend count required by - the endpoint relationship: zero for a straight, one for a quarter-turn, - and two for S- and U-like connections. This rejects extra dogleg and - loop-like fallback routes without inspecting primitive geometry. Ptype - adapters do not consume this bend budget. - - Set `plan_options={'bend_policy': 'flexible'}` to search bounded - primitive-offer routes with up to four bend roles. Bend-family requests try one-bend routes - before three-bend routes; other families try zero-to-two-bend routes - before four-bend routes. The first band with a legal candidate wins. - Within a band, candidates are ordered by total cost, adapter count, - step count, the requested straight-vs-turn topology preference, and - deterministic discovery order. The default planner's `strategy` option - therefore affects only otherwise tied candidates. - Custom planning options may be supplied through `tool_options`; they - are forwarded only to primitive offer generation. - - If `plug_destination` is `True`, the destination port is consumed by the final step. - If `thru` is provided, that port is renamed to the source name after the route is complete. - `out_ptype` constrains only the final route endpoint. Route selection - failures occur before live port state and deferred routing steps are - mutated; failures during selected-route execution, including primitive - commit, plug/thru application, or render, may leave partial output. - """ - plan_opts = _validated_plan_options(plan_options) - tool_opts = _validated_tool_options(tool_options) - with self._logger.log_operation( - self, - 'trace_into', - [portspec_src, portspec_dst], - out_ptype=out_ptype, - plug_destination=plug_destination, - thru=thru, - plan_options=plan_opts, - tool_options=tool_opts, - ): - try: - result = self.planner.plan_trace_into( - self._route_context(portspec_src), - portspec_dst, - self.pattern[portspec_dst].copy(), - out_ptype = out_ptype, - plug_destination = plug_destination, - thru = thru, - plan_options = plan_opts, - tool_options = tool_opts, - ) - except RouteError as err: - err.__traceback__ = None - raise - self._apply_route_result(result) - return self - - # - # Rendering - # - def render(self, append: bool = True) -> Self: - """ - Generate geometry for all pending render steps. - - Consecutive compatible `RenderStep`s are batched by port and Tool, then - passed to `Tool.render()`. After insertion, the rendered output port is - checked against the endpoint that planning selected. - - Rendering may modify the Library before every batch has completed and - does not provide rollback. If this method raises, the Pather must be - treated as unusable; retrying or continuing to route with it is - unsupported. - """ - with self._logger.log_operation(self, 'render', None, append=append): - tool_port_names = ('A', 'B') - pat = Pattern() - - def validate_tree(portspec: str, batch: list[RenderStep], tree: ILibrary) -> None: - missing = sorted( - name - for name in tree.dangling_refs(tree.top()) - if isinstance(name, str) and name.startswith(SINGLE_USE_PREFIX) - ) - if not missing: - return - - tool_name = type(batch[0].tool).__name__ - raise ToolContractError( - f'Tool {tool_name}.render() returned missing single-use refs for {portspec}: {missing}' - ) - - def validate_rendered_endpoint(portspec: str, batch: list[RenderStep]) -> None: - expected = batch[-1].end_port - actual = pat.ports.get(portspec) - tool_name = type(batch[0].tool).__name__ - if actual is None: - raise ToolContractError( - f'Tool {tool_name}.render() did not produce output port {portspec!r}; ' - f'expected {expected.describe()}' - ) - - offsets_match = array_close(actual.offset, expected.offset) - rotations_match = ( - actual.rotation is None - or expected.rotation is None - or angles_equal(actual.rotation, expected.rotation) - ) - ptypes_match = ptypes_compatible(actual.ptype, expected.ptype) - if offsets_match and rotations_match and ptypes_match: - return - - raise ToolContractError( - f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: ' - f'expected {expected.describe()}, got {actual.describe()}' - ) - - def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: - assert batch[0].tool is not None - tree = batch[0].tool.render(batch, port_names=tool_port_names) - validate_tree(portspec, batch, tree) - name = self.library << tree - try: - if portspec in pat.ports: - del pat.ports[portspec] - pat.ports[portspec] = batch[0].start_port.copy() - if append: - pat.plug(self.library[name], {portspec: tool_port_names[0]}, append=True) - del self.library[name] - else: - pat.plug(self.library.abstract(name), {portspec: tool_port_names[0]}, append=False) - if portspec not in pat.ports and tool_port_names[1] in pat.ports: - pat.rename_ports({tool_port_names[1]: portspec}, overwrite=True) - validate_rendered_endpoint(portspec, batch) - except Exception: - if name in self.library: - del self.library[name] - raise - - for portspec, steps in self._paths.items(): - if not steps: - continue - batch: list[RenderStep] = [] - for step in steps: - appendable = step.kind != 'plug' - same_tool = batch and step.tool is batch[0].tool - if batch and (not appendable or not same_tool or not batch[-1].is_continuous_with(step)): - render_batch(portspec, batch, append) - batch = [] - if appendable: - batch.append(step) - elif step.kind == 'plug' and portspec in pat.ports: - del pat.ports[portspec] - if batch: - render_batch(portspec, batch, append) - - self._paths.clear() - pat.ports.clear() - self.pattern.append(pat) - return self - - # - # Utilities - # - @classmethod - def interface( - cls, - source: PortList | Mapping[str, Port] | str, - *, - library: ILibrary | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - in_prefix: str = 'in_', - out_prefix: str = '', - port_map: dict[str, str] | Sequence[str] | None = None, - name: str | None = None, - **kwargs: Any, - ) -> Self: - if library is None: - if hasattr(source, 'library') and isinstance(source.library, ILibrary): - library = source.library - else: - raise BuildError('No library provided') - if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict): - tools = source.tools - if isinstance(source, str): - source = library.abstract(source).ports - pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) - return cls(library=library, pattern=pat, name=name, tools=tools, **kwargs) - - def retool(self, tool: Tool, keys: str | Sequence[str | None] | None = None) -> Self: - if keys is None or isinstance(keys, str): - self.tools[keys] = tool - else: - for k in keys: - self.tools[k] = tool - return self - - @contextmanager - def toolctx(self, tool: Tool, keys: str | Sequence[str | None] | None = None) -> Iterator[Self]: - if keys is None or isinstance(keys, str): - keys = [keys] - saved = {k: self.tools.get(k) for k in keys} - try: - yield self.retool(tool, keys) - finally: - for k, t in saved.items(): - if t is None: - self.tools.pop(k, None) - else: - self.tools[k] = t - - def flatten(self) -> Self: - self.pattern.flatten(self.library) - return self - - def at( - self, - portspec: str | Iterable[str], - *, - spacing: float | ArrayLike | None = None, - ) -> 'PortPather': - return PortPather(portspec, self, default_spacing=spacing) - - -class PortPather: - """ - Port-name selection for fluent pathing. - - The selection stores names, not stable physical port identities. Its own - rename/delete helpers update the selection, but unrelated changes made - through the parent Pather do not retarget it. - """ - def __init__( - self, - ports: str | Iterable[str], - pather: Pather, - *, - default_spacing: float | ArrayLike | None = None, - ) -> None: - self.ports = [ports] if isinstance(ports, str) else list(ports) - self.pather = pather - self.default_spacing = default_spacing - - def _single_port(self, action: str) -> str: - """Return the selected port for an exact-one operation.""" - if len(self.ports) != 1: - raise BuildError( - f'Unable to use implicit {action}() with {len(self.ports)} ports; expected exactly one.' - ) - return self.ports[0] - - def retool(self, tool: Tool) -> Self: - self.pather.retool(tool, self.ports) - return self - - def set_spacing(self, spacing: float | ArrayLike | None) -> Self: - self.default_spacing = spacing - return self - - @contextmanager - def toolctx(self, tool: Tool) -> Iterator[Self]: - with self.pather.toolctx(tool, keys=self.ports): - yield self - - def trace( - self, - ccw: SupportsBool | None, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: - spacing = self.default_spacing - self.pather.trace( - self.ports, ccw, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - each=each, set_rotation=set_rotation, emin=emin, emax=emax, pmin=pmin, pmax=pmax, - xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, - tool_options=tool_options, - ) - return self - - def trace_to( - self, - ccw: SupportsBool | None, - *, - length: float | None = None, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: - spacing = self.default_spacing - self.pather.trace_to( - self.ports, ccw, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - return self - - def straight( - self, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - return self.trace_to( - None, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - - def bend( - self, - ccw: SupportsBool, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - return self.trace_to( - ccw, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - - def ccw( - self, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - return self.bend( - True, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - - def cw( - self, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - each: float | None = None, - set_rotation: float | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - emin: float | None = None, - emax: float | None = None, - pmin: float | None = None, - pmax: float | None = None, - xmin: float | None = None, - xmax: float | None = None, - ymin: float | None = None, - ymax: float | None = None, - min_past_furthest: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - return self.bend( - False, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, - emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, - min_past_furthest=min_past_furthest, tool_options=tool_options, - ) - - def jog( - self, - offset: float, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - p: float | None = None, - pos: float | None = None, - position: float | None = None, - x: float | None = None, - y: float | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and not numpy.isclose(offset, 0): - spacing = self.default_spacing - self.pather.jog( - self.ports, offset, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, - p=p, pos=pos, position=position, x=x, y=y, tool_options=tool_options, - ) - return self - - def uturn( - self, - offset: float, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - out_ptype: str | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - if spacing is None and self.default_spacing is not None and len(self.ports) > 1: - spacing = self.default_spacing - self.pather.uturn( - self.ports, offset, length, spacing=spacing, plan_options=plan_options, - out_ptype=out_ptype, tool_options=tool_options, - ) - return self - - def trace_into( - self, - target_port: str, - *, - out_ptype: str | None = None, - plug_destination: bool = True, - thru: str | None = None, - plan_options: Mapping[str, Any] | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> Self: - port = self._single_port('trace_into') - self.pather.trace_into( - port, target_port, out_ptype=out_ptype, plug_destination=plug_destination, - thru=thru, plan_options=plan_options, tool_options=tool_options, - ) - return self - - def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self: - port = self._single_port('plug') - self.pather.plug(other, {port: other_port}, **kwargs) - return self - - def plugged(self, other_port: str | Mapping[str, str]) -> Self: - if isinstance(other_port, Mapping): - self.pather.plugged(dict(other_port)) - else: - port = self._single_port('plugged') - self.pather.plugged({port: other_port}) - return self - - # - # Delegate to port - # - # These mutate only the selected live port state. They do not rewrite already planned - # RenderSteps, so deferred geometry remains as previously planned and only future routing - # starts from the updated port. - def set_ptype(self, ptype: str) -> Self: - for port in self.ports: - self.pather.pattern[port].set_ptype(ptype) - return self - - def translate(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.pattern[port].translate(*args, **kwargs) - return self - - def mirror(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.pattern[port].mirror(*args, **kwargs) - return self - - def rotate(self, rotation: float) -> Self: - for port in self.ports: - self.pather.pattern[port].rotate(rotation) - return self - - def set_rotation(self, rotation: float | None) -> Self: - for port in self.ports: - self.pather.pattern[port].set_rotation(rotation) - return self - - def rename(self, name: str | Mapping[str, str | None]) -> Self: - """ Rename active ports. """ - name_map: dict[str, str | None] - if isinstance(name, str): - name_map = {self._single_port('rename'): name} - else: - name_map = dict(name) - self.pather.rename_ports(name_map) - renamed_ports: list[str] = [] - for port in self.ports: - renamed = name_map.get(port, port) - if renamed is not None and renamed not in renamed_ports: - renamed_ports.append(renamed) - self.ports = renamed_ports - return self - - def select(self, ports: str | Iterable[str]) -> Self: - """ Add ports to the selection. """ - if isinstance(ports, str): - ports = [ports] - for port in ports: - if port not in self.ports: - self.ports.append(port) - return self - - def deselect(self, ports: str | Iterable[str]) -> Self: - """ Remove ports from the selection. """ - if isinstance(ports, str): - ports = [ports] - ports_set = set(ports) - self.ports = [pp for pp in self.ports if pp not in ports_set] - return self - - def _normalize_copy_map(self, name: str | Mapping[str, str], action: str) -> dict[str, str]: - if isinstance(name, str): - name_map = {self._single_port(action): name} - else: - name_map = dict(name) - - missing_selected = set(name_map) - set(self.ports) - if missing_selected: - raise PortError(f'Can only {action} selected ports: {missing_selected}') - - missing_pattern = set(name_map) - set(self.pather.pattern.ports) - if missing_pattern: - raise PortError(f'Ports to {action} were not found: {missing_pattern}') - - if not self.pather._dead: - targets = list(name_map.values()) - duplicate_targets = {vv for vv in targets if targets.count(vv) > 1} - if duplicate_targets: - raise PortError(f'{action.capitalize()} targets would collide: {duplicate_targets}') - - overwritten = { - dst for src, dst in name_map.items() - if dst in self.pather.pattern.ports and dst != src - } - if overwritten: - raise PortError(f'{action.capitalize()} would overwrite existing ports: {overwritten}') - - return name_map - - def mark(self, name: str | Mapping[str, str]) -> Self: - """ Bookmark current port(s). """ - name_map = self._normalize_copy_map(name, 'mark') - source_ports = {src: self.pather.pattern[src].copy() for src in name_map} - for src, dst in name_map.items(): - self.pather.pattern.ports[dst] = source_ports[src].copy() - return self - - def fork(self, name: str | Mapping[str, str]) -> Self: - """ Split and follow new name. """ - name_map = self._normalize_copy_map(name, 'fork') - source_ports = {src: self.pather.pattern[src].copy() for src in name_map} - for src, dst in name_map.items(): - self.pather.pattern.ports[dst] = source_ports[src].copy() - self.ports = [(dst if pp == src else pp) for pp in self.ports] - self.ports = list(dict.fromkeys(self.ports)) - return self - - def drop(self) -> Self: - """ Remove selected ports from the pattern and the PortPather. """ - self.pather.rename_ports(dict.fromkeys(self.ports)) - self.ports = [] - return self - - @overload - def delete(self, name: None) -> None: ... - - @overload - def delete(self, name: str) -> Self: ... - - def delete(self, name: str | None = None) -> Self | None: - if name is None: - self.drop() - return None - self.pather.rename_ports({name: None}) - self.ports = [pp for pp in self.ports if pp != name] - return self diff --git a/masque/builder/planner/__init__.py b/masque/builder/planner/__init__.py deleted file mode 100644 index 752ac8a..0000000 --- a/masque/builder/planner/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Simplified primitive-offer route planner used by `Pather`. - -This package is the Pather-facing route-selection implementation. It keeps -the public Tool contract narrow: offers are evaluated during planning, and -offer commits are deferred until after a complete route is selected. -""" -from .interface import ( - PreparedRouteAction as PreparedRouteAction, - PreparedRouteResult as PreparedRouteResult, - RoutePlanningError as RoutePlanningError, - RoutePortContext as RoutePortContext, - route_failure_policy as route_failure_policy, - ) -from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy -from .planner import TraceIntoBendPolicy as TraceIntoBendPolicy -from .planner import RoutingPlanner as RoutingPlanner diff --git a/masque/builder/planner/bounds.py b/masque/builder/planner/bounds.py deleted file mode 100644 index 19a47d1..0000000 --- a/masque/builder/planner/bounds.py +++ /dev/null @@ -1,284 +0,0 @@ -""" -Argument validation and bound resolution for Pather routing calls. - -This module keeps user-facing mode validation outside the solver. It converts -single-port positional bounds into local travel lengths and derives multi-port -S/U bundle specs before primitive offers are considered. - -The solver expects one coherent route intent at a time. This module enforces -that public routing modes are not mixed: explicit length, per-port `each`, -positional bounds, and bundle bounds are mutually constrained before any Tool -offers are queried. Multi-port S/U bundles are also normalized here into exact -per-port public lengths and offsets. -""" -from __future__ import annotations - -# ruff: noqa: TC001,TC002,TC003 -from typing import Any -from collections.abc import Mapping, Sequence -from pprint import pformat - -import numpy -from numpy import pi -from numpy.typing import ArrayLike, NDArray - -from ...error import BuildError, PortError -from ...ports import Port -from ...utils import rotation_matrix_2d -from .._tolerances import manhattan_axis -from .interface import RoutePortContext - - -POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position') -BUNDLE_BOUND_KEYS: tuple[str, ...] = ( - 'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest', - ) - - -def finite_scalar(value: Any, name: str, *, nonnegative: bool = False) -> float: - """Return a finite scalar, preserving duck-typed numeric inputs.""" - try: - array = numpy.asarray(value, dtype=float) - except (TypeError, ValueError) as err: - raise BuildError(f'{name} must be a finite numeric scalar') from err - if array.size != 1: - raise BuildError(f'{name} must be a scalar; got {array.size} values') - result = float(array.reshape(-1)[0]) - if not numpy.isfinite(result): - raise BuildError(f'{name} must be finite') - if nonnegative and result < 0: - raise BuildError(f'{name} must be nonnegative') - return result - - -def resolved_position_bound( - port: Port, - bounds: Mapping[str, Any], - *, - allow_length: bool, - ) -> tuple[str, Any, float] | None: - """Resolve a single positional bound for a single port into a travel length.""" - present = [(key, bounds[key]) for key in POSITION_KEYS if bounds.get(key) is not None] - if not present: - return None - if len(present) > 1: - keys = ', '.join(key for key, _value in present) - raise BuildError(f'Provide exactly one positional bound; got {keys}') - if not allow_length and bounds.get('length') is not None: - raise BuildError('length cannot be combined with a positional bound') - - key, raw_value = present[0] - value = finite_scalar(raw_value, f'{key} positional bound') - if port.rotation is None: - raise BuildError('Ports must have rotation') - axis = manhattan_axis(port.rotation) - if axis is None: - raise BuildError( - 'Positional bounds require a nearly Manhattan port direction; ' - f'got rotation {port.rotation:g}' - ) - if axis == 0: - if key == 'y': - raise BuildError('Port is horizontal') - target = Port((value, port.offset[1]), rotation=None) - else: - if key == 'x': - raise BuildError('Port is vertical') - target = Port((port.offset[0], value), rotation=None) - (travel, _jog), _ = port.measure_travel(target) - return key, value, -float(travel) - - -def present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]: - """Return keys whose bound value is explicitly present and non-None.""" - return [key for key in keys if bounds.get(key) is not None] - - -def present_bundle_bounds(bounds: Mapping[str, Any]) -> list[str]: - """Return active multi-port trace bound keys.""" - return present_keys(bounds, BUNDLE_BOUND_KEYS) - - -def validate_trace_args( - portspec: Sequence[str], - *, - length: float | None, - spacing: float | ArrayLike | None, - bounds: Mapping[str, Any], - ) -> None: - """ - Validate mutually-exclusive `trace()` routing modes. - - A trace request is either an explicit single-port length, an `each` length - for all ports, a single-port omitted-length solve, or a bundle solve with - exactly one bundle bound. - """ - bundle_bounds = present_bundle_bounds(bounds) - if len(bundle_bounds) > 1: - args = ', '.join(bundle_bounds) - raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}') - - invalid_with_length = present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds - invalid_with_each = present_keys(bounds, ('set_rotation',)) + bundle_bounds - - if length is not None: - if len(portspec) > 1: - raise BuildError('length only allowed with a single port') - if spacing is not None: - invalid_with_length.append('spacing') - if invalid_with_length: - args = ', '.join(invalid_with_length) - raise BuildError(f'length cannot be combined with other routing bounds: {args}') - return - - if bounds.get('each') is not None: - if spacing is not None: - invalid_with_each.append('spacing') - if invalid_with_each: - args = ', '.join(invalid_with_each) - raise BuildError(f'each cannot be combined with other routing bounds: {args}') - return - - if not bundle_bounds and len(portspec) == 1: - if spacing is not None: - raise BuildError('spacing cannot be combined with omitted-length single-port trace()') - invalid = present_keys(bounds, ('set_rotation',)) - if invalid: - args = ', '.join(invalid) - raise BuildError(f'Unsupported routing bounds for omitted-length trace(): {args}') - return - - if not bundle_bounds: - raise BuildError('No bound type specified for trace()') - - -def validate_trace_to_positional_args( - *, - spacing: float | ArrayLike | None, - bounds: Mapping[str, Any], - ) -> None: - """Reject bound combinations that cannot be mixed with a single positional `trace_to()` target.""" - invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds) - if spacing is not None: - invalid.append('spacing') - if invalid: - args = ', '.join(invalid) - raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}') - - -def validate_jog_args( - portspec: Sequence[str], - *, - length: float | None, - spacing: float | ArrayLike | None, - bounds: Mapping[str, Any], - ) -> None: - """ - Validate `jog()` mode constraints before S-route planning. - - Single-port jogs may derive length from a positional bound. Multi-port jogs - require spacing and cannot combine omitted length with positional bounds. - """ - invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds) - if len(portspec) == 1 and spacing is not None: - invalid.append('spacing') - if len(portspec) > 1 and length is None: - invalid += present_keys(bounds, POSITION_KEYS) - if length is not None: - invalid = present_keys(bounds, POSITION_KEYS) + invalid - if invalid: - args = ', '.join(invalid) - raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}') - return - - if invalid: - args = ', '.join(invalid) - raise BuildError(f'Unsupported routing bounds for jog(): {args}') - - -def validate_uturn_args( - portspec: Sequence[str], - *, - spacing: float | ArrayLike | None, - bounds: Mapping[str, Any], - ) -> None: - """Validate `uturn()` arguments, which do not support positional or bundle-bound keywords.""" - invalid = present_keys(bounds, POSITION_KEYS + ('each', 'set_rotation')) + present_bundle_bounds(bounds) - if len(portspec) == 1 and spacing is not None: - invalid.append('spacing') - if invalid: - args = ', '.join(invalid) - raise BuildError(f'Unsupported routing bounds for uturn(): {args}') - - -def su_bundle_specs( - contexts: Sequence[RoutePortContext], - offset: float, - length: float, - spacing: float | ArrayLike | None, - *, - route_name: str, - ) -> tuple[tuple[str, float, float], ...]: - """ - Normalize a multi-port S/U bundle into per-port `(name, length, offset)` specs. - - Ports are ordered from the inside of the first bend outward. The first spec - receives the requested base route; later specs add cumulative spacing to - both route length and lateral offset so the bundle keeps the requested - separation. - """ - if spacing is None: - raise BuildError(f'Must provide spacing for multi-port {route_name}()') - finite_scalar(offset, 'offset') - finite_scalar(length, 'length', nonnegative=True) - - ports = {context.portspec: context.port for context in contexts} - has_rotation = numpy.array([port.rotation is not None for port in ports.values()], dtype=bool) - if not has_rotation.all(): - raise PortError(f'Ports must have rotation for multi-port {route_name}()') - - rotations = numpy.array([port.rotation for port in ports.values()], dtype=float) - if not numpy.allclose(rotations[0], rotations): - port_rotations = {name: numpy.rad2deg(port.rotation) for name, port in ports.items()} - raise BuildError( - f'Asked to find multi-port {route_name}() bundle for ports that face in different directions:\n' - + pformat(port_rotations) - ) - - direction = rotations[0] + pi - rot_matrix = rotation_matrix_2d(-direction) - orig_offsets = numpy.array([port.offset for port in ports.values()]) - rot_offsets = (rot_matrix @ orig_offsets.T).T - - first_ccw = bool(offset > 0) - y_order = ((-1 if first_ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable') - - spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1) - if numpy.any(spacing_arr < 0): - raise BuildError('spacing must be nonnegative') - steps: NDArray[numpy.float64] = numpy.zeros(len(ports), dtype=float) - if spacing_arr.size == 1: - steps[1:] = spacing_arr[0] - elif spacing_arr.size == len(ports) - 1: - steps[1:] = spacing_arr - else: - raise BuildError( - f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; ' - f'got length {spacing_arr.size}' - ) - if not numpy.all(numpy.isfinite(steps)): - raise BuildError('spacing must contain only finite values') - - names = tuple(ports.keys()) - ordered_spacings = numpy.cumsum(steps) - anchor_y = float(rot_offsets[y_order[0], 1]) - specs: list[tuple[str, float, float]] = [] - for order_index, port_index in enumerate(y_order): - spacing_offset = float(ordered_spacings[order_index]) - start_y = float(rot_offsets[port_index, 1]) - specs.append(( - names[port_index], - float(length) + spacing_offset, - float(offset) - start_y + anchor_y + spacing_offset, - )) - return tuple(specs) diff --git a/masque/builder/planner/interface.py b/masque/builder/planner/interface.py deleted file mode 100644 index 5be69c9..0000000 --- a/masque/builder/planner/interface.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Planner/Pather exchange types. - -`Pather` snapshots live routing state into these records before calling the -planner. The planner returns prepared actions that `Pather` can apply without -needing to know solver internals. -""" - -from __future__ import annotations - -# ruff: noqa: TC001 - -from dataclasses import dataclass - -from ...error import BuildError -from ...ports import Port -from ..tools import RenderStep, Tool -from ..error import RouteError, RouteFailurePolicy, ToolContractError - - -class RoutePlanningError(BuildError): - """Route-planning error with fallback policy metadata.""" - - policy: RouteFailurePolicy - - def __init__( - self, - *args: object, - policy: RouteFailurePolicy = RouteFailurePolicy.RECOVERABLE, - ) -> None: - super().__init__(*args) - self.policy = policy - - -def route_failure_policy(err: Exception) -> RouteFailurePolicy: - """Return typed route recovery policy, defaulting generic errors to recoverable.""" - if isinstance(err, ToolContractError): - return RouteFailurePolicy.FATAL - if isinstance(err, RoutePlanningError): - return err.policy - if isinstance(err, RouteError): - return err.policy - return RouteFailurePolicy.RECOVERABLE - - -@dataclass(frozen=True, slots=True) -class RoutePortContext: - """ - Immutable planning view of one live Pather port. - - `port` is a copy of the live port so failed route selection leaves Pather - state unchanged. `tool` is the already-resolved routing Tool for this - portspec. - """ - portspec: str - """Live Pather port name being planned.""" - port: Port - """Copied live port used as immutable route input.""" - tool: Tool - """Resolved Tool for this port.""" - - -@dataclass(frozen=True, slots=True) -class PreparedRouteAction: - """ - Prepared mutation for one routed Pather port. - - Pure selection has already completed, and the planner has materialized the - selected primitive offers into `render_steps` and computed the final live - port. `plug_into`, when set, names the destination port to consume after the - route endpoint is applied. - """ - portspec: str - """Live Pather port name to update.""" - render_steps: tuple[RenderStep, ...] - """Committed route steps to append to Pather's pending render queue.""" - final_port: Port - """Final live port value after all route steps.""" - plug_into: str | None = None - """Optional destination port to consume after the final port is applied.""" - - -@dataclass(frozen=True, slots=True) -class PreparedRouteResult: - """ - Complete prepared result for one Pather routing operation. - - `actions` contain materialized, committed render data and are applied first. - `renames` are deferred until after all route actions so trace-into/thru - behavior can be represented without exposing the solver's selected - primitive sequence to Pather. - """ - actions: tuple[PreparedRouteAction, ...] - """Prepared per-port route mutations.""" - renames: tuple[tuple[str, str], ...] = () - """Deferred `(old_name, new_name)` port renames applied after actions.""" diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py deleted file mode 100644 index 60c6e93..0000000 --- a/masque/builder/planner/planner.py +++ /dev/null @@ -1,1883 +0,0 @@ -""" -Primitive-offer route selection for `Pather`. - -`RoutingPlanner` is the stateless boundary between `Pather` routing calls and -Tool primitive offers. `Pather` passes copied `RoutePortContext` snapshots here; -the planner returns `PreparedRouteResult` records that describe pending -mutations without applying them to the live Pattern. - -Public routing modes and bounds are normalized by `bounds.py` into per-leg -`SolverRequest` values. `Solver` performs the pure search: it queries Tool -offers, enumerates bounded primitive compositions, inserts ptype adapters, -solves primitive parameters, and returns the ranked `Candidate`. A `RouteLeg` -then attaches the candidate to its copied source port and Tool. Preparation is -the layer above selection: only chosen offers are committed into `RenderStep` -payloads and returned in a `PreparedRouteResult` for Pather to apply. - -These named stages are architectural boundaries even though most of their -implementation is currently co-located in this module. File layout is an -internal organization choice; the distinction between normalized request, -pure selection, prepared result, and live application is the meaningful -mutation boundary. - -All search is performed in Tool-local route coordinates. The active input port -is at the origin, travel is along +x, and positive jog is to the left. After a -candidate is selected, committed steps are transformed back into layout-space -using the copied starting port. -""" -from __future__ import annotations - -# ruff: noqa: ANN401,PLR0912,PLR0913,PLR0915,TC001,TC002,TC003 - -from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass, replace -from itertools import combinations -from math import isclose as math_isclose -from typing import Any, Literal - -import numpy -from numpy import pi -from numpy.typing import ArrayLike - -from ...error import BuildError, PortError -from ...ports import Port -from ...utils import PTypeMatch, SupportsBool, ptype_match, ptypes_compatible, rotation_matrix_2d -from ..tools import ( - BendOffer, - PrimitiveKind, - PrimitiveOffer, - RenderStep, - SOffer, - StraightOffer, - Tool, - UOffer, - ) -from ..error import ( - MinimumStatus, - RouteError, - RouteFailureDetails, - RouteFailurePolicy, - RouteOperation, - ToolContractError, - ) -from .._tolerances import scalar_close -from ..utils import ell -from . import bounds as planner_bounds -from .interface import ( - PreparedRouteAction, - PreparedRouteResult, - RoutePlanningError, - RoutePortContext, - route_failure_policy, - ) - -RouteTieBreakStrategy = Literal['straight_first', 'turn_first'] -TraceIntoBendPolicy = Literal['flexible', 'minimal'] -COST_RTOL = 1e-10 -COST_ATOL = 1e-8 - - -class NoLegalRouteError(BuildError): - """Internal marker for exhaustive candidate-selection failure.""" - - -def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStrategy: - """Return a supported route tie-break strategy or raise a routing error.""" - if strategy in ('straight_first', 'turn_first'): - return strategy - raise BuildError(f'Invalid route strategy {strategy!r}; expected straight_first or turn_first') - - -def validate_trace_into_bend_policy( - bend_policy: TraceIntoBendPolicy | str, - ) -> TraceIntoBendPolicy: - """Return a supported trace-into bend policy or raise a routing error.""" - if bend_policy in ('flexible', 'minimal'): - return bend_policy - raise BuildError( - f'Invalid trace_into bend policy {bend_policy!r}; expected flexible or minimal' - ) - - -def is_close(a: float, b: float) -> bool: - """Compare route-solver scalars with the planner tolerance.""" - return scalar_close(a, b) - - -def costs_equal(a: float, b: float) -> bool: - """Treat sub-resolution solver noise as equal during cost ranking.""" - return math_isclose(float(a), float(b), rel_tol=COST_RTOL, abs_tol=COST_ATOL) - - -def clean_parameter(value: float) -> float: - """Snap tiny solver noise in primitive parameters before domain checks.""" - rounded = round(float(value)) - if abs(float(value) - rounded) <= 1e-8: - return float(rounded) - if abs(float(value)) <= 1e-10: - return 0.0 - return float(value) - - -def minimum_parameter(offer: PrimitiveOffer, route_name: str) -> float: - """Return an offer's deterministic minimum legal parameter.""" - lower, upper = offer.parameter_domain - if lower != upper and lower >= upper: - raise BuildError(f'{route_name} primitive has an invalid parameter domain {offer.parameter_domain}') - if not numpy.isfinite(lower): - raise BuildError(f'{route_name} primitive has no finite minimum parameter') - return offer.canonicalize_parameter(lower) - - -def minimum_nonzero_parameters(offer: PrimitiveOffer) -> tuple[float, ...]: - """Return deterministic nonzero endpoint parameters near an offer domain edge.""" - lower, upper = offer.parameter_domain - if lower == upper: - try: - value = offer.canonicalize_parameter(lower) - except BuildError: - return () - return () if is_close(value, 0) else (value,) - - candidates: list[float] = [] - if lower > 0 and numpy.isfinite(lower): - candidates.append(float(lower)) - if upper < 0 and numpy.isfinite(upper): - candidates.append(float(numpy.nextafter(upper, -numpy.inf))) - - selected: list[float] = [] - for value in candidates: - try: - parameter = offer.canonicalize_parameter(value) - except BuildError: - continue - if not is_close(parameter, 0) and not any(is_close(parameter, prev) for prev in selected): - selected.append(parameter) - return tuple(selected) - - -def adapter_s_parameter(offer: PrimitiveOffer, residual_jog: float) -> float: - """Choose a deterministic S-adapter parameter, preferring residual-jog direction.""" - candidates = minimum_nonzero_parameters(offer) - if not candidates: - raise BuildError('S adapter has no finite deterministic parameter') - - residual_sign = numpy.sign(residual_jog) - - def key(item: tuple[int, float]) -> tuple[float, int, int]: - index, value = item - sign = numpy.sign(value) - sign_rank = 0 if is_close(residual_jog, 0) or sign == residual_sign else 1 - return round(abs(value), 9), sign_rank, index - - return min(enumerate(candidates), key=key)[1] - - -def is_adapter_offer(offer: PrimitiveOffer) -> bool: - """Return true for straight/S offers that intentionally change concrete ptype.""" - return ( - isinstance(offer, StraightOffer | SOffer) - and ptype_match(offer.in_ptype, offer.in_ptype) is PTypeMatch.EXACT - and ptype_match(offer.out_ptype, offer.out_ptype) is PTypeMatch.EXACT - and ptype_match(offer.in_ptype, offer.out_ptype) is PTypeMatch.MISMATCH - ) - - -def raise_if_fatal(err: Exception) -> None: - """Propagate fatal planning errors while allowing normal candidate rejection.""" - if route_failure_policy(err) is RouteFailurePolicy.FATAL: - raise err - - -def solve_small_lstsq( - matrix: Sequence[Sequence[float]], - residual: Sequence[float], - ) -> tuple[float, ...] | None: - """ - Solve tiny least-squares systems without always paying NumPy setup cost. - - Route parameter solving only uses one or two constraints and one or two - adjustable primitive parameters. Closed forms keep common cases simple; - NumPy remains the fallback for degenerate or future larger systems. - """ - rows = len(matrix) - cols = len(matrix[0]) if rows else 0 - if rows == 1 and cols == 1: - a = matrix[0][0] - return None if a == 0 else (residual[0] / a,) - if rows == 1 and cols == 2: - a, b = matrix[0] - denom = a * a + b * b - return None if denom == 0 else (residual[0] * a / denom, residual[0] * b / denom) - if rows == 2 and cols == 1: - a = matrix[0][0] - b = matrix[1][0] - denom = a * a + b * b - return None if denom == 0 else ((a * residual[0] + b * residual[1]) / denom,) - if rows == 2 and cols == 2: - a, b = matrix[0] - c, d = matrix[1] - determinant = a * d - b * c - if determinant != 0: - return ( - (d * residual[0] - b * residual[1]) / determinant, - (-c * residual[0] + a * residual[1]) / determinant, - ) - matrix_array = numpy.array(matrix) - deltas, _residuals, _rank, _singular = numpy.linalg.lstsq(matrix_array, numpy.array(residual), rcond=None) - return tuple(float(delta) for delta in deltas) - - -@dataclass(frozen=True, slots=True) -class SelectedPrimitive: - """ - One evaluated primitive offer in a candidate route. - - `out_port` is still in route-local coordinates. `role` distinguishes - primitives that satisfy the requested route shape from ptype adapters that - the grammar may insert around those primitives. - """ - offer: PrimitiveOffer - """Offer selected for this primitive step.""" - parameter: float - """Canonicalized offer parameter used for endpoint, cost, and commit.""" - out_port: Port - """Route-local endpoint produced by the selected offer.""" - cost: float - """Finite additive planning cost reported by the offer.""" - role: Literal['main', 'adapter'] = 'main' - """Whether this step satisfies route geometry or adapts ptype.""" - - -@dataclass(frozen=True, slots=True) -class Candidate: - """A fully solved primitive sequence with its composed local endpoint.""" - steps: tuple[SelectedPrimitive, ...] - """Ordered primitive sequence selected by the grammar.""" - end_port: Port - """Composed route-local endpoint.""" - cost: float - """Sum of primitive costs.""" - order: int - """Deterministic discovery order used as the final tie-breaker.""" - public_length: float - """Length reported back to bundle planning for omitted-length anchors.""" - - -@dataclass(frozen=True, slots=True) -class SolverRequest: - """ - Normalized input for one `Solver` invocation and one route leg. - - Public Pather calls are converted into this smaller shape before grammar - enumeration. `length`, `jog`, and `out_ptype` become endpoint constraints; - `tool_options` are forwarded to Tool primitive-offer generation. - """ - family: PrimitiveKind - """High-level route family being solved.""" - tool: Tool - """Tool queried for primitive offers.""" - in_ptype: str | None - """Input ptype at the start of the route.""" - tool_options: Mapping[str, Any] - """Custom Tool kwargs forwarded to primitive-offer generation.""" - length: float | None = None - """Requested local x displacement, when constrained.""" - jog: float | None = None - """Requested local y displacement, when constrained.""" - ccw: SupportsBool | None = None - """Requested bend direction for single-bend routes.""" - out_ptype: str | None = None - """Requested final endpoint ptype.""" - constrain_jog: bool = False - """Whether bend-family trace_into routes must also match `jog`.""" - max_bends: int | None = None - """Optional override for grammar bend budget.""" - strategy: RouteTieBreakStrategy = 'straight_first' - """Final topology tie-break preference for straight-vs-turn placement.""" - - @property - def route_name(self) -> str: - if self.family in ('straight', 'bend'): - return 'trace' - if self.family == 's': - return 'S-bend' - return 'U-turn' - - @property - def out_rotation(self) -> float: - if self.family == 'straight': - return pi - if self.family == 'bend': - return -pi / 2 if bool(self.ccw) else pi / 2 - if self.family == 's': - return pi - return 0.0 - - @property - def bend_budget(self) -> int: - if self.max_bends is not None: - return self.max_bends - if self.family == 'straight': - return 0 - if self.family == 'bend': - return 1 - return 2 - - -class Solver: - """ - Bounded grammar solver for composed primitive routes. - - The grammar is `A? (N A? (B|S|U) A?)* N A?`, where `A` is a ptype adapter, - `N` is a normal straight-like primitive, and the middle term is either a - bend primitive, a Tool-provided S/U primitive, or a composed S/U route made - from bend primitives. Parameter solving happens after a sequence is - enumerated so fixed and adjustable offers share the same path. - """ - - def __init__(self, request: SolverRequest) -> None: - self.request = request - self.eval_cache: dict[tuple[int, float, str | None, str | None, str, str], SelectedPrimitive] = {} - self.offer_cache: dict[ - tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]], - tuple[PrimitiveOffer, ...], - ] = {} - self.seen_candidate_keys: set[tuple[Any, ...]] = set() - self.order = 0 - - @staticmethod - def route_bend_count(steps: Sequence[SelectedPrimitive]) -> int: - """Return the route bend budget consumed by non-adapter primitives.""" - count = 0 - for step in steps: - if step.role == 'adapter': - continue - if step.offer.kind == 'bend': - count += 1 - elif step.offer.kind in ('s', 'u'): - count += 2 - return count - - def strategy_rank(self, steps: Sequence[SelectedPrimitive]) -> tuple[int, ...]: - """Lexicographically rank every main straight-vs-turn placement.""" - preferred_kind = 'straight' if self.request.strategy == 'straight_first' else 'turn' - return tuple( - int(('straight' if step.offer.kind == 'straight' else 'turn') != preferred_kind) - for step in steps - if step.role != 'adapter' - ) - - def candidate_key(self, candidate: Candidate) -> tuple[Any, ...]: - """Return a deterministic key for duplicate solved candidates.""" - def endpoint_key(port: Port) -> tuple[float, float, float | None, str | None]: - return ( - round(float(port.x), 9), - round(float(port.y), 9), - None if port.rotation is None else round(float(port.rotation), 9), - port.ptype, - ) - - def offer_key(offer: PrimitiveOffer) -> tuple[Any, ...]: - cost_key: tuple[str, float | int] - if callable(offer.cost): - cost_key = ('callable', id(offer.cost)) - else: - cost_key = ('factor', round(float(offer.cost), 9)) - return ( - type(offer).__qualname__, - offer.in_ptype, - offer.out_ptype, - cost_key, - tuple(round(float(value), 9) for value in offer.parameter_domain), - getattr(offer, 'ccw', None), - id(offer.endpoint_planner), - id(offer.commit_planner), - ) - - return ( - endpoint_key(candidate.end_port), - tuple(( - offer_key(step.offer), - step.role, - round(float(step.parameter), 9), - endpoint_key(step.out_port), - ) for step in candidate.steps), - ) - - def solve( - self, - *, - min_bends: int = 0, - max_bends: int | None = None, - ) -> Candidate: - """ - Enumerate, finalize, deduplicate, and rank legal candidates. - - Non-fatal candidate errors are accumulated so the failure message can - preserve useful Tool feedback. Fatal offer-contract errors stop the - solve immediately. - """ - if max_bends is None: - max_bends = self.request.bend_budget - candidates: list[Candidate] = [] - errors: list[Exception] = [] - for steps in self.enumerate_grammar(max_bends): - if not steps: - continue - if self.route_bend_count(steps) < min_bends: - continue - if any(first.role == 'adapter' and second.role == 'adapter' for first, second in zip(steps, steps[1:], strict=False)): - continue - try: - candidate = self.finalize(steps) - except (BuildError, NotImplementedError, PortError) as err: - raise_if_fatal(err) - errors.append(err) - continue - key = self.candidate_key(candidate) - if key in self.seen_candidate_keys: - continue - self.seen_candidate_keys.add(key) - candidates.append(candidate) - - if not candidates: - for err in errors: - if route_failure_policy(err) is RouteFailurePolicy.FATAL: - raise err - if errors: - last_error = errors[-1] - if self.request.route_name in str(last_error): - raise NoLegalRouteError(str(last_error)) from last_error - raise NoLegalRouteError( - f'{self.request.route_name} route is unsupported: {last_error}' - ) from last_error - raise NoLegalRouteError(f'No legal primitive offer for {self.request.route_name}') - - minimum_cost = min(candidate.cost for candidate in candidates) - cost_tied = [candidate for candidate in candidates if costs_equal(candidate.cost, minimum_cost)] - return min( - cost_tied, - key=lambda candidate: ( - sum(step.role == 'adapter' for step in candidate.steps), - len(candidate.steps), - self.strategy_rank(candidate.steps), - candidate.order, - ), - ) - - def primitive_offers( - self, - kind: PrimitiveKind, - in_ptype: str | None, - *, - out_ptype: str | None = None, - extra: Mapping[str, Any] | None = None, - ) -> tuple[PrimitiveOffer, ...]: - """Query the active Tool with custom planning options and internal overrides.""" - kwargs = dict(self.request.tool_options) - if extra: - kwargs.update(extra) - - def query_tool() -> tuple[PrimitiveOffer, ...]: - expected_offer_type = { - 'straight': StraightOffer, - 'bend': BendOffer, - 's': SOffer, - 'u': UOffer, - }[kind] - offers = self.request.tool.primitive_offers( - kind, - in_ptype=in_ptype, - out_ptype=out_ptype, - **kwargs, - ) - if not isinstance(offers, tuple): - raise ToolContractError( - f'Tool.primitive_offers({kind!r}) must return a tuple, ' - f'got {type(offers).__name__}' - ) - for index, offer in enumerate(offers): - if not isinstance(offer, PrimitiveOffer): - raise ToolContractError( - f'Tool.primitive_offers({kind!r}) item {index} must be a PrimitiveOffer, ' - f'got {type(offer).__name__}' - ) - if offer.kind != kind: - raise ToolContractError( - f'Tool.primitive_offers({kind!r}) item {index} returned ' - f'{offer.kind!r} offer' - ) - if not isinstance(offer, expected_offer_type): - raise ToolContractError( - f'Tool.primitive_offers({kind!r}) item {index} must be ' - f'{expected_offer_type.__name__}, got {type(offer).__name__}' - ) - return offers - - extra_items = tuple(sorted((extra or {}).items())) - try: - cache_key = (kind, in_ptype, out_ptype, extra_items) - hash(cache_key) - except TypeError: - return query_tool() - - cached = self.offer_cache.get(cache_key) - if cached is not None: - return cached - offers = query_tool() - self.offer_cache[cache_key] = offers - return offers - - def evaluate( - self, - offer: PrimitiveOffer, - parameter: float, - in_ptype: str | None, - *, - out_ptype: str | None, - role: Literal['main', 'adapter'], - route_name: str | None = None, - ) -> SelectedPrimitive: - """ - Canonicalize and validate one offer evaluation. - - This is the single point where the solver checks ptype compatibility, - endpoint declarations, selected endpoint ptype, finite cost, and - zero-jog S rejection. - """ - route_name = self.request.route_name if route_name is None else route_name - selected = offer.canonicalize_parameter(clean_parameter(parameter)) - key = (id(offer), round(float(selected), 12), in_ptype, out_ptype, role, route_name) - cached = self.eval_cache.get(key) - if cached is not None: - return cached - - if not ptypes_compatible(in_ptype, offer.in_ptype): - raise BuildError('primitive input ptype is incompatible') - if isinstance(offer, SOffer) and is_close(selected, 0): - raise BuildError('zero-jog S primitive candidates are not allowed') - out_port = offer.endpoint_at(selected) - if not isinstance(out_port, Port): - raise ToolContractError( - f'{route_name} primitive endpoint_at() must return a Port, ' - f'got {type(out_port).__name__}' - ) - if not ptypes_compatible(out_port.ptype, offer.out_ptype): - raise ToolContractError( - f'{route_name} primitive endpoint ptype does not match declared offer out_ptype', - ) - if out_ptype is not None and not ptypes_compatible(out_port.ptype, out_ptype): - raise RoutePlanningError( - 'Requested out_ptype does not match primitive endpoint ptype', - policy=RouteFailurePolicy.FATAL, - ) - try: - if type(offer).cost_at is PrimitiveOffer.cost_at: - cost = float(PrimitiveOffer._cost_for_endpoint(offer, selected, out_port)) - else: - cost = float(offer.cost_at(selected)) - except (TypeError, ValueError, OverflowError) as err: - raise ToolContractError(f'{route_name} primitive returned a non-numeric cost') from err - if not numpy.isfinite(cost): - raise ToolContractError(f'{route_name} primitive returned non-finite cost') - if cost < 0: - raise ToolContractError(f'{route_name} primitive returned negative cost') - primitive = SelectedPrimitive( - offer, - selected, - out_port, - cost, - role=role, - ) - self.eval_cache[key] = primitive - return primitive - - def compose_endpoint(self, steps: Sequence[SelectedPrimitive]) -> Port: - """ - Compose local primitive endpoints into one local route endpoint. - - Primitive output rotations follow Masque's port convention: the port - points back into the primitive, so each step advances orientation by - the primitive output rotation plus pi. - """ - x = 0.0 - y = 0.0 - angle = 0.0 - ptype: str | None = None - for step in steps: - out_port = step.out_port - if out_port.rotation is None: - raise ToolContractError('Primitive endpoints must have rotation') - rotation = rotation_matrix_2d(angle) - out_x = float(out_port.x) - out_y = float(out_port.y) - x += rotation[0, 0] * out_x + rotation[0, 1] * out_y - y += rotation[1, 0] * out_x + rotation[1, 1] * out_y - angle += out_port.rotation + pi - ptype = out_port.ptype - return Port((x, y), rotation=angle - pi, ptype=ptype) - - def current_ptype(self, steps: Sequence[SelectedPrimitive]) -> str | None: - return self.request.in_ptype if not steps else self.compose_endpoint(steps).ptype - - def adapter_options( - self, - steps: Sequence[SelectedPrimitive], - *, - residual_jog: float, - ) -> tuple[tuple[SelectedPrimitive, ...], ...]: - """Return no-adapter plus single straight/S ptype adapter options.""" - current_ptype = self.current_ptype(steps) - options: list[tuple[SelectedPrimitive, ...]] = [()] - for kind in ('straight', 's'): - try: - offers = self.primitive_offers(kind, current_ptype, out_ptype=None) - except NotImplementedError: - continue - for offer in offers: - if not is_adapter_offer(offer): - continue - try: - parameter = ( - minimum_parameter(offer, 'straight adapter') - if kind == 'straight' - else adapter_s_parameter(offer, residual_jog) - ) - selected = self.evaluate( - offer, - parameter, - current_ptype, - out_ptype=None, - role='adapter', - route_name=f'{kind} adapter', - ) - except BuildError as err: - raise_if_fatal(err) - continue - except NotImplementedError: - continue - options.append((selected,)) - return tuple(options) - - def straight_options( - self, - steps: Sequence[SelectedPrimitive], - ) -> tuple[tuple[SelectedPrimitive, ...], ...]: - """Return no-straight plus minimum-parameter non-adapter straight options.""" - current_ptype = self.current_ptype(steps) - options: list[tuple[SelectedPrimitive, ...]] = [()] - try: - offers = self.primitive_offers('straight', current_ptype, out_ptype=None) - except NotImplementedError: - return tuple(options) - for offer in offers: - if is_adapter_offer(offer): - continue - try: - parameter = minimum_parameter(offer, 'trace') - selected = self.evaluate( - offer, - parameter, - current_ptype, - out_ptype=None, - role='main', - route_name='trace', - ) - except BuildError as err: - raise_if_fatal(err) - continue - except NotImplementedError: - continue - options.append((selected,)) - return tuple(options) - - def bend_options( - self, - steps: Sequence[SelectedPrimitive], - ccw: SupportsBool, - ) -> tuple[tuple[SelectedPrimitive, ...], ...]: - """Return legal fixed-direction bend options for the current ptype.""" - current_ptype = self.current_ptype(steps) - options: list[tuple[SelectedPrimitive, ...]] = [] - try: - offers = self.primitive_offers('bend', current_ptype, out_ptype=None, extra={'ccw': ccw}) - except NotImplementedError: - return () - for offer in offers: - if not isinstance(offer, BendOffer): - continue - if bool(offer.ccw) != bool(ccw): - continue - try: - parameter = minimum_parameter(offer, 'trace') - selected = self.evaluate( - offer, - parameter, - current_ptype, - out_ptype=None, - role='main', - route_name='trace', - ) - except BuildError as err: - raise_if_fatal(err) - continue - except NotImplementedError: - continue - options.append((selected,)) - return tuple(options) - - def su_primitive_options( - self, - steps: Sequence[SelectedPrimitive], - kind: Literal['s', 'u'], - jog: float | None = None, - ) -> tuple[tuple[SelectedPrimitive, ...], ...]: - """Return Tool-provided S/U primitive options for candidate jogs.""" - current_ptype = self.current_ptype(steps) - options: list[tuple[SelectedPrimitive, ...]] = [] - try: - offers = self.primitive_offers(kind, current_ptype, out_ptype=None) - except NotImplementedError: - return () - route_name = 'S-bend' if kind == 's' else 'U-turn' - for offer in offers: - if kind == 's' and not isinstance(offer, SOffer): - continue - for parameter in self.su_parameters(offer, kind, jog): - try: - selected = self.evaluate( - offer, - parameter, - current_ptype, - out_ptype=None, - role='main', - route_name=route_name, - ) - except BuildError as err: - raise_if_fatal(err) - continue - except NotImplementedError: - continue - options.append((selected,)) - return tuple(options) - - def su_parameters( - self, - offer: PrimitiveOffer, - kind: Literal['s', 'u'], - requested_jog: float | None, - ) -> tuple[float, ...]: - """Build deterministic jog-parameter probes for Tool-provided S/U offers.""" - candidates: list[float] = [] - if requested_jog is not None and (kind == 'u' or not is_close(requested_jog, 0)): - candidates.append(float(requested_jog)) - if kind == 'u': - lower, _upper = offer.parameter_domain - if numpy.isfinite(lower): - candidates.append(float(lower)) - else: - candidates.append(0.0) - candidates.extend(minimum_nonzero_parameters(offer)) - - selected: list[float] = [] - for candidate in candidates: - try: - parameter = offer.canonicalize_parameter(candidate) - except BuildError: - continue - if kind == 's' and is_close(parameter, 0): - continue - if not any(is_close(parameter, existing) for existing in selected): - selected.append(parameter) - return tuple(selected) - - def turn_options( - self, - steps: Sequence[SelectedPrimitive], - remaining_bends: int, - ) -> tuple[tuple[tuple[SelectedPrimitive, ...], int], ...]: - """Return bend-family options paired with their consumed bend budget.""" - options: list[tuple[tuple[SelectedPrimitive, ...], int]] = [] - if remaining_bends >= 1: - for ccw in (False, True): - options.extend((turn, 1) for turn in self.bend_options(steps, ccw)) - if remaining_bends >= 2: - jog = self.request.jog - options.extend((turn, 2) for turn in self.su_primitive_options(steps, 's', jog)) - options.extend((turn, 2) for turn in self.su_primitive_options(steps, 'u', jog)) - return tuple(options) - - def enumerate_grammar(self, max_bends: int) -> Iterable[tuple[SelectedPrimitive, ...]]: - """Yield raw primitive sequences allowed by the bounded route grammar.""" - residual_jog = 0.0 if self.request.jog is None else float(self.request.jog) - base: tuple[SelectedPrimitive, ...] = () - for prefix_adapter in self.adapter_options(base, residual_jog=residual_jog): - prefix = (*base, *prefix_adapter) - yield from self.enumerate_segments(prefix, max_bends, residual_jog=residual_jog) - - def enumerate_segments( - self, - steps: tuple[SelectedPrimitive, ...], - remaining_bends: int, - *, - residual_jog: float, - ) -> Iterable[tuple[SelectedPrimitive, ...]]: - """Recursively enumerate normal/adapter/turn blocks within the bend budget.""" - for normal in self.straight_options(steps): - after_normal = (*steps, *normal) - suffix_options = ( - self.adapter_options(after_normal, residual_jog=0) - if self.request.out_ptype is not None - else ((),) - ) - for suffix in suffix_options: - yield (*after_normal, *suffix) - - if remaining_bends <= 0: - continue - for core_adapter in self.adapter_options(after_normal, residual_jog=residual_jog): - before_core = (*after_normal, *core_adapter) - for turn, bend_count in self.turn_options(before_core, remaining_bends): - after_turn = (*before_core, *turn) - for post_adapter in self.adapter_options(after_turn, residual_jog=residual_jog): - yield from self.enumerate_segments( - (*after_turn, *post_adapter), - remaining_bends - bend_count, - residual_jog=residual_jog, - ) - - def adjustable_indices(self, steps: Sequence[SelectedPrimitive]) -> tuple[int, ...]: - """Return non-adapter primitive indices whose parameter can still move.""" - adjustable: list[int] = [] - for index, step in enumerate(steps): - if step.role == 'adapter': - continue - parameter = step.parameter - lower, upper = step.offer.parameter_domain - probes = [ - parameter + max(1e-6, abs(parameter) * 1e-6), - parameter - max(1e-6, abs(parameter) * 1e-6), - ] - if numpy.isfinite(upper): - probes.append(numpy.nextafter(upper, -numpy.inf)) - if numpy.isfinite(lower): - probes.append(lower) - for probe in probes: - try: - step.offer.canonicalize_parameter(probe) - except BuildError: - continue - if abs(float(probe) - float(parameter)) > 1e-12: - adjustable.append(index) - break - return tuple(adjustable) - - def reevaluate(self, steps: Sequence[SelectedPrimitive], parameters: Sequence[float]) -> tuple[SelectedPrimitive, ...]: - """Re-evaluate a primitive sequence with new parameters and flowing ptypes.""" - selected: list[SelectedPrimitive] = [] - current_ptype = self.request.in_ptype - for step, parameter in zip(steps, parameters, strict=True): - selected_step = self.evaluate( - step.offer, - parameter, - current_ptype, - out_ptype=None, - role=step.role, - ) - selected.append(selected_step) - current_ptype = selected_step.out_port.ptype - return tuple(selected) - - def solve_parameters( - self, - steps: Sequence[SelectedPrimitive], - solve_indices: Sequence[int], - constraints: Sequence[tuple[Literal['x', 'y'], float]], - ) -> tuple[tuple[SelectedPrimitive, ...], Port] | None: - """ - Adjust selected primitive parameters to satisfy endpoint constraints. - - The solver estimates each adjustable parameter's local linear effect by - probing the composed endpoint, solves the tiny least-squares system, - then re-evaluates with canonicalized parameters. - """ - parameters = [step.parameter for step in steps] - for _iteration in range(3): - selected_steps = self.reevaluate(steps, parameters) - base_end = self.compose_endpoint(selected_steps) - if not solve_indices: - return selected_steps, base_end - - matrix = [[0.0 for _index in solve_indices] for _constraint in constraints] - for column, solve_index in enumerate(solve_indices): - step = steps[solve_index] - parameter = parameters[solve_index] - probe = parameter + max(1e-6, abs(parameter) * 1e-6) - try: - probe = step.offer.canonicalize_parameter(probe) - except BuildError: - probe = numpy.nextafter(parameter, -numpy.inf) - try: - probe = step.offer.canonicalize_parameter(probe) - except BuildError: - return None - if abs(float(probe) - float(parameter)) <= 1e-12: - return None - probe_parameters = list(parameters) - probe_parameters[solve_index] = probe - probe_steps = self.reevaluate(steps, probe_parameters) - probe_end = self.compose_endpoint(probe_steps) - for row, (axis, _target) in enumerate(constraints): - matrix[row][column] = (float(getattr(probe_end, axis)) - float(getattr(base_end, axis))) / (probe - parameter) - - residual = [target - float(getattr(base_end, axis)) for axis, target in constraints] - if all(abs(value) <= 1e-9 for value in residual): - return selected_steps, base_end - deltas = solve_small_lstsq(matrix, residual) - if deltas is None: - return None - changed = False - for solve_index, delta in zip(solve_indices, deltas, strict=True): - parameter = steps[solve_index].offer.canonicalize_parameter( - clean_parameter(parameters[solve_index] + float(delta)), - ) - changed = changed or abs(parameter - parameters[solve_index]) > 1e-12 - parameters[solve_index] = parameter - if not changed: - return selected_steps, base_end - selected_steps = self.reevaluate(steps, parameters) - return selected_steps, self.compose_endpoint(selected_steps) - - def endpoint_matches( - self, - end_port: Port, - constraints: Sequence[tuple[Literal['x', 'y'], float]], - ) -> bool: - """Return true when a composed endpoint satisfies requested position, rotation, and ptype.""" - for axis, target in constraints: - if not is_close(getattr(end_port, axis), target): - return False - if end_port.rotation is None: - return False - rotation_delta = (float(end_port.rotation) - self.request.out_rotation) % (2 * pi) - if not (is_close(rotation_delta, 0) or is_close(rotation_delta, 2 * pi)): - return False - return self.request.out_ptype is None or ptypes_compatible(end_port.ptype, self.request.out_ptype) - - def rotation_matches_request(self, steps: Sequence[SelectedPrimitive]) -> bool: - """ - Return true when a sequence can produce the requested output rotation. - - Primitive parameters do not affect the rotation contract, so - rotation-impossible candidates can be rejected before parameter solving. - """ - angle = 0.0 - for step in steps: - step_rotation = step.out_port.rotation - if step_rotation is None: - raise BuildError('Primitive endpoints must have rotation') - angle += float(step_rotation) + pi - rotation_delta = ((angle - pi) - self.request.out_rotation) % (2 * pi) - return is_close(rotation_delta, 0) or is_close(rotation_delta, 2 * pi) - - def finalize(self, steps: Sequence[SelectedPrimitive]) -> Candidate: - """ - Try all small solve sets for one raw sequence and return the cheapest match. - - Recoverable failures reject only the current solve set. Solve-set order - breaks ties between equally priced parameter allocations. - """ - constraints: list[tuple[Literal['x', 'y'], float]] = [] - if self.request.length is not None: - constraints.append(('x', float(self.request.length))) - if self.request.family == 'straight': - constraints.append(('y', 0.0)) - elif self.request.family in ('s', 'u') or self.request.constrain_jog: - if self.request.jog is None: - raise BuildError(f'{self.request.route_name} route requires a jog constraint') - constraints.append(('y', float(self.request.jog))) - route_constraints = tuple(constraints) - if not self.rotation_matches_request(steps): - raise BuildError(f'{self.request.route_name} composed primitive route is unsupported') - adjustable = self.adjustable_indices(steps) - solve_sets: list[tuple[int, ...]] = [()] - max_solve = min(len(route_constraints), len(adjustable)) - for solve_size in range(1, max_solve + 1): - solve_sets.extend(combinations(adjustable, solve_size)) - - feasible: list[tuple[float, int, tuple[SelectedPrimitive, ...], Port]] = [] - errors: list[Exception] = [] - for solve_order, solve_indices in enumerate(solve_sets): - try: - solved = self.solve_parameters(steps, solve_indices, route_constraints) - except (BuildError, NotImplementedError, PortError) as err: - raise_if_fatal(err) - errors.append(err) - continue - if solved is None: - continue - selected_steps, end_port = solved - if not self.endpoint_matches(end_port, route_constraints): - continue - cost = sum(step.cost for step in selected_steps) - feasible.append((float(cost), solve_order, tuple(selected_steps), end_port)) - - if not feasible: - if errors: - raise errors[-1] - raise BuildError(f'{self.request.route_name} composed primitive route is unsupported') - - minimum_cost = min(result[0] for result in feasible) - cost_tied = [result for result in feasible if costs_equal(result[0], minimum_cost)] - cost, _solve_order, selected_steps, end_port = min(cost_tied, key=lambda result: result[1]) - order = self.order - self.order += 1 - public_length = float(end_port.x) if self.request.length is None else float(self.request.length) - return Candidate(selected_steps, end_port, cost, order, public_length) - - -@dataclass(frozen=True, slots=True) -class RouteLeg: - """ - One solved route leg tied to the Pather port it will update. - - `start_port` is the copied route start used for all layout transforms. - `tool` is stored with the leg so prepared render steps cannot be stamped - with a mismatched Tool after bundle ordering. - """ - portspec: str - """Pather port name this leg will update.""" - start_port: Port - """Copied layout-space route start.""" - tool: Tool - """Tool used for all render steps in this leg.""" - candidate: Candidate - """Solved local primitive candidate.""" - plug_into: str | None = None - """Optional destination port to consume after applying the final endpoint.""" - - -class RoutingPlanner: - """ - Pather-facing stateless route-selection facade. - - Public Pather methods call this class with copied port contexts. Returned - `PreparedRouteResult`s contain only committed render steps, final ports, - plug targets, and renames needed for Pather state mutation. - """ - - TRACE_INTO_MAX_BENDS: int = 4 - DEFAULT_STRATEGY: RouteTieBreakStrategy = 'straight_first' - DEFAULT_TRACE_INTO_BEND_POLICY: TraceIntoBendPolicy = 'minimal' - - def __init__( - self, - strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY, - *, - bend_policy: TraceIntoBendPolicy = DEFAULT_TRACE_INTO_BEND_POLICY, - ) -> None: - self.strategy = validate_strategy(strategy) - self.bend_policy = validate_trace_into_bend_policy(bend_policy) - - def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy: - """Return the per-route strategy or the planner default.""" - if strategy is None: - return getattr(self, 'strategy', self.DEFAULT_STRATEGY) - return validate_strategy(strategy) - - def resolve_trace_into_bend_policy( - self, - bend_policy: TraceIntoBendPolicy | str | None, - ) -> TraceIntoBendPolicy: - """Return the per-route trace-into bend policy or the planner default.""" - if bend_policy is None: - return getattr(self, 'bend_policy', self.DEFAULT_TRACE_INTO_BEND_POLICY) - return validate_trace_into_bend_policy(bend_policy) - - def validate_plan_options( - self, - plan_options: Mapping[str, Any] | None, - *, - allow_bend_policy: bool = False, - ) -> dict[str, Any]: - """Copy and validate the default planner's per-route option mapping.""" - if plan_options is None: - return {} - try: - options = dict(plan_options) - except (TypeError, ValueError) as err: - raise BuildError('plan_options must be a mapping with string keys') from err - nonstring = [key for key in options if not isinstance(key, str)] - if nonstring: - raise BuildError(f'plan_options keys must be strings; got {nonstring!r}') - supported = {'strategy'} - if allow_bend_policy: - supported.add('bend_policy') - unsupported = sorted(options.keys() - supported) - if unsupported: - raise BuildError( - f'RoutingPlanner plan_options contains unsupported keys: {", ".join(unsupported)}' - ) - if options.get('strategy') is not None: - options['strategy'] = validate_strategy(options['strategy']) - if options.get('bend_policy') is not None: - options['bend_policy'] = validate_trace_into_bend_policy(options['bend_policy']) - return options - - def trace_into_bend_bands( - self, - family: PrimitiveKind, - *, - bend_policy: TraceIntoBendPolicy | str | None = None, - ) -> tuple[tuple[int, int], ...]: - """Return trace_into bend-budget bands for the requested detour policy.""" - max_bends = self.TRACE_INTO_MAX_BENDS - if self.resolve_trace_into_bend_policy(bend_policy) == 'minimal': - required_bends = 0 if family == 'straight' else 1 if family == 'bend' else 2 - return ((required_bends, required_bends),) if required_bends <= max_bends else () - if family == 'bend': - return tuple(band for band in ((1, 1), (3, 3)) if band[1] <= max_bends) - bands: list[tuple[int, int]] = [] - if max_bends >= 0: - bands.append((0, 2 if max_bends >= 2 else 0)) - if max_bends >= 4: - bands.append((4, 4)) - return tuple(bands) - - def solver_request( - self, - family: PrimitiveKind, - context: RoutePortContext, - *, - length: float | None = None, - jog: float | None = None, - ccw: SupportsBool | None = None, - constrain_jog: bool = False, - max_bends: int | None = None, - strategy: RouteTieBreakStrategy | str | None = None, - tool_options: Mapping[str, Any] | None = None, - **kwargs: Any, - ) -> SolverRequest: - """Build normalized solver input for one route leg.""" - return SolverRequest( - family=family, - tool=context.tool, - in_ptype=context.port.ptype, - tool_options={} if tool_options is None else dict(tool_options), - length=length, - jog=jog, - ccw=ccw, - out_ptype=kwargs.get('out_ptype'), - constrain_jog=constrain_jog, - max_bends=max_bends, - strategy=self.resolve_strategy(strategy), - ) - - def solver_for_request(self, request: SolverRequest) -> Solver: - """Construct the solver for a route request.""" - return Solver(request) - - def route_leg_from_candidate( - self, - context: RoutePortContext, - candidate: Candidate, - *, - plug_into: str | None, - ) -> RouteLeg: - """Attach a solved candidate to its source Pather context.""" - return RouteLeg( - portspec=context.portspec, - start_port=context.port.copy(), - tool=context.tool, - candidate=candidate, - plug_into=plug_into, - ) - - def plan_leg( - self, - family: PrimitiveKind, - context: RoutePortContext, - operation: RouteOperation | None = None, - diagnostic_request: Mapping[str, Any] | None = None, - /, - *, - length: float | None = None, - jog: float | None = None, - ccw: SupportsBool | None = None, - plug_into: str | None = None, - constrain_jog: bool = False, - max_bends: int | None = None, - strategy: RouteTieBreakStrategy | str | None = None, - tool_options: Mapping[str, Any] | None = None, - **kwargs: Any, - ) -> RouteLeg: - """Solve one route leg and attach it to its source Pather context.""" - if operation is None: - if family in ('straight', 'bend'): - operation = 'trace' - elif family == 's': - operation = 'jog' - else: - operation = 'uturn' - if diagnostic_request is None: - diagnostic_request = {} - - if length is not None and (not numpy.isfinite(length) or length < 0): - details = RouteFailureDetails( - operation=operation, - portspec=context.portspec, - in_ptype=context.port.ptype, - out_ptype=kwargs.get('out_ptype'), - request=diagnostic_request, - resolved_length=float(length), - resolved_jog=None if jog is None else float(jog), - minimum_length=None, - minimum_status=MinimumStatus.NOT_EVALUATED, - cause='Resolved route length must be finite and nonnegative', - ) - raise RouteError(details, policy=RouteFailurePolicy.FATAL) - - request = self.solver_request( - family=family, - context=context, - length=length, - jog=jog, - ccw=ccw, - constrain_jog=constrain_jog, - max_bends=max_bends, - strategy=strategy, - tool_options=tool_options, - **kwargs, - ) - try: - candidate = self.solver_for_request(request).solve() - except NoLegalRouteError as err: - cause = ( - 'No legal primitive offer for omitted-length U-turn' - if family == 'u' and length is None - else str(err) - ) - - minimum_length: float | None = None - minimum_cause: str | None = None - minimum_status: MinimumStatus - if length is None: - minimum_cause = cause - minimum_status = MinimumStatus.NO_ROUTE - else: - minimum_request = replace(request, length=None) - try: - minimum_candidate = self.solver_for_request(minimum_request).solve() - minimum_length = minimum_candidate.public_length - minimum_status = MinimumStatus.FOUND - except NoLegalRouteError as minimum_err: - minimum_cause = str(minimum_err) - minimum_status = MinimumStatus.NO_ROUTE - except Exception as minimum_err: - if route_failure_policy(minimum_err) is RouteFailurePolicy.FATAL: - raise - minimum_cause = str(minimum_err) - minimum_status = MinimumStatus.FAILED - - details = RouteFailureDetails( - operation=operation, - portspec=context.portspec, - in_ptype=context.port.ptype, - out_ptype=request.out_ptype, - request=diagnostic_request, - resolved_length=None if length is None else float(length), - resolved_jog=None if jog is None else float(jog), - minimum_length=minimum_length, - minimum_status=minimum_status, - cause=cause, - minimum_cause=minimum_cause, - ) - raise RouteError(details) from err - return self.route_leg_from_candidate(context, candidate, plug_into=plug_into) - - def prepared_route_action_from_leg( - self, - leg: RouteLeg, - ) -> PreparedRouteAction: - """ - Convert a solved leg into committed render steps and a final live port. - - Offer commits happen here, after route selection succeeds. Each - primitive endpoint is transformed from route-local coordinates using - the previous step's layout-space output port. - """ - current = leg.start_port.copy() - render_steps: list[RenderStep] = [] - for selected in leg.candidate.steps: - port_rot = current.rotation - if port_rot is None: - raise PortError('Ports must have rotation') - out_port = selected.out_port.copy() - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(current.offset) - render_steps.append(RenderStep( - selected.offer.kind, - leg.tool, - current.copy(), - out_port.copy(), - selected.offer.commit(selected.parameter), - )) - current = out_port - if not render_steps: - raise BuildError('Route leg has no primitive steps') - return PreparedRouteAction( - portspec=leg.portspec, - render_steps=tuple(render_steps), - final_port=current.copy(), - plug_into=leg.plug_into, - ) - - def prepared_result_from_legs( - self, - legs: Sequence[RouteLeg], - *, - renames: tuple[tuple[str, str], ...] = (), - ) -> PreparedRouteResult: - """Build a prepared result from solved legs plus deferred port renames.""" - return PreparedRouteResult( - actions=tuple(self.prepared_route_action_from_leg(leg) for leg in legs), - renames=renames, - ) - - def plan_trace_route( - self, - contexts: Sequence[RoutePortContext], - ccw: SupportsBool | None, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - tool_options: Mapping[str, Any] | None = None, - **bounds: Any, - ) -> PreparedRouteResult: - """Plan straight or single-bend traces, including `each` and bundle-bound modes.""" - plan_opts = self.validate_plan_options(plan_options) - strategy = plan_opts.get('strategy') - route_bounds = dict(bounds) - request_details = {'ccw': ccw, **{ - key: value for key, value in route_bounds.items() if value is not None - }} - if length is not None: - request_details['length'] = length - if spacing is not None: - request_details['spacing'] = spacing - if plan_opts: - request_details['plan_options'] = dict(plan_opts) - if tool_options: - request_details['tool_options'] = dict(tool_options) - operation: RouteOperation = 'trace' - diagnostic_request = request_details - return self._plan_trace_route( - contexts, - ccw, - length, - operation, - diagnostic_request, - spacing=spacing, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ) - - def _plan_trace_route( - self, - contexts: Sequence[RoutePortContext], - ccw: SupportsBool | None, - length: float | None, - operation: RouteOperation, - diagnostic_request: Mapping[str, Any], - /, - *, - spacing: float | ArrayLike | None, - strategy: RouteTieBreakStrategy | str | None, - tool_options: Mapping[str, Any] | None, - **route_bounds: Any, - ) -> PreparedRouteResult: - portspec = tuple(context.portspec for context in contexts) - planner_bounds.validate_trace_args(portspec, length=length, spacing=spacing, bounds=route_bounds) - family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend' - if length is not None: - leg = self.plan_leg( - family, - contexts[0], - operation, - diagnostic_request, - length=length, - ccw=ccw, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ) - return self.prepared_result_from_legs((leg,)) - - if route_bounds.get('each') is not None: - each = route_bounds.pop('each') - return PreparedRouteResult(tuple( - self.prepared_route_action_from_leg( - self.plan_leg( - family, - context, - operation, - diagnostic_request, - length=each, - ccw=ccw, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ), - ) - for context in contexts - )) - - bundle_bounds = planner_bounds.present_bundle_bounds(route_bounds) - if not bundle_bounds: - leg = self.plan_leg( - family, - contexts[0], - operation, - diagnostic_request, - length=None, - ccw=ccw, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ) - return self.prepared_result_from_legs((leg,)) - - bound_type = bundle_bounds[0] - bound_value = route_bounds.pop(bound_type) - set_rotation = route_bounds.pop('set_rotation', None) - extensions = ell( - {context.portspec: context.port for context in contexts}, - ccw, - spacing=spacing, - bound=bound_value, - bound_type=bound_type, - set_rotation=set_rotation, - ) - actions = [] - for port_name, route_length in extensions.items(): - context = next(context for context in contexts if context.portspec == port_name) - leg = self.plan_leg( - family, - context, - operation, - diagnostic_request, - length=route_length, - ccw=ccw, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ) - actions.append(self.prepared_route_action_from_leg(leg)) - return PreparedRouteResult(tuple(actions)) - - def plan_trace_to_route( - self, - contexts: Sequence[RoutePortContext], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - tool_options: Mapping[str, Any] | None = None, - **bounds: Any, - ) -> PreparedRouteResult: - """Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes.""" - plan_opts = self.validate_plan_options(plan_options) - strategy = plan_opts.get('strategy') - route_bounds = dict(bounds) - request_details = {'ccw': ccw, **{ - key: value for key, value in route_bounds.items() if value is not None - }} - if spacing is not None: - request_details['spacing'] = spacing - if plan_opts: - request_details['plan_options'] = dict(plan_opts) - if tool_options: - request_details['tool_options'] = dict(tool_options) - operation: RouteOperation = 'trace_to' - diagnostic_request = request_details - return self._plan_trace_to_route( - contexts, - ccw, - operation, - diagnostic_request, - spacing=spacing, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ) - - def _plan_trace_to_route( - self, - contexts: Sequence[RoutePortContext], - ccw: SupportsBool | None, - operation: RouteOperation, - diagnostic_request: Mapping[str, Any], - /, - *, - spacing: float | ArrayLike | None, - strategy: RouteTieBreakStrategy | str | None, - tool_options: Mapping[str, Any] | None, - **route_bounds: Any, - ) -> PreparedRouteResult: - if len(contexts) == 1: - resolved = planner_bounds.resolved_position_bound(contexts[0].port, route_bounds, allow_length=False) - else: - resolved = None - if any(route_bounds.get(key) is not None for key in planner_bounds.POSITION_KEYS): - raise BuildError('Position bounds only allowed with a single port') - if resolved is None: - return self._plan_trace_route( - contexts, - ccw, - route_bounds.pop('length', None), - operation, - diagnostic_request, - spacing=spacing, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ) - - planner_bounds.validate_trace_to_positional_args(spacing=spacing, bounds=route_bounds) - _key, _value, length = resolved - other_bounds = { - key: value - for key, value in route_bounds.items() - if key not in planner_bounds.POSITION_KEYS and key != 'length' - } - family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend' - leg = self.plan_leg( - family, - contexts[0], - operation, - diagnostic_request, - length=length, - ccw=ccw, - strategy=strategy, - tool_options=tool_options, - **other_bounds, - ) - return self.prepared_result_from_legs((leg,)) - - def plan_jog_route( - self, - contexts: Sequence[RoutePortContext], - offset: float, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - tool_options: Mapping[str, Any] | None = None, - **bounds: Any, - ) -> PreparedRouteResult: - """Plan S-bend routes for single ports or spaced bundles.""" - plan_opts = self.validate_plan_options(plan_options) - strategy = plan_opts.get('strategy') - offset = planner_bounds.finite_scalar(offset, 'offset') - request_details = {'offset': offset, **{ - key: value for key, value in bounds.items() if value is not None - }} - if length is not None: - request_details['length'] = length - if spacing is not None: - request_details['spacing'] = spacing - if plan_opts: - request_details['plan_options'] = dict(plan_opts) - if tool_options: - request_details['tool_options'] = dict(tool_options) - operation: RouteOperation = 'jog' - diagnostic_request = request_details - if numpy.isclose(offset, 0): - return self._plan_trace_to_route( - contexts, - None, - operation, - diagnostic_request, - length=length, - spacing=spacing, - strategy=strategy, - tool_options=tool_options, - **bounds, - ) - route_bounds = dict(bounds) - portspec = tuple(context.portspec for context in contexts) - planner_bounds.validate_jog_args(portspec, length=length, spacing=spacing, bounds=route_bounds) - other_bounds = dict(route_bounds) - if length is None and len(contexts) == 1: - resolved = planner_bounds.resolved_position_bound(contexts[0].port, route_bounds, allow_length=True) - if resolved is not None: - _key, _value, length = resolved - other_bounds = {key: value for key, value in route_bounds.items() if key not in planner_bounds.POSITION_KEYS} - if len(contexts) > 1: - return PreparedRouteResult(tuple( - self.prepared_route_action_from_leg(leg) - for leg in self.plan_su_bundle_routes( - 's', - contexts, - offset, - length, - spacing, - operation, - diagnostic_request, - strategy=strategy, - tool_options=tool_options, - **other_bounds, - ) - )) - leg = self.plan_leg( - 's', - contexts[0], - operation, - diagnostic_request, - length=length, - jog=offset, - strategy=strategy, - tool_options=tool_options, - **other_bounds, - ) - return self.prepared_result_from_legs((leg,)) - - def plan_uturn_route( - self, - contexts: Sequence[RoutePortContext], - offset: float, - length: float | None = None, - *, - spacing: float | ArrayLike | None = None, - plan_options: Mapping[str, Any] | None = None, - tool_options: Mapping[str, Any] | None = None, - **bounds: Any, - ) -> PreparedRouteResult: - """Plan U-turn routes for single ports or spaced bundles.""" - plan_opts = self.validate_plan_options(plan_options) - strategy = plan_opts.get('strategy') - offset = planner_bounds.finite_scalar(offset, 'offset') - route_bounds = dict(bounds) - request_details = {'offset': offset, **{ - key: value for key, value in route_bounds.items() if value is not None - }} - if length is not None: - request_details['length'] = length - if spacing is not None: - request_details['spacing'] = spacing - if plan_opts: - request_details['plan_options'] = dict(plan_opts) - if tool_options: - request_details['tool_options'] = dict(tool_options) - operation: RouteOperation = 'uturn' - diagnostic_request = request_details - portspec = tuple(context.portspec for context in contexts) - planner_bounds.validate_uturn_args(portspec, spacing=spacing, bounds=route_bounds) - if len(contexts) > 1: - return PreparedRouteResult(tuple( - self.prepared_route_action_from_leg(leg) - for leg in self.plan_su_bundle_routes( - 'u', - contexts, - offset, - length, - spacing, - operation, - diagnostic_request, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ) - )) - leg = self.plan_leg( - 'u', - contexts[0], - operation, - diagnostic_request, - length=length, - jog=offset, - strategy=strategy, - tool_options=tool_options, - **route_bounds, - ) - return self.prepared_result_from_legs((leg,)) - - def plan_su_bundle_routes( - self, - kind: Literal['s', 'u'], - contexts: Sequence[RoutePortContext], - offset: float, - length: float | None, - spacing: float | ArrayLike | None, - operation: RouteOperation, - diagnostic_request: Mapping[str, Any], - /, - *, - strategy: RouteTieBreakStrategy | str | None = None, - tool_options: Mapping[str, Any] | None = None, - **kwargs: Any, - ) -> tuple[RouteLeg, ...]: - """ - Solve the anchor S/U route and derive exact routes for the rest of a bundle. - - The anchor may determine the public length when omitted. Once known, - `su_bundle_specs()` normalizes every other port into an exact length - and offset so all legs can be planned independently. - """ - if len(contexts) == 1: - return (self.plan_leg( - kind, - contexts[0], - operation, - diagnostic_request, - length=length, - jog=offset, - strategy=strategy, - tool_options=tool_options, - **kwargs, - ),) - route_name = 'jog' if kind == 's' else 'uturn' - if kind == 'u' and is_close(offset, 0): - raise BuildError('multi-port uturn() requires nonzero offset to determine bundle ordering') - contexts_by_name = {context.portspec: context for context in contexts} - initial_specs = planner_bounds.su_bundle_specs(contexts, offset, 0, spacing, route_name=route_name) - anchor_portspec, _anchor_length, _anchor_offset = initial_specs[0] - anchor = self.plan_leg( - kind, - contexts_by_name[anchor_portspec], - operation, - diagnostic_request, - length=length, - jog=offset, - strategy=strategy, - tool_options=tool_options, - **kwargs, - ) - base_length = anchor.candidate.public_length - specs = planner_bounds.su_bundle_specs(contexts, offset, base_length, spacing, route_name=route_name) - first_portspec, _first_length, _first_offset = specs[0] - routes_by_name = {first_portspec: anchor} - for spec_portspec, spec_length, spec_offset in specs[1:]: - if kind == 's' and is_close(spec_offset, 0): - routes_by_name[spec_portspec] = self.plan_leg( - 'straight', - contexts_by_name[spec_portspec], - operation, - diagnostic_request, - length=spec_length, - jog=spec_offset, - strategy=strategy, - tool_options=tool_options, - **kwargs, - ) - else: - routes_by_name[spec_portspec] = self.plan_leg( - kind, - contexts_by_name[spec_portspec], - operation, - diagnostic_request, - length=spec_length, - jog=spec_offset, - strategy=strategy, - tool_options=tool_options, - **kwargs, - ) - return tuple(routes_by_name[spec_portspec] for spec_portspec, _length, _offset in specs) - - def plan_trace_into( - self, - context_src: RoutePortContext, - portspec_dst: str, - port_dst: Port, - *, - out_ptype: str | None, - plug_destination: bool, - thru: str | None, - plan_options: Mapping[str, Any] | None = None, - tool_options: Mapping[str, Any] | None = None, - ) -> PreparedRouteResult: - """Plan a bounded route from one source port into a destination port.""" - plan_opts = self.validate_plan_options(plan_options, allow_bend_policy=True) - strategy = plan_opts.get('strategy') - bend_policy = plan_opts.get('bend_policy') - resolved_bend_policy = self.resolve_trace_into_bend_policy(bend_policy) - if out_ptype is None: - out_ptype = port_dst.ptype - if context_src.port.rotation is None or port_dst.rotation is None: - raise PortError('Ports must have rotation') - desired = port_dst.copy() - desired.rotation = port_dst.rotation - pi - desired.ptype = out_ptype - family, length, jog, ccw = self.trace_into_spec(context_src.port, desired) - bend_bands = self.trace_into_bend_bands(family, bend_policy=resolved_bend_policy) - max_bends = max((band[1] for band in bend_bands), default=0) - request = self.solver_request( - family, - context_src, - length=length, - jog=jog, - ccw=ccw, - constrain_jog=family == 'bend', - max_bends=max_bends, - strategy=strategy, - tool_options=tool_options, - out_ptype=out_ptype, - ) - solver = self.solver_for_request(request) - candidate = None - last_error: Exception | None = None - for min_bends, max_bends in bend_bands: - try: - candidate = solver.solve(min_bends=min_bends, max_bends=max_bends) - break - except (BuildError, NotImplementedError) as err: - if route_failure_policy(err) is RouteFailurePolicy.FATAL: - raise - last_error = err - if candidate is None: - if last_error is not None: - raise last_error - raise BuildError('No legal primitive offer for trace_into route') - leg = self.route_leg_from_candidate( - context_src, - candidate, - plug_into=portspec_dst if plug_destination else None, - ) - renames = ((thru, context_src.portspec),) if thru is not None else () - return self.prepared_result_from_legs( - (leg,), - renames=renames, - ) - - def trace_into_spec( - self, - start_port: Port, - end_port: Port, - ) -> tuple[PrimitiveKind, float, float, SupportsBool | None]: - """Convert source/destination geometry into a primitive route family and constraints.""" - def quarter_turn(rotation: float) -> int: - normalized = rotation % (2 * pi) - if is_close(normalized, 2 * pi): - normalized = 0.0 - quarter = int(round(normalized / (pi / 2))) % 4 - if not is_close(normalized, (quarter * pi / 2) % (2 * pi)): - raise BuildError('trace_into() only supports Manhattan port rotations') - return quarter - - travel_jog, _angle = start_port.measure_travel(end_port) - travel, jog = travel_jog - length = -float(travel) - offset = -float(jog) - if start_port.rotation is None or end_port.rotation is None: - raise PortError('Ports must have rotation') - relative_quarter = (quarter_turn(end_port.rotation) - quarter_turn(start_port.rotation)) % 4 - if relative_quarter == 0: - return ('straight', length, 0.0, None) if is_close(offset, 0) else ('s', length, offset, None) - if relative_quarter == 1: - return 'bend', length, offset, True - if relative_quarter == 2: - return 'u', length, offset, None - return 'bend', length, offset, False diff --git a/masque/builder/port_utils.py b/masque/builder/port_utils.py new file mode 100644 index 0000000..5746dde --- /dev/null +++ b/masque/builder/port_utils.py @@ -0,0 +1,112 @@ +""" +Functions for writing port data into a Pattern (`dev2pat`) and retrieving it (`pat2dev`). + + These use the format 'name:ptype angle_deg' written into labels, which are placed at +the port locations. This particular approach is just a sensible default; feel free to +to write equivalent functions for your own format or alternate storage methods. +""" +from typing import Sequence +import logging + +import numpy + +from ..pattern import Pattern +from ..label import Label +from ..utils import rotation_matrix_2d, layer_t +from .devices import Device, Port + + +logger = logging.getLogger(__name__) + + +def dev2pat(device: Device, layer: layer_t) -> Pattern: + """ + Place a text label at each port location, specifying the port data in the format + 'name:ptype angle_deg' + + This can be used to debug port locations or to automatically generate ports + when reading in a GDS file. + + NOTE that `device` is modified by this function, and `device.pattern` is returned. + + Args: + device: The device which is to have its ports labeled. MODIFIED in-place. + layer: The layer on which the labels will be placed. + + Returns: + `device.pattern` + """ + for name, port in device.ports.items(): + if port.rotation is None: + angle_deg = numpy.inf + else: + angle_deg = numpy.rad2deg(port.rotation) + device.pattern.labels += [ + Label(string=f'{name}:{port.ptype} {angle_deg:g}', layer=layer, offset=port.offset) + ] + return device.pattern + + +def pat2dev( + pattern: Pattern, + layers: Sequence[layer_t], + max_depth: int = 999_999, + skip_subcells: bool = True, + ) -> Device: + """ + Examine `pattern` for labels specifying port info, and use that info + to build a `Device` object. + + Labels are assumed to be placed at the port locations, and have the format + 'name:ptype angle_deg' + + Args: + pattern: Pattern object to scan for labels. + layers: Search for labels on all the given layers. + max_depth: Maximum hierarcy depth to search. Default 999_999. + Reduce this to 0 to avoid ever searching subcells. + skip_subcells: If port labels are found at a given hierarcy level, + do not continue searching at deeper levels. This allows subcells + to contain their own port info (and thus become their own Devices). + Default True. + + Returns: + The constructed Device object. Port labels are not removed from the pattern. + """ + ports = {} # Note: could do a list here, if they're not unique + annotated_cells = set() + def find_ports_each(pat, hierarchy, transform, memo) -> Pattern: + if len(hierarchy) > max_depth - 1: + return pat + + if skip_subcells and any(parent in annotated_cells for parent in hierarchy): + return pat + + labels = [ll for ll in pat.labels if ll.layer in layers] + + if len(labels) == 0: + return pat + + if skip_subcells: + annotated_cells.add(pat) + + mirr_factor = numpy.array((1, -1)) ** transform[3] + rot_matrix = rotation_matrix_2d(transform[2]) + for label in labels: + name, property_string = label.string.split(':') + properties = property_string.split(' ') + ptype = properties[0] + angle_deg = float(properties[1]) if len(ptype) else 0 + + xy_global = transform[:2] + rot_matrix @ (label.offset * mirr_factor) + angle = numpy.deg2rad(angle_deg) * mirr_factor[0] * mirr_factor[1] + transform[2] + + if name in ports: + logger.info(f'Duplicate port {name} in pattern {pattern.name}') + + ports[name] = Port(offset=xy_global, rotation=angle, ptype=ptype) + + return pat + + pattern.dfs(visit_before=find_ports_each, transform=True) + return Device(pattern, ports) diff --git a/masque/builder/tool_testing.py b/masque/builder/tool_testing.py deleted file mode 100644 index 44daf95..0000000 --- a/masque/builder/tool_testing.py +++ /dev/null @@ -1,368 +0,0 @@ -"""Pytest-independent contract checks for custom routing Tools.""" -from __future__ import annotations - -from copy import deepcopy -from dataclasses import dataclass, field -from math import isfinite -from types import MappingProxyType -from typing import TYPE_CHECKING, Any - -import numpy -from numpy import pi - -from ..library import ILibrary, SINGLE_USE_PREFIX -from ..ports import Port -from ..utils import ptypes_compatible -from ._tolerances import angles_equal, array_close, scalar_close -from .error import ToolContractError -from .tools import ( - BendOffer, PrimitiveKind, PrimitiveOffer, RenderStep, SOffer, StraightOffer, Tool, UOffer, - ) - -if TYPE_CHECKING: - from collections.abc import Mapping, Sequence - - -_RESERVED_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw')) - - -@dataclass(frozen=True, slots=True) -class ToolContractCase: - """One primitive-discovery query to exercise against a custom Tool.""" - - kind: PrimitiveKind - in_ptype: str | None = None - out_ptype: str | None = None - ccw: bool | None = None - tool_options: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) - probe_parameters: tuple[float, ...] = () - require_offers: bool = True - check_bbox: bool = False - label: str | None = None - - def __post_init__(self) -> None: - if self.kind not in ('straight', 'bend', 's', 'u'): - raise ValueError(f'Unrecognized primitive kind {self.kind!r}') - if self.kind == 'bend': - if self.ccw is None: - raise ValueError('Bend ToolContractCase requires ccw') - elif self.ccw is not None: - raise ValueError('ccw is only valid for bend ToolContractCase') - - try: - options = deepcopy(dict(self.tool_options)) - except Exception as err: - raise ValueError('ToolContractCase.tool_options must be a deep-copyable mapping') from err - nonstring = [key for key in options if not isinstance(key, str)] - if nonstring: - raise ValueError(f'ToolContractCase.tool_options keys must be strings; got {nonstring!r}') - collisions = sorted(_RESERVED_OPTION_KEYS & options.keys()) - if collisions: - raise ValueError(f'ToolContractCase.tool_options contains reserved keys: {", ".join(collisions)}') - - try: - probes = tuple(float(value) for value in self.probe_parameters) - except (TypeError, ValueError, OverflowError) as err: - raise ValueError('ToolContractCase.probe_parameters must contain numeric scalars') from err - if not all(isfinite(value) for value in probes): - raise ValueError('ToolContractCase.probe_parameters must be finite') - - object.__setattr__(self, 'ccw', None if self.ccw is None else bool(self.ccw)) - object.__setattr__(self, 'tool_options', MappingProxyType(options)) - object.__setattr__(self, 'probe_parameters', probes) - - -def _automatic_probes(offer: PrimitiveOffer) -> tuple[float, ...]: - """Choose deterministic representative parameters inside one offer domain.""" - lower, upper = (float(value) for value in offer.parameter_domain) - if lower == upper: - return (lower,) - if numpy.isfinite(lower) and numpy.isfinite(upper): - midpoint = lower / 2 + upper / 2 - if midpoint == upper: - midpoint = float(numpy.nextafter(upper, lower)) - return (lower, midpoint) - if numpy.isfinite(lower): - step = max(1.0, abs(lower) * 0.1) - return (lower, lower + step) - if numpy.isfinite(upper): - step = max(1.0, abs(upper) * 0.1) - return (upper - step, upper - 2 * step) - return (-1.0, 1.0) - - -def _offer_probes(offer: PrimitiveOffer, extras: Sequence[float]) -> tuple[float, ...]: - """Combine automatic and applicable explicit probes without duplicates.""" - probes: list[float] = [] - for parameter in (*_automatic_probes(offer), *extras): - try: - selected = offer.canonicalize_parameter(parameter) - except Exception: - continue - if not any(scalar_close(selected, previous) for previous in probes): - probes.append(selected) - return tuple(probes) - - -def _offer_metadata(offer: PrimitiveOffer) -> tuple[Any, ...]: - """Return discovery metadata that must remain stable across repeated queries.""" - cost_policy: tuple[str, Any] - if callable(offer.cost): - cost_policy = ('callable', type(offer.cost).__qualname__) - else: - cost_policy = ('factor', float(offer.cost)) - return ( - type(offer), - offer.kind, - offer.in_ptype, - offer.out_ptype, - tuple(float(value) for value in offer.parameter_domain), - getattr(offer, 'ccw', None), - cost_policy, - ) - - -def _evaluated_cost(offer: PrimitiveOffer, parameter: float, endpoint: Port) -> float: - """Mirror the solver's one-endpoint base-cost path while honoring overrides.""" - if type(offer).cost_at is PrimitiveOffer.cost_at: - return PrimitiveOffer._cost_for_endpoint(offer, parameter, endpoint) - return float(offer.cost_at(parameter)) - - -def validate_tool_contract(tool: Tool, cases: Sequence[ToolContractCase]) -> None: - """Validate Tool discovery, offer callbacks, and one-step rendering. - - All independent violations are collected and raised as one - `ExceptionGroup` containing contextual `ToolContractError` instances. - """ - cases = tuple(cases) - if not cases: - raise ValueError('validate_tool_contract() requires at least one case') - - errors: list[ToolContractError] = [] - - def violation(context: str, message: str, cause: Exception | None = None) -> None: - err = ToolContractError(f'{context}: {message}') - if cause is not None: - err.__cause__ = cause - errors.append(err) - - def discover(case: ToolContractCase, context: str, repetition: str) -> tuple[PrimitiveOffer, ...] | None: - expected_offer_type = { - 'straight': StraightOffer, - 'bend': BendOffer, - 's': SOffer, - 'u': UOffer, - }[case.kind] - try: - kwargs = deepcopy(dict(case.tool_options)) - if case.kind == 'bend': - kwargs['ccw'] = case.ccw - offers = tool.primitive_offers( - case.kind, - in_ptype=case.in_ptype, - out_ptype=case.out_ptype, - **kwargs, - ) - except Exception as err: - violation(context, f'{repetition} discovery raised {type(err).__name__}: {err}', err) - return None - - if not isinstance(offers, tuple): - violation(context, f'{repetition} discovery returned {type(offers).__name__}, expected tuple') - return None - valid = True - for offer_index, offer in enumerate(offers): - if not isinstance(offer, PrimitiveOffer): - violation( - context, - f'{repetition} discovery item {offer_index} is {type(offer).__name__}, expected PrimitiveOffer', - ) - valid = False - elif offer.kind != case.kind: - violation( - context, - f'{repetition} discovery item {offer_index} has kind {offer.kind!r}, expected {case.kind!r}', - ) - valid = False - elif not isinstance(offer, expected_offer_type): - violation( - context, - f'{repetition} discovery item {offer_index} is {type(offer).__name__}, ' - f'expected {expected_offer_type.__name__}', - ) - valid = False - return offers if valid else None - - for case_index, case in enumerate(cases): - context = case.label or f'case {case_index} ({case.kind})' - first = discover(case, context, 'first') - second = discover(case, context, 'repeated') - if first is None or second is None: - continue - if case.require_offers and not first: - violation(context, 'discovery returned no offers') - if len(first) != len(second): - violation(context, f'discovery count changed from {len(first)} to {len(second)}') - - matched_explicit = [False] * len(case.probe_parameters) - for offer_index, offer in enumerate(first): - offer_context = f'{context}, offer {offer_index}' - repeated = second[offer_index] if offer_index < len(second) else None - if repeated is not None and _offer_metadata(offer) != _offer_metadata(repeated): - violation(offer_context, 'discovery metadata changed between repeated queries') - - probes = _offer_probes(offer, case.probe_parameters) - for explicit_index, parameter in enumerate(case.probe_parameters): - try: - offer.canonicalize_parameter(parameter) - except Exception: - continue - matched_explicit[explicit_index] = True - - stable_ptype: str | None = None - stable_rotation: float | None = None - has_stable_endpoint = False - for parameter in probes: - probe_context = f'{offer_context}, parameter {parameter:g}' - try: - endpoint = offer.endpoint_at(parameter) - except Exception as err: - violation(probe_context, f'endpoint_at() raised {type(err).__name__}: {err}', err) - continue - if not isinstance(endpoint, Port): - violation(probe_context, f'endpoint_at() returned {type(endpoint).__name__}, expected Port') - continue - if not numpy.all(numpy.isfinite(endpoint.offset)): - violation(probe_context, 'endpoint offset must be finite') - if endpoint.rotation is None or not numpy.isfinite(endpoint.rotation): - violation(probe_context, 'endpoint rotation must be finite and specified') - if not ptypes_compatible(endpoint.ptype, offer.out_ptype): - violation(probe_context, 'endpoint ptype does not match declared out_ptype') - - if offer.kind in ('straight', 'bend') and not scalar_close(endpoint.x, parameter): - violation(probe_context, 'straight/bend endpoint x must equal its parameter') - if offer.kind in ('s', 'u') and not scalar_close(endpoint.y, parameter): - violation(probe_context, 'S/U endpoint y must equal its parameter') - expected_rotation = { - 'straight': pi, - 'bend': -pi / 2 if isinstance(offer, BendOffer) and offer.ccw else pi / 2, - 's': pi, - 'u': 0.0, - }[offer.kind] - if endpoint.rotation is not None and not angles_equal(endpoint.rotation, expected_rotation): - violation( - probe_context, - f'endpoint rotation does not match {offer.kind!r} geometry', - ) - - if has_stable_endpoint: - if endpoint.ptype != stable_ptype: - violation(probe_context, 'endpoint ptype changes across the offer domain') - if ( - endpoint.rotation is None - or stable_rotation is None - or not angles_equal(endpoint.rotation, stable_rotation) - ): - violation(probe_context, 'endpoint rotation changes across the offer domain') - else: - stable_ptype = endpoint.ptype - stable_rotation = endpoint.rotation - has_stable_endpoint = True - - try: - cost = float(_evaluated_cost(offer, parameter, endpoint)) - if not numpy.isfinite(cost) or cost < 0: - violation(probe_context, f'cost must be finite and nonnegative, got {cost!r}') - except Exception as err: - violation(probe_context, f'cost_at() raised {type(err).__name__}: {err}', err) - cost = None - - if repeated is not None: - try: - repeated_endpoint = repeated.endpoint_at(parameter) - repeated_cost = float(_evaluated_cost(repeated, parameter, repeated_endpoint)) - if ( - not isinstance(repeated_endpoint, Port) - or not array_close(repeated_endpoint.offset, endpoint.offset) - or repeated_endpoint.ptype != endpoint.ptype - or repeated_endpoint.rotation is None - or endpoint.rotation is None - or not angles_equal(repeated_endpoint.rotation, endpoint.rotation) - ): - violation(probe_context, 'endpoint result changed after repeated discovery') - if cost is not None and not scalar_close(repeated_cost, cost): - violation(probe_context, 'cost result changed after repeated discovery') - except Exception as err: - violation( - probe_context, - f'repeated offer evaluation raised {type(err).__name__}: {err}', - err, - ) - - if case.check_bbox: - try: - offer.bbox_at(parameter) - except Exception as err: - violation(probe_context, f'bbox_at() raised {type(err).__name__}: {err}', err) - - try: - data = offer.commit(parameter) - except Exception as err: - violation(probe_context, f'commit() raised {type(err).__name__}: {err}', err) - continue - - try: - start = Port((0, 0), rotation=pi, ptype=offer.in_ptype or 'unk') - tree = tool.render((RenderStep(offer.kind, tool, start, endpoint.copy(), data),)) - except Exception as err: - violation(probe_context, f'render() raised {type(err).__name__}: {err}', err) - continue - if not isinstance(tree, ILibrary): - violation(probe_context, f'render() returned {type(tree).__name__}, expected ILibrary') - continue - try: - top_name = tree.top() - pattern = tree.top_pattern() - except Exception as err: - violation(probe_context, f'rendered tree has no valid top cell: {err}', err) - continue - missing = sorted( - name - for name in tree.dangling_refs(top_name) - if isinstance(name, str) and name.startswith(SINGLE_USE_PREFIX) - ) - if missing: - violation(probe_context, f'rendered tree has missing single-use refs: {missing}') - missing_ports = [name for name in ('A', 'B') if name not in pattern.ports] - if missing_ports: - violation(probe_context, f'rendered top cell is missing ports: {missing_ports}') - continue - input_port, output_port = pattern.ports['A'], pattern.ports['B'] - if not ptypes_compatible(input_port.ptype, offer.in_ptype): - violation(probe_context, 'rendered input ptype does not match offer in_ptype') - try: - rendered_offset, rendered_rotation = input_port.measure_travel(output_port) - except Exception as err: - violation(probe_context, f'unable to measure rendered endpoint: {err}', err) - continue - if not array_close(rendered_offset, endpoint.offset): - violation(probe_context, 'rendered output offset does not match planned endpoint') - if ( - rendered_rotation is None - or endpoint.rotation is None - or not angles_equal(rendered_rotation, endpoint.rotation) - ): - violation(probe_context, 'rendered output rotation does not match planned endpoint') - if not ptypes_compatible(output_port.ptype, endpoint.ptype): - violation(probe_context, 'rendered output ptype does not match planned endpoint') - - for parameter, matched in zip(case.probe_parameters, matched_explicit, strict=True): - if not matched: - violation(context, f'explicit probe {parameter:g} is outside every discovered offer domain') - - if errors: - raise ExceptionGroup( - f'{type(tool).__name__} failed Tool contract validation with {len(errors)} violation(s)', - errors, - ) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 8cd3a8f..bd93f0c 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,1744 +1,22 @@ """ -Routing Tool contracts and built-in Tool implementations. - -A `Tool` is the user-extensible side of Pather routing. It does not receive -Pather state and it does not choose high-level route topology. Instead, -`primitive_offers()` exposes the local primitive moves that are legal for the -Tool: parameter domains, endpoint ptypes and geometry, additive costs, -optional footprint bounds, and a commit hook for the selected parameter. -`masque.builder.planner` composes those offers into `trace()`, `jog()`, -`uturn()`, and `trace_into()` routes. - -Offer geometry is described in local route coordinates. The current input port -is `(0, 0)` with rotation `0`; length-like parameters advance along +x; positive -jog is left of travel; returned endpoint ports describe the primitive output in -that same local frame. The planner transforms selected endpoints into layout -coordinates only after a complete route has been chosen. - -Primitive parameters are also the basis for route-failure diagnostics. Tools -must keep endpoint topology stable across each offer domain: length-like -Straight/Bend offers use a finite, attained, nonnegative minimum and advance -their local x coordinate by the selected length; S/U offers move their local y -coordinate by the selected jog. Endpoint rotation and output ptype must not -vary with the parameter and must agree with the concrete offer kind and its -declared `out_ptype`. These are Tool contract requirements rather than -exhaustively runtime-checked properties. - -Tool authors should treat offer planning callbacks as pure descriptions. The -solver may call `endpoint_at()` and `cost_at()` many times while enumerating -candidate compositions, ptype adapters, and parameter solutions. Those -callbacks should be deterministic and should not mutate a Library, Pattern, or -live Pather. `bbox_at()` follows the same purity contract, but footprint data is -currently reserved for future footprint-aware planning and is not consumed by -the solver. `commit()` is the first selected-offer hook: it runs only for -primitives in the chosen route and returns the opaque value stored in -`RenderStep.data`. - -`render()` is the geometry-construction hook. Pather calls it later with a -compatible batch of committed `RenderStep`s, already expressed in layout -coordinates and grouped by Tool/continuity. The returned single-top tree is -plugged into the pending route by Pather, which also validates that the rendered -output port matches the endpoint selected during planning. - -Routing uses the Tool assigned to the routed input port. The solver does not -search across Tools or infer `Pather.retool()` boundaries; transitions, -cross-ptype routes, and adapter shapes must be exposed by the active Tool as -primitive offers. +Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) """ -from typing import Literal, Any, ClassVar, Self -from collections import ChainMap -from collections.abc import Sequence, Callable, Mapping -from abc import ABC, abstractmethod -from copy import deepcopy -from dataclasses import dataclass, field, replace -from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt -from types import MappingProxyType +from typing import TYPE_CHECKING, Optional, Sequence -import numpy -from numpy.typing import NDArray -from numpy import pi +if TYPE_CHECKING: + from .devices import Device -from ..utils import ( - layer_t, - ptypes_compatible as ptypes_compatible, - rotation_matrix_2d, - ) -from ..ports import Port -from ..pattern import Pattern -from ..abstract import Abstract -from ..library import ILibrary, Library, SINGLE_USE_PREFIX -from ..error import BuildError -from ..shapes import Path -from ._tolerances import DOMAIN_ATOL, DOMAIN_RTOL, array_close, angles_equal -from .error import ToolContractError - -def _canonicalize_domain_value( - value: float, - domain: tuple[float, float], - *, - rtol: float = DOMAIN_RTOL, - atol: float = DOMAIN_ATOL, - ) -> float: - """ - Canonicalize a solved primitive parameter against a route domain. - - Normal domains are half-open `[min, max)`. A `(value, value)` domain is a - special closed singleton used for fixed parameters. - """ - vv = float(value) - lower, upper = (float(domain[0]), float(domain[1])) - - if scalar_isnan(lower) or scalar_isnan(upper): - raise BuildError(f'Parameter domain must not contain NaN values: {domain}') - if lower > upper: - raise BuildError(f'Parameter domain lower bound must not exceed upper bound: {domain}') - if not scalar_isfinite(vv): - raise BuildError(f'Parameter {vv:g} must be finite') - - if lower == upper: - if not scalar_isfinite(lower): - raise BuildError(f'Singleton parameter domain must be finite: {domain}') - if scalar_isclose(vv, lower, rel_tol=rtol, abs_tol=atol): - return lower - raise BuildError(f'Parameter {vv:g} is outside singleton domain {{{lower:g}}}') - - if scalar_isclose(vv, lower, rel_tol=rtol, abs_tol=atol): - vv = lower - if vv < lower or vv >= upper: - raise BuildError(f'Parameter {vv:g} is outside half-open domain [{lower:g}, {upper:g})') - return vv - - -def _validated_parameter_domain( - domain: tuple[float, float], - *, - name: str = 'Parameter domain', - nonnegative_minimum: bool = False, - ) -> tuple[float, float]: - """Validate and normalize a primitive parameter domain at construction time.""" - try: - lower_raw, upper_raw = domain - lower, upper = float(lower_raw), float(upper_raw) - except (TypeError, ValueError, OverflowError) as err: - raise BuildError(f'{name} must be a pair of numeric bounds') from err - if scalar_isnan(lower) or scalar_isnan(upper): - raise BuildError(f'{name} must not contain NaN values: {domain}') - if lower > upper: - raise BuildError(f'{name} lower bound must not exceed upper bound: {domain}') - if lower == upper and not scalar_isfinite(lower): - raise BuildError(f'{name} singleton must be finite: {domain}') - if nonnegative_minimum and (not scalar_isfinite(lower) or lower < 0): - raise BuildError(f'{name} must have a finite, nonnegative minimum: {domain}') - return lower, upper - - -EndpointCallable = Callable[[float], Port] -CommitCallable = Callable[[float], Any] -BBoxCallable = Callable[[float], NDArray[numpy.float64]] -DataCallable = Callable[[float], Any] -BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]] -CostCallable = Callable[[float, Port], float] -PrimitiveKind = Literal['straight', 'bend', 's', 'u'] -RenderStepKind = PrimitiveKind | Literal['plug'] -RenderOpcode = Literal['L', 'S', 'U', 'P'] - - -def _opcode_for_kind(kind: RenderStepKind) -> RenderOpcode: - """Return the legacy render opcode derived from one canonical kind.""" - if kind in ('straight', 'bend'): - return 'L' - if kind == 's': - return 'S' - if kind == 'u': - return 'U' - if kind == 'plug': - return 'P' - raise BuildError(f'Unrecognized render-step kind {kind!r}') - - -def _validated_cost(cost: CostCallable | float) -> CostCallable | float: - """Return a canonical primitive cost policy or raise `BuildError`.""" - if callable(cost): - return cost - try: - factor = float(cost) - except (TypeError, ValueError, OverflowError) as err: - raise BuildError(f'Primitive cost factor must be a number or callable, got {cost!r}') from err - if not scalar_isfinite(factor) or factor < 0: - raise BuildError(f'Primitive cost factor must be nonnegative and finite, got {factor:g}') - return factor - - -def _validated_cost_value(value: Any) -> float: - """Return a canonical evaluated cost or raise `ToolContractError`.""" - try: - cost = float(value) - except (TypeError, ValueError, OverflowError) as err: - raise ToolContractError(f'Primitive cost must be a number, got {value!r}') from err - if not scalar_isfinite(cost) or cost < 0: - raise ToolContractError(f'Primitive cost must be nonnegative and finite, got {cost:g}') - return cost - - -def _generated_offer_callbacks( - endpoint_at: EndpointCallable, - data_at: DataCallable, - bbox_for_data: BBoxForDataCallable | None, - ) -> tuple[EndpointCallable, CommitCallable, BBoxCallable | None]: - def commit(parameter: float) -> Any: - return data_at(parameter) - - if bbox_for_data is None: - return endpoint_at, commit, None - - def bbox(parameter: float) -> NDArray[numpy.float64]: - return bbox_for_data(data_at(parameter)) - - return endpoint_at, commit, bbox - - -def _prebuilt_offer_callbacks( - endpoint: Port, - data: Any, - bbox_for_data: BBoxForDataCallable | None, - ) -> tuple[EndpointCallable, CommitCallable, BBoxCallable | None]: - prebuilt_endpoint = endpoint.copy() - - def endpoint_at(parameter: float) -> Port: - _ = parameter - return prebuilt_endpoint.copy() - - def commit(parameter: float) -> Any: - _ = parameter - return data - - if bbox_for_data is None: - return endpoint_at, commit, None - - def bbox(parameter: float) -> NDArray[numpy.float64]: - _ = parameter - return bbox_for_data(data) - - return endpoint_at, commit, bbox - - -@dataclass(frozen=True, slots=True) -class PrimitiveOffer(ABC): - """ - Shared base type for local routing primitives made available by a `Tool`. - - Custom tools normally construct one of the concrete offer classes: - `StraightOffer`, `BendOffer`, `SOffer`, or `UOffer`. `PrimitiveOffer` - exists to hold common callback, ptype, cost, and footprint behavior and is - useful for annotations when handling offers generically. - - Offers are pure planning objects. `endpoint_at()` returns a local output - `Port`, `cost_at()` returns an additive scalar cost, `bbox_at()` returns - local primitive bounds when a footprint hook is available, and `commit()` - returns opaque render data only after an offer has been selected. These - methods should be deterministic and must not mutate the user's target - library. - - Parameter domains are half-open `[min, max)` ranges, except `(value, value)` - is a closed singleton for fixed-size primitives. `None` and `"unk"` ptypes - are wildcards; incompatible concrete ptypes are rejected by `Pather`. - - Custom offers must have stable endpoint ptype and rotation throughout their - domain. Straight/Bend length domains must have a finite, attained, - nonnegative minimum and produce local `x == parameter`; S/U offers produce - local `y == parameter`. The planner relies on these invariants when it - diagnoses whether a failed constrained route has a preferred minimum-length - alternative or is unsupported at every legal length. - """ - in_ptype: str | None - out_ptype: str | None - cost: CostCallable | float = 1.0 - bbox_planner: BBoxCallable | None = None - parameterized_bbox: Any | None = None - """Reserved footprint metadata; the current solver does not inspect it.""" - endpoint_planner: EndpointCallable | None = None - commit_planner: CommitCallable | None = None - kind: ClassVar[PrimitiveKind] - - def __post_init__(self) -> None: - has_endpoint = self.endpoint_planner is not None - has_commit = self.commit_planner is not None - - if has_endpoint != has_commit: - raise BuildError('PrimitiveOffer split callbacks require both endpoint_planner and commit_planner') - _validated_parameter_domain( - self.parameter_domain, - nonnegative_minimum=self.kind in ('straight', 'bend'), - ) - object.__setattr__(self, 'cost', _validated_cost(self.cost)) - - @property - def opcode(self) -> Literal['L', 'S', 'U']: - """Legacy render opcode derived from this offer's canonical kind.""" - opcode = _opcode_for_kind(self.kind) - assert opcode != 'P' - return opcode - - @property - @abstractmethod - def parameter_domain(self) -> tuple[float, float]: - raise NotImplementedError - - def canonicalize_parameter(self, parameter: float) -> float: - """Return a finite selected parameter inside this offer's domain.""" - return _canonicalize_domain_value(parameter, self.parameter_domain) - - def endpoint_at(self, parameter: float) -> Port: - """ - Evaluate the local endpoint for a candidate parameter. - - The returned port is in Tool-local public route coordinates. It must - not depend on live Pather state or mutate the user's target library. - """ - selected = self.canonicalize_parameter(parameter) - if self.endpoint_planner is not None: - return self.endpoint_planner(selected) - raise NotImplementedError - - def cost_at(self, parameter: float) -> float: - """ - Return this primitive's additive planning cost. - - Lower cost is preferred before internal tie-breakers. A numeric `cost` - scales the default local-displacement cost; a callable receives the - canonical parameter and local endpoint and returns the complete cost. - """ - selected = self.canonicalize_parameter(parameter) - if self.endpoint_planner is not None: - out_port = self.endpoint_planner(selected) - else: - out_port = self.endpoint_at(selected) - return self._cost_for_endpoint(selected, out_port) - - def _cost_for_endpoint(self, parameter: float, out_port: Port) -> float: - """Evaluate the base cost policy using an endpoint already produced by planning.""" - if callable(self.cost): - value = self.cost(parameter, out_port) - else: - default_cost = abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y)) - value = self.cost * default_cost - return _validated_cost_value(value) - - def bbox_at(self, parameter: float) -> NDArray[numpy.float64]: - """ - Return local primitive bounds for future footprint-aware planning. - - Tools may omit this hook by raising `NotImplementedError`; when present - it must return a finite `(2, 2)` min/max array in local coordinates. - The current route solver does not call this method. - """ - if self.bbox_planner is None: - raise NotImplementedError - - bounds = numpy.asarray( - self.bbox_planner(self.canonicalize_parameter(parameter)), - dtype=float, - ) - if bounds.shape != (2, 2): - raise BuildError(f'Primitive bbox must have shape (2, 2), got {bounds.shape}') - if not numpy.all(numpy.isfinite(bounds)): - raise BuildError('Primitive bbox must contain only finite values') - if numpy.any(bounds[0, :] > bounds[1, :]): - raise BuildError('Primitive bbox minimum corner must not exceed maximum corner') - return bounds - - def commit(self, parameter: float) -> Any: - """ - Produce opaque render data for a selected primitive. - - Routing preparation calls this only for selected primitives while - building `RenderStep.data`. Unselected candidates are evaluated by - endpoint/cost only and should not need commit-side work. This is a - materialization hook, not the live-layout mutation boundary: it must not - mutate the caller's Pather, Pattern, or Library. - """ - selected = self.canonicalize_parameter(parameter) - if self.commit_planner is not None: - return self.commit_planner(selected) - raise NotImplementedError - - -@dataclass(frozen=True, slots=True) -class StraightOffer(PrimitiveOffer): - """Straight or straight-like primitive parameterized by public route length.""" - kind: ClassVar[Literal['straight']] = 'straight' - length_domain: tuple[float, float] = (0.0, numpy.inf) - - @classmethod - def generated( - cls, - ptype: str | None, - data_at: DataCallable, - *, - cost: CostCallable | float = 1.0, - bbox_for_data: BBoxForDataCallable | None = None, - length_domain: tuple[float, float] = (0.0, numpy.inf), - ) -> Self: - """ - Build a generated straight offer with the default route-frame endpoint. - """ - def endpoint_at(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype=ptype) - - endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( - endpoint_at, - data_at, - bbox_for_data, - ) - return cls( - in_ptype = ptype, - out_ptype = ptype, - cost = cost, - bbox_planner = bbox_planner, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - length_domain = length_domain, - ) - - @classmethod - def prebuilt( - cls, - in_ptype: str | None, - out_ptype: str | None, - endpoint: Port, - data: Any, - *, - cost: CostCallable | float = 1.0, - bbox_for_data: BBoxForDataCallable | None = None, - ) -> Self: - """ - Build a prebuilt straight-like offer backed by precomputed render data. - """ - endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( - endpoint, - data, - bbox_for_data, - ) - return cls( - in_ptype = in_ptype, - out_ptype = out_ptype, - cost = cost, - bbox_planner = bbox_planner, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - length_domain = (float(endpoint.x), float(endpoint.x)), - ) - - @property - def parameter_domain(self) -> tuple[float, float]: - return self.length_domain - - -@dataclass(frozen=True, slots=True) -class BendOffer(PrimitiveOffer): - """Single-turn L-route primitive parameterized by public route length.""" - kind: ClassVar[Literal['bend']] = 'bend' - ccw: bool = True - length_domain: tuple[float, float] = (0.0, numpy.inf) - - @classmethod - def generated( - cls, - ptype: str | None, - endpoint_at: EndpointCallable, - data_at: DataCallable, - *, - ccw: bool, - cost: CostCallable | float = 1.0, - bbox_for_data: BBoxForDataCallable | None = None, - length_domain: tuple[float, float] = (0.0, numpy.inf), - ) -> Self: - """ - Build a generated bend offer from endpoint and render-data callbacks. - """ - endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( - endpoint_at, - data_at, - bbox_for_data, - ) - return cls( - in_ptype = ptype, - out_ptype = ptype, - cost = cost, - bbox_planner = bbox_planner, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - ccw = ccw, - length_domain = length_domain, - ) - - @classmethod - def prebuilt( - cls, - in_ptype: str | None, - out_ptype: str | None, - endpoint: Port, - data: Any, - *, - ccw: bool, - cost: CostCallable | float = 1.0, - bbox_for_data: BBoxForDataCallable | None = None, - ) -> Self: - """ - Build a prebuilt bend offer backed by precomputed render data. - """ - endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( - endpoint, - data, - bbox_for_data, - ) - return cls( - in_ptype = in_ptype, - out_ptype = out_ptype, - cost = cost, - bbox_planner = bbox_planner, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - ccw = ccw, - length_domain = (float(endpoint.x), float(endpoint.x)), - ) - - @property - def parameter_domain(self) -> tuple[float, float]: - return self.length_domain - - -@dataclass(frozen=True, slots=True) -class SOffer(PrimitiveOffer): - """Non-turning S-route primitive parameterized by jog for a fixed route length.""" - kind: ClassVar[Literal['s']] = 's' - jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf) - - @classmethod - def generated( - cls, - ptype: str | None, - endpoint_at: EndpointCallable, - data_at: DataCallable, - *, - cost: CostCallable | float = 1.0, - bbox_for_data: BBoxForDataCallable | None = None, - jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf), - ) -> Self: - """ - Build a generated S-like offer from endpoint and render-data callbacks. - """ - endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( - endpoint_at, - data_at, - bbox_for_data, - ) - return cls( - in_ptype = ptype, - out_ptype = ptype, - cost = cost, - bbox_planner = bbox_planner, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - jog_domain = jog_domain, - ) - - @classmethod - def prebuilt( - cls, - in_ptype: str | None, - out_ptype: str | None, - endpoint: Port, - data: Any, - *, - cost: CostCallable | float = 1.0, - bbox_for_data: BBoxForDataCallable | None = None, - ) -> Self: - """ - Build a prebuilt S-like offer backed by precomputed render data. - """ - endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( - endpoint, - data, - bbox_for_data, - ) - return cls( - in_ptype = in_ptype, - out_ptype = out_ptype, - cost = cost, - bbox_planner = bbox_planner, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - jog_domain = (float(endpoint.y), float(endpoint.y)), - ) - - @property - def parameter_domain(self) -> tuple[float, float]: - return self.jog_domain - - -@dataclass(frozen=True, slots=True) -class UOffer(PrimitiveOffer): - """U-turn-like primitive parameterized by jog for a fixed route length.""" - kind: ClassVar[Literal['u']] = 'u' - jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf) - - @classmethod - def generated( - cls, - ptype: str | None, - endpoint_at: EndpointCallable, - data_at: DataCallable, - *, - cost: CostCallable | float = 1.0, - bbox_for_data: BBoxForDataCallable | None = None, - jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf), - ) -> Self: - """ - Build a generated U-like offer from endpoint and render-data callbacks. - """ - endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( - endpoint_at, - data_at, - bbox_for_data, - ) - return cls( - in_ptype = ptype, - out_ptype = ptype, - cost = cost, - bbox_planner = bbox_planner, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - jog_domain = jog_domain, - ) - - @classmethod - def prebuilt( - cls, - in_ptype: str | None, - out_ptype: str | None, - endpoint: Port, - data: Any, - *, - cost: CostCallable | float = 1.0, - bbox_for_data: BBoxForDataCallable | None = None, - ) -> Self: - """ - Build a prebuilt U-like offer backed by precomputed render data. - """ - endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( - endpoint, - data, - bbox_for_data, - ) - return cls( - in_ptype = in_ptype, - out_ptype = out_ptype, - cost = cost, - bbox_planner = bbox_planner, - endpoint_planner = endpoint_planner, - commit_planner = commit_planner, - jog_domain = (float(endpoint.y), float(endpoint.y)), - ) - - @property - def parameter_domain(self) -> tuple[float, float]: - return self.jog_domain - - -@dataclass(frozen=True, slots=True) -class RenderStep: - """ - A single deferred routing operation. - - `Pather(render='deferred')` stores these records while routing and later - passes batches of compatible steps to `Tool.render()` when `Pather.render()` - is called. - """ - kind: RenderStepKind - """Canonical primitive kind, or `plug` for an assembly boundary.""" - - tool: 'Tool | None' - """Tool that produced this step, or `None` for `kind='plug'`.""" - - start_port: Port - """ Input-side port before this step is rendered. """ - - end_port: Port - """ Output-side port after this step is rendered. """ - - data: Any - """ Arbitrary tool-specific data""" - - def __post_init__(self) -> None: - if self.kind not in ('straight', 'bend', 's', 'u', 'plug'): - raise BuildError(f'Unrecognized RenderStep kind {self.kind!r}') - if self.kind == 'plug': - if self.tool is not None: - raise BuildError('RenderStep kind="plug" requires tool=None') - elif self.tool is None: - raise BuildError('RenderStep requires a Tool unless kind="plug"') - - @property - def opcode(self) -> RenderOpcode: - """Legacy opcode derived from the canonical render-step kind.""" - return _opcode_for_kind(self.kind) - - def is_continuous_with(self, other: 'RenderStep') -> bool: - """ - Check if another RenderStep can be appended to this one. - """ - # Check continuity with tolerance - offsets_match = array_close(other.start_port.offset, self.end_port.offset) - rotations_match = (other.start_port.rotation is None and self.end_port.rotation is None) or ( - other.start_port.rotation is not None and self.end_port.rotation is not None and - angles_equal(other.start_port.rotation, self.end_port.rotation) - ) - return offsets_match and rotations_match - - def transformed(self, translation: NDArray[numpy.float64], rotation: float, pivot: NDArray[numpy.float64]) -> 'RenderStep': - """ - Return a new RenderStep with transformed start and end ports. - """ - new_start = self.start_port.copy() - new_end = self.end_port.copy() - - for pp in (new_start, new_end): - pp.rotate_around(pivot, rotation) - pp.translate(translation) - - return RenderStep( - kind = self.kind, - tool = self.tool, - start_port = new_start, - end_port = new_end, - data = self.data, - ) - - def mirrored(self, axis: int) -> 'RenderStep': - """ - Return a new RenderStep with mirrored start and end ports. - """ - new_start = self.start_port.copy() - new_end = self.end_port.copy() - - new_start.flip_across(axis=axis) - new_end.flip_across(axis=axis) - - return RenderStep( - kind = self.kind, - tool = self.tool, - start_port = new_start, - end_port = new_end, - data = self.data, - ) - - -class Tool(ABC): - """ - Interface for path (e.g. wire or waveguide) generation. - - Subclasses must override `primitive_offers()` and explicitly return `()` - for recognized primitive kinds they do not support. - - Custom tools should return concrete offer objects (`StraightOffer`, - `BendOffer`, `SOffer`, or `UOffer`) rather than parsing offer identity from - strings after construction. - """ - @abstractmethod - def primitive_offers( +class Tool: + def path( self, - kind: PrimitiveKind, + ccw: Optional[bool], + length: float, *, - in_ptype: str | None = None, - out_ptype: str | None = None, + in_ptype: Optional[str] = None, + out_ptype: Optional[str] = None, + port_names: Sequence[str] = ('A', 'B'), **kwargs, - ) -> tuple[PrimitiveOffer, ...]: - """ - Return local primitive offers available for the requested route role. + ) -> 'Device': + raise NotImplementedError(f'path() not implemented for {type(self)}') - Tools override this to expose multiple legal primitive variants with explicit domains and costs. - Direct offer implementations should declare the actual endpoint ptype produced by the offer when it - can differ from the requested value. - - `kind` is one of: - - `'straight'`: a non-turning `StraightOffer` - - `'bend'`: a 90-degree `BendOffer`; `ccw` is supplied in `kwargs` - - `'s'`: a non-turning `SOffer` - - `'u'`: an `UOffer` - - Every returned offer must have the requested canonical `kind` and use - its corresponding concrete offer class. Contract mismatches are fatal - rather than recoverable candidate rejection. - - Other `kwargs` come only from the Pather call's explicit - `tool_options` mapping. Tools are responsible for validating the keys - they support. - - `Pather` applies any requested `out_ptype` to the final route endpoint, - not to every primitive in the route. Intermediate ptypes are - solver-selected, and heterogeneous straight/S offers may be used as - adapters when legal. - """ - raise NotImplementedError - - @abstractmethod - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - ) -> ILibrary: - """ - Render a compatible batch of selected route steps into geometry. - - `Pather.render()` passes batches that share one Tool and are continuous - in layout coordinates. The returned tree must have one top cell whose - input and output ports are named by `port_names`; `Pather` plugs the - input port into the pending route start and validates the output port - against the planned final endpoint. - - Args: - batch: A sequence of `RenderStep` objects containing committed - primitive render data. - port_names: The topcell's input and output ports should be named - `port_names[0]` and `port_names[1]` respectively. - """ - raise NotImplementedError - - -GeneratedPrimitiveFn = Callable[..., Pattern | Library] -GeneratedEndpointFn = Callable[[float], Port] - - -def circular_arc_sbend_endpoint(radius: float, ptype: str) -> GeneratedEndpointFn: - """ - Return an S-bend endpoint planner for two abutting circular arcs. - - The returned callback assumes a pure generated S-bend made from two equal - circular arcs with no attached straight or non-circular pieces. Positive - and negative jogs are supported; the output rotation is always `pi`. - """ - rr = float(radius) - if not scalar_isfinite(rr) or rr <= 0: - raise BuildError(f'S-bend radius must be positive and finite, got {rr:g}') - - def endpoint(jog: float) -> Port: - jj = float(jog) - jog_magnitude = abs(jj) - if scalar_isclose(jog_magnitude, 0.0, rel_tol=1e-9, abs_tol=1e-12): - return Port((0, 0), rotation=pi, ptype=ptype) - if jog_magnitude > 2 * rr and not scalar_isclose(jog_magnitude, 2 * rr, rel_tol=1e-9, abs_tol=1e-12): - raise BuildError(f'S-bend jog magnitude {jog_magnitude:g} exceeds diameter {2 * rr:g}') - dx = sqrt(max(0.0, 4 * rr * jog_magnitude - jog_magnitude ** 2)) - return Port((dx, jj), rotation=pi, ptype=ptype) - - return endpoint - - -@dataclass -class AutoTool(Tool): - """ - A routing tool assembled from reusable path primitives. - - `AutoTool` chooses among straight generators, pre-rendered bends, optional - generated S-bend primitives, pre-rendered U-turns, and - pre-rendered transitions registered through `add_straight()`, - `add_bend()`, `add_sbend()`, `add_uturn()`, and `add_transition()`. - - Primitive selection uses each registration's explicit `cost` policy rather - than registration order. Numeric costs scale the default geometric cost; - callable costs replace it. - - Straight and bend offers use one straight and, if turning, one bend. - `add_sbend()` exposes generated S-bend primitives. `add_uturn()` exposes - reusable U-turn primitives; otherwise U-turns are left to `Pather`'s - composed-route planning. - - Transitions are bidirectional by default: `add_transition(external, - internal)` exposes adapter offers in both directions. Pass `one_way=True` - when only the declared direction should be available. - - Straight and S-bend generator functions may return either a `Pattern` or a - single-top `Library`. Route `tool_options` are snapshotted into committed - generated data and passed to each selected generator as keyword arguments. - These options are render-only: they may change geometry, layers, or - annotations, but must not change generated port topology or endpoints. - """ - - @dataclass(frozen=True, slots=True) - class GeneratedData: - """Deferred generator call, including its route-specific option snapshot.""" - fn: GeneratedPrimitiveFn - port_name: str - parameter: float - mirrored: bool = False - tool_options: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) - - @dataclass(frozen=True, slots=True) - class ReusableData: - """ Deferred render data for one reusable abstract primitive offer. """ - abstract: Abstract - port_name: str - mirrored: bool = False - - bbox_library: Mapping[str, Pattern] | None = None - """ Optional source library used to resolve reusable refs during `bbox_at()` measurement. """ - - _straight_offers: list[PrimitiveOffer] = field( - default_factory = list, - init = False, - repr = False, - ) - _bend_offers: tuple[list[PrimitiveOffer], list[PrimitiveOffer]] = field( - default_factory = lambda: ([], []), - init = False, - repr = False, - ) - _s_offers: list[PrimitiveOffer] = field( - default_factory = list, - init = False, - repr = False, - ) - _u_offers: list[PrimitiveOffer] = field( - default_factory = list, - init = False, - repr = False, - ) - _transition_adapter_offers_by_key: dict[tuple[Literal['straight', 's'], str], list[PrimitiveOffer]] = field( - default_factory=dict, - init = False, - repr = False, - ) - _transition_adapter_offer_keys: set[tuple[int, str, str]] = field( - default_factory=set, - init = False, - repr = False, - ) - - @staticmethod - def _sample_positive_parameter(parameter_domain: tuple[float, float], route_name: str) -> float: - """Choose a finite positive value for generator metadata inference.""" - lower, upper = (float(parameter_domain[0]), float(parameter_domain[1])) - if lower > upper or (lower == upper and lower <= 0): - raise BuildError(f'{route_name} inference requires a positive in-domain sample') - if lower == upper: - return lower - sample_lower = max(lower, 0.0) - preferred = sample_lower if sample_lower > 0 else 1.0 - if numpy.isfinite(upper): - if upper <= sample_lower: - raise BuildError(f'{route_name} inference requires a positive in-domain sample') - return preferred if preferred < upper else (sample_lower + upper) / 2 - return preferred - - @staticmethod - def _generated_pattern(fn: GeneratedPrimitiveFn, parameter: float) -> Pattern: - generated = fn(parameter) - return generated if isinstance(generated, Pattern) else generated.top_pattern() - - @staticmethod - def _two_port_names(pattern: Pattern, route_name: str) -> tuple[str, str]: - port_names = tuple(pattern.ports.keys()) - if len(port_names) != 2: - raise BuildError(f'{route_name} inference requires a generated example with exactly two ports') - return port_names - - @staticmethod - def _resolve_equivalent_ptype( - in_port: Port, - out_port: Port, - ptype: str | None, - route_name: str, - ) -> str | None: - if not ptypes_compatible(in_port.ptype, out_port.ptype): - raise BuildError(f'{route_name} inference requires equivalent port ptypes') - if ptype is not None: - if not ptypes_compatible(in_port.ptype, ptype) or not ptypes_compatible(out_port.ptype, ptype): - raise BuildError(f'{route_name} ptype does not match generated example ports') - return ptype - return in_port.ptype if in_port.ptype not in (None, 'unk') else out_port.ptype - - @staticmethod - def _measure_opposite_ports( - in_port: Port, - out_port: Port, - route_name: str, - ) -> tuple[NDArray[numpy.float64], float]: - dxy, angle = in_port.measure_travel(out_port) - if angle is None: - raise BuildError(f'{route_name} inference requires generated ports with rotations') - normalized_angle = angle % (2 * pi) - if not (numpy.isclose(normalized_angle, pi)): - raise BuildError(f'{route_name} inference requires opposite generated port rotations') - return dxy, angle - - def _infer_straight_metadata( - self, - fn: GeneratedPrimitiveFn, - length_range: tuple[float, float], - ptype: str | None, - in_port_name: str | None, - ) -> tuple[str | None, str]: - if ptype is not None and in_port_name is not None: - return ptype, in_port_name - - sample = self._sample_positive_parameter(length_range, 'straight') - pattern = self._generated_pattern(fn, sample) - first_name, second_name = self._two_port_names(pattern, 'straight') - candidate_names = ( - (in_port_name, second_name if in_port_name == first_name else first_name), - ) if in_port_name is not None else ( - (first_name, second_name), - (second_name, first_name), - ) - for candidate_in, candidate_out in candidate_names: - if candidate_in not in pattern.ports or candidate_out not in pattern.ports: - continue - in_port = pattern.ports[candidate_in] - out_port = pattern.ports[candidate_out] - dxy, _angle = self._measure_opposite_ports(in_port, out_port, 'straight') - if dxy[0] > 0 and numpy.isclose(dxy[1], 0): - return ( - self._resolve_equivalent_ptype(in_port, out_port, ptype, 'straight'), - candidate_in, - ) - raise BuildError('straight inference requires an equivalent two-port straight example') - - def _infer_sbend_metadata( - self, - fn: GeneratedPrimitiveFn, - jog_range: tuple[float, float], - ptype: str | None, - in_port_name: str | None, - out_port_name: str | None, - ) -> tuple[str | None, str, str]: - if ptype is not None and in_port_name is not None and out_port_name is not None: - return ptype, in_port_name, out_port_name - - sample = self._sample_positive_parameter(jog_range, 'S-bend') - pattern = self._generated_pattern(fn, sample) - first_name, second_name = self._two_port_names(pattern, 'S-bend') - if in_port_name is not None and out_port_name is None: - out_port_name = second_name if in_port_name == first_name else first_name - elif in_port_name is None and out_port_name is not None: - in_port_name = second_name if out_port_name == first_name else first_name - - candidate_names = ( - (in_port_name, out_port_name), - ) if in_port_name is not None and out_port_name is not None else ( - (first_name, second_name), - (second_name, first_name), - ) - for candidate_in, candidate_out in candidate_names: - if candidate_in not in pattern.ports or candidate_out not in pattern.ports: - continue - in_port = pattern.ports[candidate_in] - out_port = pattern.ports[candidate_out] - dxy, _angle = self._measure_opposite_ports(in_port, out_port, 'S-bend') - if dxy[0] > 0 and numpy.isclose(dxy[1], sample): - return ( - self._resolve_equivalent_ptype(in_port, out_port, ptype, 'S-bend'), - candidate_in, - candidate_out, - ) - raise BuildError('S-bend inference requires an equivalent two-port S-bend example') - - def add_straight( - self, - fn: GeneratedPrimitiveFn, - ptype: str | None = None, - in_port_name: str | None = None, - *, - length_range: tuple[float, float] = (0, numpy.inf), - cost: CostCallable | float = 1.0, - ) -> Self: - """ - Register a generated straight primitive. - - If `ptype` or `in_port_name` is omitted, one in-domain example is - generated and the missing metadata is inferred from an equivalent - two-port straight. Metadata inference does not receive route options. - """ - cost = _validated_cost(cost) - length_range = _validated_parameter_domain( - length_range, - name='Straight length_range', - nonnegative_minimum=True, - ) - ptype, in_port_name = self._infer_straight_metadata(fn, length_range, ptype, in_port_name) - - def data_at(length: float) -> AutoTool.GeneratedData: - return self.GeneratedData(fn, in_port_name, length) - - self._straight_offers.append(StraightOffer.generated( - ptype, - data_at, - cost = cost, - bbox_for_data = self._bbox_for_data, - length_domain = length_range, - )) - return self - - def add_bend( - self, - abstract: Abstract, - in_port_name: str | None = None, - out_port_name: str | None = None, - *, - clockwise: bool | None = None, - mirror: bool = True, - cost: CostCallable | float = 1.0, - ) -> Self: - """ - Register a reusable L-bend primitive. - - If the bend has exactly two ports, port names may be omitted. The bend - direction is inferred from the selected port orientations; `clockwise`, - when provided, is checked against that inferred direction. - """ - cost = _validated_cost(cost) - if (in_port_name is None) != (out_port_name is None): - raise BuildError('Bend port names must be provided together') - if in_port_name is None or out_port_name is None: - port_names = tuple(abstract.ports.keys()) - if len(port_names) != 2: - raise BuildError(f'Bend port names are required for {len(port_names)}-port abstracts') - in_port_name, out_port_name = port_names - - in_port = abstract.ports[in_port_name] - out_port = abstract.ports[out_port_name] - source_clockwise = self._bend_clockwise(in_port, out_port) - if clockwise is not None and bool(clockwise) != source_clockwise: - raise BuildError('Bend clockwise argument does not match port orientations') - - for ccw in (False, True): - target_clockwise = not bool(ccw) - source_matches_target = source_clockwise == target_clockwise - if source_matches_target or mirror: - entry_port_name = in_port_name - entry_port, exit_port = in_port, out_port - geometry_clockwise = source_clockwise - else: - entry_port_name = out_port_name - entry_port, exit_port = out_port, in_port - geometry_clockwise = self._bend_clockwise(entry_port, exit_port) - - bend_dxy, bend_angle = self._bend2dxy( - entry_port, - exit_port, - geometry_clockwise, - target_clockwise, - ) - bend_dx = float(bend_dxy[0]) - bend_dy = float(bend_dxy[1]) - mirrored = mirror and not source_matches_target - reusable_data = self.ReusableData(abstract, entry_port_name, mirrored) - endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=exit_port.ptype) - - self._bend_offers[int(ccw)].append(BendOffer.prebuilt( - in_ptype = entry_port.ptype, - out_ptype = exit_port.ptype, - endpoint = endpoint, - data = reusable_data, - ccw = ccw, - cost = cost, - bbox_for_data = self._bbox_for_data, - )) - return self - - def add_sbend( - self, - fn: GeneratedPrimitiveFn, - ptype: str | None = None, - in_port_name: str | None = None, - out_port_name: str | None = None, - *, - jog_range: tuple[float, float] = (0, numpy.inf), - endpoint: GeneratedEndpointFn | None = None, - cost: CostCallable | float = 1.0, - ) -> Self: - """ - Register a generated S-bend primitive. - - `endpoint`, when supplied, describes the generated S-bend output port - directly during planning and avoids instantiating `fn()` inside - `endpoint_at()`. - - If `ptype` or port names are omitted, one in-domain example is generated - and the missing metadata is inferred from an equivalent two-port S-bend. - Metadata inference and endpoint planning do not receive route options. - """ - cost = _validated_cost(cost) - jog_range = _validated_parameter_domain( - jog_range, - name='S-bend jog_range', - nonnegative_minimum=True, - ) - ptype, in_port_name, out_port_name = self._infer_sbend_metadata( - fn, - jog_range, - ptype, - in_port_name, - out_port_name, - ) - if endpoint is None: - def endpoint_at(jog: float) -> Port: - jog_magnitude = abs(jog) - sbend_dxy = self._sbend2dxy(fn, in_port_name, out_port_name, jog_magnitude) - return Port((float(sbend_dxy[0]), float(jog)), rotation=pi, ptype=ptype) - else: - def endpoint_at(jog: float) -> Port: - return endpoint(jog) - - for jog_domain in self._signed_jog_domains(jog_range): - def data_at(jog: float) -> AutoTool.GeneratedData: - return self.GeneratedData( - fn, - in_port_name, - abs(jog), - mirrored = jog < 0, - ) - - self._s_offers.append(SOffer.generated( - ptype, - endpoint_at, - data_at, - cost = cost, - bbox_for_data = self._bbox_for_data, - jog_domain = jog_domain, - )) - return self - - def add_uturn( - self, - abstract: Abstract, - in_port_name: str, - out_port_name: str, - *, - mirror: bool = True, - cost: CostCallable | float = 1.0, - ) -> Self: - """ - Register a reusable U-turn primitive. - """ - cost = _validated_cost(cost) - in_port = abstract.ports[in_port_name] - out_port = abstract.ports[out_port_name] - dxy, angle = in_port.measure_travel(out_port) - if angle is None: - raise BuildError('U-turn primitive output port must have the same route-frame rotation as its input port') - normalized_angle = angle % (2 * pi) - if not (numpy.isclose(normalized_angle, 0) or numpy.isclose(normalized_angle, 2 * pi)): - raise BuildError('U-turn primitive output port must have the same route-frame rotation as its input port') - - length = float(dxy[0]) - jog = float(dxy[1]) - out_ptype = out_port.ptype - - def add_offer( - offer_jog: float, - *, - mirrored: bool, - ) -> None: - reusable_data = self.ReusableData(abstract, in_port_name, mirrored) - endpoint = Port((length, offer_jog), rotation=0, ptype=out_ptype) - - self._u_offers.append(UOffer.prebuilt( - in_ptype = in_port.ptype, - out_ptype = out_ptype, - endpoint = endpoint, - data = reusable_data, - cost = cost, - bbox_for_data = self._bbox_for_data, - )) - - add_offer(jog, mirrored=False) - if mirror and not numpy.isclose(jog, 0): - add_offer(-jog, mirrored=True) - return self - - def add_transition( - self, - abstract: Abstract, - their_port_name: str | None = None, - our_port_name: str | None = None, - *, - one_way: bool = False, - cost: CostCallable | float = 1.0, - ) -> Self: - """ - Register a reusable port-type transition and expose it as router-visible adapter offers. - - If the transition has exactly two ports and is bidirectional, port names - may be omitted. - """ - cost = _validated_cost(cost) - if (their_port_name is None) != (our_port_name is None): - raise BuildError('Transition port names must be provided together') - if their_port_name is None or our_port_name is None: - if one_way: - raise BuildError('one-way transitions require explicit port names') - port_names = tuple(abstract.ports.keys()) - if len(port_names) != 2: - raise BuildError(f'Transition port names are required for {len(port_names)}-port abstracts') - their_port_name, our_port_name = port_names - - self._add_transition_direction(abstract, their_port_name, our_port_name, cost) - if not one_way: - self._add_transition_direction(abstract, our_port_name, their_port_name, cost) - return self - - @staticmethod - def _bend_clockwise(in_port: Port, out_port: Port) -> bool: - """Return true when the selected reusable bend port order turns clockwise.""" - _bend_dxy, bend_angle = in_port.measure_travel(out_port) - if bend_angle is None: - raise BuildError('Bend primitive output port must have a 90-degree rotation from its input port') - normalized_angle = bend_angle % (2 * pi) - if numpy.isclose(normalized_angle, pi / 2): - return True - if numpy.isclose(normalized_angle, 3 * pi / 2): - return False - raise BuildError('Bend primitive output port must have a 90-degree rotation from its input port') - - @staticmethod - def _bend2dxy( - in_port: Port, - out_port: Port, - source_clockwise: bool, - target_clockwise: bool, - ) -> tuple[NDArray[numpy.float64], float]: - bend_dxy, bend_angle = in_port.measure_travel(out_port) - assert bend_angle is not None - if source_clockwise != target_clockwise: - bend_dxy[1] *= -1 - bend_angle *= -1 - return bend_dxy, bend_angle - - @staticmethod - def _wildcard_ptype_key(ptype: str | None) -> str: - return 'unk' if ptype in (None, 'unk') else ptype - - @staticmethod - def _sbend2dxy( - fn: GeneratedPrimitiveFn, - in_port_name: str, - out_port_name: str, - jog_magnitude: float, - ) -> NDArray[numpy.float64]: - if numpy.isclose(jog_magnitude, 0): - return numpy.zeros(2) - - sbend_pat_or_tree = fn(jog_magnitude) - sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() - dxy, _ = sbpat[in_port_name].measure_travel(sbpat[out_port_name]) - return dxy - - def _rendered_bbox(self, render: Callable[[ILibrary, tuple[str, str]], Any]) -> NDArray[numpy.float64]: - port_names = ('A', 'B') - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_bbox') - pat.add_port_pair(names=port_names) - render(tree, port_names) - - if self.bbox_library is None: - library: Mapping[str, Pattern] = tree - else: - library = ChainMap(dict(tree), self.bbox_library) - - try: - bounds = pat.get_bounds(library=library) - except KeyError as err: - raise NotImplementedError( - 'AutoTool bbox_at() requires bbox_library to resolve reusable primitive refs' - ) from err - if bounds is None: - return numpy.zeros((2, 2), dtype=float) - return numpy.asarray(bounds, dtype=float) - - def _bbox_for_data(self, data: Any) -> NDArray[numpy.float64]: - return self._rendered_bbox(lambda tree, names: self._render_data(data, tree, names)) - - def _add_transition_direction( - self, - abstract: Abstract, - their_port_name: str, - our_port_name: str, - cost: CostCallable | float, - ) -> None: - their_port = abstract.ports[their_port_name] - our_port = abstract.ports[our_port_name] - transition_data = self.ReusableData(abstract, their_port_name) - - key = ( - id(abstract), - their_port_name, - our_port_name, - ) - if key in self._transition_adapter_offer_keys: - return - self._transition_adapter_offer_keys.add(key) - - dxy, angle = their_port.measure_travel(our_port) - if angle is None or not numpy.isclose(angle, pi): - return - - dx = float(dxy[0]) - dy = float(dxy[1]) - kind: Literal['straight', 's'] = 'straight' if numpy.isclose(dy, 0) else 's' - in_key = self._wildcard_ptype_key(their_port.ptype) - endpoint = Port((dx, dy), rotation=pi, ptype=our_port.ptype) - - offers = self._transition_adapter_offers_by_key.setdefault((kind, in_key), []) - if kind == 'straight': - offers.append(StraightOffer.prebuilt( - in_ptype = their_port.ptype, - out_ptype = our_port.ptype, - endpoint = endpoint, - data = transition_data, - cost = cost, - bbox_for_data = self._bbox_for_data, - )) - return - - offers.append(SOffer.prebuilt( - in_ptype = their_port.ptype, - out_ptype = our_port.ptype, - endpoint = endpoint, - data = transition_data, - cost = cost, - bbox_for_data = self._bbox_for_data, - )) - - @staticmethod - def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]: - lower, upper = _validated_parameter_domain( - magnitude_range, - name='S-bend jog_range', - nonnegative_minimum=True, - ) - - if lower == upper: - if lower == 0: - return ((0.0, 0.0),) - return ((lower, lower), (-lower, -lower)) - - positive = (lower, upper) - neg_lower = -numpy.inf if numpy.isinf(upper) else float(numpy.nextafter(-upper, numpy.inf)) - negative = (neg_lower, -lower) - domains: list[tuple[float, float]] = [positive] - if neg_lower < -lower: - domains.append(negative) - if lower > 0: - domains.append((-lower, -lower)) - return tuple(domains) - - def primitive_offers( - self, - kind: PrimitiveKind, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[PrimitiveOffer, ...]: - _ = out_ptype - ccw = kwargs.pop('ccw', None) - try: - tool_options = MappingProxyType(deepcopy(dict(kwargs))) - except Exception as err: - raise BuildError('AutoTool tool_options must be deep-copyable') from err - - def configured(offer: PrimitiveOffer) -> PrimitiveOffer: - if not tool_options: - return offer - - def commit(parameter: float) -> Any: - data = offer.commit(parameter) - if not isinstance(data, self.GeneratedData): - raise BuildError('AutoTool generated offer returned unexpected commit data') - return replace(data, tool_options=tool_options) - - def bbox(parameter: float) -> NDArray[numpy.float64]: - return self._bbox_for_data(commit(parameter)) - - return replace( - offer, - commit_planner=commit, - bbox_planner=bbox if offer.bbox_planner is not None else None, - ) - - if kind == 'straight': - in_key = self._wildcard_ptype_key(in_ptype) - return ( - *self._transition_adapter_offers_by_key.get(('straight', in_key), ()), - *(configured(offer) for offer in self._straight_offers), - ) - - if kind == 'bend': - if ccw is None: - raise BuildError('AutoTool bend offer discovery requires ccw') - return tuple(self._bend_offers[int(bool(ccw))]) - - if kind == 's': - in_key = self._wildcard_ptype_key(in_ptype) - return ( - *self._transition_adapter_offers_by_key.get(('s', in_key), ()), - *(configured(offer) for offer in self._s_offers), - ) - - if kind == 'u': - return tuple(self._u_offers) - raise BuildError(f'Unrecognized primitive offer kind {kind!r}') - - def _render_generated( - self, - data: GeneratedData, - tree: ILibrary, - port_names: tuple[str, str], - ) -> ILibrary: - if numpy.isclose(data.parameter, 0): - return tree - - pat = tree.top_pattern() - generated = data.fn(data.parameter, **data.tool_options) - pmap = {port_names[1]: data.port_name} - if isinstance(generated, Pattern): - pat.plug(generated, pmap, append=True, mirrored=data.mirrored) - else: - top = generated.top() - generated.flatten(top, dangling_ok=True) - pat.plug(generated[top], pmap, append=True, mirrored=data.mirrored) - return tree - - def _render_reusable( - self, - data: ReusableData, - tree: ILibrary, - port_names: tuple[str, str], - ) -> ILibrary: - pat = tree.top_pattern() - pat.plug(data.abstract, {port_names[1]: data.port_name}, mirrored=data.mirrored) - return tree - - def _render_data( - self, - data: Any, - tree: ILibrary, - port_names: tuple[str, str], - ) -> ILibrary: - if isinstance(data, self.GeneratedData): - return self._render_generated(data=data, tree=tree, port_names=port_names) - if isinstance(data, self.ReusableData): - return self._render_reusable(data=data, tree=tree, port_names=port_names) - raise BuildError(f'Unexpected AutoTool render data {type(data).__name__}') - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - ) -> ILibrary: - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.add_port_pair(names=port_names) - - for step in batch: - assert step.tool == self - self._render_data(step.data, tree, port_names) - return tree - - -@dataclass -class PathTool(Tool): - """ - Tool that renders routes directly as `Pattern.path()` geometry. - - `PathTool` supports L and S primitive offers. `render()` combines a - compatible batch of L/S `RenderStep`s into one multi-vertex path. U routes - are left to `Pather` synthesis or to a different tool. - """ - layer: layer_t - """ Layer to draw generated path geometry on. """ - - width: float - """ Width of generated path geometry. """ - - ptype: str = 'unk' - """ Port type for generated input and output ports. """ - - def _bend_radius(self) -> float: - return self.width / 2 - - def _plan_l_vertices(self, length: float, bend_run: float) -> NDArray[numpy.float64]: - vertices = [(0.0, 0.0), (length, 0.0)] - if not numpy.isclose(bend_run, 0): - vertices.append((length, bend_run)) - return numpy.array(vertices, dtype=float) - - def _plan_s_vertices(self, length: float, jog: float) -> NDArray[numpy.float64]: - if numpy.isclose(jog, 0): - return numpy.array([(0.0, 0.0), (length, 0.0)], dtype=float) - - if length < self.width: - raise BuildError( - f'Asked to draw S-path with total length {length:,g}, shorter than required bend: {self.width:,g}' - ) - - # Match AutoTool's straight-then-s-bend placement so the jog happens - # width/2 before the end while still allowing smaller lateral offsets. - jog_x = length - self._bend_radius() - vertices = [ - (0.0, 0.0), - (jog_x, 0.0), - (jog_x, jog), - (length, jog), - ] - return numpy.array(vertices, dtype=float) - - def _path_bbox(self, vertices: NDArray[numpy.float64]) -> NDArray[numpy.float64]: - return Path(vertices=vertices, width=self.width).get_bounds_single() - - def primitive_offers( - self, - kind: PrimitiveKind, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[PrimitiveOffer, ...]: - allowed = {'ccw'} if kind == 'bend' else set() - unsupported = sorted(set(kwargs) - allowed) - if unsupported: - raise BuildError(f'PathTool does not support tool options: {", ".join(unsupported)}') - if kind == 'u': - return () - - if not ptypes_compatible(out_ptype, self.ptype): - raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') - ptype = self.ptype - - if kind == 'straight': - def straight_data(length: float) -> NDArray[numpy.float64]: - return numpy.array((length, 0.0)) - - def endpoint_straight(length: float) -> Port: - data = straight_data(length) - return Port(data, rotation=pi, ptype=ptype) - - def bbox_straight(length: float) -> NDArray[numpy.float64]: - data = straight_data(length) - return self._path_bbox(self._plan_l_vertices(float(data[0]), float(data[1]))) - - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=ptype, - bbox_planner=bbox_straight, - endpoint_planner=endpoint_straight, - commit_planner=straight_data, - ),) - - if kind == 'bend': - ccw = kwargs['ccw'] - radius = self._bend_radius() - bend_run = radius if bool(ccw) else -radius - bend_angle = -pi / 2 if bool(ccw) else pi / 2 - - def bend_data(length: float) -> NDArray[numpy.float64]: - _ = length - return numpy.array((length, bend_run)) - - def endpoint_bend(length: float) -> Port: - data = bend_data(length) - return Port(data, rotation=bend_angle, ptype=ptype) - - def bbox_bend(length: float) -> NDArray[numpy.float64]: - data = bend_data(length) - return self._path_bbox(self._plan_l_vertices(float(data[0]), float(data[1]))) - - return (BendOffer( - in_ptype=in_ptype, - out_ptype=ptype, - ccw=bool(ccw), - length_domain=(radius, radius), - bbox_planner=bbox_bend, - endpoint_planner=endpoint_bend, - commit_planner=bend_data, - ),) - - if kind == 's': - def minimum_length(jog: float) -> float: - if numpy.isclose(jog, 0): - return 0.0 - return self.width - - def s_data(jog: float) -> NDArray[numpy.float64]: - length = minimum_length(jog) - self._plan_s_vertices(length, jog) - return numpy.array((length, jog)) - - def endpoint_s(jog: float) -> Port: - data = s_data(jog) - return Port(data, rotation=pi, ptype=ptype) - - def bbox_s(jog: float) -> NDArray[numpy.float64]: - data = s_data(jog) - return self._path_bbox(self._plan_s_vertices(float(data[0]), float(data[1]))) - - return (SOffer( - in_ptype=in_ptype, - out_ptype=ptype, - bbox_planner=bbox_s, - endpoint_planner=endpoint_s, - commit_planner=s_data, - ),) - - raise BuildError(f'Unrecognized primitive offer kind {kind!r}') - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - ) -> ILibrary: - - # Transform the batch so the first port is local (at 0,0) but retains its global rotation. - # This allows the path to be rendered with its original orientation, simplified by - # translation to the origin. Pather.render will handle the final placement - # (including rotation alignment) via `pat.plug`. - first_port = batch[0].start_port - translation = -first_port.offset - rotation = 0 - pivot = first_port.offset - - # Localize the batch for rendering - local_batch = [step.transformed(translation, rotation, pivot) for step in batch] - - path_vertices = [local_batch[0].start_port.offset] - for step in local_batch: - assert step.tool == self - - port_rot = step.start_port.rotation - # Masque convention: Port rotation points INTO the device. - # So the direction of travel for the path is AWAY from the port, i.e., port_rot + pi. - assert port_rot is not None - transform = rotation_matrix_2d(port_rot + pi) - delta = step.end_port.offset - step.start_port.offset - local_end = rotation_matrix_2d(-(port_rot + pi)) @ delta - if step.kind in ('straight', 'bend'): - local_vertices = self._plan_l_vertices(float(local_end[0]), float(local_end[1])) - elif step.kind == 's': - local_vertices = self._plan_s_vertices(float(local_end[0]), float(local_end[1])) - else: - raise BuildError(f'Unsupported PathTool render kind {step.kind!r}') - - for vertex in local_vertices[1:]: - path_vertices.append(step.start_port.offset + transform @ vertex) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.path(layer=self.layer, width=self.width, vertices=path_vertices) - pat.ports = { - port_names[0]: local_batch[0].start_port.copy().rotate(pi), - port_names[1]: local_batch[-1].end_port.copy(), - } - return tree diff --git a/masque/builder/utils.py b/masque/builder/utils.py index fab28fd..72cce67 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -1,39 +1,26 @@ -from typing import TYPE_CHECKING -from collections.abc import Mapping +from typing import Dict, Tuple, List, Optional, Union, Any, cast, Sequence, TYPE_CHECKING from pprint import pformat import numpy from numpy import pi -from numpy.typing import ArrayLike, NDArray +from numpy.typing import ArrayLike -from ..utils import rotation_matrix_2d, SupportsBool +from ..utils import rotation_matrix_2d from ..error import BuildError -from ._tolerances import manhattan_axis if TYPE_CHECKING: - from ..ports import Port - - -_EXTENSION_BOUND_TYPES = ( - 'emin', 'min_extension', - 'emax', 'max_extension', - 'min_past_furthest', - ) -_POSITION_MIN_BOUND_TYPES = ('pmin', 'min_position', 'xmin', 'ymin') -_POSITION_MAX_BOUND_TYPES = ('pmax', 'max_position', 'xmax', 'ymax') -_POSITION_BOUND_TYPES = _POSITION_MIN_BOUND_TYPES + _POSITION_MAX_BOUND_TYPES -_BOUND_TYPES = _EXTENSION_BOUND_TYPES + _POSITION_BOUND_TYPES + from .devices import Port def ell( - ports: Mapping[str, 'Port'], - ccw: SupportsBool | None, + ports: Dict[str, 'Port'], + ccw: Optional[bool], bound_type: str, - bound: float | ArrayLike, + bound: Union[float, ArrayLike], *, - spacing: float | ArrayLike | None = None, - set_rotation: float | None = None, - ) -> dict[str, numpy.float64]: + spacing: Optional[Union[float, ArrayLike]] = None, + set_rotation: Optional[float] = None, + ) -> Dict[str, float]: """ Calculate extension for each port in order to build a 90-degree bend with the provided channel spacing: @@ -58,7 +45,7 @@ def ell( ccw: Turn direction. `True` means counterclockwise, `False` means clockwise, and `None` means no bend. If `None`, spacing must remain `None` or `0` (default), Otherwise, spacing must be set to a non-`None` value. - bound_type: Method used for determining the travel distance; see diagram above. + bound_method: Method used for determining the travel distance; see diagram above. Valid values are: - 'min_extension' or 'emin': The total extension value for the furthest-out port (B in the diagram). @@ -66,9 +53,9 @@ def ell( The distance between furthest out-port (B) and the innermost bend (D's bend). - 'max_extension' or 'emax': The total extension value for the closest-in port (C in the diagram). - - 'min_position', 'pmin', 'xmin', 'ymin': + - 'min_position' or 'pmin': The coordinate of the innermost bend (D's bend). - - 'max_position', 'pmax', 'xmax', 'ymax': + - 'max_position' or 'pmax': The coordinate of the outermost bend (A's bend). `bound` can also be a vector. If specifying an extension (e.g. 'min_extension', @@ -76,7 +63,7 @@ def ell( the x- and y- axes. If specifying a position, it is projected onto the extension direction. - bound: Value associated with `bound_type`, see above. + bound_value: Value associated with `bound_type`, see above. spacing: Distance between adjacent channels. Can be scalar, resulting in evenly spaced channels, or a vector with length one less than `ports`, allowing non-uniform spacing. @@ -94,21 +81,9 @@ def ell( """ if not ports: raise BuildError('Empty port list passed to `ell()`') - if bound_type not in _BOUND_TYPES: - raise BuildError(f'Invalid bound type {bound_type!r}; expected one of {_BOUND_TYPES}') - - try: - bound_arr = numpy.asarray(bound, dtype=float) - except (TypeError, ValueError) as err: - raise BuildError('bound must be a numeric scalar or length-2 vector') from err - if bound_arr.size not in (1, 2): - raise BuildError(f'bound must be scalar or have length 2; got {bound_arr.size} values') - if not numpy.all(numpy.isfinite(bound_arr)): - raise BuildError('bound must contain only finite values') - bound_values = bound_arr.reshape(-1) if ccw is None: - if spacing is not None and not numpy.allclose(spacing, 0): + if spacing is not None and not numpy.isclose(spacing, 0): raise BuildError('Spacing must be 0 or None when ccw=None') spacing = 0 elif spacing is None: @@ -130,24 +105,10 @@ def ell( raise BuildError('Asked to find aggregation for ports that face in different directions:\n' + pformat(port_rotations)) else: - if set_rotation is None: + if set_rotation is not None: raise BuildError('set_rotation must be specified if no ports have rotations!') - if not numpy.isfinite(set_rotation): - raise BuildError('set_rotation must be finite') rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) - axis = manhattan_axis(float(rotations[0])) - if bound_type in _POSITION_BOUND_TYPES and axis is None: - raise BuildError( - 'Positional bounds require a nearly Manhattan port direction; ' - f'got rotation {rotations[0]:g}' - ) - is_horizontal = axis == 0 - if bound_type in ('ymin', 'ymax') and is_horizontal: - raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!') - if bound_type in ('xmin', 'xmax') and not is_horizontal: - raise BuildError(f'Asked for {bound_type} position but ports are pointing along the y-axis!') - direction = rotations[0] + pi # direction we want to travel in (+pi relative to port) rot_matrix = rotation_matrix_2d(-direction) @@ -155,8 +116,6 @@ def ell( orig_offsets = numpy.array([p.offset for p in ports.values()]) rot_offsets = (rot_matrix @ orig_offsets.T).T -# ordering_base = rot_offsets.T * [[1], [-1 if ccw else 1]] # could work, but this is actually a more complex routing problem -# y_order = numpy.lexsort(ordering_base) # (need to make sure we don't collide with the next input port @ same y) y_order = ((-1 if ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable') y_ind = numpy.empty_like(y_order, dtype=int) y_ind[y_order] = numpy.arange(y_ind.shape[0]) @@ -164,21 +123,8 @@ def ell( if spacing is None: ch_offsets = numpy.zeros_like(y_order) else: - spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1) - if not numpy.all(numpy.isfinite(spacing_arr)): - raise BuildError('spacing must contain only finite values') - if numpy.any(spacing_arr < 0): - raise BuildError('spacing must be nonnegative') - steps: NDArray[numpy.float64] = numpy.zeros(len(y_order), dtype=float) - if spacing_arr.size == 1: - steps[1:] = spacing_arr[0] - elif spacing_arr.size == len(ports) - 1: - steps[1:] = spacing_arr - else: - raise BuildError( - f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; ' - f'got length {spacing_arr.size}' - ) + steps = numpy.zeros_like(y_order) + steps[1:] = spacing ch_offsets = numpy.cumsum(steps)[y_ind] x_start = rot_offsets[:, 0] @@ -189,7 +135,6 @@ def ell( # D-----------| `d_to_align[3]` # d_to_align = x_start.max() - x_start # distance to travel to align all - offsets: NDArray[numpy.float64] if bound_type == 'min_past_furthest': # A------------------V `d_to_exit[0]` # B-----V `d_to_exit[1]` @@ -209,41 +154,43 @@ def ell( travel = d_to_align - (ch_offsets.max() - ch_offsets) offsets = travel - travel.min().clip(max=0) - if bound_type in _EXTENSION_BOUND_TYPES: - if numpy.any(bound_values < 0): - raise BuildError(f'Got negative bound for extension: {bound_values}') - - if bound_values.size == 2: - horizontal_weight = abs(float(numpy.cos(direction))) - vertical_weight = abs(float(numpy.sin(direction))) - use_x = horizontal_weight > vertical_weight or numpy.isclose(horizontal_weight, vertical_weight) - rot_bound = float(bound_values[0 if use_x else 1]) + if bound_type in ('emin', 'min_extension', + 'emax', 'max_extension', + 'min_past_furthest',): + if numpy.size(bound) == 2: + bound = cast(Sequence[float], bound) + rot_bound = (rot_matrix @ ((bound[0], 0), + (0, bound[1])))[0, :] else: - rot_bound = float(bound_values[0]) + bound = cast(float, bound) + rot_bound = numpy.array(bound) + + if rot_bound < 0: + raise BuildError(f'Got negative bound for extension: {rot_bound}') if bound_type in ('emin', 'min_extension', 'min_past_furthest'): - offsets += rot_bound - elif bound_type in ('emax', 'max_extension'): - offsets += rot_bound - offsets.max() + offsets += rot_bound.max() + elif bound_type in('emax', 'max_extension'): + offsets += rot_bound.min() - offsets.max() else: - if bound_values.size == 2: - rot_bound = float((rot_matrix @ bound_values)[0]) + if numpy.size(bound) == 2: + bound = cast(Sequence[float], bound) + rot_bound = (rot_matrix @ bound)[0] else: + bound = cast(float, bound) neg = (direction + pi / 4) % (2 * pi) > pi - bound_scalar = float(bound_values[0]) - rot_bound = -bound_scalar if neg else bound_scalar + rot_bound = -bound if neg else bound min_possible = x_start + offsets - if bound_type in _POSITION_MAX_BOUND_TYPES: + if bound_type in ('pmax', 'max_position'): extension = rot_bound - min_possible.max() - else: + elif bound_type in ('pmin', 'min_position'): extension = rot_bound - min_possible.min() offsets += extension if extension < 0: - ext_floor = -numpy.floor(extension) - raise BuildError(f'Position is too close by at least {ext_floor}. Total extensions would be\n\t' - + '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets, strict=True))) + raise BuildError(f'Position is too close by at least {-numpy.floor(extension)}. Total extensions would be' + + '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets))) - result = dict(zip(ports.keys(), offsets, strict=True)) + result = dict(zip(ports.keys(), offsets)) return result diff --git a/masque/error.py b/masque/error.py index e475bb0..54290f9 100644 --- a/masque/error.py +++ b/masque/error.py @@ -1,10 +1,3 @@ -import traceback -import pathlib - - -MASQUE_DIR = str(pathlib.Path(__file__).parent) - - class MasqueError(Exception): """ Parent exception for all Masque-related Exceptions @@ -18,6 +11,13 @@ class PatternError(MasqueError): """ pass +class PatternLockedError(PatternError): + """ + Exception raised when trying to modify a locked pattern + """ + def __init__(self): + PatternError.__init__(self, 'Tried to modify a locked Pattern, subpattern, or shape') + class LibraryError(MasqueError): """ @@ -26,70 +26,22 @@ class LibraryError(MasqueError): pass +class DeviceLibraryError(MasqueError): + """ + Exception raised by DeviceLibrary classes + """ + pass + + +class DeviceError(MasqueError): + """ + Exception raised by Device and Port objects + """ + pass + + class BuildError(MasqueError): """ Exception raised by builder-related functions """ pass - - -class PortError(MasqueError): - """ - Exception raised by port-related functions - """ - pass - - -class OneShotError(MasqueError): - """ - Exception raised when a function decorated with `@oneshot` is called more than once - """ - def __init__(self, func_name: str) -> None: - Exception.__init__(self, f'Function "{func_name}" with @oneshot was called more than once') - - -def format_stacktrace( - stacklevel: int = 1, - *, - skip_file_prefixes: tuple[str, ...] = (MASQUE_DIR,), - low_file_prefixes: tuple[str, ...] = (''), - low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', 'concurrent/futures/process.py'), - ) -> str: - """ - Utility function for making nicer stack traces (e.g. excluding and similar) - - Args: - stacklevel: Number of frames to remove from near this function (default is to - show caller but not ourselves). Similar to `warnings.warn` and `logging.warning`. - skip_file_prefixes: Indicates frames to ignore after counting stack levels; similar - to `warnings.warn` *TODO check if this is actually the same effect re:stacklevel*. - Forces stacklevel to max(2, stacklevel). - Default is to exclude anything within `masque`. - low_file_prefixes: Indicates frames to ignore on the other (entry-point) end of the stack, - based on prefixes on their filenames. - low_file_suffixes: Indicates frames to ignore on the other (entry-point) end of the stack, - based on suffixes on their filenames. - - Returns: - Formatted trimmed stack trace - """ - if skip_file_prefixes: - stacklevel = max(2, stacklevel) - - stack = traceback.extract_stack() - - bad_inds = [ii + 1 for ii, frame in enumerate(stack) - if frame.filename.startswith(low_file_prefixes) or frame.filename.endswith(low_file_suffixes)] - first_ok = max([0] + bad_inds) - - last_ok = -stacklevel - 1 - while last_ok >= -len(stack) and stack[last_ok].filename.startswith(skip_file_prefixes): - last_ok -= 1 - - if selected := stack[first_ok:last_ok + 1]: - pass - elif selected := stack[:-stacklevel]: - pass # noqa: SIM114 # separate elif for clarity - else: - selected = stack - return ''.join(traceback.format_list(selected)) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0a04f61..4a6b9e3 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -1,56 +1,45 @@ """ DXF file format readers and writers - -Notes: - * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) - * ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID - to unique values, so byte-for-byte reproducibility is not achievable for now """ -from typing import Any, cast, TextIO, IO, Literal -from collections import defaultdict -from collections.abc import Mapping, Callable, Sequence +from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable +import re import io +import base64 +import struct import logging import pathlib import gzip -import numpy -from numpy.typing import NDArray -import ezdxf -from ezdxf import edgeminer -from ezdxf.math import Vec3 -from ezdxf.enums import TextEntityAlignment -from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace, Line +import numpy # type: ignore +import ezdxf # type: ignore -from .utils import is_gzipped, tmpfile -from .. import Pattern, Ref, PatternError, Label -from ..library import ILibraryView, LibraryView, Library -from ..shapes import Shape, Polygon, Path +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path from ..repetition import Grid -from ..utils import rotation_matrix_2d, layer_t, normalize_mirror -from ..utils.boolean import _polytree_to_polygons +from ..utils import rotation_matrix_2d, layer_t logger = logging.getLogger(__name__) -logger.warning('DXF support is experimental!') +logger.warning('DXF support is experimental and only slightly tested!') DEFAULT_LAYER = 'DEFAULT' def write( - library: Mapping[str, Pattern], # TODO could allow library=None for flat DXF - top_name: str, - stream: TextIO, + pattern: Pattern, + stream: io.TextIOBase, *, - dxf_version: str = 'AC1024', + modify_originals: bool = False, + dxf_version='AC1024', + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, ) -> None: """ Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes into polygons, and then writing patterns as DXF `Block`s, polygons as `LWPolyline`s, - and refs as `Insert`s. + and subpatterns as `Insert`s. The top level pattern's name is not written to the DXF file. Nested patterns keep their names. @@ -60,60 +49,60 @@ def write( tuple: (1, 2) -> '1.2' str: '1.2' -> '1.2' (no change) - Shape repetitions are expanded into individual DXF entities. + It is often a good idea to run `pattern.subpatternize()` prior to calling this function, + especially if calling `.polygonize()` will result in very many vertices. - Other functions you may want to call: - - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names - - `library.dangling_refs()` to check for references to missing patterns - - `pattern.polygonize()` for any patterns with shapes other - than `masque.shapes.Polygon` or `masque.shapes.Path` + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. Only `Grid` repetition objects with manhattan basis vectors are preserved as arrays. Since DXF rotations apply to basis vectors while `masque`'s rotations do not, the basis vectors of an array with rotated instances must be manhattan _after_ having a compensating rotation applied. Args: - library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced - by it are written. - top_name: Name of the top-level pattern to write. + patterns: A Pattern or list of patterns to write to the stream. stream: Stream object to write to. + modify_original: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`. + WARNING: No additional error checking is performed on the results. """ #TODO consider supporting DXF arcs? - if not isinstance(library, ILibraryView): - if isinstance(library, dict): - library = LibraryView(library) - else: - library = LibraryView(dict(library)) + if disambiguate_func is None: + disambiguate_func = lambda pats: disambiguate_pattern_names(pats) + assert(disambiguate_func is not None) - pattern = library[top_name] - subtree = library.subtree(top_name) + if not modify_originals: + pattern = pattern.deepcopy().deepunlock() + + # Get a dict of id(pattern) -> pattern + patterns_by_id = pattern.referenced_patterns_by_id() + disambiguate_func(patterns_by_id.values()) # Create library lib = ezdxf.new(dxf_version, setup=True) msp = lib.modelspace() _shapes_to_elements(msp, pattern.shapes) _labels_to_texts(msp, pattern.labels) - _mrefs_to_drefs(msp, pattern.refs) + _subpatterns_to_refs(msp, pattern.subpatterns) # Now create a block for each referenced pattern, and add in any shapes - for name, pat in subtree.items(): - assert pat is not None - if name == top_name: - continue - - block = lib.blocks.new(name=name) + for pat in patterns_by_id.values(): + assert(pat is not None) + block = lib.blocks.new(name=pat.name) _shapes_to_elements(block, pat.shapes) _labels_to_texts(block, pat.labels) - _mrefs_to_drefs(block, pat.refs) + _subpatterns_to_refs(block, pat.subpatterns) lib.write(stream) def writefile( - library: Mapping[str, Pattern], - top_name: str, - filename: str | pathlib.Path, + pattern: Pattern, + filename: Union[str, pathlib.Path], *args, **kwargs, ) -> None: @@ -123,42 +112,30 @@ def writefile( Will automatically compress the file if it has a .gz suffix. Args: - library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced - by it are written. - top_name: Name of the top-level pattern to write. + pattern: `Pattern` to save filename: Filename to save to. *args: passed to `dxf.write` **kwargs: passed to `dxf.write` """ path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open - gz_stream: IO[bytes] - with tmpfile(path) as base_stream: - streams: tuple[Any, ...] = (base_stream,) - if path.suffix == '.gz': - gz_stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) - streams = (gz_stream,) + streams - else: - gz_stream = base_stream - stream = io.TextIOWrapper(gz_stream) # type: ignore - streams = (stream,) + streams - - try: - write(library, top_name, stream, *args, **kwargs) - finally: - for ss in streams: - ss.close() + with open_func(path, mode='wt') as stream: + write(pattern, stream, *args, **kwargs) def readfile( - filename: str | pathlib.Path, + filename: Union[str, pathlib.Path], *args, **kwargs, - ) -> tuple[Library, dict[str, Any]]: + ) -> Tuple[Pattern, Dict[str, Any]]: """ Wrapper for `dxf.read()` that takes a filename or path instead of a stream. - Will automatically decompress gzipped files. + Will automatically decompress files with a .gz suffix. Args: filename: Filename to save to. @@ -166,7 +143,7 @@ def readfile( **kwargs: passed to `dxf.read` """ path = pathlib.Path(filename) - if is_gzipped(path): + if path.suffix == '.gz': open_func: Callable = gzip.open else: open_func = open @@ -177,462 +154,199 @@ def readfile( def read( - stream: TextIO, - *, - polyline_mode: Literal[0, 1, 2, 3, 4] = 2, - contour_accuracy: float = 0.0, - ) -> tuple[Library, dict[str, Any]]: + stream: io.TextIOBase, + clean_vertices: bool = True, + ) -> Tuple[Pattern, Dict[str, Any]]: """ Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are translated into `Pattern` objects; `LWPolyline`s are translated into polygons, and `Insert`s - are translated into `Ref` objects. + are translated into `SubPattern` objects. If an object has no layer it is set to this module's `DEFAULT_LAYER` ("DEFAULT"). Args: stream: Stream to read from. - polyline_mode: Treatment of straight LINE/POLYLINE/LWPOLYLINE geometry: - 0 selects automatically (1 if SOLID/HATCH exists, otherwise 2 if closed - polylines exist, otherwise 3); 1 keeps paths; 2 fills closed zero-width - polylines; 3 joins zero-width segments into polygons, keeping open - contours as paths; 4 additionally closes open contours. Closure may be - indicated by the DXF flag or exactly equal endpoints. Positive-width - paths are preserved in every mode. Curved and variable-width entities - remain unsupported. Automatic selection uses all imported blocks. - contour_accuracy: Nonnegative, finite endpoint joining distance in DXF - units, used only in modes 3 and 4. Zero requires exact coincidence. - Merged polygons are quantized to 1e-6 DXF units and use even-odd filling - (nested contours form holes), matching KLayout's polyline merge modes. + clean_vertices: If `True`, remove any redundant vertices when loading polygons. + The cleaning process removes any polygons with zero area or <3 vertices. + Default `True`. Returns: - - Library of patterns - - Layer metadata + - Top level pattern """ - if polyline_mode not in (0, 1, 2, 3, 4): - raise ValueError(f'Invalid DXF polyline_mode: {polyline_mode!r}') - if not numpy.isfinite(contour_accuracy) or contour_accuracy < 0: - raise ValueError('DXF contour_accuracy must be finite and nonnegative') lib = ezdxf.read(stream) msp = lib.modelspace() - blocks_by_name = { - bb.name: bb - for bb in lib.blocks - if not bb.is_any_layout - } + pat = _read_block(msp, clean_vertices) + patterns = [pat] + [_read_block(bb, clean_vertices) for bb in lib.blocks if bb.name != '*Model_Space'] - referenced: set[str] = set() - pending = [msp] - seen_blocks: set[str] = set() - while pending: - block = pending.pop() - block_name = getattr(block, 'name', None) - if block_name is not None and block_name in seen_blocks: - continue - if block_name is not None: - seen_blocks.add(block_name) - for element in block: - if not isinstance(element, Insert): - continue - target = element.dxfattribs().get('name') - if target is None or target in referenced: - continue - referenced.add(target) - if target in blocks_by_name: - pending.append(blocks_by_name[target]) + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + sp.pattern = patterns_dict[sp.identifier[0]] + del sp.identifier - blocks = [msp, *(bb for bb in blocks_by_name.values() - if not bb.name.startswith('_') or bb.name in referenced)] - if polyline_mode == 0: - polyline_mode = 3 - for block in blocks: - for element in block: - if element.dxftype() in ('SOLID', 'HATCH'): - polyline_mode = 1 - break - if isinstance(element, LWPolyline | Polyline): - verts = (numpy.asarray(element.get_points('xy')) if isinstance(element, LWPolyline) - else numpy.asarray([pp.xyz[:2] for pp in element.points()])) - closed = element.closed if isinstance(element, LWPolyline) else element.is_closed - if closed or (len(verts) > 1 and numpy.array_equal(verts[0], verts[-1])): - polyline_mode = 2 - if polyline_mode == 1: - break + library_info = { + 'layers': [ll.dxfattribs() for ll in lib.layers] + } - mlib = Library() - for bb in blocks: - name, pat = _read_block(bb, polyline_mode=polyline_mode, contour_accuracy=contour_accuracy) - mlib[name] = pat - - library_info = dict( - layers=[ll.dxfattribs() for ll in lib.layers], - ) - - return mlib, library_info + return pat, library_info -def _read_block( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - *, - polyline_mode: int = 2, - contour_accuracy: float = 0.0, - ) -> tuple[str, Pattern]: - name = block.name - pat = Pattern() - contours: dict[layer_t, list[numpy.ndarray]] = defaultdict(list) +def _read_block(block, clean_vertices: bool) -> Pattern: + pat = Pattern(block.name) for element in block: - if isinstance(element, LWPolyline | Polyline): - if isinstance(element, LWPolyline): - points = numpy.asarray(element.get_points()) - is_closed = element.closed + eltype = element.dxftype() + if eltype in ('POLYLINE', 'LWPOLYLINE'): + if eltype == 'LWPOLYLINE': + points = numpy.array(tuple(element.lwpoints)) else: - points = numpy.asarray([pp.xyz for pp in element.points()]) - is_closed = element.is_closed + points = numpy.array(tuple(element.points())) attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) - if len(points) < 2: - logger.warning('Ignoring DXF polyline with fewer than two vertices') - continue - width = 0 - if isinstance(element, LWPolyline): - # ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge) - if points.shape[1] >= 5: - if (points[:, 4] != 0).any(): - raise PatternError('LWPolyline has bulge (not yet representable in masque!)') - if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any(): - raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)') - width = points[0, 2] - elif points.shape[1] == 3: - # width used to be in column 2 - width = points[0, 2] - else: - if any(vertex.dxf.get('bulge', 0) != 0 for vertex in element.vertices): - raise PatternError('Polyline has bulge (not yet representable in masque!)') - widths = numpy.asarray([ - (vertex.dxf.get('start_width', attr.get('default_start_width', 0)), - vertex.dxf.get('end_width', attr.get('default_end_width', 0))) - for vertex in element.vertices - ]) - if (widths != widths[0, 0]).any(): - raise PatternError('Polyline has non-constant width (not yet representable in masque!)') - width = widths[0, 0] + if points.shape[1] == 2: + raise PatternError('Invalid or unimplemented polygon?') + #shape = Polygon(layer=layer) + elif points.shape[1] > 2: + if (points[0, 2] != points[:, 2]).any(): + raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') + elif points.shape[1] == 4 and (points[:, 3] != 0).any(): + raise PatternError('LWPolyLine has bulge (not yet representable in masque!)') - if width == 0: - width = attr.get('const_width', 0) + width = points[0, 2] + if width == 0: + width = attr.get('const_width', 0) - verts = points[:, :2] - endpoint_closed = numpy.array_equal(verts[0], verts[-1]) - if is_closed and not endpoint_closed: - verts = numpy.vstack((verts, verts[0])) - is_closed = is_closed or endpoint_closed - - shape: Path | Polygon - if width == 0 and polyline_mode >= 3: - contours[layer].append(verts) - continue - if width == 0 and is_closed and polyline_mode == 2 and _is_polygon(verts): - shape = Polygon(vertices=verts[:-1]) - else: - shape = Path(width=width, vertices=verts) - - pat.shapes[layer].append(shape) - elif isinstance(element, Line): - layer = element.dxf.get('layer', DEFAULT_LAYER) - verts = numpy.asarray((element.dxf.start.xyz[:2], element.dxf.end.xyz[:2])) - if polyline_mode >= 3: - contours[layer].append(verts) - else: - pat.shapes[layer].append(Path(vertices=verts, width=0)) - elif isinstance(element, Solid | Trace): - attr = element.dxfattribs() - layer = attr.get('layer', DEFAULT_LAYER) - points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4) - if element.has_dxf_attrib(f'vtx{i}')]) - if len(points) >= 3: - # If vtx2 == vtx3, it's a triangle. ezdxf handles this. - if len(points) == 4 and numpy.allclose(points[2], points[3]): - verts = points[:3, :2] - # DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals! - elif len(points) == 4: - verts = points[[0, 1, 3, 2], :2] + shape: Union[Path, Polygon] + if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]): + shape = Polygon(layer=layer, vertices=points[:-1, :2]) else: - verts = points[:, :2] - pat.shapes[layer].append(Polygon(vertices=verts)) - elif isinstance(element, Text): - args = dict( - offset=numpy.asarray(element.get_placement()[1])[:2], - layer=element.dxfattribs().get('layer', DEFAULT_LAYER), - ) + shape = Path(layer=layer, width=width, vertices=points[:, :2]) + + if clean_vertices: + try: + shape.clean_vertices() + except PatternError: + continue + + pat.shapes.append(shape) + + elif eltype in ('TEXT',): + args = {'offset': numpy.array(element.get_pos()[1])[:2], + 'layer': element.dxfattribs().get('layer', DEFAULT_LAYER), + } string = element.dxfattribs().get('text', '') # height = element.dxfattribs().get('height', 0) # if height != 0: # logger.warning('Interpreting DXF TEXT as a label despite nonzero height. ' # 'This could be changed in the future by setting a font path in the masque DXF code.') - pat.label(string=string, **args) + pat.labels.append(Label(string=string, **args)) # else: -# pat.shapes[args['layer']].append(Text(string=string, height=height, font_path=????)) - elif isinstance(element, Insert): +# pat.shapes.append(Text(string=string, height=height, font_path=????)) + elif eltype in ('INSERT',): attr = element.dxfattribs() xscale = attr.get('xscale', 1) yscale = attr.get('yscale', 1) if abs(xscale) != abs(yscale): logger.warning('Masque does not support per-axis scaling; using x-scaling only!') scale = abs(xscale) - mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0)) - insert_rotation = numpy.deg2rad(attr.get('rotation', 0)) - rotation = insert_rotation + extra_angle + mirrored = (yscale < 0, xscale < 0) + rotation = numpy.deg2rad(attr.get('rotation', 0)) - offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2] + offset = numpy.array(attr.get('insert', (0, 0, 0)))[:2] - args = dict( - target=attr.get('name', None), - offset=offset, - scale=scale, - mirrored=mirrored, - rotation=rotation, - ) + args = { + 'offset': offset, + 'scale': scale, + 'mirrored': mirrored, + 'rotation': rotation, + 'pattern': None, + 'identifier': (attr.get('name', None),), + } - if 'column_count' in attr or 'row_count' in attr: - col_spacing = attr.get('column_spacing', 0) - row_spacing = attr.get('row_spacing', 0) - col_count = attr.get('column_count', 1) - row_count = attr.get('row_count', 1) - local_x = numpy.array((col_spacing, 0.0)) - local_y = numpy.array((0.0, row_spacing)) - # Spacing follows only the original INSERT angle, not its scale - # or the extra angle introduced by mirror normalization. - rot = rotation_matrix_2d(insert_rotation) - args['repetition'] = Grid( - a_vector=rot @ local_x, b_vector=rot @ local_y, - a_count=col_count, b_count=row_count, - ) - pat.ref(**args) + if 'column_count' in attr: + args['repetition'] = Grid(a_vector=(attr['column_spacing'], 0), + b_vector=(0, attr['row_spacing']), + a_count=attr['column_count'], + b_count=attr['row_count']) + pat.subpatterns.append(SubPattern(**args)) else: logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).') - for layer, vertex_lists in contours.items(): - pat.shapes[layer].extend(_merge_polylines(vertex_lists, contour_accuracy, auto_close=polyline_mode == 4)) - return name, pat + return pat -def _is_polygon(vertices: NDArray) -> bool: - """At least three distinct, noncollinear points (including self-crossing contours).""" - points = numpy.unique(vertices, axis=0) - if len(points) < 3: - return False - vectors = points[1:] - points[0] - return bool(numpy.any(vectors[:, 0] * vectors[0, 1] != vectors[:, 1] * vectors[0, 0])) - - -def _contours(edges: Sequence[edgeminer.Edge], accuracy: float) -> list[tuple[NDArray, bool]]: - """Join each edge once, using indexed endpoint searches rather than loop enumeration.""" - deposit = edgeminer.Deposit(edges, gap_tol=accuracy) - unused = {edge.id for edge in edges} - result = [] - - def grow(points: list[Vec3]) -> bool: - positions = {point: index for index, point in enumerate(points[:-1])} - while True: - # A walk that started on a dangling segment can encounter a cycle - # before returning to its initial point. Extract that rim and keep - # the remaining open tail; every segment is still consumed once. - contacts = { - point for edge in deposit.edges_linked_to(points[-1]) - for point in (edge.start, edge.end) - if point in positions and positions[point] < len(points) - 2 - and point.distance(points[-1]) <= accuracy - } - if contacts: - point = min(contacts, key=lambda point: (point.distance(points[-1]), point.xyz)) - index = positions[point] - loop = points[index:-1] + [point] - result.append((numpy.asarray([pp.xyz[:2] for pp in loop]), True)) - if index == 0: - return True - for removed in points[index + 1:-1]: - positions.pop(removed, None) - del points[index + 1:] - positions[points[-1]] = len(points) - 1 - incoming = points[-1] - points[-2] - candidates = [] - for edge in deposit.edges_linked_to(points[-1]): - if edge.id not in unused: - continue - for oriented in (edge, edge.reversed()): - distance = points[-1].distance(oriented.start) - if distance <= accuracy: - direction = oriented.end - oriented.start - # Like KLayout, use endpoint distance then a signed - # cross product. Canonical seeds follow clockwise rims. - turn = -direction.cross(incoming).z / oriented.length - candidates.append((distance, turn, oriented.end.xyz, oriented.id, oriented)) - if not candidates: - return False - edge = min(candidates, key=lambda item: item[:4])[-1] - unused.remove(edge.id) - # Snap the next start to the preceding endpoint when joining a gap. - points.append(edge.end) - - # Canonical ordering makes results independent of input order/direction. - ordered = sorted(edges, key=lambda edge: sorted((edge.start.xyz, edge.end.xyz))) - for seed in ordered: - if seed.id not in unused: - continue - unused.remove(seed.id) - edge = seed.reversed() if seed.start.xyz > seed.end.xyz else seed - points = [edge.start, edge.end] - closed = grow(points) - if not closed: - points.reverse() - closed = grow(points) - if not closed: - result.append((numpy.asarray([point.xyz[:2] for point in points]), False)) - return result - - -def _merge_polylines( - vertex_lists: Sequence[NDArray], - accuracy: float, - *, - auto_close: bool, - ) -> list[Path | Polygon]: - """Assemble one cell/layer's zero-width segments, with KLayout's even-odd fill.""" - import pyclipper # noqa: PLC0415 - - edges = [] - result: list[Path | Polygon] = [] - for vertices in vertex_lists: - start_count = len(edges) - for start, end in zip(vertices[:-1], vertices[1:], strict=True): - if not numpy.array_equal(start, end): - edges.append(edgeminer.make_edge(start, end)) - if start_count == len(edges): - result.append(Path(vertices=vertices, width=0)) - - scale = 1e6 - clipper = pyclipper.Pyclipper() - has_polygons = False - for vertices, closed in _contours(edges, accuracy): - if (closed or auto_close) and _is_polygon(vertices): - # A contour can collapse at the clipping precision. Preserve its - # centerline in that case rather than silently dropping geometry. - try: - added = clipper.AddPath(pyclipper.scale_to_clipper(vertices, scale), pyclipper.PT_SUBJECT, True) - except pyclipper.ClipperException: - added = False - if added: - has_polygons = True - continue - result.append(Path(vertices=vertices, width=0)) - - if has_polygons: - tree = clipper.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) - result.extend(_polytree_to_polygons(tree, scale)) - return result - - -def _mrefs_to_drefs( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - refs: dict[str | None, list[Ref]], +def _subpatterns_to_refs( + block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], + subpatterns: List[SubPattern], ) -> None: - def mk_blockref(encoded_name: str, ref: Ref) -> None: - rotation = numpy.rad2deg(ref.rotation) % 360 - attribs = dict( - xscale=ref.scale, - yscale=ref.scale * (-1 if ref.mirrored else 1), - rotation=rotation, - ) + for subpat in subpatterns: + if subpat.pattern is None: + continue + encoded_name = subpat.pattern.name - rep = ref.repetition + rotation = (subpat.rotation * 180 / numpy.pi) % 360 + attribs = { + 'xscale': subpat.scale * (-1 if subpat.mirrored[1] else 1), + 'yscale': subpat.scale * (-1 if subpat.mirrored[0] else 1), + 'rotation': rotation, + } + + rep = subpat.repetition if rep is None: - block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) elif isinstance(rep, Grid): a = rep.a_vector b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) - # In masque, the grid basis vectors are NOT rotated by the reference's rotation. - # In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing], - # which ARE then rotated by the block reference's rotation. - # Compensate for that rotation to express the world-space basis in - # the local DXF frame. Only locally Manhattan grids fit an INSERT. - rotated_a = rotation_matrix_2d(-ref.rotation) @ a - rotated_b = rotation_matrix_2d(-ref.rotation) @ b - - if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8): + rotated_a = rotation_matrix_2d(-subpat.rotation) @ a + rotated_b = rotation_matrix_2d(-subpat.rotation) @ b + if rotated_a[1] == 0 and rotated_b[0] == 0: attribs['column_count'] = rep.a_count attribs['row_count'] = rep.b_count attribs['column_spacing'] = rotated_a[0] attribs['row_spacing'] = rotated_b[1] - block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) - elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8): + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) + elif rotated_a[0] == 0 and rotated_b[1] == 0: attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] attribs['row_spacing'] = rotated_a[1] - block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) else: #NOTE: We could still do non-manhattan (but still orthogonal) grids by getting # creative with counter-rotated nested patterns, but probably not worth it. # Instead, just break appart the grid into individual elements: for dd in rep.displacements: - block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs) + block.add_blockref(encoded_name, subpat.offset + dd, dxfattribs=attribs) else: for dd in rep.displacements: - block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs) - - for target, rseq in refs.items(): - if target is None: - continue - for ref in rseq: - mk_blockref(target, ref) + block.add_blockref(encoded_name, subpat.offset + dd, dxfattribs=attribs) def _shapes_to_elements( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - shapes: dict[layer_t, list[Shape]], + block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], + shapes: List[Shape], + polygonize_paths: bool = False, ) -> None: # Add `LWPolyline`s for each shape. # Could set do paths with width setting, but need to consider endcaps. - # TODO: can DXF do paths? - for layer, sseq in shapes.items(): - attribs = dict(layer=_mlayer2dxf(layer)) - for shape in sseq: - displacements = [numpy.zeros(2)] - if shape.repetition is not None: - displacements = shape.repetition.displacements - - for dd in displacements: - if isinstance(shape, Path): - # preserve path. - # Note: DXF paths don't support endcaps well, so this is still a bit limited. - xy = shape.vertices + dd - attribs_path = {**attribs} - if shape.width > 0: - attribs_path['const_width'] = shape.width - block.add_lwpolyline(xy, dxfattribs=attribs_path) - else: - for polygon in shape.to_polygons(): - xy_open = polygon.vertices + dd - block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs) + for shape in shapes: + attribs = {'layer': _mlayer2dxf(shape.layer)} + for polygon in shape.to_polygons(): + xy_open = polygon.vertices + polygon.offset + xy_closed = numpy.vstack((xy_open, xy_open[0, :])) + block.add_lwpolyline(xy_closed, dxfattribs=attribs) def _labels_to_texts( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - labels: dict[layer_t, list[Label]], + block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], + labels: List[Label], ) -> None: - for layer, lseq in labels.items(): - attribs = dict(layer=_mlayer2dxf(layer)) - for label in lseq: - if label.repetition is None: - block.add_text( - label.string, - dxfattribs=attribs - ).set_placement(label.offset, align=TextEntityAlignment.BOTTOM_LEFT) - else: - for dd in label.repetition.displacements: - block.add_text( - label.string, - dxfattribs=attribs - ).set_placement(label.offset + dd, align=TextEntityAlignment.BOTTOM_LEFT) + for label in labels: + attribs = {'layer': _mlayer2dxf(label.layer)} + xy = label.offset + block.add_text(label.string, dxfattribs=attribs).set_pos(xy, align='BOTTOM_LEFT') def _mlayer2dxf(layer: layer_t) -> str: @@ -641,5 +355,42 @@ def _mlayer2dxf(layer: layer_t) -> str: if isinstance(layer, int): return str(layer) if isinstance(layer, tuple): - return f'{layer[0]:d}.{layer[1]:d}' + return f'{layer[0]}.{layer[1]}' raise PatternError(f'Unknown layer type: {layer} ({type(layer)})') + + +def disambiguate_pattern_names( + patterns: Iterable[Pattern], + max_name_length: int = 32, + suffix_length: int = 6, + dup_warn_filter: Callable[[str], bool] = None, # If returns False, don't warn about this name + ) -> None: + used_names = [] + for pat in patterns: + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', pat.name) + + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + if len(suffixed_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize,\n originally "{pat.name}"') + if len(suffixed_name) > max_name_length: + raise PatternError(f'Pattern name "{suffixed_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) + diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py new file mode 100644 index 0000000..6bd4d1a --- /dev/null +++ b/masque/file/gdsii.py @@ -0,0 +1,670 @@ +""" +GDSII file format readers and writers using the `klamath` library. + +Note that GDSII references follow the same convention as `masque`, + with this order of operations: + 1. Mirroring + 2. Rotation + 3. Scaling + 4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets) + + Scaling, rotation, and mirroring apply to individual instances, not grid + vectors or offsets. + +Notes: + * absolute positioning is not supported + * PLEX is not supported + * ELFLAGS are not supported + * GDS does not support library- or structure-level annotations + * Creation/modification/access times are set to 1900-01-01 for reproducibility. +""" +from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional +from typing import Sequence, BinaryIO +import re +import io +import mmap +import copy +import base64 +import struct +import logging +import pathlib +import gzip + +import numpy +from numpy.typing import NDArray, ArrayLike +import klamath +from klamath import records + +from .utils import is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path +from ..repetition import Grid +from ..utils import layer_t, normalize_mirror, annotations_t +from ..library import Library + +logger = logging.getLogger(__name__) + + +path_cap_map = { + 0: Path.Cap.Flush, + 1: Path.Cap.Circle, + 2: Path.Cap.Square, + 4: Path.Cap.SquareCustom, + } + + +def write( + patterns: Union[Pattern, Sequence[Pattern]], + stream: BinaryIO, + meters_per_unit: float, + logical_units_per_unit: float = 1, + library_name: str = 'masque-klamath', + *, + modify_originals: bool = False, + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, + ) -> None: + """ + Convert a `Pattern` or list of patterns to a GDSII stream, and then mapping data as follows: + Pattern -> GDSII structure + SubPattern -> GDSII SREF or AREF + Path -> GSDII path + Shape (other than path) -> GDSII boundary/ies + Label -> GDSII text + annnotations -> properties, where possible + + For each shape, + layer is chosen to be equal to `shape.layer` if it is an int, + or `shape.layer[0]` if it is a tuple + datatype is chosen to be `shape.layer[1]` if available, + otherwise `0` + + It is often a good idea to run `pattern.subpatternize()` prior to calling this function, + especially if calling `.polygonize()` will result in very many vertices. + + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. + + Args: + patterns: A Pattern or list of patterns to convert. + meters_per_unit: Written into the GDSII file, meters per (database) length unit. + All distances are assumed to be an integer multiple of this unit, and are stored as such. + logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a + "logical" unit which is different from the "database" unit, for display purposes. + Default `1`. + library_name: Library name written into the GDSII file. + Default 'masque-klamath'. + modify_originals: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`, which + attempts to adhere to the GDSII standard as well as possible. + WARNING: No additional error checking is performed on the results. + """ + if isinstance(patterns, Pattern): + patterns = [patterns] + + if disambiguate_func is None: + disambiguate_func = disambiguate_pattern_names # type: ignore + assert(disambiguate_func is not None) # placate mypy + + if not modify_originals: + patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] + + patterns = [p.wrap_repeated_shapes() for p in patterns] + + # Create library + header = klamath.library.FileHeader(name=library_name.encode('ASCII'), + user_units_per_db_unit=logical_units_per_unit, + meters_per_db_unit=meters_per_unit) + header.write(stream) + + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + disambiguate_func(patterns_by_id.values()) + + # Now create a structure for each pattern, and add in any Boundary and SREF elements + for pat in patterns_by_id.values(): + elements: List[klamath.elements.Element] = [] + elements += _shapes_to_elements(pat.shapes) + elements += _labels_to_texts(pat.labels) + elements += _subpatterns_to_refs(pat.subpatterns) + + klamath.library.write_struct(stream, name=pat.name.encode('ASCII'), elements=elements) + records.ENDLIB.write(stream, None) + + +def writefile( + patterns: Union[Sequence[Pattern], Pattern], + filename: Union[str, pathlib.Path], + *args, + **kwargs, + ) -> None: + """ + Wrapper for `write()` that takes a filename or path instead of a stream. + + Will automatically compress the file if it has a .gz suffix. + + Args: + patterns: `Pattern` or list of patterns to save + filename: Filename to save to. + *args: passed to `write()` + **kwargs: passed to `write()` + """ + path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open + + with io.BufferedWriter(open_func(path, mode='wb')) as stream: + write(patterns, stream, *args, **kwargs) + + +def readfile( + filename: Union[str, pathlib.Path], + *args, + **kwargs, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + """ + Wrapper for `read()` that takes a filename or path instead of a stream. + + Will automatically decompress gzipped files. + + Args: + filename: Filename to save to. + *args: passed to `read()` + **kwargs: passed to `read()` + """ + path = pathlib.Path(filename) + if is_gzipped(path): + open_func: Callable = gzip.open + else: + open_func = open + + with io.BufferedReader(open_func(path, mode='rb')) as stream: + results = read(stream, *args, **kwargs) + return results + + +def read( + stream: BinaryIO, + raw_mode: bool = True, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + """ + Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are + translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs + are translated into SubPattern objects. + + Additional library info is returned in a dict, containing: + 'name': name of the library + 'meters_per_unit': number of meters per database unit (all values are in database units) + 'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns) + per database unit + + Args: + stream: Stream to read from. + raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True. + + Returns: + - Dict of pattern_name:Patterns generated from GDSII structures + - Dict of GDSII library info + """ + library_info = _read_header(stream) + + patterns = [] + found_struct = records.BGNSTR.skip_past(stream) + while found_struct: + name = records.STRNAME.skip_and_read(stream) + pat = read_elements(stream, name=name.decode('ASCII'), raw_mode=raw_mode) + patterns.append(pat) + found_struct = records.BGNSTR.skip_past(stream) + + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + sp.pattern = patterns_dict[sp.identifier[0]] + del sp.identifier + + return patterns_dict, library_info + + +def _read_header(stream: BinaryIO) -> Dict[str, Any]: + """ + Read the file header and create the library_info dict. + """ + header = klamath.library.FileHeader.read(stream) + + library_info = {'name': header.name.decode('ASCII'), + 'meters_per_unit': header.meters_per_db_unit, + 'logical_units_per_unit': header.user_units_per_db_unit, + } + return library_info + + +def read_elements( + stream: BinaryIO, + name: str, + raw_mode: bool = True, + ) -> Pattern: + """ + Read elements from a GDS structure and build a Pattern from them. + + Args: + stream: Seekable stream, positioned at a record boundary. + Will be read until an ENDSTR record is consumed. + name: Name of the resulting Pattern + raw_mode: If True, bypass per-shape data validation. Default True. + + Returns: + A pattern containing the elements that were read. + """ + pat = Pattern(name) + + elements = klamath.library.read_elements(stream) + for element in elements: + if isinstance(element, klamath.elements.Boundary): + poly = _boundary_to_polygon(element, raw_mode) + pat.shapes.append(poly) + elif isinstance(element, klamath.elements.Path): + path = _gpath_to_mpath(element, raw_mode) + pat.shapes.append(path) + elif isinstance(element, klamath.elements.Text): + label = Label(offset=element.xy.astype(float), + layer=element.layer, + string=element.string.decode('ASCII'), + annotations=_properties_to_annotations(element.properties)) + pat.labels.append(label) + elif isinstance(element, klamath.elements.Reference): + pat.subpatterns.append(_ref_to_subpat(element)) + return pat + + +def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]: + """ Helper to turn a layer tuple-or-int into a layer and datatype""" + if isinstance(mlayer, int): + layer = mlayer + data_type = 0 + elif isinstance(mlayer, tuple): + layer = mlayer[0] + if len(mlayer) > 1: + data_type = mlayer[1] + else: + data_type = 0 + else: + raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.') + return layer, data_type + + +def _ref_to_subpat(ref: klamath.library.Reference) -> SubPattern: + """ + Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None + and sets the instance .identifier to (struct_name,). + """ + xy = ref.xy.astype(float) + offset = xy[0] + repetition = None + if ref.colrow is not None: + a_count, b_count = ref.colrow + a_vector = (xy[1] - offset) / a_count + b_vector = (xy[2] - offset) / b_count + repetition = Grid(a_vector=a_vector, b_vector=b_vector, + a_count=a_count, b_count=b_count) + + subpat = SubPattern(pattern=None, + offset=offset, + rotation=numpy.deg2rad(ref.angle_deg), + scale=ref.mag, + mirrored=(ref.invert_y, False), + annotations=_properties_to_annotations(ref.properties), + repetition=repetition) + subpat.identifier = (ref.struct_name.decode('ASCII'),) + return subpat + + +def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> Path: + if gpath.path_type in path_cap_map: + cap = path_cap_map[gpath.path_type] + else: + raise PatternError(f'Unrecognized path type: {gpath.path_type}') + + mpath = Path(vertices=gpath.xy.astype(float), + layer=gpath.layer, + width=gpath.width, + cap=cap, + offset=numpy.zeros(2), + annotations=_properties_to_annotations(gpath.properties), + raw=raw_mode, + ) + if cap == Path.Cap.SquareCustom: + mpath.cap_extensions = gpath.extension + return mpath + + +def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> Polygon: + return Polygon(vertices=boundary.xy[:-1].astype(float), + layer=boundary.layer, + offset=numpy.zeros(2), + annotations=_properties_to_annotations(boundary.properties), + raw=raw_mode, + ) + + +def _subpatterns_to_refs(subpatterns: List[SubPattern]) -> List[klamath.library.Reference]: + refs = [] + for subpat in subpatterns: + if subpat.pattern is None: + continue + encoded_name = subpat.pattern.name.encode('ASCII') + + # Note: GDS mirrors first and rotates second + mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) + rep = subpat.repetition + angle_deg = numpy.rad2deg(subpat.rotation + extra_angle) % 360 + properties = _annotations_to_properties(subpat.annotations, 512) + + if isinstance(rep, Grid): + b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) + b_count = rep.b_count if rep.b_count is not None else 1 + xy: NDArray[numpy.float64] = numpy.array(subpat.offset) + [ + [0, 0], + rep.a_vector * rep.a_count, + b_vector * b_count, + ] + aref = klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round(xy).astype(int), + colrow=(numpy.round(rep.a_count), numpy.round(rep.b_count)), + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + refs.append(aref) + elif rep is None: + ref = klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round([subpat.offset]).astype(int), + colrow=None, + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + refs.append(ref) + else: + new_srefs = [klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round([subpat.offset + dd]).astype(int), + colrow=None, + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + for dd in rep.displacements] + refs += new_srefs + return refs + + +def _properties_to_annotations(properties: Dict[int, bytes]) -> annotations_t: + return {str(k): [v.decode()] for k, v in properties.items()} + + +def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Dict[int, bytes]: + cum_len = 0 + props = {} + for key, vals in annotations.items(): + try: + i = int(key) + except ValueError: + raise PatternError(f'Annotation key {key} is not convertable to an integer') + if not (0 < i < 126): + raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])') + + val_strings = ' '.join(str(val) for val in vals) + b = val_strings.encode() + if len(b) > 126: + raise PatternError(f'Annotation value {b!r} is longer than 126 characters!') + cum_len += numpy.ceil(len(b) / 2) * 2 + 2 + if cum_len > max_len: + raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}') + props[i] = b + return props + + +def _shapes_to_elements( + shapes: List[Shape], + polygonize_paths: bool = False, + ) -> List[klamath.elements.Element]: + elements: List[klamath.elements.Element] = [] + # Add a Boundary element for each shape, and Path elements if necessary + for shape in shapes: + layer, data_type = _mlayer2gds(shape.layer) + properties = _annotations_to_properties(shape.annotations, 128) + if isinstance(shape, Path) and not polygonize_paths: + xy = numpy.round(shape.vertices + shape.offset).astype(int) + width = numpy.round(shape.width).astype(int) + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + + extension: Tuple[int, int] + if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: + extension = tuple(shape.cap_extensions) # type: ignore + else: + extension = (0, 0) + + path = klamath.elements.Path(layer=(layer, data_type), + xy=xy, + path_type=path_type, + width=width, + extension=extension, + properties=properties) + elements.append(path) + elif isinstance(shape, Polygon): + polygon = shape + xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) + numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') + xy_closed[-1] = xy_closed[0] + boundary = klamath.elements.Boundary(layer=(layer, data_type), + xy=xy_closed, + properties=properties) + elements.append(boundary) + else: + for polygon in shape.to_polygons(): + xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) + numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') + xy_closed[-1] = xy_closed[0] + boundary = klamath.elements.Boundary(layer=(layer, data_type), + xy=xy_closed, + properties=properties) + elements.append(boundary) + return elements + + +def _labels_to_texts(labels: List[Label]) -> List[klamath.elements.Text]: + texts = [] + for label in labels: + properties = _annotations_to_properties(label.annotations, 128) + layer, text_type = _mlayer2gds(label.layer) + xy = numpy.round([label.offset]).astype(int) + text = klamath.elements.Text(layer=(layer, text_type), + xy=xy, + string=label.string.encode('ASCII'), + properties=properties, + presentation=0, # TODO maybe set some of these? + angle_deg=0, + invert_y=False, + width=0, + path_type=0, + mag=1) + texts.append(text) + return texts + + +def disambiguate_pattern_names( + patterns: Sequence[Pattern], + max_name_length: int = 32, + suffix_length: int = 6, + dup_warn_filter: Optional[Callable[[str], bool]] = None, + ) -> None: + """ + Args: + patterns: List of patterns to disambiguate + max_name_length: Names longer than this will be truncated + suffix_length: Names which get truncated are truncated by this many extra characters. This is to + leave room for a suffix if one is necessary. + dup_warn_filter: (optional) Function for suppressing warnings about cell names changing. Receives + the cell name and returns `False` if the warning should be suppressed and `True` if it should + be displayed. Default displays all warnings. + """ + used_names = [] + for pat in set(patterns): + # Shorten names which already exceed max-length + if len(pat.name) > max_name_length: + shortened_name = pat.name[:max_name_length - suffix_length] + logger.warning(f'Pattern name "{pat.name}" is too long ({len(pat.name)}/{max_name_length} chars),\n' + + f' shortening to "{shortened_name}" before generating suffix') + else: + shortened_name = pat.name + + # Remove invalid characters + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name) + + # Add a suffix that makes the name unique + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + # Encode into a byte-string and perform some final checks + encoded_name = suffixed_name.encode('ASCII') + if len(encoded_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') + if len(encoded_name) > max_name_length: + raise PatternError(f'Pattern name "{encoded_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) + + +def load_library( + stream: BinaryIO, + tag: str, + is_secondary: Optional[Callable[[str], bool]] = None, + *, + full_load: bool = False, + ) -> Tuple[Library, Dict[str, Any]]: + """ + Scan a GDSII stream to determine what structures are present, and create + a library from them. This enables deferred reading of structures + on an as-needed basis. + All structures are loaded as secondary + + Args: + stream: Seekable stream. Position 0 should be the start of the file. + The caller should leave the stream open while the library + is still in use, since the library will need to access it + in order to read the structure contents. + tag: Unique identifier that will be used to identify this data source + is_secondary: Function which takes a structure name and returns + True if the structure should only be used as a subcell + and not appear in the main Library interface. + Default always returns False. + full_load: If True, force all structures to be read immediately rather + than as-needed. Since data is read sequentially from the file, + this will be faster than using the resulting library's + `precache` method. + + Returns: + Library object, allowing for deferred load of structures. + Additional library info (dict, same format as from `read`). + """ + if is_secondary is None: + def is_secondary(k: str) -> bool: + return False + assert(is_secondary is not None) + + stream.seek(0) + lib = Library() + + if full_load: + # Full load approach (immediately load everything) + patterns, library_info = read(stream) + for name, pattern in patterns.items(): + lib.set_const(name, tag, pattern, secondary=is_secondary(name)) + return lib, library_info + + # Normal approach (scan and defer load) + library_info = _read_header(stream) + structs = klamath.library.scan_structs(stream) + + for name_bytes, pos in structs.items(): + name = name_bytes.decode('ASCII') + + def mkstruct(pos: int = pos, name: str = name) -> Pattern: + stream.seek(pos) + return read_elements(stream, name, raw_mode=True) + + lib.set_value(name, tag, mkstruct, secondary=is_secondary(name)) + + return lib, library_info + + +def load_libraryfile( + filename: Union[str, pathlib.Path], + tag: str, + is_secondary: Optional[Callable[[str], bool]] = None, + *, + use_mmap: bool = True, + full_load: bool = False, + ) -> Tuple[Library, Dict[str, Any]]: + """ + Wrapper for `load_library()` that takes a filename or path instead of a stream. + + Will automatically decompress the file if it is gzipped. + + NOTE that any streams/mmaps opened will remain open until ALL of the + `PatternGenerator` objects in the library are garbage collected. + + Args: + path: filename or path to read from + tag: Unique identifier for library, see `load_library` + is_secondary: Function specifying subcess, see `load_library` + use_mmap: If `True`, will attempt to memory-map the file instead + of buffering. In the case of gzipped files, the file + is decompressed into a python `bytes` object in memory + and reopened as an `io.BytesIO` stream. + full_load: If `True`, immediately loads all data. See `load_library`. + + Returns: + Library object, allowing for deferred load of structures. + Additional library info (dict, same format as from `read`). + """ + path = pathlib.Path(filename) + if is_gzipped(path): + if mmap: + logger.info('Asked to mmap a gzipped file, reading into memory instead...') + base_stream = gzip.open(path, mode='rb') + stream = io.BytesIO(base_stream.read()) + else: + base_stream = gzip.open(path, mode='rb') + stream = io.BufferedReader(base_stream) + else: + base_stream = open(path, mode='rb') + if mmap: + stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) + else: + stream = io.BufferedReader(base_stream) + return load_library(stream, tag, is_secondary) diff --git a/masque/file/gdsii/__init__.py b/masque/file/gdsii/__init__.py deleted file mode 100644 index d5f7297..0000000 --- a/masque/file/gdsii/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -GDSII file format readers and writers. -""" -from .klamath import check_valid_names as check_valid_names -from .klamath import read as read -from .klamath import readfile as readfile -from .writer import write as write -from .writer import writefile as writefile diff --git a/masque/file/gdsii/arrow.py b/masque/file/gdsii/arrow.py deleted file mode 100644 index 6a2a42c..0000000 --- a/masque/file/gdsii/arrow.py +++ /dev/null @@ -1,843 +0,0 @@ -# ruff: noqa: ARG001 -""" -GDSII file format readers and writers using the `TODO` library. - -Note that GDSII references follow the same convention as `masque`, - with this order of operations: - 1. Mirroring - 2. Rotation - 3. Scaling - 4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets) - - Scaling, rotation, and mirroring apply to individual instances, not grid - vectors or offsets. - -Notes: - * absolute positioning is not supported - * PLEX is not supported - * ELFLAGS are not supported - * GDS does not support library- or structure-level annotations - * GDS creation/modification/access times are set to 1900-01-01 for reproducibility. - * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) - - TODO writing - TODO warn on boxes, nodes -""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Any -from functools import cache -from importlib.machinery import EXTENSION_SUFFIXES -import importlib.util -import logging -import os -import pathlib -import gzip -import sys -import tempfile - -from klamath.basic import KlamathError -import numpy -import pyarrow -from pyarrow.cffi import ffi - -from ..utils import is_gzipped -from ... import Pattern, Ref, PatternError, Label -from ...shapes import Polygon, Path, PolyCollection, RectCollection -from ...repetition import Grid -from ...library import Library - -if TYPE_CHECKING: - from collections.abc import Callable - import mmap - - from numpy.typing import NDArray - - from ...utils import annotations_t - - -logger = logging.getLogger(__name__) - -ffi.cdef( - """ - const char* last_error_message(void); - int read_path(const char* path, struct ArrowArray* array, struct ArrowSchema* schema); - int scan_bytes(uint8_t* data, size_t size, struct ArrowArray* array, struct ArrowSchema* schema); - int read_cells_bytes( - uint8_t* data, - size_t size, - uint64_t* ranges, - size_t range_count, - struct ArrowArray* array, - struct ArrowSchema* schema - ); - """ -) - -_PATH_CAP_MAP = { - 0: Path.Cap.Flush, - 1: Path.Cap.Circle, - 2: Path.Cap.Square, - 4: Path.Cap.SquareCustom, - } - - -def _packed_layer_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int16]: - layer = (values >> numpy.uint32(16)).astype(numpy.uint16).view(numpy.int16) - dtype = (values & numpy.uint32(0xffff)).astype(numpy.uint16).view(numpy.int16) - return numpy.stack((layer, dtype), axis=-1) - - -def _packed_counts_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int64]: - a_count = (values >> numpy.uint32(16)).astype(numpy.uint16).astype(numpy.int64) - b_count = (values & numpy.uint32(0xffff)).astype(numpy.uint16).astype(numpy.int64) - return numpy.stack((a_count, b_count), axis=-1) - - -def _packed_xy_u64_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int32]: - xx = (values >> numpy.uint64(32)).astype(numpy.uint32).view(numpy.int32) - yy = (values & numpy.uint64(0xffff_ffff)).astype(numpy.uint32).view(numpy.int32) - return numpy.stack((xx, yy), axis=-1) - - -def _local_library_filename() -> str: - if sys.platform.startswith('linux'): - return 'libklamath_rs_ext.so' - if sys.platform == 'darwin': - return 'libklamath_rs_ext.dylib' - if sys.platform == 'win32': - return 'klamath_rs_ext.dll' - raise OSError(f'Unsupported platform for klamath_rs_ext: {sys.platform!r}') - - -def _installed_library_candidates() -> list[pathlib.Path]: - candidates: list[pathlib.Path] = [] - - try: - spec = importlib.util.find_spec('klamath_rs_ext.klamath_rs_ext') - except ModuleNotFoundError: - spec = None - if spec is not None and spec.origin is not None: - candidates.append(pathlib.Path(spec.origin)) - - try: - pkg_spec = importlib.util.find_spec('klamath_rs_ext') - except ModuleNotFoundError: - pkg_spec = None - if pkg_spec is not None and pkg_spec.submodule_search_locations is not None: - for location in pkg_spec.submodule_search_locations: - pkg_dir = pathlib.Path(location) - for suffix in EXTENSION_SUFFIXES: - candidates.extend(sorted(pkg_dir.glob(f'klamath_rs_ext*{suffix}'))) - - return candidates - - -def _repo_library_candidates() -> list[pathlib.Path]: - repo_root = pathlib.Path(__file__).resolve().parents[3] - library_name = _local_library_filename() - return [ - repo_root / 'klamath-rs' / 'target' / 'release' / library_name, - repo_root / 'klamath-rs' / 'target' / 'debug' / library_name, - ] - - -def _find_klamath_rs_library() -> pathlib.Path | None: - env_path = os.environ.get('KLAMATH_RS_EXT_LIB') - if env_path: - candidate = pathlib.Path(env_path).expanduser() - if candidate.exists(): - return candidate.resolve() - - seen: set[pathlib.Path] = set() - for candidate in _installed_library_candidates() + _repo_library_candidates(): - resolved = candidate.expanduser() - if resolved in seen: - continue - seen.add(resolved) - if resolved.exists(): - return resolved.resolve() - return None - - -def is_available() -> bool: - return _find_klamath_rs_library() is not None - - -@cache -def _get_clib() -> Any: - lib_path = _find_klamath_rs_library() - if lib_path is None: - raise ImportError( - 'Could not locate klamath_rs_ext shared library. ' - 'Build klamath-rs with `cargo build --release --manifest-path klamath-rs/Cargo.toml` ' - 'or set KLAMATH_RS_EXT_LIB to the built library path.' - ) - return ffi.dlopen(str(lib_path)) - - -def _read_annotations( - prop_offs: NDArray[numpy.integer[Any]], - prop_key: NDArray[numpy.integer[Any]], - prop_val: list[str], - ee: int, - ) -> annotations_t: - prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1] - if prop_ii >= prop_ff: - return None - return {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)} - - -def _read_to_arrow( - filename: str | pathlib.Path, - ) -> pyarrow.Array: - path = pathlib.Path(filename).expanduser().resolve() - ptr_array = ffi.new('struct ArrowArray[]', 1) - ptr_schema = ffi.new('struct ArrowSchema[]', 1) - if is_gzipped(path): - with gzip.open(path, mode='rb') as src: - data = src.read() - with tempfile.NamedTemporaryFile(suffix='.gds', delete=False) as tmp_stream: - tmp_stream.write(data) - tmp_name = tmp_stream.name - try: - _call_native(_get_clib().read_path(tmp_name.encode(), ptr_array, ptr_schema), 'read_path') - finally: - pathlib.Path(tmp_name).unlink(missing_ok=True) - else: - _call_native(_get_clib().read_path(str(path).encode(), ptr_array, ptr_schema), 'read_path') - return _import_arrow_array(ptr_array, ptr_schema) - - -def _import_arrow_array(ptr_array: Any, ptr_schema: Any) -> pyarrow.Array: - iptr_schema = int(ffi.cast('uintptr_t', ptr_schema)) - iptr_array = int(ffi.cast('uintptr_t', ptr_array)) - return pyarrow.Array._import_from_c(iptr_array, iptr_schema) - - -def _call_native(status: int, action: str) -> None: - if status == 0: - return - - err_ptr = _get_clib().last_error_message() - if err_ptr == ffi.NULL: - raise KlamathError(f'{action} failed') - - message = ffi.string(err_ptr).decode(errors='replace') - raise KlamathError(message) - - -def _scan_buffer_to_arrow(buffer: bytes | mmap.mmap | memoryview) -> pyarrow.Array: - ptr_array = ffi.new('struct ArrowArray[]', 1) - ptr_schema = ffi.new('struct ArrowSchema[]', 1) - buf_view = memoryview(buffer) - cbuf = ffi.from_buffer('uint8_t[]', buf_view) - _call_native(_get_clib().scan_bytes(cbuf, len(buf_view), ptr_array, ptr_schema), 'scan_bytes') - return _import_arrow_array(ptr_array, ptr_schema) - - -def _read_selected_cells_to_arrow( - buffer: bytes | mmap.mmap | memoryview, - ranges: NDArray[numpy.uint64], - ) -> pyarrow.Array: - ptr_array = ffi.new('struct ArrowArray[]', 1) - ptr_schema = ffi.new('struct ArrowSchema[]', 1) - buf_view = memoryview(buffer) - cbuf = ffi.from_buffer('uint8_t[]', buf_view) - flat_ranges = numpy.require(ranges, dtype=numpy.uint64, requirements=('C_CONTIGUOUS', 'ALIGNED')) - cranges = ffi.from_buffer('uint64_t[]', flat_ranges) - _call_native( - _get_clib().read_cells_bytes(cbuf, len(buf_view), cranges, int(flat_ranges.shape[0]), ptr_array, ptr_schema), - 'read_cells_bytes', - ) - return _import_arrow_array(ptr_array, ptr_schema) - - -def readfile( - filename: str | pathlib.Path, - ) -> tuple[Library, dict[str, Any]]: - """ - Read a GDSII file from a path into `masque.Library` / `Pattern` objects. - - Will automatically decompress gzipped files. - - Args: - filename: Filename to read. - - For callers that can consume Arrow directly, prefer `readfile_arrow()` - to skip Python `Pattern` construction entirely. - """ - arrow_arr = _read_to_arrow(filename) - assert len(arrow_arr) == 1 - - results = read_arrow(arrow_arr[0]) - - return results - - -def readfile_arrow( - filename: str | pathlib.Path, - ) -> tuple[pyarrow.StructScalar, dict[str, Any]]: - """ - Read a GDSII file into the native Arrow representation without converting - it into `masque.Library` / `Pattern` objects. - - This is the lowest-overhead public read path exposed by this module. - - Args: - filename: Filename to read. - - Returns: - - Arrow struct scalar for the library payload - - dict of GDSII library info - """ - arrow_arr = _read_to_arrow(filename) - assert len(arrow_arr) == 1 - libarr = arrow_arr[0] - return libarr, _read_header(libarr) - - -def read_arrow( - libarr: pyarrow.Array, - ) -> tuple[Library, dict[str, Any]]: - """ - # TODO check GDSII file for cycles! - Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are - translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs - are translated into Ref objects. - - Additional library info is returned in a dict, containing: - 'name': name of the library - 'meters_per_unit': number of meters per database unit (all values are in database units) - 'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns) - per database unit - - Args: - libarr: Arrow library payload as returned by `readfile_arrow()`. - - Returns: - - dict of pattern_name:Patterns generated from GDSII structures - - dict of GDSII library info - """ - library_info = _read_header(libarr) - - layer_names_np = _packed_layer_u32_to_pairs(libarr['layers'].values.to_numpy()) - layer_tups = [(int(pair[0]), int(pair[1])) for pair in layer_names_np] - - cell_ids = libarr['cells'].values.field('id').to_numpy() - cell_names = libarr['cell_names'].as_py() - - # Masque geometry is mutable and supports fractional transforms. Convert - # coordinates in bulk before slicing them into objects; scan-only and raw - # GDS copy-through workflows never enter this materialization path. - def get_geom(libarr: pyarrow.Array, geom_type: str) -> dict[str, Any]: - el = libarr['cells'].values.field(geom_type) - elem = dict( - offsets = el.offsets.to_numpy(), - xy_arr = el.values.field('xy').values.to_numpy().astype(float).reshape((-1, 2)), - xy_off = el.values.field('xy').offsets.to_numpy() // 2, - layer_inds = el.values.field('layer').to_numpy(), - prop_off = el.values.field('properties').offsets.to_numpy(), - prop_key = el.values.field('properties').values.field('key').to_numpy(), - prop_val = el.values.field('properties').values.field('value').to_pylist(), - ) - return elem - - def get_boundary_batches(libarr: pyarrow.Array) -> dict[str, Any]: - batches = libarr['cells'].values.field('boundary_batches') - return dict( - offsets = batches.offsets.to_numpy(), - layer_inds = batches.values.field('layer').to_numpy(), - vert_arr = batches.values.field('vertices').values.to_numpy().astype(float).reshape((-1, 2)), - vert_off = batches.values.field('vertices').offsets.to_numpy() // 2, - poly_off = batches.values.field('vertex_offsets').offsets.to_numpy(), - poly_offsets = batches.values.field('vertex_offsets').values.to_numpy(), - ) - - def get_rect_batches(libarr: pyarrow.Array) -> dict[str, Any]: - batches = libarr['cells'].values.field('rect_batches') - return dict( - offsets = batches.offsets.to_numpy(), - layer_inds = batches.values.field('layer').to_numpy(), - rect_arr = batches.values.field('rects').values.to_numpy().astype(float).reshape((-1, 4)), - rect_off = batches.values.field('rects').offsets.to_numpy() // 4, - ) - - def get_boundary_props(libarr: pyarrow.Array) -> dict[str, Any]: - boundaries = libarr['cells'].values.field('boundary_props') - return dict( - offsets = boundaries.offsets.to_numpy(), - layer_inds = boundaries.values.field('layer').to_numpy(), - vert_arr = boundaries.values.field('vertices').values.to_numpy().astype(float).reshape((-1, 2)), - vert_off = boundaries.values.field('vertices').offsets.to_numpy() // 2, - prop_off = boundaries.values.field('properties').offsets.to_numpy(), - prop_key = boundaries.values.field('properties').values.field('key').to_numpy(), - prop_val = boundaries.values.field('properties').values.field('value').to_pylist(), - ) - - def get_refs(libarr: pyarrow.Array, geom_type: str, has_repetition: bool) -> dict[str, Any]: - refs = libarr['cells'].values.field(geom_type) - values = refs.values - elem = dict( - offsets = refs.offsets.to_numpy(), - targets = values.field('target').to_numpy(), - xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()).astype(float), - invert_y = values.field('invert_y').to_numpy(zero_copy_only=False), - angle_rad = values.field('angle_rad').to_numpy(), - scale = values.field('scale').to_numpy(), - ) - if has_repetition: - elem.update(dict( - xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()).astype(float), - xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float), - counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()), - )) - return elem - - def get_ref_props(libarr: pyarrow.Array, geom_type: str, has_repetition: bool) -> dict[str, Any]: - refs = libarr['cells'].values.field(geom_type) - values = refs.values - elem = dict( - offsets = refs.offsets.to_numpy(), - targets = values.field('target').to_numpy(), - xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()).astype(float), - invert_y = values.field('invert_y').to_numpy(zero_copy_only=False), - angle_rad = values.field('angle_rad').to_numpy(), - scale = values.field('scale').to_numpy(), - prop_off = values.field('properties').offsets.to_numpy(), - prop_key = values.field('properties').values.field('key').to_numpy(), - prop_val = values.field('properties').values.field('value').to_pylist(), - ) - if has_repetition: - elem.update(dict( - xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()).astype(float), - xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float), - counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()), - )) - return elem - - txt = libarr['cells'].values.field('texts') - texts = dict( - offsets = txt.offsets.to_numpy(), - layer_inds = txt.values.field('layer').to_numpy(), - xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()).astype(float), - string = txt.values.field('string').to_pylist(), - prop_off = txt.values.field('properties').offsets.to_numpy(), - prop_key = txt.values.field('properties').values.field('key').to_numpy(), - prop_val = txt.values.field('properties').values.field('value').to_pylist(), - ) - - elements = dict( - srefs = get_refs(libarr, 'srefs', has_repetition=False), - arefs = get_refs(libarr, 'arefs', has_repetition=True), - sref_props = get_ref_props(libarr, 'sref_props', has_repetition=False), - aref_props = get_ref_props(libarr, 'aref_props', has_repetition=True), - rect_batches = get_rect_batches(libarr), - boundary_batches = get_boundary_batches(libarr), - boundary_props = get_boundary_props(libarr), - paths = get_geom(libarr, 'paths'), - texts = texts, - ) - - paths = libarr['cells'].values.field('paths') - elements['paths'].update(dict( - width = paths.values.field('width').fill_null(0).to_numpy(), - path_type = paths.values.field('path_type').fill_null(0).to_numpy(), - extensions = numpy.stack(( - paths.values.field('extension_start').fill_null(0).to_numpy(), - paths.values.field('extension_end').fill_null(0).to_numpy(), - ), axis=-1, dtype=float), - )) - - global_args = dict( - cell_names = cell_names, - layer_tups = layer_tups, - ) - - mlib = Library() - for cc in range(len(libarr['cells'])): - name = cell_names[int(cell_ids[cc])] - pat = Pattern() - _rect_batches_to_rectcollections(pat, global_args, elements['rect_batches'], cc) - _boundary_batches_to_polygons(pat, global_args, elements['boundary_batches'], cc) - _boundary_props_to_polygons(pat, global_args, elements['boundary_props'], cc) - _gpaths_to_mpaths(pat, global_args, elements['paths'], cc) - _srefs_to_mrefs(pat, global_args, elements['srefs'], cc) - _arefs_to_mrefs(pat, global_args, elements['arefs'], cc) - _sref_props_to_mrefs(pat, global_args, elements['sref_props'], cc) - _aref_props_to_mrefs(pat, global_args, elements['aref_props'], cc) - _texts_to_labels(pat, global_args, elements['texts'], cc) - mlib[name] = pat - - return mlib, library_info - - -def _read_header(libarr: pyarrow.Array) -> dict[str, Any]: - """ - Read the file header and create the library_info dict. - """ - library_info = dict( - name = libarr['lib_name'].as_py(), - meters_per_unit = libarr['meters_per_db_unit'].as_py(), - logical_units_per_unit = libarr['user_units_per_db_unit'].as_py(), - ) - return library_info - - -def _srefs_to_mrefs( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - cell_names = global_args['cell_names'] - elem_off = elem['offsets'] - elem_count = elem_off[cc + 1] - elem_off[cc] - if elem_count == 0: - return - - start = elem_off[cc] - stop = elem_off[cc + 1] - elem_targets = elem['targets'][start:stop] - elem_xy = elem['xy'][start:stop] - elem_invert_y = elem['invert_y'][start:stop] - elem_angle_rad = elem['angle_rad'][start:stop] - elem_scale = elem['scale'][start:stop] - - _append_plain_refs_sorted( - pat=pat, - cell_names=cell_names, - elem_targets=elem_targets, - elem_xy=elem_xy, - elem_invert_y=elem_invert_y, - elem_angle_rad=elem_angle_rad, - elem_scale=elem_scale, - ) - - -def _append_plain_refs_sorted( - *, - pat: Pattern, - cell_names: list[str], - elem_targets: NDArray[numpy.integer[Any]], - elem_xy: NDArray[numpy.float64], - elem_invert_y: NDArray[numpy.bool_ | numpy.bool], - elem_angle_rad: NDArray[numpy.floating[Any]], - elem_scale: NDArray[numpy.floating[Any]], - ) -> None: - elem_count = len(elem_targets) - if elem_count == 0: - return - - target_start = 0 - while target_start < elem_count: - target_id = int(elem_targets[target_start]) - target_stop = target_start + 1 - while target_stop < elem_count and elem_targets[target_stop] == target_id: - target_stop += 1 - - append_refs = pat.refs[cell_names[target_id]].extend - append_refs( - Ref._from_raw( - offset=elem_xy[ee], - mirrored=elem_invert_y[ee], - rotation=elem_angle_rad[ee], - scale=elem_scale[ee], - repetition=None, - annotations=None, - ) - for ee in range(target_start, target_stop) - ) - - target_start = target_stop - - -def _arefs_to_mrefs( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - cell_names = global_args['cell_names'] - elem_off = elem['offsets'] - elem_count = elem_off[cc + 1] - elem_off[cc] - if elem_count == 0: - return - - start = elem_off[cc] - stop = elem_off[cc + 1] - elem_targets = elem['targets'][start:stop] - elem_xy = elem['xy'][start:stop] - elem_invert_y = elem['invert_y'][start:stop] - elem_angle_rad = elem['angle_rad'][start:stop] - elem_scale = elem['scale'][start:stop] - elem_xy0 = elem['xy0'][start:stop] - elem_xy1 = elem['xy1'][start:stop] - elem_counts = elem['counts'][start:stop] - - if len(elem_targets) == 0: - return - - target = None - append_ref: Callable[[Ref], Any] | None = None - for ee in range(len(elem_targets)): - target_id = int(elem_targets[ee]) - if target != target_id: - target = target_id - append_ref = pat.refs[cell_names[target_id]].append - assert append_ref is not None - a_count, b_count = elem_counts[ee] - append_ref(Ref._from_raw( - offset=elem_xy[ee], - mirrored=elem_invert_y[ee], - rotation=elem_angle_rad[ee], - scale=elem_scale[ee], - repetition=Grid._from_raw(a_vector=elem_xy0[ee], b_vector=elem_xy1[ee], a_count=a_count, b_count=b_count), - annotations=None, - )) - - -def _sref_props_to_mrefs( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - cell_names = global_args['cell_names'] - elem_off = elem['offsets'] - prop_key = elem['prop_key'] - prop_val = elem['prop_val'] - - elem_count = elem_off[cc + 1] - elem_off[cc] - if elem_count == 0: - return - - elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) - prop_offs = elem['prop_off'][elem_slc] - elem_targets = elem['targets'][elem_off[cc]:elem_off[cc + 1]] - elem_xy = elem['xy'][elem_off[cc]:elem_off[cc + 1]] - elem_invert_y = elem['invert_y'][elem_off[cc]:elem_off[cc + 1]] - elem_angle_rad = elem['angle_rad'][elem_off[cc]:elem_off[cc + 1]] - elem_scale = elem['scale'][elem_off[cc]:elem_off[cc + 1]] - - for ee in range(elem_count): - annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) - ref = Ref._from_raw( - offset=elem_xy[ee], - mirrored=elem_invert_y[ee], - rotation=elem_angle_rad[ee], - scale=elem_scale[ee], - repetition=None, - annotations=annotations, - ) - pat.refs[cell_names[int(elem_targets[ee])]].append(ref) - - -def _aref_props_to_mrefs( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - cell_names = global_args['cell_names'] - elem_off = elem['offsets'] - prop_key = elem['prop_key'] - prop_val = elem['prop_val'] - - elem_count = elem_off[cc + 1] - elem_off[cc] - if elem_count == 0: - return - - elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) - prop_offs = elem['prop_off'][elem_slc] - elem_targets = elem['targets'][elem_off[cc]:elem_off[cc + 1]] - elem_xy = elem['xy'][elem_off[cc]:elem_off[cc + 1]] - elem_invert_y = elem['invert_y'][elem_off[cc]:elem_off[cc + 1]] - elem_angle_rad = elem['angle_rad'][elem_off[cc]:elem_off[cc + 1]] - elem_scale = elem['scale'][elem_off[cc]:elem_off[cc + 1]] - elem_xy0 = elem['xy0'][elem_off[cc]:elem_off[cc + 1]] - elem_xy1 = elem['xy1'][elem_off[cc]:elem_off[cc + 1]] - elem_counts = elem['counts'][elem_off[cc]:elem_off[cc + 1]] - - for ee in range(elem_count): - a_count, b_count = elem_counts[ee] - annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) - ref = Ref._from_raw( - offset=elem_xy[ee], - mirrored=elem_invert_y[ee], - rotation=elem_angle_rad[ee], - scale=elem_scale[ee], - repetition=Grid._from_raw(a_vector=elem_xy0[ee], b_vector=elem_xy1[ee], a_count=a_count, b_count=b_count), - annotations=annotations, - ) - pat.refs[cell_names[int(elem_targets[ee])]].append(ref) - - -def _texts_to_labels( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - elem_off = elem['offsets'] # which elements belong to each cell - xy = elem['xy'] - layer_tups = global_args['layer_tups'] - layer_inds = elem['layer_inds'] - prop_key = elem['prop_key'] - prop_val = elem['prop_val'] - - elem_count = elem_off[cc + 1] - elem_off[cc] - elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem - prop_offs = elem['prop_off'][elem_slc] # which props belong to each element - elem_xy = xy[elem_slc][:elem_count] - elem_layer_inds = layer_inds[elem_slc][:elem_count] - elem_strings = elem['string'][elem_slc][:elem_count] - - for ee in range(elem_count): - layer = layer_tups[int(elem_layer_inds[ee])] - offset = elem_xy[ee] - string = elem_strings[ee] - - annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) - mlabel = Label._from_raw(string=string, offset=offset, annotations=annotations) - pat.labels[layer].append(mlabel) - - -def _gpaths_to_mpaths( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - elem_off = elem['offsets'] # which elements belong to each cell - xy_val = elem['xy_arr'] - layer_tups = global_args['layer_tups'] - layer_inds = elem['layer_inds'] - prop_key = elem['prop_key'] - prop_val = elem['prop_val'] - - elem_count = elem_off[cc + 1] - elem_off[cc] - elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem - xy_offs = elem['xy_off'][elem_slc] # which xy coords belong to each element - prop_offs = elem['prop_off'][elem_slc] # which props belong to each element - elem_layer_inds = layer_inds[elem_slc][:elem_count] - elem_widths = elem['width'][elem_slc][:elem_count] - elem_path_types = elem['path_type'][elem_slc][:elem_count] - elem_extensions = elem['extensions'][elem_slc][:elem_count] - - for ee in range(elem_count): - layer = layer_tups[int(elem_layer_inds[ee])] - vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1]] - width = elem_widths[ee] - cap_int = int(elem_path_types[ee]) - if cap_int not in _PATH_CAP_MAP: - raise PatternError(f'Unrecognized path type: {cap_int}') - cap = _PATH_CAP_MAP[cap_int] - if cap_int == 4: - cap_extensions = elem_extensions[ee] - else: - cap_extensions = None - - annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) - path = Path._from_raw( - vertices=vertices, - width=width, - cap=cap, - cap_extensions=cap_extensions, - annotations=annotations, - ) - pat.shapes[layer].append(path) - - -def _boundary_batches_to_polygons( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - elem_off = elem['offsets'] # which elements belong to each cell - vert_arr = elem['vert_arr'] - vert_off = elem['vert_off'] - layer_inds = elem['layer_inds'] - layer_tups = global_args['layer_tups'] - poly_off = elem['poly_off'] - poly_offsets = elem['poly_offsets'] - - batch_count = elem_off[cc + 1] - elem_off[cc] - if batch_count == 0: - return - - elem_slc = slice(elem_off[cc], elem_off[cc] + batch_count + 1) # +1 to capture ending location for last elem - elem_vert_off = vert_off[elem_slc] - elem_poly_off = poly_off[elem_slc] - elem_layer_inds = layer_inds[elem_slc][:batch_count] - - for bb in range(batch_count): - layer = layer_tups[int(elem_layer_inds[bb])] - vertices = vert_arr[elem_vert_off[bb]:elem_vert_off[bb + 1]] - vertex_offsets = poly_offsets[elem_poly_off[bb]:elem_poly_off[bb + 1]] - - if vertex_offsets.size == 1: - poly = Polygon._from_raw(vertices=vertices, annotations=None) - pat.shapes[layer].append(poly) - else: - polys = PolyCollection._from_raw(vertex_lists=vertices, vertex_offsets=vertex_offsets, annotations=None) - pat.shapes[layer].append(polys) - - -def _rect_batches_to_rectcollections( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - elem_off = elem['offsets'] - rect_arr = elem['rect_arr'] - rect_off = elem['rect_off'] - layer_inds = elem['layer_inds'] - layer_tups = global_args['layer_tups'] - - batch_count = elem_off[cc + 1] - elem_off[cc] - if batch_count == 0: - return - - elem_slc = slice(elem_off[cc], elem_off[cc] + batch_count + 1) - elem_rect_off = rect_off[elem_slc] - elem_layer_inds = layer_inds[elem_slc][:batch_count] - - for bb in range(batch_count): - layer = layer_tups[int(elem_layer_inds[bb])] - rects = rect_arr[elem_rect_off[bb]:elem_rect_off[bb + 1]] - rect_collection = RectCollection._from_raw(rects=rects, annotations=None) - pat.shapes[layer].append(rect_collection) - - -def _boundary_props_to_polygons( - pat: Pattern, - global_args: dict[str, Any], - elem: dict[str, Any], - cc: int, - ) -> None: - elem_off = elem['offsets'] - vert_arr = elem['vert_arr'] - vert_off = elem['vert_off'] - layer_inds = elem['layer_inds'] - layer_tups = global_args['layer_tups'] - prop_key = elem['prop_key'] - prop_val = elem['prop_val'] - - elem_count = elem_off[cc + 1] - elem_off[cc] - if elem_count == 0: - return - - elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) - elem_vert_off = vert_off[elem_slc] - prop_offs = elem['prop_off'][elem_slc] - elem_layer_inds = layer_inds[elem_slc][:elem_count] - - for ee in range(elem_count): - layer = layer_tups[int(elem_layer_inds[ee])] - vertices = vert_arr[elem_vert_off[ee]:elem_vert_off[ee + 1]] - annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) - poly = Polygon._from_raw(vertices=vertices, annotations=annotations) - pat.shapes[layer].append(poly) diff --git a/masque/file/gdsii/klamath.py b/masque/file/gdsii/klamath.py deleted file mode 100644 index 1b7a968..0000000 --- a/masque/file/gdsii/klamath.py +++ /dev/null @@ -1,501 +0,0 @@ -""" -GDSII file format readers and writers using the `klamath` library. - -Note that GDSII references follow the same convention as `masque`, - with this order of operations: - 1. Mirroring - 2. Rotation - 3. Scaling - 4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets) - - Scaling, rotation, and mirroring apply to individual instances, not grid - vectors or offsets. - -Notes: - * absolute positioning is not supported - * PLEX is not supported - * ELFLAGS are not supported - * GDS does not support library- or structure-level annotations - * GDS creation/modification/access times are set to 1900-01-01 for reproducibility. - * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) -""" -from typing import IO, Any -from collections.abc import Iterable, Mapping, Callable -from types import MappingProxyType -import logging -import pathlib -import gzip -import string -from pprint import pformat - -import numpy -from numpy.typing import ArrayLike, NDArray -import klamath -from klamath import records - -from ..utils import is_gzipped -from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape -from ...shapes import Polygon, Path, RectCollection -from ...repetition import Grid -from ...utils import layer_t, annotations_t -from ...library import Library - - -logger = logging.getLogger(__name__) - - -_PATH_CAP_MAP = { - 0: Path.Cap.Flush, - 1: Path.Cap.Circle, - 2: Path.Cap.Square, - 4: Path.Cap.SquareCustom, - } - -_EMPTY_PROPERTIES: Mapping[int, bytes] = MappingProxyType({}) - - -def _rint_cast(val: ArrayLike) -> NDArray[numpy.int32]: - return numpy.rint(val).astype(numpy.int32) - - -def _write_header( - stream: IO[bytes], - meters_per_unit: float, - logical_units_per_unit: float, - library_name: str, - ) -> None: - header = klamath.library.FileHeader( - name=library_name.encode('ASCII'), - user_units_per_db_unit=logical_units_per_unit, - meters_per_db_unit=meters_per_unit, - ) - header.write(stream) - - -def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None: - elements: list[klamath.elements.Element] = [] - elements += _shapes_to_elements(pat.shapes) - elements += _labels_to_texts(pat.labels) - elements += _mrefs_to_grefs(pat.refs) - klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements) - - -def _write_footer(stream: IO[bytes]) -> None: - records.ENDLIB.write(stream, None) - - -def readfile( - filename: str | pathlib.Path, - *args, - **kwargs, - ) -> tuple[Library, dict[str, Any]]: - """ - Wrapper for `read()` that takes a filename or path instead of a stream. - - Will automatically decompress gzipped files. - - Args: - filename: Filename to save to. - *args: passed to `read()` - **kwargs: passed to `read()` - """ - path = pathlib.Path(filename) - if is_gzipped(path): - open_func: Callable = gzip.open - else: - open_func = open - - with open_func(path, mode='rb') as stream: - results = read(stream, *args, **kwargs) - return results - - -def read( - stream: IO[bytes], - raw_mode: bool = True, - ) -> tuple[Library, dict[str, Any]]: - """ - # TODO check GDSII file for cycles! - Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are - translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs - are translated into Ref objects. - - Additional library info is returned in a dict, containing: - 'name': name of the library - 'meters_per_unit': number of meters per database unit (all values are in database units) - 'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns) - per database unit - - Args: - stream: Stream to read from. - raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True. - - Returns: - - dict of pattern_name:Patterns generated from GDSII structures - - dict of GDSII library info - """ - library_info = _read_header(stream) - - mlib = Library() - found_struct = records.BGNSTR.skip_past(stream) - while found_struct: - name = records.STRNAME.skip_and_read(stream) - pat = _read_elements(stream, raw_mode=raw_mode) - mlib[name.decode('ASCII')] = pat - found_struct = records.BGNSTR.skip_past(stream) - - return mlib, library_info - - -def _read_header(stream: IO[bytes]) -> dict[str, Any]: - """ - Read the file header and create the library_info dict. - """ - header = klamath.library.FileHeader.read(stream) - - library_info = {'name': header.name.decode('ASCII'), - 'meters_per_unit': header.meters_per_db_unit, - 'logical_units_per_unit': header.user_units_per_db_unit, - } - return library_info - - -def _read_elements( - stream: IO[bytes], - raw_mode: bool = True, - ) -> Pattern: - """ - Read elements from a GDS structure and build a Pattern from them. - - Args: - stream: Seekable stream, positioned at a record boundary. - Will be read until an ENDSTR record is consumed. - name: Name of the resulting Pattern - raw_mode: If True, bypass per-shape data validation. Default True. - - Returns: - A pattern containing the elements that were read. - """ - pat = Pattern() - - elements = klamath.library.read_elements(stream) - for element in elements: - if isinstance(element, klamath.elements.Boundary): - layer, poly = _boundary_to_polygon(element, raw_mode) - pat.shapes[layer].append(poly) - elif isinstance(element, klamath.elements.Path): - layer, path = _gpath_to_mpath(element, raw_mode) - pat.shapes[layer].append(path) - elif isinstance(element, klamath.elements.Text): - pat.label( - layer=element.layer, - offset=element.xy.astype(float), - string=element.string.decode('ASCII'), - annotations=_properties_to_annotations(element.properties), - ) - elif isinstance(element, klamath.elements.Reference): - target, ref = _gref_to_mref(element) - pat.refs[target].append(ref) - return pat - - -def _mlayer2gds(mlayer: layer_t) -> tuple[int, int]: - """ Helper to turn a layer tuple-or-int into a layer and datatype""" - if isinstance(mlayer, int): - layer = mlayer - data_type = 0 - elif isinstance(mlayer, tuple): - layer = mlayer[0] - if len(mlayer) > 1: - data_type = mlayer[1] - else: - data_type = 0 - else: - raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.') - return layer, data_type - - -def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]: - """ - Helper function to create a Ref from an SREF or AREF. Sets ref.target to struct_name. - """ - xy = ref.xy.astype(float) - offset = xy[0] - repetition = None - if ref.colrow is not None: - a_count, b_count = ref.colrow - a_vector = (xy[1] - offset) / a_count - b_vector = (xy[2] - offset) / b_count - repetition = Grid(a_vector=a_vector, b_vector=b_vector, - a_count=a_count, b_count=b_count) - - target = ref.struct_name.decode('ASCII') - mref = Ref( - offset=offset, - rotation=numpy.deg2rad(ref.angle_deg), - scale=ref.mag, - mirrored=ref.invert_y, - annotations=_properties_to_annotations(ref.properties), - repetition=repetition, - ) - return target, mref - - -def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_t, Path]: - if gpath.path_type in _PATH_CAP_MAP: - cap = _PATH_CAP_MAP[gpath.path_type] - else: - raise PatternError(f'Unrecognized path type: {gpath.path_type}') - - vertices = gpath.xy.astype(float) - annotations = _properties_to_annotations(gpath.properties) - cap_extensions = None - if cap == Path.Cap.SquareCustom: - cap_extensions = numpy.asarray(gpath.extension, dtype=float) - - if raw_mode: - mpath = Path._from_raw( - vertices=vertices, - width=gpath.width, - cap=cap, - cap_extensions=cap_extensions, - annotations=annotations, - ) - else: - mpath = Path( - vertices=vertices, - width=gpath.width, - cap=cap, - cap_extensions=cap_extensions, - offset=numpy.zeros(2), - annotations=annotations, - ) - return gpath.layer, mpath - - -def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]: - vertices = boundary.xy[:-1].astype(float) - annotations = _properties_to_annotations(boundary.properties) - if raw_mode: - poly = Polygon._from_raw(vertices=vertices, annotations=annotations) - else: - poly = Polygon(vertices=vertices, offset=numpy.zeros(2), annotations=annotations) - return boundary.layer, poly - - -def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]: - grefs = [] - for target, rseq in refs.items(): - if target is None: - continue - encoded_name = target.encode('ASCII') - for ref in rseq: - # Note: GDS also mirrors first and rotates second - rep = ref.repetition - angle_deg = numpy.rad2deg(ref.rotation) % 360 - properties = _annotations_to_properties(ref.annotations, 512) - - if isinstance(rep, Grid): - b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) - b_count = rep.b_count if rep.b_count is not None else 1 - xy = numpy.asarray(ref.offset) + numpy.array([ - [0.0, 0.0], - rep.a_vector * rep.a_count, - b_vector * b_count, - ]) - aref = klamath.library.Reference( - struct_name=encoded_name, - xy=_rint_cast(xy), - colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)), - angle_deg=angle_deg, - invert_y=ref.mirrored, - mag=ref.scale, - properties=properties, - ) - grefs.append(aref) - elif rep is None: - sref = klamath.library.Reference( - struct_name=encoded_name, - xy=_rint_cast([ref.offset]), - colrow=None, - angle_deg=angle_deg, - invert_y=ref.mirrored, - mag=ref.scale, - properties=properties, - ) - grefs.append(sref) - else: - new_srefs = [ - klamath.library.Reference( - struct_name=encoded_name, - xy=_rint_cast([ref.offset + dd]), - colrow=None, - angle_deg=angle_deg, - invert_y=ref.mirrored, - mag=ref.scale, - properties=properties, - ) - for dd in rep.displacements] - grefs += new_srefs - return grefs - - -def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t: - if not properties: - return None - return {str(k): [v.decode()] for k, v in properties.items()} - - -def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]: - if annotations is None: - return _EMPTY_PROPERTIES - cum_len = 0 - props = {} - for key, vals in annotations.items(): - try: - i = int(key) - except ValueError as err: - raise PatternError(f'Annotation key {key} is not convertable to an integer') from err - if not (0 < i <= 126): - raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,126])') - - val_strings = ' '.join(str(val) for val in vals) - b = val_strings.encode() - if len(b) > 126: - raise PatternError(f'Annotation value {b!r} is longer than 126 characters!') - cum_len += numpy.ceil(len(b) / 2) * 2 + 2 - if cum_len > max_len: - raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}') - props[i] = b - return props - - -def _shapes_to_elements( - shapes: dict[layer_t, list[Shape]], - polygonize_paths: bool = False, - ) -> list[klamath.elements.Element]: - elements: list[klamath.elements.Element] = [] - # Add a Boundary element for each shape, and Path elements if necessary - for mlayer, sseq in shapes.items(): - layer, data_type = _mlayer2gds(mlayer) - for shape in sseq: - if shape.repetition is not None: - raise PatternError('Shape repetitions are not supported by GDS.' - ' Please call library.wrap_repeated_shapes() before writing to file.') - - properties = _annotations_to_properties(shape.annotations, 128) - if isinstance(shape, Path) and not polygonize_paths: - xy = _rint_cast(shape.vertices + shape.offset) - width = _rint_cast(shape.width) - path_type = next(k for k, v in _PATH_CAP_MAP.items() if v == shape.cap) # reverse lookup - - extension: tuple[int, int] - if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: - extension = tuple(_rint_cast(shape.cap_extensions)) - else: - extension = (0, 0) - - path = klamath.elements.Path( - layer=(layer, data_type), - xy=xy, - path_type=path_type, - width=int(width), - extension=extension, - properties=properties, - ) - elements.append(path) - elif isinstance(shape, RectCollection): - for rect in shape.rects: - xy_closed = numpy.empty((5, 2), dtype=numpy.int32) - xy_closed[0] = _rint_cast((rect[0], rect[1])) - xy_closed[1] = _rint_cast((rect[0], rect[3])) - xy_closed[2] = _rint_cast((rect[2], rect[3])) - xy_closed[3] = _rint_cast((rect[2], rect[1])) - xy_closed[4] = xy_closed[0] - boundary = klamath.elements.Boundary( - layer=(layer, data_type), - xy=xy_closed, - properties=properties, - ) - elements.append(boundary) - elif isinstance(shape, Polygon): - polygon = shape - xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) - numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') - xy_closed[-1] = xy_closed[0] - boundary = klamath.elements.Boundary( - layer=(layer, data_type), - xy=xy_closed, - properties=properties, - ) - elements.append(boundary) - else: - for polygon in shape.to_polygons(): - xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) - numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') - xy_closed[-1] = xy_closed[0] - boundary = klamath.elements.Boundary( - layer=(layer, data_type), - xy=xy_closed, - properties=properties, - ) - elements.append(boundary) - return elements - - -def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.elements.Text]: - texts = [] - for mlayer, lseq in labels.items(): - layer, text_type = _mlayer2gds(mlayer) - for label in lseq: - properties = _annotations_to_properties(label.annotations, 128) - xy = _rint_cast([label.offset]) - text = klamath.elements.Text( - layer=(layer, text_type), - xy=xy, - string=label.string.encode('ASCII'), - properties=properties, - presentation=0, # font number & alignment -- unused by us - angle_deg=0, # rotation -- unused by us - invert_y=False, # inversion -- unused by us - width=0, # stroke width -- unused by us - path_type=0, # text path endcaps, unused - mag=1, # size -- unused by us - ) - texts.append(text) - return texts - - -def check_valid_names( - names: Iterable[str], - max_length: int = 32, - ) -> None: - """ - Check all provided names to see if they're valid GDSII cell names. - - Args: - names: Collection of names to check - max_length: Max allowed length - - """ - names = tuple(names) - allowed_chars = set(string.ascii_letters + string.digits + '_?$') - - bad_chars = [ - name for name in names - if not set(name).issubset(allowed_chars) - ] - - bad_lengths = [ - name for name in names - if len(name) > max_length - ] - - if bad_chars: - logger.error('Names contain invalid characters:\n' + pformat(bad_chars)) - - if bad_lengths: - logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths)) - - if bad_chars or bad_lengths: - raise LibraryError('Library contains invalid names, see log above') diff --git a/masque/file/gdsii/lazy.py b/masque/file/gdsii/lazy.py deleted file mode 100644 index e03082b..0000000 --- a/masque/file/gdsii/lazy.py +++ /dev/null @@ -1,288 +0,0 @@ -""" -Classic source-backed lazy GDSII reader built on the pure-python klamath path. - -This module provides the non-Arrow half of Masque's lazy GDS architecture: - -- `GdsLibrarySource` scans a GDS stream once to discover library metadata, - struct order, and child edges without materializing every cell. -- cells are materialized on demand through the classic `gdsii` decoder - whenever a caller indexes the lazy view -- untouched cells can be copied directly to another GDS file without - materializing them -- the source can be wrapped in `PortLoadView` or merged through - `OverlayLibrary` - -The public surface intentionally parallels `gdsii.lazy_arrow` closely so that -callers can swap between the classic and Arrow-backed implementations with -minimal changes. -""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import IO, TYPE_CHECKING, Any, cast -import gzip -import io -import logging -import mmap -import pathlib - -import klamath -from klamath import records - -from . import klamath as gdsii_klamath -from ..utils import is_gzipped -from ...error import LibraryError -from ...library import ( - ILibraryView, - IMaterializable, - LibraryView, -) - -if TYPE_CHECKING: - from collections.abc import Iterator, Sequence - - import numpy - from numpy.typing import NDArray - - from ...pattern import Pattern - - -logger = logging.getLogger(__name__) - - -@dataclass -class _SourceHandle: - """ Owns the underlying stream and any companion file handle for a source. """ - path: pathlib.Path | None - stream: IO[bytes] - handle: IO[bytes] | None = None - - def close(self) -> None: - self.stream.close() - if self.handle is not None and self.handle is not self.stream: - self.handle.close() - self.handle = None - - -@dataclass(frozen=True) -class _CellScan: - """ Scan-time metadata for one cell in the source stream. """ - offset: int - struct_start: int - struct_end: int - children: set[str] - - -def _open_source_stream( - filename: str | pathlib.Path, - *, - use_mmap: bool, - ) -> _SourceHandle: - path = pathlib.Path(filename).expanduser().resolve() - if is_gzipped(path): - if use_mmap: - logger.info('Asked to mmap a gzipped file, reading into memory instead...') - with gzip.open(path, mode='rb') as gzip_stream: - data = gzip_stream.read() - return _SourceHandle(path=path, stream=io.BytesIO(data)) - source_stream = cast('IO[bytes]', gzip.open(path, mode='rb')) # noqa: SIM115 - return _SourceHandle(path=path, stream=source_stream) - - if use_mmap: - handle = path.open(mode='rb', buffering=0) - mapped = cast('IO[bytes]', mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ)) - return _SourceHandle(path=path, stream=mapped, handle=handle) - - source_stream = path.open(mode='rb') - return _SourceHandle(path=path, stream=source_stream) - - -def _scan_library( - stream: IO[bytes], - ) -> tuple[dict[str, Any], list[str], dict[str, _CellScan]]: - library_info = gdsii_klamath._read_header(stream) - order: list[str] = [] - cells: dict[str, _CellScan] = {} - - while True: - struct_start = stream.tell() - if not records.BGNSTR.skip_past(stream): - break - name = records.STRNAME.skip_and_read(stream).decode('ASCII') - offset = stream.tell() - elements = klamath.library.read_elements(stream) - struct_end = stream.tell() - children = { - element.struct_name.decode('ASCII') - for element in elements - if isinstance(element, klamath.elements.Reference) - } - order.append(name) - cells[name] = _CellScan( - offset=offset, - struct_start=struct_start, - struct_end=struct_end, - children=children, - ) - - return library_info, order, cells - - -class GdsLibrarySource(ILibraryView, IMaterializable): - """ - Read-only library backed by a seekable GDS stream. - - Cells are scanned once up front to discover order, byte ranges, and child - edges. Untouched structures can be copied directly, while accessed cells - are materialized through the classic GDS decoder. - - The source owns the stream lifetime, preserves on-disk ordering through - `source_order()`, and answers graph queries from scan metadata whenever - possible so callers can inspect hierarchy without forcing a full load. - """ - - def __init__( - self, - *, - source: _SourceHandle, - library_info: dict[str, Any], - cell_order: Sequence[str], - cells: dict[str, _CellScan], - ) -> None: - self.path = source.path - self.library_info = library_info - self._source = source - self._cell_order = tuple(cell_order) - self._cells = cells - self._cache: dict[str, Pattern] = {} - self._lookups_in_progress: list[str] = [] - - @classmethod - def from_file( - cls, - filename: str | pathlib.Path, - *, - use_mmap: bool = True, - ) -> GdsLibrarySource: - source = _open_source_stream(filename, use_mmap=use_mmap) - source.stream.seek(0) - library_info, cell_order, cells = _scan_library(source.stream) - return cls(source=source, library_info=library_info, cell_order=cell_order, cells=cells) - - def __getitem__(self, key: str) -> Pattern: - return self.materialize(key, persist=True) - - def __iter__(self) -> Iterator[str]: - return iter(self._cell_order) - - def __len__(self) -> int: - return len(self._cell_order) - - def __contains__(self, key: object) -> bool: - return key in self._cells - - def source_order(self) -> tuple[str, ...]: - return self._cell_order - - def can_copy_raw_struct(self, name: str) -> bool: - """Return whether `name` still matches its original GDS structure.""" - return name in self._cells and name not in self._cache - - def raw_struct_bytes(self, name: str) -> bytes: - """Read the original complete GDS structure for `name`.""" - cell = self._cells[name] - stream = self._source.stream - stream.seek(cell.struct_start) - data = stream.read(cell.struct_end - cell.struct_start) - if len(data) != cell.struct_end - cell.struct_start: - raise LibraryError(f'Unexpected end of GDS source while copying structure {name!r}') - return data - - def _decode_pattern(self, name: str) -> Pattern: - if name not in self._cells: - raise KeyError(name) - - if name in self._lookups_in_progress: - chain = ' -> '.join(self._lookups_in_progress + [name]) - raise LibraryError( - f'Detected circular reference or recursive lookup of "{name}".\n' - f'Lookup chain: {chain}\n' - 'This may be caused by an invalid (cyclical) reference, or buggy code.\n' - 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' - ) - - self._lookups_in_progress.append(name) - try: - self._source.stream.seek(self._cells[name].offset) - pat = gdsii_klamath._read_elements(self._source.stream, raw_mode=True) - finally: - self._lookups_in_progress.pop() - - return pat - - def materialize(self, name: str, *, persist: bool = True) -> Pattern: - if name in self._cache: - return self._cache[name] - - pat = self._decode_pattern(name) - - if persist: - self._cache[name] = pat - return pat - - def materialize_detached(self, name: str) -> Pattern: - if name in self._cache: - return self._cache[name].deepcopy() - return self._decode_pattern(name) - - def materialize_many_detached( - self, - names: Sequence[str], - ) -> LibraryView: - return LibraryView({ - name: self.materialize_detached(name) - for name in dict.fromkeys(names) - }) - - def _raw_children(self, name: str) -> set[str]: - if name in self._cache: - return super()._raw_children(name) - return set(self._cells[name].children) - - def _raw_ref_transforms( - self, - parent: str, - target: str, - ) -> list[NDArray[numpy.float64]]: - if parent in self._cache: - return super()._raw_ref_transforms(parent, target) - pat = self.materialize(parent, persist=False) - return [ref.as_transforms() for ref in pat.refs.get(target, ())] - - def close(self) -> None: - self._source.close() - - def __enter__(self) -> GdsLibrarySource: - return self - - def __exit__(self, *_args: object) -> None: - self.close() - - -def read( - stream: IO[bytes], - ) -> tuple[GdsLibrarySource, dict[str, Any]]: - source = _SourceHandle(path=None, stream=stream) - stream.seek(0) - library_info, cell_order, cells = _scan_library(stream) - lib = GdsLibrarySource(source=source, library_info=library_info, cell_order=cell_order, cells=cells) - return lib, library_info - - -def readfile( - filename: str | pathlib.Path, - *, - use_mmap: bool = True, - ) -> tuple[GdsLibrarySource, dict[str, Any]]: - lib = GdsLibrarySource.from_file(filename, use_mmap=use_mmap) - return lib, lib.library_info diff --git a/masque/file/gdsii/lazy_arrow.py b/masque/file/gdsii/lazy_arrow.py deleted file mode 100644 index d686bfa..0000000 --- a/masque/file/gdsii/lazy_arrow.py +++ /dev/null @@ -1,382 +0,0 @@ -""" -Lazy GDSII readers and writers backed by native Arrow scan/materialize paths. - -This module is intentionally separate from `gdsii.arrow` so the eager read path -keeps its current behavior and performance profile. -""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import IO, TYPE_CHECKING, Any -import gzip -import logging -import mmap -import pathlib - -import numpy - -from . import arrow -from ..utils import is_gzipped -from ...library import ( - ILibraryView, - IMaterializable, - LibraryView, -) - -if TYPE_CHECKING: - from collections.abc import Iterator, Sequence - - from numpy.typing import NDArray - import pyarrow - - from ...pattern import Pattern - - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class _StructRange: - start: int - end: int - - -@dataclass -class _SourceBuffer: - path: pathlib.Path - data: bytes | mmap.mmap - handle: IO[bytes] | None = None - - def raw_slice(self, start: int, end: int) -> bytes: - return self.data[start:end] - - -@dataclass -class _ScanRefs: - offsets: NDArray[numpy.integer[Any]] - targets: NDArray[numpy.integer[Any]] - xy: NDArray[numpy.int32] - xy0: NDArray[numpy.int32] - xy1: NDArray[numpy.int32] - counts: NDArray[numpy.int64] - invert_y: NDArray[numpy.bool_ | numpy.bool] - angle_rad: NDArray[numpy.floating[Any]] - scale: NDArray[numpy.floating[Any]] - - -@dataclass(frozen=True) -class _CellScan: - cell_id: int - struct_range: _StructRange - ref_start: int - ref_stop: int - children: set[str] - - -@dataclass -class _ScanPayload: - libarr: pyarrow.StructScalar - library_info: dict[str, Any] - cell_names: list[str] - cell_order: list[str] - cells: dict[str, _CellScan] - refs: _ScanRefs - -def _open_source_buffer(path: pathlib.Path) -> _SourceBuffer: - if is_gzipped(path): - with gzip.open(path, mode='rb') as stream: - data = stream.read() - return _SourceBuffer(path=path, data=data) - - handle = path.open(mode='rb', buffering=0) - mapped = mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) - return _SourceBuffer(path=path, data=mapped, handle=handle) - - -def _extract_scan_payload(libarr: pyarrow.StructScalar) -> _ScanPayload: - library_info = arrow._read_header(libarr) - cell_names = libarr['cell_names'].as_py() - - cells = libarr['cells'] - cell_values = cells.values - cell_ids = cell_values.field('id').to_numpy() - struct_starts = cell_values.field('struct_start_offset').to_numpy() - struct_ends = cell_values.field('struct_end_offset').to_numpy() - - refs = cell_values.field('refs') - ref_values = refs.values - ref_offsets = refs.offsets.to_numpy() - targets = ref_values.field('target').to_numpy() - xy = arrow._packed_xy_u64_to_pairs(ref_values.field('xy').to_numpy()) - xy0 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy0').to_numpy()) - xy1 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy1').to_numpy()) - counts = arrow._packed_counts_u32_to_pairs(ref_values.field('counts').to_numpy()) - invert_y = ref_values.field('invert_y').to_numpy(zero_copy_only=False) - angle_rad = ref_values.field('angle_rad').to_numpy() - scale = ref_values.field('scale').to_numpy() - - ref_payload = _ScanRefs( - offsets=ref_offsets, - targets=targets, - xy=xy, - xy0=xy0, - xy1=xy1, - counts=counts, - invert_y=invert_y, - angle_rad=angle_rad, - scale=scale, - ) - - cell_order = [cell_names[int(cell_id)] for cell_id in cell_ids] - cell_scan: dict[str, _CellScan] = {} - for cc, name in enumerate(cell_order): - ref_start = int(ref_offsets[cc]) - ref_stop = int(ref_offsets[cc + 1]) - children = { - cell_names[int(target)] - for target in targets[ref_start:ref_stop] - } - cell_scan[name] = _CellScan( - cell_id=int(cell_ids[cc]), - struct_range=_StructRange(int(struct_starts[cc]), int(struct_ends[cc])), - ref_start=ref_start, - ref_stop=ref_stop, - children=children, - ) - - return _ScanPayload( - libarr=libarr, - library_info=library_info, - cell_names=cell_names, - cell_order=cell_order, - cells=cell_scan, - refs=ref_payload, - ) - -def _make_ref_rows( - xy: NDArray[numpy.integer[Any]], - angle_rad: NDArray[numpy.floating[Any]], - invert_y: NDArray[numpy.bool_ | numpy.bool], - scale: NDArray[numpy.floating[Any]], - ) -> NDArray[numpy.float64]: - rows = numpy.empty((len(xy), 5), dtype=float) - rows[:, :2] = xy - rows[:, 2] = angle_rad - rows[:, 3] = invert_y.astype(float) - rows[:, 4] = scale - return rows - - -def _expand_aref_row( - xy: NDArray[numpy.integer[Any]], - xy0: NDArray[numpy.integer[Any]], - xy1: NDArray[numpy.integer[Any]], - counts: NDArray[numpy.integer[Any]], - angle_rad: float, - invert_y: bool, - scale: float, - ) -> NDArray[numpy.float64]: - a_count = int(counts[0]) - b_count = int(counts[1]) - aa, bb = numpy.meshgrid(numpy.arange(a_count), numpy.arange(b_count), indexing='ij') - displacements = aa.reshape(-1, 1) * xy0[None, :] + bb.reshape(-1, 1) * xy1[None, :] - rows = numpy.empty((displacements.shape[0], 5), dtype=float) - rows[:, :2] = xy + displacements - rows[:, 2] = angle_rad - rows[:, 3] = float(invert_y) - rows[:, 4] = scale - return rows - - -class ArrowLibrary(ILibraryView, IMaterializable): - """ - Read-only library backed by the native lazy Arrow scan schema. - - Materializing a cell via `__getitem__` caches a real `Pattern` for that cell. - Cached cells are treated as edited for future writes from this module. - """ - - path: pathlib.Path - library_info: dict[str, Any] - - def __init__( - self, - *, - path: pathlib.Path, - payload: _ScanPayload, - source: _SourceBuffer, - ) -> None: - self.path = path - self.library_info = payload.library_info - self._payload = payload - self._name_to_id = {name: cell_id for cell_id, name in enumerate(payload.cell_names)} - self._source = source - self._cache: dict[str, Pattern] = {} - - @classmethod - def from_file(cls, filename: str | pathlib.Path) -> ArrowLibrary: - path = pathlib.Path(filename).expanduser().resolve() - source = _open_source_buffer(path) - scan_arr = arrow._scan_buffer_to_arrow(source.data) - assert len(scan_arr) == 1 - payload = _extract_scan_payload(scan_arr[0]) - return cls(path=path, payload=payload, source=source) - - def __getitem__(self, key: str) -> Pattern: - return self.materialize(key, persist=True) - - def __iter__(self) -> Iterator[str]: - return iter(self._payload.cell_order) - - def __len__(self) -> int: - return len(self._payload.cell_order) - - def __contains__(self, key: object) -> bool: - return key in self._payload.cells - - def source_order(self) -> tuple[str, ...]: - return tuple(self._payload.cell_order) - - def raw_struct_bytes(self, name: str) -> bytes: - struct_range = self._payload.cells[name].struct_range - return self._source.raw_slice(struct_range.start, struct_range.end) - - def can_copy_raw_struct(self, name: str) -> bool: - return name not in self._cache - - def materialize_many( - self, - names: Sequence[str], - *, - persist: bool = True, - ) -> LibraryView: - mats = self._materialize_patterns(names, persist=persist, detached=False) - return LibraryView(mats) - - def materialize_many_detached( - self, - names: Sequence[str], - ) -> LibraryView: - mats = self._materialize_patterns(names, persist=False, detached=True) - return LibraryView(mats) - - def _materialize_patterns( - self, - names: Sequence[str], - *, - persist: bool, - detached: bool, - ) -> dict[str, Pattern]: - ordered_names = list(dict.fromkeys(names)) - missing = [name for name in ordered_names if name not in self._payload.cells] - if missing: - raise KeyError(missing[0]) - - materialized: dict[str, Pattern] = {} - uncached = [name for name in ordered_names if name not in self._cache] - if uncached: - ranges = numpy.asarray( - [ - [ - self._payload.cells[name].struct_range.start, - self._payload.cells[name].struct_range.end, - ] - for name in uncached - ], - dtype=numpy.uint64, - ) - arrow_arr = arrow._read_selected_cells_to_arrow(self._source.data, ranges) - assert len(arrow_arr) == 1 - selected_lib, _info = arrow.read_arrow(arrow_arr[0]) - for name in uncached: - pat = selected_lib[name] - materialized[name] = pat - if persist: - self._cache[name] = pat - - for name in ordered_names: - if name not in materialized: - cached = self._cache[name] - materialized[name] = cached.deepcopy() if detached else cached - return materialized - - def materialize(self, name: str, *, persist: bool = True) -> Pattern: - return self._materialize_patterns((name,), persist=persist, detached=False)[name] - - def materialize_detached(self, name: str) -> Pattern: - return self._materialize_patterns((name,), persist=False, detached=True)[name] - - def _raw_children(self, name: str) -> set[str]: - if name in self._cache: - return super()._raw_children(name) - return set(self._payload.cells[name].children) - - def _collect_raw_transforms(self, cell: _CellScan, target_id: int) -> list[NDArray[numpy.float64]]: - refs = self._payload.refs - start = cell.ref_start - stop = cell.ref_stop - if stop <= start: - return [] - - targets = refs.targets[start:stop] - mask = targets == target_id - if not mask.any(): - return [] - - rows: list[NDArray[numpy.float64]] = [] - counts = refs.counts[start:stop] - unit_mask = mask & (counts[:, 0] == 1) & (counts[:, 1] == 1) - if unit_mask.any(): - rows.append(_make_ref_rows( - refs.xy[start:stop][unit_mask], - refs.angle_rad[start:stop][unit_mask], - refs.invert_y[start:stop][unit_mask], - refs.scale[start:stop][unit_mask], - )) - - aref_indices = numpy.nonzero(mask & ~unit_mask)[0] - for idx in aref_indices: - abs_idx = start + int(idx) - rows.append(_expand_aref_row( - xy=refs.xy[abs_idx], - xy0=refs.xy0[abs_idx], - xy1=refs.xy1[abs_idx], - counts=refs.counts[abs_idx], - angle_rad=float(refs.angle_rad[abs_idx]), - invert_y=bool(refs.invert_y[abs_idx]), - scale=float(refs.scale[abs_idx]), - )) - return rows - - def close(self) -> None: - data = self._source.data - if isinstance(data, mmap.mmap): - data.close() - if self._source.handle is not None: - self._source.handle.close() - self._source.handle = None - - def __enter__(self) -> ArrowLibrary: - return self - - def __exit__(self, *_args: object) -> None: - self.close() - - def _raw_ref_transforms( - self, - parent: str, - target: str, - ) -> list[NDArray[numpy.float64]]: - if parent in self._cache: - return super()._raw_ref_transforms(parent, target) - target_id = self._name_to_id.get(target) - if target_id is None or parent not in self._payload.cells: - return [] - return self._collect_raw_transforms(self._payload.cells[parent], target_id) - - -def readfile( - filename: str | pathlib.Path, - ) -> tuple[ArrowLibrary, dict[str, Any]]: - lib = ArrowLibrary.from_file(filename) - return lib, lib.library_info diff --git a/masque/file/gdsii/writer.py b/masque/file/gdsii/writer.py deleted file mode 100644 index d65d523..0000000 --- a/masque/file/gdsii/writer.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -GDSII writer for eager and source-backed libraries. - -The generic mutable overlay and ports-importing view live in `masque.library`. -This module preserves source-backed GDS copy-through behavior where possible, -falling back to normal pattern serialization when a cell has been materialized -or remapped. -""" -from __future__ import annotations - -from typing import IO, TYPE_CHECKING, Any, Protocol, cast, runtime_checkable -import gzip -import logging -import pathlib - -from . import klamath -from ..utils import tmpfile -from ...error import LibraryError -from ...library import IBorrowing, ILibraryView, IMaterializable - -if TYPE_CHECKING: - from collections.abc import Mapping - - from ...pattern import Pattern - - -logger = logging.getLogger(__name__) - - -@runtime_checkable -class _GdsInfoSource(Protocol): - """Structural capability for propagating GDS header metadata.""" - library_info: dict[str, Any] - - -@runtime_checkable -class _GdsRawCellSource(Protocol): - """GDS-specific raw-structure copy-through capability.""" - - def can_copy_raw_struct(self, name: str) -> bool: ... - - def raw_struct_bytes(self, name: str) -> bytes: ... - - -def _resolve_raw_struct( - library: ILibraryView, - name: str, - ) -> tuple[_GdsRawCellSource, str] | None: - """Resolve an unchanged visible cell to a copyable raw GDS structure.""" - current = library - current_name = name - seen: set[tuple[int, str]] = set() - while True: - key = (id(current), current_name) - if key in seen: - return None - seen.add(key) - - if isinstance(current, _GdsRawCellSource): - if current.can_copy_raw_struct(current_name): - return current, current_name - return None - - if not isinstance(current, IBorrowing): - return None - source_cell = current.source_cell(current_name) - if source_cell is None: - return None - source, source_name = source_cell - if source_name != current_name: - return None - current = source - current_name = source_name - - -def _get_write_info( - library: Mapping[str, Pattern] | ILibraryView, - *, - meters_per_unit: float | None, - logical_units_per_unit: float | None, - library_name: str | None, - ) -> tuple[float, float, str]: - if meters_per_unit is not None and logical_units_per_unit is not None and library_name is not None: - return meters_per_unit, logical_units_per_unit, library_name - - infos: list[dict[str, Any]] = [] - stack: list[Mapping[str, Pattern] | ILibraryView] = [library] - seen: set[int] = set() - while stack: - current = stack.pop() - if id(current) in seen: - continue - seen.add(id(current)) - if isinstance(current, _GdsInfoSource) and isinstance(current.library_info, dict): - infos.append(current.library_info) - if isinstance(current, IBorrowing): - stack.extend(reversed(current.borrowed_sources())) - - if infos: - unit_pairs = {(info['meters_per_unit'], info['logical_units_per_unit']) for info in infos} - if len(unit_pairs) > 1: - raise LibraryError('Merged lazy GDS sources must have identical units before writing') - info = infos[0] - meters = info['meters_per_unit'] if meters_per_unit is None else meters_per_unit - logical = info['logical_units_per_unit'] if logical_units_per_unit is None else logical_units_per_unit - name = info['name'] if library_name is None else library_name - return meters, logical, name - - if meters_per_unit is None: - raise LibraryError('meters_per_unit is required when writing a library without GDS metadata') - logical = 1 if logical_units_per_unit is None else logical_units_per_unit - name = 'masque-klamath' if library_name is None else library_name - return meters_per_unit, logical, name - - -def write( - library: Mapping[str, Pattern] | ILibraryView, - stream: IO[bytes], - meters_per_unit: float | None = None, - logical_units_per_unit: float | None = None, - library_name: str | None = None, - ) -> None: - """Write an eager or source-backed library to a GDSII stream.""" - meters_per_unit, logical_units_per_unit, library_name = _get_write_info( - library, - meters_per_unit=meters_per_unit, - logical_units_per_unit=logical_units_per_unit, - library_name=library_name, - ) - - klamath._write_header(stream, meters_per_unit, logical_units_per_unit, library_name) - - names = library.source_order() if isinstance(library, ILibraryView) else tuple(library) - for name in names: - if isinstance(library, ILibraryView): - raw_struct = _resolve_raw_struct(library, name) - if raw_struct is not None: - raw_source, source_name = raw_struct - stream.write(raw_source.raw_struct_bytes(source_name)) - continue - - if isinstance(library, IMaterializable): - pat = library.materialize(name, persist=False) - else: - pat = library[name] - klamath._write_pattern_struct(stream, name, pat) - - klamath._write_footer(stream) - - -def writefile( - library: Mapping[str, Pattern] | ILibraryView, - filename: str | pathlib.Path, - meters_per_unit: float | None = None, - logical_units_per_unit: float | None = None, - library_name: str | None = None, - ) -> None: - """Write an eager or source-backed library to a path, compressing `.gz` files.""" - path = pathlib.Path(filename) - with tmpfile(path) as base_stream: - if path.suffix == '.gz': - stream = cast('IO[bytes]', gzip.GzipFile( - filename='', - mtime=0, - fileobj=base_stream, - mode='wb', - compresslevel=6, - )) - try: - write(library, stream, meters_per_unit, logical_units_per_unit, library_name) - finally: - stream.close() - else: - write(library, base_stream, meters_per_unit, logical_units_per_unit, library_name) diff --git a/masque/file/klamath.py b/masque/file/klamath.py new file mode 100644 index 0000000..2208f3d --- /dev/null +++ b/masque/file/klamath.py @@ -0,0 +1,2 @@ +# FOr backwards compatibility +from .gdsii import * diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 67ea644..27c8b71 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -10,36 +10,33 @@ Note that OASIS references follow the same convention as `masque`, Scaling, rotation, and mirroring apply to individual instances, not grid vectors or offsets. - -Notes: - * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) """ -from typing import Any, IO, cast -from collections.abc import Sequence, Iterable, Mapping, Callable +from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional +import re +import io +import copy +import base64 +import struct import logging import pathlib import gzip -import string -from pprint import pformat import numpy -from numpy.typing import ArrayLike, NDArray import fatamorgana import fatamorgana.records as fatrec from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringReference -from .utils import is_gzipped, tmpfile -from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape -from ..library import Library, ILibrary -from ..shapes import Path, Circle +from .utils import clean_pattern_vertices, is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path, Circle from ..repetition import Grid, Arbitrary, Repetition -from ..utils import layer_t, annotations_t +from ..utils import layer_t, normalize_mirror, annotations_t logger = logging.getLogger(__name__) -logger.warning('OASIS support is experimental!') +logger.warning('OASIS support is experimental and mostly untested!') path_cap_map = { @@ -48,23 +45,21 @@ path_cap_map = { PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom, } -#TODO implement more shape types in OASIS? - -def rint_cast(val: ArrayLike) -> NDArray[numpy.int64]: - return numpy.rint(val).astype(numpy.int64) - +#TODO implement more shape types? def build( - library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable! + patterns: Union[Pattern, Sequence[Pattern]], units_per_micron: int, - layer_map: dict[str, int | tuple[int, int]] | None = None, + layer_map: Optional[Dict[str, Union[int, Tuple[int, int]]]] = None, *, - annotations: annotations_t | None = None, + modify_originals: bool = False, + disambiguate_func: Optional[Callable[[Iterable[Pattern]], None]] = None, + annotations: Optional[annotations_t] = None, ) -> fatamorgana.OasisLayout: """ - Convert a collection of {name: Pattern} pairs to an OASIS stream, writing patterns - as OASIS cells, refs as Placement records, and mapping other shapes and labels - to equivalent record types (Polygon, Path, Circle, Text). + Convert a `Pattern` or list of patterns to an OASIS stream, writing patterns + as OASIS cells, subpatterns as Placement records, and other shapes and labels + mapped to equivalent record types (Polygon, Path, Circle, Text). Other shape types may be converted to polygons if no equivalent record type exists (or is not implemented here yet). @@ -76,17 +71,14 @@ def build( If a layer map is provided, layer strings will be converted automatically, and layer names will be written to the file. - Other functions you may want to call: - - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names - - `library.dangling_refs()` to check for references to missing patterns - - `pattern.polygonize()` for any patterns with shapes other - than `masque.shapes.Polygon`, `masque.shapes.Path`, or `masque.shapes.Circle` + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. Args: - library: A {name: Pattern} mapping of patterns to write. + patterns: A Pattern or list of patterns to convert. units_per_micron: Written into the OASIS file, number of grid steps per micrometer. All distances are assumed to be an integer multiple of the grid step, and are stored as such. - layer_map: dictionary which translates layer names into layer numbers. If this argument is + layer_map: Dictionary which translates layer names into layer numbers. If this argument is provided, input shapes and labels are allowed to have layer names instead of numbers. It is assumed that geometry and text share the same layer names, and each name is assigned only to a single layer (not a range). @@ -94,23 +86,31 @@ def build( into numbers, omit this argument, and manually generate the required `fatamorgana.records.LayerName` entries. Default is an empty dict (no names provided). + modify_originals: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`. annotations: dictionary of key-value pairs which are saved as library-level properties Returns: `fatamorgana.OasisLayout` """ - if not isinstance(library, ILibrary): - if isinstance(library, dict): - library = Library(library) - else: - library = Library(dict(library)) + if isinstance(patterns, Pattern): + patterns = [patterns] if layer_map is None: layer_map = {} + if disambiguate_func is None: + disambiguate_func = disambiguate_pattern_names + if annotations is None: annotations = {} + if not modify_originals: + patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] + # Create library lib = fatamorgana.OasisLayout(unit=units_per_micron, validation=None) lib.properties = annotations_to_properties(annotations) @@ -119,38 +119,44 @@ def build( for name, layer_num in layer_map.items(): layer, data_type = _mlayer2oas(layer_num) lib.layers += [ - fatrec.LayerName( - nstring = name, - layer_interval = (layer, layer), - type_interval = (data_type, data_type), - is_textlayer = tt, - ) + fatrec.LayerName(nstring=name, + layer_interval=(layer, layer), + type_interval=(data_type, data_type), + is_textlayer=tt) for tt in (True, False)] - def layer2oas(mlayer: layer_t) -> tuple[int, int]: - assert layer_map is not None + def layer2oas(mlayer: layer_t) -> Tuple[int, int]: + assert(layer_map is not None) layer_num = layer_map[mlayer] if isinstance(mlayer, str) else mlayer return _mlayer2oas(layer_num) else: layer2oas = _mlayer2oas + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + disambiguate_func(patterns_by_id.values()) + # Now create a structure for each pattern - for name, pat in library.items(): - structure = fatamorgana.Cell(name=name) + for pat in patterns_by_id.values(): + structure = fatamorgana.Cell(name=pat.name) lib.cells.append(structure) structure.properties += annotations_to_properties(pat.annotations) structure.geometry += _shapes_to_elements(pat.shapes, layer2oas) structure.geometry += _labels_to_texts(pat.labels, layer2oas) - structure.placements += _refs_to_placements(pat.refs) + structure.placements += _subpatterns_to_placements(pat.subpatterns) return lib def write( - library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable! - stream: IO[bytes], + patterns: Union[Sequence[Pattern], Pattern], + stream: io.BufferedIOBase, *args, **kwargs, ) -> None: @@ -159,18 +165,18 @@ def write( for details. Args: - library: A {name: Pattern} mapping of patterns to write. + patterns: A Pattern or list of patterns to write to file. stream: Stream to write to. *args: passed to `oasis.build()` **kwargs: passed to `oasis.build()` """ - lib = build(library, *args, **kwargs) + lib = build(patterns, *args, **kwargs) lib.write(stream) def writefile( - library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable! - filename: str | pathlib.Path, + patterns: Union[Sequence[Pattern], Pattern], + filename: Union[str, pathlib.Path], *args, **kwargs, ) -> None: @@ -180,42 +186,35 @@ def writefile( Will automatically compress the file if it has a .gz suffix. Args: - library: A {name: Pattern} mapping of patterns to write. + patterns: `Pattern` or list of patterns to save filename: Filename to save to. - *args: passed to `oasis.build()` - **kwargs: passed to `oasis.build()` + *args: passed to `oasis.write` + **kwargs: passed to `oasis.write` """ path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open - with tmpfile(path) as base_stream: - streams: tuple[Any, ...] = (base_stream,) - if path.suffix == '.gz': - stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) - streams += (stream,) - else: - stream = base_stream - - try: - write(library, stream, *args, **kwargs) - finally: - for ss in streams: - ss.close() + with io.BufferedWriter(open_func(path, mode='wb')) as stream: + write(patterns, stream, *args, **kwargs) def readfile( - filename: str | pathlib.Path, + filename: Union[str, pathlib.Path], *args, **kwargs, - ) -> tuple[Library, dict[str, Any]]: + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: """ Wrapper for `oasis.read()` that takes a filename or path instead of a stream. Will automatically decompress gzipped files. Args: - filename: Filename to load from. - *args: passed to `oasis.read()` - **kwargs: passed to `oasis.read()` + filename: Filename to save to. + *args: passed to `oasis.read` + **kwargs: passed to `oasis.read` """ path = pathlib.Path(filename) if is_gzipped(path): @@ -223,18 +222,19 @@ def readfile( else: open_func = open - with open_func(path, mode='rb') as stream: + with io.BufferedReader(open_func(path, mode='rb')) as stream: results = read(stream, *args, **kwargs) return results def read( - stream: IO[bytes], - ) -> tuple[Library, dict[str, Any]]: + stream: io.BufferedIOBase, + clean_vertices: bool = True, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: """ Read a OASIS file and translate it into a dict of Pattern objects. OASIS cells are translated into Pattern objects; Polygons are translated into polygons, and Placements - are translated into Ref objects. + are translated into SubPattern objects. Additional library info is returned in a dict, containing: 'units_per_micrometer': number of database units per micrometer (all values are in database units) @@ -243,15 +243,18 @@ def read( Args: stream: Stream to read from. + clean_vertices: If `True`, remove any redundant vertices when loading polygons. + The cleaning process removes any polygons with zero area or <3 vertices. + Default `True`. Returns: - - dict of `pattern_name`:`Pattern`s generated from OASIS cells - - dict of OASIS library info + - Dict of `pattern_name`:`Pattern`s generated from OASIS cells + - Dict of OASIS library info """ lib = fatamorgana.OasisLayout.read(stream) - library_info: dict[str, Any] = { + library_info: Dict[str, Any] = { 'units_per_micrometer': lib.unit, 'annotations': properties_to_annotations(lib.properties, lib.propnames, lib.propstrings), } @@ -261,76 +264,72 @@ def read( layer_map[str(layer_name.nstring)] = layer_name library_info['layer_map'] = layer_map - mlib = Library() + patterns = [] for cell in lib.cells: if isinstance(cell.name, int): cell_name = lib.cellnames[cell.name].nstring.string else: cell_name = cell.name.string - pat = Pattern() + pat = Pattern(name=cell_name) for element in cell.geometry: if isinstance(element, fatrec.XElement): logger.warning('Skipping XElement record') # note XELEMENT has no repetition continue - assert not isinstance(element.repetition, fatamorgana.ReuseRepetition) + assert(not isinstance(element.repetition, fatamorgana.ReuseRepetition)) repetition = repetition_fata2masq(element.repetition) # Switch based on element type: if isinstance(element, fatrec.Polygon): - # Drop last point (`fatamorgana` returns explicity closed list; we use implicit close) - # also need `cumsum` to convert from deltas to locations - vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list()[:-1])), axis=0) - + vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.polygon( - vertices = vertices, - layer = element.get_layer_tuple(), - offset = element.get_xy(), - annotations = annotations, - repetition = repetition, - ) + poly = Polygon(vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + annotations=annotations, + repetition=repetition) + + pat.shapes.append(poly) + elif isinstance(element, fatrec.Path): vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) cap_start = path_cap_map[element.get_extension_start()[0]] cap_end = path_cap_map[element.get_extension_end()[0]] if cap_start != cap_end: - raise PatternError('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types + raise Exception('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types cap = cap_start - path_args: dict[str, Any] = {} + path_args: Dict[str, Any] = {} if cap == Path.Cap.SquareCustom: - path_args['cap_extensions'] = numpy.array(( - element.get_extension_start()[1], - element.get_extension_end()[1], - )) + path_args['cap_extensions'] = numpy.array((element.get_extension_start()[1], + element.get_extension_end()[1])) annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.path( - vertices = vertices, - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - annotations = annotations, - width = element.get_half_width() * 2, - cap = cap, - **path_args, - ) + path = Path(vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + width=element.get_half_width() * 2, + cap=cap, + **path_args) + + pat.shapes.append(path) elif isinstance(element, fatrec.Rectangle): width = element.get_width() height = element.get_height() annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.polygon( - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), - annotations = annotations, - ) + rect = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), + annotations=annotations, + ) + pat.shapes.append(rect) elif isinstance(element, fatrec.Trapezoid): vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (element.get_width(), element.get_height()) @@ -358,13 +357,13 @@ def read( vertices[2, 0] -= b annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.polygon( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - vertices=vertices, - annotations=annotations, - ) + trapz = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=vertices, + annotations=annotations, + ) + pat.shapes.append(trapz) elif isinstance(element, fatrec.CTrapezoid): cttype = element.get_ctrapezoid_type() @@ -413,24 +412,22 @@ def read( vertices[0, 1] += width annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.polygon( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - vertices=vertices, - annotations=annotations, - ) + ctrapz = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=vertices, + annotations=annotations, + ) + pat.shapes.append(ctrapz) elif isinstance(element, fatrec.Circle): annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - layer = element.get_layer_tuple() - circle = Circle( - offset=element.get_xy(), - repetition=repetition, - annotations=annotations, - radius=float(element.get_radius()), - ) - pat.shapes[layer].append(circle) + circle = Circle(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + radius=float(element.get_radius())) + pat.shapes.append(circle) elif isinstance(element, fatrec.Text): annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) @@ -439,30 +436,38 @@ def read( string = lib.textstrings[str_or_ref].string else: string = str_or_ref.string - pat.label( - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - annotations = annotations, - string = string, - ) + label = Label(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + string=string) + pat.labels.append(label) else: logger.warning(f'Skipping record {element} (unimplemented)') continue for placement in cell.placements: - target, ref = _placement_to_ref(placement, lib) - if isinstance(target, int): - target = lib.cellnames[target].nstring.string - pat.refs[target].append(ref) + pat.subpatterns.append(_placement_to_subpat(placement, lib)) - mlib[cell_name] = pat + if clean_vertices: + clean_pattern_vertices(pat) + patterns.append(pat) - return mlib, library_info + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + ident = sp.identifier[0] + name = ident if isinstance(ident, str) else lib.cellnames[ident].nstring.string + sp.pattern = patterns_dict[name] + del sp.identifier + + return patterns_dict, library_info -def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]: +def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]: """ Helper to turn a layer tuple-or-int into a layer and datatype""" if isinstance(mlayer, int): layer = mlayer @@ -474,168 +479,182 @@ def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]: else: data_type = 0 else: - raise PatternError(f'Invalid layer for OASIS: {mlayer}. Note that OASIS layers cannot be ' + raise PatternError(f'Invalid layer for OASIS: {layer}. Note that OASIS layers cannot be ' f'strings unless a layer map is provided.') return layer, data_type -def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> tuple[int | str, Ref]: +def _placement_to_subpat(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> SubPattern: """ - Helper function to create a Ref from a placment. Also returns the placement name (or id). + Helper function to create a SubPattern from a placment. Sets subpat.pattern to None + and sets the instance .identifier to (struct_name,). """ - assert not isinstance(placement.repetition, fatamorgana.ReuseRepetition) + assert(not isinstance(placement.repetition, fatamorgana.ReuseRepetition)) xy = numpy.array((placement.x, placement.y)) mag = placement.magnification if placement.magnification is not None else 1 - pname = placement.get_name() - name: int | str = pname if isinstance(pname, int) else pname.string # TODO deal with referenced names - + name = pname if isinstance(pname, int) else pname.string annotations = properties_to_annotations(placement.properties, lib.propnames, lib.propstrings) if placement.angle is None: rotation = 0 else: rotation = numpy.deg2rad(float(placement.angle)) - ref = Ref( - offset=xy, - mirrored=placement.flip, - rotation=rotation, - scale=float(mag), - repetition=repetition_fata2masq(placement.repetition), - annotations=annotations, - ) - return name, ref + subpat = SubPattern(offset=xy, + pattern=None, + mirrored=(placement.flip, False), + rotation=rotation, + scale=float(mag), + identifier=(name,), + repetition=repetition_fata2masq(placement.repetition), + annotations=annotations) + return subpat -def _refs_to_placements( - refs: dict[str | None, list[Ref]], - ) -> list[fatrec.Placement]: - placements = [] - for target, rseq in refs.items(): - if target is None: +def _subpatterns_to_placements( + subpatterns: List[SubPattern], + ) -> List[fatrec.Placement]: + refs = [] + for subpat in subpatterns: + if subpat.pattern is None: continue - for ref in rseq: - # Note: OASIS also mirrors first and rotates second - frep, rep_offset = repetition_masq2fata(ref.repetition) - offset = rint_cast(ref.offset + rep_offset) - angle = numpy.rad2deg(ref.rotation) % 360 - placement = fatrec.Placement( - name=target, - flip=ref.mirrored, - angle=angle, - magnification=ref.scale, - properties=annotations_to_properties(ref.annotations), - x=offset[0], - y=offset[1], - repetition=frep, - ) + # Note: OASIS mirrors first and rotates second + mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) + frep, rep_offset = repetition_masq2fata(subpat.repetition) - placements.append(placement) - return placements + offset = numpy.round(subpat.offset + rep_offset).astype(int) + angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360 + ref = fatrec.Placement( + name=subpat.pattern.name, + flip=mirror_across_x, + angle=angle, + magnification=subpat.scale, + properties=annotations_to_properties(subpat.annotations), + x=offset[0], + y=offset[1], + repetition=frep) + + refs.append(ref) + return refs def _shapes_to_elements( - shapes: dict[layer_t, list[Shape]], - layer2oas: Callable[[layer_t], tuple[int, int]], - ) -> list[fatrec.Polygon | fatrec.Path | fatrec.Circle]: + shapes: List[Shape], + layer2oas: Callable[[layer_t], Tuple[int, int]], + ) -> List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]]: # Add a Polygon record for each shape, and Path elements if necessary - elements: list[fatrec.Polygon | fatrec.Path | fatrec.Circle] = [] - for mlayer, sseq in shapes.items(): - layer, datatype = layer2oas(mlayer) - for shape in sseq: - repetition, rep_offset = repetition_masq2fata(shape.repetition) - properties = annotations_to_properties(shape.annotations) - if isinstance(shape, Circle): - offset = rint_cast(shape.offset + rep_offset) - radius = rint_cast(shape.radius) - circle = fatrec.Circle( - layer = layer, - datatype = datatype, - radius = cast('int', radius), - x = offset[0], - y = offset[1], - properties = properties, - repetition = repetition, - ) - elements.append(circle) - elif isinstance(shape, Path): - xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset) - deltas = rint_cast(numpy.diff(shape.vertices, axis=0)) - half_width = rint_cast(shape.width / 2) - path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup - if path_type is None: - raise PatternError(f'OASIS writer does not support path cap {shape.cap}') - extensions = None if shape.cap_extensions is None else rint_cast(shape.cap_extensions) - extension_start = (path_type, extensions[0] if extensions is not None else None) - extension_end = (path_type, extensions[1] if extensions is not None else None) - path = fatrec.Path( - layer = layer, - datatype = datatype, - point_list = cast('Sequence[Sequence[int]]', deltas), - half_width = cast('int', half_width), - x = xy[0], - y = xy[1], - extension_start = extension_start, # TODO implement multiple cap types? - extension_end = extension_end, - properties = properties, - repetition = repetition, - ) - elements.append(path) - else: - for polygon in shape.to_polygons(): - xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset) - points = rint_cast(numpy.diff(polygon.vertices, axis=0)) - elements.append(fatrec.Polygon( - layer = layer, - datatype = datatype, - x = xy[0], - y = xy[1], - point_list = cast('list[list[int]]', points), - properties = properties, - repetition = repetition, - )) + elements: List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]] = [] + for shape in shapes: + layer, datatype = layer2oas(shape.layer) + repetition, rep_offset = repetition_masq2fata(shape.repetition) + properties = annotations_to_properties(shape.annotations) + if isinstance(shape, Circle): + offset = numpy.round(shape.offset + rep_offset).astype(int) + radius = numpy.round(shape.radius).astype(int) + circle = fatrec.Circle(layer=layer, + datatype=datatype, + radius=radius, + x=offset[0], + y=offset[1], + properties=properties, + repetition=repetition) + elements.append(circle) + elif isinstance(shape, Path): + xy = numpy.round(shape.offset + shape.vertices[0] + rep_offset).astype(int) + deltas = numpy.round(numpy.diff(shape.vertices, axis=0)).astype(int) + half_width = numpy.round(shape.width / 2).astype(int) + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None) + extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) + path = fatrec.Path(layer=layer, + datatype=datatype, + point_list=deltas, + half_width=half_width, + x=xy[0], + y=xy[1], + extension_start=extension_start, # TODO implement multiple cap types? + extension_end=extension_end, + properties=properties, + repetition=repetition, + ) + elements.append(path) + else: + for polygon in shape.to_polygons(): + xy = numpy.round(polygon.offset + polygon.vertices[0] + rep_offset).astype(int) + points = numpy.round(numpy.diff(polygon.vertices, axis=0)).astype(int) + elements.append(fatrec.Polygon(layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + point_list=points, + properties=properties, + repetition=repetition)) return elements def _labels_to_texts( - labels: dict[layer_t, list[Label]], - layer2oas: Callable[[layer_t], tuple[int, int]], - ) -> list[fatrec.Text]: + labels: List[Label], + layer2oas: Callable[[layer_t], Tuple[int, int]], + ) -> List[fatrec.Text]: texts = [] - for mlayer, lseq in labels.items(): - layer, datatype = layer2oas(mlayer) - for label in lseq: - repetition, rep_offset = repetition_masq2fata(label.repetition) - xy = rint_cast(label.offset + rep_offset) - properties = annotations_to_properties(label.annotations) - texts.append(fatrec.Text( - layer = layer, - datatype = datatype, - x = xy[0], - y = xy[1], - string = label.string, - properties = properties, - repetition = repetition, - )) + for label in labels: + layer, datatype = layer2oas(label.layer) + repetition, rep_offset = repetition_masq2fata(label.repetition) + xy = numpy.round(label.offset + rep_offset).astype(int) + properties = annotations_to_properties(label.annotations) + texts.append(fatrec.Text(layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + string=label.string, + properties=properties, + repetition=repetition)) return texts +def disambiguate_pattern_names( + patterns, + dup_warn_filter: Callable[[str], bool] = None, # If returns False, don't warn about this name + ) -> None: + used_names = [] + for pat in patterns: + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', pat.name) + + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + if len(suffixed_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) + + def repetition_fata2masq( - rep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None, - ) -> Repetition | None: - mrep: Repetition | None + rep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None], + ) -> Optional[Repetition]: + mrep: Optional[Repetition] if isinstance(rep, fatamorgana.GridRepetition): - mrep = Grid( - a_vector = rep.a_vector, - b_vector = rep.b_vector, - a_count = rep.a_count, - b_count = rep.b_count, - ) + mrep = Grid(a_vector=rep.a_vector, + b_vector=rep.b_vector, + a_count=rep.a_count, + b_count=rep.b_count) elif isinstance(rep, fatamorgana.ArbitraryRepetition): - displacements = numpy.cumsum(numpy.column_stack(( - rep.x_displacements, - rep.y_displacements, - )), axis=0) + displacements = numpy.cumsum(numpy.column_stack((rep.x_displacements, + rep.y_displacements)), axis=0) displacements = numpy.vstack(([0, 0], displacements)) mrep = Arbitrary(displacements) elif rep is None: @@ -644,45 +663,38 @@ def repetition_fata2masq( def repetition_masq2fata( - rep: Repetition | None, - ) -> tuple[ - fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None, - tuple[int, int] - ]: - frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None + rep: Optional[Repetition], + ) -> Tuple[Union[fatamorgana.GridRepetition, + fatamorgana.ArbitraryRepetition, + None], + Tuple[int, int]]: + frep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None] if isinstance(rep, Grid): a_vector = rint_cast(rep.a_vector) - a_count = int(rep.a_count) - if rep.b_count > 1: - b_vector = rint_cast(rep.b_vector) - b_count = int(rep.b_count) - else: - b_vector = None - b_count = None - + b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None + a_count = rint_cast(rep.a_count) + b_count = rint_cast(rep.b_count) if rep.b_count is not None else None frep = fatamorgana.GridRepetition( - a_vector = a_vector, - b_vector = b_vector, - a_count = a_count, - b_count = b_count, + a_vector=a_vector, + b_vector=b_vector, + a_count=a_count, + b_count=b_count, ) offset = (0, 0) elif isinstance(rep, Arbitrary): diffs = numpy.diff(rep.displacements, axis=0) diff_ints = rint_cast(diffs) - frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) # type: ignore - offset = tuple(rep.displacements[0, :]) + frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) + offset = rep.displacements[0, :] else: - assert rep is None + assert(rep is None) frep = None offset = (0, 0) return frep, offset -def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Property]: +def annotations_to_properties(annotations: annotations_t) -> List[fatrec.Property]: #TODO determine is_standard based on key? - if annotations is None: - return [] properties = [] for key, values in annotations.items(): vals = [AString(v) if isinstance(v, str) else v @@ -692,24 +704,24 @@ def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Propert def properties_to_annotations( - properties: list[fatrec.Property], - propnames: dict[int, NString], - propstrings: dict[int, AString], + properties: List[fatrec.Property], + propnames: Dict[int, NString], + propstrings: Dict[int, AString], ) -> annotations_t: annotations = {} for proprec in properties: - assert proprec.name is not None + assert(proprec.name is not None) if isinstance(proprec.name, int): key = propnames[proprec.name].string else: key = proprec.name.string - values: list[str | float | int] = [] + values: List[Union[str, float, int]] = [] - assert proprec.values is not None + assert(proprec.values is not None) for value in proprec.values: - if isinstance(value, float | int): + if isinstance(value, (float, int)): values.append(value) - elif isinstance(value, NString | AString): + elif isinstance(value, (NString, AString)): values.append(value.string) elif isinstance(value, PropStringReference): values.append(propstrings[value.ref].string) # dereference @@ -717,27 +729,9 @@ def properties_to_annotations( string = repr(value) logger.warning(f'Converting property value for key ({key}) to string ({string})') values.append(string) - annotations.setdefault(key, []).extend(values) + annotations[key] = values return annotations - -def check_valid_names( - names: Iterable[str], - ) -> None: - """ - Check all provided names to see if they're valid GDSII cell names. - - Args: - names: Collection of names to check - max_length: Max allowed length - - """ - allowed_chars = set(string.ascii_letters + string.digits + string.punctuation + ' ') - - bad_chars = [ - name for name in names - if not set(name).issubset(allowed_chars) - ] - - if bad_chars: - raise LibraryError('Names contain invalid characters:\n' + pformat(bad_chars)) + properties = [fatrec.Property(key, vals, is_standard=False) + for key, vals in annotations.items()] + return properties diff --git a/masque/file/python_gdsii.py b/masque/file/python_gdsii.py new file mode 100644 index 0000000..6b89abc --- /dev/null +++ b/masque/file/python_gdsii.py @@ -0,0 +1,580 @@ +""" +GDSII file format readers and writers using python-gdsii + +Note that GDSII references follow the same convention as `masque`, + with this order of operations: + 1. Mirroring + 2. Rotation + 3. Scaling + 4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets) + + Scaling, rotation, and mirroring apply to individual instances, not grid + vectors or offsets. + +Notes: + * absolute positioning is not supported + * PLEX is not supported + * ELFLAGS are not supported + * GDS does not support library- or structure-level annotations +""" +from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional +from typing import Sequence +import re +import io +import copy +import base64 +import struct +import logging +import pathlib +import gzip + +import numpy +from numpy.typing import NDArray, ArrayLike +# python-gdsii +import gdsii.library #type: ignore +import gdsii.structure #type: ignore +import gdsii.elements #type: ignore + +from .utils import clean_pattern_vertices, is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path +from ..repetition import Grid +from ..utils import get_bit, set_bit, layer_t, normalize_mirror, annotations_t + + +logger = logging.getLogger(__name__) + + +path_cap_map = { + None: Path.Cap.Flush, + 0: Path.Cap.Flush, + 1: Path.Cap.Circle, + 2: Path.Cap.Square, + 4: Path.Cap.SquareCustom, + } + + +def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]: + return numpy.rint(val, dtype=numpy.int32, casting='unsafe') + + +def build( + patterns: Union[Pattern, Sequence[Pattern]], + meters_per_unit: float, + logical_units_per_unit: float = 1, + library_name: str = 'masque-gdsii-write', + *, + modify_originals: bool = False, + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, + ) -> gdsii.library.Library: + """ + Convert a `Pattern` or list of patterns to a GDSII stream, by first calling + `.polygonize()` to change the shapes into polygons, and then writing patterns + as GDSII structures, polygons as boundary elements, and subpatterns as structure + references (sref). + + For each shape, + layer is chosen to be equal to `shape.layer` if it is an int, + or `shape.layer[0]` if it is a tuple + datatype is chosen to be `shape.layer[1]` if available, + otherwise `0` + + It is often a good idea to run `pattern.subpatternize()` prior to calling this function, + especially if calling `.polygonize()` will result in very many vertices. + + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. + + Args: + patterns: A Pattern or list of patterns to convert. + meters_per_unit: Written into the GDSII file, meters per (database) length unit. + All distances are assumed to be an integer multiple of this unit, and are stored as such. + logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a + "logical" unit which is different from the "database" unit, for display purposes. + Default `1`. + library_name: Library name written into the GDSII file. + Default 'masque-gdsii-write'. + modify_originals: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`, which + attempts to adhere to the GDSII standard as well as possible. + WARNING: No additional error checking is performed on the results. + + Returns: + `gdsii.library.Library` + """ + if isinstance(patterns, Pattern): + patterns = [patterns] + + if disambiguate_func is None: + disambiguate_func = disambiguate_pattern_names # type: ignore + assert(disambiguate_func is not None) # placate mypy + + if not modify_originals: + patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] + + patterns = [p.wrap_repeated_shapes() for p in patterns] + + # Create library + lib = gdsii.library.Library(version=600, + name=library_name.encode('ASCII'), + logical_unit=logical_units_per_unit, + physical_unit=meters_per_unit) + + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + disambiguate_func(patterns_by_id.values()) + + # Now create a structure for each pattern, and add in any Boundary and SREF elements + for pat in patterns_by_id.values(): + structure = gdsii.structure.Structure(name=pat.name.encode('ASCII')) + lib.append(structure) + + structure += _shapes_to_elements(pat.shapes) + structure += _labels_to_texts(pat.labels) + structure += _subpatterns_to_refs(pat.subpatterns) + + return lib + + +def write( + patterns: Union[Pattern, Sequence[Pattern]], + stream: io.BufferedIOBase, + *args, + **kwargs, + ) -> None: + """ + Write a `Pattern` or list of patterns to a GDSII file. + See `masque.file.gdsii.build()` for details. + + Args: + patterns: A Pattern or list of patterns to write to file. + stream: Stream to write to. + *args: passed to `masque.file.gdsii.build()` + **kwargs: passed to `masque.file.gdsii.build()` + """ + lib = build(patterns, *args, **kwargs) + lib.save(stream) + return + +def writefile( + patterns: Union[Sequence[Pattern], Pattern], + filename: Union[str, pathlib.Path], + *args, + **kwargs, + ) -> None: + """ + Wrapper for `masque.file.gdsii.write()` that takes a filename or path instead of a stream. + + Will automatically compress the file if it has a .gz suffix. + + Args: + patterns: `Pattern` or list of patterns to save + filename: Filename to save to. + *args: passed to `masque.file.gdsii.write` + **kwargs: passed to `masque.file.gdsii.write` + """ + path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open + + with io.BufferedWriter(open_func(path, mode='wb')) as stream: + write(patterns, stream, *args, **kwargs) + + +def readfile( + filename: Union[str, pathlib.Path], + *args, + **kwargs, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + """ + Wrapper for `masque.file.gdsii.read()` that takes a filename or path instead of a stream. + + Will automatically decompress gzipped files. + + Args: + filename: Filename to save to. + *args: passed to `masque.file.gdsii.read` + **kwargs: passed to `masque.file.gdsii.read` + """ + path = pathlib.Path(filename) + if is_gzipped(path): + open_func: Callable = gzip.open + else: + open_func = open + + with io.BufferedReader(open_func(path, mode='rb')) as stream: + results = read(stream, *args, **kwargs) + return results + + +def read( + stream: io.BufferedIOBase, + clean_vertices: bool = True, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + """ + Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are + translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs + are translated into SubPattern objects. + + Additional library info is returned in a dict, containing: + 'name': name of the library + 'meters_per_unit': number of meters per database unit (all values are in database units) + 'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns) + per database unit + + Args: + stream: Stream to read from. + clean_vertices: If `True`, remove any redundant vertices when loading polygons. + The cleaning process removes any polygons with zero area or <3 vertices. + Default `True`. + + Returns: + - Dict of pattern_name:Patterns generated from GDSII structures + - Dict of GDSII library info + """ + + lib = gdsii.library.Library.load(stream) + + library_info = {'name': lib.name.decode('ASCII'), + 'meters_per_unit': lib.physical_unit, + 'logical_units_per_unit': lib.logical_unit, + } + + raw_mode = True # Whether to construct shapes in raw mode (less error checking) + + patterns = [] + for structure in lib: + pat = Pattern(name=structure.name.decode('ASCII')) + for element in structure: + # Switch based on element type: + if isinstance(element, gdsii.elements.Boundary): + poly = _boundary_to_polygon(element, raw_mode) + pat.shapes.append(poly) + + if isinstance(element, gdsii.elements.Path): + path = _gpath_to_mpath(element, raw_mode) + pat.shapes.append(path) + + elif isinstance(element, gdsii.elements.Text): + label = Label(offset=element.xy.astype(float), + layer=(element.layer, element.text_type), + string=element.string.decode('ASCII')) + pat.labels.append(label) + + elif isinstance(element, (gdsii.elements.SRef, gdsii.elements.ARef)): + pat.subpatterns.append(_ref_to_subpat(element)) + + if clean_vertices: + clean_pattern_vertices(pat) + patterns.append(pat) + + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + sp.pattern = patterns_dict[sp.identifier[0].decode('ASCII')] + del sp.identifier + + return patterns_dict, library_info + + +def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]: + """ Helper to turn a layer tuple-or-int into a layer and datatype""" + if isinstance(mlayer, int): + layer = mlayer + data_type = 0 + elif isinstance(mlayer, tuple): + layer = mlayer[0] + if len(mlayer) > 1: + data_type = mlayer[1] + else: + data_type = 0 + else: + raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.') + return layer, data_type + + +def _ref_to_subpat( + element: Union[gdsii.elements.SRef, + gdsii.elements.ARef] + ) -> SubPattern: + """ + Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None + and sets the instance .identifier to (struct_name,). + + NOTE: "Absolute" means not affected by parent elements. + That's not currently supported by masque at all (and not planned). + """ + rotation = 0.0 + offset = numpy.array(element.xy[0], dtype=float) + scale = 1.0 + mirror_across_x = False + repetition = None + + if element.strans is not None: + if element.mag is not None: + scale = element.mag + # Bit 13 means absolute scale + if get_bit(element.strans, 15 - 13): + raise PatternError('Absolute scale is not implemented in masque!') + if element.angle is not None: + rotation = numpy.deg2rad(element.angle) + # Bit 14 means absolute rotation + if get_bit(element.strans, 15 - 14): + raise PatternError('Absolute rotation is not implemented in masque!') + # Bit 0 means mirror x-axis + if get_bit(element.strans, 15 - 0): + mirror_across_x = True + + if isinstance(element, gdsii.elements.ARef): + a_count = element.cols + b_count = element.rows + a_vector = (element.xy[1] - offset) / a_count + b_vector = (element.xy[2] - offset) / b_count + repetition = Grid(a_vector=a_vector, b_vector=b_vector, + a_count=a_count, b_count=b_count) + + subpat = SubPattern(pattern=None, + offset=offset, + rotation=rotation, + scale=scale, + mirrored=(mirror_across_x, False), + annotations=_properties_to_annotations(element.properties), + repetition=repetition) + subpat.identifier = (element.struct_name,) + return subpat + + +def _gpath_to_mpath(element: gdsii.elements.Path, raw_mode: bool) -> Path: + if element.path_type in path_cap_map: + cap = path_cap_map[element.path_type] + else: + raise PatternError(f'Unrecognized path type: {element.path_type}') + + args = {'vertices': element.xy.astype(float), + 'layer': (element.layer, element.data_type), + 'width': element.width if element.width is not None else 0.0, + 'cap': cap, + 'offset': numpy.zeros(2), + 'annotations': _properties_to_annotations(element.properties), + 'raw': raw_mode, + } + + if cap == Path.Cap.SquareCustom: + args['cap_extensions'] = numpy.zeros(2) + if element.bgn_extn is not None: + args['cap_extensions'][0] = element.bgn_extn + if element.end_extn is not None: + args['cap_extensions'][1] = element.end_extn + + return Path(**args) + + +def _boundary_to_polygon(element: gdsii.elements.Boundary, raw_mode: bool) -> Polygon: + args = {'vertices': element.xy[:-1].astype(float), + 'layer': (element.layer, element.data_type), + 'offset': numpy.zeros(2), + 'annotations': _properties_to_annotations(element.properties), + 'raw': raw_mode, + } + return Polygon(**args) + + +def _subpatterns_to_refs( + subpatterns: List[SubPattern], + ) -> List[Union[gdsii.elements.ARef, gdsii.elements.SRef]]: + refs = [] + for subpat in subpatterns: + if subpat.pattern is None: + continue + encoded_name = subpat.pattern.name.encode('ASCII') + + # Note: GDS mirrors first and rotates second + mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) + rep = subpat.repetition + + new_refs: List[Union[gdsii.elements.SRef, gdsii.elements.ARef]] + ref: Union[gdsii.elements.SRef, gdsii.elements.ARef] + if isinstance(rep, Grid): + b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) + b_count = rep.b_count if rep.b_count is not None else 1 + xy: NDArray[numpy.float64] = numpy.array(subpat.offset) + [ + [0, 0], + rep.a_vector * rep.a_count, + b_vector * b_count, + ] + ref = gdsii.elements.ARef( + struct_name=encoded_name, + xy=rint_cast(xy), + cols=rint_cast(rep.a_count), + rows=rint_cast(rep.b_count), + ) + new_refs = [ref] + elif rep is None: + ref = gdsii.elements.SRef( + struct_name=encoded_name, + xy=rint_cast([subpat.offset]), + ) + new_refs = [ref] + else: + new_refs = [gdsii.elements.SRef( + struct_name=encoded_name, + xy=rint_cast([subpat.offset + dd]), + ) + for dd in rep.displacements] + + for ref in new_refs: + ref.angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360 + # strans must be non-None for angle and mag to take effect + ref.strans = set_bit(0, 15 - 0, mirror_across_x) + ref.mag = subpat.scale + ref.properties = _annotations_to_properties(subpat.annotations, 512) + + refs += new_refs + return refs + + +def _properties_to_annotations(properties: List[Tuple[int, bytes]]) -> annotations_t: + return {str(k): [v.decode()] for k, v in properties} + + +def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> List[Tuple[int, bytes]]: + cum_len = 0 + props = [] + for key, vals in annotations.items(): + try: + i = int(key) + except ValueError: + raise PatternError(f'Annotation key {key} is not convertable to an integer') + if not (0 < i < 126): + raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])') + + val_strings = ' '.join(str(val) for val in vals) + b = val_strings.encode() + if len(b) > 126: + raise PatternError(f'Annotation value {b!r} is longer than 126 characters!') + cum_len += numpy.ceil(len(b) / 2) * 2 + 2 + if cum_len > max_len: + raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}') + props.append((i, b)) + return props + + +def _shapes_to_elements( + shapes: List[Shape], + polygonize_paths: bool = False, + ) -> List[Union[gdsii.elements.Boundary, gdsii.elements.Path]]: + elements: List[Union[gdsii.elements.Boundary, gdsii.elements.Path]] = [] + # Add a Boundary element for each shape, and Path elements if necessary + for shape in shapes: + layer, data_type = _mlayer2gds(shape.layer) + properties = _annotations_to_properties(shape.annotations, 128) + if isinstance(shape, Path) and not polygonize_paths: + xy = rint_cast(shape.vertices + shape.offset) + width = rint_cast(shape.width) + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + path = gdsii.elements.Path(layer=layer, + data_type=data_type, + xy=xy) + path.path_type = path_type + path.width = width + path.properties = properties + elements.append(path) + else: + for polygon in shape.to_polygons(): + xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) + numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') + xy_closed[-1] = xy_closed[0] + boundary = gdsii.elements.Boundary( + layer=layer, + data_type=data_type, + xy=xy_closed, + ) + boundary.properties = properties + elements.append(boundary) + return elements + + +def _labels_to_texts(labels: List[Label]) -> List[gdsii.elements.Text]: + texts = [] + for label in labels: + properties = _annotations_to_properties(label.annotations, 128) + layer, text_type = _mlayer2gds(label.layer) + xy = rint_cast([label.offset]) + text = gdsii.elements.Text( + layer=layer, + text_type=text_type, + xy=xy, + string=label.string.encode('ASCII'), + ) + text.properties = properties + texts.append(text) + return texts + + +def disambiguate_pattern_names( + patterns: Sequence[Pattern], + max_name_length: int = 32, + suffix_length: int = 6, + dup_warn_filter: Optional[Callable[[str], bool]] = None, + ) -> None: + """ + Args: + patterns: List of patterns to disambiguate + max_name_length: Names longer than this will be truncated + suffix_length: Names which get truncated are truncated by this many extra characters. This is to + leave room for a suffix if one is necessary. + dup_warn_filter: (optional) Function for suppressing warnings about cell names changing. Receives + the cell name and returns `False` if the warning should be suppressed and `True` if it should + be displayed. Default displays all warnings. + """ + used_names = [] + for pat in set(patterns): + # Shorten names which already exceed max-length + if len(pat.name) > max_name_length: + shortened_name = pat.name[:max_name_length - suffix_length] + logger.warning(f'Pattern name "{pat.name}" is too long ({len(pat.name)}/{max_name_length} chars),\n' + + f' shortening to "{shortened_name}" before generating suffix') + else: + shortened_name = pat.name + + # Remove invalid characters + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name) + + # Add a suffix that makes the name unique + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + # Encode into a byte-string and perform some final checks + encoded_name = suffixed_name.encode('ASCII') + if len(encoded_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') + if len(encoded_name) > max_name_length: + raise PatternError(f'Pattern name "{encoded_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) diff --git a/masque/file/svg.py b/masque/file/svg.py index b2782ae..58c9c6a 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -1,76 +1,34 @@ """ SVG file format readers and writers """ -from collections.abc import Mapping -import logging +from typing import Dict, Optional +import warnings import numpy from numpy.typing import ArrayLike import svgwrite # type: ignore from .utils import mangle_name -from .. import Pattern, Ref -from ..library import IMaterializable -from ..utils import rotation_matrix_2d - - -logger = logging.getLogger(__name__) - - -def _ref_to_svg_transform(ref: Ref) -> str: - linear = rotation_matrix_2d(ref.rotation) * ref.scale - if ref.mirrored: - linear = linear @ numpy.diag((1.0, -1.0)) - - a = linear[0, 0] - b = linear[1, 0] - c = linear[0, 1] - d = linear[1, 1] - e = ref.offset[0] - f = ref.offset[1] - return f'matrix({a:g} {b:g} {c:g} {d:g} {e:g} {f:g})' - - -def _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]: - svg_ids: dict[str, str] = {} - seen_ids: set[str] = set() - for name in names: - base_id = mangle_name(name) - svg_id = base_id - suffix = 1 - while svg_id in seen_ids: - suffix += 1 - svg_id = f'{base_id}_{suffix}' - seen_ids.add(svg_id) - svg_ids[name] = svg_id - return svg_ids - - -def _detached_library(library: Mapping[str, Pattern]) -> dict[str, Pattern]: - if isinstance(library, IMaterializable): - detached = library.materialize_many_detached(tuple(library)) - return dict(detached.items()) - return {name: pat.deepcopy() for name, pat in library.items()} +from .. import Pattern def writefile( - library: Mapping[str, Pattern], - top: str, + pattern: Pattern, filename: str, custom_attributes: bool = False, - annotate_ports: bool = False, ) -> None: """ - Write a Pattern to an SVG file, by first calling .polygonize() on a detached - materialized copy + Write a Pattern to an SVG file, by first calling .polygonize() on it to change the shapes into polygons, and then writing patterns as SVG - groups (, inside ), polygons as paths (), and refs + groups (, inside ), polygons as paths (), and subpatterns as elements. - If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute - is written to the relevant elements. + Note that this function modifies the Pattern. - It is often a good idea to run `pattern.dedup()` on pattern prior to + If `custom_attributes` is `True`, non-standard `pattern_layer` and `pattern_dose` attributes + are written to the relevant elements. + + It is often a good idea to run `pattern.subpatternize()` on pattern prior to calling this function, especially if calling `.polygonize()` will result in very many vertices. @@ -78,24 +36,19 @@ def writefile( prior to calling this function. Args: - library: Mapping of pattern names to patterns. - top: Name of the top-level pattern to render. + pattern: Pattern to write to file. Modified by this function. filename: Filename to write to. - custom_attributes: Whether to write non-standard `pattern_layer` attribute to the - SVG elements. - annotate_ports: If True, draw an arrow for each port (similar to - `Pattern.visualize(..., ports=True)`). + custom_attributes: Whether to write non-standard `pattern_layer` and + `pattern_dose` attributes to the SVG elements. """ - detached = _detached_library(library) - pattern = detached[top] # Polygonize pattern pattern.polygonize() - bounds = pattern.get_bounds(library=detached) + bounds = pattern.get_bounds() if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) - logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) + warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox') else: bounds_min, bounds_max = bounds @@ -105,86 +58,65 @@ def writefile( # Create file svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string, debug=(not custom_attributes)) - svg_ids = _make_svg_ids(detached) - # Now create a group for each pattern and add in any Boundary and Use elements - for name, pat in detached.items(): - svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red') + # Get a dict of id(pattern) -> pattern + patterns_by_id = {**(pattern.referenced_patterns_by_id()), id(pattern): pattern} # type: Dict[int, Optional[Pattern]] - for layer, shapes in pat.shapes.items(): - for shape in shapes: - for polygon in shape.to_polygons(): - path_spec = poly2path(polygon.vertices + polygon.offset) + # Now create a group for each row in sd_table (ie, each pattern + dose combination) + # and add in any Boundary and Use elements + for pat in patterns_by_id.values(): + if pat is None: + continue + svg_group = svg.g(id=mangle_name(pat), fill='blue', stroke='red') - path = svg.path(d=path_spec) - if custom_attributes: - path['pattern_layer'] = layer + for shape in pat.shapes: + for polygon in shape.to_polygons(): + path_spec = poly2path(polygon.vertices + polygon.offset) - svg_group.add(path) + path = svg.path(d=path_spec) + if custom_attributes: + path['pattern_layer'] = polygon.layer + path['pattern_dose'] = polygon.dose - if annotate_ports: - # Draw arrows for the ports, pointing into the device (per port definition) - for port_name, port in pat.ports.items(): - if port.rotation is not None: - p1 = port.offset - angle = port.rotation - size = 1.0 # arrow size - p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) + svg_group.add(path) - # head - head_angle = 0.5 - h1 = p1 + 0.7 * size * numpy.array([numpy.cos(angle + head_angle), numpy.sin(angle + head_angle)]) - h2 = p1 + 0.7 * size * numpy.array([numpy.cos(angle - head_angle), numpy.sin(angle - head_angle)]) - - line = svg.line(start=p1, end=p2, stroke='green', stroke_width=0.2) - head = svg.polyline(points=[h1, p1, h2], fill='none', stroke='green', stroke_width=0.2) - - svg_group.add(line) - svg_group.add(head) - svg_group.add(svg.text(port_name, insert=p2, font_size=0.5, fill='green')) - - for target, refs in pat.refs.items(): - if target is None: + for subpat in pat.subpatterns: + if subpat.pattern is None: continue - for ref in refs: - transform = _ref_to_svg_transform(ref) - use = svg.use(href='#' + svg_ids[target], transform=transform) - svg_group.add(use) + transform = f'scale({subpat.scale:g}) rotate({subpat.rotation:g}) translate({subpat.offset[0]:g},{subpat.offset[1]:g})' + use = svg.use(href='#' + mangle_name(subpat.pattern), transform=transform) + if custom_attributes: + use['pattern_dose'] = subpat.dose + svg_group.add(use) svg.defs.add(svg_group) - svg.add(svg.use(href='#' + svg_ids[top])) + svg.add(svg.use(href='#' + mangle_name(pattern))) svg.save() -def writefile_inverted( - library: Mapping[str, Pattern], - top: str, - filename: str, - ) -> None: +def writefile_inverted(pattern: Pattern, filename: str): """ Write an inverted Pattern to an SVG file, by first calling `.polygonize()` and `.flatten()` on it to change the shapes into polygons, then drawing a bounding box and drawing the polygons with reverse vertex order inside it, all within one `` element. + Note that this function modifies the Pattern. + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` prior to calling this function. Args: - library: Mapping of pattern names to patterns. - top: Name of the top-level pattern to render. + pattern: Pattern to write to file. Modified by this function. filename: Filename to write to. """ - detached = _detached_library(library) - pattern = detached[top] - # Polygonize and flatten pattern - pattern.polygonize().flatten(detached) + pattern.polygonize().flatten() - bounds = pattern.get_bounds(library=detached) + bounds = pattern.get_bounds() if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) - logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) + warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox') else: bounds_min, bounds_max = bounds @@ -202,10 +134,9 @@ def writefile_inverted( path_spec = poly2path(slab_edge) # Draw polygons with reversed vertex order - for _layer, shapes in pattern.shapes.items(): - for shape in shapes: - for polygon in shape.to_polygons(): - path_spec += poly2path(polygon.vertices[::-1] + polygon.offset) + for shape in pattern.shapes: + for polygon in shape.to_polygons(): + path_spec += poly2path(polygon.vertices[::-1] + polygon.offset) svg.add(svg.path(d=path_spec, fill='blue', stroke='red')) svg.save() @@ -221,9 +152,9 @@ def poly2path(vertices: ArrayLike) -> str: Returns: SVG path-string. """ - verts = numpy.asarray(vertices) - commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) # noqa: UP032 + verts = numpy.array(vertices, copy=False) + commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) for vertex in verts[1:]: - commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) # noqa: UP032 + commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) commands += ' Z ' return commands diff --git a/masque/file/utils.py b/masque/file/utils.py index dd8ed5c..47e8b7d 100644 --- a/masque/file/utils.py +++ b/masque/file/utils.py @@ -1,284 +1,29 @@ """ Helper functions for file reading and writing """ -from typing import IO -from collections.abc import Iterator, Mapping +from typing import Set, Tuple, List import re +import copy import pathlib -import logging -import tempfile -import shutil -from collections import defaultdict -from contextlib import contextmanager -from pprint import pformat -from itertools import chain -from .. import Pattern, PatternError, Library, LibraryError -from ..library import ( - IBorrowing, ILibraryView, OverlayLibrary, SINGLE_USE_PREFIX, - dangling_mode_t, - ) +from .. import Pattern, PatternError from ..shapes import Polygon, Path -logger = logging.getLogger(__name__) - - -def _has_source_provenance(library: ILibraryView, name: str) -> bool: - """Return whether `name` has an uninterrupted borrowing provenance chain.""" - current = library - current_name = name - seen: set[tuple[int, str]] = set() - followed_source = False - while isinstance(current, IBorrowing): - key = (id(current), current_name) - if key in seen: - return False - seen.add(key) - source_cell = current.source_cell(current_name) - if source_cell is None: - return False - followed_source = True - current, current_name = source_cell - return followed_source - - -def _prune_checked_empty( - library: OverlayLibrary, - checked_names: set[str], - *, - dangling: dangling_mode_t, - ) -> set[str]: - """Prune checked empty cells without modifying source-backed parents.""" - parent_graph = library.parent_graph(dangling=dangling) - source_backed = set(library) - checked_names - - def safely_empty(name: str) -> bool: - return ( - name in library - and name in checked_names - and not (parent_graph.get(name, set()) & source_backed) - and library[name].is_empty() - ) - - empty = {name for name in checked_names if safely_empty(name)} - pruned: set[str] = set() - while empty: - parents: set[str] = set() - for name in empty: - name_parents = parent_graph.get(name, set()) - del library[name] - checked_names.discard(name) - for parent in name_parents & checked_names: - if parent in library and name in library[parent].refs: - del library[parent].refs[name] - parents |= name_parents - pruned |= empty - empty = {parent for parent in parents if safely_empty(parent)} - return pruned - - -def _wrap_checked_repeated_shapes( - library: OverlayLibrary, - checked_names: set[str], - ) -> None: - """Wrap repetitions in checked cells while leaving source-backed cells untouched.""" - for pattern_name in tuple(checked_names): - if pattern_name not in library: - continue - pattern = library[pattern_name] - for layer in pattern.shapes: - new_shapes = [] - for shape in pattern.shapes[layer]: - if shape.repetition is None: - new_shapes.append(shape) - continue - name = library.get_name(SINGLE_USE_PREFIX + 'rep') - library[name] = Pattern(shapes={layer: [shape]}) - checked_names.add(name) - pattern.ref(name, repetition=shape.repetition) - shape.repetition = None - pattern.shapes[layer] = new_shapes - - for layer in pattern.labels: - new_labels = [] - for label in pattern.labels[layer]: - if label.repetition is None: - new_labels.append(label) - continue - name = library.get_name(SINGLE_USE_PREFIX + 'rep') - library[name] = Pattern(labels={layer: [label]}) - checked_names.add(name) - pattern.ref(name, repetition=label.repetition) - label.repetition = None - pattern.labels[layer] = new_labels - - -def preflight_source_aware( - lib: ILibraryView, - sort: bool = True, - sort_elements: bool = False, - allow_dangling_refs: bool | None = None, - allow_named_layers: bool = True, - prune_empty_patterns: bool = False, - wrap_repeated_shapes: bool = False, - ) -> OverlayLibrary: +def mangle_name(pattern: Pattern, dose_multiplier: float = 1.0) -> str: """ - Preflight cells without reusable source provenance. - - Returns a borrowing `OverlayLibrary`. Cells with uninterrupted - `IBorrowing.source_cell()` provenance remain source-backed and receive no - per-pattern checks. Other cells are detached into the overlay and checked. - Keep `lib` and its borrowed sources open for the result's lifetime. + Create a name using `pattern.name`, `id(pattern)`, and the dose multiplier. Args: - sort: Whether to sort checked pattern contents. Library name order is - retained because sorting source-backed cells would require loading them. - sort_elements: Whether to sort elements within checked patterns. - allow_dangling_refs: If `None` (default), warns about any refs to patterns that are not - in the provided library. If `True`, no check is performed; if `False`, a `LibraryError` - is raised instead. - allow_named_layers: If `False`, raises a `PatternError` if any layer is referred to by - a string in a checked pattern instead of a number (or tuple). - prune_empty_patterns: Recursively delete checked empty patterns when - doing so does not require modifying a source-backed parent. - wrap_repeated_shapes: Turn repeated shapes in checked patterns into - repeated refs containing non-repeated shapes. - - Returns: - A borrowing overlay containing checked patterns and source-backed cells. - """ - checked_names = { - name - for name in lib - if not _has_source_provenance(lib, name) - } - overlay = OverlayLibrary() - overlay.add_source(lib) - - if sort: - for name in sorted(checked_names): - overlay[name].sort(sort_elements=sort_elements) - - if not allow_dangling_refs: - refs = overlay.referenced_patterns() - dangling = refs - set(overlay.keys()) - if dangling: - msg = 'Dangling refs found: ' + pformat(dangling) - if allow_dangling_refs is None: - logger.warning(msg) - else: - raise LibraryError(msg) - - if not allow_named_layers: - checked_named_layers: Mapping[str, set] = defaultdict(set) - for name in checked_names: - pattern = overlay[name] - for layer in chain(pattern.shapes.keys(), pattern.labels.keys()): - if isinstance(layer, str): - checked_named_layers[name].add(layer) - checked_named_layers = dict(checked_named_layers) - if checked_named_layers: - raise PatternError('Non-numeric layers found:' + pformat(checked_named_layers)) - - if prune_empty_patterns: - prune_dangling: dangling_mode_t = 'error' if allow_dangling_refs is False else 'ignore' - pruned = _prune_checked_empty(overlay, checked_names, dangling=prune_dangling) - if pruned: - logger.info(f'Preflight pruned {len(pruned)} checked empty patterns') - logger.debug('Pruned: ' + pformat(pruned)) - else: - logger.debug('Preflight found no safely prunable checked patterns') - - if wrap_repeated_shapes: - _wrap_checked_repeated_shapes(overlay, checked_names) - - return overlay - - -def preflight( - lib: Library, - sort: bool = True, - sort_elements: bool = False, - allow_dangling_refs: bool | None = None, - allow_named_layers: bool = True, - prune_empty_patterns: bool = False, - wrap_repeated_shapes: bool = False, - ) -> Library: - """ - Run a standard set of useful operations and checks on an entire library. - - This helper is not copy-isolating. When `sort=True`, it constructs a new - `Library` wrapper around the same `Pattern` objects after sorting them in - place. Later mutating steps may still mutate caller-owned patterns. Deep-copy - the library first when isolation is required. - - Args: - sort: Whether to sort patterns by name and sort each pattern's contents. - sort_elements: Whether to sort elements within each pattern. Requires - `sort=True`. - allow_dangling_refs: If `None`, warn about missing targets. If `True`, - skip the check. If `False`, raise `LibraryError`. - allow_named_layers: If `False`, raise `PatternError` for string layers. - prune_empty_patterns: Recursively delete empty patterns. - wrap_repeated_shapes: Move shape and label repetitions onto wrapping refs. - - Returns: - `lib`, or an equivalent name-sorted `Library` when `sort=True`. - """ - mutable_lib = lib - if sort: - mutable_lib = Library(dict(sorted( - (nn, pp.sort(sort_elements=sort_elements)) for nn, pp in mutable_lib.items() - ))) - - if not allow_dangling_refs: - refs = mutable_lib.referenced_patterns() - dangling = refs - set(mutable_lib.keys()) - if dangling: - msg = 'Dangling refs found: ' + pformat(dangling) - if allow_dangling_refs is None: - logger.warning(msg) - else: - raise LibraryError(msg) - - if not allow_named_layers: - named_layers: Mapping[str, set] = defaultdict(set) - for name, pat in mutable_lib.items(): - for layer in chain(pat.shapes.keys(), pat.labels.keys()): - if isinstance(layer, str): - named_layers[name].add(layer) - named_layers = dict(named_layers) - if named_layers: - raise PatternError('Non-numeric layers found:' + pformat(named_layers)) - - if prune_empty_patterns: - prune_dangling: dangling_mode_t = 'error' if allow_dangling_refs is False else 'ignore' - pruned = mutable_lib.prune_empty(dangling=prune_dangling) - if pruned: - logger.info(f'Preflight pruned {len(pruned)} empty patterns') - logger.debug('Pruned: ' + pformat(pruned)) - else: - logger.debug('Preflight found no empty patterns') - - if wrap_repeated_shapes: - mutable_lib.wrap_repeated_shapes() - - return mutable_lib - - -def mangle_name(name: str) -> str: - """ - Sanitize a name. - - Args: - name: Name we want to mangle. + pattern: Pattern whose name we want to mangle. + dose_multiplier: Dose multiplier to mangle with. Returns: Mangled name. """ expression = re.compile(r'[^A-Za-z0-9_\?\$]') - sanitized_name = expression.sub('_', name) + full_name = '{}_{}_{}'.format(pattern.name, dose_multiplier, id(pattern)) + sanitized_name = expression.sub('_', full_name) return sanitized_name @@ -293,43 +38,149 @@ def clean_pattern_vertices(pat: Pattern) -> Pattern: Returns: pat """ - for shapes in pat.shapes.values(): - remove_inds = [] - for ii, shape in enumerate(shapes): - if not isinstance(shape, Polygon | Path): - continue - try: - shape.clean_vertices() - except PatternError: - remove_inds.append(ii) - for ii in sorted(remove_inds, reverse=True): - del shapes[ii] + remove_inds = [] + for ii, shape in enumerate(pat.shapes): + if not isinstance(shape, (Polygon, Path)): + continue + try: + shape.clean_vertices() + except PatternError: + remove_inds.append(ii) + for ii in sorted(remove_inds, reverse=True): + del pat.shapes[ii] return pat +def make_dose_table(patterns: List[Pattern], dose_multiplier: float = 1.0) -> Set[Tuple[int, float]]: + """ + Create a set containing `(id(pat), written_dose)` for each pattern (including subpatterns) + + Args: + pattern: Source Patterns. + dose_multiplier: Multiplier for all written_dose entries. + + Returns: + `{(id(subpat.pattern), written_dose), ...}` + """ + dose_table = {(id(pattern), dose_multiplier) for pattern in patterns} + for pattern in patterns: + for subpat in pattern.subpatterns: + if subpat.pattern is None: + continue + subpat_dose_entry = (id(subpat.pattern), subpat.dose * dose_multiplier) + if subpat_dose_entry not in dose_table: + subpat_dose_table = make_dose_table([subpat.pattern], subpat.dose * dose_multiplier) + dose_table = dose_table.union(subpat_dose_table) + return dose_table + + +def dtype2dose(pattern: Pattern) -> Pattern: + """ + For each shape in the pattern, if the layer is a tuple, set the + layer to the tuple's first element and set the dose to the + tuple's second element. + + Generally intended for use with `Pattern.apply()`. + + Args: + pattern: Pattern to modify + + Returns: + pattern + """ + for shape in pattern.shapes: + if isinstance(shape.layer, tuple): + shape.dose = shape.layer[1] + shape.layer = shape.layer[0] + return pattern + + +def dose2dtype( + patterns: List[Pattern], + ) -> Tuple[List[Pattern], List[float]]: + """ + For each shape in each pattern, set shape.layer to the tuple + (base_layer, datatype), where: + layer is chosen to be equal to the original shape.layer if it is an int, + or shape.layer[0] if it is a tuple. `str` layers raise a PatterError. + datatype is chosen arbitrarily, based on calcualted dose for each shape. + Shapes with equal calcualted dose will have the same datatype. + A list of doses is retured, providing a mapping between datatype + (list index) and dose (list entry). + + Note that this function modifies the input Pattern(s). + + Args: + patterns: A `Pattern` or list of patterns to write to file. Modified by this function. + + Returns: + (patterns, dose_list) + patterns: modified input patterns + dose_list: A list of doses, providing a mapping between datatype (int, list index) + and dose (float, list entry). + """ + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + # Get a table of (id(pat), written_dose) for each pattern and subpattern + sd_table = make_dose_table(patterns) + + # Figure out all the unique doses necessary to write this pattern + # This means going through each row in sd_table and adding the dose values needed to write + # that subpattern at that dose level + dose_vals = set() + for pat_id, pat_dose in sd_table: + pat = patterns_by_id[pat_id] + for shape in pat.shapes: + dose_vals.add(shape.dose * pat_dose) + + if len(dose_vals) > 256: + raise PatternError('Too many dose values: {}, maximum 256 when using dtypes.'.format(len(dose_vals))) + + dose_vals_list = list(dose_vals) + + # Create a new pattern for each non-1-dose entry in the dose table + # and update the shapes to reflect their new dose + new_pats = {} # (id, dose) -> new_pattern mapping + for pat_id, pat_dose in sd_table: + if pat_dose == 1: + new_pats[(pat_id, pat_dose)] = patterns_by_id[pat_id] + continue + + old_pat = patterns_by_id[pat_id] + pat = old_pat.copy() # keep old subpatterns + pat.shapes = copy.deepcopy(old_pat.shapes) + pat.labels = copy.deepcopy(old_pat.labels) + + encoded_name = mangle_name(pat, pat_dose) + if len(encoded_name) == 0: + raise PatternError('Zero-length name after mangle+encode, originally "{}"'.format(pat.name)) + pat.name = encoded_name + + for shape in pat.shapes: + data_type = dose_vals_list.index(shape.dose * pat_dose) + if isinstance(shape.layer, int): + shape.layer = (shape.layer, data_type) + elif isinstance(shape.layer, tuple): + shape.layer = (shape.layer[0], data_type) + else: + raise PatternError(f'Invalid layer for gdsii: {shape.layer}') + + new_pats[(pat_id, pat_dose)] = pat + + # Go back through all the dose-specific patterns and fix up their subpattern entries + for (pat_id, pat_dose), pat in new_pats.items(): + for subpat in pat.subpatterns: + dose_mult = subpat.dose * pat_dose + subpat.pattern = new_pats[(id(subpat.pattern), dose_mult)] + + return patterns, dose_vals_list + + def is_gzipped(path: pathlib.Path) -> bool: - with path.open('rb') as stream: + with open(path, 'rb') as stream: magic_bytes = stream.read(2) return magic_bytes == b'\x1f\x8b' - - -@contextmanager -def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]: - """ - Context manager which allows you to write to a temporary file, - and move that file into its final location only after the write - has finished. - """ - path = pathlib.Path(path) - suffixes = ''.join(path.suffixes) - with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream: - try: - yield tmp_stream - except Exception: - pathlib.Path(tmp_stream.name).unlink(missing_ok=True) - raise - - try: - shutil.move(tmp_stream.name, path) - finally: - pathlib.Path(tmp_stream.name).unlink(missing_ok=True) diff --git a/masque/label.py b/masque/label.py index d220fee..fe7d9ab 100644 --- a/masque/label.py +++ b/masque/label.py @@ -1,30 +1,31 @@ -from typing import Self, Any +from typing import Tuple, Dict, Optional, TypeVar import copy -import functools import numpy from numpy.typing import ArrayLike, NDArray from .repetition import Repetition -from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key -from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable +from .utils import rotation_matrix_2d, layer_t, AutoSlots, annotations_t +from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, LockableImpl, RepeatableImpl from .traits import AnnotatableImpl -@functools.total_ordering -class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable): +L = TypeVar('L', bound='Label') + + +class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, AnnotatableImpl, + Pivotable, Copyable, metaclass=AutoSlots): """ - A text annotation with a position (but no size; it is not drawn) + A text annotation with a position and layer (but no size; it is not drawn) """ - __slots__ = ( - '_string', - # Inherited - '_offset', '_repetition', '_annotations', - ) + __slots__ = ( '_string', 'identifier') _string: str """ Label string """ + identifier: Tuple + """ Arbitrary identifier tuple, useful for keeping track of history when flattening """ + ''' ---- Properties ''' @@ -45,66 +46,38 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl string: str, *, offset: ArrayLike = (0.0, 0.0), - repetition: Repetition | None = None, - annotations: annotations_t | None = None, + layer: layer_t = 0, + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, + locked: bool = False, + identifier: Tuple = (), ) -> None: + LockableImpl.unlock(self) + self.identifier = identifier self.string = string - self.offset = numpy.array(offset, dtype=float) + self.offset = numpy.array(offset, dtype=float, copy=True) + self.layer = layer self.repetition = repetition self.annotations = annotations if annotations is not None else {} + self.set_locked(locked) - @classmethod - def _from_raw( - cls, - string: str, - *, - offset: NDArray[numpy.float64], - repetition: Repetition | None = None, - annotations: annotations_t | None = None, - ) -> Self: - new = cls.__new__(cls) - new._string = string - new._offset = offset - new._repetition = repetition - new._annotations = annotations - return new + def __copy__(self: L) -> L: + return type(self)(string=self.string, + offset=self.offset.copy(), + layer=self.layer, + repetition=self.repetition, + locked=self.locked, + identifier=self.identifier) - def __copy__(self) -> Self: - return type(self)( - string=self.string, - offset=self.offset.copy(), - repetition=self.repetition, - annotations=copy.copy(self.annotations), - ) - - def __deepcopy__(self, memo: dict | None = None) -> Self: + def __deepcopy__(self: L, memo: Dict = None) -> L: memo = {} if memo is None else memo new = copy.copy(self) + LockableImpl.unlock(new) new._offset = self._offset.copy() - new._repetition = copy.deepcopy(self._repetition, memo) - new._annotations = copy.deepcopy(self._annotations, memo) + new.set_locked(self.locked) return new - def __lt__(self, other: 'Label') -> bool: - if self.string != other.string: - return self.string < other.string - if not numpy.array_equal(self.offset, other.offset): - return tuple(self.offset) < tuple(other.offset) - if self.repetition != other.repetition: - return rep2key(self.repetition) < rep2key(other.repetition) - return annotations_lt(self.annotations, other.annotations) - - def __eq__(self, other: Any) -> bool: - if type(self) is not type(other): - return False - return ( - self.string == other.string - and numpy.array_equal(self.offset, other.offset) - and self.repetition == other.repetition - and annotations_eq(self.annotations, other.annotations) - ) - - def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: + def rotate_around(self: L, pivot: ArrayLike, rotation: float) -> L: """ Rotate the label around a point. @@ -115,37 +88,13 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl Returns: self """ - pivot = numpy.asarray(pivot, dtype=float) + pivot = numpy.array(pivot, dtype=float) self.translate(-pivot) - if self.repetition is not None: - self.repetition.rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) self.translate(+pivot) return self - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - """ - Extrinsic transformation: Flip the label across a line in the pattern's - coordinate system. This affects both the label's offset and its - repetition grid. - - Args: - axis: Axis to mirror across. 0: x-axis (flip y), 1: y-axis (flip x). - x: Vertical line x=val to mirror across. - y: Horizontal line y=val to mirror across. - - Returns: - self - """ - axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) - self.translate(-pivot) - if self.repetition is not None: - self.repetition.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self - - def get_bounds_single(self) -> NDArray[numpy.float64]: + def get_bounds(self) -> NDArray[numpy.float64]: """ Return the bounds of the label. @@ -157,3 +106,17 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl Bounds [[xmin, xmax], [ymin, ymax]] """ return numpy.array([self.offset, self.offset]) + + def lock(self: L) -> L: + PositionableImpl._lock(self) + LockableImpl.lock(self) + return self + + def unlock(self: L) -> L: + LockableImpl.unlock(self) + PositionableImpl._unlock(self) + return self + + def __repr__(self) -> str: + locked = ' L' if self.locked else '' + return f'