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..2ed7489 100644 --- a/README.md +++ b/README.md @@ -37,55 +37,6 @@ 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) | - +-----------------------------------------------------------------------+ -``` - - -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") | - | } | - | | - +---------------------------------------------------------------+ -``` - - `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. @@ -145,7 +96,7 @@ References are accomplished by listing the target's name, not its `Pattern` obje 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 + * Another way is use `Builder.place()` or `Builder.plug()`, which automatically creates an `Abstract` from its internally-referenced `Library`. @@ -182,7 +133,7 @@ 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 +name_mapping = library.add(tree) # add all patterns from `tree`, renaming elgible 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 @@ -193,8 +144,8 @@ 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, ...) +# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit: +my_builder = Builder(library=lib, ...) ... my_builder.place(make_tree(...)) ``` @@ -225,7 +176,7 @@ my_pattern.place(library << make_tree(...), ...) ### Quickly add geometry, labels, or refs: -Adding elements can be overly verbose: +The long form for adding elements can be overly verbose: ```python3 my_pattern.shapes[layer].append(Polygon(vertices, ...)) my_pattern.labels[layer] += [Label('my text')] @@ -275,6 +226,11 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...) ``` -## Development +## TODO -Project-level planned work is tracked in [TODO.md](TODO.md). +* Better interface for polygon operations (e.g. with `pyclipper`) + - de-embedding + - boolean ops +* Tests tests tests +* check renderpather +* pather and renderpather examples diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py index 57b170c..a51a27e 100644 --- a/examples/ellip_grating.py +++ b/examples/ellip_grating.py @@ -6,7 +6,7 @@ from masque.file import gdsii from masque import Arc, Pattern -def main() -> None: +def main(): pat = Pattern() layer = (0, 0) pat.shapes[layer].extend([ diff --git a/examples/nested_poly_test.py b/examples/nested_poly_test.py index 60e0a3e..de51d6a 100644 --- a/examples/nested_poly_test.py +++ b/examples/nested_poly_test.py @@ -1,5 +1,7 @@ +import numpy from pyclipper import ( - Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO, + Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO, + scale_to_clipper, scale_from_clipper, ) p = Pyclipper() p.AddPaths([ @@ -10,8 +12,8 @@ p.AddPaths([ ], 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(PT_UNION, PT_NONZERO, PT_NONZERO) +p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO) p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) p = Pyclipper() 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..f82575d 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis -def main() -> None: +def main(): lib = Library() cell_name = 'ellip_grating' diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 7aee3f7..7210a93 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -1,12 +1,6 @@ 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 -------- @@ -14,30 +8,24 @@ Contents * 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 + * Snap ports together to build 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 + * Create a `LazyLibrary`, which loads / generates patterns only when they are first used * Explore alternate ways of specifying a pattern for `.plug()` and `.place()` + * Design a pattern which is meant to plug into an existing pattern (via `.interface()`) - [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), + * Use `BasicTool` to generate paths + * Use `BasicTool` to automatically transition between path types +- [renderpather](rendpather.py) + * Use `RenderPather` 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. +Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices. Running @@ -49,6 +37,3 @@ 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..87baaf0 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -1,9 +1,12 @@ +from collections.abc import 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, Label, Port, + Circle, Arc, Polygon, + ) import masque.file.gdsii @@ -36,45 +39,6 @@ def hole( 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), @@ -96,7 +60,9 @@ def triangle( ]) * radius pat = Pattern() - pat.polygon(layer, vertices=vertices) + pat.shapes[layer].extend([ + Polygon(offset=(0, 0), vertices=vertices), + ]) return pat @@ -145,13 +111,9 @@ def main() -> None: lib['smile'] = smile(1000) lib['triangle'] = triangle(1000) - # Use a Grid to make many holes efficiently - lib['grid'], lib['hole'] = hole_array(1000) - masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS) lib['triangle'].visualize() - lib['grid'].visualize(lib) if __name__ == '__main__': diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index 955e786..6b9cfa2 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -1,19 +1,11 @@ -""" -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 import numpy from numpy import pi from masque import ( - layer_t, Pattern, Ref, Pather, Port, Polygon, - Library, + layer_t, Pattern, Ref, Label, Builder, Port, Polygon, + Library, ILibraryView, ) from masque.utils import ports2data from masque.file.gdsii import writefile, check_valid_names @@ -72,9 +64,9 @@ 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. @@ -87,15 +79,14 @@ def perturbed_l3( 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. + # Build L3 cavity, using references to the provided hole pattern pat = Pattern() pat.refs[hole] += [ Ref(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. + # Add rectangular undercut aids min_xy, max_xy = pat.get_bounds_nonempty(hole_lib) trench_dx = max_xy[0] - min_xy[0] @@ -104,7 +95,7 @@ def perturbed_l3( Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), ] - # 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'), @@ -134,17 +125,17 @@ def waveguide( Returns: `Pattern` 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. + # Build the pattern pat = Pattern() pat.refs[hole] += [ Ref(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'), @@ -173,17 +164,17 @@ def bend( `Pattern` 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() + # Build the pattern + pat= Pattern() pat.refs[hole] += [ Ref(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'), @@ -212,17 +203,17 @@ def y_splitter( `Pattern` 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. + # Build pattern pat = Pattern() pat.refs[hole] += [ Ref(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 = { 'in': Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -236,13 +227,13 @@ def y_splitter( def main(interactive: bool = True) -> None: - # First make a couple of reusable primitive cells. + # Generate some basic hole patterns shape_lib = { '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 devices = {} @@ -254,23 +245,22 @@ def main(interactive: bool = True) -> None: 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. + # Turn our dict of devices into a Library. + # This provides some convenience functions in the future! lib = Library(devices) # # Build a circuit # - # Create a `Pather`, and register the resulting top cell as "my_circuit". - circ = Pather(library=lib, name='my_circuit') + # Create a `Builder`, and add the circuit to our library as "my_circuit". + circ = Builder(library=lib, name='my_circuit') - # Start by placing a waveguide and renaming its ports to match the circuit-level - # names we want to use while assembling the design. + # 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`. + # 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'}) # We could have done the following instead: @@ -278,8 +268,8 @@ def main(interactive: bool = True) -> None: # 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. + # but `Builder` lets us omit some of the repetition of `lib.abstract(...)`, and uses similar + # syntax to `Pather` and `RenderPather`, which add wire/waveguide routing functionality. # Attach a y-splitter to the signal path. # Since the y-splitter has 3 ports total, we can't auto-inherit the @@ -291,10 +281,13 @@ def main(interactive: bool = True) -> None: 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. + # 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'}) @@ -303,26 +296,29 @@ def main(interactive: bool = True) -> None: circ.plug('l3cav', {'signal1': 'input'}) circ.plug('wg10', {'signal1': 'left'}) - # `signal2` gets a single waveguide of equivalent overall length. + # "signal2" just gets a single of equivalent length circ.plug('wg28', {'signal2': 'left'}) - # Now bend both branches back towards each other. + # 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. + # 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'}) - # 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. + # 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. ports_to_data(circ.pattern) # Check if we forgot to include any patterns... ooops! @@ -334,12 +330,12 @@ def main(interactive: bool = True) -> None: 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) - # Write out only the subtree reachable from our top cell. + #Write out to GDS, only keeping patterns referenced by our circuit (including itself) 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) diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index 5650540..eab8a12 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,133 +1,135 @@ -""" -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 collections.abc import 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 import Pattern, Builder, LazyLibrary +from masque.file.gdsii import writefile, load_libraryfile +import pcgen import basic_shapes import devices +from devices import ports_to_data, data_to_ports 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 `LazyLibrary`, which provides lazy evaluation for generating + # patterns and lazy-loading of GDS contents. + lib = LazyLibrary() # # 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 + gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports) - print('Registered imported cells:\n' + pformat(list(gds_lib.keys()))) + # 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. + lib.add(gds_lib) + + print('Patterns loaded from GDS into library:\n' + pformat(list(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) + lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS) opts: dict[str, Any] = dict( - lattice_constant=devices.LATTICE_CONSTANT, - hole='triangle', - ) - - 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, + lattice_constant = devices.LATTICE_CONSTANT, + hole = 'triangle', ) - cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder.library) - print('Declared cells waiting to be built:\n' + pformat(list(builder.keys()))) + # Triangle-based variants. These are defined here, but they won't run until they're + # retrieved from the library. + lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts) + lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts) + lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts) + lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts) + lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts) + lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib) # - # 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 = Builder(library=lib, ports='tri_l3cav') + + # First way to get abstracts is `lib.abstract(name)` + # We can use this syntax directly with `Pattern.plug()` and `Pattern.place()` as well as through `Builder`. + circ2.plug(lib.abstract('wg10'), {'input': 'right'}) + + # Second way to get abstracts is to use an AbstractView + # This also works directly with `Pattern.plug()` / `Pattern.place()`. + abstracts = lib.abstract_view() + circ2.plug(abstracts['wg10'], {'output': 'left'}) + + # Third way to specify an abstract works by automatically getting + # it from the library already within the Builder object. + # This wouldn't work if we only had a `Pattern` (not a `Builder`). + # Just pass the pattern name! + circ2.plug('tri_wg10', {'input': 'right'}) + circ2.plug('tri_wg10', {'output': 'left'}) + + # Add the circuit to the device library. + lib['mixed_wg_cav'] = circ2.pattern + # - # 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 = Builder.interface(source=circ2) + + # ... that lets us continue from where we left off. + circ3.plug('tri_bend0', {'input': 'right'}) + circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry + circ3.plug('tri_bend0', {'input': 'right'}) + circ3.plug('bend0', {'output': 'left'}) + circ3.plug('bend0', {'output': 'left'}) + circ3.plug('bend0', {'output': 'left'}) + circ3.plug('tri_wg10', {'input': 'right'}) + circ3.plug('tri_wg28', {'input': 'right'}) + circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'}) + + lib['loop_segment'] = circ3.pattern # - # 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() + writefile(lib, 'library.gds', **GDS_OPTS) if __name__ == '__main__': main() + + +# +#class prout: +# def place( +# self, +# other: Pattern, +# label_layer: layer_t = 'WATLAYER', +# *, +# port_map: Dict[str, str | None] | None = None, +# **kwargs, +# ) -> 'prout': +# +# Pattern.place(self, other, port_map=port_map, **kwargs) +# name: str | None +# 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.label(string=name, offset=self.ports[name].offset, layer=label_layer) +# return self +# diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index c536428..101fbb5 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -1,20 +1,11 @@ """ -Manual wire routing tutorial: Pather and primitive offers +Manual wire routing tutorial: Pather and BasicTool """ -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Any, Literal - -import numpy +from collections.abc import Callable 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 import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers +from masque.builder.tools import BasicTool, PathTool from masque.file.gdsii import writefile -from masque.library import ILibrary, SINGLE_USE_PREFIX from basic_shapes import GDS_OPTS @@ -116,336 +107,87 @@ def map_layer(layer: layer_t) -> layer_t: 'M2': (20, 0), 'V1': (30, 0), } - if isinstance(layer, str): - return layer_mapping.get(layer, layer) - return layer + return layer_mapping.get(layer, 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 - """ +# +# 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 `RenderPather` +# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others +# (e.g. geometry definition). +# +def main() -> None: # 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', + 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. + # M1_tool will route on M1, using wires with M1_WIDTH + # M2_tool will route on M2, using wires with M2_WIDTH + # Both tools are able to automatically transition from the other wire type (with a via) # - # 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. + # Note that while we use BasicTool for this tutorial, you can define your own `Tool` + # with arbitrary logic inside -- e.g. with single-use bends, complex transition rules, + # transmission line geometry, or other features. # - via = library.abstract('v1_via') - via_transitions = ( - WireTransitionSpec(via, 'top', 'bottom'), - WireTransitionSpec(via, 'bottom', 'top'), + M1_tool = BasicTool( + straight = ( + # First, we need a function which takes in a length and spits out an M1 wire + lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length), + 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input + 'output', # and use the port named 'output' as the output + ), + bend = ( + library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier + 'input', # To orient it clockwise, use the port named 'input' as the input + 'output', # and 'output' as the output + ), + transitions = { # We can automate transitions for different (normally incompatible) port types + 'm2wire': ( # For example, when we're attaching to a port with type 'm2wire' + library.abstract('v1_via'), # we can place a V1 via + 'top', # using the port named 'top' as the input (i.e. the M2 side of the via) + 'bottom', # and using the port named 'bottom' as the output + ), + }, + default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1 ) - M1_tool = PrimitiveWireTool( - layer = 'M1', - width = M1_WIDTH, - ptype = 'm1wire', - bend = library.abstract('m1_bend'), - transitions = via_transitions, + M2_tool = BasicTool( + straight = ( + # Again, we use make_straight_wire, but this time we set parameters for M2 + lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length), + 'input', + 'output', + ), + bend = ( + library.abstract('m2_bend'), # and we use an M2 bend + 'input', + 'output', + ), + transitions = { + 'm1wire': ( + library.abstract('v1_via'), # We still use the same via, + 'bottom', # but the input port is now 'bottom' + 'top', # and the output port is now 'top' + ), + }, + default_out_ptype = 'm2wire', # We default to trying to stay on M2 ) - 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! @@ -461,25 +203,27 @@ def main() -> None: # 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) + pather.path('VCC', ccw=False, length=6_000) - # Now path VCC to x=0. This time, don't include any bend. + # Now path VCC to x=0. This time, don't include any bend (ccw=None). # 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) + pather.path_to('VCC', ccw=None, x=0) # Path GND forward by 5um, turning clockwise 90 degrees. - pather.cw('GND', 5_000) + # This time we use shorthand (bool(0) == False) and omit the parameter labels + # Note that although ccw=0 is equivalent to ccw=False, ccw=None is not! + pather.path('GND', 0, 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]) + pather.path_to('GND', None, 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. + # Since we have defined an M2-to-M1 transition for BasicPather, 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') + # the next time we draw a path on GND (the pather.mpath() statement 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. @@ -487,7 +231,7 @@ def main() -> None: # # 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) + pather.mpath(['GND', 'VCC'], ccw=True, 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. @@ -497,37 +241,38 @@ def main() -> None: # 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) + pather.mpath(['GND', 'VCC'], ccw=True, 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 + # Here, emin specifies the travel distance for the shortest segment. For the first mpath() call + # that applies to VCC, and for teh 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) + pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) + pather.mpath(['GND', 'VCC'], ccw=False, 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. + # explicitly assigned a tool. We could `del pather.tools['GND']` to force it to use the default. 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 + # When ccw is not None, xmin constrains the trailing/innermost port to stop at the target x coordinate, + # However, 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) + pather.mpath(['GND', 'VCC'], None, 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') + pather.path_to('VCC', None, -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) + with pather.toolctx(M2_tool, keys=['GND']): + pather.path_to('GND', None, -40_000) + pather.path_to('GND', None, -50_000) # Save the pather's pattern into our library - library['Pather_and_PrimitiveOffers'] = pather.pattern + library['Pather_and_BasicTool'] = pather.pattern # Convert from text-based layers to numeric layers for GDS, and output the file library.map_layers(map_layer) diff --git a/examples/tutorial/pcgen.py b/examples/tutorial/pcgen.py index 5c5c31b..023079c 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 collection.abc import Sequence import numpy from numpy.typing import ArrayLike, NDArray @@ -50,7 +50,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 +197,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( @@ -248,7 +248,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 +309,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 index d048d65..cb002f3 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -1,8 +1,9 @@ """ -Manual wire routing tutorial: deferred Pather and PathTool +Manual wire routing tutorial: RenderPather an PathTool """ -from masque import Pather, Library -from masque.builder import PathTool +from collections.abc import Callable +from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers +from masque.builder.tools import PathTool from masque.file.gdsii import writefile from basic_shapes import GDS_OPTS @@ -11,9 +12,9 @@ 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 + # To illustrate the advantages of using `RenderPather`, we use `PathTool` instead + # of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) + # but when used with `RenderPather`, 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` @@ -24,68 +25,66 @@ def main() -> None: 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', + 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 + # `PathTool` is more limited than `BasicTool`. 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') + rpather = RenderPather(tools=M2_ptool, library=library) - # As in the pather tutorial, we make some pads and labels... + # As in the pather tutorial, we make soem 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) + rpather.path('VCC', ccw=False, length=6_000) + rpather.path_to('VCC', ccw=None, x=0) + rpather.path('GND', 0, 5_000) + rpather.path_to('GND', None, x=rpather['VCC'].offset[0]) # `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) + rpather.retool(M1_ptool, keys=['GND']) + rpather.mpath(['GND', 'VCC'], ccw=True, 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) + rpather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200) + rpather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) + rpather.mpath(['GND', 'VCC'], ccw=False, 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) + rpather.mpath(['GND', 'VCC'], None, 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) + via_size = abs( + library['v1_via'].ports['top'].offset[0] + - library['v1_via'].ports['bottom'].offset[0] + ) + rpather.path_to('VCC', None, -50_000 + via_size) rpather.plug('v1_via', {'VCC': 'top'}) - # Render the path we defined rpather.render() - library['Deferred_Pather_and_PathTool'] = rpather.pattern + library['RenderPather_and_PathTool'] = rpather.pattern # Convert from text-based layers to numeric layers for GDS, and output the file diff --git a/masque/__init__.py b/masque/__init__.py index 8c9529b..3515fa5 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -42,7 +42,6 @@ from .error import ( from .shapes import ( Shape as Shape, Polygon as Polygon, - RectCollection as RectCollection, Path as Path, Circle as Circle, Arc as Arc, @@ -56,27 +55,16 @@ from .pattern import ( 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, @@ -84,17 +72,13 @@ from .ports import ( ) from .abstract import Abstract as Abstract from .builder import ( + Builder as Builder, Tool as Tool, - ToolContractError as ToolContractError, Pather as Pather, - RouteError as RouteError, - RouteFailureDetails as RouteFailureDetails, - RouteFailurePolicy as RouteFailurePolicy, - MinimumStatus as MinimumStatus, + RenderPather as RenderPather, RenderStep as RenderStep, - AutoTool as AutoTool, + BasicTool as BasicTool, PathTool as PathTool, - PortPather as PortPather, ) from .utils import ( ports2data as ports2data, @@ -106,5 +90,5 @@ from .utils import ( __author__ = 'Jan Petykiewicz' -__version__ = '4.0a2' +__version__ = '3.4' version = __version__ # legacy diff --git a/masque/abstract.py b/masque/abstract.py index d23d7c7..248c8a5 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -8,20 +8,22 @@ from numpy.typing import ArrayLike from .ref import Ref from .ports import PortList, Port from .utils import rotation_matrix_2d -from .traits import Mirrorable + +#if TYPE_CHECKING: +# from .builder import Builder, Tool +# from .library import ILibrary logger = logging.getLogger(__name__) -class Abstract(PortList, Mirrorable): +class Abstract(PortList): """ 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 @@ -46,6 +48,8 @@ class Abstract(PortList, Mirrorable): self.name = name self.ports = copy.deepcopy(ports) + # TODO do we want to store a Ref instead of just a name? then we can translate/rotate/mirror... + def __repr__(self) -> str: s = f' Self: """ - Rotate the Abstract around a pivot point. + Rotate the Abstract around the a location. Args: pivot: (x, y) location to rotate around @@ -128,18 +132,50 @@ class Abstract(PortList, Mirrorable): port.rotate(rotation) return self - def mirror(self, axis: int = 0) -> Self: + def mirror_port_offsets(self, across_axis: int = 0) -> Self: """ - Mirror the Abstract across an axis through its origin. + Mirror the offsets of all shapes, labels, and refs across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ for port in self.ports.values(): - port.flip_across(axis=axis) + port.offset[across_axis - 1] *= -1 + return self + + def mirror_ports(self, across_axis: int = 0) -> Self: + """ + Mirror each port's rotation across an axis, relative to its + offset + + Args: + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) + + Returns: + self + """ + for port in self.ports.values(): + port.mirror(across_axis) + return self + + def mirror(self, across_axis: int = 0) -> Self: + """ + Mirror the Pattern across an axis + + Args: + axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) + + Returns: + self + """ + self.mirror_ports(across_axis) + self.mirror_port_offsets(across_axis) return self def apply_ref_transform(self, ref: Ref) -> Self: @@ -157,8 +193,6 @@ class Abstract(PortList, Mirrorable): 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 @@ -176,8 +210,6 @@ class Abstract(PortList, Mirrorable): # 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: diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index cc0b2aa..1eea9f1 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,90 +1,10 @@ -""" -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 .builder import Builder as Builder +from .pather import Pather as Pather +from .renderpather import RenderPather as RenderPather 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, -) + BasicTool as BasicTool, + PathTool as PathTool, + ) 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/builder.py b/masque/builder/builder.py new file mode 100644 index 0000000..fed839a --- /dev/null +++ b/masque/builder/builder.py @@ -0,0 +1,443 @@ +""" +Simplified Pattern assembly (`Builder`) +""" +from typing import Self +from collections.abc import Iterable, Sequence, Mapping +import copy +import logging +from functools import wraps + +from numpy.typing import ArrayLike + +from ..pattern import Pattern +from ..library import ILibrary, TreeView +from ..error import BuildError +from ..ports import PortList, Port +from ..abstract import Abstract + + +logger = logging.getLogger(__name__) + + +class Builder(PortList): + """ + A `Builder` is a helper object used for snapping together multiple + lower-level patterns at their `Port`s. + + The `Builder` mostly just holds context, in the form of a `Library`, + in addition to its underlying pattern. This simplifies some calls + to `plug` and `place`, by making the library implicit. + + `Builder` can also be `set_dead()`, at which point further calls to `plug()` + and `place()` are ignored (intended for debugging). + + + Examples: Creating a Builder + =========================== + - `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes + an empty pattern, adds the given ports, and places it into `library` + under the name `'mypat'`. + + - `Builder(library)` makes an empty pattern with no ports. The pattern + is not added into `library` and must later be added with e.g. + `library['mypat'] = builder.pattern` + + - `Builder(library, pattern=pattern, name='mypat')` uses an existing + pattern (including its ports) and sets `library['mypat'] = pattern`. + + - `Builder.interface(other_pat, port_map=['A', 'B'], library=library)` + makes a new (empty) pattern, copies over ports 'A' and 'B' from + `other_pat`, 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 `other_pat` (using the 'in_*' ports) but which does not + itself include `other_pat` as a subcomponent. + + - `Builder.interface(other_builder, ...)` does the same thing as + `Builder.interface(other_builder.pattern, ...)` but also uses + `other_builder.library` as its library by default. + + + Examples: Adding to a pattern + ============================= + - `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', 'library', '_dead') + + pattern: Pattern + """ Layout of this device """ + + library: ILibrary + """ + Library from which patterns should be referenced + """ + + _dead: bool + """ If True, plug()/place() are skipped (for debugging)""" + + @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, + name: str | None = None, + ) -> None: + """ + Args: + library: The library from which referenced patterns will be taken + pattern: The pattern which will be modified by subsequent operations. + If `None` (default), a new pattern is created. + ports: Allows specifying the initial set of ports, if `pattern` does + not already have any ports (or is not provided). May be a string, + in which case it is interpreted as a name in `library`. + Default `None` (no ports). + name: If specified, `library[name]` is set to `self.pattern`. + """ + self._dead = False + self.library = library + if pattern is not None: + self.pattern = pattern + else: + self.pattern = Pattern() + + 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 name is not None: + library[name] = self.pattern + + @classmethod + def interface( + cls: type['Builder'], + source: PortList | Mapping[str, Port] | str, + *, + library: ILibrary | None = None, + in_prefix: str = 'in_', + out_prefix: str = '', + port_map: dict[str, str] | Sequence[str] | None = None, + name: str | None = None, + ) -> 'Builder': + """ + Wrapper for `Pattern.interface()`, which returns a Builder instead. + + Args: + source: A collection of ports (e.g. Pattern, Builder, or dict) + from which to create the interface. May be a pattern name if + `library` is provided. + library: Library from which existing patterns should be referenced, + and to which the new one should be added (if named). If not provided, + `source.library` must exist and will be used. + 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 builder, with an empty pattern and 2x as many ports as + listed in port_map. + + Raises: + `PortError` if `port_map` contains port names not present in the + current device. + `PortError` if applying the prefixes results in duplicate port + names. + """ + if library is None: + if hasattr(source, 'library') and isinstance(source.library, ILibrary): + library = source.library + else: + raise BuildError('No library was given, and `source.library` does not have one either.') + + 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) + new = Builder(library=library, pattern=pat, name=name) + return new + + @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 + + # Note: We're a superclass of `Pather`, where path() means something different... + #@wraps(Pattern.path) + #def path(self, *args, **kwargs) -> Self: + # self.pattern.path(*args, **kwargs) + # return self + + def plug( + self, + other: Abstract | str | Pattern | TreeView, + map_in: dict[str, str], + map_out: dict[str, str | None] | None = None, + *, + mirrored: bool = False, + inherit_name: bool = True, + set_rotation: bool | None = None, + append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), + ) -> Self: + """ + Wrapper around `Pattern.plug` which allows a string for `other`. + + The `Builder`'s library is used to dereference the string (or `Abstract`, if + one is passed with `append=True`). If a `TreeView` is passed, it is first + added into `self.library`. + + Args: + other: An `Abstract`, string, `Pattern`, or `TreeView` describing the + device to be instatiated. If it is a `TreeView`, it is first + added into `self.library`, after which the topcell is plugged; + an equivalent statement is `self.plug(self.library << other, ...)`. + 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 axis 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`. + append: If `True`, `other` is appended instead of being referenced. + Note that this does not flatten `other`, so its refs will still + be refs (now inside `self`). + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. + + Returns: + self + + Raises: + `PortError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `PortError` if there are any duplicate names after `map_in` and `map_out` + are applied. + `PortError` 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 not isinstance(other, str | Abstract | Pattern): + # We got a Tree; add it into self.library and grab an Abstract for it + other = self.library << other + + if isinstance(other, str): + other = self.library.abstract(other) + if append and isinstance(other, Abstract): + other = self.library[other.name] + + self.pattern.plug( + other=other, + map_in=map_in, + map_out=map_out, + mirrored=mirrored, + inherit_name=inherit_name, + set_rotation=set_rotation, + append=append, + ok_connections=ok_connections, + ) + return self + + def place( + self, + other: Abstract | str | Pattern | TreeView, + *, + offset: ArrayLike = (0, 0), + rotation: float = 0, + pivot: ArrayLike = (0, 0), + mirrored: bool = False, + port_map: dict[str, str | None] | None = None, + skip_port_check: bool = False, + append: bool = False, + ) -> Self: + """ + Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`. + + The `Builder`'s library is used to dereference the string (or `Abstract`, if + one is passed with `append=True`). If a `TreeView` is passed, it is first + added into `self.library`. + + Args: + other: An `Abstract`, string, `Pattern`, or `TreeView` describing the + device to be instatiated. If it is a `TreeView`, it is first + added into `self.library`, after which the topcell is plugged; + an equivalent statement is `self.plug(self.library << other, ...)`. + offset: Offset at which to place the instance. Default (0, 0). + rotation: Rotation applied to the instance before placement. Default 0. + pivot: Rotation is applied around this pivot point (default (0, 0)). + Rotation is applied prior to translation (`offset`). + mirrored: Whether theinstance should be mirrored across the x axis. + Mirroring is applied before translation and rotation. + port_map: dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in the instantiated device. 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. + append: If `True`, `other` is appended instead of being referenced. + Note that this does not flatten `other`, so its refs will still + be refs (now inside `self`). + + Returns: + self + + Raises: + `PortError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other.ports`. + `PortError` 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 not isinstance(other, str | Abstract | Pattern): + # We got a Tree; add it into self.library and grab an Abstract for it + other = self.library << other + + if isinstance(other, str): + other = self.library.abstract(other) + if append and isinstance(other, Abstract): + other = self.library[other.name] + + self.pattern.place( + other=other, + offset=offset, + rotation=rotation, + pivot=pivot, + mirrored=mirrored, + port_map=port_map, + skip_port_check=skip_port_check, + append=append, + ) + return self + + def translate(self, offset: ArrayLike) -> Self: + """ + Translate the pattern and all ports. + + Args: + offset: (x, y) distance to translate by + + Returns: + self + """ + self.pattern.translate_elements(offset) + return self + + def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: + """ + Rotate the pattern and all ports. + + Args: + angle: angle (radians, counterclockwise) to rotate by + pivot: location to rotate around + + Returns: + self + """ + self.pattern.rotate_around(pivot, angle) + for port in self.ports.values(): + port.rotate_around(pivot, angle) + return self + + def mirror(self, axis: int = 0) -> Self: + """ + Mirror 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) + return self + + def set_dead(self) -> Self: + """ + 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 __repr__(self) -> str: + s = f'' + return s + + 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 index c9bd1d4..d0deacb 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -1,276 +1,130 @@ """ -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. +Manual wire/waveguide routing (`Pather`) """ -from typing import Self, Any, Literal, Protocol, overload -from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence +from typing import Self +from collections.abc import Sequence, MutableMapping, Mapping, Iterator 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 +from pprint import pformat 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 ..library import ILibrary, SINGLE_USE_PREFIX +from ..error import PortError, BuildError 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 +from ..utils import SupportsBool, rotation_matrix_2d +from .tools import Tool +from .utils import ell +from .builder import Builder 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): +class Pather(Builder): """ - 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. + An extension of `Builder` which provides functionality for routing and attaching + single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns. - The `Pather` holds context in the form of a `Library`, its underlying - pattern, and a set of `Tool`s for generating routing segments. + `Pather` is mostly concerned with calculating how long each wire should be. It calls + out to `Tool.path` functions provided by subclasses of `Tool` to build the actual patterns. + `Tool`s are assigned on a per-port basis and stored in `.tools`; a key of `None` represents + a "default" `Tool` used for all ports which do not have a port-specific `Tool` assigned. - 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, tools=my_tool)` makes an empty pattern with no ports. The pattern + is not added into `library` and must later be added with e.g. + `library['mypat'] = pather.pattern`. + The default wire/waveguide generating tool for all ports is set to `my_tool`. + + - `Pather(library, ports={'in': Port(...), 'out': ...}, name='mypat', tools=my_tool)` + makes an empty pattern, adds the given ports, and places it into `library` + under the name `'mypat'`. The default wire/waveguide generating tool + for all ports is set to `my_tool` + + - `Pather(..., tools={'in': top_metal_40um, 'out': bottom_metal_1um, None: my_tool})` + assigns specific tools to individual ports, and `my_tool` as a default for ports + which are not specified. + + - `Pather.interface(other_pat, port_map=['A', 'B'], library=library, tools=my_tool)` + makes a new (empty) pattern, copies over ports 'A' and 'B' from + `other_pat`, 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 `other_pat` (using the 'in_*' ports) but which does not + itself include `other_pat` as a subcomponent. + + - `Pather.interface(other_pather, ...)` does the same thing as + `Builder.interface(other_builder.pattern, ...)` but also uses + `other_builder.library` as its library by default. - - `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.path('my_port', ccw=True, distance)` creates a "wire" for which the output + port is `distance` units away along the axis of `'my_port'` and rotated 90 degrees + counterclockwise (since `ccw=True`) relative to `'my_port'`. The wire is `plug`ged + into the existing `'my_port'`, causing the port to move to the wire's output. - - `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. + There is no formal guarantee about how far off-axis the output will be located; + there may be a significant width to the bend that is used to accomplish the 90 degree + turn. However, an error is raised if `distance` is too small to fit the bend. - Examples: Route completion - ========================== - Route completion callbacks can add labels or other endpoint annotations - without wrapping the individual routing methods:: + - `pather.path('my_port', ccw=None, distance)` creates a straight wire with a length + of `distance` and `plug`s it into `'my_port'`. - def label_route_endpoints(pather, endpoints): - for name, port in endpoints.items(): - pather.label('PORT_LABELS', string=name, offset=port.offset) + - `pather.path_to('my_port', ccw=False, position)` creates a wire which starts at + `'my_port'` and has its output at the specified `position`, pointing 90 degrees + clockwise relative to the input. Again, the off-axis position or distance to the + output is not specified, so `position` takes the form of a single coordinate. To + ease debugging, position may be specified as `x=position` or `y=position` and an + error will be raised if the wrong coordinate is given. - pather = Pather( - library, - tools=my_tool, - on_route_complete=label_route_endpoints, - ) + - `pather.mpath(['A', 'B', 'C'], ..., spacing=spacing)` is a superset of `path` + and `path_to` which can act on multiple ports simultaneously. Each port's wire is + generated using its own `Tool` (or the default tool if left unspecified). + The output ports are spaced out by `spacing` along the input ports' axis, unless + `ccw=None` is specified (i.e. no bends) in which case they all end at the same + destination coordinate. + + - `pather.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' + of `pather.pattern`. 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. + + - `pather.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 `pather.pattern`. 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. + + - `pather.retool(tool)` or `pather.retool(tool, ['in', 'out', None])` can change + which tool is used for the given ports (or as the default tool). Useful + when placing vias or using multiple waveguide types along a route. """ - __slots__ = ( - 'pattern', 'library', 'tools', 'planner', '_paths', - 'on_route_complete', '_dead', '_logger', '_render_policy', '_render_append', '_context_depth' - ) - - pattern: Pattern - """ Layout of this device """ + __slots__ = ('tools',) library: ILibrary - """ Library from which patterns should be referenced """ + """ + Library from which existing patterns should be referenced, and to which + new ones should be added + """ 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. + Tool objects are used to dynamically generate new single-use `Pattern`s + (e.g wires or waveguides) to be plugged into this device. A key of `None` + indicates the default `Tool`. """ - 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, @@ -279,59 +133,40 @@ class Pather(PortList): 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. + library: The library from which referenced patterns will be taken, + and where new patterns (e.g. generated by the `tools`) will be placed. + pattern: The pattern which will be modified by subsequent operations. + If `None` (default), a new pattern is created. + ports: Allows specifying the initial set of ports, if `pattern` does + not already have any ports (or is not provided). May be a string, + in which case it is interpreted as a name in `library`. + Default `None` (no ports). + tools: A mapping of {port: tool} which specifies what `Tool` should be used + to generate waveguide or wire segments when `path`/`path_to`/`mpath` + are called. Relies on `Tool.path` implementations. 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 pattern is not None: + self.pattern = pattern + else: + self.pattern = Pattern() 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 name is not None: + library[name] = self.pattern + if tools is None: self.tools = {} elif isinstance(tools, Tool): @@ -339,971 +174,29 @@ class Pather(PortList): 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, + @classmethod + def from_builder( + cls: type['Pather'], + builder: Builder, *, - out_rot: float | None = None, - out_ptype: str | None = None, - ) -> Port: + tools: Tool | MutableMapping[str | None, Tool] | None = None, + ) -> 'Pather': """ - Move a dead Pather port without generating geometry. + Construct a `Pather` by adding tools to a `Builder`. - Dead fallback is only for debugging or dry layout flow. Fatal route - errors bypass it because they indicate an invalid Tool offer contract. + Args: + builder: Builder to turn into a Pather + tools: Tools for the `Pather` + + Returns: + A new Pather object, using `builder.library` and `builder.pattern`. """ - 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() + new = Pather(library=builder.library, tools=tools, pattern=builder.pattern) + return new - # - # 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, + cls: type['Pather'], source: PortList | Mapping[str, Port] | str, *, library: ILibrary | None = None, @@ -1312,489 +205,522 @@ class Pather(PortList): out_prefix: str = '', port_map: dict[str, str] | Sequence[str] | None = None, name: str | None = None, - **kwargs: Any, - ) -> Self: + ) -> 'Pather': + """ + Wrapper for `Pattern.interface()`, which returns a Pather instead. + + Args: + source: A collection of ports (e.g. Pattern, Builder, or dict) + from which to create the interface. May be a pattern name if + `library` is provided. + library: Library from which existing patterns should be referenced, + and to which the new one should be added (if named). If not provided, + `source.library` must exist and will be used. + tools: `Tool`s which will be used by the pather for generating new wires + or waveguides (via `path`/`path_to`/`mpath`). + 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 pather, with an empty pattern and 2x as many ports as + listed in port_map. + + Raises: + `PortError` if `port_map` contains port names not present in the + current device. + `PortError` if applying the prefixes results in duplicate port + names. + """ if library is None: if hasattr(source, 'library') and isinstance(source.library, ILibrary): library = source.library else: - raise BuildError('No library provided') + raise BuildError('No library provided (and not present in `source.library`') + 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: + pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) + new = Pather(library=library, pattern=pat, name=name, tools=tools) + return new + + def __repr__(self) -> str: + s = f'' + return s + + def retool( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Self: + """ + Update the `Tool` which will be used when generating `Pattern`s for the ports + given by `keys`. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ if keys is None or isinstance(keys, str): self.tools[keys] = tool else: - for k in keys: - self.tools[k] = tool + for key in keys: + self.tools[key] = tool return self @contextmanager - def toolctx(self, tool: Tool, keys: str | Sequence[str | None] | None = None) -> Iterator[Self]: + def toolctx( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Iterator[Self]: + """ + Context manager for temporarily `retool`-ing and reverting the `retool` + upon exiting the context. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ if keys is None or isinstance(keys, str): keys = [keys] - saved = {k: self.tools.get(k) for k in keys} + saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` try: - yield self.retool(tool, keys) + yield self.retool(tool=tool, keys=keys) finally: - for k, t in saved.items(): - if t is None: - self.tools.pop(k, None) + for kk, tt in saved_tools.items(): + if tt is None: + # delete if present + self.tools.pop(kk, None) else: - self.tools[k] = t + self.tools[kk] = tt + + def path( + self, + portspec: str, + ccw: SupportsBool | None, + length: float, + *, + tool_port_names: tuple[str, str] = ('A', 'B'), + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of traveling exactly `length` distance. + + The wire will travel `length` distance along the port's axis, an an unspecified + (tool-dependent) distance in the perpendicular direction. The output port will + be rotated (or not) based on the `ccw` parameter. + + Args: + portspec: The name of the port into which the wire will be plugged. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + length: The total distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + tool_port_names: The names of the ports on the generated pattern. It is unlikely + that you will need to change these. The first port is the input (to be + connected to `portspec`). + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `distance` is too small to fit the bend (if a bend is present). + LibraryError if no valid name could be picked for the pattern. + """ + if self._dead: + logger.error('Skipping path() since device is dead') + return self + + tool = self.tools.get(portspec, self.tools[None]) + in_ptype = self.pattern[portspec].ptype + tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + abstract = self.library << tree + if plug_into is not None: + output = {plug_into: tool_port_names[1]} + else: + output = {} + return self.plug(abstract, {portspec: tool_port_names[0], **output}) + + def path_to( + self, + portspec: str, + ccw: SupportsBool | None, + position: float | None = None, + *, + x: float | None = None, + y: float | None = None, + tool_port_names: tuple[str, str] = ('A', 'B'), + plug_into: str | None = None, + **kwargs, + ) -> Self: + """ + Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim + of ending exactly at a target position. + + The wire will travel so that the output port will be placed at exactly the target + position along the input port's axis. There can be an unspecified (tool-dependent) + offset in the perpendicular direction. The output port will be rotated (or not) + based on the `ccw` parameter. + + Args: + portspec: The name of the port into which the wire will be plugged. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + position: The final port position, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + Only one of `position`, `x`, and `y` may be specified. + x: The final port position along the x axis. + `portspec` must refer to a horizontal port if `x` is passed, otherwise a + BuildError will be raised. + y: The final port position along the y axis. + `portspec` must refer to a vertical port if `y` is passed, otherwise a + BuildError will be raised. + tool_port_names: The names of the ports on the generated pattern. It is unlikely + that you will need to change these. The first port is the input (to be + connected to `portspec`). + plug_into: If not None, attempts to plug the wire's output port into the provided + port on `self`. + + Returns: + self + + Raises: + BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend + is present). + BuildError if `x` or `y` is specified but does not match the axis of `portspec`. + BuildError if more than one of `x`, `y`, and `position` is specified. + """ + if self._dead: + logger.error('Skipping path_to() since device is dead') + return self + + pos_count = sum(vv is not None for vv in (position, x, y)) + if pos_count > 1: + raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') + if pos_count < 1: + raise BuildError('One of `position`, `x`, and `y` must be specified') + + port = self.pattern[portspec] + if port.rotation is None: + raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') + + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise BuildError('path_to was asked to route from non-manhattan port') + + is_horizontal = numpy.isclose(port.rotation % pi, 0) + if is_horizontal: + if y is not None: + raise BuildError('Asked to path to y-coordinate, but port is horizontal') + if position is None: + position = x + else: + if x is not None: + raise BuildError('Asked to path to x-coordinate, but port is vertical') + if position is None: + position = y + + x0, y0 = port.offset + if is_horizontal: + if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): + raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') + length = numpy.abs(position - x0) + else: + if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): + raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') + length = numpy.abs(position - y0) + + return self.path( + portspec, + ccw, + length, + tool_port_names=tool_port_names, + plug_into=plug_into, + **kwargs, + ) + + def path_into( + self, + portspec_src: str, + portspec_dst: str, + *, + tool_port_names: tuple[str, str] = ('A', 'B'), + out_ptype: str | None = None, + plug_destination: bool = True, + **kwargs, + ) -> Self: + """ + Create a "wire"/"waveguide" and traveling between the ports `portspec_src` and + `portspec_dst`, and `plug` it into both (or just the source port). + + Only unambiguous scenarios are allowed: + - Straight connector between facing ports + - Single 90 degree bend + - Jog between facing ports + (jog is done as late as possible, i.e. only 2 L-shaped segments are used) + + By default, the destination's `pytpe` will be used as the `out_ptype` for the + wire, and the `portspec_dst` will be plugged (i.e. removed). + + Args: + portspec_src: The name of the starting port into which the wire will be plugged. + portspec_dst: The name of the destination port. + tool_port_names: The names of the ports on the generated pattern. It is unlikely + that you will need to change these. The first port is the input (to be + connected to `portspec`). + out_ptype: Passed to the pathing tool in order to specify the desired port type + to be generated at the destination end. If `None` (default), the destination + port's `ptype` will be used. + + Returns: + self + + Raises: + PortError if either port does not have a specified rotation. + BuildError if and invalid port config is encountered: + - Non-manhattan ports + - U-bend + - Destination too close to (or behind) source + """ + if self._dead: + logger.error('Skipping path_into() since device is dead') + return self + + port_src = self.pattern[portspec_src] + port_dst = self.pattern[portspec_dst] + + if out_ptype is None: + out_ptype = port_dst.ptype + + if port_src.rotation is None: + raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()') + if port_dst.rotation is None: + raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()') + + if not numpy.isclose(port_src.rotation % (pi / 2), 0): + raise BuildError('path_into was asked to route from non-manhattan port') + if not numpy.isclose(port_dst.rotation % (pi / 2), 0): + raise BuildError('path_into was asked to route to non-manhattan port') + + src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0) + dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0) + xs, ys = port_src.offset + xd, yd = port_dst.offset + + angle = (port_dst.rotation - port_src.rotation) % (2 * pi) + + src_ne = port_src.rotation % (2 * pi) > (3 * pi / 4) # path from src will go north or east + + def get_jog(ccw: SupportsBool, length: float) -> float: + tool = self.tools.get(portspec_src, self.tools[None]) + in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already... + tree2 = tool.path(ccw, length, in_ptype=in_ptype, port_names=('A', 'B'), out_ptype=out_ptype, **kwargs) + top2 = tree2.top_pattern() + jog = rotation_matrix_2d(top2['A'].rotation) @ (top2['B'].offset - top2['A'].offset) + return jog[1] * [-1, 1][int(bool(ccw))] + + dst_extra_args = {'out_ptype': out_ptype} + if plug_destination: + dst_extra_args['plug_into'] = portspec_dst + + src_args = {**kwargs, 'tool_port_names': tool_port_names} + dst_args = {**src_args, **dst_extra_args} + if src_is_horizontal and not dst_is_horizontal: + # single bend should suffice + self.path_to(portspec_src, angle > pi, x=xd, **src_args) + self.path_to(portspec_src, None, y=yd, **dst_args) + elif dst_is_horizontal and not src_is_horizontal: + # single bend should suffice + self.path_to(portspec_src, angle > pi, y=yd, **src_args) + self.path_to(portspec_src, None, x=xd, **dst_args) + elif numpy.isclose(angle, pi): + if src_is_horizontal and ys == yd: + # straight connector + self.path_to(portspec_src, None, x=xd, **dst_args) + elif not src_is_horizontal and xs == xd: + # straight connector + self.path_to(portspec_src, None, y=yd, **dst_args) + elif src_is_horizontal: + # figure out how much x our y-segment (2nd) takes up, then path based on that + y_len = numpy.abs(yd - ys) + ccw2 = src_ne != (yd > ys) + jog = get_jog(ccw2, y_len) * numpy.sign(xd - xs) + self.path_to(portspec_src, not ccw2, x=xd - jog, **src_args) + self.path_to(portspec_src, ccw2, y=yd, **dst_args) + else: + # figure out how much y our x-segment (2nd) takes up, then path based on that + x_len = numpy.abs(xd - xs) + ccw2 = src_ne != (xd < xs) + jog = get_jog(ccw2, x_len) * numpy.sign(yd - ys) + self.path_to(portspec_src, not ccw2, y=yd - jog, **src_args) + self.path_to(portspec_src, ccw2, x=xd, **dst_args) + elif numpy.isclose(angle, 0): + raise BuildError('Don\'t know how to route a U-bend at this time!') + else: + raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') + + return self + + def mpath( + self, + portspec: str | Sequence[str], + ccw: SupportsBool | None, + *, + spacing: float | ArrayLike | None = None, + set_rotation: float | None = None, + tool_port_names: tuple[str, str] = ('A', 'B'), + force_container: bool = False, + base_name: str = SINGLE_USE_PREFIX + 'mpath', + **kwargs, + ) -> Self: + """ + `mpath` is a superset of `path` and `path_to` which can act on bundles or buses + of "wires or "waveguides". + + The wires will travel so that the output ports will be placed at well-defined + locations along the axis of their input ports, but may have arbitrary (tool- + dependent) offsets in the perpendicular direction. + + If `ccw` is not `None`, the wire bundle will turn 90 degres in either the + clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the + bundle, the center-to-center wire spacings after the turn are set by `spacing`, + which is required when `ccw` is not `None`. The final position of bundle as a + whole can be set in a number of ways: + + =A>---------------------------V turn direction: `ccw=False` + =B>-------------V | + =C>-----------------------V | + =D=>----------------V | + | + + x---x---x---x `spacing` (can be scalar or array) + + <--------------> `emin=` + <------> `bound_type='min_past_furthest', bound=` + <--------------------------------> `emax=` + x `pmin=` + x `pmax=` + + - `emin=`, equivalent to `bound_type='min_extension', bound=` + The total extension value for the furthest-out port (B in the diagram). + - `emax=`, equivalent to `bound_type='max_extension', bound=`: + The total extension value for the closest-in port (C in the diagram). + - `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`: + The coordinate of the innermost bend (D's bend). + The x/y versions throw an error if they do not match the port axis (for debug) + - `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`: + The coordinate of the outermost bend (A's bend). + The x/y versions throw an error if they do not match the port axis (for debug) + - `bound_type='min_past_furthest', bound=`: + The distance between furthest out-port (B) and the innermost bend (D's bend). + + If `ccw=None`, final output positions (along the input axis) of all wires will be + identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is + required. In this case, `emin=` and `emax=` are equivalent to each other, and + `pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other. + + + Args: + portspec: The names of the ports which are to be routed. + ccw: If `None`, the outputs should be along the same axis as the inputs. + Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` + and clockwise otherwise. + spacing: Center-to-center distance between output ports along the input port's axis. + Must be provided if (and only if) `ccw` is not `None`. + set_rotation: If the provided ports have `rotation=None`, this can be used + to set a rotation for them. + tool_port_names: The names of the ports on the generated pattern. It is unlikely + that you will need to change these. The first port is the input (to be + connected to `portspec`). + force_container: If `False` (default), and only a single port is provided, the + generated wire for that port will be referenced directly, rather than being + wrapped in an additonal `Pattern`. + base_name: Name to use for the generated `Pattern`. This will be passed through + `self.library.get_name()` to get a unique name for each new `Pattern`. + + Returns: + self + + Raises: + BuildError if the implied length for any wire is too close to fit the bend + (if a bend is requested). + BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not + match the axis of `portspec`. + BuildError if an incorrect bound type or spacing is specified. + """ + if self._dead: + logger.error('Skipping mpath() since device is dead') + return self + + bound_types = set() + if 'bound_type' in kwargs: + bound_types.add(kwargs['bound_type']) + bound = kwargs['bound'] + del kwargs['bound_type'] + del kwargs['bound'] + for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): + if bt in kwargs: + bound_types.add(bt) + bound = kwargs[bt] + del kwargs[bt] + + if not bound_types: + raise BuildError('No bound type specified for mpath') + if len(bound_types) > 1: + raise BuildError(f'Too many bound types specified for mpath: {bound_types}') + bound_type = tuple(bound_types)[0] + + if isinstance(portspec, str): + portspec = [portspec] + ports = self.pattern[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, **kwargs) + + bld = Pather.interface(source=ports, library=self.library, tools=self.tools) + for port_name, length in extensions.items(): + bld.path(port_name, ccw, length, tool_port_names=tool_port_names, **kwargs) + name = self.library.get_name(base_name) + self.library[name] = bld.pattern + return self.plug(Abstract(name, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'? + + # TODO def bus_join()? def flatten(self) -> Self: + """ + Flatten the contained pattern, using the contained library to resolve references. + + Returns: + 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/renderpather.py b/masque/builder/renderpather.py new file mode 100644 index 0000000..8dae18b --- /dev/null +++ b/masque/builder/renderpather.py @@ -0,0 +1,703 @@ +""" +Pather with batched (multi-step) rendering +""" +from typing import Self +from collections.abc import Sequence, Mapping, MutableMapping +import copy +import logging +from collections import defaultdict +from pprint import pformat + +import numpy +from numpy import pi +from numpy.typing import ArrayLike + +from ..pattern import Pattern +from ..library import ILibrary +from ..error import PortError, BuildError +from ..ports import PortList, Port +from ..abstract import Abstract +from ..utils import SupportsBool +from .tools import Tool, RenderStep +from .utils import ell + + +logger = logging.getLogger(__name__) + + +class RenderPather(PortList): + """ + `RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath` + functions to plan out wire paths without incrementally generating the layout. Instead, + it waits until `render` is called, at which point it draws all the planned segments + simultaneously. This allows it to e.g. draw each wire using a single `Path` or + `Polygon` shape instead of multiple rectangles. + + `RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific + dimensions and build the final geometry for each wire. `Tool.planL` provides the + output port data (relative to the input) for each segment. The tool, input and output + ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for + each port. When `render` is called, it bundles `RenderStep`s into batches which use + the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build + the geometry. + + See `Pather` for routing examples. After routing is complete, `render` must be called + to generate the final geometry. + """ + __slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', ) + + pattern: Pattern + """ Layout of this device """ + + library: ILibrary + """ Library from which patterns should be referenced """ + + _dead: bool + """ If True, plug()/place() are skipped (for debugging) """ + + paths: defaultdict[str, list[RenderStep]] + """ Per-port list of operations, to be used by `render` """ + + tools: dict[str | None, Tool] + """ + Tool objects are used to dynamically generate new single-use Devices + (e.g wires or waveguides) to be plugged into this device. + """ + + @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, + ) -> None: + """ + Args: + library: The library from which referenced patterns will be taken, + and where new patterns (e.g. generated by the `tools`) will be placed. + pattern: The pattern which will be modified by subsequent operations. + If `None` (default), a new pattern is created. + ports: Allows specifying the initial set of ports, if `pattern` does + not already have any ports (or is not provided). May be a string, + in which case it is interpreted as a name in `library`. + Default `None` (no ports). + tools: A mapping of {port: tool} which specifies what `Tool` should be used + to generate waveguide or wire segments when `path`/`path_to`/`mpath` + are called. Relies on `Tool.planL` and `Tool.render` implementations. + name: If specified, `library[name]` is set to `self.pattern`. + """ + self._dead = False + self.paths = defaultdict(list) + self.library = library + if pattern is not None: + self.pattern = pattern + else: + self.pattern = Pattern() + + if ports is not None: + if self.pattern.ports: + raise BuildError('Ports supplied for pattern with pre-existing ports!') + if isinstance(ports, str): + if library is None: + raise BuildError('Ports given as a string, but `library` was `None`!') + ports = library.abstract(ports).ports + + self.pattern.ports.update(copy.deepcopy(dict(ports))) + + if name is not None: + if library is None: + raise BuildError('Name was supplied, but no library was given!') + library[name] = self.pattern + + if tools is None: + self.tools = {} + elif isinstance(tools, Tool): + self.tools = {None: tools} + else: + self.tools = dict(tools) + + @classmethod + def interface( + cls: type['RenderPather'], + 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, + ) -> 'RenderPather': + """ + Wrapper for `Pattern.interface()`, which returns a RenderPather instead. + + Args: + source: A collection of ports (e.g. Pattern, Builder, or dict) + from which to create the interface. May be a pattern name if + `library` is provided. + library: Library from which existing patterns should be referenced, + and to which the new one should be added (if named). If not provided, + `source.library` must exist and will be used. + tools: `Tool`s which will be used by the pather for generating new wires + or waveguides (via `path`/`path_to`/`mpath`). + 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 `RenderPather`, with an empty pattern and 2x as many ports as + listed in port_map. + + Raises: + `PortError` if `port_map` contains port names not present in the + current device. + `PortError` if applying the prefixes results in duplicate port + names. + """ + if library is None: + if hasattr(source, 'library') and isinstance(source.library, ILibrary): + library = source.library + else: + raise BuildError('No library provided (and not present in `source.library`') + + 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) + new = RenderPather(library=library, pattern=pat, name=name, tools=tools) + return new + + def plug( + self, + other: Abstract | str, + map_in: dict[str, str], + map_out: dict[str, str | None] | None = None, + *, + mirrored: bool = False, + inherit_name: bool = True, + set_rotation: bool | None = None, + append: bool = False, + ) -> Self: + """ + Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P' + for any affected ports. This separates any future `RenderStep`s on the + same port into a new batch, since the plugged device interferes with drawing. + + Args: + other: An `Abstract`, string, or `Pattern` describing the device to be instatiated. + 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 axis 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`. + append: If `True`, `other` is appended instead of being referenced. + Note that this does not flatten `other`, so its refs will still + be refs (now inside `self`). + + Returns: + self + + Raises: + `PortError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `PortError` if there are any duplicate names after `map_in` and `map_out` + are applied. + `PortError` 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 + + other_tgt: Pattern | Abstract + if isinstance(other, str): + other_tgt = self.library.abstract(other) + if append and isinstance(other, Abstract): + other_tgt = self.library[other.name] + + # get rid of plugged ports + for kk in map_in: + if kk in self.paths: + self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None)) + + plugged = map_in.values() + for name, port in other_tgt.ports.items(): + if name in plugged: + continue + new_name = map_out.get(name, name) if map_out is not None else name + if new_name is not None and new_name in self.paths: + self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) + + self.pattern.plug( + other=other_tgt, + map_in=map_in, + map_out=map_out, + mirrored=mirrored, + inherit_name=inherit_name, + set_rotation=set_rotation, + append=append, + ) + + return self + + def place( + self, + other: Abstract | str, + *, + offset: ArrayLike = (0, 0), + rotation: float = 0, + pivot: ArrayLike = (0, 0), + mirrored: bool = False, + port_map: dict[str, str | None] | None = None, + skip_port_check: bool = False, + append: bool = False, + ) -> Self: + """ + Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P' + for any affected ports. This separates any future `RenderStep`s on the + same port into a new batch, since the placed device interferes with drawing. + + Note that mirroring is applied before rotation; translation (`offset`) is applied last. + + Args: + other: An `Abstract` or `Pattern` describing the device to be instatiated. + offset: Offset at which to place the instance. Default (0, 0). + rotation: Rotation applied to the instance before placement. Default 0. + pivot: Rotation is applied around this pivot point (default (0, 0)). + Rotation is applied prior to translation (`offset`). + mirrored: Whether theinstance should be mirrored across the x axis. + Mirroring is applied before translation and rotation. + port_map: dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in the instantiated pattern. 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. + append: If `True`, `other` is appended instead of being referenced. + Note that this does not flatten `other`, so its refs will still + be refs (now inside `self`). + + Returns: + self + + Raises: + `PortError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other.ports`. + `PortError` 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 + + other_tgt: Pattern | Abstract + if isinstance(other, str): + other_tgt = self.library.abstract(other) + if append and isinstance(other, Abstract): + other_tgt = self.library[other.name] + + for name, port in other_tgt.ports.items(): + new_name = port_map.get(name, name) if port_map is not None else name + if new_name is not None and new_name in self.paths: + self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) + + self.pattern.place( + other=other_tgt, + offset=offset, + rotation=rotation, + pivot=pivot, + mirrored=mirrored, + port_map=port_map, + skip_port_check=skip_port_check, + append=append, + ) + + return self + + def retool( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Self: + """ + Update the `Tool` which will be used when generating `Pattern`s for the ports + given by `keys`. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + 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, + portspec: str, + ccw: SupportsBool | None, + length: float, + **kwargs, + ) -> Self: + """ + Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim + of traveling exactly `length` distance. + + The wire will travel `length` distance along the port's axis, an an unspecified + (tool-dependent) distance in the perpendicular direction. The output port will + be rotated (or not) based on the `ccw` parameter. + + `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec: The name of the port into which the wire will be plugged. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + length: The total distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + + Returns: + self + + Raises: + BuildError if `distance` is too small to fit the bend (if a bend is present). + LibraryError if no valid name could be picked for the pattern. + """ + if self._dead: + logger.error('Skipping path() since device is dead') + return self + + port = self.pattern[portspec] + in_ptype = port.ptype + port_rot = port.rotation + assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? + + tool = self.tools.get(portspec, self.tools[None]) + # ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype + out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) + + # Update port + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + + step = RenderStep('L', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) + + self.pattern.ports[portspec] = out_port.copy() + + return self + + def path_to( + self, + portspec: str, + ccw: SupportsBool | None, + position: float | None = None, + *, + x: float | None = None, + y: float | None = None, + **kwargs, + ) -> Self: + """ + Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim + of ending exactly at a target position. + + The wire will travel so that the output port will be placed at exactly the target + position along the input port's axis. There can be an unspecified (tool-dependent) + offset in the perpendicular direction. The output port will be rotated (or not) + based on the `ccw` parameter. + + `RenderPather.render` must be called after all paths have been fully planned. + + Args: + portspec: The name of the port into which the wire will be plugged. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + position: The final port position, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + Only one of `position`, `x`, and `y` may be specified. + x: The final port position along the x axis. + `portspec` must refer to a horizontal port if `x` is passed, otherwise a + BuildError will be raised. + y: The final port position along the y axis. + `portspec` must refer to a vertical port if `y` is passed, otherwise a + BuildError will be raised. + + Returns: + self + + Raises: + BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend + is present). + BuildError if `x` or `y` is specified but does not match the axis of `portspec`. + BuildError if more than one of `x`, `y`, and `position` is specified. + """ + if self._dead: + logger.error('Skipping path_to() since device is dead') + return self + + pos_count = sum(vv is not None for vv in (position, x, y)) + if pos_count > 1: + raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') + if pos_count < 1: + raise BuildError('One of `position`, `x`, and `y` must be specified') + + port = self.pattern[portspec] + if port.rotation is None: + raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') + + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise BuildError('path_to was asked to route from non-manhattan port') + + is_horizontal = numpy.isclose(port.rotation % pi, 0) + if is_horizontal: + if y is not None: + raise BuildError('Asked to path to y-coordinate, but port is horizontal') + if position is None: + position = x + else: + if x is not None: + raise BuildError('Asked to path to x-coordinate, but port is vertical') + if position is None: + position = y + + x0, y0 = port.offset + if is_horizontal: + if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): + raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') + length = numpy.abs(position - x0) + else: + if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): + raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') + length = numpy.abs(position - y0) + + return self.path(portspec, ccw, length, **kwargs) + + def mpath( + self, + portspec: str | Sequence[str], + ccw: SupportsBool | None, + *, + spacing: float | ArrayLike | None = None, + set_rotation: float | None = None, + **kwargs, + ) -> Self: + """ + `mpath` is a superset of `path` and `path_to` which can act on bundles or buses + of "wires or "waveguides". + + See `Pather.mpath` for details. + + Args: + portspec: The names of the ports which are to be routed. + ccw: If `None`, the outputs should be along the same axis as the inputs. + Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` + and clockwise otherwise. + spacing: Center-to-center distance between output ports along the input port's axis. + Must be provided if (and only if) `ccw` is not `None`. + set_rotation: If the provided ports have `rotation=None`, this can be used + to set a rotation for them. + + Returns: + self + + Raises: + BuildError if the implied length for any wire is too close to fit the bend + (if a bend is requested). + BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not + match the axis of `portspec`. + BuildError if an incorrect bound type or spacing is specified. + """ + if self._dead: + logger.error('Skipping mpath() 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', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): + if bt in kwargs: + bound_types.add(bt) + bound = kwargs[bt] + + if not bound_types: + raise BuildError('No bound type specified for mpath') + if len(bound_types) > 1: + raise BuildError(f'Too many bound types specified for mpath: {bound_types}') + bound_type = tuple(bound_types)[0] + + if isinstance(portspec, str): + portspec = [portspec] + ports = self.pattern[tuple(portspec)] + + extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) + + if len(ports) == 1: + # Not a bus, so having a container just adds noise to the layout + port_name = tuple(portspec)[0] + self.path(port_name, ccw, extensions[port_name]) + else: + for port_name, length in extensions.items(): + self.path(port_name, ccw, length) + return self + + def render( + self, + append: bool = True, + ) -> Self: + """ + Generate the geometry which has been planned out with `path`/`path_to`/etc. + + Args: + append: If `True`, the rendered geometry will be directly appended to + `self.pattern`. Note that it will not be flattened, so if only one + layer of hierarchy is eliminated. + + Returns: + self + """ + lib = self.library + tool_port_names = ('A', 'B') + pat = Pattern() + + def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: + assert batch[0].tool is not None + name = lib << batch[0].tool.render(batch, port_names=tool_port_names) + pat.ports[portspec] = batch[0].start_port.copy() + if append: + pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append) + del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened + else: + pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append) + + for portspec, steps in self.paths.items(): + batch: list[RenderStep] = [] + for step in steps: + appendable_op = step.opcode in ('L', 'S', 'U') + same_tool = batch and step.tool == batch[0].tool + + # If we can't continue a batch, render it + if batch and (not appendable_op or not same_tool): + render_batch(portspec, batch, append) + batch = [] + + # batch is emptied already if we couldn't continue it + if appendable_op: + batch.append(step) + + # Opcodes which break the batch go below this line + if not appendable_op and portspec in pat.ports: + del pat.ports[portspec] + + #If the last batch didn't end yet + if batch: + render_batch(portspec, batch, append) + + self.paths.clear() + pat.ports.clear() + self.pattern.append(pat) + + return self + + def translate(self, offset: ArrayLike) -> Self: + """ + Translate the pattern and all ports. + + Args: + offset: (x, y) distance to translate by + + Returns: + self + """ + self.pattern.translate_elements(offset) + return self + + def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: + """ + Rotate the pattern and all ports. + + Args: + angle: angle (radians, counterclockwise) to rotate by + pivot: location to rotate around + + Returns: + self + """ + self.pattern.rotate_around(pivot, angle) + return self + + def mirror(self, axis: int) -> Self: + """ + Mirror 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) + return self + + def set_dead(self) -> Self: + """ + 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 __repr__(self) -> str: + s = f'' + return s + + 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..ca27c86 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,1744 +1,553 @@ """ -Routing Tool contracts and built-in Tool implementations. +Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) -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. +# TODO document all tools """ -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 Literal, Any +from collections.abc import Sequence, Callable +from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? +from dataclasses import dataclass import numpy from numpy.typing import NDArray from numpy import pi -from ..utils import ( - layer_t, - ptypes_compatible as ptypes_compatible, - rotation_matrix_2d, - ) +from ..utils import SupportsBool, rotation_matrix_2d, layer_t 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. + Representation of a single saved operation, used by `RenderPather` and passed + to `Tool.render()` when `RenderPather.render()` is called. + """ + opcode: Literal['L', 'S', 'U', 'P'] + """ What operation is being performed. + L: planL (straight, optionally with a single bend) + S: planS (s-bend) + U: planU (u-bend) + P: plug """ - kind: RenderStepKind - """Canonical primitive kind, or `plug` for an assembly boundary.""" tool: 'Tool | None' - """Tool that produced this step, or `None` for `kind='plug'`.""" + """ The current tool. May be `None` if `opcode='P'` """ 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, - ) + if self.opcode != 'P' and self.tool is None: + raise BuildError('Got tool=None but the opcode is not "P"') -class Tool(ABC): +class Tool: """ 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. + Note that subclasses may implement only a subset of the methods and leave others + unimplemented (e.g. in cases where they don't make sense or the required components + are impractical or unavailable). """ - @abstractmethod - def primitive_offers( + def path( self, - kind: PrimitiveKind, + ccw: SupportsBool | None, + length: float, *, in_ptype: str | None = None, out_ptype: str | None = None, - **kwargs, - ) -> tuple[PrimitiveOffer, ...]: - """ - Return local primitive offers available for the requested route role. - - 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: + **kwargs, + ) -> Library: """ - Render a compatible batch of selected route steps into geometry. + Create a wire or waveguide that travels exactly `length` distance along the axis + of its input port. - `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. + Used by `Pather`. + + The output port must be exactly `length` away along the input port's axis, but + may be placed an additional (unspecified) distance away along the perpendicular + direction. The output port should be rotated (or not) based on the value of + `ccw`. + + The input and output ports should be compatible with `in_ptype` and + `out_ptype`, respectively. They should also be named `port_names[0]` and + `port_names[1]`, respectively. Args: - batch: A sequence of `RenderStep` objects containing committed - primitive render data. + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + length: The total distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. + out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. + port_names: The output pattern will have its input port named `port_names[0]` and + its output named `port_names[1]`. + kwargs: Custom tool-specific parameters. + + Returns: + A pattern tree containing the requested L-shaped (or straight) wire or waveguide + + Raises: + BuildError if an impossible or unsupported geometry is requested. + """ + raise NotImplementedError(f'path() not implemented for {type(self)}') + + def planL( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, + ) -> tuple[Port, Any]: + """ + Plan a wire or waveguide that travels exactly `length` distance along the axis + of its input port. + + Used by `RenderPather`. + + The output port must be exactly `length` away along the input port's axis, but + may be placed an additional (unspecified) distance away along the perpendicular + direction. The output port should be rotated (or not) based on the value of + `ccw`. + + The input and output ports should be compatible with `in_ptype` and + `out_ptype`, respectively. + + Args: + ccw: If `None`, the output should be along the same axis as the input. + Otherwise, cast to bool and turn counterclockwise if True + and clockwise otherwise. + length: The total distance from input to output, along the input's axis only. + (There may be a tool-dependent offset along the other axis.) + in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. + out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. + kwargs: Custom tool-specific parameters. + + Returns: + The calculated output `Port` for the wire. + Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. + + Raises: + BuildError if an impossible or unsupported geometry is requested. + """ + raise NotImplementedError(f'planL() not implemented for {type(self)}') + + def planS( + self, + length: float, + jog: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, + ) -> tuple[Port, Any]: + """ + Plan a wire or waveguide that travels exactly `length` distance along the axis + of its input port and `jog` distance along the perpendicular axis (i.e. an S-bend). + + Used by `RenderPather`. + + The output port must have an orientation rotated by pi from the input port. + + The input and output ports should be compatible with `in_ptype` and + `out_ptype`, respectively. + + Args: + length: The total distance from input to output, along the input's axis only. + jog: The total offset from the input to output, along the perpendicular axis. + A positive number implies a rightwards shift (i.e. clockwise bend followed + by a counterclockwise bend) + in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. + out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. + kwargs: Custom tool-specific parameters. + + Returns: + The calculated output `Port` for the wire. + Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. + + Raises: + BuildError if an impossible or unsupported geometry is requested. + """ + raise NotImplementedError(f'planS() not implemented for {type(self)}') + + def planU( + self, + jog: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, + ) -> tuple[Port, Any]: + """ + # NOTE: TODO: U-bend is WIP; this interface may change in the future. + + Plan a wire or waveguide that travels exactly `jog` distance along the axis + perpendicular to its input port (i.e. a U-bend). + + Used by `RenderPather`. + + The output port must have an orientation identical to the input port. + + The input and output ports should be compatible with `in_ptype` and + `out_ptype`, respectively. + + Args: + jog: The total offset from the input to output, along the perpendicular axis. + A positive number implies a rightwards shift (i.e. clockwise bend followed + by a counterclockwise bend) + in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. + out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. + kwargs: Custom tool-specific parameters. + + Returns: + The calculated output `Port` for the wire. + Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. + + Raises: + BuildError if an impossible or unsupported geometry is requested. + """ + raise NotImplementedError(f'planU() not implemented for {type(self)}') + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: Sequence[str] = ('A', 'B'), # noqa: ARG002 (unused) + **kwargs, # noqa: ARG002 (unused) + ) -> ILibrary: + """ + Render the provided `batch` of `RenderStep`s into geometry, returning a tree + (a Library with a single topcell). + + Args: + batch: A sequence of `RenderStep` objects containing the ports and data + provided by this tool's `planL`/`planS`/`planU` functions. port_names: The topcell's input and output ports should be named `port_names[0]` and `port_names[1]` respectively. + kwargs: Custom tool-specific parameters. """ - raise NotImplementedError + assert not batch or batch[0].tool == self + raise NotImplementedError(f'render() not implemented for {type(self)}') -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 +abstract_tuple_t = tuple[Abstract, str, str] @dataclass -class AutoTool(Tool): +class BasicTool(Tool, metaclass=ABCMeta): """ - 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. + A simple tool which relies on a single pre-rendered `bend` pattern, a function + for generating straight paths, and a table of pre-rendered `transitions` for converting + from non-native ptypes. """ + straight: tuple[Callable[[float], Pattern], str, str] + """ `create_straight(length: float), in_port_name, out_port_name` """ + + bend: abstract_tuple_t # Assumed to be clockwise + """ `clockwise_bend_abstract, in_port_name, out_port_name` """ + + transitions: dict[str, abstract_tuple_t] + """ `{ptype: (transition_abstract`, ptype_port_name, other_port_name), ...}` """ + + default_out_ptype: str + """ Default value for out_ptype """ @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({})) + class LData: + """ Data for planL """ + straight_length: float + ccw: SupportsBool | None + in_transition: abstract_tuple_t | None + out_transition: abstract_tuple_t | None - @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( + def path( 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, + ccw: SupportsBool | None, + length: float, *, in_ptype: str | None = None, out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), **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 + ) -> Library: + _out_port, data = self.planL( + ccw, + length, + in_ptype=in_ptype, + out_ptype=out_ptype, + ) - def configured(offer: PrimitiveOffer) -> PrimitiveOffer: - if not tool_options: - return offer + gen_straight, sport_in, sport_out = self.straight + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.add_port_pair(names=port_names, ptype=in_ptype) + if data.in_transition: + ipat, iport_theirs, _iport_ours = data.in_transition + pat.plug(ipat, {port_names[1]: iport_theirs}) + if not numpy.isclose(data.straight_length, 0): + straight = tree <= {SINGLE_USE_PREFIX + 'straight': gen_straight(data.straight_length, **kwargs)} + pat.plug(straight, {port_names[1]: sport_in}) + if data.ccw is not None: + bend, bport_in, bport_out = self.bend + pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw)) + if data.out_transition: + opat, oport_theirs, oport_ours = data.out_transition + pat.plug(opat, {port_names[1]: oport_ours}) - 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) + return tree - 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( + def planL( self, - data: GeneratedData, - tree: ILibrary, - port_names: tuple[str, str], - ) -> ILibrary: - if numpy.isclose(data.parameter, 0): - return tree + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, # noqa: ARG002 (unused) + ) -> tuple[Port, LData]: + # TODO check all the math for L-shaped bends + if ccw is not None: + bend, bport_in, bport_out = self.bend - 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) + angle_in = bend.ports[bport_in].rotation + angle_out = bend.ports[bport_out].rotation + assert angle_in is not None + assert angle_out is not None + + bend_dxy = rotation_matrix_2d(-angle_in) @ ( + bend.ports[bport_out].offset + - bend.ports[bport_in].offset + ) + + bend_angle = angle_out - angle_in + + if bool(ccw): + bend_dxy[1] *= -1 + bend_angle *= -1 else: - top = generated.top() - generated.flatten(top, dangling_ok=True) - pat.plug(generated[top], pmap, append=True, mirrored=data.mirrored) - return tree + bend_dxy = numpy.zeros(2) + bend_angle = 0 - 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 + in_transition = self.transitions.get('unk' if in_ptype is None else in_ptype, None) + if in_transition is not None: + ipat, iport_theirs, iport_ours = in_transition + irot = ipat.ports[iport_theirs].rotation + assert irot is not None + itrans_dxy = rotation_matrix_2d(-irot) @ ( + ipat.ports[iport_ours].offset + - ipat.ports[iport_theirs].offset + ) + else: + itrans_dxy = numpy.zeros(2) - 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__}') + out_transition = self.transitions.get('unk' if out_ptype is None else out_ptype, None) + if out_transition is not None: + opat, oport_theirs, oport_ours = out_transition + orot = opat.ports[oport_ours].rotation + assert orot is not None + + otrans_dxy = rotation_matrix_2d(-orot + bend_angle) @ ( + opat.ports[oport_theirs].offset + - opat.ports[oport_ours].offset + ) + else: + otrans_dxy = numpy.zeros(2) + + if out_transition is not None: + out_ptype_actual = opat.ports[oport_theirs].ptype + elif ccw is not None: + out_ptype_actual = bend.ports[bport_out].ptype + else: + out_ptype_actual = self.default_out_ptype + + straight_length = length - bend_dxy[0] - itrans_dxy[0] - otrans_dxy[0] + bend_run = bend_dxy[1] + itrans_dxy[1] + otrans_dxy[1] + + if straight_length < 0: + raise BuildError( + f'Asked to draw path with total length {length:,g}, shorter than required bends and transitions:\n' + f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g} out_trans: {otrans_dxy[0]:,g}' + ) + + data = self.LData(straight_length, ccw, in_transition, out_transition) + out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) + return out_port, data def render( self, batch: Sequence[RenderStep], *, - port_names: tuple[str, str] = ('A', 'B'), + port_names: Sequence[str] = ('A', 'B'), + append: bool = True, + **kwargs, ) -> ILibrary: - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.add_port_pair(names=port_names) + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.add_port_pair(names=(port_names[0], port_names[1])) + gen_straight, sport_in, _sport_out = self.straight for step in batch: + straight_length, ccw, in_transition, out_transition = step.data assert step.tool == self - self._render_data(step.data, tree, port_names) + + if step.opcode == 'L': + if in_transition: + ipat, iport_theirs, _iport_ours = in_transition + pat.plug(ipat, {port_names[1]: iport_theirs}) + if not numpy.isclose(straight_length, 0): + straight_pat = gen_straight(straight_length, **kwargs) + if append: + pat.plug(straight_pat, {port_names[1]: sport_in}, append=True) + else: + straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} + pat.plug(straight, {port_names[1]: sport_in}, append=True) + if ccw is not None: + bend, bport_in, bport_out = self.bend + pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw)) + if out_transition: + opat, oport_theirs, oport_ours = out_transition + pat.plug(opat, {port_names[1]: oport_ours}) return tree @dataclass -class PathTool(Tool): +class PathTool(Tool, metaclass=ABCMeta): """ - Tool that renders routes directly as `Pattern.path()` geometry. + A tool which draws `Path` geometry elements. - `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. + If `planL` / `render` are used, the `Path` elements can cover >2 vertices; + with `path` only individual rectangles will be drawn. """ layer: layer_t - """ Layer to draw generated path geometry on. """ + """ Layer to draw on """ width: float - """ Width of generated path geometry. """ + """ `Path` width """ ptype: str = 'unk' - """ Port type for generated input and output ports. """ + """ ptype for any ports in patterns generated by this tool """ - def _bend_radius(self) -> float: - return self.width / 2 + #@dataclass(frozen=True, slots=True) + #class LData: + # dxy: NDArray[numpy.float64] - 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 __init__(self, layer: layer_t, width: float, ptype: str = 'unk') -> None: + # Tool.__init__(self) + # self.layer = layer + # self.width = width + # self.ptype: str - 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( + def path( self, - kind: PrimitiveKind, + ccw: SupportsBool | None, + length: float, *, 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 () + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, # noqa: ARG002 (unused) + ) -> Library: + out_port, dxy = self.planL( + ccw, + length, + in_ptype=in_ptype, + out_ptype=out_ptype, + ) - if not ptypes_compatible(out_ptype, self.ptype): + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)]) + + if ccw is None: + out_rot = pi + elif bool(ccw): + out_rot = -pi / 2 + else: + out_rot = pi / 2 + + pat.ports = { + port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), + port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype), + } + + return tree + + def planL( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, # noqa: ARG002 (unused) + out_ptype: str | None = None, + **kwargs, # noqa: ARG002 (unused) + ) -> tuple[Port, NDArray[numpy.float64]]: + # TODO check all the math for L-shaped bends + + if out_ptype and 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)) + if ccw is not None: + bend_dxy = numpy.array([1, -1]) * self.width / 2 + bend_angle = pi / 2 - def endpoint_straight(length: float) -> Port: - data = straight_data(length) - return Port(data, rotation=pi, ptype=ptype) + if bool(ccw): + bend_dxy[1] *= -1 + bend_angle *= -1 + else: + bend_dxy = numpy.zeros(2) + bend_angle = pi - 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]))) + straight_length = length - bend_dxy[0] + bend_run = bend_dxy[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}') + if straight_length < 0: + raise BuildError( + f'Asked to draw path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' + ) + data = numpy.array((length, bend_run)) + out_port = Port(data, rotation=bend_angle, ptype=self.ptype) + return out_port, data def render( self, batch: Sequence[RenderStep], *, - port_names: tuple[str, str] = ('A', 'B'), + port_names: Sequence[str] = ('A', 'B'), + **kwargs, # noqa: ARG002 (unused) ) -> 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: + path_vertices = [batch[0].start_port.offset] + for step in 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])) + + if step.opcode == 'L': + length, bend_run = step.data + dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0) + #path_vertices.append(step.start_port.offset) + path_vertices.append(step.start_port.offset + dxy) else: - raise BuildError(f'Unsupported PathTool render kind {step.kind!r}') + raise BuildError(f'Unrecognized opcode "{step.opcode}"') - for vertex in local_vertices[1:]: - path_vertices.append(step.start_port.offset + transform @ vertex) + if (path_vertices[-1] != batch[-1].end_port.offset).any(): + # If the path ends in a bend, we need to add the final vertex + path_vertices.append(batch[-1].end_port.offset) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') 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(), + port_names[0]: batch[0].start_port.copy().rotate(pi), + port_names[1]: batch[-1].end_port.copy().rotate(pi), } return tree diff --git a/masque/builder/utils.py b/masque/builder/utils.py index fab28fd..3109f46 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -1,5 +1,5 @@ -from typing import TYPE_CHECKING -from collections.abc import Mapping +from typing import SupportsFloat, cast, TYPE_CHECKING +from collections.abc import Mapping, Sequence from pprint import pformat import numpy @@ -8,23 +8,11 @@ from numpy.typing import ArrayLike, NDArray from ..utils import rotation_matrix_2d, SupportsBool 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 - - def ell( ports: Mapping[str, 'Port'], ccw: SupportsBool | None, @@ -58,7 +46,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). @@ -76,7 +64,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 +82,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,19 +106,11 @@ 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 + is_horizontal = numpy.isclose(rotations[0] % pi, 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: @@ -164,21 +132,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] @@ -209,34 +164,38 @@ 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]) + rot_bound: SupportsFloat + 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 + offsets += rot_bound.max() elif bound_type in ('emax', 'max_extension'): - offsets += rot_bound - offsets.max() + 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', 'xmax', 'ymax'): extension = rot_bound - min_possible.max() - else: + elif bound_type in ('pmin', 'min_position', 'xmin', 'ymin'): extension = rot_bound - min_possible.min() offsets += extension diff --git a/masque/error.py b/masque/error.py index e475bb0..0e46849 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 @@ -32,64 +25,15 @@ class BuildError(MasqueError): """ pass - class PortError(MasqueError): """ - Exception raised by port-related functions + Exception raised by builder-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..078245b 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -6,21 +6,17 @@ Notes: * 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 Any, cast, TextIO, IO +from collections.abc import Mapping, Callable import io 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 +from ezdxf.entities import LWPolyline, Polyline, Text, Insert from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -28,7 +24,6 @@ from ..library import ILibraryView, LibraryView, Library from ..shapes import Shape, Polygon, Path from ..repetition import Grid from ..utils import rotation_matrix_2d, layer_t, normalize_mirror -from ..utils.boolean import _polytree_to_polygons logger = logging.getLogger(__name__) @@ -60,7 +55,8 @@ def write( tuple: (1, 2) -> '1.2' str: '1.2' -> '1.2' (no change) - Shape repetitions are expanded into individual DXF entities. + DXF does not support shape repetition (only block repeptition). Please call + library.wrap_repeated_shapes() before writing to file. Other functions you may want to call: - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names @@ -178,9 +174,6 @@ 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]]: """ Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are @@ -191,77 +184,19 @@ def read( 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. 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 - } - - 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: + top_name, top_pat = _read_block(msp) + mlib = Library({top_name: top_pat}) + for bb in lib.blocks: + if bb.name == '*Model_Space': 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]) - - 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 - - mlib = Library() - for bb in blocks: - name, pat = _read_block(bb, polyline_mode=polyline_mode, contour_accuracy=contour_accuracy) + name, pat = _read_block(bb) mlib[name] = pat library_info = dict( @@ -271,94 +206,39 @@ def read( return mlib, library_info -def _read_block( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - *, - polyline_mode: int = 2, - contour_accuracy: float = 0.0, - ) -> tuple[str, Pattern]: +def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> tuple[str, Pattern]: name = block.name pat = Pattern() - contours: dict[layer_t, list[numpy.ndarray]] = defaultdict(list) for element in block: if isinstance(element, LWPolyline | Polyline): if isinstance(element, LWPolyline): points = numpy.asarray(element.get_points()) - is_closed = element.closed - else: + elif isinstance(element, Polyline): points = numpy.asarray([pp.xyz for pp in element.points()]) - is_closed = element.is_closed 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?') - if width == 0: - width = attr.get('const_width', 0) + if points.shape[1] > 2: + if (points[0, 2] != points[:, 2]).any(): + raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') + if points.shape[1] == 4 and (points[:, 3] != 0).any(): + raise PatternError('LWPolyLine has bulge (not yet representable in masque!)') - 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 + width = points[0, 2] + if width == 0: + width = attr.get('const_width', 0) - 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) + shape: Path | Polygon + if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]): + shape = Polygon(vertices=points[:-1, :2]) + else: + shape = Path(width=width, vertices=points[:, :2]) 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] - 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], @@ -380,8 +260,7 @@ def _read_block( 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 + rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2] @@ -393,144 +272,19 @@ def _read_block( rotation=rotation, ) - 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) + if 'column_count' in attr: args['repetition'] = Grid( - a_vector=rot @ local_x, b_vector=rot @ local_y, - a_count=col_count, b_count=row_count, + a_vector=(attr['column_spacing'], 0), + b_vector=(0, attr['row_spacing']), + a_count=attr['column_count'], + b_count=attr['row_count'], ) pat.ref(**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 -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]], @@ -549,21 +303,15 @@ def _mrefs_to_drefs( 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): + 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): + 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] @@ -596,23 +344,16 @@ def _shapes_to_elements( 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 + raise PatternError( + 'Shape repetitions are not supported by DXF.' + ' Please call library.wrap_repeated_shapes() before writing to file.' + ) - 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 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( @@ -622,17 +363,11 @@ def _labels_to_texts( 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) + xy = label.offset + block.add_text( + label.string, + dxfattribs=attribs + ).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT) def _mlayer2dxf(layer: layer_t) -> str: @@ -641,5 +376,5 @@ 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)})') diff --git a/masque/file/gdsii/klamath.py b/masque/file/gdsii.py similarity index 60% rename from masque/file/gdsii/klamath.py rename to masque/file/gdsii.py index 1b7a968..c323ecf 100644 --- a/masque/file/gdsii/klamath.py +++ b/masque/file/gdsii.py @@ -19,9 +19,10 @@ Notes: * 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 typing import IO, cast, Any from collections.abc import Iterable, Mapping, Callable -from types import MappingProxyType +import io +import mmap import logging import pathlib import gzip @@ -33,37 +34,77 @@ 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 +from .utils import is_gzipped, tmpfile +from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape +from ..shapes import Polygon, Path +from ..repetition import Grid +from ..utils import layer_t, annotations_t +from ..library import LazyLibrary, Library, ILibrary, ILibraryView logger = logging.getLogger(__name__) -_PATH_CAP_MAP = { +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]: +def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]: return numpy.rint(val).astype(numpy.int32) -def _write_header( +def write( + library: Mapping[str, Pattern], stream: IO[bytes], meters_per_unit: float, - logical_units_per_unit: float, - library_name: str, + logical_units_per_unit: float = 1, + library_name: str = 'masque-klamath', ) -> None: + """ + Convert a library to a GDSII stream, mapping data as follows: + Pattern -> GDSII structure + Ref -> 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` + + GDS does not support shape repetition (only cell repeptition). Please call + `library.wrap_repeated_shapes()` before writing to file. + + Other functions you may want to call: + - `masque.file.gdsii.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` + + Args: + library: A {name: Pattern} mapping of patterns to write. + 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'. + """ + if not isinstance(library, ILibrary): + if isinstance(library, dict): + library = Library(library) + else: + library = Library(dict(library)) + + # Create library header = klamath.library.FileHeader( name=library_name.encode('ASCII'), user_units_per_db_unit=logical_units_per_unit, @@ -71,19 +112,51 @@ def _write_header( ) header.write(stream) + # Now create a structure for each pattern, and add in any Boundary and SREF elements + for name, pat in library.items(): + elements: list[klamath.elements.Element] = [] + elements += _shapes_to_elements(pat.shapes) + elements += _labels_to_texts(pat.labels) + elements += _mrefs_to_grefs(pat.refs) -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: + klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements) records.ENDLIB.write(stream, None) +def writefile( + library: Mapping[str, Pattern], + filename: 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: + library: {name: Pattern} pairs to save. + filename: Filename to save to. + *args: passed to `write()` + **kwargs: passed to `write()` + """ + path = pathlib.Path(filename) + + 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', compresslevel=6)) + streams = (stream,) + streams + else: + stream = base_stream + + try: + write(library, stream, *args, **kwargs) + finally: + for ss in streams: + ss.close() + + def readfile( filename: str | pathlib.Path, *args, @@ -140,7 +213,7 @@ def read( 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) + pat = read_elements(stream, raw_mode=raw_mode) mlib[name.decode('ASCII')] = pat found_struct = records.BGNSTR.skip_past(stream) @@ -160,7 +233,7 @@ def _read_header(stream: IO[bytes]) -> dict[str, Any]: return library_info -def _read_elements( +def read_elements( stream: IO[bytes], raw_mode: bool = True, ) -> Pattern: @@ -242,45 +315,31 @@ def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]: 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] + 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 + mpath = Path( + vertices=gpath.xy.astype(float), + width=gpath.width, + cap=cap, + offset=numpy.zeros(2), + annotations=_properties_to_annotations(gpath.properties), + raw=raw_mode, + ) 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, - ) + mpath.cap_extensions = gpath.extension 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 + return boundary.layer, Polygon( + vertices=boundary.xy[:-1].astype(float), + offset=numpy.zeros(2), + annotations=_properties_to_annotations(boundary.properties), + raw=raw_mode, + ) def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]: @@ -305,7 +364,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R ]) aref = klamath.library.Reference( struct_name=encoded_name, - xy=_rint_cast(xy), + xy=rint_cast(xy), colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)), angle_deg=angle_deg, invert_y=ref.mirrored, @@ -316,7 +375,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R elif rep is None: sref = klamath.library.Reference( struct_name=encoded_name, - xy=_rint_cast([ref.offset]), + xy=rint_cast([ref.offset]), colrow=None, angle_deg=angle_deg, invert_y=ref.mirrored, @@ -328,7 +387,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R new_srefs = [ klamath.library.Reference( struct_name=encoded_name, - xy=_rint_cast([ref.offset + dd]), + xy=rint_cast([ref.offset + dd]), colrow=None, angle_deg=angle_deg, invert_y=ref.mirrored, @@ -340,15 +399,11 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R return grefs -def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t: - if not properties: - return None +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) -> Mapping[int, bytes]: - if annotations is None: - return _EMPTY_PROPERTIES +def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> dict[int, bytes]: cum_len = 0 props = {} for key, vals in annotations.items(): @@ -356,8 +411,8 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) - 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])') + 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() @@ -385,13 +440,13 @@ def _shapes_to_elements( 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 + 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)) + extension = tuple(shape.cap_extensions) # type: ignore else: extension = (0, 0) @@ -404,20 +459,6 @@ def _shapes_to_elements( 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) @@ -449,7 +490,7 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element layer, text_type = _mlayer2gds(mlayer) for label in lseq: properties = _annotations_to_properties(label.annotations, 128) - xy = _rint_cast([label.offset]) + xy = rint_cast([label.offset]) text = klamath.elements.Text( layer=(layer, text_type), xy=xy, @@ -466,6 +507,112 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element return texts +def load_library( + stream: IO[bytes], + *, + full_load: bool = False, + postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None + ) -> tuple[LazyLibrary, 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. + 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. + postprocess: If given, this function is used to post-process each + pattern *upon first load only*. + + Returns: + LazyLibrary object, allowing for deferred load of structures. + Additional library info (dict, same format as from `read`). + """ + stream.seek(0) + lib = LazyLibrary() + + if full_load: + # Full load approach (immediately load everything) + patterns, library_info = read(stream) + for name, pattern in patterns.items(): + if postprocess is not None: + lib[name] = postprocess(lib, name, pattern) + else: + lib[name] = pattern + 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) + pat = read_elements(stream, raw_mode=True) + if postprocess is not None: + pat = postprocess(lib, name, pat) + return pat + + lib[name] = mkstruct + + return lib, library_info + + +def load_libraryfile( + filename: str | pathlib.Path, + *, + use_mmap: bool = True, + full_load: bool = False, + postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None + ) -> tuple[LazyLibrary, 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 + 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`. + postprocess: Passed to `load_library` + + Returns: + LazyLibrary object, allowing for deferred load of structures. + Additional library info (dict, same format as from `read`). + """ + path = pathlib.Path(filename) + stream: IO[bytes] + if is_gzipped(path): + if use_mmap: + logger.info('Asked to mmap a gzipped file, reading into memory instead...') + gz_stream = gzip.open(path, mode='rb') # noqa: SIM115 + stream = io.BytesIO(gz_stream.read()) # type: ignore + else: + gz_stream = gzip.open(path, mode='rb') # noqa: SIM115 + stream = io.BufferedReader(gz_stream) # type: ignore + else: # noqa: PLR5501 + if use_mmap: + base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115 + stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore + else: + stream = path.open(mode='rb') # noqa: SIM115 + return load_library(stream, full_load=full_load, postprocess=postprocess) + + def check_valid_names( names: Iterable[str], max_length: int = 32, @@ -478,7 +625,6 @@ def check_valid_names( max_length: Max allowed length """ - names = tuple(names) allowed_chars = set(string.ascii_letters + string.digits + '_?$') bad_chars = [ @@ -495,7 +641,7 @@ def check_valid_names( 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)) + logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars)) if bad_chars or bad_lengths: raise LibraryError('Library contains invalid names, see log above') 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/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/oasis.py b/masque/file/oasis.py index 67ea644..e64bb4c 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -120,10 +120,10 @@ def build( 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, + nstring=name, + layer_interval=(layer, layer), + type_interval=(data_type, data_type), + is_textlayer=tt, ) for tt in (True, False)] @@ -182,8 +182,8 @@ def writefile( Args: library: A {name: Pattern} mapping of patterns to write. 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) @@ -213,9 +213,9 @@ def readfile( 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): @@ -286,11 +286,11 @@ def read( 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, + vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + annotations=annotations, + repetition=repetition, ) elif isinstance(element, fatrec.Path): vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) @@ -310,13 +310,13 @@ def read( 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, + 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, ) @@ -325,11 +325,11 @@ def read( 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, + 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, ) elif isinstance(element, fatrec.Trapezoid): @@ -440,11 +440,11 @@ def read( else: string = str_or_ref.string pat.label( - layer = element.get_layer_tuple(), - offset = element.get_xy(), - repetition = repetition, - annotations = annotations, - string = string, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + string=string, ) else: @@ -549,36 +549,33 @@ def _shapes_to_elements( 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, + 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_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 = 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, + 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: @@ -586,13 +583,13 @@ def _shapes_to_elements( 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, + layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + point_list=cast('list[list[int]]', points), + properties=properties, + repetition=repetition, )) return elements @@ -609,13 +606,13 @@ def _labels_to_texts( 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, + layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + string=label.string, + properties=properties, + repetition=repetition, )) return texts @@ -625,12 +622,10 @@ def repetition_fata2masq( ) -> Repetition | None: mrep: Repetition | None 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, @@ -652,26 +647,21 @@ def repetition_masq2fata( frep: 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=cast('list[int]', a_vector), + b_vector=cast('list[int] | None', b_vector), + a_count=cast('int', a_count), + b_count=cast('int | None', 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, :]) + offset = rep.displacements[0, :] else: assert rep is None frep = None @@ -681,8 +671,6 @@ def repetition_masq2fata( 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 @@ -717,9 +705,13 @@ 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 + properties = [fatrec.Property(key, vals, is_standard=False) + for key, vals in annotations.items()] + return properties + def check_valid_names( names: Iterable[str], diff --git a/masque/file/svg.py b/masque/file/svg.py index b2782ae..148f6d4 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -2,55 +2,14 @@ SVG file format readers and writers """ from collections.abc import Mapping -import logging +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( @@ -58,15 +17,15 @@ def writefile( top: str, 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 as elements. + Note that this function modifies the Pattern. + If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute is written to the relevant elements. @@ -78,24 +37,20 @@ 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)`). """ - detached = _detached_library(library) - pattern = detached[top] + pattern = library[top] # Polygonize pattern pattern.polygonize() - bounds = pattern.get_bounds(library=detached) + bounds = pattern.get_bounds(library=library) 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', stacklevel=1) else: bounds_min, bounds_max = bounds @@ -105,11 +60,10 @@ 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') + for name, pat in library.items(): + svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red') for layer, shapes in pat.shapes.items(): for shape in shapes: @@ -122,37 +76,16 @@ def writefile( svg_group.add(path) - 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)]) - - # 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: continue for ref in refs: - transform = _ref_to_svg_transform(ref) - use = svg.use(href='#' + svg_ids[target], transform=transform) + transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})' + use = svg.use(href='#' + mangle_name(target), transform=transform) svg_group.add(use) svg.defs.add(svg_group) - svg.add(svg.use(href='#' + svg_ids[top])) + svg.add(svg.use(href='#' + mangle_name(top))) svg.save() @@ -167,24 +100,24 @@ def writefile_inverted( 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] + pattern = library[top] # Polygonize and flatten pattern - pattern.polygonize().flatten(detached) + pattern.polygonize().flatten(library) - bounds = pattern.get_bounds(library=detached) + bounds = pattern.get_bounds(library=library) 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', stacklevel=1) else: bounds_min, bounds_max = bounds diff --git a/masque/file/utils.py b/masque/file/utils.py index dd8ed5c..33f68d4 100644 --- a/masque/file/utils.py +++ b/masque/file/utils.py @@ -14,188 +14,12 @@ 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 ..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: - """ - 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. - - 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, @@ -206,35 +30,33 @@ def preflight( 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. + Run a standard set of useful operations and checks, usually done immediately prior + to writing to a file (or immediately after reading). 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. + sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents. + Default True. Useful for reproducible builds. + sort_elements: Whether to sort the pattern contents. Requires sort=True to run. + 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 instead of a number (or tuple). + prune_empty_patterns: Runs `Library.prune_empty()`, recursively deleting any empty patterns. + wrap_repeated_shapes: Runs `Library.wrap_repeated_shapes()`, turning repeated shapes into + repeated refs containing non-repeated shapes. Returns: - `lib`, or an equivalent name-sorted `Library` when `sort=True`. + `lib` or an equivalent sorted library """ - mutable_lib = lib if sort: - mutable_lib = Library(dict(sorted( - (nn, pp.sort(sort_elements=sort_elements)) for nn, pp in mutable_lib.items() + lib = Library(dict(sorted( + (nn, pp.sort(sort_elements=sort_elements)) for nn, pp in lib.items() ))) if not allow_dangling_refs: - refs = mutable_lib.referenced_patterns() - dangling = refs - set(mutable_lib.keys()) + refs = lib.referenced_patterns() + dangling = refs - set(lib.keys()) if dangling: msg = 'Dangling refs found: ' + pformat(dangling) if allow_dangling_refs is None: @@ -244,7 +66,7 @@ def preflight( if not allow_named_layers: named_layers: Mapping[str, set] = defaultdict(set) - for name, pat in mutable_lib.items(): + for name, pat in lib.items(): for layer in chain(pat.shapes.keys(), pat.labels.keys()): if isinstance(layer, str): named_layers[name].add(layer) @@ -253,8 +75,7 @@ def preflight( 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) + pruned = lib.prune_empty() if pruned: logger.info(f'Preflight pruned {len(pruned)} empty patterns') logger.debug('Pruned: ' + pformat(pruned)) @@ -262,9 +83,9 @@ def preflight( logger.debug('Preflight found no empty patterns') if wrap_repeated_shapes: - mutable_lib.wrap_repeated_shapes() + lib.wrap_repeated_shapes() - return mutable_lib + return lib def mangle_name(name: str) -> str: @@ -323,11 +144,7 @@ def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]: 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 + yield tmp_stream try: shutil.move(tmp_stream.name, path) diff --git a/masque/label.py b/masque/label.py index d220fee..711ef35 100644 --- a/masque/label.py +++ b/masque/label.py @@ -7,12 +7,12 @@ 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 .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded from .traits import AnnotatableImpl @functools.total_ordering -class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable): +class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): """ A text annotation with a position (but no size; it is not drawn) """ @@ -53,36 +53,17 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl self.repetition = repetition self.annotations = annotations if annotations is not None else {} - @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) -> 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: memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() - new._repetition = copy.deepcopy(self._repetition, memo) - new._annotations = copy.deepcopy(self._annotations, memo) return new def __lt__(self, other: 'Label') -> bool: @@ -95,8 +76,6 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl 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) @@ -117,34 +96,10 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl """ pivot = numpy.asarray(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]: """ Return the bounds of the label. diff --git a/masque/library/base.py b/masque/library.py similarity index 60% rename from masque/library/base.py rename to masque/library.py index 422135e..b52da74 100644 --- a/masque/library/base.py +++ b/masque/library.py @@ -1,43 +1,99 @@ -"""Core library interfaces.""" -from __future__ import annotations +""" +Library classes for managing unique name->pattern mappings and deferred loading or execution. -from abc import ABCMeta, abstractmethod -from collections import defaultdict -from collections.abc import Callable, Mapping, MutableMapping, Sequence -from graphlib import CycleError, TopologicalSorter -from pprint import pformat -from typing import TYPE_CHECKING, Self, cast +Classes include: +- `ILibraryView`: Defines a general interface for read-only name->pattern mappings. +- `LibraryView`: An implementation of `ILibraryView` backed by an arbitrary `Mapping`. + Can be used to wrap any arbitrary `Mapping` to give it all the functionality in `ILibraryView` +- `ILibrary`: Defines a general interface for mutable name->pattern mappings. +- `Library`: An implementation of `ILibrary` backed by an arbitrary `MutableMapping`. + Can be used to wrap any arbitrary `MutableMapping` to give it all the functionality in `ILibrary`. + By default, uses a `dict` as the underylingmapping. +- `LazyLibrary`: An implementation of `ILibrary` which enables on-demand loading or generation + of patterns. +- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked + library. Generated with `ILibraryView.abstract_view()`. +""" +from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal +from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable +import logging +import re import copy +from pprint import pformat +from collections import defaultdict +from abc import ABCMeta, abstractmethod +from graphlib import TopologicalSorter import numpy +from numpy.typing import ArrayLike, NDArray -from ..abstract import Abstract -from ..error import LibraryError, PatternError -from ..pattern import Pattern, map_layers -from ..shapes import Polygon, Shape -from ..utils import apply_transforms, layer_t -from .utils import ( - SINGLE_USE_PREFIX, - INameView, - TreeView, - b64suffix, - dangling_mode_t, - _plan_source_names, - _rename_patterns, - _source_rename_map, - _validate_dangling_mode, - visitor_function_t, -) +from .error import LibraryError, PatternError +from .utils import layer_t, apply_transforms +from .shapes import Shape, Polygon +from .label import Label +from .abstract import Abstract +from .pattern import map_layers if TYPE_CHECKING: - from collections.abc import Iterator - - from numpy.typing import ArrayLike, NDArray - - from ..label import Label + from .pattern import Pattern -class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): +logger = logging.getLogger(__name__) + + +class visitor_function_t(Protocol): + """ Signature for `Library.dfs()` visitor functions. """ + def __call__( + self, + pattern: 'Pattern', + hierarchy: tuple[str | None, ...], + memo: dict, + transform: NDArray[numpy.float64] | Literal[False], + ) -> 'Pattern': + ... + + +TreeView: TypeAlias = Mapping[str, 'Pattern'] +""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """ + +Tree: TypeAlias = MutableMapping[str, 'Pattern'] +""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """ + + +SINGLE_USE_PREFIX = '_' +""" +Names starting with this prefix are assumed to refer to single-use patterns, +which may be renamed automatically by `ILibrary.add()` (via +`rename_theirs=_rename_patterns()` ) +""" +# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere? + + +def _rename_patterns(lib: 'ILibraryView', name: str) -> str: + """ + The default `rename_theirs` function for `ILibrary.add`. + + Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as + "one-offs" for which name conflicts should be automatically resolved. + Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)` + where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`. + Names lacking the prefix are directly returned (not renamed). + + Args: + lib: The library into which `name` is to be added (but is presumed to conflict) + name: The original name, to be modified + + Returns: + The new name, not guaranteed to be conflict-free! + """ + if not name.startswith(SINGLE_USE_PREFIX): + return name + + stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0] + return lib.get_name(SINGLE_USE_PREFIX + stem) + + +class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ Interface for a read-only library. @@ -53,7 +109,7 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): def __repr__(self) -> str: return '' - def abstract_view(self) -> AbstractView: + def abstract_view(self) -> 'AbstractView': """ Returns: An AbstractView into this library @@ -72,19 +128,10 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): """ return Abstract(name=name, ports=self[name].ports) - def source_order(self) -> tuple[str, ...]: - """ - Return names in the library's preferred source order. - - Source-backed views may override this to preserve on-disk ordering - without materializing patterns. - """ - return tuple(self.keys()) - def dangling_refs( self, tops: str | Sequence[str] | None = None, - ) -> set[str]: + ) -> set[str | None]: """ Get the set of all pattern names not present in the library but referenced by `tops`, recursively traversing any refs. @@ -94,6 +141,7 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): Args: tops: Name(s) of the pattern(s) to check. Default is all patterns in the library. + skip: Memo, set patterns which have already been traversed. Returns: Set of all referenced pattern names @@ -107,8 +155,8 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): def referenced_patterns( self, tops: str | Sequence[str] | None = None, - skip: set[str] | None = None, - ) -> set[str]: + skip: set[str | None] | None = None, + ) -> set[str | None]: """ Get the set of all pattern names referenced by `tops`. Recursively traverses into any refs. @@ -122,84 +170,33 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): Returns: Set of all referenced pattern names """ - graph = self.child_graph(dangling='include') - return self._referenced_patterns_from_graph(graph, tops=tops, skip=skip) - - def _referenced_patterns_from_graph( - self, - graph: Mapping[str, set[str]], - *, - tops: str | Sequence[str] | None, - skip: set[str] | None = None, - ) -> set[str]: - """Traverse named references in an already-computed child graph.""" - existing = set(self.keys()) if tops is None: - roots = tuple(existing) - elif isinstance(tops, str): - roots = (tops,) - else: - roots = tuple(tops) - - for root in roots: - if root not in existing: - raise KeyError(root) + tops = tuple(self.keys()) if skip is None: - skip = set() - root_set = set(roots) - skip |= root_set + skip = {None} - targets: set[str] = set() - stack = list(root_set) - while stack: - name = stack.pop() - children = graph.get(name, set()) - targets |= children - for child in children - skip: - skip.add(child) - if child in existing: - stack.append(child) - return targets + if isinstance(tops, str): + tops = (tops,) - def _referenced_patterns_by_lookup( - self, - tops: str | Sequence[str] | None = None, - skip: set[str] | None = None, - ) -> set[str]: - """Traverse refs by loading only reachable patterns.""" - if tops is None: - roots = tuple(self.keys()) - elif isinstance(tops, str): - roots = (tops,) - else: - roots = tuple(tops) + # Get referenced patterns for all tops + targets = set() + for top in set(tops): + targets |= self[top].referenced_patterns() - if skip is None: - skip = set() - root_set = set(roots) - skip |= root_set + # Perform recursive lookups, but only once for each name + for target in targets - skip: + assert target is not None + if target in self: + targets |= self.referenced_patterns(target, skip=skip) + skip.add(target) - targets: set[str] = set() - stack = list(root_set) - while stack: - name = stack.pop() - children = { - target - for target in self[name].referenced_patterns() - if target is not None - } - targets |= children - for child in children - skip: - skip.add(child) - if child in self: - stack.append(child) return targets def subtree( self, tops: str | Sequence[str], - ) -> ILibraryView: + ) -> 'ILibraryView': """ Return a new `ILibraryView`, containing only the specified patterns and the patterns they reference (recursively). @@ -209,22 +206,17 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): tops: Name(s) of patterns to keep Returns: - A borrowed view containing only `tops` and the patterns they reference. - - The returned view snapshots membership and hierarchy, but borrows patterns - and source capabilities from this library. Keep this library open and do - not structurally mutate it for the lifetime of the returned view. + A `LibraryView` containing only `tops` and the patterns they reference. """ if isinstance(tops, str): tops = (tops,) - graph = self.child_graph(dangling='include') - keep = self._referenced_patterns_from_graph(graph, tops=tops) - keep &= set(self.keys()) + keep = cast('set[str]', self.referenced_patterns(tops) - {None}) keep |= set(tops) - from .mapping import _SubtreeLibraryView # noqa: PLC0415 - return _SubtreeLibraryView(self, names=keep, child_graph=graph) + filtered = {kk: vv for kk, vv in self.items() if kk in keep} + new = LibraryView(filtered) + return new def polygonize( self, @@ -272,8 +264,7 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): self, tops: str | Sequence[str], flatten_ports: bool = False, - dangling_ok: bool = False, - ) -> dict[str, Pattern]: + ) -> dict[str, 'Pattern']: """ Returns copies of all `tops` patterns with all refs removed and replaced with equivalent shapes. @@ -282,12 +273,9 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): For an in-place variant, see `Pattern.flatten`. Args: - tops: The pattern(s) to flatten. + tops: The pattern(s) to flattern. flatten_ports: If `True`, keep ports from any referenced patterns; otherwise discard them. - dangling_ok: If `True`, no error will be thrown if any - ref points to a name which is not present in the library. - Default False. Returns: {name: flat_pattern} mapping for all flattened patterns. @@ -300,38 +288,26 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): def flatten_single(name: str) -> None: flattened[name] = None pat = self[name].deepcopy() - pat.prune_refs() - refs_by_target = tuple((target, tuple(refs)) for target, refs in pat.refs.items()) - for target, refs in refs_by_target: + for target in pat.refs: if target is None: continue - if dangling_ok and target not in self: - continue if target not in flattened: flatten_single(target) target_pat = flattened[target] if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - ports_only = flatten_ports and bool(target_pat.ports) - if target_pat.is_empty() and not ports_only: # avoid some extra allocations + if target_pat.is_empty(): # avoid some extra allocations continue - for ref in refs: - if flatten_ports and ref.repetition is not None and target_pat.ports: - raise PatternError( - f'Cannot flatten ports from repeated ref to {target!r}; ' - 'flatten with flatten_ports=False or expand/rename the ports manually first.' - ) + for ref in pat.refs[target]: p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() pat.append(p) - for target in set(pat.refs.keys()) & set(self.keys()): - del pat.refs[target] - + pat.refs.clear() flattened[name] = pat for top in tops: @@ -340,6 +316,59 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): assert None not in flattened.values() return cast('dict[str, Pattern]', flattened) + def get_name( + self, + name: str = SINGLE_USE_PREFIX * 2, + sanitize: bool = True, + max_length: int = 32, + quiet: bool | None = None, + ) -> str: + """ + Find a unique name for the pattern. + + This function may be overridden in a subclass or monkey-patched to fit the caller's requirements. + + Args: + name: Preferred name for the pattern. Default is `SINGLE_USE_PREFIX * 2`. + sanitize: Allows only alphanumeric charaters and _?$. Replaces invalid characters with underscores. + max_length: Names longer than this will be truncated. + quiet: If `True`, suppress log messages. Default `None` suppresses messages only if + the name starts with `SINGLE_USE_PREFIX`. + + Returns: + Name, unique within this library. + """ + if quiet is None: + quiet = name.startswith(SINGLE_USE_PREFIX) + + if sanitize: + # Remove invalid characters + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', name) + else: + sanitized_name = name + + suffixed_name = sanitized_name + if sanitized_name in self: + ii = sum(1 for nn in self.keys() if nn.startswith(sanitized_name)) + else: + ii = 0 + while suffixed_name in self or suffixed_name == '': + suffixed_name = sanitized_name + b64suffix(ii) + ii += 1 + + if len(suffixed_name) > max_length: + if name == '': + raise LibraryError(f'No valid pattern names remaining within the specified {max_length=}') + + cropped_name = self.get_name(sanitized_name[:-1], sanitize=sanitize, max_length=max_length, quiet=True) + else: + cropped_name = suffixed_name + + if not quiet: + logger.info(f'Requested name "{name}" changed to "{cropped_name}"') + + return cropped_name + def tops(self) -> list[str]: """ Return the list of all patterns that are not referenced by any other pattern in the library. @@ -347,10 +376,13 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): Returns: A list of pattern names in which no pattern is referenced by any other pattern. """ - graph = self.child_graph(dangling='ignore') names = set(self.keys()) - referenced = set().union(*graph.values()) if graph else set() - return list(names - referenced) + not_toplevel: set[str | None] = set() + for name in names: + not_toplevel |= set(self[name].refs.keys()) + + toplevel = list(names - not_toplevel) + return toplevel def top(self) -> str: """ @@ -364,7 +396,7 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}') return tops[0] - def top_pattern(self) -> Pattern: + def top_pattern(self) -> 'Pattern': """ Shorthand for self[self.top()] @@ -373,32 +405,9 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): """ return self[self.top()] - @staticmethod - def _dangling_refs_error(dangling: set[str], context: str) -> LibraryError: - dangling_list = sorted(dangling) - return LibraryError(f'Dangling refs found while {context}: ' + pformat(dangling_list)) - - def _raw_children(self, name: str) -> set[str]: - """Return direct, non-empty named references for one pattern.""" - return { - child - for child, refs in self[name].refs.items() - if child is not None and refs - } - - def _raw_child_graph(self) -> tuple[dict[str, set[str]], set[str]]: - existing = set(self.keys()) - graph: dict[str, set[str]] = {} - dangling: set[str] = set() - for name in self: - children = self._raw_children(name) - graph[name] = children - dangling |= children - existing - return graph, dangling - def dfs( self, - pattern: Pattern, + pattern: 'Pattern', visit_before: visitor_function_t | None = None, visit_after: visitor_function_t | None = None, *, @@ -423,7 +432,7 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): tuple of all parent-and-higher pattern names. Top pattern name may be `None` if not provided in first call to .dfs() `transform`: numpy.ndarray containing cumulative - [x_offset, y_offset, rotation (rad), mirror_x (0 or 1), scale] + [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)] for the instance being visited `memo`: Arbitrary dict (not altered except by `visit_before()` and `visit_after()`) @@ -450,24 +459,22 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): memo = {} if transform is None or transform is True: - transform = numpy.array([0, 0, 0, 0, 1], dtype=float) + transform = numpy.zeros(4) elif transform is not False: transform = numpy.asarray(transform, dtype=float) - if transform.size == 4: - transform = numpy.append(transform, 1.0) original_pattern = pattern if visit_before is not None: pattern = visit_before(pattern, hierarchy=hierarchy, memo=memo, transform=transform) - for target, refs in pattern.refs.items(): - if target is None or not refs: + for target in pattern.refs: + if target is None: continue if target in hierarchy: raise LibraryError(f'.dfs() called on pattern with circular reference to "{target}"') - for ref in refs: + for ref in pattern.refs[target]: ref_transforms: list[bool] | NDArray[numpy.float64] if transform is not False: ref_transforms = apply_transforms(transform, ref.as_transforms()) @@ -476,12 +483,12 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): for ref_transform in ref_transforms: self.dfs( - pattern = self[target], - visit_before = visit_before, - visit_after = visit_after, - hierarchy = hierarchy + (target,), - transform = ref_transform, - memo = memo, + pattern=self[target], + visit_before=visit_before, + visit_after=visit_after, + hierarchy=hierarchy + (target,), + transform=ref_transform, + memo=memo, ) if visit_after is not None: @@ -497,106 +504,50 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): raise LibraryError('visit_* functions returned a new `Pattern` object' ' but no top-level name was provided in `hierarchy`') - del cast('ILibrary', self)[name] cast('ILibrary', self)[name] = pattern return self - def child_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: + def child_graph(self) -> dict[str, set[str | None]]: """ Return a mapping from pattern name to a set of all child patterns (patterns it references). - Only non-empty ref lists with non-`None` targets are treated as graph edges. - - Args: - dangling: How refs to missing targets are handled. `'error'` raises, - `'ignore'` drops those edges, and `'include'` exposes them as - synthetic leaf nodes. - Returns: Mapping from pattern name to a set of all pattern names it references. """ - _validate_dangling_mode(dangling) - graph, dangling_refs = self._raw_child_graph() - if dangling == 'error': - if dangling_refs: - raise self._dangling_refs_error(dangling_refs, 'building child graph') - return graph - if dangling == 'ignore': - existing = set(graph) - return {name: {child for child in children if child in existing} for name, children in graph.items()} - - for target in dangling_refs: - graph.setdefault(target, set()) + graph = {name: set(pat.refs.keys()) for name, pat in self.items()} return graph - def parent_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: + def parent_graph(self) -> dict[str, set[str]]: """ Return a mapping from pattern name to a set of all parent patterns (patterns which reference it). - Args: - dangling: How refs to missing targets are handled. `'error'` raises, - `'ignore'` drops those targets, and `'include'` adds them as - synthetic keys whose values are their existing parents. - Returns: Mapping from pattern name to a set of all patterns which reference it. """ - _validate_dangling_mode(dangling) - graph_mode: dangling_mode_t = 'ignore' if dangling == 'ignore' else 'include' - child_graph = self.child_graph(dangling=graph_mode) - existing = set(self.keys()) - dangling_refs = set(child_graph) - existing - if dangling == 'error' and dangling_refs: - raise self._dangling_refs_error(dangling_refs, 'building parent graph') - - graph_names = set(child_graph) if dangling == 'include' else existing - igraph: dict[str, set[str]] = {name: set() for name in graph_names} - for parent in existing: - children = child_graph.get(parent, set()) - for child in children: - if child in existing: - igraph[child].add(parent) - elif dangling == 'include': - igraph.setdefault(child, set()).add(parent) + igraph: dict[str, set[str]] = {name: set() for name in self} + for name, pat in self.items(): + for child, reflist in pat.refs.items(): + if reflist and child is not None: + igraph[child].add(name) return igraph - def child_order( - self, - dangling: dangling_mode_t = 'error', - ) -> list[str]: + def child_order(self) -> list[str]: """ - Return a topologically sorted list of graph node names. + Return a topologically sorted list of all contained pattern names. Child (referenced) patterns will appear before their parents. - Args: - dangling: Passed to `child_graph()`. - Return: Topologically sorted list of pattern names. """ - _validate_dangling_mode(dangling) - try: - return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order())) - except CycleError as exc: - cycle = exc.args[1] if len(exc.args) > 1 else None - if cycle is None: - raise LibraryError('Cycle found while building child order') from exc - raise LibraryError(f'Cycle found while building child order: {cycle}') from exc + return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order())) def find_refs_local( self, name: str, parent_graph: dict[str, set[str]] | None = None, - dangling: dangling_mode_t = 'error', ) -> dict[str, list[NDArray[numpy.float64]]]: """ Find the location and orientation of all refs pointing to `name`. @@ -609,49 +560,28 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): The provided graph may be for a superset of `self` (i.e. it may contain additional patterns which are not present in self; they will be ignored). - dangling: How refs to missing targets are handled if `parent_graph` - is not provided. `'include'` also allows querying missing names. Returns: Mapping of {parent_name: transform_list}, where transform_list - is an Nx5 ndarray with rows - `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x, scale)`. + is an Nx4 ndarray with rows + `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. """ - _validate_dangling_mode(dangling) instances = defaultdict(list) if parent_graph is None: - graph_mode = 'ignore' if dangling == 'ignore' else 'include' - parent_graph = self.parent_graph(dangling=graph_mode) - - if name not in self: - if name not in parent_graph: - return instances - if dangling == 'error': - raise self._dangling_refs_error({name}, f'finding local refs for {name!r}') - if dangling == 'ignore': - return instances - - for parent in parent_graph.get(name, set()): + parent_graph = self.parent_graph() + for parent in parent_graph[name]: if parent not in self: # parent_graph may be a for a superset of self continue - instances[parent].extend(self._raw_ref_transforms(parent, name)) + for ref in self[parent].refs[name]: + instances[parent].append(ref.as_transforms()) return instances - def _raw_ref_transforms( - self, - parent: str, - target: str, - ) -> list[NDArray[numpy.float64]]: - """Return local transforms from one parent to one target.""" - return [ref.as_transforms() for ref in self[parent].refs.get(target, ())] - def find_refs_global( self, name: str, order: list[str] | None = None, parent_graph: dict[str, set[str]] | None = None, - dangling: dangling_mode_t = 'error', ) -> dict[tuple[str, ...], NDArray[numpy.float64]]: """ Find the absolute (top-level) location and orientation of all refs (including @@ -668,29 +598,18 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): The provided graph may be for a superset of `self` (i.e. it may contain additional patterns which are not present in self; they will be ignored). - dangling: How refs to missing targets are handled if `order` or - `parent_graph` are not provided. `'include'` also allows - querying missing names. Returns: Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form - `(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx5 ndarray - with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x, scale)`. + `(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray + with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. """ - _validate_dangling_mode(dangling) - graph_mode = 'ignore' if dangling == 'ignore' else 'include' - if order is None: - order = self.child_order(dangling=graph_mode) - if parent_graph is None: - parent_graph = self.parent_graph(dangling=graph_mode) - if name not in self: - if name not in parent_graph: - return {} - if dangling == 'error': - raise self._dangling_refs_error({name}, f'finding global refs for {name!r}') - if dangling == 'ignore': - return {} + return {} + if order is None: + order = self.child_order() + if parent_graph is None: + parent_graph = self.parent_graph() self_keys = set(self.keys()) @@ -699,16 +618,16 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): NDArray[numpy.float64] ]]] transforms = defaultdict(list) - for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items(): + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items(): transforms[parent] = [((name,), numpy.concatenate(vals))] for next_name in order: if next_name not in transforms: continue - if not parent_graph.get(next_name, set()) & self_keys: + if not parent_graph[next_name] & self_keys: continue - outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) + outers = self.find_refs_local(next_name, parent_graph=parent_graph) inners = transforms.pop(next_name) for parent, outer in outers.items(): for path, inner in inners: @@ -727,15 +646,11 @@ class ILibraryView(Mapping[str, 'Pattern'], INameView, metaclass=ABCMeta): -class ILibrary(ILibraryView, metaclass=ABCMeta): +class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): """ - Interface for an insertable and deletable library. + Interface for a writeable library. A library is a mapping from unique names (str) to collections of geometry (`Pattern`). - Assignment inserts new names but does not replace existing ones. `ILibrary` - intentionally does not implement `MutableMapping` or its generic mutation - helpers; use the library-specific insertion, deletion, rename, and add - operations instead. """ # inherited abstract functions #def __getitem__(self, key: str) -> 'Pattern': @@ -748,7 +663,7 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): def __setitem__( self, key: str, - value: Pattern | Callable[[], Pattern], + value: 'Pattern | Callable[[], Pattern]', ) -> None: pass @@ -757,36 +672,9 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): pass @abstractmethod - def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: + def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: pass - def resolve( - self, - other: Abstract | str | Pattern | TreeView, - append: bool = False, - ) -> Abstract | Pattern: - """ - Resolve another device (name, Abstract, Pattern, or TreeView) into an Abstract or Pattern. - If it is a TreeView, it is first added into this library. - - Args: - other: The device to resolve. - append: If True and `other` is an `Abstract`, returns the full `Pattern` from the library. - - Returns: - An `Abstract` or `Pattern` object. - """ - from ..pattern import Pattern # noqa: PLC0415 - if not isinstance(other, str | Abstract | Pattern): - # We got a TreeView; add it into self and grab its topcell as an Abstract - other = self << other - - if isinstance(other, str): - other = self.abstract(other) - if append and isinstance(other, Abstract): - other = self[other.name] - return other - def rename( self, old_name: str, @@ -805,11 +693,6 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): Returns: self """ - if old_name not in self: - raise LibraryError(f'"{old_name}" does not exist in the library.') - if old_name == new_name: - return self - self[new_name] = self[old_name] del self[old_name] if move_references: @@ -834,9 +717,6 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): Returns: self """ - if old_target == new_target: - return self - for pattern in self.values(): if old_target in pattern.refs: pattern.refs[new_target].extend(pattern.refs[old_target]) @@ -865,7 +745,7 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): pattern.labels = map_layers(pattern.labels, map_layer) return self - def mkpat(self, name: str) -> tuple[str, Pattern]: + def mkpat(self, name: str) -> tuple[str, 'Pattern']: """ Convenience method to create an empty pattern, add it to the library, and return both the pattern and name. @@ -876,85 +756,78 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): Returns: (name, pattern) tuple """ - from ..pattern import Pattern # noqa: PLC0415 + from .pattern import Pattern pat = Pattern() self[name] = pat return name, pat def add( self, - other: Mapping[str, Pattern], - rename_theirs: Callable[[INameView, str], str] = _rename_patterns, + other: Mapping[str, 'Pattern'], + rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns, mutate_other: bool = False, ) -> dict[str, str]: """ Add items from another library into this one. - If any name in `other` is already present in the prospective destination, - `rename_theirs(view, name)` is called to pick a new name for the newly-added pattern. - The name-only `INameView` includes destination names and names reserved earlier in - the same addition plan. - If the new name still conflicts, a `LibraryError` is raised. All references to the - original name within `other` are updated to the new name. + If any name in `other` is already present in `self`, `rename_theirs(self, name)` is called + to pick a new name for the newly-added pattern. If the new name still conflicts with a name + in `self` a `LibraryError` is raised. All references to the original name (within `other)` + are updated to the new name. If `mutate_other=False` (default), all changes are made to a deepcopy of `other`. - Name resolution, copying, and reference remapping complete before cells are inserted, - so failures during those phases do not partially modify either library. Custom - implementations of `_merge()` may still have partial effects if they raise while cells - are being committed. - By default, `rename_theirs` makes no changes to the name (causing a `LibraryError`) unless the name starts with `SINGLE_USE_PREFIX`. Prefixed names are truncated to before their first non-prefix '$' and then passed to `self.get_name()` to create a new unique name. Args: other: The library to insert keys from. - rename_theirs: Called as `rename_theirs(view, name)` for each duplicate name - encountered in `other`, where `view` supports membership, iteration, - `len()`, and `get_name()`, but not pattern lookup. Should return the new - name for the pattern in `other`. See above for default behavior. + rename_theirs: Called as rename_theirs(self, name) for each duplicate name + encountered in `other`. Should return the new name for the pattern in + `other`. See above for default behavior. mutate_other: If `True`, modify the original library and its contained patterns (e.g. when renaming patterns and updating refs). Otherwise, operate on a deepcopy (default). Returns: - A mapping of `{old_name: new_name}` for all names in `other` which were - renamed while being added. Unchanged names are omitted. + A mapping of `{old_name: new_name}` for all `old_name`s in `other`. Unchanged + names map to themselves. Raises: `LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`. """ - from ..pattern import map_targets # noqa: PLC0415 - from .mapping import Library # noqa: PLC0415 + from .pattern import map_targets + duplicates = set(self.keys()) & set(other.keys()) - source_order = tuple(other.keys()) - source_to_visible = _plan_source_names( - self, - source_order, - rename_theirs = rename_theirs, - rename_when = 'conflict', - ) - rename_map = _source_rename_map(source_to_visible) + if not duplicates: + for key in other: + self._merge(key, other, key) + return {} if mutate_other: if isinstance(other, Library): temp = other else: - temp = Library({name: other[name] for name in source_order}) + temp = Library(dict(other)) else: - temp = Library(copy.deepcopy({name: other[name] for name in source_order})) + temp = Library(copy.deepcopy(dict(other))) + rename_map = {} + for old_name in temp: + if old_name in self: + new_name = rename_theirs(self, old_name) + if new_name in self: + raise LibraryError(f'Unresolved duplicate key encountered in library merge: {old_name} -> {new_name}') + rename_map[old_name] = new_name + else: + new_name = old_name - if rename_map: - target_map = cast('dict[str | None, str | None]', rename_map) - remapped_refs = { - source_name: map_targets(temp[source_name].refs, lambda target: target_map.get(target, target)) - for source_name in source_order - } - for source_name, refs in remapped_refs.items(): - temp[source_name].refs = refs + self._merge(new_name, temp, old_name) - for source_name in source_order: - self._merge(source_to_visible[source_name], temp, source_name) + # Update references in the newly-added cells + for old_name in temp: + new_name = rename_map.get(old_name, old_name) + pat = self[new_name] + pat.refs = map_targets(pat.refs, lambda tt: cast('dict[str | None, str | None]', rename_map).get(tt, tt)) return rename_map @@ -965,30 +838,31 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): based on `add()`'s default `rename_theirs` argument). Raises: - LibraryError if there is not exactly one topcell in `other`. + LibraryError if there is more than one topcell in `other`. """ - from .mapping import LibraryView # noqa: PLC0415 + if len(other) == 1: + name = next(iter(other)) + else: + if not isinstance(other, ILibraryView): + other = LibraryView(other) - if not isinstance(other, ILibraryView): - other = LibraryView(other) + tops = other.tops() + if len(tops) > 1: + raise LibraryError('Received a library containing multiple topcells!') - tops = other.tops() - if len(tops) != 1: - raise LibraryError(f'Received a library without exactly one topcell: {pformat(tops)}') - - name = tops[0] + name = tops[0] rename_map = self.add(other) new_name = rename_map.get(name, name) return new_name - def __le__(self, other: Mapping[str, Pattern]) -> Abstract: + def __le__(self, other: Mapping[str, 'Pattern']) -> Abstract: """ Perform the same operation as `__lshift__` / `<<`, but return an `Abstract` instead of just the pattern's name. Raises: - LibraryError if there is not exactly one topcell in `other`. + LibraryError if there is more than one topcell in `other`. """ new_name = self << other return self.abstract(new_name) @@ -1028,7 +902,7 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): # This currently simplifies globally (same shape in different patterns is # merged into the same ref target). - from ..pattern import Pattern # noqa: PLC0415 + from .pattern import Pattern if exclude_types is None: exclude_types = () @@ -1037,18 +911,6 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): def label2name(label: tuple) -> str: # noqa: ARG001 return self.get_name(SINGLE_USE_PREFIX + 'shape') - used_names = set(self.keys()) - - def reserve_target_name(label: tuple) -> str: - base_name = label2name(label) - name = base_name - ii = sum(1 for nn in used_names if nn.startswith(base_name)) if base_name in used_names else 0 - while name in used_names or name == '': - name = base_name + b64suffix(ii) - ii += 1 - used_names.add(name) - return name - shape_counts: MutableMapping[tuple, int] = defaultdict(int) shape_funcs = {} @@ -1065,7 +927,6 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): shape_counts[label] += 1 shape_pats = {} - target_names = {} for label, count in shape_counts.items(): if count < threshold: continue @@ -1074,7 +935,6 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): shape_pat = Pattern() shape_pat.shapes[label[-1]] += [shape_func()] shape_pats[label] = shape_pat - target_names[label] = reserve_target_name(label) # ## Second pass ## for pat in tuple(self.values()): @@ -1099,14 +959,14 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): # For repeated shapes, create a `Pattern` holding a normalized shape object, # and add `pat.refs` entries for each occurrence in pat. Also, note down that # we should delete the `pat.shapes` entries for which we made `Ref`s. + shapes_to_remove = [] for label, shape_entries in shape_table.items(): layer = label[-1] - target = target_names[label] - shapes_to_remove = [] + target = label2name(label) for ii, values in shape_entries: offset, scale, rotation, mirror_x = values pat.ref(target=target, offset=offset, scale=scale, - rotation=rotation, mirrored=mirror_x) + rotation=rotation, mirrored=(mirror_x, False)) shapes_to_remove.append(ii) # Remove any shapes for which we have created refs. @@ -1114,13 +974,13 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): del pat.shapes[layer][ii] for ll, pp in shape_pats.items(): - self[target_names[ll]] = pp + self[label2name(ll)] = pp return self def wrap_repeated_shapes( self, - name_func: Callable[[Pattern, Shape | Label], str] | None = None, + name_func: Callable[['Pattern', Shape | Label], str] | None = None, ) -> Self: """ Wraps all shapes and labels with a non-`None` `repetition` attribute @@ -1135,7 +995,7 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): Returns: self """ - from ..pattern import Pattern # noqa: PLC0415 + from .pattern import Pattern if name_func is None: def name_func(_pat: Pattern, _shape: Shape | Label) -> str: @@ -1169,25 +1029,6 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): return self - def resolve_repeated_refs(self, name: str | None = None) -> Self: - """ - Expand all repeated references into multiple individual references. - Alters the library in-place. - - Args: - name: If specified, only resolve repeated refs in this pattern. - Otherwise, resolve in all patterns. - - Returns: - self - """ - if name is not None: - self[name].resolve_repeated_refs() - else: - for pat in self.values(): - pat.resolve_repeated_refs() - return self - def subtree( self, tops: str | Sequence[str], @@ -1206,7 +1047,7 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): if isinstance(tops, str): tops = (tops,) - keep = self.referenced_patterns(tops) + keep = cast('set[str]', self.referenced_patterns(tops) - {None}) keep |= set(tops) new = type(self)() @@ -1217,20 +1058,17 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): def prune_empty( self, repeat: bool = True, - dangling: dangling_mode_t = 'error', ) -> set[str]: """ Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`). Args: repeat: Also recursively delete any patterns which only contain(ed) empty patterns. - dangling: Passed to `parent_graph()`. Returns: A set containing the names of all deleted patterns """ - _validate_dangling_mode(dangling) - parent_graph = self.parent_graph(dangling=dangling) + parent_graph = self.parent_graph() empty = {name for name, pat in self.items() if pat.is_empty()} trimmed = set() while empty: @@ -1267,6 +1105,246 @@ class ILibrary(ILibraryView, metaclass=ABCMeta): del pat.refs[key] return self + +class LibraryView(ILibraryView): + """ + Default implementation for a read-only library. + + A library is a mapping from unique names (str) to collections of geometry (`Pattern`). + This library is backed by an arbitrary python object which implements the `Mapping` interface. + """ + mapping: Mapping[str, 'Pattern'] + + def __init__( + self, + mapping: Mapping[str, 'Pattern'], + ) -> None: + self.mapping = mapping + + def __getitem__(self, key: str) -> 'Pattern': + return self.mapping[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.mapping) + + def __len__(self) -> int: + return len(self.mapping) + + def __contains__(self, key: object) -> bool: + return key in self.mapping + + def __repr__(self) -> str: + return f'' + + +class Library(ILibrary): + """ + Default implementation for a writeable library. + + A library is a mapping from unique names (str) to collections of geometry (`Pattern`). + This library is backed by an arbitrary python object which implements the `MutableMapping` interface. + """ + mapping: MutableMapping[str, 'Pattern'] + + def __init__( + self, + mapping: MutableMapping[str, 'Pattern'] | None = None, + ) -> None: + if mapping is None: + self.mapping = {} + else: + self.mapping = mapping + + def __getitem__(self, key: str) -> 'Pattern': + return self.mapping[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.mapping) + + def __len__(self) -> int: + return len(self.mapping) + + def __contains__(self, key: object) -> bool: + return key in self.mapping + + def __setitem__( + self, + key: str, + value: 'Pattern | Callable[[], Pattern]', + ) -> None: + if key in self.mapping: + raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') + + value = value() if callable(value) else value + self.mapping[key] = value + + def __delitem__(self, key: str) -> None: + del self.mapping[key] + + def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: + self[key_self] = other[key_other] + + def __repr__(self) -> str: + return f'' + + @classmethod + def mktree(cls: type[Self], name: str) -> tuple[Self, 'Pattern']: + """ + Create a new Library and immediately add a pattern + + Args: + name: The name for the new pattern (usually the name of the topcell). + + Returns: + The newly created `Library` and the newly created `Pattern` + """ + from .pattern import Pattern + tree = cls() + pat = Pattern() + tree[name] = pat + return tree, pat + + +class LazyLibrary(ILibrary): + """ + This class is usually used to create a library of Patterns by mapping names to + functions which generate or load the relevant `Pattern` object as-needed. + + TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid? + """ + mapping: dict[str, Callable[[], 'Pattern']] + cache: dict[str, 'Pattern'] + _lookups_in_progress: set[str] + + def __init__(self) -> None: + self.mapping = {} + self.cache = {} + self._lookups_in_progress = set() + + def __setitem__( + self, + key: str, + value: 'Pattern | Callable[[], Pattern]', + ) -> None: + if key in self.mapping: + raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') + + if callable(value): + value_func = value + else: + value_func = lambda: cast('Pattern', value) # noqa: E731 + + self.mapping[key] = value_func + if key in self.cache: + del self.cache[key] + + def __delitem__(self, key: str) -> None: + del self.mapping[key] + if key in self.cache: + del self.cache[key] + + def __getitem__(self, key: str) -> 'Pattern': + logger.debug(f'loading {key}') + if key in self.cache: + logger.debug(f'found {key} in cache') + return self.cache[key] + + if key in self._lookups_in_progress: + raise LibraryError( + f'Detected multiple simultaneous lookups of "{key}".\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.' # TODO give advice on finding cycles + ) + + self._lookups_in_progress.add(key) + func = self.mapping[key] + pat = func() + self._lookups_in_progress.remove(key) + self.cache[key] = pat + return pat + + def __iter__(self) -> Iterator[str]: + return iter(self.mapping) + + def __len__(self) -> int: + return len(self.mapping) + + def __contains__(self, key: object) -> bool: + return key in self.mapping + + def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: + if isinstance(other, LazyLibrary): + self.mapping[key_self] = other.mapping[key_other] + if key_other in other.cache: + self.cache[key_self] = other.cache[key_other] + else: + self[key_self] = other[key_other] + + def __repr__(self) -> str: + return '' + + def rename( + self, + old_name: str, + new_name: str, + move_references: bool = False, + ) -> Self: + """ + Rename a pattern. + + Args: + old_name: Current name for the pattern + new_name: New name for the pattern + move_references: Whether to scan all refs in the pattern and + move them to point to `new_name` as necessary. + Default `False`. + + Returns: + self + """ + self[new_name] = self.mapping[old_name] # copy over function + if old_name in self.cache: + self.cache[new_name] = self.cache[old_name] + del self[old_name] + + if move_references: + self.move_references(old_name, new_name) + + return self + + def move_references(self, old_target: str, new_target: str) -> Self: + """ + Change all references pointing at `old_target` into references pointing at `new_target`. + + Args: + old_target: Current reference target + new_target: New target for the reference + + Returns: + self + """ + self.precache() + for pattern in self.cache.values(): + if old_target in pattern.refs: + pattern.refs[new_target].extend(pattern.refs[old_target]) + del pattern.refs[old_target] + return self + + def precache(self) -> Self: + """ + Force all patterns into the cache + + Returns: + self + """ + for key in self.mapping: + _ = self[key] # want to trigger our own __getitem__ + return self + + def __deepcopy__(self, memo: dict | None = None) -> 'LazyLibrary': + raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)') + + class AbstractView(Mapping[str, Abstract]): """ A read-only mapping from names to `Abstract` objects. @@ -1287,5 +1365,19 @@ class AbstractView(Mapping[str, Abstract]): def __len__(self) -> int: return self.library.__len__() - def __contains__(self, key: object) -> bool: - return key in self.library + +def b64suffix(ii: int) -> str: + """ + Turn an integer into a base64-equivalent suffix. + + This could be done with base64.b64encode, but this way is faster for many small `ii`. + """ + def i2a(nn: int) -> str: + return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn] + + parts = ['$', i2a(ii % 64)] + ii >>= 6 + while ii: + parts.append(i2a(ii % 64)) + ii >>= 6 + return ''.join(parts) diff --git a/masque/library/__init__.py b/masque/library/__init__.py deleted file mode 100644 index a781938..0000000 --- a/masque/library/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Library classes for managing name-to-pattern mappings.""" -from .utils import ( - INameView as INameView, - SINGLE_USE_PREFIX as SINGLE_USE_PREFIX, - Tree as Tree, - TreeView as TreeView, - b64suffix as b64suffix, - dangling_mode_t as dangling_mode_t, - visitor_function_t as visitor_function_t, -) -from .base import ( - AbstractView as AbstractView, - ILibrary as ILibrary, - ILibraryView as ILibraryView, -) -from .capabilities import ( - IBorrowing as IBorrowing, - IMaterializable as IMaterializable, -) -from .mapping import ( - Library as Library, - LibraryView as LibraryView, -) -from .overlay import ( - OverlayLibrary as OverlayLibrary, - PortLoadView as PortLoadView, - LayerMappedView as LayerMappedView, -) -from .build import ( - LibraryBuilder as LibraryBuilder, - BuildReport as BuildReport, - CellProvenance as CellProvenance, - cell as cell, -) -from .lazy import LazyLibrary as LazyLibrary diff --git a/masque/library/build.py b/masque/library/build.py deleted file mode 100644 index 1681bc1..0000000 --- a/masque/library/build.py +++ /dev/null @@ -1,1052 +0,0 @@ -"""Two-phase build library implementation.""" -from __future__ import annotations - -from collections import defaultdict -from collections.abc import Mapping -from dataclasses import dataclass, replace -from functools import wraps -from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, Self, cast -import copy - -from ..error import BuildError, LibraryError -from .base import ILibrary, ILibraryView -from .capabilities import IMaterializable -from .utils import INameView, TreeView, dangling_mode_t, _plan_source_names, _rename_patterns, _source_rename_map -from .mapping import Library, LibraryView -from .overlay import OverlayLibrary - -if TYPE_CHECKING: - from collections.abc import Callable, Iterator, KeysView, Sequence - - from ..pattern import Pattern - - -@dataclass(frozen=True) -class CellProvenance: - """ - Provenance record for one cell in a completed build output. - - Each output name in a `BuildReport` maps to one `CellProvenance`. The - record captures both where the cell came from and how its visible name was - chosen. - - Attributes: - requested_name: First name requested for this cell during the build. - kind: Whether the cell came from a declaration, helper emission, or an - imported source library. - owner_declared_name: Declared cell responsible for this output cell, if - any. Imported source cells leave this as `None`. - build_chain: Declared-cell dependency chain that was active when the - cell was emitted. - """ - requested_name: str - kind: Literal['declared', 'helper', 'source'] - owner_declared_name: str | None - build_chain: tuple[str, ...] - - -@dataclass(frozen=True) -class BuildReport: - """ - Immutable summary of one `LibraryBuilder.validate()` or `.build()` run. - - The report is designed to answer two questions after a build completes: - which declared cells depended on which other declared cells, and where each - output cell came from. - - Attributes: - requested_roots: Roots explicitly requested for the run. A full - `build()` uses all declared cells. - provenance: Mapping from final output name to provenance metadata. - dependency_graph: Declared-cell dependency graph discovered through - library-mediated reads and explicit recipe hints. - """ - requested_roots: tuple[str, ...] - provenance: Mapping[str, CellProvenance] - dependency_graph: Mapping[str, frozenset[str]] - - def __post_init__(self) -> None: - object.__setattr__(self, 'requested_roots', tuple(self.requested_roots)) - object.__setattr__(self, 'provenance', MappingProxyType(dict(self.provenance))) - object.__setattr__( - self, - 'dependency_graph', - MappingProxyType({name: frozenset(deps) for name, deps in self.dependency_graph.items()}), - ) - -@dataclass -class _BuildRecipe: - """ Captured deferred call to a pattern or tree factory. """ - func: Callable[..., Pattern | TreeView] - args: tuple[Any, ...] - kwargs: dict[str, Any] - explicit_dependencies: tuple[str, ...] = () - - def depends_on(self, *names: str) -> _BuildRecipe: - self.explicit_dependencies += tuple(names) - return self - - -@dataclass(frozen=True) -class _BuildDeclaration: - """One deferred or direct declaration plus its implementation transform.""" - value: Pattern | TreeView | _BuildRecipe - postprocess: Callable[[Pattern], Pattern] | None = None - - -def cell(func: Callable[..., Pattern | TreeView]) -> Callable[..., _BuildRecipe]: - """ - Wrap a plain Pattern or single-top tree factory so calls return deferred build recipes. - - Use as either `cell(fn)(...)` or `@cell`. - """ - @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> _BuildRecipe: - return _BuildRecipe(func=func, args=args, kwargs=kwargs) - - return wrapper - - -class _LibraryPlaceholder: - """Identity token replaced by this builder's active build-session library.""" - __slots__ = ('_builder',) - - def __init__(self, builder: LibraryBuilder) -> None: - self._builder = builder - - def __repr__(self) -> str: - return '' - - -def _identity_name(name: str) -> str: - return name - - -def _make_facade( - library: ILibrary, - target: str, - transform: Callable[[Pattern], Pattern], - ) -> Pattern: - from ..pattern import Pattern # noqa: PLC0415 - - implementation = library[target] - proxy = Pattern(ports=implementation.ports).ref(target) - result = transform(proxy) - if not isinstance(result, Pattern): - raise BuildError(f'Facade transform returned {type(result).__name__}, expected Pattern') - return result - - -class BuildCellsView: - """ - Attribute-based declaration namespace for `LibraryBuilder`. - - This is the ergonomic authoring surface exposed as `builder.cells`. It is - intentionally write-focused: attribute or item assignment and deletion - register declarations, while reads fail with guidance to build first and - use the returned library. - - `prefixed()` and `renamed()` create independent write-through views over - the same builder. Their optional `postprocess` callable transforms the - implementation Pattern. If a name changes, their optional `facade` - callable transforms a generated Pattern with copied ports and a reference - to the implementation. - - Example: - `cells = builder.cells.prefixed(prefix, facade=encode_ports)` - `cells.device = cell(make_device)(width=10)` - `cells[generated_name] = cell(make_device)(width=20)` - """ - __slots__ = ('_facade', '_library', '_postprocess', '_rename') - - def __init__( - self, - library: LibraryBuilder, - *, - rename: Callable[[str], str] = _identity_name, - facade: Callable[[Pattern], Pattern] | None = None, - postprocess: Callable[[Pattern], Pattern] | None = None, - ) -> None: - object.__setattr__(self, '_library', library) - object.__setattr__(self, '_rename', rename) - object.__setattr__(self, '_facade', facade) - object.__setattr__(self, '_postprocess', postprocess) - - def prefixed( - self, - prefix: str | None, - *, - facade: Callable[[Pattern], Pattern] | None = None, - postprocess: Callable[[Pattern], Pattern] | None = None, - ) -> BuildCellsView: - """ - Return an independent write-through view which conditionally prefixes names. - - Names which already start with a non-empty `prefix` are left unchanged. - `None` and the empty string select the identity naming policy. - - Args: - prefix: Prefix for implementation names. - facade: Transform applied to a generated logical-name proxy when - prefixing changes a name. - postprocess: Transform applied to the implementation Pattern on - each validation or build. - """ - if prefix is not None and not isinstance(prefix, str): - raise TypeError('Build cell prefixes must be strings or None.') - self._validate_transform('facade', facade) - self._validate_transform('postprocess', postprocess) - - def add_prefix(name: str) -> str: - if not prefix or name.startswith(prefix): - return name - return prefix + name - - return BuildCellsView( - self._library, - rename=add_prefix, - facade=facade, - postprocess=postprocess, - ) - - def renamed( - self, - rename: Callable[[str], str], - *, - facade: Callable[[Pattern], Pattern] | None = None, - postprocess: Callable[[Pattern], Pattern] | None = None, - ) -> BuildCellsView: - """ - Return an independent write-through view using `rename(name)` for output names. - - `rename` must be deterministic and return a string. `facade` and - `postprocess` have the same behavior as in `prefixed()`. - """ - if not callable(rename): - raise TypeError('Build cell name transforms must be callable.') - self._validate_transform('facade', facade) - self._validate_transform('postprocess', postprocess) - return BuildCellsView( - self._library, - rename=rename, - facade=facade, - postprocess=postprocess, - ) - - @staticmethod - def _validate_transform( - description: str, - transform: Callable[[Pattern], Pattern] | None, - ) -> None: - if transform is not None and not callable(transform): - raise TypeError(f'Build cell {description} transforms must be callable or None.') - - def _physical_name(self, name: str) -> str: - if not isinstance(name, str): - raise TypeError('Build cell names must be strings.') - physical_name = self._rename(name) - if not isinstance(physical_name, str): - raise TypeError( - f'Build cell name transform returned {type(physical_name).__name__}, expected str.' - ) - return physical_name - - def __getattr__(self, name: str) -> Pattern: - raise BuildError( - f'LibraryBuilder.cells.{name} is write-only during authoring. ' - 'Call build() and index the returned library instead.' - ) - - def __getitem__(self, name: str) -> Pattern: - raise BuildError( - f'LibraryBuilder.cells[{name!r}] is write-only during authoring. ' - 'Call build() and index the returned library instead.' - ) - - def __setitem__( - self, - name: str, - value: Pattern | TreeView | _BuildRecipe, - ) -> None: - self._library._assert_editable() - physical_name = self._physical_name(name) - self._library._register_view_declaration( - logical_name=name, - physical_name=physical_name, - value=value, - facade=self._facade, - postprocess=self._postprocess, - ) - - def __delitem__(self, name: str) -> None: - self._library._assert_editable() - physical_name = self._physical_name(name) - if physical_name != name: - emitted = [physical_name] - if self._facade is not None: - emitted.append(name) - raise BuildError( - f'Cannot delete renamed build declaration {name!r} through this view; ' - f'it emitted {emitted}. Delete concrete names through builder.cells[...] instead.' - ) - del self._library[name] - - def __setattr__(self, name: str, value: Pattern | TreeView | _BuildRecipe) -> None: - self[name] = value - - def __delattr__(self, name: str) -> None: - if name in self.__slots__: - raise AttributeError(name) - del self[name] - - -class LibraryBuilder(INameView): - """ - Two-phase declaration surface for mixed imported/generated libraries. - - A `LibraryBuilder` collects three kinds of inputs: - - direct declared `Pattern` objects or single-top trees - - deferred Pattern or tree recipes created with `cell(...)` - - imported source-backed library views added with `add_source(...)` - - The builder itself is not a normal readable library during authoring. - Instead, `validate()` and `build()` create a temporary build-session library - that recipes can read from and write helper cells into while dependencies - are resolved. `build()` then freezes the builder on success and returns the - built library plus a `BuildReport`. - """ - - def __init__(self) -> None: - self.cells = BuildCellsView(self) - self._library_placeholder = _LibraryPlaceholder(self) - self._frozen = False - self._building = False - self._declarations: dict[str, _BuildDeclaration] = {} - self._sources: list[tuple[ILibraryView, dict[str, str]]] = [] - self._names: dict[str, None] = {} - - @property - def library(self) -> _LibraryPlaceholder: - """Read-only placeholder replaced by the active library in direct recipe arguments.""" - return self._library_placeholder - - def _assert_editable(self) -> None: - if self._frozen: - raise BuildError('This LibraryBuilder has already been built successfully and is now frozen.') - if self._building: - raise BuildError('Cannot modify a LibraryBuilder while validate() or build() is running.') - - def __iter__(self) -> Iterator[str]: - return iter(self._names) - - def __len__(self) -> int: - return len(self._names) - - def __contains__(self, key: object) -> bool: - return key in self._names - - def keys(self) -> KeysView[str]: - return self._names.keys() - - def __setitem__( - self, - key: str, - value: Pattern | TreeView | _BuildRecipe, - ) -> None: - self._register_declarations(((key, value, None),)) - - def _prepare_declaration( - self, - value: Pattern | TreeView | _BuildRecipe, - postprocess: Callable[[Pattern], Pattern] | None, - ) -> _BuildDeclaration: - from ..pattern import Pattern # noqa: PLC0415 - - if isinstance(value, _BuildRecipe): - placeholders = ( - arg for arg in (*value.args, *value.kwargs.values()) - if isinstance(arg, _LibraryPlaceholder) - ) - if any(placeholder is not self.library for placeholder in placeholders): - raise BuildError('A recipe cannot use another LibraryBuilder.library placeholder.') - elif callable(value): - raise TypeError('LibraryBuilder recipes must be wrapped with cell(fn)(...) or @cell.') - elif not isinstance(value, Pattern | Mapping): - raise TypeError( - f'LibraryBuilder declarations must be a Pattern, tree, or cell recipe, not {type(value).__name__}.' - ) - return _BuildDeclaration(value=value, postprocess=postprocess) - - def _register_declarations( - self, - entries: Sequence[tuple[str, Pattern | TreeView | _BuildRecipe, Callable[[Pattern], Pattern] | None]], - ) -> None: - self._assert_editable() - entry_names = [name for name, _value, _postprocess in entries] - if any(not isinstance(name, str) for name in entry_names): - raise TypeError('Build cell names must be strings.') - if len(set(entry_names)) != len(entry_names): - raise LibraryError(f'Duplicate names in build declaration: {entry_names}') - conflicts = [name for name in entry_names if name in self._names] - if conflicts: - raise LibraryError(f'Build declaration names already exist: {conflicts}') - - prepared = [ - (name, self._prepare_declaration(value, postprocess)) - for name, value, postprocess in entries - ] - for name, declaration in prepared: - self._declarations[name] = declaration - self._names[name] = None - - def _register_view_declaration( - self, - *, - logical_name: str, - physical_name: str, - value: Pattern | TreeView | _BuildRecipe, - facade: Callable[[Pattern], Pattern] | None, - postprocess: Callable[[Pattern], Pattern] | None, - ) -> None: - entries: list[tuple[str, Pattern | TreeView | _BuildRecipe, Callable[[Pattern], Pattern] | None]] = [ - (physical_name, value, postprocess), - ] - if physical_name != logical_name and facade is not None: - facade_recipe = _BuildRecipe( - func=_make_facade, - args=(self.library, physical_name, facade), - kwargs={}, - explicit_dependencies=(physical_name,), - ) - entries.append((logical_name, facade_recipe, None)) - self._register_declarations(entries) - - def __delitem__(self, key: str) -> None: - self._assert_editable() - if key not in self._declarations: - raise KeyError(key) - del self._declarations[key] - del self._names[key] - - def add( - self, - other: Mapping[str, Pattern], - rename_theirs: Callable[[INameView, str], str] = _rename_patterns, - mutate_other: bool = False, - ) -> dict[str, str]: - from ..pattern import map_targets # noqa: PLC0415 - - self._assert_editable() - - materializable = isinstance(other, IMaterializable) - if materializable: - if mutate_other: - raise BuildError('LibraryBuilder.add(..., mutate_other=True) is not supported for source-backed inputs.') - return self.add_source( - other, - rename_theirs = rename_theirs, - rename_when = 'conflict', - ) - - source_order = tuple(other.keys()) - source_to_visible = _plan_source_names( - self, - source_order, - rename_theirs = rename_theirs, - rename_when = 'conflict', - ) - rename_map = _source_rename_map(source_to_visible) - - if mutate_other: - temp = other - else: - temp = Library(copy.deepcopy({name: other[name] for name in source_order})) - - if rename_map: - target_map = cast('dict[str | None, str | None]', rename_map) - remapped_refs = { - source_name: map_targets(temp[source_name].refs, lambda target: target_map.get(target, target)) - for source_name in source_order - } - for source_name, refs in remapped_refs.items(): - temp[source_name].refs = refs - - for source_name in source_order: - visible_name = source_to_visible[source_name] - pattern = temp[source_name] - self[visible_name] = pattern - - return rename_map - - def __lshift__(self, other: TreeView) -> str: - self._assert_editable() - view = other if isinstance(other, ILibraryView) else LibraryView(other) - tops = view.tops() - if len(tops) != 1: - raise LibraryError(f'Received a library without exactly one topcell: {tops}') - top = tops[0] - rename_map = self.add(view) - return rename_map.get(top, top) - - def add_source( - self, - source: Mapping[str, Pattern] | ILibraryView, - *, - rename_theirs: Callable[[INameView, str], str] | None = _rename_patterns, - rename_when: Literal['conflict', 'always'] = 'conflict', - ) -> dict[str, str]: - """ - Register an imported source-backed library with the builder. - - The source is not materialized immediately. Its names are scanned once - to reserve visible builder names, then the source is read again when a - build session starts. The source's cell membership must not be - closed or structurally mutated between `add_source()` and - `build()`/`validate()`. - Source cells may be renamed on entry to avoid collisions with existing - declarations or other imported sources. - - Sources are borrowed rather than owned. An output built with - `output='overlay'` remains source-backed, so its sources must also stay - open and unchanged for the lifetime of that output. - - Args: - rename_theirs: Function used to choose visible names for imported - source cells. Its `INameView` argument contains existing and - previously reserved names, but does not support pattern lookup. - By default, conflicting single-use names are made unique; - pass `None` to reject every conflict. - rename_when: If `'conflict'`, only conflicting names are renamed. - If `'always'`, every imported source name is passed through - `rename_theirs`. - - Returns: - Mapping of `{source_name: visible_name}` for imported names that - were renamed while being added. - """ - self._assert_editable() - - view = source if isinstance(source, ILibraryView) else LibraryView(source) - source_order = tuple(view.source_order()) - source_to_visible = _plan_source_names( - self, - source_order, - rename_theirs = rename_theirs, - rename_when = rename_when, - ) - - self._sources.append((view, dict(source_to_visible))) - for source_name in source_order: - visible = source_to_visible[source_name] - self._names[visible] = None - return _source_rename_map(source_to_visible) - - def validate( - self, - names: str | Sequence[str] | None = None, - *, - allow_dangling: bool = False, - ) -> BuildReport: - """ - Run the full build logic and return a `BuildReport` without producing output. - - This is a dry run over the same dependency resolution and recipe - execution path used by `build()`. Any generated library is discarded - after validation completes. - - Args: - names: Declared root name or names to validate. `None` validates - every declaration. Duplicate names are ignored after their - first occurrence. - allow_dangling: If `False`, fail validation when the generated - library contains dangling references. - """ - _session, report = self._run_build(names=names, allow_dangling=allow_dangling) - return report - - def build( - self, - *, - output: Literal['overlay', 'library'] = 'overlay', - allow_dangling: bool = False, - ) -> tuple[ILibrary, BuildReport]: - """ - Materialize declarations and return a usable output library plus report. - - Args: - output: `'overlay'` preserves imported source-backed cells where - possible and continues borrowing their sources. `'library'` - eagerly materializes the full result and no longer needs the - sources afterward. - allow_dangling: If `False`, fail the build when the completed - library still contains dangling references. - """ - if output not in ('overlay', 'library'): - raise ValueError(f'Unknown build output mode: {output!r}') - if self._building: - raise BuildError( - 'Cannot call build() or validate() recursively on a LibraryBuilder ' - 'from one of its own recipes.' - ) - self._assert_editable() - session, report = self._run_build(names=None, allow_dangling=allow_dangling) - if output == 'library': - built_output = session.to_library() - else: - built_output = session.to_overlay() - self._frozen = True - return built_output, report - - def _run_build( - self, - *, - names: str | Sequence[str] | None, - allow_dangling: bool, - ) -> tuple[_BuildSessionLibrary, BuildReport]: - if self._building: - raise BuildError( - 'Cannot call build() or validate() recursively on a LibraryBuilder ' - 'from one of its own recipes.' - ) - - if names is None: - requested_names = tuple(self._declarations) - elif isinstance(names, str): - requested_names = (names,) - else: - requested_names = tuple(names) - if any(not isinstance(name, str) for name in requested_names): - raise TypeError('Build roots must be strings.') - - roots = tuple(dict.fromkeys(requested_names)) - unknown = [name for name in roots if name not in self._names] - if unknown: - raise BuildError(f'Unknown build roots requested: {unknown}') - - self._building = True - try: - session = _BuildSessionLibrary(self) - session.materialize_many(roots) - if not allow_dangling: - session.child_graph(dangling='error') - finally: - self._building = False - - report = session.build_report(roots) - return session, report - - -class _NamesWithout(INameView): - """Name view which temporarily makes one reserved declaration available.""" - - def __init__(self, names: INameView, omitted: str) -> None: - self._names = names - self._omitted = omitted - - def __iter__(self) -> Iterator[str]: - return (name for name in self._names if name != self._omitted) - - def __len__(self) -> int: - return len(self._names) - int(self._omitted in self._names) - - def __contains__(self, key: object) -> bool: - return key != self._omitted and key in self._names - - -class _BuildSessionLibrary(ILibrary): - """ - Internal overlay-backed library used while a `LibraryBuilder` is executing. - - This object provides the mutable-library surface that recipes expect while - also tracking declared-cell dependencies, helper-cell provenance, and - imported source cells. It exists only for the duration of a validation or - build run. - """ - - def __init__(self, builder: LibraryBuilder) -> None: - self._builder = builder - self._overlay = OverlayLibrary() - self._built: set[str] = set() - self._declared_stack: list[str] = [] - self._names = dict(builder._names) - self._provenance: dict[str, CellProvenance] = {} - self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set) - self._install_sources() - - def _install_sources(self) -> None: - for source_library, source_to_visible in self._builder._sources: - source_order = source_library.source_order() - expected_names = set(source_to_visible) - actual_names = set(source_order) - if actual_names != expected_names: - added_names = sorted(actual_names - expected_names) - removed_names = sorted(expected_names - actual_names) - detail = [] - if added_names: - detail.append(f'added={added_names}') - if removed_names: - detail.append(f'removed={removed_names}') - raise BuildError( - 'Imported source library changed after add_source() was called ' - f'({", ".join(detail)}). ' - 'Do not structurally mutate source libraries between add_source() and build()/validate().' - ) - - def rename_source(_lib: INameView, name: str, *, mapping: Mapping[str, str] = source_to_visible) -> str: - return mapping[name] - - self._overlay.add_source( - source_library, - rename_theirs = rename_source, - rename_when = 'always', - ) - - for source_name in source_order: - visible_name = source_to_visible[source_name] - self._provenance[visible_name] = CellProvenance( - requested_name = source_name, - kind = 'source', - owner_declared_name = None, - build_chain = (), - ) - - def __iter__(self) -> Iterator[str]: - return iter(self._names) - - def __len__(self) -> int: - return len(self._names) - - def __contains__(self, key: object) -> bool: - return key in self._names - - def _current_declared(self) -> str | None: - if not self._declared_stack: - return None - return self._declared_stack[-1] - - def _record_dependency(self, target: str) -> None: - current = self._current_declared() - if current is None or current == target or target not in self._builder._declarations: - return - self._dependency_graph[current].add(target) - - def _guard_mutable_output_name(self, key: str, *, operation: str) -> None: - if key in self._builder._declarations: - raise BuildError(f'Cannot {operation} declared build cell "{key}" during an active build session.') - - provenance = self._provenance.get(key) - if provenance is not None and provenance.kind == 'source': - raise BuildError(f'Cannot {operation} imported source cell "{key}" during an active build session.') - - def rename( - self, - old_name: str, - new_name: str, - move_references: bool = False, - ) -> Self: - if old_name == new_name: - return self - if old_name not in self._overlay: - if old_name in self._builder._declarations: - self._guard_mutable_output_name(old_name, operation='rename') - raise LibraryError(f'"{old_name}" does not exist in the library.') - - self._guard_mutable_output_name(old_name, operation='rename') - if new_name in self._names: - raise LibraryError(f'"{new_name}" already exists in the library.') - - self._overlay.rename(old_name, new_name, move_references=move_references) - self._names = { - new_name if name == old_name else name: None - for name in self._names - } - - provenance = self._provenance.pop(old_name) - self._provenance[new_name] = provenance - return self - - def __getitem__(self, key: str) -> Pattern: - if key not in self._names: - raise KeyError(key) - if key in self._builder._declarations: - self._record_dependency(key) - self._ensure_declared(key) - return self._overlay[key] - - def __setitem__( - self, - key: str, - value: Pattern | Callable[[], Pattern], - ) -> None: - if key in self._overlay: - raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') - current = self._current_declared() - if key in self._builder._declarations and key != current: - raise LibraryError(f'"{key}" is reserved for a declared cell and cannot be used as a helper name.') - - pattern = value() if callable(value) else value - self._overlay[key] = pattern - self._names.setdefault(key, None) - - kind: Literal['declared', 'helper'] - if current is not None and key == current: - kind = 'declared' - else: - kind = 'helper' - - self._provenance[key] = CellProvenance( - requested_name = key, - kind = kind, - owner_declared_name = current if kind == 'helper' else key, - build_chain = tuple(self._declared_stack), - ) - - def __delitem__(self, key: str) -> None: - if key not in self._overlay: - if key in self._builder._declarations: - self._guard_mutable_output_name(key, operation='delete') - raise KeyError(key) - - self._guard_mutable_output_name(key, operation='delete') - if key in self._overlay: - del self._overlay[key] - self._names.pop(key, None) - self._provenance.pop(key, None) - - def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: - self[key_self] = copy.deepcopy(other[key_other]) - - def add( - self, - other: Mapping[str, Pattern], - rename_theirs: Callable[[INameView, str], str] = _rename_patterns, - mutate_other: bool = False, - ) -> dict[str, str]: - rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other) - - current = self._current_declared() - for old_name, new_name in rename_map.items(): - if new_name in self._provenance: - self._provenance[new_name] = replace( - self._provenance[new_name], - requested_name = old_name, - owner_declared_name = current if current is not None else self._provenance[new_name].owner_declared_name, - ) - return rename_map - - def _wrap_error(self, name: str, exc: Exception) -> BuildError: - chain = tuple(self._declared_stack) - msg = [f'Failed while building declared cell "{name}"'] - if chain: - msg.append(f'Dependency chain: {" -> ".join(chain)}') - msg.append(f'Cause: {exc}') - return BuildError('\n'.join(msg)) - - def _ensure_named(self, name: str) -> None: - if name in self._builder._declarations: - self._record_dependency(name) - self._ensure_declared(name) - return - if name in self._overlay: - return - raise BuildError(f'Missing dependency "{name}"') - - def _apply_postprocess( - self, - name: str, - pattern: Pattern, - postprocess: Callable[[Pattern], Pattern] | None, - ) -> Pattern: - from ..pattern import Pattern # noqa: PLC0415 - - if postprocess is None: - return pattern - result = postprocess(pattern) - if not isinstance(result, Pattern): - raise BuildError( - f'Postprocess transform for "{name}" returned {type(result).__name__}, expected Pattern' - ) - return result - - def _commit_declared_tree( - self, - name: str, - tree: TreeView, - postprocess: Callable[[Pattern], Pattern] | None, - ) -> Pattern: - from ..pattern import map_targets # noqa: PLC0415 - - view = tree if isinstance(tree, ILibraryView) else LibraryView(tree) - source_order = tuple(view.source_order()) - temp = Library(copy.deepcopy({source_name: view[source_name] for source_name in source_order})) - tops = temp.tops() - if len(tops) != 1: - raise BuildError(f'Tree declaration for "{name}" must have exactly one topcell, found {tops}') - - old_top = tops[0] - source_order = (old_top, *(source_name for source_name in temp if source_name != old_top)) - - def rename_tree_source(names: INameView, source_name: str) -> str: - if source_name == old_top: - return name - if source_name in names: - return _rename_patterns(names, source_name) - return source_name - - source_to_visible = _plan_source_names( - _NamesWithout(self, name), - source_order, - rename_theirs=rename_tree_source, - rename_when='always', - ) - temp.mapping[old_top] = self._apply_postprocess(name, temp[old_top], postprocess) - rename_map = _source_rename_map(source_to_visible) - if rename_map: - target_map = cast('dict[str | None, str | None]', rename_map) - remapped_refs = { - source_name: map_targets(temp[source_name].refs, lambda target: target_map.get(target, target)) - for source_name in source_order - } - for source_name, refs in remapped_refs.items(): - temp[source_name].refs = refs - - for source_name in source_order: - visible_name = source_to_visible[source_name] - self[visible_name] = temp[source_name] - if source_name not in (old_top, visible_name): - self._provenance[visible_name] = replace( - self._provenance[visible_name], - requested_name=source_name, - ) - return temp[old_top] - - def _ensure_declared(self, name: str) -> None: - from ..pattern import Pattern # noqa: PLC0415 - - if name in self._built: - return - if name in self._declared_stack: - chain = ' -> '.join(self._declared_stack + [name]) - raise BuildError(f'Cycle detected while building declared cells: {chain}') - - declaration = self._builder._declarations[name] - value = declaration.value - self._declared_stack.append(name) - try: - if isinstance(value, _BuildRecipe): - for dep in value.explicit_dependencies: - self._ensure_named(dep) - args = tuple( - self if arg is self._builder.library else arg - for arg in value.args - ) - kwargs = { - key: self if arg_value is self._builder.library else arg_value - for key, arg_value in value.kwargs.items() - } - result = value.func(*args, **kwargs) - else: - result = value.deepcopy() if isinstance(value, Pattern) else value - - if isinstance(result, Pattern): - published_pattern = result - pattern = self._apply_postprocess(name, result, declaration.postprocess) - elif isinstance(result, Mapping): - pattern = self._commit_declared_tree(name, result, declaration.postprocess) - published_pattern = pattern - else: - raise BuildError( # noqa: TRY301 - f'Recipe for "{name}" returned {type(result).__name__}, expected Pattern or single-top tree' - ) - - if name in self._overlay: - existing = self._overlay[name] - if existing is not published_pattern: - raise BuildError( # noqa: TRY301 - f'Recipe for "{name}" wrote a different pattern into the session under its own name.' - ) - if existing is not pattern: - del self._overlay[name] - self._overlay[name] = pattern - else: - self[name] = pattern - self._built.add(name) - except Exception as exc: - raise self._wrap_error(name, exc) from exc - finally: - self._declared_stack.pop() - - def materialize_many(self, names: Sequence[str]) -> None: - for name in dict.fromkeys(names): - self._ensure_named(name) - - def source_order(self) -> tuple[str, ...]: - return self._overlay.source_order() - - def child_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: - return self._overlay.child_graph(dangling=dangling) - - def parent_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: - return self._overlay.parent_graph(dangling=dangling) - - def subtree( - self, - tops: str | Sequence[str], - ) -> Self: - if isinstance(tops, str): - tops = (tops,) - - keep = self._referenced_patterns_by_lookup(tops=tops) - keep &= set(self) - keep |= set(tops) - order = tuple(name for name in self._names if name in keep) - - patterns = {name: self[name] for name in order} - new = object.__new__(type(self)) - new._builder = self._builder - new._overlay = OverlayLibrary() - for name in order: - new._overlay[name] = patterns[name] - new._built = { - name for name in order - if name in self._builder._declarations - } - new._declared_stack = [] - new._names = dict.fromkeys(order) - new._provenance = { - name: self._provenance[name] - for name in order - if name in self._provenance - } - new._dependency_graph = defaultdict(set, { - name: set(dependencies) & keep - for name, dependencies in self._dependency_graph.items() - if name in keep - }) - return new - - def build_report(self, requested_roots: Sequence[str]) -> BuildReport: - dependency_graph = { - name: frozenset(self._dependency_graph.get(name, set())) - for name in self._builder._declarations - if name in self._dependency_graph or name in requested_roots - } - return BuildReport( - requested_roots = tuple(dict.fromkeys(requested_roots)), - provenance = dict(self._provenance), - dependency_graph = dependency_graph, - ) - - def to_overlay(self) -> ILibrary: - return self._overlay - - def to_library(self) -> Library: - mapping = {name: self._overlay[name] for name in self._overlay.source_order()} - return Library(mapping) diff --git a/masque/library/capabilities.py b/masque/library/capabilities.py deleted file mode 100644 index f6f3dba..0000000 --- a/masque/library/capabilities.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Optional capabilities implemented by lazy and borrowing libraries.""" -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Sequence - - from ..pattern import Pattern - from .base import ILibraryView - from .mapping import LibraryView - - -class IMaterializable(ABC): - """Capability for libraries which support explicit pattern materialization.""" - - @abstractmethod - def materialize(self, name: str, *, persist: bool = True) -> Pattern: - """Materialize one pattern, optionally retaining it in the library's cache.""" - - def materialize_many( - self, - names: Sequence[str], - *, - persist: bool = True, - ) -> LibraryView: - """Materialize a de-duplicated sequence into a plain read-only view.""" - from .mapping import LibraryView # noqa: PLC0415 - - return LibraryView({ - name: self.materialize(name, persist=persist) - for name in dict.fromkeys(names) - }) - - def materialize_detached(self, name: str) -> Pattern: - """Materialize a caller-owned pattern which is safe to mutate.""" - return self.materialize(name, persist=False).deepcopy() - - def materialize_many_detached( - self, - names: Sequence[str], - ) -> LibraryView: - """Materialize caller-owned patterns without retaining them in this library.""" - from .mapping import LibraryView # noqa: PLC0415 - - materialized = self.materialize_many(names, persist=False) - return LibraryView({ - name: materialized[name].deepcopy() - for name in dict.fromkeys(names) - }) - - -class IBorrowing(ABC): - """Capability for library views which directly borrow other libraries.""" - - @abstractmethod - def borrowed_sources(self) -> tuple[ILibraryView, ...]: - """Return the source views directly borrowed by this library.""" - - def source_cell(self, name: str) -> tuple[ILibraryView, str] | None: # noqa: ARG002 - """ - Return a direct source cell with unchanged layout data, if available. - - The source may use a different name, which is returned alongside it. - Port metadata may differ because ports are not layout-file content. - The result is not recursively resolved: consumers must follow further - borrowing views themselves and enforce format-specific constraints such - as whether the visible and source names must match. `None` means the - source cannot be reused safely or no source provenance is available. - """ - return None diff --git a/masque/library/lazy.py b/masque/library/lazy.py deleted file mode 100644 index dcb26ed..0000000 --- a/masque/library/lazy.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Closure-backed lazy library implementation.""" -from __future__ import annotations - -from pprint import pformat -from typing import TYPE_CHECKING, Self, cast -import logging - -from ..error import LibraryError -from .base import ILibrary -from .capabilities import IMaterializable - -if TYPE_CHECKING: - from collections.abc import Callable, Iterator, Mapping, Sequence - - from ..pattern import Pattern - - -logger = logging.getLogger(__name__) - - -class LazyLibrary(ILibrary, IMaterializable): - """ - This class is usually used to create a library of Patterns by mapping names to - functions which generate or load the relevant `Pattern` object as-needed. - - TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid? - """ - mapping: dict[str, Callable[[], Pattern]] - cache: dict[str, Pattern] - _lookups_in_progress: list[str] - - def __init__(self) -> None: - self.mapping = {} - self.cache = {} - self._lookups_in_progress = [] - - def __setitem__( - self, - key: str, - value: Pattern | Callable[[], Pattern], - ) -> None: - if key in self.mapping: - raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') - - if callable(value): - value_func = value - else: - value_func = lambda: cast('Pattern', value) # noqa: E731 - - self.mapping[key] = value_func - if key in self.cache: - del self.cache[key] - - def __delitem__(self, key: str) -> None: - del self.mapping[key] - if key in self.cache: - del self.cache[key] - - def __getitem__(self, key: str) -> Pattern: - return self.materialize(key, persist=True) - - def materialize(self, key: str, *, persist: bool = True) -> Pattern: - logger.debug(f'loading {key}') - if key in self.cache: - logger.debug(f'found {key} in cache') - return self.cache[key] - - if key in self._lookups_in_progress: - chain = ' -> '.join(self._lookups_in_progress + [key]) - raise LibraryError( - f'Detected circular reference or recursive lookup of "{key}".\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(key) - try: - func = self.mapping[key] - pat = func() - finally: - self._lookups_in_progress.pop() - if persist: - self.cache[key] = pat - return pat - - def __iter__(self) -> Iterator[str]: - return iter(self.mapping) - - def __len__(self) -> int: - return len(self.mapping) - - def __contains__(self, key: object) -> bool: - return key in self.mapping - - def referenced_patterns( - self, - tops: str | Sequence[str] | None = None, - skip: set[str] | None = None, - ) -> set[str]: - # Closure-backed cells do not have hierarchy metadata. Preserve laziness - # by loading only patterns reached from the requested roots. - return self._referenced_patterns_by_lookup(tops=tops, skip=skip) - - def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: - if isinstance(other, LazyLibrary): - self.mapping[key_self] = other.mapping[key_other] - if key_other in other.cache: - self.cache[key_self] = other.cache[key_other] - else: - self[key_self] = other[key_other] - - def __repr__(self) -> str: - return '' - - def rename( - self, - old_name: str, - new_name: str, - move_references: bool = False, - ) -> Self: - """ - Rename a pattern. - - Args: - old_name: Current name for the pattern - new_name: New name for the pattern - move_references: Whether to scan all refs in the pattern and - move them to point to `new_name` as necessary. - Default `False`. - - Returns: - self - """ - if old_name not in self.mapping: - raise LibraryError(f'"{old_name}" does not exist in the library.') - if old_name == new_name: - return self - - self[new_name] = self.mapping[old_name] # copy over function - if old_name in self.cache: - self.cache[new_name] = self.cache[old_name] - del self[old_name] - - if move_references: - self.move_references(old_name, new_name) - - return self - - def move_references(self, old_target: str, new_target: str) -> Self: - """ - Change all references pointing at `old_target` into references pointing at `new_target`. - - Args: - old_target: Current reference target - new_target: New target for the reference - - Returns: - self - """ - if old_target == new_target: - return self - - self.precache() - for pattern in self.cache.values(): - if old_target in pattern.refs: - pattern.refs[new_target].extend(pattern.refs[old_target]) - del pattern.refs[old_target] - return self - - def precache(self) -> Self: - """ - Force all patterns into the cache - - Returns: - self - """ - for key in self.mapping: - _ = self[key] # want to trigger our own __getitem__ - return self - - def __deepcopy__(self, memo: dict | None = None) -> LazyLibrary: - raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)') diff --git a/masque/library/mapping.py b/masque/library/mapping.py deleted file mode 100644 index 501f147..0000000 --- a/masque/library/mapping.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Concrete mapping-backed library implementations.""" -from __future__ import annotations - -from pprint import pformat -from typing import TYPE_CHECKING, Self - -from ..error import LibraryError -from .base import ILibrary, ILibraryView -from .capabilities import IBorrowing, IMaterializable -from .utils import dangling_mode_t, _validate_dangling_mode - -if TYPE_CHECKING: - from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence - - import numpy - from numpy.typing import NDArray - - from ..pattern import Pattern - - -class LibraryView(ILibraryView): - """ - Default implementation for a read-only library. - - A library is a mapping from unique names (str) to collections of geometry (`Pattern`). - This library is backed by an arbitrary python object which implements the `Mapping` interface. - """ - mapping: Mapping[str, Pattern] - - def __init__( - self, - mapping: Mapping[str, Pattern], - ) -> None: - self.mapping = mapping - - def __getitem__(self, key: str) -> Pattern: - return self.mapping[key] - - def __iter__(self) -> Iterator[str]: - return iter(self.mapping) - - def __len__(self) -> int: - return len(self.mapping) - - def __contains__(self, key: object) -> bool: - return key in self.mapping - - def __repr__(self) -> str: - return f'' - - -class _SubtreeLibraryView(ILibraryView, IMaterializable, IBorrowing): - """Borrowed subtree view with snapshotted membership and hierarchy.""" - - def __init__( - self, - source: ILibraryView, - *, - names: set[str], - child_graph: Mapping[str, set[str]], - ) -> None: - self._source = source - source_order = source.source_order() - ordered = list(dict.fromkeys(name for name in source_order if name in names)) - seen = set(ordered) - ordered.extend(name for name in source if name in names and name not in seen) - self._order = tuple(ordered) - self._names = frozenset(self._order) - self._child_graph = { - name: set(child_graph.get(name, set())) - for name in self._order - } - - def __getitem__(self, key: str) -> Pattern: - if key not in self._names: - raise KeyError(key) - return self._source[key] - - def __iter__(self) -> Iterator[str]: - return iter(self._order) - - def __len__(self) -> int: - return len(self._order) - - def __contains__(self, key: object) -> bool: - return key in self._names - - def borrowed_sources(self) -> tuple[ILibraryView, ...]: - return (self._source,) - - def source_cell(self, name: str) -> tuple[ILibraryView, str] | None: - if name not in self._names: - return None - return self._source, name - - def source_order(self) -> tuple[str, ...]: - return self._order - - def materialize(self, name: str, *, persist: bool = True) -> Pattern: - if name not in self._names: - raise KeyError(name) - if isinstance(self._source, IMaterializable): - return self._source.materialize(name, persist=persist) - return self._source[name] - - def materialize_detached(self, name: str) -> Pattern: - if name not in self._names: - raise KeyError(name) - if isinstance(self._source, IMaterializable): - return self._source.materialize_detached(name) - return self._source[name].deepcopy() - - def materialize_many_detached( - self, - names: Sequence[str], - ) -> LibraryView: - ordered_names = tuple(dict.fromkeys(names)) - missing = next((name for name in ordered_names if name not in self._names), None) - if missing is not None: - raise KeyError(missing) - if isinstance(self._source, IMaterializable): - return self._source.materialize_many_detached(ordered_names) - return LibraryView({name: self._source[name].deepcopy() for name in ordered_names}) - - def child_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: - _validate_dangling_mode(dangling) - graph = {name: set(children) for name, children in self._child_graph.items()} - existing = set(graph) - dangling_refs = set().union(*(children - existing for children in graph.values())) if graph else set() - if dangling == 'error': - if dangling_refs: - raise self._dangling_refs_error(dangling_refs, 'building child graph') - return graph - if dangling == 'ignore': - return { - name: {child for child in children if child in existing} - for name, children in graph.items() - } - for target in dangling_refs: - graph.setdefault(target, set()) - return graph - - def find_refs_local( - self, - name: str, - parent_graph: dict[str, set[str]] | None = None, - dangling: dangling_mode_t = 'error', - ) -> dict[str, list[NDArray[numpy.float64]]]: - _validate_dangling_mode(dangling) - if parent_graph is None: - graph_mode: dangling_mode_t = 'ignore' if dangling == 'ignore' else 'include' - parent_graph = self.parent_graph(dangling=graph_mode) - refs = self._source.find_refs_local(name, parent_graph=parent_graph, dangling=dangling) - return {parent: transforms for parent, transforms in refs.items() if parent in self._names} - - -class Library(ILibrary): - """ - Default implementation for a writeable library. - - A library is a mapping from unique names (str) to collections of geometry (`Pattern`). - This library is backed by an arbitrary python object which implements the `MutableMapping` interface. - """ - mapping: MutableMapping[str, Pattern] - - def __init__( - self, - mapping: MutableMapping[str, Pattern] | None = None, - ) -> None: - if mapping is None: - self.mapping = {} - else: - self.mapping = mapping - - def __getitem__(self, key: str) -> Pattern: - return self.mapping[key] - - def __iter__(self) -> Iterator[str]: - return iter(self.mapping) - - def __len__(self) -> int: - return len(self.mapping) - - def __contains__(self, key: object) -> bool: - return key in self.mapping - - def __setitem__( - self, - key: str, - value: Pattern | Callable[[], Pattern], - ) -> None: - if key in self.mapping: - raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') - - value = value() if callable(value) else value - self.mapping[key] = value - - def __delitem__(self, key: str) -> None: - del self.mapping[key] - - def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: - self[key_self] = other[key_other] - - def __repr__(self) -> str: - return f'' - - @classmethod - def mktree(cls: type[Self], name: str) -> tuple[Self, Pattern]: - """ - Create a new Library and immediately add a pattern - - Args: - name: The name for the new pattern (usually the name of the topcell). - - Returns: - The newly created `Library` and the newly created `Pattern` - """ - from ..pattern import Pattern # noqa: PLC0415 - tree = cls() - pat = Pattern() - tree[name] = pat - return tree, pat diff --git a/masque/library/overlay.py b/masque/library/overlay.py deleted file mode 100644 index 43e201e..0000000 --- a/masque/library/overlay.py +++ /dev/null @@ -1,561 +0,0 @@ -"""Overlay and lazily processed library views.""" -from __future__ import annotations - -from collections import defaultdict -from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, Self, cast -import copy - -from ..error import LibraryError -from ..pattern import Pattern, map_layers, map_targets -from .base import ILibrary, ILibraryView -from .capabilities import IBorrowing, IMaterializable -from .utils import ( - INameView, - dangling_mode_t, - _plan_source_names, - _rename_patterns, - _source_rename_map, - _validate_dangling_mode, - ) -from .mapping import LibraryView - -if TYPE_CHECKING: - from collections.abc import Callable, Iterator, Mapping, Sequence - - import numpy - from numpy.typing import NDArray - - from ..ports import Port - from ..utils import layer_t - - -@dataclass -class _SourceLayer: - """ One imported source layer tracked by an `OverlayLibrary`. """ - library: ILibraryView - source_target_map: dict[str, str] - child_graph: dict[str, set[str]] - - -@dataclass(frozen=True) -class _SourceEntry: - """ Reference to a single visible source-backed cell in an overlay. """ - layer_index: int - source_name: str - - -def _materialize_detached_pattern(view: ILibraryView, name: str) -> Pattern: - if isinstance(view, IMaterializable): - return view.materialize_detached(name) - return view[name].deepcopy() - - -class _ProcessedLibraryView(ILibraryView, IMaterializable, IBorrowing): - """Shared detached-materialization behavior for read-only processing views.""" - - def __init__( - self, - source: ILibraryView, - *, - copy_through: bool, - ) -> None: - self._source = source - self._copy_through = copy_through - self._cache: dict[str, Pattern] = {} - self._lookups_in_progress: list[str] = [] - - def __getitem__(self, key: str) -> Pattern: - return self.materialize(key, persist=True) - - def __iter__(self) -> Iterator[str]: - return iter(self._source) - - def __len__(self) -> int: - return len(self._source) - - def __contains__(self, key: object) -> bool: - return key in self._source - - def _process_pattern(self, name: str, pattern: Pattern) -> Pattern: - """Apply this view's processing to one detached source pattern.""" - raise NotImplementedError - - def _materialize_uncached_detached(self, name: str) -> Pattern: - 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.' - ) - - self._lookups_in_progress.append(name) - try: - pattern = _materialize_detached_pattern(self._source, name) - pattern = self._process_pattern(name, pattern) - finally: - self._lookups_in_progress.pop() - - return pattern - - def materialize(self, name: str, *, persist: bool = True) -> Pattern: - if name in self._cache: - return self._cache[name] - - pattern = self._materialize_uncached_detached(name) - - if persist: - self._cache[name] = pattern - return pattern - - def materialize_detached(self, name: str) -> Pattern: - if name in self._cache: - return self._cache[name].deepcopy() - return self._materialize_uncached_detached(name) - - def materialize_many_detached( - self, - names: Sequence[str], - ) -> LibraryView: - ordered_names = tuple(dict.fromkeys(names)) - result: dict[str, Pattern] = {} - uncached = [name for name in ordered_names if name not in self._cache] - - for name in ordered_names: - if name in self._cache: - result[name] = self._cache[name].deepcopy() - - if uncached: - if isinstance(self._source, IMaterializable): - source_patterns = self._source.materialize_many_detached(uncached) - else: - source_patterns = LibraryView({name: self._source[name].deepcopy() for name in uncached}) - for name in uncached: - 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}".\nLookup chain: {chain}') - self._lookups_in_progress.append(name) - try: - result[name] = self._process_pattern(name, source_patterns[name]) - finally: - self._lookups_in_progress.pop() - - return LibraryView({name: result[name] for name in ordered_names}) - - def source_order(self) -> tuple[str, ...]: - return self._source.source_order() - - def borrowed_sources(self) -> tuple[ILibraryView, ...]: - return (self._source,) - - def source_cell(self, name: str) -> tuple[ILibraryView, str] | None: - if not self._copy_through or name not in self._source or name in self._cache: - return None - return self._source, name - - def child_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: - _validate_dangling_mode(dangling) - return self._source.child_graph(dangling=dangling) - - def find_refs_local( - self, - name: str, - parent_graph: dict[str, set[str]] | None = None, - dangling: dangling_mode_t = 'error', - ) -> dict[str, list[NDArray[numpy.float64]]]: - _validate_dangling_mode(dangling) - finder = getattr(self._source, 'find_refs_local', None) - if callable(finder): - return cast('dict[str, list[NDArray[numpy.float64]]]', finder(name, parent_graph=parent_graph, dangling=dangling)) - return super().find_refs_local(name, parent_graph=parent_graph, dangling=dangling) - - -class PortLoadView(_ProcessedLibraryView): - """ - Read-only view which loads or applies ports on first materialization. - - The wrapped source remains untouched; this view owns a separate processed - cache so direct-copy workflows can continue to use the raw source view. - The view borrows its source: callers must keep the source open for the - lifetime of the view and close the source themselves. - - Graph queries and source ordering are delegated to the wrapped source, - while `source_cell()` exposes unchanged layout provenance and `__getitem__` - and `materialize_many()` return port-imported patterns. - """ - - def __init__( - self, - source: ILibraryView, - *, - layers: Sequence[layer_t] = (), - max_depth: int = 0, - skip_subcells: bool = True, - ports: Mapping[str, Mapping[str, Port]] | None = None, - replace: bool = False, - ) -> None: - super().__init__(source, copy_through=True) - self._layers = tuple(layers) - self._max_depth = max_depth - self._skip_subcells = skip_subcells - self._ports = { - name: copy.deepcopy(dict(cell_ports)) - for name, cell_ports in (ports or {}).items() - } - self._replace = replace - - def _process_pattern(self, name: str, pattern: Pattern) -> Pattern: - from ..utils.ports2data import data_to_ports # noqa: PLC0415 - - if self._layers: - pattern = data_to_ports( - layers=self._layers, - library=self, - pattern=pattern, - name=name, - max_depth=self._max_depth, - skip_subcells=self._skip_subcells, - ) - if name in self._ports: - ports = copy.deepcopy(self._ports[name]) - if self._replace: - pattern.ports = ports - else: - pattern.ports.update(ports) - return pattern - - -class LayerMappedView(_ProcessedLibraryView): - """ - Read-only view which remaps shape and label layers on materialization. - - The wrapped source remains untouched. By default, source-aware writers - must materialize and serialize every mapped cell. With `copy_through=True`, - unmaterialized cells may instead be copied unchanged from their source; - persistent access maps and caches a cell, disabling copy-through for it. - """ - - def __init__( - self, - source: ILibraryView, - map_layer: Callable[[layer_t], layer_t], - *, - copy_through: bool = False, - ) -> None: - super().__init__(source, copy_through=copy_through) - self._map_layer = map_layer - - def _process_pattern(self, name: str, pattern: Pattern) -> Pattern: - _ = name - pattern.shapes = map_layers(pattern.shapes, self._map_layer) - pattern.labels = map_layers(pattern.labels, self._map_layer) - return pattern - - -class OverlayLibrary(ILibrary, IMaterializable, IBorrowing): - """ - Mutable overlay over one or more source libraries. - - Source-backed cells remain lazy until accessed through `__getitem__`, which - persistently materializes a detached, overlay-owned `Pattern`. - - Source libraries must remain open and must not be mutated after they are - added. The overlay borrows each source and snapshots its names, hierarchy, - and initial visible-name mapping while retaining the source itself for lazy - pattern materialization. - """ - - def __init__(self) -> None: - self._layers: list[_SourceLayer] = [] - self._entries: dict[str, Pattern | _SourceEntry] = {} - self._order: list[str] = [] - - def __iter__(self) -> Iterator[str]: - return (name for name in self._order if name in self._entries) - - def __len__(self) -> int: - return len(self._entries) - - def __contains__(self, key: object) -> bool: - return key in self._entries - - def __getitem__(self, key: str) -> Pattern: - return self.materialize(key, persist=True) - - def __setitem__( - self, - key: str, - value: Pattern | Callable[[], Pattern], - ) -> None: - if key in self._entries: - raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') - pattern = value() if callable(value) else value - self._entries[key] = pattern - if key not in self._order: - self._order.append(key) - - def __delitem__(self, key: str) -> None: - if key not in self._entries: - raise KeyError(key) - del self._entries[key] - - def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: - self[key_self] = copy.deepcopy(other[key_other]) - - def add_source( - self, - source: Mapping[str, Pattern] | ILibraryView, - *, - rename_theirs: Callable[[INameView, str], str] | None = _rename_patterns, - rename_when: Literal['conflict', 'always'] = 'conflict', - ) -> dict[str, str]: - """ - Add a source-backed library layer. - - The source must remain open, and its names, hierarchy, and pattern - contents must remain unchanged for the lifetime of this overlay. - - Args: - rename_theirs: Function used to choose visible names for imported - source cells. Its `INameView` argument contains existing and - previously reserved names, but does not support pattern lookup. - By default, conflicting single-use names are made unique; - pass `None` to reject every conflict. - rename_when: If `'conflict'`, only conflicting names are renamed. - If `'always'`, every imported source name is passed through - `rename_theirs`. - """ - view = source if isinstance(source, ILibraryView) else LibraryView(source) - source_order = list(view.source_order()) - child_graph = view.child_graph(dangling='include') - - source_to_visible = _plan_source_names( - self, - source_order, - rename_theirs = rename_theirs, - rename_when = rename_when, - ) - layer = _SourceLayer( - library=view, - source_target_map=dict(source_to_visible), - child_graph=child_graph, - ) - # Include dangling targets so each source tracks current names directly. - for children in child_graph.values(): - for child in children: - layer.source_target_map.setdefault(child, child) - layer_index = len(self._layers) - self._layers.append(layer) - - for source_name, visible_name in source_to_visible.items(): - self._entries[visible_name] = _SourceEntry(layer_index=layer_index, source_name=source_name) - if visible_name not in self._order: - self._order.append(visible_name) - - return _source_rename_map(source_to_visible) - - def rename( - self, - old_name: str, - new_name: str, - move_references: bool = False, - ) -> OverlayLibrary: - if old_name not in self._entries: - raise LibraryError(f'"{old_name}" does not exist in the library.') - if old_name == new_name: - return self - if new_name in self._entries: - raise LibraryError(f'"{new_name}" already exists in the library.') - - entry = self._entries.pop(old_name) - self._entries[new_name] = entry - - self._order = [name for name in self._order if name != new_name] - idx = self._order.index(old_name) - self._order[idx] = new_name - - if move_references: - self.move_references(old_name, new_name) - return self - - def move_references(self, old_target: str, new_target: str) -> OverlayLibrary: - if old_target == new_target: - return self - for layer in self._layers: - for source_target, current_target in layer.source_target_map.items(): - if current_target == old_target: - layer.source_target_map[source_target] = new_target - for entry in list(self._entries.values()): - if isinstance(entry, Pattern) and old_target in entry.refs: - entry.refs[new_target].extend(entry.refs[old_target]) - del entry.refs[old_target] - return self - - def _effective_target(self, layer: _SourceLayer, target: str) -> str: - return layer.source_target_map.get(target, target) - - def _remap_source_pattern(self, layer: _SourceLayer, source_pat: Pattern) -> Pattern: - def remap(target: str | None) -> str | None: - return None if target is None else self._effective_target(layer, target) - - if source_pat.refs: - source_pat.refs = map_targets(source_pat.refs, remap) - return source_pat - - def materialize(self, name: str, *, persist: bool = True) -> Pattern: - if name not in self._entries: - raise KeyError(name) - entry = self._entries[name] - if isinstance(entry, Pattern): - return entry - - layer = self._layers[entry.layer_index] - source_pat = _materialize_detached_pattern(layer.library, entry.source_name) - pat = self._remap_source_pattern(layer, source_pat) - if persist: - self._entries[name] = pat - return pat - - def materialize_detached(self, name: str) -> Pattern: - if name not in self._entries: - raise KeyError(name) - entry = self._entries[name] - if isinstance(entry, Pattern): - return entry.deepcopy() - layer = self._layers[entry.layer_index] - source_pat = _materialize_detached_pattern(layer.library, entry.source_name) - return self._remap_source_pattern(layer, source_pat) - - def materialize_many_detached( - self, - names: Sequence[str], - ) -> LibraryView: - ordered_names = tuple(dict.fromkeys(names)) - missing = next((name for name in ordered_names if name not in self._entries), None) - if missing is not None: - raise KeyError(missing) - - result: dict[str, Pattern] = {} - grouped: dict[int, list[tuple[str, str]]] = defaultdict(list) - for name in ordered_names: - entry = self._entries[name] - if isinstance(entry, Pattern): - result[name] = entry.deepcopy() - else: - grouped[entry.layer_index].append((name, entry.source_name)) - - for layer_index, cells in grouped.items(): - layer = self._layers[layer_index] - source_names = [source_name for _name, source_name in cells] - if isinstance(layer.library, IMaterializable): - source_patterns = layer.library.materialize_many_detached(source_names) - else: - source_patterns = LibraryView({ - source_name: layer.library[source_name].deepcopy() - for source_name in source_names - }) - for name, source_name in cells: - result[name] = self._remap_source_pattern(layer, source_patterns[source_name]) - - return LibraryView({name: result[name] for name in ordered_names}) - - def child_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: - _validate_dangling_mode(dangling) - graph: dict[str, set[str]] = {} - for name in self._order: - if name not in self._entries: - continue - entry = self._entries[name] - if isinstance(entry, Pattern): - graph[name] = {child for child, refs in entry.refs.items() if child is not None and refs} - continue - layer = self._layers[entry.layer_index] - children = {self._effective_target(layer, child) for child in layer.child_graph.get(entry.source_name, set())} - graph[name] = children - - existing = set(graph) - dangling_refs = set().union(*(children - existing for children in graph.values())) - if dangling == 'error': - if dangling_refs: - raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph') - return graph - if dangling == 'ignore': - return {name: {child for child in children if child in existing} for name, children in graph.items()} - - for child in dangling_refs: - graph.setdefault(cast('str', child), set()) - return graph - - def subtree( - self, - tops: str | Sequence[str], - ) -> Self: - if isinstance(tops, str): - tops = (tops,) - - graph = self.child_graph(dangling='include') - keep = self._referenced_patterns_from_graph(graph, tops=tops) - keep &= set(self) - keep |= set(tops) - - new = type(self)() - new._layers = [ - _SourceLayer( - library=layer.library, - source_target_map=dict(layer.source_target_map), - child_graph={name: set(children) for name, children in layer.child_graph.items()}, - ) - for layer in self._layers - ] - new._order = [name for name in self._order if name in keep and name in self._entries] - new._entries = {name: self._entries[name] for name in new._order} - return new - - def find_refs_local( - self, - name: str, - parent_graph: dict[str, set[str]] | None = None, - dangling: dangling_mode_t = 'error', - ) -> dict[str, list[NDArray[numpy.float64]]]: - _validate_dangling_mode(dangling) - instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list) - if parent_graph is None: - graph_mode = 'ignore' if dangling == 'ignore' else 'include' - parent_graph = self.parent_graph(dangling=graph_mode) - - if name not in self: - if name not in parent_graph: - return instances - if dangling == 'error': - raise self._dangling_refs_error({name}, f'finding local refs for {name!r}') - if dangling == 'ignore': - return instances - - for parent in parent_graph.get(name, set()): - pat = self.materialize(parent, persist=False) - for ref in pat.refs.get(name, []): - instances[parent].append(ref.as_transforms()) - return instances - - def source_order(self) -> tuple[str, ...]: - return tuple(name for name in self._order if name in self._entries) - - def borrowed_sources(self) -> tuple[ILibraryView, ...]: - return tuple(layer.library for layer in self._layers) - - def source_cell(self, name: str) -> tuple[ILibraryView, str] | None: - entry = self._entries.get(name) - if not isinstance(entry, _SourceEntry): - return None - layer = self._layers[entry.layer_index] - children = layer.child_graph.get(entry.source_name, set()) - if any(self._effective_target(layer, child) != child for child in children): - return None - return layer.library, entry.source_name diff --git a/masque/library/utils.py b/masque/library/utils.py deleted file mode 100644 index ffdb9ce..0000000 --- a/masque/library/utils.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Shared types and helpers for library implementations.""" -from __future__ import annotations - -from abc import ABC -from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias -from collections.abc import Callable, Collection, Iterator, Mapping, MutableMapping, Sequence -import logging -import re - -from ..error import LibraryError - -if TYPE_CHECKING: - import numpy - from numpy.typing import NDArray - - from ..pattern import Pattern - - -logger = logging.getLogger(__name__) - -SINGLE_USE_PREFIX = '_' -""" -Names starting with this prefix are assumed to refer to single-use patterns, -which may be renamed automatically by `ILibrary.add()` (via -`rename_theirs=_rename_patterns()` ) -""" -# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere? - - -class INameView(Collection[str], ABC): - """ - Read-only collection of reserved names with a shared name allocator. - - Name views support membership, iteration, `len()`, and `get_name()`. They - do not provide pattern lookup or the other operations of a library mapping. - """ - - def get_name( - self, - name: str = SINGLE_USE_PREFIX * 2, - sanitize: bool = True, - max_length: int = 32, - quiet: bool | None = None, - ) -> str: - """ - Find a unique name. - - This function may be overridden in a subclass or monkey-patched to fit - the caller's requirements. - - Args: - name: Preferred name. Default is `SINGLE_USE_PREFIX * 2`. - sanitize: Allow only alphanumeric characters and _?$, replacing - invalid characters with underscores. - max_length: Truncate names longer than this. - quiet: Suppress log messages when `True`. The default suppresses - messages only when `name` starts with `SINGLE_USE_PREFIX`. - - Returns: - A name unique within this view. - """ - if quiet is None: - quiet = name.startswith(SINGLE_USE_PREFIX) - - if sanitize: - sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', name) - else: - sanitized_name = name - - suffixed_name = sanitized_name - if sanitized_name in self: - ii = sum(1 for nn in self if nn.startswith(sanitized_name)) - else: - ii = 0 - while suffixed_name in self or suffixed_name == '': - suffixed_name = sanitized_name + b64suffix(ii) - ii += 1 - - if len(suffixed_name) > max_length: - if name == '': - raise LibraryError(f'No valid pattern names remaining within the specified {max_length=}') - - cropped_name = self.get_name(sanitized_name[:-1], sanitize=sanitize, max_length=max_length, quiet=True) - else: - cropped_name = suffixed_name - - if not quiet: - logger.info(f'Requested name "{name}" changed to "{cropped_name}"') - - return cropped_name - - -class visitor_function_t(Protocol): - """ Signature for `Library.dfs()` visitor functions. """ - def __call__( - self, - pattern: Pattern, - hierarchy: tuple[str | None, ...], - memo: dict, - transform: NDArray[numpy.float64] | Literal[False], - ) -> Pattern: - ... - - -TreeView: TypeAlias = Mapping[str, 'Pattern'] -""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """ - -Tree: TypeAlias = MutableMapping[str, 'Pattern'] -""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """ - -dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include'] -""" How helpers should handle refs whose targets are not present in the library. """ - - -def _rename_patterns(lib: INameView, name: str) -> str: - """ - The default `rename_theirs` function for `ILibrary.add`. - - Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as - "one-offs" for which name conflicts should be automatically resolved. - Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)` - where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`. - Names lacking the prefix are directly returned (not renamed). - - Args: - lib: The library into which `name` is to be added (but is presumed to conflict) - name: The original name, to be modified - - Returns: - The new name, not guaranteed to be conflict-free! - """ - if not name.startswith(SINGLE_USE_PREFIX): - return name - - stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0] - return lib.get_name(SINGLE_USE_PREFIX + stem) - - -def _validate_dangling_mode(dangling: dangling_mode_t) -> None: - if dangling not in ('error', 'ignore', 'include'): - raise ValueError( - f'Unknown dangling-reference mode {dangling!r}; ' - 'expected one of "error", "ignore", or "include"' - ) - - -class _ProspectiveNames(INameView): - """Target names plus names reserved earlier in an addition plan.""" - - def __init__( - self, - target: INameView, - reserved: set[str], - ) -> None: - self._target = target - self._reserved = reserved - - def __iter__(self) -> Iterator[str]: - yield from self._target - yield from self._reserved - - def __len__(self) -> int: - return len(self._target) + len(self._reserved) - - def __contains__(self, key: object) -> bool: - return key in self._reserved or key in self._target - - -def _plan_source_names( - target: INameView, - source_order: Sequence[str], - *, - rename_theirs: Callable[[INameView, str], str] | None = None, - rename_when: Literal['conflict', 'always'] = 'conflict', - ) -> dict[str, str]: - if rename_when not in ('conflict', 'always'): - raise ValueError(f'Unknown source rename mode: {rename_when!r}') - if rename_when == 'always' and rename_theirs is None: - raise TypeError('rename_theirs is required when rename_when="always"') - - source_to_visible: dict[str, str] = {} - reserved: set[str] = set() - prospective = _ProspectiveNames(target, reserved) - - for name in source_order: - visible = name - if rename_when == 'always': - assert rename_theirs is not None - visible = rename_theirs(prospective, name) - elif visible in prospective: - if rename_theirs is None: - raise LibraryError(f'Conflicting name while adding source: {name!r}') - visible = rename_theirs(prospective, name) - if visible in prospective: - raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') - source_to_visible[name] = visible - reserved.add(visible) - - return source_to_visible - - -def _source_rename_map(source_to_visible: Mapping[str, str]) -> dict[str, str]: - return { - source_name: visible_name - for source_name, visible_name in source_to_visible.items() - if source_name != visible_name - } - -def b64suffix(ii: int) -> str: - """ - Turn an integer into a base64-equivalent suffix. - - This could be done with base64.b64encode, but this way is faster for many small `ii`. - """ - def i2a(nn: int) -> str: - return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn] - - parts = ['$', i2a(ii % 64)] - ii >>= 6 - while ii: - parts.append(i2a(ii % 64)) - ii >>= 6 - return ''.join(parts) diff --git a/masque/pattern.py b/masque/pattern.py index c649543..5bf030a 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -26,45 +26,9 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab from .ports import Port, PortList - logger = logging.getLogger(__name__) -def _check_ref_cycles( - pattern: 'Pattern', - library: Mapping[str, 'Pattern'], - *, - operation: str, - ) -> None: - visited: set[str] = set() - active: list[str] = [] - active_set: set[str] = set() - - def visit(current: 'Pattern') -> None: - for target, refs in current.refs.items(): - if target is None or not refs: - continue - if target in active_set: - cycle_start = active.index(target) - cycle = active[cycle_start:] + [target] - raise PatternError( - f'Circular reference while {operation}: {" -> ".join(cycle)}' - ) - if target in visited: - continue - - active.append(target) - active_set.add(target) - try: - visit(library[target]) - finally: - active.pop() - active_set.remove(target) - visited.add(target) - - visit(pattern) - - @functools.total_ordering class Pattern(PortList, AnnotatableImpl, Mirrorable): """ @@ -73,8 +37,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): or provide equivalent functions. `Pattern` also stores a dict of `Port`s, which can be used to "snap" together points. - See `Pattern.plug()` and `Pattern.place()`, as well as `builder.Pather` - and `ports.PortsList`. + See `Pattern.plug()` and `Pattern.place()`, as well as the helper classes + `builder.Builder`, `builder.Pather`, `builder.RenderPather`, and `ports.PortsList`. For convenience, ports can be read out using square brackets: - `pattern['A'] == Port((0, 0), 0)` @@ -207,8 +171,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return s def __copy__(self) -> 'Pattern': - logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! ' - 'Consider using .deepcopy() if this was not intended.') + logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!') new = Pattern( annotations=copy.deepcopy(self.annotations), ports=copy.deepcopy(self.ports), @@ -235,7 +198,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def __lt__(self, other: 'Pattern') -> bool: self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] - other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) @@ -249,7 +212,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return refs_ours < refs_theirs self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] - other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) @@ -258,21 +221,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(other.shapes[layer])) + shapes_theirs = tuple(sorted(self.shapes[layer])) if shapes_ours != shapes_theirs: return shapes_ours < shapes_theirs self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] - other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) if self_txtlayerkeys != other_txtlayerkeys: return self_txtlayerkeys < other_txtlayerkeys - for _, _, layer in self_txtlayerkeys: + for _, _, layer in self_layerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(other.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) if labels_ours != labels_theirs: return labels_ours < labels_theirs @@ -289,7 +252,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return False self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] - other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) @@ -303,7 +266,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return False self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] - other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) @@ -312,21 +275,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(other.shapes[layer])) + shapes_theirs = tuple(sorted(self.shapes[layer])) if shapes_ours != shapes_theirs: return False self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] - other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) if self_txtlayerkeys != other_txtlayerkeys: return False - for _, _, layer in self_txtlayerkeys: + for _, _, layer in self_layerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(other.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) if labels_ours != labels_theirs: return False @@ -369,7 +332,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): )) self.ports = dict(sorted(self.ports.items())) - self.annotations = dict(sorted(self.annotations.items())) if self.annotations is not None else None + self.annotations = dict(sorted(self.annotations.items())) return self @@ -384,16 +347,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - annotation_conflicts: set[str] = set() - if other_pattern.annotations is not None and self.annotations is not None: - annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys()) - if annotation_conflicts: - raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') - - port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys()) - if port_conflicts: - raise PatternError(f'Port names overlap: {port_conflicts}') - for target, rseq in other_pattern.refs.items(): self.refs[target].extend(rseq) for layer, sseq in other_pattern.shapes.items(): @@ -401,10 +354,14 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for layer, lseq in other_pattern.labels.items(): self.labels[layer].extend(lseq) - if other_pattern.annotations is not None: - if self.annotations is None: - self.annotations = {} - self.annotations.update(other_pattern.annotations) + annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys()) + if annotation_conflicts: + raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') + self.annotations.update(other_pattern.annotations) + + port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys()) + if port_conflicts: + raise PatternError(f'Port names overlap: {port_conflicts}') self.ports.update(other_pattern.ports) return self @@ -458,7 +415,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): elif default_keep: pat.refs = copy.copy(self.refs) - if annotations is not None and self.annotations is not None: + if annotations is not None: pat.annotations = {k: v for k, v in self.annotations.items() if annotations(k, v)} elif default_keep: pat.annotations = copy.copy(self.annotations) @@ -539,74 +496,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ] return polys - def layer_as_polygons( - self, - layer: layer_t, - flatten: bool = True, - library: Mapping[str, 'Pattern'] | None = None, - *, - _cycle_checked: bool = False, - ) -> list[Polygon]: - """ - Collect all geometry effectively on a given layer as a list of polygons. - - If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`. - `Repetition` objects are expanded, and non-polygon shapes are converted - to `Polygon` approximations. - - Args: - layer: The layer to collect geometry from. - flatten: If `True`, include geometry from referenced patterns. - library: Required if `flatten=True` to resolve references. - - Returns: - A list of `Polygon` objects. - - Raises: - PatternError: If `flatten=True` and the referenced hierarchy contains a cycle. - """ - if flatten and self.has_refs() and library is None: - raise PatternError("Must provide a library to layer_as_polygons() when flatten=True") - if flatten and self.has_refs() and not _cycle_checked: - assert library is not None - _check_ref_cycles(self, library, operation='collecting layer polygons') - - polys: list[Polygon] = [] - - # Local shapes - for shape in self.shapes.get(layer, []): - for p in shape.to_polygons(): - # expand repetitions - if p.repetition is not None: - for offset in p.repetition.displacements: - polys.append(p.deepcopy().translate(offset).set_repetition(None)) - else: - polys.append(p.deepcopy()) - - if flatten and self.has_refs(): - assert library is not None - for target, refs in self.refs.items(): - if target is None or not refs: - continue - target_pat = library[target] - for ref in refs: - # Get polygons from target pattern on the same layer - ref_polys = target_pat.layer_as_polygons( - layer, - flatten=True, - library=library, - _cycle_checked=True, - ) - # Apply ref transformations - for p in ref_polys: - p_pat = ref.as_pattern(Pattern(shapes={layer: [p]})) - # as_pattern expands repetition of the ref itself - # but we need to pull the polygons back out - for p_transformed in p_pat.shapes[layer]: - polys.append(cast('Polygon', p_transformed)) - - return polys - def referenced_patterns(self) -> set[str | None]: """ Get all pattern namers referenced by this pattern. Non-recursive. @@ -614,15 +503,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: A set of all pattern names referenced by this pattern. """ - return {target for target, refs in self.refs.items() if refs} + return set(self.refs.keys()) def get_bounds( self, library: Mapping[str, 'Pattern'] | None = None, recurse: bool = True, cache: MutableMapping[str, NDArray[numpy.float64] | None] | None = None, - *, - _cycle_checked: bool = False, ) -> NDArray[numpy.float64] | None: """ Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the @@ -639,13 +526,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: `[[x_min, y_min], [x_max, y_max]]` or `None` - - Raises: - PatternError: If recursive bounds are requested for a cyclic hierarchy. """ - if recurse and self.has_refs() and library is not None and not _cycle_checked: - _check_ref_cycles(self, library, operation='calculating bounds') - if self.is_empty(): return None @@ -684,12 +565,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if target in cache: unrot_bounds = cache[target] elif any(numpy.isclose(ref.rotation % (pi / 2), 0) for ref in refs): - unrot_bounds = library[target].get_bounds( - library=library, - recurse=recurse, - cache=cache, - _cycle_checked=True, - ) + unrot_bounds = library[target].get_bounds(library=library, recurse=recurse, cache=cache) cache[target] = unrot_bounds for ref in refs: @@ -705,7 +581,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): bounds = numpy.vstack((numpy.min(corners, axis=0), numpy.max(corners, axis=0))) * ref.scale + [ref.offset] if ref.repetition is not None: - bounds += ref.repetition.get_bounds_nonempty() + bounds += ref.repetition.get_bounds() else: # Non-manhattan rotation, have to figure out bounds by rotating the pattern @@ -756,7 +632,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): """ for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()): cast('Positionable', entry).translate(offset) - self._log_bulk_update(f"translate({offset!r})") return self def scale_elements(self, c: float) -> Self: @@ -810,9 +685,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Extrinsic transformation: Rotate the Pattern around the a location in the - container's coordinate system. This affects all elements' offsets and - their repetition grids. + Rotate the Pattern around the a location. Args: pivot: (x, y) location to rotate around @@ -826,14 +699,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self.rotate_elements(rotation) self.rotate_element_centers(rotation) self.translate_elements(+pivot) - self._log_bulk_update(f"rotate_around({pivot}, {rotation})") return self def rotate_element_centers(self, rotation: float) -> Self: """ - Extrinsic transformation part: Rotate the offsets and repetition grids of all - shapes, labels, refs, and ports around (0, 0) in the container's - coordinate system. + Rotate the offsets of all shapes, labels, refs, and ports around (0, 0) Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -844,15 +714,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): old_offset = cast('Positionable', entry).offset cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) - if isinstance(entry, Repeatable) and entry.repetition is not None: - entry.repetition.rotate(rotation) return self def rotate_elements(self, rotation: float) -> Self: """ - Intrinsic transformation part: Rotate each shape, ref, label, and port around its - origin (offset) in the container's coordinate system. This does NOT - affect their repetition grids. + Rotate each shape, ref, and port around its origin (offset) Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -860,61 +726,54 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - if isinstance(entry, Rotatable): - entry.rotate(rotation) + for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): + cast('Rotatable', entry).rotate(rotation) return self - def mirror_element_centers(self, axis: int = 0) -> Self: + def mirror_element_centers(self, across_axis: int = 0) -> Self: """ - Extrinsic transformation part: Mirror the offsets and repetition grids of all - shapes, labels, refs, and ports relative to the container's origin. + Mirror the offsets of all shapes, labels, and refs across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis) + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Positionable', entry).offset[1 - axis] *= -1 - if isinstance(entry, Repeatable) and entry.repetition is not None: - entry.repetition.mirror(axis) + cast('Positionable', entry).offset[across_axis - 1] *= -1 return self - def mirror_elements(self, axis: int = 0) -> Self: + def mirror_elements(self, across_axis: int = 0) -> Self: """ - Intrinsic transformation part: Mirror each shape, ref, label, and port relative - to its offset. This does NOT affect their repetition grids. + Mirror each shape, ref, and pattern across an axis, relative + to its offset Args: - axis: Axis to mirror across - 0: mirror across x axis (flip y), - 1: mirror across y axis (flip x) + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - if isinstance(entry, Mirrorable): - entry.mirror(axis=axis) - self._log_bulk_update(f"mirror_elements({axis})") + for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): + cast('Mirrorable', entry).mirror(across_axis) return self - def mirror(self, axis: int = 0) -> Self: + def mirror(self, across_axis: int = 0) -> Self: """ - Extrinsic transformation: Mirror the Pattern across an axis through its origin. - This affects all elements' offsets and their internal orientations. + Mirror the Pattern across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ - self.mirror_elements(axis=axis) - self.mirror_element_centers(axis=axis) - self._log_bulk_update(f"mirror({axis})") + self.mirror_elements(across_axis) + self.mirror_element_centers(across_axis) return self def copy(self) -> Self: @@ -925,7 +784,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: A deep copy of the current Pattern. """ - return self.deepcopy() + return copy.deepcopy(self) def deepcopy(self) -> Self: """ @@ -1068,28 +927,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): del self.labels[layer] return self - def resolve_repeated_refs(self) -> Self: - """ - Expand all repeated references into multiple individual references. - Alters the current pattern in-place. - - Returns: - self - """ - new_refs: defaultdict[str | None, list[Ref]] = defaultdict(list) - for target, rseq in self.refs.items(): - for ref in rseq: - if ref.repetition is None: - new_refs[target].append(ref) - else: - for dd in ref.repetition.displacements: - new_ref = ref.deepcopy() - new_ref.offset = ref.offset + dd - new_ref.repetition = None - new_refs[target].append(new_ref) - self.refs = new_refs - return self - def prune_refs(self) -> Self: """ Remove empty ref lists in `self.refs`. @@ -1141,16 +978,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - ports_only = flatten_ports and bool(target_pat.ports) - if target_pat.is_empty() and not ports_only: # avoid some extra allocations + if target_pat.is_empty(): # avoid some extra allocations continue for ref in refs: - if flatten_ports and ref.repetition is not None and target_pat.ports: - raise PatternError( - f'Cannot flatten ports from repeated ref to {target!r}; ' - 'flatten with flatten_ports=False or expand/rename the ports manually first.' - ) p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() @@ -1169,8 +1000,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): line_color: str = 'k', fill_color: str = 'none', overdraw: bool = False, - filename: str | None = None, - ports: bool = False, ) -> None: """ Draw a picture of the Pattern and wait for the user to inspect it @@ -1181,181 +1010,65 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): klayout or a different GDS viewer! Args: - library: Mapping of {name: Pattern} for resolving references. Required if `self.has_refs()`. - offset: Coordinates to offset by before drawing. - line_color: Outlines are drawn with this color. - fill_color: Interiors are drawn with this color. - overdraw: Whether to create a new figure or draw on a pre-existing one. - filename: If provided, save the figure to this file instead of showing it. - ports: If True, annotate the plot with arrows representing the ports. - - Raises: - PatternError: If the referenced hierarchy contains a cycle. + offset: Coordinates to offset by before drawing + line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`) + fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`) + overdraw: Whether to create a new figure or draw on a pre-existing one """ # TODO: add text labels to visualize() - if self.has_refs() and library is None: - raise PatternError('Must provide a library when visualizing a pattern with refs') - if self.has_refs(): - assert library is not None - _check_ref_cycles(self, library, operation='visualizing pattern hierarchy') - try: - from matplotlib import pyplot # type: ignore #noqa: PLC0415 - import matplotlib.collections # type: ignore #noqa: PLC0415 + from matplotlib import pyplot # type: ignore + import matplotlib.collections # type: ignore except ImportError: logger.exception('Pattern.visualize() depends on matplotlib!\n' + 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.') raise - # Cache for {Pattern object ID: List of local polygon vertex arrays} - # Polygons are stored relative to the pattern's origin (offset included) - poly_cache: dict[int, list[NDArray[numpy.float64]]] = {} + if self.has_refs() and library is None: + raise PatternError('Must provide a library when visualizing a pattern with refs') - def get_local_polys(pat: 'Pattern') -> list[NDArray[numpy.float64]]: - pid = id(pat) - if pid not in poly_cache: - polys = [] - for shape in chain.from_iterable(pat.shapes.values()): - for ss in shape.to_polygons(): - # Shape.to_polygons() returns Polygons with their own offsets and vertices. - # We need to expand any shape-level repetition here. - v_base = ss.vertices + ss.offset - if ss.repetition is not None: - for disp in ss.repetition.displacements: - polys.append(v_base + disp) - else: - polys.append(v_base) - poly_cache[pid] = polys - return poly_cache[pid] + offset = numpy.asarray(offset, dtype=float) - all_polygons: list[NDArray[numpy.float64]] = [] - port_info: list[tuple[str, NDArray[numpy.float64], float]] = [] - - def collect_polys_recursive( - pat: 'Pattern', - c_offset: NDArray[numpy.float64], - c_rotation: float, - c_mirrored: bool, - c_scale: float, - ) -> None: - # Current transform: T(c_offset) * R(c_rotation) * M(c_mirrored) * S(c_scale) - - # 1. Transform and collect local polygons - local_polys = get_local_polys(pat) - if local_polys: - rot_mat = rotation_matrix_2d(c_rotation) - for v in local_polys: - vt = v * c_scale - if c_mirrored: - vt = vt.copy() - vt[:, 1] *= -1 - vt = (rot_mat @ vt.T).T + c_offset - all_polygons.append(vt) - - # 2. Collect ports if requested - if ports: - for name, p in pat.ports.items(): - pt_v = p.offset * c_scale - if c_mirrored: - pt_v = pt_v.copy() - pt_v[1] *= -1 - pt_v = rotation_matrix_2d(c_rotation) @ pt_v + c_offset - - if p.rotation is not None: - pt_rot = p.rotation - if c_mirrored: - pt_rot = -pt_rot - pt_rot += c_rotation - port_info.append((name, pt_v, pt_rot)) - - # 3. Recurse into refs - for target, refs in pat.refs.items(): - if target is None or not refs: - continue - assert library is not None - target_pat = library[target] - for ref in refs: - # Ref order of operations: mirror, rotate, scale, translate, repeat - - # Combined scale and mirror - r_scale = c_scale * ref.scale - r_mirrored = c_mirrored ^ ref.mirrored - - # Combined rotation: push c_mirrored and c_rotation through ref.rotation - r_rot_relative = -ref.rotation if c_mirrored else ref.rotation - r_rotation = c_rotation + r_rot_relative - - # Offset composition helper - def get_full_offset(rel_offset: NDArray[numpy.float64]) -> NDArray[numpy.float64]: - o = rel_offset * c_scale - if c_mirrored: - o = o.copy() - o[1] *= -1 - return rotation_matrix_2d(c_rotation) @ o + c_offset - - if ref.repetition is not None: - for disp in ref.repetition.displacements: - collect_polys_recursive( - target_pat, - get_full_offset(ref.offset + disp), - r_rotation, - r_mirrored, - r_scale - ) - else: - collect_polys_recursive( - target_pat, - get_full_offset(ref.offset), - r_rotation, - r_mirrored, - r_scale - ) - - # Start recursive collection - collect_polys_recursive(self, numpy.asarray(offset, dtype=float), 0.0, False, 1.0) - - # Plotting if not overdraw: figure = pyplot.figure() + pyplot.axis('equal') else: figure = pyplot.gcf() axes = figure.gca() - if all_polygons: - mpl_poly_collection = matplotlib.collections.PolyCollection( - all_polygons, - facecolors = fill_color, - edgecolors = line_color, - ) - axes.add_collection(mpl_poly_collection) + polygons = [] + for shape in chain.from_iterable(self.shapes.values()): + polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()] - if ports: - for port_name, pt_v, pt_rot in port_info: - p1 = pt_v - angle = pt_rot - size = 1.0 # arrow size - p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) + mpl_poly_collection = matplotlib.collections.PolyCollection( + polygons, + facecolors=fill_color, + edgecolors=line_color, + ) + axes.add_collection(mpl_poly_collection) + pyplot.axis('equal') - axes.annotate( - port_name, - xy = tuple(p1), - xytext = tuple(p2), - arrowprops = dict(arrowstyle="->", color='g', linewidth=1), - color = 'g', - fontsize = 8, + for target, refs in self.refs.items(): + if target is None: + continue + if not refs: + continue + assert library is not None + target_pat = library[target] + for ref in refs: + ref.as_pattern(target_pat).visualize( + library=library, + offset=offset, + overdraw=True, + line_color=line_color, + fill_color=fill_color, ) - axes.autoscale_view() - axes.set_aspect('equal') - if not overdraw: - axes.set_xlabel('x') - axes.set_ylabel('y') - if filename: - figure.savefig(filename) - else: - figure.show() + pyplot.xlabel('x') + pyplot.ylabel('y') + pyplot.show() # @overload # def place( @@ -1398,7 +1111,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map: dict[str, str | None] | None = None, skip_port_check: bool = False, append: bool = False, - skip_geometry: bool = False, ) -> Self: """ Instantiate or append the pattern `other` into the current pattern, adding its @@ -1430,10 +1142,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). - skip_geometry: If `True`, the operation only updates the port list and - skips adding any geometry (shapes, labels, or references). This - allows the pattern assembly to proceed for port-tracking purposes - even when layout generation is suppressed. Returns: self @@ -1448,26 +1156,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map = {} if not skip_port_check: - port_map, overwrite_targets = self._resolve_insert_mapping( - other.ports.keys(), - map_in=None, - map_out=port_map, - allow_conflicts=skip_geometry, - ) - for target in overwrite_targets: - self.ports.pop(target, None) - - if not skip_geometry: - if append: - if isinstance(other, Abstract): - raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') - if other.annotations is not None and self.annotations is not None: - annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys()) - if annotation_conflicts: - raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') - elif isinstance(other, Pattern): - raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' - 'Use `append=True` if you intended to append the full geometry.') + self.check_ports(other.ports.keys(), map_in=None, map_out=port_map) ports = {} for name, port in other.ports.items(): @@ -1477,19 +1166,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ports[new_name] = port for name, port in ports.items(): - pp = port.deepcopy() + p = port.deepcopy() if mirrored: - pp.mirror() - pp.offset[1] *= -1 - pp.rotate_around(pivot, rotation) - pp.translate(offset) - self.ports[name] = pp - self._log_port_update(name) - - if skip_geometry: - return self + p.mirror() + p.rotate_around(pivot, rotation) + p.translate(offset) + self.ports[name] = p if append: + if isinstance(other, Abstract): + raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') other_copy = other.deepcopy() other_copy.ports.clear() if mirrored: @@ -1498,6 +1184,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): other_copy.translate_elements(offset) self.append(other_copy) else: + assert not isinstance(other, Pattern) ref = Ref(mirrored=mirrored) ref.rotate_around(pivot, rotation) ref.translate(offset) @@ -1512,7 +1199,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # map_out: dict[str, str | None] | None, # *, # mirrored: bool, -# thru: bool | str, +# inherit_name: bool, # set_rotation: bool | None, # append: Literal[False], # ) -> Self: @@ -1526,7 +1213,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # map_out: dict[str, str | None] | None, # *, # mirrored: bool, -# thru: bool | str, +# inherit_name: bool, # set_rotation: bool | None, # append: bool, # ) -> Self: @@ -1539,11 +1226,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_out: dict[str, str | None] | None = None, *, mirrored: bool = False, - thru: bool | str = True, + inherit_name: bool = True, set_rotation: bool | None = None, append: bool = False, ok_connections: Iterable[tuple[str, str]] = (), - skip_geometry: bool = False, ) -> Self: """ Instantiate or append a pattern into the current pattern, connecting @@ -1551,7 +1237,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ports specified by `map_out`. Examples: - ========= + ======list, === - `my_pat.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` instantiates `subdevice` into `my_pat`, plugging ports 'A' and 'B' of `my_pat` into ports 'C' and 'B' of `subdevice`. The connected ports @@ -1561,7 +1247,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): - `my_pat.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' of `my_pat`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out` argument is - provided, and the `thru` argument is not explicitly set to `False`, + 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. @@ -1574,15 +1260,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): new names for ports in `other`. mirrored: Enables mirroring `other` across the x axis prior to connecting any ports. - thru: If map_in specifies only a single port, `thru` provides a mechainsm - to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, - - If True (default), and `other` has only two ports total, and map_out - doesn't specify a name for the other port, its name is set to the key - in `map_in`, i.e. 'myport'. - - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). - An error is raised if that entry already exists. - - This makes it easy to extend a pattern with simple 2-port devices + 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 pattern 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 @@ -1593,15 +1275,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). - ok_connections: Set of additional allowed ptype combinations. - Ptypes accepted by the shared compatibility policy are always - allowed. Non-allowed ptype connections will emit a warning. - Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. - skip_geometry: If `True`, only ports are updated and geometry is - skipped. If a valid transform cannot be found (e.g. due to - misaligned ports), a 'best-effort' dummy transform is used - to ensure new ports are still added at approximate locations, - allowing downstream routing to continue. + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: self @@ -1614,88 +1292,44 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): `PortError` if the specified port mapping is not achieveable (the ports do not line up) """ + # If asked to inherit a name, check that all conditions are met + 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) - # If asked to inherit a name, check that all conditions are met - if isinstance(thru, str): - if not len(map_in) == 1: - raise PatternError(f'Got {thru=} but have multiple map_in entries; don\'t know which one to use') - if thru in map_out: - raise PatternError(f'Got {thru=} but tha port already exists in map_out') - map_out[thru] = next(iter(map_in.keys())) - elif (bool(thru) - and len(map_in) == 1 - and not map_out - 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()))} - - map_out, overwrite_targets = self._resolve_insert_mapping( - other.ports.keys(), + self.check_ports(other.ports.keys(), map_in, map_out) + translation, rotation, pivot = self.find_transform( + other, map_in, - map_out, - allow_conflicts=skip_geometry, + mirrored=mirrored, + set_rotation=set_rotation, + ok_connections=ok_connections, ) - if not skip_geometry: - if append: - if isinstance(other, Abstract): - raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') - if other.annotations is not None and self.annotations is not None: - annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys()) - if annotation_conflicts: - raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') - elif isinstance(other, Pattern): - raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' - 'Use `append=True` if you intended to append the full geometry.') - try: - translation, rotation, pivot = self.find_transform( - other, - map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, - ) - except PortError: - if not skip_geometry: - raise - logger.warning("Port transform failed for dead device. Using dummy transform.") - if map_in: - ki, vi = next(iter(map_in.items())) - s_port = self.ports[ki] - o_port = other.ports[vi].deepcopy() - if mirrored: - o_port.mirror() - o_port.offset[1] *= -1 - translation = s_port.offset - o_port.offset - rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0 - pivot = o_port.offset - else: - translation = numpy.zeros(2) - rotation = 0.0 - pivot = numpy.zeros(2) - - for target in overwrite_targets: - self.ports.pop(target, None) # get rid of plugged ports for ki, vi in map_in.items(): del self.ports[ki] - self._log_port_removal(ki) map_out[vi] = None + if isinstance(other, Pattern): + assert append, 'Got a name (not an abstract) but was asked to reference (not append)' + self.place( other, - offset = translation, - rotation = rotation, - pivot = pivot, - mirrored = mirrored, - port_map = map_out, - skip_port_check = True, - append = append, - skip_geometry = skip_geometry, + offset=translation, + rotation=rotation, + pivot=pivot, + mirrored=mirrored, + port_map=map_out, + skip_port_check=True, + append=append, ) return self @@ -1729,7 +1363,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): current device. Args: - source: A collection of ports (e.g. Pattern, Pather, or dict) + source: A collection of ports (e.g. Pattern, Builder, or dict) from which to create the interface. in_prefix: Prepended to port names for newly-created ports with reversed directions compared to the current device. @@ -1757,13 +1391,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): else: raise PatternError(f'Unable to get ports from {type(source)}: {source}') - if port_map is not None: + if port_map: if isinstance(port_map, dict): missing_inkeys = set(port_map.keys()) - set(orig_ports.keys()) - port_targets = list(port_map.values()) - duplicate_targets = {vv for vv in port_targets if port_targets.count(vv) > 1} - if duplicate_targets: - raise PortError(f'Duplicate targets in `port_map`: {duplicate_targets}') mapped_ports = {port_map[k]: v for k, v in orig_ports.items() if k in port_map} else: port_set = set(port_map) @@ -1829,8 +1459,6 @@ def map_layers( new_elements: defaultdict[layer_t, list[TT]] = defaultdict(list) for old_layer, seq in elements.items(): new_layer = map_layer(old_layer) - if new_layer is None: - raise PatternError(f'Layer mapping returned None for source layer {old_layer!r}') new_elements[new_layer].extend(seq) return new_elements diff --git a/masque/ports.py b/masque/ports.py index 552f081..1cc711a 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -1,8 +1,9 @@ from typing import overload, Self, NoReturn, Any from collections.abc import Iterable, KeysView, ValuesView, Mapping +import warnings +import traceback import logging import functools -import copy from collections import Counter from abc import ABCMeta, abstractmethod from itertools import chain @@ -11,17 +12,16 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable -from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d -from .error import PortError, format_stacktrace +from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable +from .utils import rotate_offsets_around +from .error import PortError logger = logging.getLogger(__name__) -port_logger = logging.getLogger('masque.ports') @functools.total_ordering -class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): +class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): """ A point at which a `Device` can be snapped to another `Device`. @@ -64,7 +64,7 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): return self._rotation @rotation.setter - def rotation(self, val: float | None) -> None: + def rotation(self, val: float) -> None: if val is None: self._rotation = None else: @@ -93,12 +93,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): def copy(self) -> Self: return self.deepcopy() - def __deepcopy__(self, memo: dict | None = None) -> Self: - memo = {} if memo is None else memo - new = copy.copy(self) - new._offset = self._offset.copy() - return new - def get_bounds(self) -> NDArray[numpy.float64]: return numpy.vstack((self.offset, self.offset)) @@ -107,28 +101,8 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): self.ptype = ptype return self - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - """ - Mirror the object across a line in the container's coordinate system. - - Note this operation is performed relative to the pattern's origin and modifies the port's offset. - - Args: - axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0. - 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) - self.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self - def mirror(self, axis: int = 0) -> Self: + self.offset[1 - axis] *= -1 if self.rotation is not None: self.rotation *= -1 self.rotation += axis * pi @@ -143,34 +117,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): self.rotation = rotation return self - def describe(self) -> str: - """ - Returns a human-readable description of the port's state including cardinal directions. - """ - deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None - - cardinal = "" - travel_dir = "" - - if self.rotation is not None: - dirs = {0: "East (+x)", 90: "North (+y)", 180: "West (-x)", 270: "South (-y)"} - # normalize to [0, 360) - deg_norm = deg % 360 - - # Find closest cardinal - closest = min(dirs.keys(), key=lambda x: abs((deg_norm - x + 180) % 360 - 180)) - if numpy.isclose((deg_norm - closest + 180) % 360 - 180, 0, atol=1e-3): - cardinal = f" ({dirs[closest]})" - - # Travel direction (rotation + 180) - t_deg = (deg_norm + 180) % 360 - closest_t = min(dirs.keys(), key=lambda x: abs((t_deg - x + 180) % 360 - 180)) - if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3): - travel_dir = f" (Travel -> {dirs[closest_t]})" - - deg_text = 'any' if deg is None else f'{deg:g}' - return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}" - def __repr__(self) -> str: if self.rotation is None: rot = 'any' @@ -199,28 +145,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): and self.rotation == other.rotation ) - def measure_travel(self, destination: 'Port') -> tuple[NDArray[numpy.float64], float | None]: - """ - Find the (travel, jog) distances and rotation angle from the current port to the provided - `destination` port. - - Travel is along the source port's axis (into the device interior), and jog is perpendicular, - with left of the travel direction corresponding to a positive jog. - - Args: - (self): Source `Port` - destination: Destination `Port` - - Returns - [travel, jog], rotation - """ - angle_in = self.rotation - angle_out = destination.rotation - assert angle_in is not None - dxy = rotation_matrix_2d(-angle_in) @ (destination.offset - self.offset) - angle = ((angle_out - angle_in) % (2 * pi)) if angle_out is not None else None - return dxy, angle - class PortList(metaclass=ABCMeta): __slots__ = () # Allow subclasses to use __slots__ @@ -236,19 +160,6 @@ class PortList(metaclass=ABCMeta): def ports(self, value: dict[str, Port]) -> None: pass - def _log_port_update(self, name: str) -> None: - """ Log the current state of the named port """ - port_logger.debug("Port %s: %s", name, self.ports[name].describe()) - - def _log_port_removal(self, name: str) -> None: - """ Log that the named port has been removed """ - port_logger.debug("Port %s: removed", name) - - def _log_bulk_update(self, label: str) -> None: - """ Log all current ports at DEBUG level """ - for name, port in self.ports.items(): - port_logger.debug("%s: Port %s: %s", label, name, port) - @overload def __getitem__(self, key: str) -> Port: pass @@ -273,12 +184,6 @@ class PortList(metaclass=ABCMeta): else: # noqa: RET505 return {k: self.ports[k] for k in key} - def measure_travel(self, src: str, dst: str) -> tuple[NDArray[numpy.float64], float | None]: - """ - Convenience wrapper for measuring travel between two named ports. - """ - return self[src].measure_travel(self[dst]) - def __contains__(self, key: str) -> NoReturn: raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.') @@ -308,7 +213,6 @@ class PortList(metaclass=ABCMeta): raise PortError(f'Port {name} already exists.') assert name not in self.ports self.ports[name] = value - self._log_port_update(name) return self def rename_ports( @@ -330,147 +234,17 @@ class PortList(metaclass=ABCMeta): Returns: self """ - self._rename_ports_impl(mapping, overwrite=overwrite) - return self - - @staticmethod - def _normalize_target_mapping( - ordered_targets: Iterable[tuple[str, str | None]], - explicit_map: Mapping[str, str | None] | None = None, - ) -> dict[str, str | None]: - ordered_targets = list(ordered_targets) - normalized = {} if explicit_map is None else copy.deepcopy(dict(explicit_map)) - winners = { - target: source - for source, target in ordered_targets - if target is not None - } - for source, target in ordered_targets: - if target is not None and winners[target] != source: - normalized[source] = None - return normalized - - def _resolve_insert_mapping( - self, - other_names: Iterable[str], - map_in: Mapping[str, str] | None = None, - map_out: Mapping[str, str | None] | None = None, - *, - allow_conflicts: bool = False, - ) -> tuple[dict[str, str | None], set[str]]: - if map_in is None: - map_in = {} - - normalized_map_out = {} if map_out is None else copy.deepcopy(dict(map_out)) - other_names = list(other_names) - other = set(other_names) - - missing_inkeys = set(map_in.keys()) - set(self.ports.keys()) - if missing_inkeys: - raise PortError(f'`map_in` keys not present in device: {missing_inkeys}') - - missing_invals = set(map_in.values()) - other - if missing_invals: - raise PortError(f'`map_in` values not present in other device: {missing_invals}') - - map_in_counts = Counter(map_in.values()) - conflicts_in = {kk for kk, vv in map_in_counts.items() if vv > 1} - if conflicts_in: - raise PortError(f'Duplicate values in `map_in`: {conflicts_in}') - - missing_outkeys = set(normalized_map_out.keys()) - other - if missing_outkeys: - raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}') - - connected_outkeys = set(normalized_map_out.keys()) & set(map_in.values()) - if connected_outkeys: - raise PortError(f'`map_out` keys conflict with connected ports: {connected_outkeys}') - - orig_remaining = set(self.ports.keys()) - set(map_in.keys()) - connected = set(map_in.values()) - if allow_conflicts: - ordered_targets = [ - (name, normalized_map_out.get(name, name)) - for name in other_names - if name not in connected - ] - normalized_map_out = self._normalize_target_mapping(ordered_targets, normalized_map_out) - final_targets = { - normalized_map_out.get(name, name) - for name in other_names - if name not in connected and normalized_map_out.get(name, name) is not None - } - overwrite_targets = {target for target in final_targets if target in orig_remaining} - return normalized_map_out, overwrite_targets - - other_remaining = other - set(normalized_map_out.keys()) - connected - mapped_vals = set(normalized_map_out.values()) - mapped_vals.discard(None) - - conflicts_final = orig_remaining & (other_remaining | mapped_vals) - if conflicts_final: - raise PortError(f'Device ports conflict with existing ports: {conflicts_final}') - - conflicts_partial = other_remaining & mapped_vals - if conflicts_partial: - raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}') - - map_out_counts = Counter(normalized_map_out.values()) - map_out_counts[None] = 0 - conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1} - if conflicts_out: - raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}') - return normalized_map_out, set() - - def _rename_ports_impl( - self, - mapping: Mapping[str, str | None], - *, - overwrite: bool = False, - allow_collisions: bool = False, - ) -> dict[str, str]: if not overwrite: duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values()) if duplicates: raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') - missing = set(mapping) - set(self.ports) - if missing: - raise PortError(f'Ports to rename were not found: {missing}') - renamed_targets = [vv for vv in mapping.values() if vv is not None] - if not allow_collisions: - duplicate_targets = {vv for vv in renamed_targets if renamed_targets.count(vv) > 1} - if duplicate_targets: - raise PortError(f'Renamed ports would collide: {duplicate_targets}') - winners = { - target: source - for source, target in mapping.items() - if target is not None - } - overwritten = { - target - for target, source in winners.items() - if target in self.ports and target not in mapping and target != source - } + renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} + if None in renamed: + del renamed[None] - for kk, vv in mapping.items(): - if vv is None or vv != kk: - self._log_port_removal(kk) - - source_ports = {kk: self.ports.pop(kk) for kk in mapping} - for target in overwritten: - self.ports.pop(target, None) - - renamed = { - vv: source_ports[kk] - for kk, vv in mapping.items() - if vv is not None and winners[vv] == kk - } self.ports.update(renamed) # type: ignore - - for vv in winners: - self._log_port_update(vv) - return winners + return self def add_port_pair( self, @@ -492,16 +266,12 @@ class PortList(metaclass=ABCMeta): Returns: self """ - if names[0] == names[1]: - raise PortError(f'Port names must be distinct: {names[0]!r}') new_ports = { names[0]: Port(offset, rotation=rotation, ptype=ptype), names[1]: Port(offset, rotation=rotation + pi, ptype=ptype), } self.check_ports(names) self.ports.update(new_ports) - self._log_port_update(names[0]) - self._log_port_update(names[1]) return self def plugged( @@ -524,34 +294,22 @@ class PortList(metaclass=ABCMeta): Raises: `PortError` if the ports are not properly aligned. """ - if not connections: - raise PortError('Must provide at least one port connection') - missing_a = set(connections) - set(self.ports) - if missing_a: - raise PortError(f'Connection source ports were not found: {missing_a}') - missing_b = set(connections.values()) - set(self.ports) - if missing_b: - raise PortError(f'Connection destination ports were not found: {missing_b}') a_names, b_names = list(zip(*connections.items(), strict=True)) - used_names = list(chain(a_names, b_names)) - duplicate_names = {name for name in used_names if used_names.count(name) > 1} - if duplicate_names: - raise PortError(f'Each port may appear in at most one connection: {duplicate_names}') a_ports = [self.ports[pp] for pp in a_names] b_ports = [self.ports[pp] for pp in b_names] a_types = [pp.ptype for pp in a_ports] b_types = [pp.ptype for pp in b_ports] - type_conflicts = numpy.array([not ptypes_compatible(at, bt) + type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt) for at, bt in zip(a_types, b_types, strict=True)]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' - for nn, (kk, vv) in enumerate(connections.items()): + for nn, (k, v) in enumerate(connections.items()): if type_conflicts[nn]: - msg += f'{kk} | {a_types[nn]}:{b_types[nn]} | {vv}\n' - msg += '\nStack trace:\n' + format_stacktrace() - logger.warning(msg) + msg += f'{k} | {a_types[nn]}:{b_types[nn]} | {v}\n' + msg = ''.join(traceback.format_stack()) + '\n' + msg + warnings.warn(msg, stacklevel=2) a_offsets = numpy.array([pp.offset for pp in a_ports]) b_offsets = numpy.array([pp.offset for pp in b_ports]) @@ -568,22 +326,21 @@ class PortList(metaclass=ABCMeta): if not numpy.allclose(rotations, 0): rot_deg = numpy.rad2deg(rotations) msg = 'Port orientations do not match:\n' - for nn, (kk, vv) in enumerate(connections.items()): + for nn, (k, v) in enumerate(connections.items()): if not numpy.isclose(rot_deg[nn], 0): - msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n' + msg += f'{k} | {rot_deg[nn]:g} | {v}\n' raise PortError(msg) translations = a_offsets - b_offsets - if not numpy.allclose(a_offsets, b_offsets): + if not numpy.allclose(translations, 0): msg = 'Port translations do not match:\n' - for nn, (kk, vv) in enumerate(connections.items()): - if not numpy.allclose(a_offsets[nn], b_offsets[nn]): - msg += f'{kk} | {translations[nn]} | {vv}\n' + for nn, (k, v) in enumerate(connections.items()): + if not numpy.allclose(translations[nn], 0): + msg += f'{k} | {translations[nn]} | {v}\n' raise PortError(msg) for pp in chain(a_names, b_names): del self.ports[pp] - self._log_port_removal(pp) return self def check_ports( @@ -614,7 +371,45 @@ class PortList(metaclass=ABCMeta): `PortError` if there are any duplicate names after `map_in` and `map_out` are applied. """ - self._resolve_insert_mapping(other_names, map_in, map_out) + 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 PortError(f'`map_in` keys not present in device: {missing_inkeys}') + + missing_invals = set(map_in.values()) - other + if missing_invals: + raise PortError(f'`map_in` values not present in other device: {missing_invals}') + + missing_outkeys = set(map_out.keys()) - other + if missing_outkeys: + raise PortError(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 PortError(f'Device ports conflict with existing ports: {conflicts_final}') + + conflicts_partial = other_remaining & mapped_vals + if conflicts_partial: + raise PortError(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 PortError(f'Duplicate targets in `map_out`: {conflicts_out}') + return self def find_transform( @@ -641,10 +436,11 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `other` should be rotated. Otherwise, `set_rotation` must remain `None`. - ok_connections: Set of additional allowed ptype combinations. - Ptypes accepted by the shared compatibility policy are always - allowed. Non-allowed ptype connections will log a warning. - Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -653,17 +449,15 @@ class PortList(metaclass=ABCMeta): The rotation should be performed before the translation. """ - if not map_in: - raise PortError('Must provide at least one port connection') s_ports = self[map_in.keys()] o_ports = other[map_in.values()] return self.find_port_transform( - s_ports = s_ports, - o_ports = o_ports, - map_in = map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, + s_ports=s_ports, + o_ports=o_ports, + map_in=map_in, + mirrored=mirrored, + set_rotation=set_rotation, + ok_connections=ok_connections, ) @staticmethod @@ -693,10 +487,11 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `o_ports` should be rotated. Otherwise, `set_rotation` must remain `None`. - ok_connections: Set of additional allowed ptype combinations. - Ptypes accepted by the shared compatibility policy are always - allowed. Non-allowed ptype connections will log a warning. - Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -705,8 +500,6 @@ class PortList(metaclass=ABCMeta): The rotation should be performed before the translation. """ - if not map_in: - raise PortError('Must provide at least one port connection') 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()] @@ -723,22 +516,20 @@ class PortList(metaclass=ABCMeta): o_rotations *= -1 ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]} - type_conflicts = numpy.array([ - not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs - for st, ot in zip(s_types, o_types, strict=True) - ]) + type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs) + for st, ot in zip(s_types, o_types, strict=True)]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' - for nn, (kk, vv) in enumerate(map_in.items()): + for nn, (k, v) in enumerate(map_in.items()): if type_conflicts[nn]: - msg += f'{kk} | {s_types[nn]}:{o_types[nn]} | {vv}\n' - msg += '\nStack trace:\n' + format_stacktrace() - logger.warning(msg) + 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: - raise PortError('Must provide set_rotation if rotation is indeterminate') + PortError('Must provide set_rotation if rotation is indeterminate') rotations[:] = set_rotation else: rotations[~has_rot] = rotations[has_rot][0] @@ -755,11 +546,8 @@ class PortList(metaclass=ABCMeta): translations = s_offsets - o_offsets if not numpy.allclose(translations[:1], translations): msg = 'Port translations do not match:\n' - common_translation = numpy.min(translations, axis=0) - msg += f'Common: {common_translation} \n' - msg += 'Deltas:\n' for nn, (kk, vv) in enumerate(map_in.items()): - msg += f'{kk} | {translations[nn] - common_translation} | {vv}\n' + msg += f'{kk} | {translations[nn]} | {vv}\n' raise PortError(msg) return translations[0], rotations[0], o_offsets[0] diff --git a/masque/ref.py b/masque/ref.py index 0cc911f..09e00b1 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -11,12 +11,11 @@ import numpy from numpy import pi from numpy.typing import NDArray, ArrayLike -from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key, SupportsBool +from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, - PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, - FlippableImpl, + Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ) @@ -26,9 +25,8 @@ if TYPE_CHECKING: @functools.total_ordering class Ref( - FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, - PositionableImpl, RotatableImpl, ScalableImpl, - Copyable, + PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, + PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ): """ `Ref` provides basic support for nesting Pattern objects within each other. @@ -44,7 +42,7 @@ class Ref( __slots__ = ( '_mirrored', # inherited - '_offset', '_rotation', '_scale', '_repetition', '_annotations', + '_offset', '_rotation', 'scale', '_repetition', '_annotations', ) _mirrored: bool @@ -52,11 +50,11 @@ class Ref( # Mirrored property @property - def mirrored(self) -> bool: + def mirrored(self) -> bool: # mypy#3004, setter should be SupportsBool return self._mirrored @mirrored.setter - def mirrored(self, val: SupportsBool) -> None: + def mirrored(self, val: bool) -> None: self._mirrored = bool(val) def __init__( @@ -86,48 +84,24 @@ class Ref( self.repetition = repetition self.annotations = annotations if annotations is not None else {} - @classmethod - def _from_raw( - cls, - *, - offset: NDArray[numpy.float64], - rotation: float, - mirrored: bool, - scale: float, - repetition: Repetition | None, - annotations: annotations_t | None, - ) -> Self: - new = cls.__new__(cls) - new._offset = offset - new._rotation = rotation % (2 * pi) - new._scale = scale - new._mirrored = mirrored - new._repetition = repetition - new._annotations = annotations - return new - def __copy__(self) -> 'Ref': new = Ref( offset=self.offset.copy(), rotation=self.rotation, scale=self.scale, mirrored=self.mirrored, - repetition=self.repetition, - annotations=self.annotations, + repetition=copy.deepcopy(self.repetition), + annotations=copy.deepcopy(self.annotations), ) return new def __deepcopy__(self, memo: dict | None = None) -> 'Ref': memo = {} if memo is None else memo new = copy.copy(self) - new._offset = self._offset.copy() - new.repetition = copy.deepcopy(self.repetition, memo) - new.annotations = copy.deepcopy(self.annotations, memo) + #new.repetition = copy.deepcopy(self.repetition, memo) + #new.annotations = copy.deepcopy(self.annotations, memo) return new - def copy(self) -> 'Ref': - return self.deepcopy() - def __lt__(self, other: 'Ref') -> bool: if (self.offset != other.offset).any(): return tuple(self.offset) < tuple(other.offset) @@ -142,8 +116,6 @@ class Ref( return annotations_lt(self.annotations, other.annotations) def __eq__(self, other: Any) -> bool: - if type(self) is not type(other): - return False return ( numpy.array_equal(self.offset, other.offset) and self.mirrored == other.mirrored @@ -188,16 +160,16 @@ class Ref( return pattern def rotate(self, rotation: float) -> Self: - """ - Intrinsic transformation: Rotate the target pattern relative to this Ref's - origin. This does NOT affect the repetition grid. - """ self.rotation += rotation + if self.repetition is not None: + self.repetition.rotate(rotation) return self def mirror(self, axis: int = 0) -> Self: self.mirror_target(axis) self.rotation *= -1 + if self.repetition is not None: + self.repetition.mirror(axis) return self def mirror_target(self, axis: int = 0) -> Self: @@ -215,11 +187,10 @@ class Ref( xys = self.offset[None, :] if self.repetition is not None: xys = xys + self.repetition.displacements - transforms = numpy.empty((xys.shape[0], 5)) + transforms = numpy.empty((xys.shape[0], 4)) transforms[:, :2] = xys transforms[:, 2] = self.rotation transforms[:, 3] = self.mirrored - transforms[:, 4] = self.scale return transforms def get_bounds_single( @@ -256,10 +227,7 @@ class Ref( bounds = numpy.vstack((numpy.min(corners, axis=0), numpy.max(corners, axis=0))) * self.scale + [self.offset] return bounds - - single_ref = self.deepcopy() - single_ref.repetition = None - return single_ref.as_pattern(pattern=pattern).get_bounds(library) + return self.as_pattern(pattern=pattern).get_bounds(library) def __repr__(self) -> str: rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' diff --git a/masque/repetition.py b/masque/repetition.py index 9e8af26..e6d00fc 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -34,7 +34,7 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A pass @abstractmethod - def __lt__(self, other: 'Repetition') -> bool: + def __le__(self, other: 'Repetition') -> bool: pass @abstractmethod @@ -64,7 +64,7 @@ class Grid(Repetition): _a_count: int """ Number of instances along the direction specified by the `a_vector` """ - _b_vector: NDArray[numpy.float64] + _b_vector: NDArray[numpy.float64] | None """ Vector `[x, y]` specifying a second lattice vector for the grid. Specifies center-to-center spacing between adjacent elements. Can be `None` for a 1D array. @@ -113,22 +113,6 @@ class Grid(Repetition): self.a_count = a_count self.b_count = b_count - @classmethod - def _from_raw( - cls: type[GG], - *, - a_vector: NDArray[numpy.float64], - a_count: int, - b_vector: NDArray[numpy.float64], - b_count: int, - ) -> GG: - new = cls.__new__(cls) - new._a_vector = a_vector - new._b_vector = b_vector - new._a_count = int(a_count) - new._b_count = int(b_count) - return new - @classmethod def aligned( cls: type[GG], @@ -200,8 +184,6 @@ class Grid(Repetition): def a_count(self, val: int) -> None: if val != int(val): raise PatternError('a_count must be convertable to an int!') - if int(val) < 1: - raise PatternError(f'Repetition has too-small a_count: {val}') self._a_count = int(val) # b_count property @@ -213,12 +195,13 @@ class Grid(Repetition): def b_count(self, val: int) -> None: if val != int(val): raise PatternError('b_count must be convertable to an int!') - if int(val) < 1: - raise PatternError(f'Repetition has too-small b_count: {val}') self._b_count = int(val) @property def displacements(self) -> NDArray[numpy.float64]: + if self.b_vector is None: + return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :] + aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij') return (aa.flatten()[:, None] * self.a_vector[None, :] + bb.flatten()[:, None] * self.b_vector[None, :]) # noqa @@ -308,7 +291,7 @@ class Grid(Repetition): return False return True - def __lt__(self, other: Repetition) -> bool: + def __le__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) other = cast('Grid', other) @@ -318,8 +301,12 @@ class Grid(Repetition): return self.b_count < other.b_count if not numpy.array_equal(self.a_vector, other.a_vector): return tuple(self.a_vector) < tuple(other.a_vector) + if self.b_vector is None: + return other.b_vector is not None + if other.b_vector is None: + return False if not numpy.array_equal(self.b_vector, other.b_vector): - return tuple(self.b_vector) < tuple(other.b_vector) + return tuple(self.a_vector) < tuple(other.a_vector) return False @@ -340,27 +327,12 @@ class Arbitrary(Repetition): """ @property - def displacements(self) -> NDArray[numpy.float64]: + def displacements(self) -> Any: # mypy#3004 NDArray[numpy.float64]: return self._displacements @displacements.setter def displacements(self, val: ArrayLike) -> None: - try: - vala = numpy.array(val, dtype=float) - except (TypeError, ValueError) as exc: - raise PatternError('displacements must be convertible to an Nx2 ndarray') from exc - - if vala.size == 0: - self._displacements = numpy.empty((0, 2), dtype=float) - return - - if vala.ndim == 1: - if vala.size != 2: - raise PatternError('displacements must be convertible to an Nx2 ndarray') - vala = vala.reshape(1, 2) - elif vala.ndim != 2 or vala.shape[1] != 2: - raise PatternError('displacements must be convertible to an Nx2 ndarray') - + vala = numpy.array(val, dtype=float) order = numpy.lexsort(vala.T[::-1]) # sortrows self._displacements = vala[order] @@ -378,11 +350,11 @@ class Arbitrary(Repetition): return (f'') def __eq__(self, other: Any) -> bool: - if type(other) is not type(self): + if not type(other) is not type(self): return False return numpy.array_equal(self.displacements, other.displacements) - def __lt__(self, other: Repetition) -> bool: + def __le__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) other = cast('Arbitrary', other) @@ -419,9 +391,7 @@ class Arbitrary(Repetition): Returns: self """ - new_displacements = self.displacements.copy() - new_displacements[:, 1 - axis] *= -1 - self.displacements = new_displacements + self.displacements[1 - axis] *= -1 return self def get_bounds(self) -> NDArray[numpy.float64] | None: @@ -432,8 +402,6 @@ class Arbitrary(Repetition): Returns: `[[x_min, y_min], [x_max, y_max]]` or `None` """ - if self.displacements.size == 0: - return None xy_min = numpy.min(self.displacements, axis=0) xy_max = numpy.max(self.displacements, axis=0) return numpy.array((xy_min, xy_max)) @@ -448,5 +416,6 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements = self.displacements * c + self.displacements *= c return self + diff --git a/masque/shapes/__init__.py b/masque/shapes/__init__.py index ac3a14b..8ad46ef 100644 --- a/masque/shapes/__init__.py +++ b/masque/shapes/__init__.py @@ -10,8 +10,6 @@ from .shape import ( ) from .polygon import Polygon as Polygon -from .poly_collection import PolyCollection as PolyCollection -from .rect_collection import RectCollection as RectCollection from .circle import Circle as Circle from .ellipse import Ellipse as Ellipse from .arc import Arc as Arc diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 35362a9..f3f4e1e 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -1,7 +1,6 @@ from typing import Any, cast import copy import functools -from enum import Enum import numpy from numpy import pi @@ -11,40 +10,20 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key -from ..traits import PositionableImpl @functools.total_ordering -class ArcAngleRef(Enum): - Center = 'center' - FocusPos = 'focus_pos' - FocusNeg = 'focus_neg' - - def __lt__(self, other: Any) -> bool: - if self.__class__ is not other.__class__: - return self.__class__.__name__ < other.__class__.__name__ - order = { - ArcAngleRef.Center: 0, - ArcAngleRef.FocusPos: 1, - ArcAngleRef.FocusNeg: 2, - } - return order[self] < order[other] - - -@functools.total_ordering -class Arc(PositionableImpl, Shape): +class Arc(Shape): """ - An elliptical arc, formed by cutting off an elliptical ring with two rays. - By default the rays exit from its center, but they can optionally exit from one of the - foci of the nominal ellipse. It has a position, two radii, a start and stop angle, - a rotation, and a width. + An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its + center. It has a position, two radii, a start and stop angle, a rotation, and a width. The radii define an ellipse; the ring is formed with radii +/- width/2. The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius. The start and stop angle are measured counterclockwise from the first (x) radius. """ __slots__ = ( - '_radii', '_angles', '_width', '_rotation', '_angle_ref', + '_radii', '_angles', '_width', '_rotation', # Inherited '_offset', '_repetition', '_annotations', ) @@ -61,14 +40,9 @@ class Arc(PositionableImpl, Shape): _width: float """ Width of the arc """ - _angle_ref: ArcAngleRef - """ Origin used by start/stop rays """ - - AngleRef = ArcAngleRef - # radius properties @property - def radii(self) -> NDArray[numpy.float64]: + def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ Return the radii `[rx, ry]` """ @@ -79,8 +53,8 @@ class Arc(PositionableImpl, Shape): val = numpy.array(val, dtype=float).flatten() if not val.size == 2: raise PatternError('Radii must have length 2') - if not val.min() > 0: - raise PatternError('Radii must be positive') + if not val.min() >= 0: + raise PatternError('Radii must be non-negative') self._radii = val @property @@ -89,8 +63,8 @@ class Arc(PositionableImpl, Shape): @radius_x.setter def radius_x(self, val: float) -> None: - if not val > 0: - raise PatternError('Radius must be positive') + if not val >= 0: + raise PatternError('Radius must be non-negative') self._radii[0] = val @property @@ -99,13 +73,13 @@ class Arc(PositionableImpl, Shape): @radius_y.setter def radius_y(self, val: float) -> None: - if not val > 0: - raise PatternError('Radius must be positive') + if not val >= 0: + raise PatternError('Radius must be non-negative') self._radii[1] = val # arc start/stop angle properties @property - def angles(self) -> NDArray[numpy.float64]: + def angles(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ Return the start and stop angles `[a_start, a_stop]`. Angles are measured from x-axis after rotation @@ -138,18 +112,6 @@ class Arc(PositionableImpl, Shape): def stop_angle(self, val: float) -> None: self.angles = (self.angles[0], val) - # Angle reference property - @property - def angle_ref(self) -> ArcAngleRef: - """ - Origin used to interpret start and stop angle rays. - """ - return self._angle_ref - - @angle_ref.setter - def angle_ref(self, val: ArcAngleRef | str) -> None: - self._angle_ref = ArcAngleRef(val) - # Rotation property @property def rotation(self) -> float: @@ -195,41 +157,28 @@ class Arc(PositionableImpl, Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t = None, - angle_ref: ArcAngleRef | str = ArcAngleRef.Center, + annotations: annotations_t | None = None, + raw: bool = False, ) -> None: - self.radii = radii - self.angles = angles - self.width = width - self.offset = offset - self.rotation = rotation - self.angle_ref = angle_ref - self.repetition = repetition - self.annotations = annotations - - @classmethod - def _from_raw( - cls, - *, - radii: NDArray[numpy.float64], - angles: NDArray[numpy.float64], - width: float, - offset: NDArray[numpy.float64], - rotation: float, - annotations: annotations_t = None, - repetition: Repetition | None = None, - angle_ref: ArcAngleRef | str = ArcAngleRef.Center, - ) -> 'Arc': - new = cls.__new__(cls) - new._radii = radii - new._angles = angles - new._width = width - new._offset = offset - new._rotation = rotation % (2 * pi) - new._angle_ref = ArcAngleRef(angle_ref) - new._repetition = repetition - new._annotations = annotations - return new + if raw: + assert isinstance(radii, numpy.ndarray) + assert isinstance(angles, numpy.ndarray) + assert isinstance(offset, numpy.ndarray) + self._radii = radii + self._angles = angles + self._width = width + self._offset = offset + self._rotation = rotation + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + else: + self.radii = radii + self.angles = angles + self.width = width + self.offset = offset + self.rotation = rotation + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} def __deepcopy__(self, memo: dict | None = None) -> 'Arc': memo = {} if memo is None else memo @@ -237,7 +186,6 @@ class Arc(PositionableImpl, Shape): new._offset = self._offset.copy() new._radii = self._radii.copy() new._angles = self._angles.copy() - new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -249,7 +197,6 @@ class Arc(PositionableImpl, Shape): and numpy.array_equal(self.angles, other.angles) and self.width == other.width and self.rotation == other.rotation - and self.angle_ref == other.angle_ref and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) ) @@ -266,8 +213,6 @@ class Arc(PositionableImpl, Shape): return tuple(self.radii) < tuple(other.radii) if not numpy.array_equal(self.angles, other.angles): return tuple(self.angles) < tuple(other.angles) - if self.angle_ref != other.angle_ref: - return self.angle_ref < other.angle_ref if not numpy.array_equal(self.offset, other.offset): return tuple(self.offset) < tuple(other.offset) if self.rotation != other.rotation: @@ -284,8 +229,6 @@ class Arc(PositionableImpl, Shape): if (num_vertices is None) and (max_arclen is None): raise PatternError('Max number of points and arclength left unspecified' + ' (default was also overridden)') - if max_arclen is not None and (numpy.isnan(max_arclen) or max_arclen <= 0): - raise PatternError('Max arclength must be positive and not NaN') r0, r1 = self.radii @@ -312,38 +255,29 @@ class Arc(PositionableImpl, Shape): return arc_lengths, tt wh = self.width / 2.0 - arclen_limits: list[float] = [] - if max_arclen is not None: - arclen_limits.append(max_arclen) if num_vertices is not None: n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int) perimeter_inner = get_arclens(n_pts, *a_ranges[0], dr=-wh)[0].sum() perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum() implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices - if not (numpy.isnan(implied_arclen) or implied_arclen <= 0): - arclen_limits.append(implied_arclen) - if not arclen_limits: - raise PatternError('Arc polygonization could not determine a valid max_arclen') - max_arclen = min(arclen_limits) + max_arclen = min(implied_arclen, max_arclen if max_arclen is not None else numpy.inf) + assert max_arclen is not None def get_thetas(inner: bool) -> NDArray[numpy.float64]: """ Figure out the parameter values at which we should place vertices to meet the arclength constraint""" dr = -wh if inner else wh - n_pts = max(2, int(numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen))) + n_pts = numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen).astype(int) arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) keep = [0] - start = 0 + removable = (numpy.cumsum(arc_lengths) <= max_arclen) + start = 1 while start < arc_lengths.size: - removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen) - if not removable.any(): - next_to_keep = start + 1 - else: - next_to_keep = start + numpy.where(removable)[0][-1] + 1 + next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough? keep.append(next_to_keep) - start = next_to_keep - + removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen) + start = next_to_keep + 1 if keep[-1] != thetas.size - 1: keep.append(thetas.size - 1) @@ -375,58 +309,81 @@ class Arc(PositionableImpl, Shape): return [poly] def get_bounds_single(self) -> NDArray[numpy.float64]: + """ + Equation for rotated ellipse is + `x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)` + `y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)` + where `t` is our parameter. + + Differentiating and solving for 0 slope wrt. `t`, we find + `tan(t) = -+ b/a cot(phi)` + where -+ is for x, y cases, so that's where the extrema are. + + If the extrema are innaccessible due to arc constraints, check the arc endpoints instead. + """ a_ranges = cast('_array2x2_t', self._angles_to_parameters()) - sin_r = numpy.sin(self.rotation) - cos_r = numpy.cos(self.rotation) - def point(rx: float, ry: float, tt: float) -> NDArray[numpy.float64]: - return numpy.array(( - rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r, - rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r, - )) - - def points_in_interval(rx: float, ry: float, a0: float, a1: float) -> list[NDArray[numpy.float64]]: - candidates = [a0, a1] - if rx != 0 and ry != 0: - tx = numpy.arctan2(-ry * sin_r, rx * cos_r) - ty = numpy.arctan2(ry * cos_r, rx * sin_r) - candidates.extend((tx, tx + pi, ty, ty + pi)) - - lo = min(a0, a1) - hi = max(a0, a1) - pts = [] - for base in candidates: - k_min = int(numpy.floor((lo - base) / (2 * pi))) - 1 - k_max = int(numpy.ceil((hi - base) / (2 * pi))) + 1 - for kk in range(k_min, k_max + 1): - tt = base + kk * 2 * pi - if lo <= tt <= hi: - pts.append(point(rx, ry, tt)) - return pts - - pts = [] + mins = [] + maxs = [] for aa, sgn in zip(a_ranges, (-1, +1), strict=True): wh = sgn * self.width / 2 rx = self.radius_x + wh ry = self.radius_y + wh - if rx == 0 or ry == 0: - pts.append(numpy.zeros(2)) - continue - pts.extend(points_in_interval(rx, ry, aa[0], aa[1])) - all_pts = numpy.asarray(pts) + self.offset - return numpy.vstack((numpy.min(all_pts, axis=0), - numpy.max(all_pts, axis=0))) + if rx == 0 or ry == 0: + # Single point, at origin + mins.append([0, 0]) + maxs.append([0, 0]) + continue + + a0, a1 = aa + a0_offset = a0 - (a0 % (2 * pi)) + + sin_r = numpy.sin(self.rotation) + cos_r = numpy.cos(self.rotation) + sin_a = numpy.sin(aa) + cos_a = numpy.cos(aa) + + # Cutoff angles + xpt = (-self.rotation) % (2 * pi) + a0_offset + ypt = (pi / 2 - self.rotation) % (2 * pi) + a0_offset + xnt = (xpt - pi) % (2 * pi) + a0_offset + ynt = (ypt - pi) % (2 * pi) + a0_offset + + # Points along coordinate axes + rx2_inv = 1 / (rx * rx) + ry2_inv = 1 / (ry * ry) + xr = numpy.abs(cos_r * cos_r * rx2_inv + sin_r * sin_r * ry2_inv) ** -0.5 + yr = numpy.abs(-sin_r * -sin_r * rx2_inv + cos_r * cos_r * ry2_inv) ** -0.5 + + # Arc endpoints + xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a) + yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) + + # If our arc subtends a coordinate axis, use the extremum along that axis + if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1: + xp = xr + + if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1: + xn = -xr + + if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1: + yp = yr + + if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1: + yn = -yr + + mins.append([xn, yn]) + maxs.append([xp, yp]) + return numpy.vstack((numpy.min(mins, axis=0) + self.offset, + numpy.max(maxs, axis=0) + self.offset)) def rotate(self, theta: float) -> 'Arc': self.rotation += theta return self def mirror(self, axis: int = 0) -> 'Arc': - # Both external reflections use a local Y reflection; the extra pi - # rotation below accounts for the external axis. - if self.angle_ref != ArcAngleRef.Center and self.radius_y > self.radius_x: - self._swap_focus_ref() + self.offset[axis - 1] *= -1 self.rotation *= -1 self.rotation += axis * pi self.angles *= -1 @@ -438,7 +395,6 @@ class Arc(PositionableImpl, Shape): return self def normalized_form(self, norm_value: float) -> normalized_shape_tuple: - angle_ref = self.angle_ref if self.radius_x < self.radius_y: radii = self.radii / self.radius_x scale = self.radius_x @@ -449,26 +405,23 @@ class Arc(PositionableImpl, Shape): scale = self.radius_y rotation = self.rotation + pi / 2 angles = self.angles - pi / 2 - angle_ref = _swapped_focus_ref(angle_ref) delta_angle = angles[1] - angles[0] start_angle = angles[0] % (2 * pi) if start_angle >= pi: start_angle -= pi rotation += pi - angle_ref = _swapped_focus_ref(angle_ref) - norm_angles = (start_angle, start_angle + delta_angle) + angles = (start_angle, start_angle + delta_angle) rotation %= 2 * pi width = self.width - return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value), + return ((type(self), radii, angles, width / norm_value), (self.offset, scale / norm_value, rotation, False), lambda: Arc( radii=radii * norm_value, - angles=norm_angles, + angles=angles, width=width * norm_value, - angle_ref=angle_ref, )) def get_cap_edges(self) -> NDArray[numpy.float64]: @@ -479,16 +432,27 @@ class Arc(PositionableImpl, Shape): [[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse. ``` """ - a_ranges = self._angles_to_parameters() + a_ranges = cast('_array2x2_t', self._angles_to_parameters()) - cuts = [] - for index in range(2): - edge = [] - for aa, sgn in zip(a_ranges, (-1, +1), strict=True): - wh = sgn * self.width / 2 - edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index])) - cuts.append(edge) - return numpy.array(cuts) + self.offset + mins = [] + maxs = [] + for aa, sgn in zip(a_ranges, (-1, +1), strict=True): + wh = sgn * self.width / 2 + rx = self.radius_x + wh + ry = self.radius_y + wh + + sin_r = numpy.sin(self.rotation) + cos_r = numpy.cos(self.rotation) + sin_a = numpy.sin(aa) + cos_a = numpy.cos(aa) + + # arc endpoints + xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a) + yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) + + mins.append([xn, yn]) + maxs.append([xp, yp]) + return numpy.array([mins, maxs]) + self.offset def _angles_to_parameters(self) -> NDArray[numpy.float64]: """ @@ -499,111 +463,22 @@ class Arc(PositionableImpl, Shape): `[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]` """ aa = [] - d_angle = self.angles[1] - self.angles[0] - if abs(d_angle) >= 2 * pi: - # Full ring - return numpy.tile([0, 2 * pi], (2, 1)).astype(float) - for sgn in (-1, +1): wh = sgn * self.width / 2.0 rx = self.radius_x + wh ry = self.radius_y + wh - a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles) - sign = numpy.sign(d_angle) + a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles) + sign = numpy.sign(self.angles[1] - self.angles[0]) if sign != numpy.sign(a1 - a0): a1 += sign * 2 * pi aa.append((a0, a1)) return numpy.array(aa, dtype=float) - def _angle_to_parameter(self, angle: float, rx: float, ry: float) -> float: - """ - Convert an angle-reference ray to the ellipse parameter for one boundary edge. - - Center-referenced arcs convert the ray angle from polar coordinates about the origin. - Focus-referenced arcs solve the forward ray/ellipse intersection from the selected - nominal focus and return the parameter `t` for `[rx*cos(t), ry*sin(t)]`. - """ - if self.angle_ref == ArcAngleRef.Center: - return numpy.arctan2(rx * numpy.sin(angle), ry * numpy.cos(angle)) - - focus = self._focus_point() - if rx <= 0 or ry <= 0: - raise PatternError('Focus-referenced arc boundary radii must be positive') - - fx, fy = focus - origin_position = fx * fx / (rx * rx) + fy * fy / (ry * ry) - if origin_position >= 1: - raise PatternError('Focus-referenced arc ray origin must be inside both arc boundary ellipses') - - dx = numpy.cos(angle) - dy = numpy.sin(angle) - aa = dx * dx / (rx * rx) + dy * dy / (ry * ry) - bb = 2 * (fx * dx / (rx * rx) + fy * dy / (ry * ry)) - cc = origin_position - 1 - determinant = bb * bb - 4 * aa * cc - if determinant < 0: - raise PatternError('Focus-referenced arc ray does not intersect boundary ellipse') - - roots = numpy.array(( - (-bb - numpy.sqrt(determinant)) / (2 * aa), - (-bb + numpy.sqrt(determinant)) / (2 * aa), - )) - positive_roots = roots[roots > 0] - if positive_roots.size != 1: - raise PatternError('Focus-referenced arc ray must have exactly one forward boundary intersection') - - point = focus + positive_roots[0] * numpy.array((dx, dy)) - return numpy.arctan2(point[1] / ry, point[0] / rx) - - def _focus_point(self) -> NDArray[numpy.float64]: - """ - Return the selected nominal focus in the arc's unrotated local coordinates. - - `FocusPos` and `FocusNeg` select opposite directions along the major axis. Circles - have coincident foci, so both focus modes intentionally collapse to the center. - """ - if self.angle_ref == ArcAngleRef.Center or self.radius_x == self.radius_y: - return numpy.zeros(2) - - sign = 1 if self.angle_ref == ArcAngleRef.FocusPos else -1 - if self.radius_x > self.radius_y: - return numpy.array((sign * numpy.sqrt(self.radius_x * self.radius_x - self.radius_y * self.radius_y), 0.0)) - return numpy.array((0.0, sign * numpy.sqrt(self.radius_y * self.radius_y - self.radius_x * self.radius_x))) - - def _point_on_edge(self, rx: float, ry: float, tt: float) -> NDArray[numpy.float64]: - """ - Return a rotated local-space point on a boundary ellipse, before applying offset. - """ - sin_r = numpy.sin(self.rotation) - cos_r = numpy.cos(self.rotation) - return numpy.array(( - rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r, - rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r, - )) - - def _swap_focus_ref(self) -> None: - """ - Swap `focus_pos` and `focus_neg`, leaving center-referenced arcs unchanged. - """ - self.angle_ref = _swapped_focus_ref(self.angle_ref) - def __repr__(self) -> str: angles = f' a°{numpy.rad2deg(self.angles)}' rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' - angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else '' - return f'' - - -def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef: - """ - Return the opposite focus reference, or center for center-referenced arcs. - """ - if angle_ref == ArcAngleRef.FocusPos: - return ArcAngleRef.FocusNeg - if angle_ref == ArcAngleRef.FocusNeg: - return ArcAngleRef.FocusPos - return angle_ref + return f'' _array2x2_t = tuple[tuple[float, float], tuple[float, float]] diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index d7591db..2d403b4 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -10,11 +10,10 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key -from ..traits import PositionableImpl @functools.total_ordering -class Circle(PositionableImpl, Shape): +class Circle(Shape): """ A circle, which has a position and radius. """ @@ -49,34 +48,25 @@ class Circle(PositionableImpl, Shape): *, offset: ArrayLike = (0.0, 0.0), repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, + raw: bool = False, ) -> None: - self.radius = radius - self.offset = offset - self.repetition = repetition - self.annotations = annotations - - @classmethod - def _from_raw( - cls, - *, - radius: float, - offset: NDArray[numpy.float64], - annotations: annotations_t = None, - repetition: Repetition | None = None, - ) -> 'Circle': - new = cls.__new__(cls) - new._radius = radius - new._offset = offset - new._repetition = repetition - new._annotations = annotations - return new + if raw: + assert isinstance(offset, numpy.ndarray) + self._radius = radius + self._offset = offset + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + else: + self.radius = radius + self.offset = offset + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} def __deepcopy__(self, memo: dict | None = None) -> 'Circle': memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() - new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -117,7 +107,7 @@ class Circle(PositionableImpl, Shape): n += [num_vertices] if max_arclen is not None: n += [2 * pi * self.radius / max_arclen] - num_vertices = max(3, int(round(max(n)))) + num_vertices = int(round(max(n))) thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False) xs = numpy.cos(thetas) * self.radius ys = numpy.sin(thetas) * self.radius @@ -133,6 +123,7 @@ class Circle(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused) + self.offset *= -1 return self def scale_by(self, c: float) -> 'Circle': diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 52a3297..0d6a6c5 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -11,11 +11,10 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key -from ..traits import PositionableImpl @functools.total_ordering -class Ellipse(PositionableImpl, Shape): +class Ellipse(Shape): """ An ellipse, which has a position, two radii, and a rotation. The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius. @@ -34,7 +33,7 @@ class Ellipse(PositionableImpl, Shape): # radius properties @property - def radii(self) -> NDArray[numpy.float64]: + def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ Return the radii `[rx, ry]` """ @@ -42,7 +41,7 @@ class Ellipse(PositionableImpl, Shape): @radii.setter def radii(self, val: ArrayLike) -> None: - val = numpy.array(val, dtype=float).flatten() + val = numpy.array(val).flatten() if not val.size == 2: raise PatternError('Radii must have length 2') if not val.min() >= 0: @@ -94,38 +93,29 @@ class Ellipse(PositionableImpl, Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, + raw: bool = False, ) -> None: - self.radii = radii - self.offset = offset - self.rotation = rotation - self.repetition = repetition - self.annotations = annotations - - @classmethod - def _from_raw( - cls, - *, - radii: NDArray[numpy.float64], - offset: NDArray[numpy.float64], - rotation: float, - annotations: annotations_t = None, - repetition: Repetition | None = None, - ) -> Self: - new = cls.__new__(cls) - new._radii = radii - new._offset = offset - new._rotation = rotation % pi - new._repetition = repetition - new._annotations = annotations - return new + if raw: + assert isinstance(radii, numpy.ndarray) + assert isinstance(offset, numpy.ndarray) + self._radii = radii + self._offset = offset + self._rotation = rotation + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + else: + self.radii = radii + self.offset = offset + self.rotation = rotation + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() new._radii = self._radii.copy() - new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -177,7 +167,7 @@ class Ellipse(PositionableImpl, Shape): n += [num_vertices] if max_arclen is not None: n += [perimeter / max_arclen] - num_vertices = max(3, int(round(max(n)))) + num_vertices = int(round(max(n))) thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False) sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas)) @@ -189,19 +179,16 @@ class Ellipse(PositionableImpl, Shape): return [poly] def get_bounds_single(self) -> NDArray[numpy.float64]: - cos_r = numpy.cos(self.rotation) - sin_r = numpy.sin(self.rotation) - x_extent = numpy.sqrt((self.radius_x * cos_r) ** 2 + (self.radius_y * sin_r) ** 2) - y_extent = numpy.sqrt((self.radius_x * sin_r) ** 2 + (self.radius_y * cos_r) ** 2) - extents = numpy.array((x_extent, y_extent)) - return numpy.vstack((self.offset - extents, - self.offset + extents)) + rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii) + return numpy.vstack((self.offset - rot_radii[0], + self.offset + rot_radii[1])) def rotate(self, theta: float) -> Self: self.rotation += theta return self def mirror(self, axis: int = 0) -> Self: + self.offset[axis - 1] *= -1 self.rotation *= -1 self.rotation += axis * pi return self @@ -219,7 +206,7 @@ class Ellipse(PositionableImpl, Shape): radii = self.radii[::-1] / self.radius_y scale = self.radius_y angle = (self.rotation + pi / 2) % pi - return ((type(self), tuple(radii.tolist())), + return ((type(self), radii), (self.offset, scale / norm_value, angle, False), lambda: Ellipse(radii=radii * norm_value)) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index a1e04af..93e85ea 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -1,4 +1,4 @@ -from typing import Any, cast, Self +from typing import Any, cast from collections.abc import Sequence import copy import functools @@ -24,22 +24,14 @@ class PathCap(Enum): # # defined by path.cap_extensions def __lt__(self, other: Any) -> bool: - if self.__class__ is not other.__class__: - return self.__class__.__name__ < other.__class__.__name__ - # Order: Flush, Square, Circle, SquareCustom - order = { - PathCap.Flush: 0, - PathCap.Square: 1, - PathCap.Circle: 2, - PathCap.SquareCustom: 3, - } - return order[self] < order[other] + return self.value == other.value @functools.total_ordering class Path(Shape): """ - A path, consisting of a bunch of vertices (Nx2 ndarray), a width, and an end-cap shape. + A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape, + and an offset. Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates. @@ -48,7 +40,7 @@ class Path(Shape): __slots__ = ( '_vertices', '_width', '_cap', '_cap_extensions', # Inherited - '_repetition', '_annotations', + '_offset', '_repetition', '_annotations', ) _vertices: NDArray[numpy.float64] _width: float @@ -88,14 +80,14 @@ class Path(Shape): def cap(self, val: PathCap) -> None: self._cap = PathCap(val) if self.cap != PathCap.SquareCustom: - self._cap_extensions = None - elif self._cap_extensions is None: + self.cap_extensions = None + elif self.cap_extensions is None: # just got set to SquareCustom - self._cap_extensions = numpy.zeros(2) + self.cap_extensions = numpy.zeros(2) # cap_extensions property @property - def cap_extensions(self) -> NDArray[numpy.float64] | None: + def cap_extensions(self) -> Any | None: # mypy#3004 NDArray[numpy.float64]]: """ Path end-cap extension @@ -121,7 +113,7 @@ class Path(Shape): # vertices property @property - def vertices(self) -> NDArray[numpy.float64]: + def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]]: """ Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]` @@ -168,28 +160,6 @@ class Path(Shape): raise PatternError('Wrong number of vertices') self.vertices[:, 1] = val - # Offset property for `Positionable` - @property - def offset(self) -> NDArray[numpy.float64]: - """ - [x, y] offset - """ - return numpy.zeros(2) - - @offset.setter - def offset(self, val: ArrayLike) -> None: - if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') - - def set_offset(self, val: ArrayLike) -> Self: - if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') - return self - - def translate(self, offset: ArrayLike) -> Self: - self._vertices += numpy.atleast_2d(offset) - return self - def __init__( self, vertices: ArrayLike, @@ -200,57 +170,46 @@ class Path(Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, + raw: bool = False, ) -> None: self._cap_extensions = None # Since .cap setter might access it - self.vertices = vertices - self.repetition = repetition - self.annotations = annotations - self._cap = cap - if cap == PathCap.SquareCustom and cap_extensions is None: - self._cap_extensions = numpy.zeros(2) + if raw: + assert isinstance(vertices, numpy.ndarray) + assert isinstance(offset, numpy.ndarray) + assert isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None + self._vertices = vertices + self._offset = offset + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + self._width = width + self._cap = cap + self._cap_extensions = cap_extensions else: + self.vertices = vertices + self.offset = offset + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} + self.width = width + self.cap = cap self.cap_extensions = cap_extensions - self.width = width - if rotation: - self.rotate(rotation) - if numpy.any(offset): - self.translate(offset) - - @classmethod - def _from_raw( - cls, - *, - vertices: NDArray[numpy.float64], - width: float, - cap: PathCap, - cap_extensions: NDArray[numpy.float64] | None = None, - annotations: annotations_t = None, - repetition: Repetition | None = None, - ) -> Self: - new = cls.__new__(cls) - new._vertices = vertices - new._width = width - new._cap = cap - new._cap_extensions = cap_extensions - new._repetition = repetition - new._annotations = annotations - return new + self.rotate(rotation) def __deepcopy__(self, memo: dict | None = None) -> 'Path': memo = {} if memo is None else memo new = copy.copy(self) + new._offset = self._offset.copy() new._vertices = self._vertices.copy() new._cap = copy.deepcopy(self._cap, memo) new._cap_extensions = copy.deepcopy(self._cap_extensions, memo) - new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new def __eq__(self, other: Any) -> bool: return ( type(self) is type(other) + and numpy.array_equal(self.offset, other.offset) and numpy.array_equal(self.vertices, other.vertices) and self.width == other.width and self.cap == other.cap @@ -275,14 +234,8 @@ class Path(Shape): if self.cap_extensions is None: return True return tuple(self.cap_extensions) < tuple(other.cap_extensions) - if not numpy.array_equal(self.vertices, other.vertices): - min_len = min(self.vertices.shape[0], other.vertices.shape[0]) - eq_mask = self.vertices[:min_len] != other.vertices[:min_len] - eq_lt = self.vertices[:min_len] < other.vertices[:min_len] - eq_lt_masked = eq_lt[eq_mask] - if eq_lt_masked.size > 0: - return eq_lt_masked.flat[0] - return self.vertices.shape[0] < other.vertices.shape[0] + 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) @@ -333,34 +286,13 @@ class Path(Shape): ) -> list['Polygon']: extensions = self._calculate_cap_extensions() - v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) + v = remove_colinear_vertices(self.vertices, closed_path=False) dv = numpy.diff(v, axis=0) - norms = numpy.sqrt((dv * dv).sum(axis=1)) - - # Filter out zero-length segments if any remained after remove_colinear_vertices - valid = (norms > 1e-18) - if not numpy.all(valid): - # This shouldn't happen much if remove_colinear_vertices is working - v = v[numpy.append(valid, True)] - dv = numpy.diff(v, axis=0) - norms = norms[valid] - - if dv.shape[0] == 0: - # All vertices were the same. It's a point. - if self.width == 0: - return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate - if self.cap == PathCap.Circle: - return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen) - if self.cap == PathCap.Square: - return [Polygon.square(side_length=self.width, offset=v[0])] - # Flush or CustomSquare - return [Polygon(vertices=numpy.zeros((3, 2)))] - - dvdir = dv / norms[:, None] + dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] if self.width == 0: verts = numpy.vstack((v, v[::-1])) - return [Polygon(vertices=verts)] + return [Polygon(offset=self.offset, vertices=verts)] perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2 @@ -375,21 +307,11 @@ class Path(Shape): bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] - try: - # Vectorized solve for all intersections - # solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1) - rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0] - rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0] - except numpy.linalg.LinAlgError: - # Fallback to slower lstsq if some segments are parallel (singular matrix) - rp = numpy.zeros(As.shape[0]) - rn = numpy.zeros(As.shape[0]) - for ii in range(As.shape[0]): - rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0] - rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0] + rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0] + rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0] - intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1] - intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1] + intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] + intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1] towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp? # straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight @@ -421,7 +343,7 @@ class Path(Shape): o1.append(v[-1] - perp[-1]) verts = numpy.vstack((o0, o1[::-1])) - polys = [Polygon(vertices=verts)] + polys = [Polygon(offset=self.offset, vertices=verts)] if self.cap == PathCap.Circle: #for vert in v: # not sure if every vertex, or just ends? @@ -433,8 +355,8 @@ class Path(Shape): def get_bounds_single(self) -> NDArray[numpy.float64]: if self.cap == PathCap.Circle: - bounds = numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2, - numpy.max(self.vertices, axis=0) + self.width / 2)) + bounds = self.offset + numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2, + numpy.max(self.vertices, axis=0) + self.width / 2)) elif self.cap in ( PathCap.Flush, PathCap.Square, @@ -457,20 +379,18 @@ class Path(Shape): return self def mirror(self, axis: int = 0) -> 'Path': - self.vertices[:, 1 - axis] *= -1 + self.vertices[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Path': self.vertices *= c self.width *= c - if self.cap_extensions is not None: - self.cap_extensions *= c return self def normalized_form(self, norm_value: float) -> normalized_shape_tuple: # Note: this function is going to be pretty slow for many-vertexed paths, relative to # other shapes - offset = self.vertices.mean(axis=0) + offset = self.vertices.mean(axis=0) + self.offset zeroed_vertices = self.vertices - offset scale = zeroed_vertices.std() @@ -481,22 +401,21 @@ class Path(Shape): rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v) for v in normed_vertices]) - # Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller - if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat): - reordered_vertices = rotated_vertices[::-1] - else: - reordered_vertices = rotated_vertices + # Reorder the vertices so that the one with lowest x, then y, comes first. + x_min = rotated_vertices[:, 0].argmin() + if not is_scalar(x_min): + y_min = rotated_vertices[x_min, 1].argmin() + x_min = cast('Sequence', x_min)[y_min] + reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) width0 = self.width / norm_value - cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions) - return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0), + return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap), (offset, scale / norm_value, rotation, False), lambda: Path( reordered_vertices * norm_value, - width=width0 * norm_value, + width=self.width * norm_value, cap=self.cap, - cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0), )) def clean_vertices(self) -> 'Path': @@ -526,7 +445,7 @@ class Path(Shape): Returns: self """ - self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) + self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) return self def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: @@ -541,5 +460,5 @@ class Path(Shape): return extensions def __repr__(self) -> str: - centroid = self.vertices.mean(axis=0) + centroid = self.offset + self.vertices.mean(axis=0) return f'' diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py deleted file mode 100644 index f1c840a..0000000 --- a/masque/shapes/poly_collection.py +++ /dev/null @@ -1,235 +0,0 @@ -from typing import Any, cast, Self -from collections.abc import Iterator -import copy -import functools -from itertools import chain - -import numpy -from numpy import pi -from numpy.typing import NDArray, ArrayLike - -from . import Shape, normalized_shape_tuple -from .polygon import Polygon -from ..error import PatternError -from ..repetition import Repetition -from ..utils import rotation_matrix_2d, annotations_lt, annotations_eq, rep2key, annotations_t - - -@functools.total_ordering -class PolyCollection(Shape): - """ - A collection of polygons, consisting of concatenated vertex arrays (N_m x 2 ndarray) which specify - implicitly-closed boundaries, and an array of offets specifying the first vertex of each - successive polygon. - - A `normalized_form(...)` is available, but is untested and probably fairly slow. - """ - __slots__ = ( - '_vertex_lists', - '_vertex_offsets', - # Inherited - '_repetition', '_annotations', - ) - - _vertex_lists: NDArray[numpy.float64] - """ 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """ - - _vertex_offsets: NDArray[numpy.integer[Any]] - """ 1D NDArray specifying the starting offset for each polygon """ - - @property - def vertex_lists(self) -> NDArray[numpy.float64]: - """ - Vertices of the polygons, ((N+M+...) x 2). Use with `vertex_offsets`. - """ - return self._vertex_lists - - @property - def vertex_offsets(self) -> NDArray[numpy.integer[Any]]: - """ - Starting offset (in `vertex_lists`) for each polygon - """ - return self._vertex_offsets - - @property - def vertex_slices(self) -> Iterator[slice]: - """ - Iterator which provides slices which index vertex_lists - """ - if self._vertex_offsets.size == 0: - return - for ii, ff in zip( - self._vertex_offsets, - chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]), - strict=True, - ): - yield slice(int(ii), int(ff)) - - @property - def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]: - for slc in self.vertex_slices: - yield self._vertex_lists[slc] - - # Offset property for `Positionable` - @property - def offset(self) -> NDArray[numpy.float64]: - """ - [x, y] offset - """ - return numpy.zeros(2) - - @offset.setter - def offset(self, _val: ArrayLike) -> None: - raise PatternError('PolyCollection offset is forced to (0, 0)') - - def set_offset(self, val: ArrayLike) -> Self: - if numpy.any(val): - raise PatternError('PolyCollection offset is forced to (0, 0)') - return self - - def translate(self, offset: ArrayLike) -> Self: - self._vertex_lists += numpy.atleast_2d(offset) - return self - - def __init__( - self, - vertex_lists: ArrayLike, - vertex_offsets: ArrayLike, - *, - offset: ArrayLike = (0.0, 0.0), - rotation: float = 0.0, - repetition: Repetition | None = None, - annotations: annotations_t = None, - ) -> None: - self._vertex_lists = numpy.asarray(vertex_lists, dtype=float) - self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp) - self.repetition = repetition - self.annotations = annotations - if rotation: - self.rotate(rotation) - if numpy.any(offset): - self.translate(offset) - - @classmethod - def _from_raw( - cls, - *, - vertex_lists: NDArray[numpy.float64], - vertex_offsets: NDArray[numpy.integer[Any]], - annotations: annotations_t = None, - repetition: Repetition | None = None, - ) -> Self: - new = cls.__new__(cls) - new._vertex_lists = vertex_lists - new._vertex_offsets = vertex_offsets - new._repetition = repetition - new._annotations = annotations - return new - - def __deepcopy__(self, memo: dict | None = None) -> Self: - memo = {} if memo is None else memo - new = copy.copy(self) - new._vertex_lists = self._vertex_lists.copy() - new._vertex_offsets = self._vertex_offsets.copy() - new._repetition = copy.deepcopy(self._repetition, memo) - new._annotations = copy.deepcopy(self._annotations) - return new - - def __eq__(self, other: Any) -> bool: - return ( - type(self) is type(other) - and numpy.array_equal(self._vertex_lists, other._vertex_lists) - and numpy.array_equal(self.vertex_offsets, other.vertex_offsets) - and self.repetition == other.repetition - and annotations_eq(self.annotations, other.annotations) - ) - - def __lt__(self, other: Shape) -> bool: - if type(self) is not type(other): - if repr(type(self)) != repr(type(other)): - return repr(type(self)) < repr(type(other)) - return id(type(self)) < id(type(other)) - - other = cast('PolyCollection', other) - - for vv, oo in zip(self.polygon_vertices, other.polygon_vertices, strict=False): - if not numpy.array_equal(vv, oo): - min_len = min(vv.shape[0], oo.shape[0]) - eq_mask = vv[:min_len] != oo[:min_len] - eq_lt = vv[:min_len] < oo[:min_len] - eq_lt_masked = eq_lt[eq_mask] - if eq_lt_masked.size > 0: - return eq_lt_masked.flat[0] - return vv.shape[0] < oo.shape[0] - if len(self.vertex_lists) != len(other.vertex_lists): - return len(self.vertex_lists) < len(other.vertex_lists) - if self.repetition != other.repetition: - return rep2key(self.repetition) < rep2key(other.repetition) - return annotations_lt(self.annotations, other.annotations) - - def to_polygons( - self, - num_vertices: int | None = None, # unused # noqa: ARG002 - max_arclen: float | None = None, # unused # noqa: ARG002 - ) -> list['Polygon']: - return [Polygon( - vertices = vv, - repetition = copy.deepcopy(self.repetition), - annotations = copy.deepcopy(self.annotations), - ) for vv in self.polygon_vertices] - - def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition - if self._vertex_lists.size == 0: - return None - return numpy.vstack((numpy.min(self._vertex_lists, axis=0), - numpy.max(self._vertex_lists, axis=0))) - - def rotate(self, theta: float) -> Self: - if theta != 0: - rot = rotation_matrix_2d(theta) - self._vertex_lists = numpy.einsum('ij,kj->ki', rot, self._vertex_lists) - return self - - def mirror(self, axis: int = 0) -> Self: - self._vertex_lists[:, 1 - axis] *= -1 - return self - - def scale_by(self, c: float) -> Self: - self._vertex_lists *= c - return self - - def normalized_form(self, norm_value: float) -> normalized_shape_tuple: - # Note: this function is going to be pretty slow for many-vertexed polygons, relative to - # other shapes - meanv = self._vertex_lists.mean(axis=0) - zeroed_vertices = self._vertex_lists - [meanv] - offset = meanv - - scale = zeroed_vertices.std() - normed_vertices = zeroed_vertices / scale - - _, _, vertex_axis = numpy.linalg.svd(zeroed_vertices) - rotation = numpy.arctan2(vertex_axis[0][1], vertex_axis[0][0]) % (2 * pi) - rotated_vertices = numpy.einsum('ij,kj->ki', rotation_matrix_2d(-rotation), normed_vertices) - - # TODO consider how to reorder vertices for polycollection - ## Reorder the vertices so that the one with lowest x, then y, comes first. - #x_min = rotated_vertices[:, 0].argmin() - #if not is_scalar(x_min): - # y_min = rotated_vertices[x_min, 1].argmin() - # x_min = cast('Sequence', x_min)[y_min] - #reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) - - # TODO: normalize mirroring? - - return ((type(self), rotated_vertices.data.tobytes() + self.vertex_offsets.tobytes()), - (offset, scale / norm_value, rotation, False), - lambda: PolyCollection( - vertex_lists=rotated_vertices * norm_value, - vertex_offsets=self.vertex_offsets.copy(), - ), - ) - - def __repr__(self) -> str: - centroid = self.vertex_lists.mean(axis=0) - return f'' diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 06e5c2b..2976271 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING, Self, Literal +from typing import Any, cast, TYPE_CHECKING import copy import functools @@ -20,7 +20,7 @@ if TYPE_CHECKING: class Polygon(Shape): """ A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an - implicitly-closed boundary. + implicitly-closed boundary, and an offset. Note that the setter for `Polygon.vertices` creates a copy of the passed vertex coordinates. @@ -30,7 +30,7 @@ class Polygon(Shape): __slots__ = ( '_vertices', # Inherited - '_repetition', '_annotations', + '_offset', '_repetition', '_annotations', ) _vertices: NDArray[numpy.float64] @@ -38,7 +38,7 @@ class Polygon(Shape): # vertices property @property - def vertices(self) -> NDArray[numpy.float64]: + def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`) @@ -85,28 +85,6 @@ class Polygon(Shape): raise PatternError('Wrong number of vertices') self.vertices[:, 1] = val - # Offset property for `Positionable` - @property - def offset(self) -> NDArray[numpy.float64]: - """ - [x, y] offset - """ - return numpy.zeros(2) - - @offset.setter - def offset(self, val: ArrayLike) -> None: - if numpy.any(val): - raise PatternError('Polygon offset is forced to (0, 0)') - - def set_offset(self, val: ArrayLike) -> Self: - if numpy.any(val): - raise PatternError('Polygon offset is forced to (0, 0)') - return self - - def translate(self, offset: ArrayLike) -> Self: - self._vertices += numpy.atleast_2d(offset) - return self - def __init__( self, vertices: ArrayLike, @@ -114,41 +92,36 @@ class Polygon(Shape): offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, + raw: bool = False, ) -> None: - self.vertices = vertices - self.repetition = repetition - self.annotations = annotations + if raw: + assert isinstance(vertices, numpy.ndarray) + assert isinstance(offset, numpy.ndarray) + self._vertices = vertices + self._offset = offset + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + else: + self.vertices = vertices + self.offset = offset + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} if rotation: self.rotate(rotation) - if numpy.any(offset): - self.translate(offset) - - @classmethod - def _from_raw( - cls, - *, - vertices: NDArray[numpy.float64], - annotations: annotations_t = None, - repetition: Repetition | None = None, - ) -> Self: - new = cls.__new__(cls) - new._vertices = vertices - new._repetition = repetition - new._annotations = annotations - return new def __deepcopy__(self, memo: dict | None = None) -> 'Polygon': memo = {} if memo is None else memo new = copy.copy(self) + new._offset = self._offset.copy() new._vertices = self._vertices.copy() - new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new def __eq__(self, other: Any) -> bool: return ( type(self) is type(other) + and numpy.array_equal(self.offset, other.offset) and numpy.array_equal(self.vertices, other.vertices) and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) @@ -168,6 +141,8 @@ class Polygon(Shape): if eq_lt_masked.size > 0: return eq_lt_masked.flat[0] return self.vertices.shape[0] < other.vertices.shape[0] + 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) @@ -264,11 +239,6 @@ class Polygon(Shape): Returns: A Polygon object containing the requested rectangle """ - if sum(int(pp is None) for pp in (xmin, xmax, xctr, lx)) != 2: - raise PatternError('Exactly two of xmin, xctr, xmax, lx must be provided!') - if sum(int(pp is None) for pp in (ymin, ymax, yctr, ly)) != 2: - raise PatternError('Exactly two of ymin, yctr, ymax, ly must be provided!') - if lx is None: if xctr is None: assert xmin is not None @@ -278,11 +248,11 @@ class Polygon(Shape): elif xmax is None: assert xmin is not None assert xctr is not None - lx = 2.0 * (xctr - xmin) + lx = 2 * (xctr - xmin) elif xmin is None: assert xctr is not None assert xmax is not None - lx = 2.0 * (xmax - xctr) + lx = 2 * (xmax - xctr) else: raise PatternError('Two of xmin, xctr, xmax, lx must be None!') else: # noqa: PLR5501 @@ -308,11 +278,11 @@ class Polygon(Shape): elif ymax is None: assert ymin is not None assert yctr is not None - ly = 2.0 * (yctr - ymin) + ly = 2 * (yctr - ymin) elif ymin is None: assert yctr is not None assert ymax is not None - ly = 2.0 * (ymax - yctr) + ly = 2 * (ymax - yctr) else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') else: # noqa: PLR5501 @@ -329,7 +299,7 @@ class Polygon(Shape): else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') - poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition) + poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition) return poly @staticmethod @@ -393,8 +363,8 @@ class Polygon(Shape): return [copy.deepcopy(self)] def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition - return numpy.vstack((numpy.min(self.vertices, axis=0), - numpy.max(self.vertices, axis=0))) + return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0), + self.offset + numpy.max(self.vertices, axis=0))) def rotate(self, theta: float) -> 'Polygon': if theta != 0: @@ -402,7 +372,7 @@ class Polygon(Shape): return self def mirror(self, axis: int = 0) -> 'Polygon': - self.vertices[:, 1 - axis] *= -1 + self.vertices[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Polygon': @@ -414,7 +384,7 @@ class Polygon(Shape): # other shapes meanv = self.vertices.mean(axis=0) zeroed_vertices = self.vertices - meanv - offset = meanv + offset = meanv + self.offset scale = zeroed_vertices.std() normed_vertices = zeroed_vertices / scale @@ -425,15 +395,11 @@ class Polygon(Shape): for v in normed_vertices]) # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min_val = rotated_vertices[:, 0].min() - x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0] - if x_min_inds.size > 1: - y_min_val = rotated_vertices[x_min_inds, 1].min() - tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0] - start_ind = x_min_inds[tie_breaker] - else: - start_ind = x_min_inds[0] - reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0) + x_min = rotated_vertices[:, 0].argmin() + if not is_scalar(x_min): + y_min = rotated_vertices[x_min, 1].argmin() + x_min = cast('Sequence', x_min)[y_min] + reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) # TODO: normalize mirroring? @@ -472,25 +438,5 @@ class Polygon(Shape): return self def __repr__(self) -> str: - centroid = self.vertices.mean(axis=0) + centroid = self.offset + self.vertices.mean(axis=0) return f'' - - def boolean( - self, - other: Any, - operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', - scale: float = 1e6, - ) -> list['Polygon']: - """ - Perform a boolean operation using this polygon as the subject. - - Args: - other: Polygon, Iterable[Polygon], or raw vertices acting as the CLIP. - operation: 'union', 'intersection', 'difference', 'xor'. - scale: Scaling factor for integer conversion. - - Returns: - A list of resulting Polygons. - """ - from ..utils.boolean import boolean #noqa: PLC0415 - return boolean([self], other, operation=operation, scale=scale) diff --git a/masque/shapes/rect_collection.py b/masque/shapes/rect_collection.py deleted file mode 100644 index 291f23a..0000000 --- a/masque/shapes/rect_collection.py +++ /dev/null @@ -1,254 +0,0 @@ -from typing import Any, cast, Self -from collections.abc import Iterator -import copy -import functools - -import numpy -from numpy import pi -from numpy.typing import NDArray, ArrayLike - -from . import Shape, normalized_shape_tuple -from .polygon import Polygon -from ..error import PatternError -from ..repetition import Repetition -from ..utils import annotations_lt, annotations_eq, rep2key, annotations_t - - -def _normalize_rects(rects: ArrayLike) -> NDArray[numpy.float64]: - arr = numpy.asarray(rects, dtype=float) - if arr.ndim != 2 or arr.shape[1] != 4: - raise PatternError('Rectangles must be an Nx4 array of [xmin, ymin, xmax, ymax]') - if numpy.any(arr[:, 0] > arr[:, 2]) or numpy.any(arr[:, 1] > arr[:, 3]): - raise PatternError('Rectangles must satisfy xmin <= xmax and ymin <= ymax') - if arr.shape[0] <= 1: - return arr - order = numpy.lexsort((arr[:, 3], arr[:, 2], arr[:, 1], arr[:, 0])) - return arr[order] - - -def _renormalize_rects_in_place(rects: NDArray[numpy.float64]) -> None: - x0 = numpy.minimum(rects[:, 0], rects[:, 2]) - x1 = numpy.maximum(rects[:, 0], rects[:, 2]) - y0 = numpy.minimum(rects[:, 1], rects[:, 3]) - y1 = numpy.maximum(rects[:, 1], rects[:, 3]) - rects[:, 0] = x0 - rects[:, 1] = y0 - rects[:, 2] = x1 - rects[:, 3] = y1 - - -@functools.total_ordering -class RectCollection(Shape): - """ - A collection of axis-aligned rectangles, stored as an Nx4 array of - `[xmin, ymin, xmax, ymax]` rows. - """ - __slots__ = ( - '_rects', - '_repetition', '_annotations', - ) - - _rects: NDArray[numpy.float64] - - @property - def rects(self) -> NDArray[numpy.float64]: - return self._rects - - @rects.setter - def rects(self, val: ArrayLike) -> None: - self._rects = _normalize_rects(val) - - @property - def offset(self) -> NDArray[numpy.float64]: - return numpy.zeros(2) - - @offset.setter - def offset(self, val: ArrayLike) -> None: - if numpy.any(val): - raise PatternError('RectCollection offset is forced to (0, 0)') - - def set_offset(self, val: ArrayLike) -> Self: - if numpy.any(val): - raise PatternError('RectCollection offset is forced to (0, 0)') - return self - - def translate(self, offset: ArrayLike) -> Self: - delta = numpy.asarray(offset, dtype=float).reshape(2) - self._rects[:, [0, 2]] += delta[0] - self._rects[:, [1, 3]] += delta[1] - return self - - def __init__( - self, - rects: ArrayLike, - *, - offset: ArrayLike = (0.0, 0.0), - rotation: float = 0.0, - repetition: Repetition | None = None, - annotations: annotations_t = None, - ) -> None: - self.rects = rects - self.repetition = repetition - self.annotations = annotations - if rotation: - self.rotate(rotation) - if numpy.any(offset): - self.translate(offset) - - @classmethod - def _from_raw( - cls, - *, - rects: NDArray[numpy.float64], - annotations: annotations_t = None, - repetition: Repetition | None = None, - ) -> Self: - new = cls.__new__(cls) - new._rects = rects - new._repetition = repetition - new._annotations = annotations - return new - - @property - def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]: - for rect in self._rects: - xmin, ymin, xmax, ymax = rect - yield numpy.array([ - [xmin, ymin], - [xmin, ymax], - [xmax, ymax], - [xmax, ymin], - ], dtype=float) - - def __deepcopy__(self, memo: dict | None = None) -> Self: - memo = {} if memo is None else memo - new = copy.copy(self) - new._rects = self._rects.copy() - new._repetition = copy.deepcopy(self._repetition, memo) - new._annotations = copy.deepcopy(self._annotations) - return new - - def _sorted_rects(self) -> NDArray[numpy.float64]: - if self._rects.shape[0] <= 1: - return self._rects - order = numpy.lexsort((self._rects[:, 3], self._rects[:, 2], self._rects[:, 1], self._rects[:, 0])) - return self._rects[order] - - def __eq__(self, other: Any) -> bool: - return ( - type(self) is type(other) - and numpy.array_equal(self._sorted_rects(), other._sorted_rects()) - and self.repetition == other.repetition - and annotations_eq(self.annotations, other.annotations) - ) - - def __lt__(self, other: Shape) -> bool: - if type(self) is not type(other): - if repr(type(self)) != repr(type(other)): - return repr(type(self)) < repr(type(other)) - return id(type(self)) < id(type(other)) - - other = cast('RectCollection', other) - self_rects = self._sorted_rects() - other_rects = other._sorted_rects() - if not numpy.array_equal(self_rects, other_rects): - min_len = min(self_rects.shape[0], other_rects.shape[0]) - eq_mask = self_rects[:min_len] != other_rects[:min_len] - eq_lt = self_rects[:min_len] < other_rects[:min_len] - eq_lt_masked = eq_lt[eq_mask] - if eq_lt_masked.size > 0: - return bool(eq_lt_masked.flat[0]) - return self_rects.shape[0] < other_rects.shape[0] - if self.repetition != other.repetition: - return rep2key(self.repetition) < rep2key(other.repetition) - return annotations_lt(self.annotations, other.annotations) - - def to_polygons( - self, - num_vertices: int | None = None, # unused # noqa: ARG002 - max_arclen: float | None = None, # unused # noqa: ARG002 - ) -> list[Polygon]: - return [ - Polygon( - vertices=vertices, - repetition=copy.deepcopy(self.repetition), - annotations=copy.deepcopy(self.annotations), - ) - for vertices in self.polygon_vertices - ] - - def get_bounds_single(self) -> NDArray[numpy.float64] | None: - if self._rects.size == 0: - return None - mins = self._rects[:, :2].min(axis=0) - maxs = self._rects[:, 2:].max(axis=0) - return numpy.vstack((mins, maxs)) - - def rotate(self, theta: float) -> Self: - quarter_turns = int(numpy.rint(theta / (pi / 2))) - if not numpy.isclose(theta, quarter_turns * (pi / 2)): - raise PatternError( - f'RectCollection cannot rotate by {theta!r} radians; only Manhattan rotations (multiples of pi/2) are supported. ' - 'Explicitly replace the collection with to_polygons(), or call Pattern.polygonize() ' - 'on the pattern containing it (including referenced child patterns) before transforming, ' - 'flattening, or computing hierarchical bounds.' - ) - turns = quarter_turns % 4 - if turns == 0 or self._rects.size == 0: - return self - - corners = numpy.stack(( - self._rects[:, [0, 1]], - self._rects[:, [0, 3]], - self._rects[:, [2, 3]], - self._rects[:, [2, 1]], - ), axis=1) - flat = corners.reshape(-1, 2) - if turns == 1: - rotated = numpy.column_stack((-flat[:, 1], flat[:, 0])) - elif turns == 2: - rotated = -flat - else: - rotated = numpy.column_stack((flat[:, 1], -flat[:, 0])) - corners = rotated.reshape(corners.shape) - self._rects[:, 0] = corners[:, :, 0].min(axis=1) - self._rects[:, 1] = corners[:, :, 1].min(axis=1) - self._rects[:, 2] = corners[:, :, 0].max(axis=1) - self._rects[:, 3] = corners[:, :, 1].max(axis=1) - return self - - def mirror(self, axis: int = 0) -> Self: - if axis not in (0, 1): - raise PatternError('Axis must be 0 or 1') - if axis == 0: - self._rects[:, [1, 3]] *= -1 - else: - self._rects[:, [0, 2]] *= -1 - _renormalize_rects_in_place(self._rects) - return self - - def scale_by(self, c: float) -> Self: - self._rects *= c - _renormalize_rects_in_place(self._rects) - return self - - def normalized_form(self, norm_value: float) -> normalized_shape_tuple: - rects = self._sorted_rects() - centers = 0.5 * (rects[:, :2] + rects[:, 2:]) - offset = centers.mean(axis=0) - zeroed = rects.copy() - zeroed[:, [0, 2]] -= offset[0] - zeroed[:, [1, 3]] -= offset[1] - normed = zeroed / norm_value - return ( - (type(self), normed.data.tobytes()), - (offset, 1.0, 0.0, False), - lambda: RectCollection(rects=normed * norm_value), - ) - - def __repr__(self) -> str: - if self._rects.size == 0: - return '' - centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:]) - centroid = centers.mean(axis=0) - return f'' diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index efc0859..0a7c86d 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -6,8 +6,8 @@ import numpy from numpy.typing import NDArray, ArrayLike from ..traits import ( - Copyable, Scalable, FlippableImpl, - PivotableImpl, RepeatableImpl, AnnotatableImpl, + Rotatable, Mirrorable, Copyable, Scalable, + PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,9 +26,8 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, - Copyable, Scalable, - metaclass=ABCMeta): +class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, + PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): """ Class specifying functions common to all shapes. """ @@ -74,7 +73,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, pass @abstractmethod - def normalized_form(self, norm_value: float) -> normalized_shape_tuple: + def normalized_form(self, norm_value: int) -> normalized_shape_tuple: """ Writes the shape in a standardized notation, with offset, scale, and rotation information separated out from the remaining values. @@ -121,7 +120,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon #noqa: PLC0415 + from . import Polygon gx = numpy.unique(grid_x) gy = numpy.unique(grid_y) @@ -135,28 +134,26 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, mins, maxs = bounds vertex_lists = [] - p_verts = polygon.vertices + p_verts = polygon.vertices + polygon.offset for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True): dv = v_next - v - # Find x-index bounds for the line + # Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape gxi_range = numpy.digitize([v[0], v_next[0]], gx) - gxi_min = int(numpy.min(gxi_range - 1).clip(0, len(gx) - 1)) - gxi_max = int(numpy.max(gxi_range).clip(0, len(gx))) + gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1) + gxi_max = numpy.max(gxi_range).clip(0, len(gx)) - if gxi_min < len(gx) - 1: - err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min]) - if err_xmin >= 0.5: - gxi_min += 1 + err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min]) + err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1]) - if gxi_max > 0 and gxi_max < len(gx): - err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1]) - if err_xmax >= 0.5: - gxi_max += 1 + if err_xmin >= 0.5: + gxi_min += 1 + if err_xmax >= 0.5: + gxi_max += 1 if abs(dv[0]) < 1e-20: # Vertical line, don't calculate slope - xi = [gxi_min, max(gxi_min, gxi_max - 1)] + xi = [gxi_min, gxi_max - 1] ys = numpy.array([v[1], v_next[1]]) yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1) err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1]) @@ -252,9 +249,9 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon #noqa: PLC0415 - import skimage.measure #noqa: PLC0415 - import float_raster #noqa: PLC0415 + from . import Polygon + import skimage.measure # type: ignore + import float_raster grx = numpy.unique(grid_x) gry = numpy.unique(grid_y) @@ -285,7 +282,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, offset = (numpy.where(keep_x)[0][0], numpy.where(keep_y)[0][0]) - rastered = float_raster.raster((polygon.vertices).T, gx, gy) + rastered = float_raster.raster((polygon.vertices + polygon.offset).T, gx, gy) binary_rastered = (numpy.abs(rastered) >= 0.5) supersampled = binary_rastered.repeat(2, axis=0).repeat(2, axis=1) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index c078879..69318ac 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -9,8 +9,8 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, Polygon, normalized_shape_tuple from ..error import PatternError from ..repetition import Repetition -from ..traits import PositionableImpl, RotatableImpl -from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key, SupportsBool +from ..traits import RotatableImpl +from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key # Loaded on use: # from freetype import Face @@ -18,7 +18,7 @@ from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotatio @functools.total_ordering -class Text(PositionableImpl, RotatableImpl, Shape): +class Text(RotatableImpl, Shape): """ Text (to be printed e.g. as a set of polygons). This is distinct from non-printed Label objects. @@ -55,11 +55,11 @@ class Text(PositionableImpl, RotatableImpl, Shape): self._height = val @property - def mirrored(self) -> bool: + def mirrored(self) -> bool: # mypy#3004, should be bool return self._mirrored @mirrored.setter - def mirrored(self, val: SupportsBool) -> None: + def mirrored(self, val: bool) -> None: self._mirrored = bool(val) def __init__( @@ -70,48 +70,31 @@ class Text(PositionableImpl, RotatableImpl, Shape): *, offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, - mirrored: bool = False, repetition: Repetition | None = None, - annotations: annotations_t = None, + annotations: annotations_t | None = None, + raw: bool = False, ) -> None: - self.offset = offset - self.string = string - self.height = height - self.rotation = rotation - self.mirrored = mirrored - self.repetition = repetition - self.annotations = annotations + if raw: + assert isinstance(offset, numpy.ndarray) + self._offset = offset + self._string = string + self._height = height + self._rotation = rotation + self._repetition = repetition + self._annotations = annotations if annotations is not None else {} + else: + self.offset = offset + self.string = string + self.height = height + self.rotation = rotation + self.repetition = repetition + self.annotations = annotations if annotations is not None else {} self.font_path = font_path - @classmethod - def _from_raw( - cls, - *, - string: str, - height: float, - font_path: str, - offset: NDArray[numpy.float64], - rotation: float, - mirrored: bool, - annotations: annotations_t = None, - repetition: Repetition | None = None, - ) -> Self: - new = cls.__new__(cls) - new._offset = offset - new._string = string - new._height = height - new._rotation = rotation % (2 * pi) - new._mirrored = mirrored - new._repetition = repetition - new._annotations = annotations - new.font_path = font_path - return new - def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() - new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -122,7 +105,6 @@ class Text(PositionableImpl, RotatableImpl, Shape): and self.string == other.string and self.height == other.height and self.font_path == other.font_path - and self.mirrored == other.mirrored and self.rotation == other.rotation and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) @@ -142,8 +124,6 @@ class Text(PositionableImpl, RotatableImpl, Shape): return self.font_path < other.font_path if not numpy.array_equal(self.offset, other.offset): return tuple(self.offset) < tuple(other.offset) - if self.mirrored != other.mirrored: - return self.mirrored < other.mirrored if self.rotation != other.rotation: return self.rotation < other.rotation if self.repetition != other.repetition: @@ -166,7 +146,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): if self.mirrored: poly.mirror() poly.scale_by(self.height) - poly.translate(self.offset + [total_advance, 0]) + poly.offset = self.offset + [total_advance, 0] poly.rotate_around(self.offset, self.rotation) all_polygons += [poly] @@ -191,25 +171,22 @@ class Text(PositionableImpl, RotatableImpl, Shape): (self.offset, self.height / norm_value, rotation, bool(self.mirrored)), lambda: Text( string=self.string, - height=norm_value, + height=self.height * norm_value, font_path=self.font_path, rotation=rotation, ).mirror2d(across_x=self.mirrored), ) - def get_bounds_single(self) -> NDArray[numpy.float64] | None: + def get_bounds_single(self) -> NDArray[numpy.float64]: # rotation makes this a huge pain when using slot.advance and glyph.bbox(), so # just convert to polygons instead polys = self.to_polygons() - if not polys: - return None - pbounds = numpy.full((len(polys), 2, 2), nan) for pp, poly in enumerate(polys): pbounds[pp] = poly.get_bounds_nonempty() bounds = numpy.vstack(( - numpy.min(pbounds[:, 0, :], axis=0), - numpy.max(pbounds[:, 1, :], axis=0), + numpy.min(pbounds[: 0, :], axis=0), + numpy.max(pbounds[: 1, :], axis=0), )) return bounds @@ -224,9 +201,9 @@ def get_char_as_polygons( font_path: str, char: str, resolution: float = 48 * 64, - ) -> tuple[list[NDArray[numpy.float64]], float]: - from freetype import Face # type: ignore #noqa: PLC0415 - from matplotlib.path import Path # type: ignore #noqa: PLC0415 + ) -> tuple[list[list[list[float]]], float]: + from freetype import Face # type: ignore + from matplotlib.path import Path # type: ignore """ Get a list of polygons representing a single character. @@ -299,12 +276,11 @@ def get_char_as_polygons( advance = slot.advance.x / resolution - polygons: list[NDArray[numpy.float64]] if len(all_verts) == 0: polygons = [] else: path = Path(all_verts, all_codes) path.should_simplify = False - polygons = [numpy.asarray(poly) for poly in path.to_polygons()] + polygons = path.to_polygons() return polygons, advance diff --git a/masque/test/__init__.py b/masque/test/__init__.py deleted file mode 100644 index e02b636..0000000 --- a/masque/test/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests (run with `python3 -m pytest -rxPXs | tee results.txt`) -""" diff --git a/masque/test/conftest.py b/masque/test/conftest.py deleted file mode 100644 index 3116ee2..0000000 --- a/masque/test/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -""" - -Test fixtures - -""" - -# ruff: noqa: ARG001 -from typing import Any -import numpy - - -FixtureRequest = Any -PRNG = numpy.random.RandomState(12345) diff --git a/masque/test/helpers.py b/masque/test/helpers.py deleted file mode 100644 index 9f69933..0000000 --- a/masque/test/helpers.py +++ /dev/null @@ -1,146 +0,0 @@ -from typing import Any -from collections.abc import Callable -from copy import deepcopy - -import numpy -from numpy.typing import ArrayLike, NDArray -from numpy.testing import assert_allclose - -from masque import Pather, Port -from masque.builder.tools import RenderStep - - -def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]: - """ - Return lengths for each edge of an implicitly closed vertex loop. - """ - vv = numpy.asarray(vertices, dtype=float) - return numpy.sqrt(numpy.sum(numpy.diff(vv, axis=0, append=vv[:1]) ** 2, axis=1)) - - -def assert_closed_edges_within(vertices: ArrayLike, max_len: float, *, atol: float = 1e-6) -> None: - """ - Assert that every edge in an implicitly closed vertex loop is no longer than `max_len`. - """ - assert numpy.all(closed_edge_lengths(vertices) <= max_len + atol) - - -def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: float = 1e-10) -> None: - """ - Assert that an object's single-shape bounds match `expected`. - """ - assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol) - - -def normalized_route_data(data: Any) -> Any: - """ - Return a deterministic, comparison-friendly representation of route data. - """ - if isinstance(data, dict): - return tuple((key, normalized_route_data(value)) for key, value in sorted(data.items(), key=lambda item: repr(item[0]))) - if isinstance(data, list | tuple): - return tuple(normalized_route_data(value) for value in data) - if isinstance(data, numpy.ndarray): - return tuple(normalized_route_data(value) for value in data.tolist()) - if isinstance(data, numpy.generic): - return data.item() - try: - hash(data) - except TypeError: - return repr(data) - return data - - -def route_step_signature(step: RenderStep) -> tuple[Any, ...]: - """ - Return the stable planning-relevant portion of one rendered route step. - """ - return ( - step.opcode, - tuple(round(float(value), 9) for value in step.start_port.offset), - None if step.start_port.rotation is None else round(float(step.start_port.rotation), 9), - step.start_port.ptype, - tuple(round(float(value), 9) for value in step.end_port.offset), - None if step.end_port.rotation is None else round(float(step.end_port.rotation), 9), - step.end_port.ptype, - normalized_route_data(step.data), - ) - - -def route_signature(pather: Pather, portspec: str) -> tuple[tuple[Any, ...], ...]: - """ - Return a deterministic signature for a pather route. - """ - return tuple(route_step_signature(step) for step in pather._paths[portspec]) - - -def route_endpoint(pather: Pather, portspec: str) -> Port: - """ - Return the endpoint of a routed port, falling back to the live port for empty routes. - """ - steps = pather._paths[portspec] - if not steps: - return pather.pattern[portspec] - return steps[-1].end_port - - -def assert_route_endpoint( - pather: Pather, - portspec: str, - expected: Port, - *, - atol: float = 1e-8, - ) -> None: - """ - Assert that a route endpoint matches an expected port pose and ptype. - """ - actual = route_endpoint(pather, portspec) - assert_allclose(actual.offset, expected.offset, atol=atol) - if expected.rotation is None: - assert actual.rotation is None - else: - assert actual.rotation is not None - assert numpy.isclose(actual.rotation, expected.rotation, atol=atol) - assert actual.ptype == expected.ptype - - -def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> None: - """ - Assert a simple render-step bend budget for route signatures. - """ - bend_count = sum(1 for step in pather._paths[portspec] if step.kind == 'bend') - assert bend_count <= max_bends - - -def assert_route_deterministic( - make_pather: Callable[[], Pather], - route: Callable[[Pather], None], - portspec: str, - ) -> None: - """ - Assert that the same route operation produces the same route signature twice. - """ - first = make_pather() - route(first) - first_signature = route_signature(first, portspec) - - second = make_pather() - route(second) - assert route_signature(second, portspec) == first_signature - - -def assert_route_failure_does_not_mutate( - pather: Pather, - route: Callable[[], None], - expected_exception: type[BaseException], - ) -> BaseException: - """ - Assert that a failing route operation leaves pending route steps untouched. - """ - before = deepcopy(dict(pather._paths)) - try: - route() - except expected_exception as err: - assert dict(pather._paths) == before - return err - raise AssertionError(f'Expected {expected_exception.__name__}') diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py deleted file mode 100644 index d2f54ed..0000000 --- a/masque/test/test_abstract.py +++ /dev/null @@ -1,85 +0,0 @@ -from numpy.testing import assert_allclose -from numpy import pi - -from ..abstract import Abstract -from ..ports import Port -from ..ref import Ref - - -def test_abstract_init() -> None: - ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)} - abs_obj = Abstract("test", ports) - assert abs_obj.name == "test" - assert len(abs_obj.ports) == 2 - assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied - - -def test_abstract_transform() -> None: - abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) - # Rotate 90 deg around (0,0) - abs_obj.rotate_around((0, 0), pi / 2) - # (10, 0) rot 0 -> (0, 10) rot pi/2 - assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) - - # Mirror across x axis (axis 0): flips y-offset - abs_obj.mirror(0) - # (0, 10) mirrored(0) -> (0, -10) - # rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2 - assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10) - - -def test_abstract_ref_transform() -> None: - abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) - - # Apply ref transform - abs_obj.apply_ref_transform(ref) - # Ref order: mirror, rotate, scale, translate - - # 1. mirror (across x: y -> -y) - # (10, 0) rot 0 -> (10, 0) rot 0 - - # 2. rotate pi/2 around (0,0) - # (10, 0) rot 0 -> (0, 10) rot pi/2 - - # 3. translate (100, 100) - # (0, 10) -> (100, 110) - - assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) - - -def test_abstract_ref_transform_scales_offsets() -> None: - abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2) - - abs_obj.apply_ref_transform(ref) - - assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) - - -def test_abstract_undo_transform() -> None: - abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) - - abs_obj.undo_ref_transform(ref) - assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) - - -def test_abstract_undo_transform_scales_offsets() -> None: - abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)}) - ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2) - - abs_obj.undo_ref_transform(ref) - assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) - assert abs_obj.ports["A"].rotation is not None - assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) diff --git a/masque/test/test_arc.py b/masque/test/test_arc.py deleted file mode 100644 index ae2789a..0000000 --- a/masque/test/test_arc.py +++ /dev/null @@ -1,102 +0,0 @@ -import pytest -import numpy -from numpy import pi -from numpy.testing import assert_equal, assert_allclose - -from ..error import PatternError -from ..shapes import Arc -from .helpers import assert_closed_edges_within - - -def test_arc_init() -> None: - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0)) - assert_equal(a.radii, [10, 10]) - assert_equal(a.angles, [0, pi / 2]) - assert a.width == 2 - - -@pytest.mark.parametrize('axis', [0, 1]) -@pytest.mark.parametrize('radii', [(10, 6), (6, 10), (10, 10)]) -@pytest.mark.parametrize('angle_ref', list(Arc.AngleRef)) -@pytest.mark.parametrize('rotation', [0, pi / 5]) -def test_arc_reflection_preserves_caps_and_bounds(axis: int, radii: tuple, angle_ref: Arc.AngleRef, rotation: float) -> None: - arc = Arc(radii=radii, angles=(-0.3, 1.1), width=1, angle_ref=angle_ref, rotation=rotation) - reflected = arc.deepcopy().mirror(axis) - signs = numpy.ones(2) - signs[1 - axis] = -1 - assert_allclose(reflected.get_cap_edges(), arc.get_cap_edges() * signs, atol=1e-12) - expected = arc.get_bounds_single() * signs - assert_allclose(reflected.get_bounds_single(), numpy.sort(expected, axis=0), atol=1e-12) - assert_allclose(reflected.mirror(axis).get_cap_edges(), arc.get_cap_edges(), atol=1e-12) - -def test_arc_to_polygons() -> None: - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - polys = a.to_polygons(num_vertices=32) - assert len(polys) == 1 - - # Quarter-circle ring section with outer radius 11 and inner radius 9. - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10) - -def test_arc_focus_to_polygons() -> None: - a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos) - polys = a.to_polygons(num_vertices=32) - assert len(polys) == 1 - - focus = numpy.array([8.0, 0.0]) - cuts = a.get_cap_edges() - for angle, cut in zip(a.angles, cuts, strict=True): - direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) - for point in cut: - delta = point - focus - assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10) - assert numpy.dot(direction, delta) > 0 - -def test_arc_circle_focus_matches_center() -> None: - center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos) - - assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices, - center.to_polygons(num_vertices=32)[0].vertices, - atol=1e-10) - -def test_arc_edge_cases() -> None: - a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2) - a.to_polygons(num_vertices=64) - bounds = a.get_bounds_single() - assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10) - -def test_rotated_arc_bounds_match_polygonized_geometry() -> None: - arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200)) - bounds = arc.get_bounds_single() - poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() - assert_allclose(bounds, poly_bounds, atol=1e-3) - -def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None: - arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4, - offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos) - bounds = arc.get_bounds_single() - poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() - assert_allclose(bounds, poly_bounds, atol=1e-3) - -def test_arc_polygonization_rejects_nan_implied_arclen() -> None: - arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2) - with pytest.raises(PatternError, match='valid max_arclen'): - arc.to_polygons(num_vertices=24) - -def test_focus_arc_rejects_focus_outside_inner_boundary() -> None: - arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos) - with pytest.raises(PatternError, match='inside both arc boundary ellipses'): - arc.to_polygons(num_vertices=24) - -def test_focus_arc_max_arclen_limits_segments() -> None: - arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg) - assert_closed_edges_within(arc.to_polygons(max_arclen=2)[0].vertices, 2) - -def test_arc_rejects_zero_radii_up_front() -> None: - with pytest.raises(PatternError, match='Radii must be positive'): - Arc(radii=(0, 5), angles=(0, 1), width=1) - with pytest.raises(PatternError, match='Radii must be positive'): - Arc(radii=(5, 0), angles=(0, 1), width=1) - with pytest.raises(PatternError, match='Radii must be positive'): - Arc(radii=(0, 0), angles=(0, 1), width=1) diff --git a/masque/test/test_autotool_planning.py b/masque/test/test_autotool_planning.py deleted file mode 100644 index 969674b..0000000 --- a/masque/test/test_autotool_planning.py +++ /dev/null @@ -1,1550 +0,0 @@ -from contextlib import suppress -from typing import Any - -import pytest -import numpy -from numpy import pi -from numpy.testing import assert_allclose - -from masque.builder.tools import AutoTool, PrimitiveOffer, RenderStep, UOffer, circular_arc_sbend_endpoint -from masque.builder.pather import Pather -from masque.builder.error import ToolContractError -from masque.library import Library -from masque.pattern import Pattern -from masque.ports import Port -from masque.error import BuildError - - -def commit_offer(offer: PrimitiveOffer, parameter: float) -> tuple[Port, Any]: - return offer.endpoint_at(parameter), offer.commit(parameter) - - -def selected_offer(tool: AutoTool, kind: str, parameter: float, **kwargs: Any) -> tuple[PrimitiveOffer, Port, Any]: - valid = [] - for offer in tool.primitive_offers(kind, **kwargs): - try: - out_port, data = commit_offer(offer, parameter) - except BuildError: - continue - valid.append((offer, out_port, data)) - - assert valid - return min(valid, key=lambda item: item[0].cost_at(parameter)) - - -def selected_matching_offer( - tool: AutoTool, - kind: str, - parameter: float, - predicate: Any, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, Port, Any]: - valid = [] - for offer in tool.primitive_offers(kind, **kwargs): - try: - out_port, data = commit_offer(offer, parameter) - except BuildError: - continue - if predicate(offer, out_port, data): - valid.append((offer, out_port, data)) - - assert valid - return min(valid, key=lambda item: item[0].cost_at(parameter)) - - -def rendered_offer_tree( - tool: AutoTool, - offer: PrimitiveOffer, - parameter: float, - source_ptype: str | None = None, - ) -> Library: - start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk") - end, data = commit_offer(offer, parameter) - return tool.render((RenderStep(offer.kind, tool, start, end, data),)) - - -def _make_transition_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) - pat.ports["in"] = Port((0, 0), 0, ptype=ptype) - pat.ports["out"] = Port((length, 0), pi, ptype=ptype) - return pat - - -@pytest.fixture -def autotool_setup() -> tuple[Pather, AutoTool, Library]: - lib = Library() - - bend_pat = Pattern() - bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire") - bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire") - lib["bend"] = bend_pat - lib.abstract("bend") - - via_pat = Pattern() - via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1") - via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2") - lib["via"] = via_pat - via_abs = lib.abstract("via") - - tool_m1 = ( - AutoTool(bbox_library=lib) - .add_straight( - lambda length: _make_transition_straight(length, ptype="wire_m1"), - "wire_m1", - "in", - ) - .add_transition(via_abs, "m2", "m1") - ) - - p = Pather(lib, tools=tool_m1) - p.ports["start"] = Port((0, 0), pi, ptype="wire_m2") - - return p, tool_m1, lib - -def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: - p, _tool, _lib = autotool_setup - - p.straight("start", 10) - - # Via length is 1, so the remaining wire_m1 straight length is 9. - assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) - assert p.ports["start"].ptype == "wire_m1" - - -def test_autotool_route_level_methods_removed(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: - _p, tool, _lib = autotool_setup - - for name in ("planL", "planS", "planU", "traceL", "traceS", "traceU"): - assert not hasattr(tool, name) - - -def test_autotool_straight_offer_supports_requested_output_transition( - autotool_setup: tuple[Pather, AutoTool, Library], - ) -> None: - _p, tool, lib = autotool_setup - - p = Pather(lib, tools=tool, render='deferred') - p.ports["start"] = Port((0, 0), pi, ptype="wire_m1") - p.straight("start", 10, out_ptype="wire_m2") - - assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) - assert p.ports["start"].ptype == "wire_m2" - assert [type(step.data) for step in p._paths["start"]] == [AutoTool.GeneratedData, AutoTool.ReusableData] - assert p._paths["start"][0].data.parameter == 9 - assert p._paths["start"][1].data.port_name == "m1" - - -def test_pather_straight_topology_allows_transition_offset_cancellation() -> None: - lib = Library() - - trans_in = Pattern() - trans_in.ports["EXT"] = Port((0, 0), 0, ptype="ext_in") - trans_in.ports["CORE"] = Port((1, 1), pi, ptype="core") - lib["trans_in"] = trans_in - - trans_out = Pattern() - trans_out.ports["EXT"] = Port((0, 0), 0, ptype="ext_out") - trans_out.ports["CORE"] = Port((1, -1), pi, ptype="core") - lib["trans_out"] = trans_out - - tool = ( - AutoTool(bbox_library=lib) - .add_straight( - lambda length: _make_transition_straight(length, ptype="core"), - "core", - "in", - length_range=(0, 1e8), - ) - .add_transition(lib.abstract("trans_in"), "EXT", "CORE") - .add_transition(lib.abstract("trans_out"), "EXT", "CORE") - ) - - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), pi, ptype="ext_in") - p.trace("A", None, length=10, out_ptype="ext_out") - - assert_allclose(p.ports["A"].offset, [10, 0], atol=1e-10) - assert p.ports["A"].ptype == "ext_out" - assert [type(step.data) for step in p._paths["A"]] == [ - AutoTool.ReusableData, - AutoTool.GeneratedData, - AutoTool.ReusableData, - ] - assert p._paths["A"][1].data.parameter == 8 - - -def test_autotool_add_transition_dedupes_bidirectional_adapter_offers() -> None: - lib = Library() - - trans_pat = Pattern() - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") - lib["trans"] = trans_pat - trans_abs = lib.abstract("trans") - - tool = AutoTool(bbox_library=lib) - tool.add_transition(trans_abs, "EXT", "CORE") - tool.add_transition(trans_abs, "EXT", "CORE") - - ext_offers = tool.primitive_offers("straight", in_ptype="ext") - core_offers = tool.primitive_offers("straight", in_ptype="core") - - assert len(ext_offers) == 1 - assert len(core_offers) == 1 - - _ext_port, ext_data = commit_offer(ext_offers[0], 2) - _core_port, core_data = commit_offer(core_offers[0], 2) - assert isinstance(ext_data, AutoTool.ReusableData) - assert isinstance(core_data, AutoTool.ReusableData) - assert ext_data.port_name == "EXT" - assert core_data.port_name == "CORE" - - -def test_autotool_add_transition_infers_two_port_bidirectional_transition() -> None: - lib = Library() - - trans_pat = Pattern() - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") - lib["trans"] = trans_pat - - tool = AutoTool(bbox_library=lib) - tool.add_transition(lib.abstract("trans")) - - ext_offers = tool.primitive_offers("straight", in_ptype="ext") - core_offers = tool.primitive_offers("straight", in_ptype="core") - - assert len(ext_offers) == 1 - assert len(core_offers) == 1 - - ext_port, ext_data = commit_offer(ext_offers[0], 2) - core_port, core_data = commit_offer(core_offers[0], 2) - assert_allclose(ext_port.offset, [2, 0]) - assert_allclose(core_port.offset, [2, 0]) - assert isinstance(ext_data, AutoTool.ReusableData) - assert isinstance(core_data, AutoTool.ReusableData) - assert ext_data.port_name == "EXT" - assert core_data.port_name == "CORE" - - -def test_autotool_add_transition_one_way_inhibits_reverse_adapter_offer() -> None: - lib = Library() - - trans_pat = Pattern() - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") - lib["trans"] = trans_pat - trans_abs = lib.abstract("trans") - - tool = AutoTool(bbox_library=lib) - tool.add_transition(trans_abs, "EXT", "CORE", one_way=True) - - ext_offers = tool.primitive_offers("straight", in_ptype="ext") - core_offers = tool.primitive_offers("straight", in_ptype="core") - - assert len(ext_offers) == 1 - assert core_offers == () - - _ext_port, ext_data = commit_offer(ext_offers[0], 2) - assert isinstance(ext_data, AutoTool.ReusableData) - assert ext_data.port_name == "EXT" - - -def test_autotool_add_transition_requires_explicit_names_for_one_way() -> None: - lib = Library() - - trans_pat = Pattern() - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") - lib["trans"] = trans_pat - - with pytest.raises(BuildError, match='one-way transitions require explicit port names'): - AutoTool().add_transition(lib.abstract("trans"), one_way=True) - - -def test_autotool_add_transition_rejects_partial_port_names() -> None: - lib = Library() - - trans_pat = Pattern() - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") - lib["trans"] = trans_pat - - with pytest.raises(BuildError, match='Transition port names must be provided together'): - AutoTool().add_transition(lib.abstract("trans"), "EXT") - - -def test_autotool_add_transition_requires_explicit_names_for_non_two_port_abstract() -> None: - lib = Library() - - trans_pat = Pattern() - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") - trans_pat.ports["TAP"] = Port((1, 1), pi, ptype="tap") - lib["trans"] = trans_pat - - with pytest.raises(BuildError, match='Transition port names are required for 3-port abstracts'): - AutoTool().add_transition(lib.abstract("trans")) - - -def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((length, 0), pi, ptype=ptype) - return pat - - -def make_bend(R: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern: - pat = Pattern() - # Rectangular approximation of a 90 degree bend. - if clockwise: - pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) - pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((R, -R), pi / 2, ptype=ptype) - else: - pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) - pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((R, R), -pi / 2, ptype=ptype) - return pat - - -def make_sbend(jog: float, ptype: str = "wire") -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((10, jog), pi, ptype=ptype) - return pat - - -@pytest.fixture -def multi_bend_tool() -> tuple[AutoTool, Library]: - lib = Library() - - lib["b1"] = make_bend(2, ptype="wire") - b1_abs = lib.abstract("b1") - lib["b2"] = make_bend(5, ptype="wire") - b2_abs = lib.abstract("b2") - - tool = ( - AutoTool(bbox_library=lib) - .add_straight(make_straight, "wire", "A", length_range=(0, 10)) - .add_straight(lambda length: make_straight(length, width=4), "wire", "A", length_range=(10, 1e8)) - .add_bend(b1_abs, "A", "B", clockwise=True, mirror=True) - .add_bend(b2_abs, "A", "B", clockwise=True, mirror=True) - ) - return tool, lib - -@pytest.fixture -def asymmetric_transition_tool() -> AutoTool: - lib = Library() - - bend_pat = Pattern() - bend_pat.ports["in"] = Port((0, 0), 0, ptype="core") - bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="core") - lib["core_bend"] = bend_pat - - trans_pat = Pattern() - trans_pat.ports["CORE"] = Port((0, 0), 0, ptype="core") - trans_pat.ports["MID"] = Port((3, 1), pi, ptype="mid") - lib["core_mid"] = trans_pat - - return ( - AutoTool(bbox_library=lib) - .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 3)) - .add_straight(lambda length: make_straight(length, ptype="mid"), "mid", "A", length_range=(0, 1e8)) - .add_bend(lib.abstract("core_bend"), "in", "out", clockwise=True, mirror=True) - .add_transition(lib.abstract("core_mid"), "MID", "CORE") - ) - -@pytest.fixture -def wildcard_transition_tool() -> tuple[AutoTool, Library]: - lib = Library() - - def make_core_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((10, jog), pi, ptype="core") - return pat - - trans_pat = Pattern() - trans_pat.ports["WILD"] = Port((0, 0), 0, ptype="unk") - trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") - lib["wild_core"] = trans_pat - - tool = ( - AutoTool(bbox_library=lib) - .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) - .add_sbend(make_core_sbend, "core", "A", "B", jog_range=(0, 1e8)) - .add_transition(lib.abstract("wild_core"), "WILD", "CORE") - ) - return tool, lib - -def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[str, str] = ("A", "B")) -> None: - pat = tree.top_pattern() - out_port = pat[port_names[1]] - dxy, rot = pat[port_names[0]].measure_travel(out_port) - assert_allclose(dxy, plan_port.offset) - assert rot is not None - assert plan_port.rotation is not None - assert_allclose(rot, plan_port.rotation) - assert out_port.ptype == plan_port.ptype - - -def assert_rendered_offer_endpoint_matches_plan( - tool: AutoTool, - offer: PrimitiveOffer, - parameter: float, - source_ptype: str | None = None, - ) -> None: - out_port = offer.endpoint_at(parameter) - tree = rendered_offer_tree(tool, offer, parameter, source_ptype) - assert_trace_matches_plan(out_port, tree) - - -def assert_offer_bbox_matches_trace(offer: PrimitiveOffer, parameter: float, tree: Library, source_lib: Library) -> None: - expected = tree.top_pattern().get_bounds(library=source_lib) - assert expected is not None - assert_allclose(offer.bbox_at(parameter), expected) - - -def assert_offer_bbox_matches_rendered_offer( - tool: AutoTool, - offer: PrimitiveOffer, - parameter: float, - source_lib: Library, - ) -> None: - tree = rendered_offer_tree(tool, offer, parameter) - assert_offer_bbox_matches_trace(offer, parameter, tree, source_lib) - - -def make_sbend_tool(jog_range: tuple[float, float]) -> AutoTool: - def make_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((10, jog), pi, ptype="core") - return pat - - return ( - AutoTool() - .add_straight(make_straight, "core", "A", length_range=(0, 1e8)) - .add_sbend(make_sbend, "core", "A", "B", jog_range=jog_range) - ) - - -def test_autotool_add_straight_infers_metadata_from_generated_example() -> None: - calls: list[float] = [] - - def make_counted_straight(length: float) -> Pattern: - calls.append(length) - return make_straight(length, ptype="core") - - tool = AutoTool().add_straight(make_counted_straight, length_range=(0, 10)) - - assert calls == [1] - offer = tool.primitive_offers("straight", in_ptype="core")[0] - out_port, data = commit_offer(offer, 7) - assert out_port.ptype == "core" - assert isinstance(data, AutoTool.GeneratedData) - assert data.port_name == "A" - assert data.parameter == 7 - assert calls == [1] - - -def test_autotool_add_straight_explicit_metadata_does_not_sample_generator() -> None: - calls: list[float] = [] - - def make_counted_straight(length: float) -> Pattern: - calls.append(length) - return make_straight(length, ptype="core") - - tool = AutoTool().add_straight(make_counted_straight, "core", "A", length_range=(0, 10)) - - assert calls == [] - offer = tool.primitive_offers("straight", in_ptype="core")[0] - _out_port, data = commit_offer(offer, 7) - assert isinstance(data, AutoTool.GeneratedData) - assert data.port_name == "A" - assert calls == [] - - -def test_autotool_add_sbend_infers_metadata_from_generated_example() -> None: - calls: list[float] = [] - - def make_counted_sbend(jog: float) -> Pattern: - calls.append(jog) - return make_sbend(jog, ptype="core") - - tool = AutoTool().add_sbend(make_counted_sbend, jog_range=(0, 10)) - - assert calls == [1] - offer = tool.primitive_offers("s", in_ptype="core")[0] - data = offer.commit(4) - assert offer.in_ptype == "core" - assert offer.out_ptype == "core" - assert isinstance(data, AutoTool.GeneratedData) - assert data.port_name == "A" - assert data.parameter == 4 - assert calls == [1] - - -def test_autotool_sbend_custom_endpoint_avoids_generator_during_planning() -> None: - calls = 0 - - def make_counted_sbend(jog: float) -> Pattern: - nonlocal calls - calls += 1 - return make_sbend(jog, ptype="core") - - def endpoint(jog: float) -> Port: - return Port((20, jog), rotation=pi, ptype="core") - - tool = AutoTool().add_sbend( - make_counted_sbend, - "core", - "A", - "B", - jog_range=(0, 1e8), - endpoint=endpoint, - ) - - offer = tool.primitive_offers("s", in_ptype="core")[0] - out_port = offer.endpoint_at(4) - - assert calls == 0 - assert_allclose(out_port.offset, [20, 4]) - assert out_port.rotation == pi - assert out_port.ptype == "core" - - data = offer.commit(4) - assert data.parameter == 4 - assert data.mirrored is False - - -def test_autotool_sbend_endpoint_ptype_mismatch_is_fatal() -> None: - def make_core_sbend(jog: float) -> Pattern: - return make_sbend(jog, ptype='core') - - tool = ( - AutoTool() - .add_straight( - lambda length: make_straight(length, ptype='core'), - 'core', - 'A', - ) - .add_sbend( - make_core_sbend, - 'core', - 'A', - 'B', - jog_range=(0, 20), - endpoint=lambda jog: Port((10, jog), pi, ptype='wrong'), - ) - .add_sbend( - make_core_sbend, - 'core', - 'A', - 'B', - jog_range=(0, 20), - ) - ) - pather = Pather(Library(), tools=tool, render='deferred') - pather.ports['A'] = Port((0, 0), 0, ptype='core') - - with pytest.raises(ToolContractError, match='declared offer out_ptype'): - pather.jog('A', 4, length=20) - - assert_allclose(pather.ports['A'].offset, (0, 0)) - - -def test_circular_arc_sbend_endpoint() -> None: - endpoint = circular_arc_sbend_endpoint(radius=5, ptype="core") - - out_port = endpoint(4) - assert_allclose(out_port.offset, [8, 4]) - assert out_port.rotation == pi - assert out_port.ptype == "core" - - mirrored_port = endpoint(-4) - assert_allclose(mirrored_port.offset, [8, -4]) - assert mirrored_port.rotation == pi - assert mirrored_port.ptype == "core" - - zero_port = endpoint(0) - assert_allclose(zero_port.offset, [0, 0]) - assert zero_port.rotation == pi - - with pytest.raises(BuildError, match="exceeds diameter"): - endpoint(11) - - -def make_uturn_pattern(length: float = 10, jog: float = 4, ptype: str = "core") -> Pattern: - pat = Pattern() - y0, y1 = sorted((0, jog)) - pat.rect((2, 0), xmin=0, xmax=length, ymin=y0 - 1, ymax=y1 + 1) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((length, jog), 0, ptype=ptype) - return pat - - -def make_uturn_tool() -> tuple[AutoTool, Library]: - lib = Library() - lib["u"] = make_uturn_pattern() - tool = AutoTool(bbox_library=lib).add_uturn(lib.abstract("u"), "A", "B") - return tool, lib - - -@pytest.mark.parametrize("in_ptype", [None, "unk"]) -def test_autotool_transition_offer_wildcard_input_key_treats_none_and_unk_equivalently( - wildcard_transition_tool: tuple[AutoTool, Library], - in_ptype: str | None, - ) -> None: - tool, _lib = wildcard_transition_tool - - valid = [] - for offer in tool.primitive_offers("straight", in_ptype=in_ptype, out_ptype="core"): - try: - out_port, data = commit_offer(offer, 2) - except BuildError: - continue - valid.append((offer, out_port, data)) - - assert valid - _offer, out_port, data = min(valid, key=lambda item: item[0].cost_at(2)) - assert isinstance(data, AutoTool.ReusableData) - assert_allclose(out_port.offset, [2, 0]) - assert out_port.ptype == "core" - - -@pytest.mark.parametrize("out_ptype", [None, "unk"]) -def test_autotool_transition_offer_wildcard_output_key_treats_none_and_unk_equivalently( - wildcard_transition_tool: tuple[AutoTool, Library], - out_ptype: str | None, - ) -> None: - tool, _lib = wildcard_transition_tool - - _offer, out_port, data = selected_matching_offer( - tool, - "straight", - 2, - lambda _offer, out_port, data: out_port.ptype == "unk" and isinstance(data, AutoTool.ReusableData), - in_ptype="core", - out_ptype=out_ptype, - ) - - assert_allclose(out_port.offset, [2, 0]) - assert out_port.ptype == "unk" - - -def test_autotool_l_offer_selection_uses_primitive_cost_and_domains( - multi_bend_tool: tuple[AutoTool, Library], - ) -> None: - tool, _lib = multi_bend_tool - - small_straight_offer, small_straight_port, small_straight_data = selected_offer(tool, "straight", 5) - assert small_straight_offer.parameter_domain == (0, 10) - assert small_straight_data.parameter == 5 - assert_allclose(small_straight_port.offset, [5, 0]) - - large_straight_offer, large_straight_port, large_straight_data = selected_offer(tool, "straight", 15) - assert large_straight_offer.parameter_domain == (10, 1e8) - assert large_straight_data.parameter == 15 - assert_allclose(large_straight_port.offset, [15, 0]) - - _small_bend_offer, small_bend_port, small_bend_data = selected_offer(tool, "bend", 2, ccw=True) - assert small_bend_data.abstract.name == "b1" - assert_allclose(small_bend_port.offset, [2, 2]) - - large_bend_offer, large_bend_port, large_bend_data = selected_offer(tool, "bend", 5, ccw=True) - assert large_bend_data.abstract.name == "b2" - assert_allclose(large_bend_port.offset, [5, 5]) - valid_costs = [] - for offer in tool.primitive_offers("straight"): - with suppress(BuildError): - valid_costs.append(offer.cost_at(15)) - assert large_straight_offer.cost_at(15) == min(valid_costs) - assert large_bend_offer.parameter_domain == (5, 5) - - -def test_autotool_add_bend_infers_two_port_clockwise_bend() -> None: - lib = Library() - lib["bend"] = make_bend(2, ptype="wire", clockwise=True) - - tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend")) - - _cw_offer, cw_port, cw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=False) - _ccw_offer, ccw_port, ccw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=True) - assert_allclose(cw_port.offset, [2, -2]) - assert_allclose(ccw_port.offset, [2, 2]) - assert isinstance(cw_data, AutoTool.ReusableData) - assert isinstance(ccw_data, AutoTool.ReusableData) - assert cw_data.port_name == "A" - assert not cw_data.mirrored - assert ccw_data.port_name == "A" - assert ccw_data.mirrored - - -def test_autotool_add_bend_infers_two_port_counterclockwise_bend() -> None: - lib = Library() - lib["bend"] = make_bend(2, ptype="wire", clockwise=False) - - tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend")) - - _cw_offer, cw_port, cw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=False) - _ccw_offer, ccw_port, ccw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=True) - assert_allclose(cw_port.offset, [2, -2]) - assert_allclose(ccw_port.offset, [2, 2]) - assert isinstance(cw_data, AutoTool.ReusableData) - assert isinstance(ccw_data, AutoTool.ReusableData) - assert cw_data.port_name == "A" - assert cw_data.mirrored - assert ccw_data.port_name == "A" - assert not ccw_data.mirrored - - -def test_autotool_add_bend_inferred_names_allow_rotational_reuse_without_mirror() -> None: - lib = Library() - lib["bend"] = make_bend(2, ptype="wire", clockwise=True) - - tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend"), mirror=False) - - cw_offer, _cw_port, cw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=False) - ccw_offer, _ccw_port, ccw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=True) - assert isinstance(cw_data, AutoTool.ReusableData) - assert isinstance(ccw_data, AutoTool.ReusableData) - assert cw_data.port_name == "A" - assert not cw_data.mirrored - assert ccw_data.port_name == "B" - assert not ccw_data.mirrored - assert_rendered_offer_endpoint_matches_plan(tool, cw_offer, 2, "wire") - assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "wire") - - -def test_autotool_add_bend_reverse_reuse_swaps_cross_ptypes() -> None: - lib = Library() - bend = make_bend(2, ptype="core", clockwise=True) - bend.ports["B"].ptype = "external" - lib["bend"] = bend - - tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend"), mirror=False) - - cw_offer = tool.primitive_offers("bend", in_ptype="core", ccw=False)[0] - ccw_offer = tool.primitive_offers("bend", in_ptype="external", ccw=True)[0] - assert (cw_offer.in_ptype, cw_offer.out_ptype) == ("core", "external") - assert (ccw_offer.in_ptype, ccw_offer.out_ptype) == ("external", "core") - assert cw_offer.commit(2).port_name == "A" - assert ccw_offer.commit(2).port_name == "B" - assert_rendered_offer_endpoint_matches_plan(tool, cw_offer, 2, "core") - assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "external") - - -def test_autotool_add_bend_rejects_clockwise_mismatch() -> None: - lib = Library() - lib["bend"] = make_bend(2, ptype="wire", clockwise=True) - - with pytest.raises(BuildError, match='Bend clockwise argument does not match port orientations'): - AutoTool().add_bend(lib.abstract("bend"), "A", "B", clockwise=False) - - -def test_autotool_add_bend_rejects_partial_port_names() -> None: - lib = Library() - lib["bend"] = make_bend(2, ptype="wire", clockwise=True) - - with pytest.raises(BuildError, match='Bend port names must be provided together'): - AutoTool().add_bend(lib.abstract("bend"), "A") - - -def test_autotool_add_bend_requires_explicit_names_for_non_two_port_abstract() -> None: - lib = Library() - bend = make_bend(2, ptype="wire", clockwise=True) - bend.ports["TAP"] = Port((1, 1), pi, ptype="wire") - lib["bend"] = bend - - with pytest.raises(BuildError, match='Bend port names are required for 3-port abstracts'): - AutoTool().add_bend(lib.abstract("bend")) - - -def test_autotool_l_offer_bbox_matches_rendered_primitive(multi_bend_tool: tuple[AutoTool, Library]) -> None: - tool, lib = multi_bend_tool - offer, _out_port, _data = selected_offer(tool, "bend", 2, ccw=True) - - assert_offer_bbox_matches_rendered_offer(tool, offer, 2, lib) - - -def test_autotool_generated_straight_endpoint_matches_rendered_offer( - multi_bend_tool: tuple[AutoTool, Library], - ) -> None: - tool, _lib = multi_bend_tool - offer, _out_port, data = selected_offer(tool, "straight", 7, in_ptype="wire") - - assert isinstance(data, AutoTool.GeneratedData) - assert_rendered_offer_endpoint_matches_plan(tool, offer, 7, "wire") - - -@pytest.mark.parametrize("ccw", [False, True]) -def test_autotool_reusable_bend_endpoint_matches_rendered_offer( - multi_bend_tool: tuple[AutoTool, Library], - ccw: bool, - ) -> None: - tool, _lib = multi_bend_tool - offer, _out_port, data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=ccw) - - assert isinstance(data, AutoTool.ReusableData) - assert_rendered_offer_endpoint_matches_plan(tool, offer, 2, "wire") - - -def test_autotool_transition_offer_bbox_matches_rendered_primitive() -> None: - lib = Library() - - lib["core_bend"] = make_bend(2, ptype="core") - trans_pat = Pattern() - trans_pat.rect((2, 0), xmin=0, xmax=3, yctr=2, ly=2) - trans_pat.ports["CORE"] = Port((0, 0), 0, ptype="core") - trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext") - lib["out_trans"] = trans_pat - - tool = ( - AutoTool(bbox_library=lib) - .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) - .add_bend(lib.abstract("core_bend"), "A", "B", clockwise=True, mirror=True) - .add_transition(lib.abstract("out_trans"), "EXT", "CORE") - ) - offer, _out_port, data = selected_offer(tool, "s", 1, in_ptype="core", out_ptype="ext") - - assert isinstance(data, AutoTool.ReusableData) - assert_offer_bbox_matches_rendered_offer(tool, offer, 1, lib) - - -def test_autotool_transition_endpoint_matches_rendered_offer( - autotool_setup: tuple[Pather, AutoTool, Library], - ) -> None: - _pather, tool, _lib = autotool_setup - offer, _out_port, data = selected_matching_offer( - tool, - "straight", - 1, - lambda _offer, _out_port, data: isinstance(data, AutoTool.ReusableData), - in_ptype="wire_m1", - out_ptype="wire_m2", - ) - - assert isinstance(data, AutoTool.ReusableData) - assert_rendered_offer_endpoint_matches_plan(tool, offer, 1, "wire_m1") - - -def test_autotool_s_offer_bbox_matches_rendered_sbend() -> None: - lib = Library() - - def make_wide_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.rect((2, 0), xmin=0, xmax=10, yctr=jog / 2, ly=abs(jog) + 2) - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((10, jog), pi, ptype="core") - return pat - - trans_pat = Pattern() - trans_pat.rect((2, 0), xmin=0, xmax=5, yctr=0, ly=2) - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core") - lib["xin"] = trans_pat - - tool = ( - AutoTool(bbox_library=lib) - .add_straight(make_straight, "core", "A", length_range=(1, 1e8)) - .add_sbend(make_wide_sbend, "core", "A", "B", jog_range=(0, 1e8)) - .add_transition(lib.abstract("xin"), "EXT", "CORE") - ) - offer, _out_port, _data = selected_offer(tool, "s", 4, in_ptype="core") - - assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib) - - pather = Pather(lib, tools=tool, render='deferred') - pather.ports["A"] = Port((0, 0), 0, ptype="ext") - pather.jog("A", 4) - - assert_allclose(pather.ports["A"].offset, [-15, -4]) - assert [step.opcode for step in pather._paths["A"]] == ['L', 'S'] - assert isinstance(pather._paths["A"][0].data, AutoTool.ReusableData) - assert isinstance(pather._paths["A"][1].data, AutoTool.GeneratedData) - - -def test_autotool_s_offer_uses_absolute_jog_range_for_both_signs() -> None: - tool = make_sbend_tool((0, 1e8)) - offers = tool.primitive_offers('s', in_ptype="core") - - valid_positive = [] - valid_negative = [] - for offer in offers: - try: - if offer.endpoint_at(4).y == 4: - valid_positive.append(offer) - except BuildError: - pass - try: - if offer.endpoint_at(-4).y == -4: - valid_negative.append(offer) - except BuildError: - pass - - assert valid_positive - assert valid_negative - - pather = Pather(Library(), tools=tool, render='deferred') - pather.ports["A"] = Port((0, 0), 0, ptype="core") - pather.jog("A", -4) - assert_allclose(pather.ports["A"].offset, [-10, 4]) - assert isinstance(pather._paths["A"][0].data, AutoTool.GeneratedData) - - -@pytest.mark.parametrize('reverse', [False, True]) -def test_autotool_sbend_cost_is_independent_of_registration_order(reverse: bool) -> None: - def first_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((20, jog), pi, ptype="core") - return pat - - def second_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((5, jog), pi, ptype="core") - return pat - - tool = AutoTool() - generators = (second_sbend, first_sbend) if reverse else (first_sbend, second_sbend) - for generator in generators: - tool.add_sbend(generator, "core", "A", "B", jog_range=(0, 1e8)) - - _offer, out_port, data = selected_offer(tool, "s", 4, in_ptype="core") - - assert isinstance(data, AutoTool.GeneratedData) - assert data.fn is second_sbend - assert_allclose(out_port.offset, [5, 4]) - - -def test_autotool_sbend_explicit_cost_can_override_geometric_cost() -> None: - def long_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((20, jog), pi, ptype="core") - return pat - - def short_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((5, jog), pi, ptype="core") - return pat - - tool = ( - AutoTool() - .add_sbend(short_sbend, "core", "A", "B", jog_range=(0, 1e8), cost=10) - .add_sbend(long_sbend, "core", "A", "B", jog_range=(0, 1e8), cost=1) - ) - - _offer, out_port, data = selected_offer(tool, "s", 4, in_ptype="core") - - assert isinstance(data, AutoTool.GeneratedData) - assert data.fn is long_sbend - assert_allclose(out_port.offset, [20, 4]) - - -@pytest.mark.parametrize('jog', [41_988.0, -41_988.0]) -def test_autotool_strategy_orders_main_steps_across_adapters(jog: float) -> None: - radius = 60_000.0 - transition_length = 50_000.0 - route_length = 545_929.0 - primary = 'primary' - secondary = 'secondary' - sbend_endpoint = circular_arc_sbend_endpoint(radius, primary) - - def make_primary_sbend(offset: float) -> Pattern: - pattern = Pattern() - pattern.ports['A'] = Port((0, 0), 0, ptype=primary) - pattern.ports['B'] = sbend_endpoint(offset) - return pattern - - library = Library() - library['bend'] = make_bend(radius, ptype=primary) - transition = Pattern() - transition.ports['P'] = Port((0, 0), 0, ptype=primary) - transition.ports['S'] = Port((transition_length, 0), pi, ptype=secondary) - library['transition'] = transition - tool = ( - AutoTool(bbox_library=library) - .add_straight(lambda length: make_straight(length, ptype=primary), primary, 'A') - .add_straight( - lambda length: make_straight(length, ptype=secondary), - secondary, - 'A', - cost=0.3, - ) - .add_bend(library.abstract('bend'), 'A', 'B', clockwise=True) - .add_sbend( - make_primary_sbend, - primary, - 'A', - 'B', - jog_range=(0, 2 * radius), - endpoint=sbend_endpoint, - ) - .add_transition(library.abstract('transition'), 'P', 'S') - ) - - selected_kinds = {} - for strategy in ('straight_first', 'turn_first'): - pather = Pather(library, tools=tool, render='deferred') - pather.ports['A'] = Port((0, 0), 0, ptype=primary) - pather.jog( - 'A', - jog, - length=route_length, - out_ptype=primary, - plan_options={'strategy': strategy}, - ) - selected_kinds[strategy] = [step.kind for step in pather._paths['A']] - - assert selected_kinds['straight_first'] == ['straight', 'straight', 'straight', 's'] - assert selected_kinds['turn_first'] == ['s', 'straight', 'straight', 'straight'] - - -def test_autotool_add_methods_propagate_callable_cost_to_all_created_offers() -> None: - def cost(parameter: float, endpoint: Port) -> float: - return abs(parameter) + abs(endpoint.x) + abs(endpoint.y) - - def make_straight(length: float) -> Pattern: - return _make_transition_straight(length, ptype="core") - - def make_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((5, jog), pi, ptype="core") - return pat - - lib = Library() - - bend = Pattern() - bend.ports["A"] = Port((0, 0), 0, ptype="core") - bend.ports["B"] = Port((2, -2), pi / 2, ptype="core") - lib["bend"] = bend - - uturn = Pattern() - uturn.ports["A"] = Port((0, 0), 0, ptype="core") - uturn.ports["B"] = Port((3, 4), 0, ptype="core") - lib["uturn"] = uturn - - transition = Pattern() - transition.ports["EXT"] = Port((0, 0), 0, ptype="external") - transition.ports["CORE"] = Port((1, 0), pi, ptype="core") - lib["transition"] = transition - - tool = ( - AutoTool() - .add_straight(make_straight, "core", "in", cost=cost) - .add_bend(lib.abstract("bend"), "A", "B", clockwise=True, cost=cost) - .add_sbend( - make_sbend, - "core", - "A", - "B", - endpoint=lambda jog: Port((5, jog), pi, ptype="core"), - cost=cost, - ) - .add_uturn(lib.abstract("uturn"), "A", "B", cost=cost) - .add_transition(lib.abstract("transition"), "EXT", "CORE", cost=cost) - ) - - offers = [ - *(offer for offer in tool.primitive_offers("straight", in_ptype="core") - if offer.in_ptype == offer.out_ptype == "core"), - *tool.primitive_offers("bend", in_ptype="core", ccw=False), - *tool.primitive_offers("bend", in_ptype="core", ccw=True), - *(offer for offer in tool.primitive_offers("s", in_ptype="core") - if offer.in_ptype == offer.out_ptype == "core"), - *tool.primitive_offers("u", in_ptype="core"), - *(offer for offer in tool.primitive_offers("straight", in_ptype="external") - if offer.in_ptype == "external" and offer.out_ptype == "core"), - *(offer for offer in tool.primitive_offers("straight", in_ptype="core") - if offer.in_ptype == "core" and offer.out_ptype == "external"), - ] - - assert len(offers) == 9 - assert all(offer.cost is cost for offer in offers) - - -def test_autotool_validates_cost_before_registering_any_offers() -> None: - def unused_sbend(_jog: float) -> Pattern: - raise AssertionError('invalid cost should be rejected before metadata inference') - - with pytest.raises(BuildError, match='cost factor'): - AutoTool().add_sbend(unused_sbend, jog_range=(-1, 1), cost=-1) - - -@pytest.mark.parametrize('length_range', [(-1, 2), (2, 1), (numpy.nan, 2), (numpy.inf, numpy.inf)]) -def test_autotool_rejects_invalid_straight_range_before_sampling( - length_range: tuple[float, float], - ) -> None: - def unused_straight(_length: float) -> Pattern: - raise AssertionError('invalid range should be rejected before metadata inference') - - with pytest.raises(BuildError, match='Straight length_range'): - AutoTool().add_straight(unused_straight, length_range=length_range) - - -@pytest.mark.parametrize('jog_range', [(-1, 2), (2, 1), (numpy.nan, 2), (numpy.inf, numpy.inf)]) -def test_autotool_rejects_invalid_sbend_range_before_sampling( - jog_range: tuple[float, float], - ) -> None: - def unused_sbend(_jog: float) -> Pattern: - raise AssertionError('invalid range should be rejected before metadata inference') - - with pytest.raises(BuildError, match='S-bend jog_range'): - AutoTool().add_sbend(unused_sbend, jog_range=jog_range) - - -def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None: - tool = make_sbend_tool((4, 4)) - offers = tool.primitive_offers('s', in_ptype="core") - - assert sorted(offer.jog_domain for offer in offers) == [(-4.0, -4.0), (4.0, 4.0)] - assert [offer.endpoint_at(4).y for offer in offers if offer.jog_domain == (4.0, 4.0)] == [4] - assert [offer.endpoint_at(-4).y for offer in offers if offer.jog_domain == (-4.0, -4.0)] == [-4] - - for jog, expected_y in ((4, -4), (-4, 4)): - pather = Pather(Library(), tools=tool) - pather.ports["A"] = Port((0, 0), 0, ptype="core") - pather.jog("A", jog) - assert_allclose(pather.ports["A"].offset, [-10, expected_y]) - - -def test_autotool_s_offer_rejects_negative_minimum_jog_range() -> None: - with pytest.raises(BuildError, match='finite, nonnegative minimum'): - make_sbend_tool((-4, 4)) - - -def test_autotool_uturn_offer_endpoint_matches_rendered_offer() -> None: - tool, lib = make_uturn_tool() - offer, out_port, data = selected_offer(tool, "u", 4, in_ptype="core") - - assert isinstance(offer, UOffer) - assert isinstance(data, AutoTool.ReusableData) - assert data.abstract.name == "u" - assert not data.mirrored - assert_allclose(out_port.offset, [10, 4]) - assert out_port.rotation is not None - assert_allclose(out_port.rotation, 0) - assert_rendered_offer_endpoint_matches_plan(tool, offer, 4, "core") - assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib) - - -def test_autotool_uturn_offer_mirror_exposes_both_signs() -> None: - tool, _lib = make_uturn_tool() - offers = tool.primitive_offers('u', in_ptype="core") - - assert sorted(offer.jog_domain for offer in offers if isinstance(offer, UOffer)) == [ - (-4.0, -4.0), - (4.0, 4.0), - ] - for offer in offers: - assert isinstance(offer, UOffer) - jog = offer.jog_domain[0] - data = offer.commit(jog) - assert isinstance(data, AutoTool.ReusableData) - assert data.mirrored == (jog < 0) - assert_allclose(offer.endpoint_at(jog).offset, [10, jog]) - - -def test_pather_autotool_uses_prebaked_uturn_offer() -> None: - tool, lib = make_uturn_tool() - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="core") - - p.uturn("A", 4, length=10) - - assert_allclose(p.ports["A"].offset, [-10, -4]) - assert p.ports["A"].rotation is not None - assert_allclose(p.ports["A"].rotation, pi) - assert [step.opcode for step in p._paths["A"]] == ['U'] - assert isinstance(p._paths["A"][0].data, AutoTool.ReusableData) - - -def test_autotool_uturn_rejects_non_uturn_orientation() -> None: - lib = Library() - pat = make_uturn_pattern() - pat.ports["B"].rotation = pi - lib["bad_u"] = pat - - with pytest.raises(BuildError, match="U-turn primitive output port"): - AutoTool().add_uturn(lib.abstract("bad_u"), "A", "B") - - -def test_pather_autotool_omitted_uturn_composes_l_offers( - multi_bend_tool: tuple[AutoTool, Library], - ) -> None: - tool, lib = multi_bend_tool - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="wire") - - p.uturn("A", 20) - - assert_allclose(p.ports["A"].offset, [0, -20]) - assert p.ports["A"].rotation is not None - assert_allclose(p.ports["A"].rotation, pi) - assert [type(step.data) for step in p._paths["A"]] == [ - AutoTool.ReusableData, - AutoTool.GeneratedData, - AutoTool.ReusableData, - ] - - -def test_pather_autotool_explicit_uturn_composes_l_offers( - multi_bend_tool: tuple[AutoTool, Library], - ) -> None: - tool, lib = multi_bend_tool - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="wire") - - p.uturn("A", 20, length=10) - - assert_allclose(p.ports["A"].offset, [-10, -20]) - assert p.ports["A"].rotation is not None - assert_allclose(p.ports["A"].rotation, pi) - assert [type(step.data) for step in p._paths["A"]] == [ - AutoTool.GeneratedData, - AutoTool.ReusableData, - AutoTool.GeneratedData, - AutoTool.ReusableData, - ] - - -def test_autotool_offer_bbox_rejects_invalid_parameter(multi_bend_tool: tuple[AutoTool, Library]) -> None: - tool, _lib = multi_bend_tool - offer = tool.primitive_offers('bend', ccw=True)[0] - - with pytest.raises(BuildError, match='outside singleton domain'): - offer.bbox_at(offer.parameter_domain[0] - 1) - -def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, Library]) -> None: - tool, lib = multi_bend_tool - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="wire") - - p.trace("A", True, length=15) - - assert_allclose(p.ports["A"].offset, [-15, -2]) - straight_step, bend_step = p._paths["A"] - assert isinstance(straight_step.data, AutoTool.GeneratedData) - assert isinstance(bend_step.data, AutoTool.ReusableData) - assert straight_step.data.parameter == 13 - assert bend_step.data.abstract.name == "b1" - - -def test_autotool_generated_primitives_snapshot_route_options() -> None: - markers: list[str | None] = [] - - def make_marked_straight( - length: float, - marker: str | None = None, - nested: dict[str, list[int]] | None = None, - ) -> Pattern: - _ = nested - markers.append(marker) - return make_straight(length, ptype="wire") - - tool = AutoTool().add_straight(make_marked_straight, "wire", "A", length_range=(0, 1e8)) - p = Pather(Library(), tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="wire") - - first_options = {'marker': 'first', 'nested': {'values': [1]}} - p.straight("A", 5, tool_options=first_options) - first_options['marker'] = 'mutated' - first_options['nested']['values'].append(2) - p.straight("A", 6, tool_options={'marker': 'second'}) - - first_data, second_data = (step.data for step in p._paths['A']) - assert isinstance(first_data, AutoTool.GeneratedData) - assert isinstance(second_data, AutoTool.GeneratedData) - assert dict(first_data.tool_options) == {'marker': 'first', 'nested': {'values': [1]}} - assert dict(second_data.tool_options) == {'marker': 'second'} - - p.render() - - assert markers == ['first', 'second'] - - -def test_autotool_route_options_must_be_deepcopyable() -> None: - class NotCopyable: - def __deepcopy__(self, memo: dict[int, Any]) -> None: - _ = memo - raise TypeError('no copy') - - tool = AutoTool().add_straight(make_straight, 'wire', 'A') - with pytest.raises(BuildError, match='must be deep-copyable'): - tool.primitive_offers('straight', marker=NotCopyable()) - - -def test_autotool_route_options_do_not_attach_to_reusable_bends( - multi_bend_tool: tuple[AutoTool, Library], - ) -> None: - tool, _lib = multi_bend_tool - offers = tool.primitive_offers('bend', in_ptype='wire', ccw=True, marker='ignored') - - assert offers - assert all(isinstance(offer.commit(offer.parameter_domain[0]), AutoTool.ReusableData) for offer in offers) - - -def test_autotool_sbend_generator_receives_route_options() -> None: - markers: list[str] = [] - - def make_marked_sbend(jog: float, *, marker: str) -> Pattern: - markers.append(marker) - pat = Pattern() - pat.ports['A'] = Port((0, 0), 0, ptype='wire') - pat.ports['B'] = Port((3, jog), pi, ptype='wire') - return pat - - tool = AutoTool().add_sbend( - make_marked_sbend, - 'wire', - 'A', - 'B', - endpoint=lambda jog: Port((3, jog), pi, ptype='wire'), - ) - p = Pather(Library(), tools=tool, render='deferred') - p.ports['A'] = Port((0, 0), 0, ptype='wire') - - p.jog('A', 4, length=3, tool_options={'marker': 'sbend'}) - p.render() - - assert markers == ['sbend'] - - -def test_autotool_generated_bbox_uses_route_options() -> None: - tool = AutoTool().add_straight(make_straight, 'wire', 'A') - offer = tool.primitive_offers('straight', width=6)[0] - - assert_allclose(offer.bbox_at(10), [[0, -3], [10, 3]]) - - -def test_autotool_unsupported_generator_option_fails_during_render() -> None: - tool = AutoTool().add_straight(make_straight, 'wire', 'A') - p = Pather(Library(), tools=tool, render='deferred') - p.ports['A'] = Port((0, 0), 0, ptype='wire') - - p.straight('A', 5, tool_options={'unknown_generator_option': True}) - - with pytest.raises(TypeError, match='unknown_generator_option'): - p.render() - - -def test_autotool_generator_options_must_preserve_planned_endpoint() -> None: - def shifted_straight(length: float, endpoint_shift: float = 0) -> Pattern: - pat = make_straight(length) - pat.ports['B'].x += endpoint_shift - return pat - - tool = AutoTool().add_straight(shifted_straight, 'wire', 'A') - p = Pather(Library(), tools=tool, render='deferred') - p.ports['A'] = Port((0, 0), 0, ptype='wire') - - p.straight('A', 5, tool_options={'endpoint_shift': 1}) - - with pytest.raises(BuildError, match='does not match planned endpoint'): - p.render() - - -@pytest.mark.parametrize("ccw", [False, True]) -def test_autotool_bend_offer_supports_requested_output_transition(ccw: bool) -> None: - lib = Library() - - bend_pat = Pattern() - bend_pat.ports["A"] = Port((0, 0), 0, ptype="core") - bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="core") - lib["core_bend"] = bend_pat - - trans_pat = Pattern() - trans_pat.ports["CORE"] = Port((0, 0), 0, ptype="core") - trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext") - lib["out_trans"] = trans_pat - - tool = ( - AutoTool(bbox_library=lib) - .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) - .add_bend(lib.abstract("core_bend"), "A", "B", clockwise=True, mirror=True) - .add_transition(lib.abstract("out_trans"), "EXT", "CORE") - ) - - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="core") - p.trace("A", ccw, length=10, out_ptype="ext") - - assert p.ports["A"].ptype == "ext" - assert [type(step.data) for step in p._paths["A"]] == [ - AutoTool.GeneratedData, - AutoTool.ReusableData, - AutoTool.ReusableData, - ] - assert p._paths["A"][2].data.port_name == "CORE" - - -@pytest.mark.parametrize("ccw", [False, True]) -def test_autotool_bend_offer_supports_bend_input_transition(ccw: bool) -> None: - lib = Library() - - bend_pat = Pattern() - bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") - bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") - lib["mid_bend"] = bend_pat - - trans_pat = Pattern() - trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") - trans_pat.ports["CORE"] = Port((1, 0), pi, ptype="core") - lib["bend_trans"] = trans_pat - - tool = ( - AutoTool(bbox_library=lib) - .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) - .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) - .add_transition(lib.abstract("bend_trans"), "MID", "CORE") - ) - - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="core") - p.trace("A", ccw, length=10, out_ptype="mid") - - assert p.ports["A"].ptype == "mid" - assert [type(step.data) for step in p._paths["A"]] == [ - AutoTool.GeneratedData, - AutoTool.ReusableData, - AutoTool.ReusableData, - ] - assert p._paths["A"][1].data.port_name == "CORE" - - -def test_pather_accepts_bend_offer_with_zero_lateral_endpoint() -> None: - lib = Library() - - bend_pat = Pattern() - bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") - bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") - lib["mid_bend"] = bend_pat - - trans_pat = Pattern() - trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") - trans_pat.ports["CORE"] = Port((1, -2), pi, ptype="core") - lib["bend_trans"] = trans_pat - - tool = ( - AutoTool(bbox_library=lib) - .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) - .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) - .add_transition(lib.abstract("bend_trans"), "MID", "CORE") - ) - - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="core") - p.trace("A", True, length=10, out_ptype="mid") - - assert_allclose(p.ports["A"].offset, [-10, 0], atol=1e-10) - assert p.ports["A"].ptype == "mid" - assert [type(step.data) for step in p._paths["A"]] == [ - AutoTool.GeneratedData, - AutoTool.ReusableData, - AutoTool.ReusableData, - ] - - -@pytest.mark.parametrize("ccw", [False, True]) -def test_autotool_bend_offer_supports_bend_and_output_transitions(ccw: bool) -> None: - lib = Library() - - bend_pat = Pattern() - bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") - bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") - lib["mid_bend"] = bend_pat - - bend_trans_pat = Pattern() - bend_trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") - bend_trans_pat.ports["CORE"] = Port((1, 0), pi, ptype="core") - lib["bend_trans"] = bend_trans_pat - - out_trans_pat = Pattern() - out_trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") - out_trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext") - lib["out_trans"] = out_trans_pat - - tool = ( - AutoTool(bbox_library=lib) - .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) - .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) - .add_transition(lib.abstract("bend_trans"), "MID", "CORE") - .add_transition(lib.abstract("out_trans"), "EXT", "MID") - ) - - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="core") - p.trace("A", ccw, length=12, out_ptype="ext") - - assert p.ports["A"].ptype == "ext" - assert [type(step.data) for step in p._paths["A"]] == [ - AutoTool.GeneratedData, - AutoTool.ReusableData, - AutoTool.ReusableData, - AutoTool.ReusableData, - ] - assert p._paths["A"][1].data.port_name == "CORE" - assert p._paths["A"][3].data.port_name == "MID" - - -def test_pather_autotool_pure_sbend_with_transition_dx() -> None: - lib = Library() - - def make_core_straight(length: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((length, 0), pi, ptype="core") - return pat - - def make_core_sbend(jog: float) -> Pattern: - pat = Pattern() - pat.ports["A"] = Port((0, 0), 0, ptype="core") - pat.ports["B"] = Port((10, jog), pi, ptype="core") - return pat - - trans_pat = Pattern() - trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") - trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core") - lib["xin"] = trans_pat - - tool = ( - AutoTool() - .add_straight(make_core_straight, "core", "A", length_range=(1, 1e8)) - .add_sbend(make_core_sbend, "core", "A", "B", jog_range=(0, 1e8)) - .add_transition(lib.abstract("xin"), "EXT", "CORE") - ) - - transition_offer, trans_port, trans_data = selected_offer( - tool, - "straight", - 5, - in_ptype="ext", - out_ptype="core", - ) - assert transition_offer.out_ptype == "core" - assert_allclose(trans_port.offset, [5, 0]) - assert isinstance(trans_data, AutoTool.ReusableData) - - s_offer, s_port, s_data = selected_offer(tool, "s", 4, in_ptype="core") - assert_allclose(s_port.offset, [10, 4]) - assert isinstance(s_data, AutoTool.GeneratedData) - assert s_offer.out_ptype == "core" - - p = Pather(lib, tools=tool, render='deferred') - p.ports["A"] = Port((0, 0), 0, ptype="ext") - p.jog("A", 4) - - assert_allclose(p.ports["A"].offset, [-15, -4]) - assert [step.opcode for step in p._paths["A"]] == ['L', 'S'] - assert isinstance(p._paths["A"][0].data, AutoTool.ReusableData) - assert isinstance(p._paths["A"][1].data, AutoTool.GeneratedData) diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py deleted file mode 100644 index b8d0538..0000000 --- a/masque/test/test_boolean.py +++ /dev/null @@ -1,276 +0,0 @@ -# ruff: noqa: PLC0415 -import pytest -import numpy -from numpy.testing import assert_allclose -from masque.pattern import Pattern -from masque.shapes.polygon import Polygon -from masque.repetition import Grid -from masque.library import Library -from masque.error import PatternError - - -def _poly_area(poly: Polygon) -> float: - verts = poly.vertices - x = verts[:, 0] - y = verts[:, 1] - return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1))) - - -@pytest.mark.parametrize('repeated_clip', [False, True]) -@pytest.mark.parametrize('nested', [False, True]) -def test_boolean_expands_repetitions(repeated_clip: bool, nested: bool) -> None: - from masque import boolean - from masque.repetition import Arbitrary - from masque.shapes import RectCollection - - repeated = RectCollection([[0, 0, 2, 2]], repetition=Arbitrary([[0, 0], [10, 0]])) - clip = Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]) - subject, other = (clip, repeated) if repeated_clip else (repeated, clip) - result = boolean([[subject]] if nested else subject, [other], operation='intersection') - assert len(result) == 1 - assert_allclose(result[0].get_bounds_single(), [[10, 0], [12, 2]]) - assert _poly_area(result[0]) == 4 - assert result[0].repetition is None - assert_allclose(repeated.rects, [[0, 0, 2, 2]]) - assert_allclose(repeated.repetition.displacements, [[0, 0], [10, 0]]) - - -@pytest.mark.parametrize('operation', ['union', 'difference', 'xor']) -def test_boolean_single_set_normalizes_overlaps(operation: str) -> None: - from masque import boolean - - subject = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]], repetition=Grid(a_vector=(1, 0), a_count=2)) - for clips in (None, []): - result = boolean(subject, clips, operation=operation) - assert len(result) == 1 - assert _poly_area(result[0]) == 6 - if operation != 'difference': - result = boolean([], subject, operation=operation) - assert len(result) == 1 - assert _poly_area(result[0]) == 6 - -def test_layer_as_polygons_basic() -> None: - pat = Pattern() - pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]]) - - polys = pat.layer_as_polygons((1, 0), flatten=False) - assert len(polys) == 1 - assert isinstance(polys[0], Polygon) - assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - -def test_layer_as_polygons_repetition() -> None: - pat = Pattern() - rep = Grid(a_vector=(2, 0), a_count=2) - pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep) - - polys = pat.layer_as_polygons((1, 0), flatten=False) - assert len(polys) == 2 - # First polygon at (0,0) - assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - # Second polygon at (2,0) - assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]]) - -def test_layer_as_polygons_flatten() -> None: - lib = Library() - - child = Pattern() - child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]]) - lib['child'] = child - - parent = Pattern() - parent.ref('child', offset=(10, 10), rotation=numpy.pi/2) - - polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib) - assert len(polys) == 1 - # Child vertices are rotated by the ref and then translated by the ref offset. - expected = numpy.array([[10, 10], [10, 11], [9, 11]]) - assert_allclose(polys[0].vertices, expected, atol=1e-10) - -def test_boolean_import_error() -> None: - from masque import boolean - # If pyclipper is not installed, this should raise ImportError - try: - import pyclipper # noqa: F401 - pytest.skip("pyclipper is installed, cannot test ImportError") - except ImportError: - with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): - boolean([], [], operation='union') - -def test_polygon_boolean_shortcut() -> None: - poly = Polygon([[0, 0], [1, 0], [1, 1]]) - # This should also raise ImportError if pyclipper is missing - try: - import pyclipper # noqa: F401 - pytest.skip("pyclipper is installed") - except ImportError: - with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): - poly.boolean(poly) - - -def test_boolean_intersection_with_pyclipper() -> None: - pytest.importorskip("pyclipper") - from masque.utils.boolean import boolean - - result = boolean( - [Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])], - [Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])], - operation='intersection', - ) - - assert len(result) == 1 - assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) - - -def test_polygon_boolean_shortcut_with_pyclipper() -> None: - pytest.importorskip("pyclipper") - - poly = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]) - result = poly.boolean( - Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), - operation='intersection', - ) - - assert len(result) == 1 - assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) - - -def test_boolean_union_difference_and_xor_with_pyclipper() -> None: - pytest.importorskip("pyclipper") - from masque.utils.boolean import boolean - - rect_a = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]) - rect_b = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]) - - union = boolean([rect_a], [rect_b], operation='union') - assert len(union) == 1 - assert_allclose(union[0].get_bounds_single(), [[0, 0], [3, 3]], atol=1e-10) - assert_allclose(_poly_area(union[0]), 7, atol=1e-10) - - difference = boolean([rect_a], [rect_b], operation='difference') - assert len(difference) == 1 - assert_allclose(difference[0].get_bounds_single(), [[0, 0], [2, 2]], atol=1e-10) - assert_allclose(_poly_area(difference[0]), 3, atol=1e-10) - - xor = boolean([rect_a], [rect_b], operation='xor') - assert len(xor) == 2 - assert_allclose(sorted(_poly_area(poly) for poly in xor), [3, 3], atol=1e-10) - xor_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in xor) - assert xor_bounds == [((0.0, 0.0), (2.0, 2.0)), ((1.0, 1.0), (3.0, 3.0))] - - -def test_boolean_accepts_raw_vertices_and_single_shape_inputs() -> None: - pytest.importorskip("pyclipper") - from masque.utils.boolean import boolean - - raw_result = boolean( - [numpy.array([[0, 0], [2, 0], [2, 2], [0, 2]])], - numpy.array([[1, 1], [3, 1], [3, 3], [1, 3]]), - operation='intersection', - ) - assert len(raw_result) == 1 - assert_allclose(raw_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) - assert_allclose(_poly_area(raw_result[0]), 1, atol=1e-10) - - single_shape_result = boolean( - Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]), - Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), - operation='intersection', - ) - assert len(single_shape_result) == 1 - assert_allclose(single_shape_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) - - -def test_boolean_handles_multi_polygon_inputs() -> None: - pytest.importorskip("pyclipper") - from masque.utils.boolean import boolean - - result = boolean( - [ - Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]), - Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]), - ], - [ - Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), - Polygon([[11, 1], [13, 1], [13, 3], [11, 3]]), - ], - operation='intersection', - ) - assert len(result) == 2 - assert_allclose(sorted(_poly_area(poly) for poly in result), [1, 1], atol=1e-10) - result_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in result) - assert result_bounds == [((1.0, 1.0), (2.0, 2.0)), ((11.0, 1.0), (12.0, 2.0))] - - -def test_boolean_difference_preserves_hole_area_via_bridged_polygon() -> None: - pytest.importorskip("pyclipper") - from masque.utils.boolean import boolean - - outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]]) - hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]]) - result = boolean([outer], [hole], operation='difference') - - assert len(result) == 1 - assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10) - assert_allclose(_poly_area(result[0]), 64, atol=1e-10) - - -def test_boolean_nested_hole_and_island_case() -> None: - pytest.importorskip("pyclipper") - from masque.utils.boolean import boolean - - outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]]) - hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]]) - island = Polygon([[4, 4], [6, 4], [6, 6], [4, 6]]) - - result = boolean([outer, island], [hole], operation='union') - - assert len(result) == 1 - assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10) - assert_allclose(_poly_area(result[0]), 100, atol=1e-10) - - -def test_boolean_empty_inputs_follow_set_semantics() -> None: - pytest.importorskip("pyclipper") - from masque.utils.boolean import boolean - - rect = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]) - - union = boolean([], [rect], operation='union') - assert len(union) == 1 - assert_allclose(union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) - - intersection = boolean([], [rect], operation='intersection') - assert intersection == [] - - difference = boolean([], [rect], operation='difference') - assert difference == [] - - xor = boolean([], [rect], operation='xor') - assert len(xor) == 1 - assert_allclose(xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) - - clip_empty_union = boolean([rect], [], operation='union') - assert len(clip_empty_union) == 1 - assert_allclose(clip_empty_union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) - - clip_empty_intersection = boolean([rect], [], operation='intersection') - assert clip_empty_intersection == [] - - clip_empty_difference = boolean([rect], [], operation='difference') - assert len(clip_empty_difference) == 1 - assert_allclose(clip_empty_difference[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) - - clip_empty_xor = boolean([rect], [], operation='xor') - assert len(clip_empty_xor) == 1 - assert_allclose(clip_empty_xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) - - -def test_boolean_invalid_inputs_raise_pattern_error() -> None: - pytest.importorskip("pyclipper") - from masque.utils.boolean import boolean - - rect = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) - - for bad in (123, object(), [123]): - with pytest.raises(PatternError, match='Unsupported type'): - boolean([rect], bad, operation='intersection') diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py deleted file mode 100644 index 647b5f0..0000000 --- a/masque/test/test_build_library.py +++ /dev/null @@ -1,1089 +0,0 @@ -from collections.abc import Iterator -from types import FunctionType -import traceback - -import pytest - -from ..builder import Pather, PathTool, RouteError -from ..error import BuildError, LibraryError -from ..library import ( - INameView, - ILibrary, - IMaterializable, - LibraryBuilder, - BuildReport, - CellProvenance, - ILibraryView, - Library, - LibraryView, - cell, - dangling_mode_t, -) -from ..pattern import Pattern -from ..ports import Port -from ..utils.ports2data import ports_to_data - - -def _owned_by(report: BuildReport, owner: str) -> set[str]: - return { - name for name, prov in report.provenance.items() - if prov.owner_declared_name == owner - } - - -def _external_failing_route_recipe(lib: ILibrary) -> Pattern: - pather = Pather( - lib, - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=PathTool(layer='M1', width=2, ptype='wire'), - render='deferred', - ) - pather.ccw('A', 10, out_ptype='optical') - return pather.pattern - - -class _MetadataSource(ILibraryView): - def __init__(self, mapping: dict[str, Pattern], child_graph: dict[str, set[str]]) -> None: - self.mapping = mapping - self._child_graph = child_graph - self.loads = 0 - - def __getitem__(self, key: str) -> Pattern: - self.loads += 1 - return self.mapping[key] - - def __iter__(self) -> Iterator[str]: - return iter(self.mapping) - - def __len__(self) -> int: - return len(self.mapping) - - def __contains__(self, key: object) -> bool: - return key in self.mapping - - def source_order(self) -> tuple[str, ...]: - return tuple(self.mapping) - - def child_graph(self, dangling: dangling_mode_t = 'error') -> dict[str, set[str]]: # noqa: ARG002 - return self._child_graph - - -class _MaterializableMetadataSource(_MetadataSource, IMaterializable): - def materialize(self, name: str, *, persist: bool = True) -> Pattern: # noqa: ARG002 - return self[name] - - -def test_metadata_source_base_tops_stays_lazy() -> None: - source = _MetadataSource( - {"child": Pattern(), "top": Pattern()}, - {"child": set(), "top": {"child"}}, - ) - - assert source.tops() == ["top"] - assert source.loads == 0 - - -def test_metadata_source_reachability_and_subtree_stay_lazy() -> None: - source = _MetadataSource( - {"child": Pattern(), "top": Pattern(), "unused": Pattern()}, - {"child": set(), "top": {"child"}, "unused": set()}, - ) - - assert source.referenced_patterns("top") == {"child"} - assert source.dangling_refs("top") == set() - subtree = source.subtree("top") - - assert source.loads == 0 - assert subtree.source_order() == ("child", "top") - _ = subtree["top"] - assert source.loads == 1 - - -def test_build_report_defensively_freezes_mappings() -> None: - provenance = { - "top": CellProvenance( - requested_name="top", - kind="declared", - owner_declared_name="top", - build_chain=("top",), - ), - } - dependencies = {"top": frozenset({"child"})} - report = BuildReport( - requested_roots=("top",), - provenance=provenance, - dependency_graph=dependencies, - ) - provenance.clear() - dependencies.clear() - - assert set(report.provenance) == {"top"} - assert report.dependency_graph == {"top": frozenset({"child"})} - with pytest.raises(TypeError): - report.provenance["other"] = report.provenance["top"] # type: ignore[index] - with pytest.raises(TypeError): - report.dependency_graph["top"] = frozenset() # type: ignore[index] - - -def test_build_library_traces_declared_dependencies_out_of_order() -> None: - builder = LibraryBuilder() - - def make_parent(lib: ILibrary) -> Pattern: - pat = Pattern() - pat.ref("child") - assert lib.abstract("child").name == "child" - return pat - - builder.cells.parent = cell(make_parent)(builder.library) - builder["child"] = Pattern(ports={"p": Port((0, 0), 0)}) - - built, report = builder.build() - - assert "parent" in built - assert "child" in built - assert report.dependency_graph["parent"] == frozenset({"child"}) - assert report.provenance["parent"].kind == "declared" - - -def test_build_cells_view_supports_underscore_declarations() -> None: - builder = LibraryBuilder() - builder.cells._helper = Pattern() - - assert "_helper" in builder - with pytest.raises(BuildError, match="write-only"): - _value = builder.cells._helper - report = builder.validate() - assert report.provenance["_helper"].kind == "declared" - - del builder.cells._helper - assert "_helper" not in builder - - -def test_build_cells_view_builds_underscore_declaration() -> None: - builder = LibraryBuilder() - builder.cells._helper = Pattern() - - built, _report = builder.build(output="library") - - assert "_helper" in built - - -def test_build_library_tree_merge_rejects_single_cell_cycle() -> None: - tree = Library({"loop": Pattern()}) - tree["loop"].ref("loop") - builder = LibraryBuilder() - - with pytest.raises(LibraryError, match="exactly one topcell"): - builder << tree - - assert not builder - - -def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None: - builder = LibraryBuilder() - - def make_top(lib: ILibrary) -> Pattern: - tree = Library({"_helper": Pattern()}) - name_a = lib << tree - name_b = lib << tree - top = Pattern() - top.ref(name_a) - top.ref(name_b) - return top - - builder.cells.top = cell(make_top)(builder.library) - _built, report = builder.build() - - helpers = [ - (name, prov) for name, prov in report.provenance.items() - if prov.owner_declared_name == "top" and prov.kind == "helper" - ] - - assert "top" in _owned_by(report, "top") - assert len(helpers) == 2 - assert any(name != prov.requested_name for name, prov in helpers) - - -def test_build_library_authoring_tree_merge_renames_repeated_single_use_names() -> None: - builder = LibraryBuilder() - tree = Library({"_helper": Pattern()}) - - name_a = builder << tree - name_b = builder << tree - built, report = builder.build() - - assert name_a == "_helper" - assert name_b != "_helper" - assert name_a in built - assert name_b in built - assert report.provenance[name_b].requested_name == name_b - - -def test_build_library_authoring_tree_merge_remaps_internal_refs() -> None: - builder = LibraryBuilder() - builder["_helper"] = Pattern() - helper = Pattern() - top = Pattern() - top.ref("_helper") - - top_name = builder << Library({"_helper": helper, "top": top}) - built, _report = builder.build() - - assert top_name == "top" - assert "_helper" not in built[top_name].refs - assert any(name != "_helper" for name in built[top_name].refs) - - -def test_library_builder_is_not_a_readable_library_and_freezes_after_build() -> None: - builder = LibraryBuilder() - builder["leaf"] = Pattern() - - assert isinstance(builder, INameView) - assert not isinstance(builder, ILibraryView) - with pytest.raises(TypeError, match="not subscriptable"): - _ = builder["leaf"] # type: ignore[index] - - with pytest.raises(BuildError, match="write-only"): - _ = builder.cells.leaf - - built, report = builder.build(output="library") - - assert isinstance(built, Library) - assert report.requested_roots == ("leaf",) - - with pytest.raises(BuildError, match="frozen"): - builder["later"] = Pattern() - with pytest.raises(BuildError, match="frozen"): - builder.build() - - -def test_build_library_validate_is_retryable_after_failure() -> None: - builder = LibraryBuilder() - - def make_parent(lib: ILibrary) -> Pattern: - pat = Pattern() - pat.ref("child") - lib.abstract("child") - return pat - - builder.cells.parent = cell(make_parent)(builder.library) - - with pytest.raises(BuildError, match='Failed while building declared cell "parent"'): - builder.validate() - - builder["child"] = Pattern(ports={"p": Port((0, 0), 0)}) - report = builder.validate() - - assert report.dependency_graph["parent"] == frozenset({"child"}) - - -def test_build_library_error_preserves_route_call_site() -> None: - builder = LibraryBuilder() - external_recipe = FunctionType( - _external_failing_route_recipe.__code__.replace(co_filename='/project/user_recipe.py'), - globals(), - ) - builder.cells.top = cell(external_recipe)(builder.library) - - with pytest.raises(BuildError) as exc_info: - builder.validate() - - message = str(exc_info.value) - assert 'Cause: Unable to plan trace_to route' in message - assert 'Stack trace:' not in message - - route_error = exc_info.value.__cause__ - assert isinstance(route_error, RouteError) - assert route_error.__traceback__ is not None - route_frames = traceback.extract_tb(route_error.__traceback__) - assert sum(frame.filename == '/project/user_recipe.py' for frame in route_frames) == 1 - assert not any('/builder/planner/' in frame.filename for frame in route_frames) - assert route_error.__cause__ is not None - assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack) - - native = ''.join(traceback.format_exception(exc_info.value)) - assert native.count('/project/user_recipe.py') == 1 - - -def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None: - builder = LibraryBuilder() - builder["child"] = Pattern() - - def make_parent() -> Pattern: - pat = Pattern() - pat.ref("child") - return pat - - builder.cells.parent = cell(make_parent)().depends_on("child") - report = builder.validate(names=("parent",)) - - assert report.requested_roots == ("parent",) - assert report.dependency_graph["parent"] == frozenset({"child"}) - - -def test_build_library_validate_accepts_single_string_and_deduplicates_roots() -> None: - builder = LibraryBuilder() - builder["top"] = Pattern() - - single = builder.validate(names="top") - duplicate = builder.validate(names=("top", "top")) - - assert single.requested_roots == ("top",) - assert duplicate.requested_roots == ("top",) - - -def test_build_library_validate_rejects_non_string_roots() -> None: - builder = LibraryBuilder() - builder["top"] = Pattern() - - with pytest.raises(TypeError, match="roots must be strings"): - builder.validate(names=("top", 1)) # type: ignore[arg-type] - - -@pytest.mark.parametrize("operation", ["build", "validate"]) -def test_build_library_rejects_same_builder_reentrancy(operation: str) -> None: - builder = LibraryBuilder() - calls = 0 - - def make_top() -> Pattern: - nonlocal calls - calls += 1 - if operation == "build": - builder.build() - else: - builder.validate(names=()) - return Pattern() - - builder.cells.top = cell(make_top)() - - with pytest.raises(BuildError, match="recursively"): - builder.build() - assert calls == 1 - - -def test_build_library_allows_nested_build_of_different_builder() -> None: - inner = LibraryBuilder() - inner["leaf"] = Pattern() - outer = LibraryBuilder() - - def make_top() -> Pattern: - built, _report = inner.build(output="library") - assert "leaf" in built - return Pattern() - - outer.cells.top = cell(make_top)() - built, _report = outer.build(output="library") - - assert "top" in built - - -def test_build_library_validate_rejects_removed_output_argument() -> None: - builder = LibraryBuilder() - builder["leaf"] = Pattern() - - with pytest.raises(TypeError): - builder.validate(output="library") # type: ignore[call-arg] - - -def test_build_library_rejects_unknown_build_output_mode() -> None: - builder = LibraryBuilder() - builder["leaf"] = Pattern() - - with pytest.raises(ValueError, match="Unknown build output mode"): - builder.build(output="bad") # type: ignore[arg-type] - - -def test_build_library_allows_helper_writes_via_pather() -> None: - builder = LibraryBuilder() - builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)}) - - def make_top(lib: ILibrary) -> Pattern: - helper = Pather(library=lib, ports="leaf", name="_route") - top = Pattern() - top.ref("_route") - top.ref("leaf") - top.ports.update(helper.pattern.ports) - return top - - builder.cells.top = cell(make_top)(builder.library) - _built, report = builder.build() - - helper_prov = report.provenance["_route"] - assert helper_prov.kind == "helper" - assert helper_prov.owner_declared_name == "top" - - -def test_build_library_contains_tracks_active_session_names() -> None: - builder = LibraryBuilder() - builder["leaf"] = Pattern() - builder.add_source(Library({"src": Pattern()})) - - def make_top(lib: ILibrary) -> Pattern: - assert "leaf" in lib - assert "src" in lib - assert "_helper" not in lib - lib["_helper"] = Pattern() - assert "_helper" in lib - return Pattern() - - builder.cells.top = cell(make_top)(builder.library) - built, _report = builder.build() - - assert "_helper" in built - - -def test_build_library_preserves_source_cells_and_records_source_provenance() -> None: - source = Library({"src": Pattern()}) - builder = LibraryBuilder() - builder.add_source(source) - builder.cells.top = cell(lambda: Pattern())() - - built, report = builder.build() - - assert "src" in built - assert report.provenance["src"].kind == "source" - - -def test_build_library_add_reserves_all_planned_names() -> None: - builder = LibraryBuilder() - builder["_shape$A"] = Pattern() - builder["_shape$B"] = Pattern() - source = Library({"_shape$A": Pattern(), "_shape$B": Pattern()}) - - rename_map = builder.add(source) - - assert len(set(rename_map.values())) == 2 - assert set(rename_map.values()) <= set(builder) - - -def test_build_library_add_source_can_rename_every_source_cell() -> None: - source = Library() - source["child"] = Pattern() - parent = Pattern() - parent.ref("child") - source["parent"] = parent - - builder = LibraryBuilder() - rename_map = builder.add_source( - source, - rename_theirs=lambda _lib, name: f"mapped_{name}", - rename_when="always", - ) - built, report = builder.build() - - assert rename_map == { - "child": "mapped_child", - "parent": "mapped_parent", - } - assert "mapped_child" in built["mapped_parent"].refs - assert report.provenance["mapped_child"].requested_name == "child" - - -def test_build_library_add_source_renames_conflicting_single_use_name_by_default() -> None: - source = Library({'_myCellName$F': Pattern(), 'source_top': Pattern()}) - source['source_top'].ref('_myCellName$F') - builder = LibraryBuilder() - builder['_myCellName$F'] = Pattern() - - rename_map = builder.add_source(source) - built, _report = builder.build() - - assert rename_map == {'_myCellName$F': '_myCellName'} - assert set(built['source_top'].refs) == {'_myCellName'} - - -def test_library_builder_adds_an_ordinary_view_eagerly() -> None: - child = Pattern() - top = Pattern() - top.ref("child") - source = _MetadataSource( - {"child": child, "top": top}, - {"child": set(), "top": {"child"}}, - ) - - builder = LibraryBuilder() - top_name = builder << source - built, _report = builder.build() - - assert top_name == "top" - assert "top" in built - assert source.loads == 2 - - -def test_library_builder_adds_and_renames_an_ordinary_view_eagerly() -> None: - existing = Pattern() - source_top = Pattern() - source = _MetadataSource( - {"_helper": source_top}, - {"_helper": set()}, - ) - - builder = LibraryBuilder() - builder["_helper"] = existing - top_name = builder << source - built, _report = builder.build() - - assert top_name != "_helper" - assert top_name in built - assert source.loads == 1 - - -def test_library_builder_add_borrows_materializable_views() -> None: - source = _MaterializableMetadataSource( - {"child": Pattern(), "top": Pattern()}, - {"child": set(), "top": {"child"}}, - ) - source.mapping["top"].ref("child") - builder = LibraryBuilder() - - builder.add(source) - built, _report = builder.build() - - assert source.loads == 0 - assert set(built) == {"child", "top"} - - -def test_library_view_wrapper_intentionally_erases_materializable_marker() -> None: - source = _MaterializableMetadataSource( - {"top": Pattern()}, - {"top": set()}, - ) - builder = LibraryBuilder() - - builder.add(LibraryView(source)) - - assert source.loads == 1 - - -def test_build_library_authoring_source_tree_merge_remaps_renamed_child_on_materialization() -> None: - source_helper = Pattern() - source_top = Pattern() - source_top.ref("_helper") - source = _MetadataSource( - {"_helper": source_helper, "top": source_top}, - {"_helper": set(), "top": {"_helper"}}, - ) - - builder = LibraryBuilder() - builder["_helper"] = Pattern() - top_name = builder << source - built, _report = builder.build(output="library") - - assert top_name == "top" - assert "_helper" not in built[top_name].refs - assert source.loads == 2 - - -def test_library_builder_does_not_expose_library_hierarchy_operations() -> None: - builder = LibraryBuilder() - - with pytest.raises(TypeError): - _abstract = builder <= Library({"leaf": Pattern()}) - assert not hasattr(builder, "abstract") - assert not hasattr(builder, "resolve") - assert not hasattr(builder, "rename") - - -def test_build_library_rejects_source_cells_added_after_add_source() -> None: - source = Library({"src": Pattern()}) - builder = LibraryBuilder() - builder.add_source(source) - source["late"] = Pattern() - - with pytest.raises(BuildError, match="Do not structurally mutate source libraries"): - builder.build() - - -def test_build_library_rejects_source_cells_removed_after_add_source() -> None: - source = Library({"src": Pattern()}) - builder = LibraryBuilder() - builder.add_source(source) - del source["src"] - - with pytest.raises(BuildError, match="Do not structurally mutate source libraries"): - builder.build() - - -def test_build_library_rejects_add_source_during_build() -> None: - builder = LibraryBuilder() - - def make_top() -> Pattern: - builder.add_source(Library({"src": Pattern()})) - return Pattern() - - builder.cells.top = cell(make_top)() - - with pytest.raises(BuildError, match="Cannot modify"): - builder.build() - - -def test_build_library_helper_rename_updates_provenance_owner() -> None: - builder = LibraryBuilder() - - def make_top(lib: ILibrary) -> Pattern: - lib["_helper"] = Pattern() - lib.rename("_helper", "final_helper") - top = Pattern() - top.ref("final_helper") - return top - - builder.cells.top = cell(make_top)(builder.library) - built, report = builder.build() - - assert "final_helper" in built - assert "_helper" not in built - owned = _owned_by(report, "top") - assert "final_helper" in owned - assert "_helper" not in owned - prov = report.provenance["final_helper"] - assert prov.kind == "helper" - assert prov.requested_name == "_helper" - - -def test_build_library_helper_delete_removes_provenance_and_ownership() -> None: - builder = LibraryBuilder() - - def make_top(lib: ILibrary) -> Pattern: - lib["_helper"] = Pattern() - del lib["_helper"] - return Pattern() - - builder.cells.top = cell(make_top)(builder.library) - built, report = builder.build() - - assert "_helper" not in built - assert "_helper" not in report.provenance - assert _owned_by(report, "top") == {"top"} - - -def test_build_library_helper_rename_after_auto_rename_preserves_requested_name() -> None: - builder = LibraryBuilder() - - def make_top(lib: ILibrary) -> Pattern: - tree = Library({"_helper": Pattern()}) - _ = lib << tree - renamed = lib << tree - lib.rename(renamed, "final_helper") - top = Pattern() - top.ref("_helper") - top.ref("final_helper") - return top - - builder.cells.top = cell(make_top)(builder.library) - built, report = builder.build() - - assert "final_helper" in built - prov = report.provenance["final_helper"] - assert prov.requested_name == "_helper" - - -def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None: - declared = LibraryBuilder() - declared["leaf"] = Pattern() - - def rename_declared(lib: ILibrary) -> Pattern: - lib.rename("leaf", "renamed_leaf") - return Pattern() - - declared.cells.top = cell(rename_declared)(declared.library) - with pytest.raises(BuildError, match='Cannot rename declared build cell "leaf"'): - declared.build() - - source = LibraryBuilder() - source.add_source(Library({"src": Pattern()})) - - def rename_source(lib: ILibrary) -> Pattern: - lib.rename("src", "renamed_src") - return Pattern() - - source.cells.top = cell(rename_source)(source.library) - with pytest.raises(BuildError, match='Cannot rename imported source cell "src"'): - source.build() - - -def test_build_library_rejects_deleting_declared_or_source_cells_during_build() -> None: - declared = LibraryBuilder() - declared["leaf"] = Pattern() - - def delete_declared(lib: ILibrary) -> Pattern: - del lib["leaf"] - return Pattern() - - declared.cells.top = cell(delete_declared)(declared.library) - with pytest.raises(BuildError, match='Cannot delete declared build cell "leaf"'): - declared.build() - - source = LibraryBuilder() - source.add_source(Library({"src": Pattern()})) - - def delete_source(lib: ILibrary) -> Pattern: - del lib["src"] - return Pattern() - - source.cells.top = cell(delete_source)(source.library) - with pytest.raises(BuildError, match='Cannot delete imported source cell "src"'): - source.build() - - -def test_library_builder_replaces_its_library_placeholder_in_direct_recipe_arguments() -> None: - builder = LibraryBuilder() - builder["leaf"] = Pattern() - - def make_top(positional: ILibrary, *, keyword: ILibrary) -> Pattern: - assert positional is keyword - assert positional.abstract("leaf").name == "leaf" - return Pattern() - - builder.cells.top = cell(make_top)(builder.library, keyword=builder.library) - built, _report = builder.build(output="library") - - assert "top" in built - - -def test_library_builder_library_placeholder_is_read_only() -> None: - builder = LibraryBuilder() - placeholder = builder.library - - with pytest.raises(AttributeError): - builder.library = object() # type: ignore[misc] - - assert builder.library is placeholder - builder.cells.top = cell(lambda _lib: Pattern())(builder.library) - built, _report = builder.build(output="library") - assert "top" in built - - -def test_library_builder_rejects_another_builders_direct_placeholder() -> None: - builder = LibraryBuilder() - other = LibraryBuilder() - - with pytest.raises(BuildError, match="another LibraryBuilder"): - builder.cells.top = cell(lambda _lib: Pattern())(other.library) - - -def test_library_builder_does_not_substitute_nested_placeholders() -> None: - builder = LibraryBuilder() - - def make_top(values: tuple[object, ...]) -> Pattern: - assert values == (builder.library,) - return Pattern() - - builder.cells.top = cell(make_top)((builder.library,)) - builder.build() - - -def test_library_builder_keeps_context_free_recipes_and_name_queries() -> None: - builder = LibraryBuilder() - builder.cells.top = cell(Pattern)() - - assert tuple(builder.keys()) == ("top",) - assert builder.get_name("top") != "top" - - -def test_library_builder_uses_library_name_allocation() -> None: - builder = LibraryBuilder() - builder["cell_name"] = Pattern() - library = Library({"cell_name": Pattern()}) - - requests = ( - ("cell name", True, 32), - ("a name that needs truncation", True, 12), - ("unsanitized name", False, 32), - ("", True, 32), - ) - for name, sanitize, max_length in requests: - assert builder.get_name(name, sanitize=sanitize, max_length=max_length) == library.get_name( - name, - sanitize=sanitize, - max_length=max_length, - ) - - -def test_build_session_subtree_preserves_type_and_builds_deferred_dependencies() -> None: - builder = LibraryBuilder() - child_calls = 0 - - def make_top(lib: ILibrary) -> Pattern: - subtree = lib.subtree("child") - assert type(subtree) is type(lib) - assert set(subtree) == {"child", "leaf"} - assert subtree["child"] is lib["child"] - with pytest.raises(KeyError): - _ = subtree["unrelated"] - subtree["_local"] = Pattern() - assert "_local" not in lib - - top = Pattern() - top.ref("child") - return top - - def make_child() -> Pattern: - nonlocal child_calls - child_calls += 1 - child = Pattern() - child.ref("leaf") - return child - - builder.cells.top = cell(make_top)(builder.library) - builder.cells.child = cell(make_child)() - builder.cells.leaf = Pattern() - builder.cells.unrelated = Pattern() - - built, _report = builder.build(output="library") - - assert child_calls == 1 - assert set(built) == {"top", "child", "leaf", "unrelated"} - - -def test_build_cells_prefixed_view_generates_facades_for_attributes_and_items() -> None: - builder = LibraryBuilder() - cells = builder.cells.prefixed( - "vendor_", - facade=lambda proxy: ports_to_data(proxy, (10, 2)), - ) - - cells.fixed = cell(Pattern)(ports={"p": Port((1, 2), 0)}) - dynamic_name = "dynamic" - cells[dynamic_name] = cell(Pattern)(ports={"q": Port((3, 4), 0)}) - built, report = builder.build(output="library") - - assert set(built) == {"vendor_fixed", "fixed", "vendor_dynamic", "dynamic"} - assert set(built["fixed"].refs) == {"vendor_fixed"} - assert set(built["dynamic"].refs) == {"vendor_dynamic"} - assert set(built["fixed"].ports) == {"p"} - assert len(built["fixed"].labels[(10, 2)]) == 1 - assert not built["vendor_fixed"].labels - assert report.dependency_graph["fixed"] == frozenset({"vendor_fixed"}) - assert report.dependency_graph["dynamic"] == frozenset({"vendor_dynamic"}) - - -def test_build_cells_prefixed_view_postprocesses_only_the_implementation() -> None: - source = Pattern(ports={"p": Port((0, 0), 0)}) - builder = LibraryBuilder() - physical = builder.cells.prefixed( - "vendor_", - postprocess=lambda pattern: ports_to_data(pattern, (20, 1)), - ) - physical["cell"] = source - unprefixed = builder.cells.prefixed( - None, - postprocess=lambda pattern: ports_to_data(pattern, (21, 1)), - ) - unprefixed["local"] = Pattern(ports={"q": Port((1, 1), 0)}) - built, _report = builder.build(output="library") - - assert set(built) == {"vendor_cell", "local"} - assert len(built["vendor_cell"].labels[(20, 1)]) == 1 - assert len(built["local"].labels[(21, 1)]) == 1 - assert not source.labels - - -def test_build_cells_postprocess_precedes_facade_port_copy() -> None: - builder = LibraryBuilder() - - def add_port(pattern: Pattern) -> Pattern: - pattern.ports["added"] = Port((5, 6), 0) - return pattern - - cells = builder.cells.prefixed( - "impl_", - postprocess=add_port, - facade=lambda pattern: ports_to_data(pattern, (30, 0)), - ) - cells.device = Pattern() - built, _report = builder.build(output="library") - - assert set(built["impl_device"].ports) == {"added"} - assert set(built["device"].ports) == {"added"} - assert built["device"].labels[(30, 0)][0].string.startswith("added:") - - -def test_build_cells_facade_is_ignored_when_name_does_not_change() -> None: - builder = LibraryBuilder() - - def fail_if_called(_pattern: Pattern) -> Pattern: - raise AssertionError("facade should not be called") - - cells = builder.cells.prefixed("vendor_", facade=fail_if_called) - cells["vendor_cell"] = Pattern() - built, _report = builder.build(output="library") - - assert set(built) == {"vendor_cell"} - - -def test_build_cells_renamed_views_are_independent_and_accept_dynamic_names() -> None: - builder = LibraryBuilder() - upper = builder.cells.renamed(str.upper) - suffixed = builder.cells.renamed(lambda name: name + "_v2") - - builder.cells.plain = Pattern() - upper["upper"] = Pattern() - suffixed["other"] = Pattern() - built, _report = builder.build(output="library") - - assert set(built) == {"plain", "UPPER", "other_v2"} - - -def test_build_cells_policy_registration_is_atomic() -> None: - builder = LibraryBuilder() - builder.cells.public = Pattern() - prefixed = builder.cells.prefixed("impl_", facade=lambda pattern: pattern) - - with pytest.raises(LibraryError, match="already exist"): - prefixed.public = Pattern() - - assert set(builder) == {"public"} - assert "impl_public" not in builder - - -def test_build_cells_policy_views_validate_names_and_transforms() -> None: - builder = LibraryBuilder() - invalid_name = builder.cells.renamed(lambda _name: None) # type: ignore[arg-type,return-value] - - with pytest.raises(TypeError, match="expected str"): - invalid_name["cell"] = Pattern() - with pytest.raises(TypeError, match="must be callable"): - builder.cells.prefixed("x_", facade=object()) # type: ignore[arg-type] - - assert not builder - - -def test_build_cells_view_deletion_requires_concrete_names_after_rename() -> None: - builder = LibraryBuilder() - prefixed = builder.cells.prefixed("impl_", facade=lambda pattern: pattern) - prefixed["cell"] = Pattern() - - with pytest.raises(BuildError, match="Delete concrete names"): - del prefixed["cell"] - - del builder.cells["cell"] - del builder.cells["impl_cell"] - builder.cells["plain"] = Pattern() - del builder.cells["plain"] - assert not builder - - -def test_build_cells_item_reads_remain_write_only() -> None: - builder = LibraryBuilder() - builder.cells["cell"] = Pattern() - - with pytest.raises(BuildError, match="write-only"): - _ = builder.cells["cell"] - - -def test_build_cells_prefixed_view_normalizes_recipe_tree_and_generates_facade() -> None: - builder = LibraryBuilder() - builder.cells["_helper"] = Pattern() - cells = builder.cells.prefixed( - "impl_", - postprocess=lambda pattern: Pattern(ports={"out": Port((7, 8), 0)}, refs=pattern.refs), - facade=lambda pattern: ports_to_data(pattern, (40, 0)), - ) - - def make_tree() -> Library: - tree = Library({"_helper": Pattern(), "old_top": Pattern()}) - tree["old_top"].ref("_helper") - return tree - - cells.tree = cell(make_tree)() - built, report = builder.build(output="library") - - assert {"_helper", "impl_tree", "tree"} <= set(built) - helper_targets = set(built["impl_tree"].refs) - assert "_helper" not in helper_targets - assert len(helper_targets) == 1 - renamed_helper = helper_targets.pop() - assert renamed_helper in built - assert report.provenance[renamed_helper].kind == "helper" - assert report.provenance[renamed_helper].requested_name == "_helper" - assert report.provenance[renamed_helper].owner_declared_name == "impl_tree" - assert set(built["tree"].ports) == {"out"} - assert set(built["tree"].refs) == {"impl_tree"} - - -def test_build_cells_view_accepts_direct_tree_and_rejects_multiple_tops() -> None: - builder = LibraryBuilder() - builder.cells["tree"] = Library({"old_top": Pattern()}) - built, _report = builder.build(output="library") - assert set(built) == {"tree"} - - invalid = LibraryBuilder() - invalid.cells["tree"] = cell(lambda: Library({"one": Pattern(), "two": Pattern()}))() - with pytest.raises(BuildError, match="exactly one topcell"): - invalid.validate() - - -def test_build_cells_tree_declared_name_wins_over_single_use_helper() -> None: - builder = LibraryBuilder() - - def make_tree() -> Library: - top = Pattern() - top.ref("_device") - return Library({"_device": Pattern(), "old_top": top}) - - builder.cells["_device"] = cell(make_tree)() - built, report = builder.build(output="library") - - assert "_device" in built - assert set(built["_device"].refs) != {"_device"} - assert len(built["_device"].refs) == 1 - helper_name = next(iter(built["_device"].refs)) - assert helper_name in built - assert report.provenance["_device"].kind == "declared" - assert report.provenance["_device"].requested_name == "_device" - assert report.provenance[helper_name].kind == "helper" - assert report.provenance[helper_name].requested_name == "_device" - assert report.provenance[helper_name].owner_declared_name == "_device" - - -def test_build_cells_tree_declared_name_rejects_public_helper_conflict() -> None: - builder = LibraryBuilder() - - def make_tree() -> Library: - top = Pattern() - top.ref("device") - return Library({"device": Pattern(), "old_top": top}) - - builder.cells["device"] = cell(make_tree)() - with pytest.raises(BuildError, match="Unresolved duplicate key"): - builder.validate() - - del builder.cells["device"] - builder.cells["device"] = Pattern() - builder.validate() - - -def test_build_cells_replacement_postprocess_updates_self_published_recipe_and_facade() -> None: - builder = LibraryBuilder() - cells = builder.cells.prefixed( - "impl_", - postprocess=lambda _pattern: Pattern(ports={"new": Port((5, 6), 0)}), - facade=lambda pattern: ports_to_data(pattern, (50, 0)), - ) - - def make_device(lib: ILibrary) -> Pattern: - original = Pattern(ports={"old": Port((1, 2), 0)}) - lib["impl_device"] = original - return original - - cells.device = cell(make_device)(builder.library) - built, report = builder.build(output="library") - - assert set(built) == {"impl_device", "device"} - assert set(built["impl_device"].ports) == {"new"} - assert set(built["device"].ports) == {"new"} - assert built["device"].labels[(50, 0)][0].string.startswith("new:") - assert report.provenance["impl_device"].kind == "declared" - assert report.dependency_graph["device"] == frozenset({"impl_device"}) - - -def test_build_cells_rejects_unrelated_self_published_pattern() -> None: - builder = LibraryBuilder() - - def make_device(lib: ILibrary) -> Pattern: - lib["device"] = Pattern() - return Pattern() - - builder.cells.device = cell(make_device)(builder.library) - with pytest.raises(BuildError, match="wrote a different pattern"): - builder.validate() diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py deleted file mode 100644 index 55e1b21..0000000 --- a/masque/test/test_builder.py +++ /dev/null @@ -1,254 +0,0 @@ -import numpy -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..builder import MinimumStatus, Pather, RouteFailureDetails -from ..builder.utils import ell -from ..error import BuildError -from ..library import Library -from ..pattern import Pattern -from ..ports import Port - - -def test_builder_public_imports() -> None: - from masque import PortPather as TopPortPather - from masque import RenderStep as TopRenderStep - from masque import RouteError as TopRouteError - from masque import RouteFailureDetails as TopRouteFailureDetails - from masque import RouteFailurePolicy as TopRouteFailurePolicy - from masque import MinimumStatus as TopMinimumStatus - from masque.builder import PortPather as BuilderPortPather - from masque.builder import RenderStep as BuilderRenderStep - from masque.builder import RouteError as BuilderRouteError - from masque.builder import RouteFailureDetails as BuilderRouteFailureDetails - from masque.builder import RouteFailurePolicy as BuilderRouteFailurePolicy - from masque.builder import MinimumStatus as BuilderMinimumStatus - - assert TopPortPather is BuilderPortPather - assert TopRenderStep is BuilderRenderStep - assert TopRouteError is BuilderRouteError - assert TopRouteFailureDetails is BuilderRouteFailureDetails - assert TopRouteFailurePolicy is BuilderRouteFailurePolicy - assert TopMinimumStatus is BuilderMinimumStatus - - -def test_route_failure_details_enforces_minimum_status_invariants() -> None: - common = { - 'operation': 'trace', - 'portspec': 'A', - 'in_ptype': 'wire', - 'out_ptype': 'wide', - 'request': {}, - 'resolved_length': 1, - 'resolved_jog': None, - 'cause': 'no route', - } - - with pytest.raises(BuildError, match='FOUND requires minimum_length'): - RouteFailureDetails( - **common, - minimum_length=None, - minimum_status=MinimumStatus.FOUND, - ) - - with pytest.raises(BuildError, match='requires minimum_length=None'): - RouteFailureDetails( - **common, - minimum_length=2, - minimum_status=MinimumStatus.NO_ROUTE, - ) - - -def test_plain_build_error_does_not_include_stacktrace() -> None: - assert str(BuildError('plain builder failure')) == 'plain builder failure' - - -def test_builder_init() -> None: - lib = Library() - b = Pather(lib, name="mypat") - assert b.pattern is lib["mypat"] - assert b.library is lib - - -def test_builder_place() -> None: - lib = Library() - child = Pattern() - child.ports["A"] = Port((0, 0), 0) - lib["child"] = child - - b = Pather(lib) - b.place("child", offset=(10, 20), port_map={"A": "child_A"}) - - assert "child_A" in b.ports - assert_equal(b.ports["child_A"].offset, [10, 20]) - assert "child" in b.pattern.refs - - -def test_builder_plug() -> None: - lib = Library() - - wire = Pattern() - wire.ports["in"] = Port((0, 0), 0) - wire.ports["out"] = Port((10, 0), pi) - lib["wire"] = wire - - b = Pather(lib) - b.ports["start"] = Port((100, 100), 0) - - # Plug wire's "in" port into builder's "start" port - # Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports - # builder start: (100, 100) rotation 0 - # wire in: (0, 0) rotation 0 - # wire out: (10, 0) rotation pi - # Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg) - # so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100) - b.plug("wire", map_in={"start": "in"}) - - assert "start" in b.ports - assert_equal(b.ports["start"].offset, [90, 100]) - assert b.ports["start"].rotation is not None - assert_allclose(b.ports["start"].rotation, 0, atol=1e-10) - - -def test_builder_interface() -> None: - lib = Library() - source = Pattern() - source.ports["P1"] = Port((0, 0), 0) - lib["source"] = source - - b = Pather.interface("source", library=lib, name="iface") - assert "in_P1" in b.ports - assert "P1" in b.ports - assert b.pattern is lib["iface"] - - -def test_builder_set_dead() -> None: - lib = Library() - lib["sub"] = Pattern() - b = Pather(lib) - b.set_dead() - - b.place("sub") - assert not b.pattern.has_refs() - - -def test_builder_dead_ports() -> None: - lib = Library() - pat = Pattern() - pat.ports['A'] = Port((0, 0), 0) - b = Pather(lib, pattern=pat) - b.set_dead() - - # Attempt to plug a device where ports don't line up - # A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference). - other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)}) - - # This should NOT raise PortError because b is dead - b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'}) - - # Port A should be removed, and Port B (renamed from D) should be added - assert 'A' not in b.ports - assert 'B' in b.ports - - # Verify geometry was not added - assert not b.pattern.has_refs() - assert not b.pattern.has_shapes() - - -def test_dead_plug_best_effort() -> None: - lib = Library() - pat = Pattern() - pat.ports['A'] = Port((0, 0), 0) - b = Pather(lib, pattern=pat) - b.set_dead() - - # Device with multiple ports, none of which line up correctly - other = Pattern(ports={ - 'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi) - 'P2': Port((20, 20), pi) # Correct rotation but wrong offset - }) - - # Try to plug. find_transform will fail. - # It should fall back to aligning the first pair ('A' and 'P1'). - b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'}) - - assert 'A' not in b.ports - assert 'B' in b.ports - - # Dummy transform aligns A (0,0) with P1 (10,10) - # A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi - # P2 (20,20) rotation pi: - # 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10) - # 2. Rotate (10,10) by -pi: (-10,-10) - # 3. Translate by s_port.offset (0,0): (-10,-10) - assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10) - # P2 rot pi + transform rot -pi = 0 - assert b.ports['B'].rotation is not None - assert_allclose(b.ports['B'].rotation, 0, atol=1e-10) - - -def test_ell_validates_spacing_length() -> None: - ports = { - 'A': Port((0, 0), 0), - 'B': Port((0, 1), 0), - 'C': Port((0, 2), 0), - } - - with pytest.raises(BuildError, match='spacing must be scalar or have length 2'): - ell(ports, True, 'min_extension', 5, spacing=[1, 2, 3]) - - with pytest.raises(BuildError, match='spacing must be scalar or have length 2'): - ell(ports, True, 'min_extension', 5, spacing=[]) - - -def test_ell_handles_array_spacing_when_ccw_none() -> None: - ports = { - 'A': Port((0, 0), 0), - 'B': Port((0, 1), 0), - } - - scalar = ell(ports, None, 'min_extension', 5, spacing=0) - array_zero = ell(ports, None, 'min_extension', 5, spacing=numpy.array([0, 0])) - assert scalar == array_zero - - with pytest.raises(BuildError, match='Spacing must be 0 or None'): - ell(ports, None, 'min_extension', 5, spacing=numpy.array([1, 0])) - - -@pytest.mark.parametrize('bound_type', ['emin', 'emax', 'min_past_furthest']) -@pytest.mark.parametrize( - ('rotation', 'expected'), - [ - (0, 5), - (pi / 2, 7), - (pi / 6, 5), - (pi / 3, 7), - (pi / 4, 5), - ], - ) -def test_ell_extension_vector_selects_dominant_route_axis( - bound_type: str, - rotation: float, - expected: float, - ) -> None: - result = ell({'A': Port((0, 0), rotation)}, None, bound_type, (5, 7), spacing=0) - - assert_allclose(result['A'], expected) - - -@pytest.mark.parametrize('bound', [(-1, 2), (1, -2), (numpy.nan, 2), (1, numpy.inf), (1, 2, 3)]) -def test_ell_rejects_invalid_extension_vector(bound: tuple[float, ...]) -> None: - with pytest.raises(BuildError, match='bound|negative'): - ell({'A': Port((0, 0), 0)}, None, 'emin', bound, spacing=0) - - -def test_ell_position_vector_still_projects_onto_route_direction() -> None: - result = ell({'A': Port((0, 0), pi)}, None, 'pmax', (5, 7), spacing=0) - - assert_allclose(result['A'], 5) - - -def test_ell_rejects_invalid_bound_type() -> None: - with pytest.raises(BuildError, match='Invalid bound type'): - ell({'A': Port((0, 0), 0)}, None, 'nearest', 5, spacing=0) diff --git a/masque/test/test_circle.py b/masque/test/test_circle.py deleted file mode 100644 index 1b4158e..0000000 --- a/masque/test/test_circle.py +++ /dev/null @@ -1,17 +0,0 @@ -from numpy.testing import assert_equal, assert_allclose - -from ..shapes import Circle, Polygon - - -def test_circle_init() -> None: - c = Circle(radius=10, offset=(5, 5)) - assert c.radius == 10 - assert_equal(c.offset, [5, 5]) - -def test_circle_to_polygons() -> None: - c = Circle(radius=10) - polys = c.to_polygons(num_vertices=32) - assert len(polys) == 1 - assert isinstance(polys[0], Polygon) - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10) diff --git a/masque/test/test_curve_polygonization.py b/masque/test/test_curve_polygonization.py deleted file mode 100644 index aaf4070..0000000 --- a/masque/test/test_curve_polygonization.py +++ /dev/null @@ -1,26 +0,0 @@ -from numpy import pi - -from ..shapes import Arc, Circle, Ellipse -from .helpers import assert_closed_edges_within - - -def test_shape_arclen() -> None: - e = Ellipse(radii=(10, 5)) - polys = e.to_polygons(max_arclen=5) - v = polys[0].vertices - assert_closed_edges_within(v, 5) - assert len(v) > 10 - - a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) - polys = a.to_polygons(max_arclen=2) - assert_closed_edges_within(polys[0].vertices, 2) - -def test_curve_polygonizers_clamp_large_max_arclen() -> None: - for shape in ( - Circle(radius=10), - Ellipse(radii=(10, 20)), - Arc(radii=(10, 20), angles=(0, 1), width=2), - ): - polys = shape.to_polygons(num_vertices=None, max_arclen=1e9) - assert len(polys) == 1 - assert len(polys[0].vertices) >= 3 diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py deleted file mode 100644 index 442b62f..0000000 --- a/masque/test/test_dxf.py +++ /dev/null @@ -1,168 +0,0 @@ -import io -import numpy -import ezdxf -from numpy.testing import assert_allclose -from pathlib import Path - -from ..pattern import Pattern -from ..library import Library -from ..shapes import Path as MPath, Polygon -from ..repetition import Grid -from ..file import dxf - - -def _matches_open_path(actual: numpy.ndarray, expected: numpy.ndarray) -> bool: - return bool( - numpy.allclose(actual, expected) - or numpy.allclose(actual, expected[::-1]) - ) - - -def _matches_closed_vertices(actual: numpy.ndarray, expected: numpy.ndarray) -> bool: - return {tuple(row) for row in actual.tolist()} == {tuple(row) for row in expected.tolist()} - - -def test_dxf_roundtrip(tmp_path: Path): - lib = Library() - pat = Pattern() - - poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) - pat.polygon("1", vertices=poly_verts) - - path_verts = numpy.array([[20, 0], [30, 0], [30, 10]]) - pat.path("2", vertices=path_verts, width=2) - - # Two-point paths remain paths rather than being polygonized. - path2_verts = numpy.array([[40, 0], [50, 10]]) - pat.path("3", vertices=path2_verts, width=0) - - subpat = Pattern() - subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]]) - lib["sub"] = subpat - - pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3)) - - lib["top"] = pat - - dxf_file = tmp_path / "test.dxf" - dxf.writefile(lib, "top", dxf_file) - - read_lib, _ = dxf.readfile(dxf_file) - - top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] - - polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)] - assert len(polys) >= 1 - poly_read = polys[0] - assert _matches_closed_vertices(poly_read.vertices, poly_verts) - - paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)] - assert len(paths) >= 1 - path_read = paths[0] - assert _matches_open_path(path_read.vertices, path_verts) - assert path_read.width == 2 - - paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)] - assert len(paths2) >= 1 - path2_read = paths2[0] - assert _matches_open_path(path2_read.vertices, path2_verts) - assert path2_read.width == 0 - - assert "sub" in read_lib - - found_grid = False - for target, reflist in top_pat.refs.items(): - if target.upper() == "SUB": - for ref in reflist: - if isinstance(ref.repetition, Grid): - assert ref.repetition.a_count == 2 - assert ref.repetition.b_count == 3 - assert_allclose(ref.repetition.a_vector, (10, 0)) - assert_allclose(ref.repetition.b_vector, (0, 10)) - found_grid = True - assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}" - -def test_dxf_manhattan_precision(tmp_path: Path): - lib = Library() - sub = Pattern() - sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) - lib["sub"] = sub - - top = Pattern() - angle = numpy.pi / 2 # 90 degrees - top.ref("sub", offset=(0, 0), rotation=angle, - repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2)) - - lib["top"] = top - - dxf_file = tmp_path / "precision.dxf" - dxf.writefile(lib, "top", dxf_file) - - # Near-integer rotated basis vectors round-trip as a Manhattan Grid. - read_lib, _ = dxf.readfile(dxf_file) - read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] - - target_name = next(k for k in read_top.refs if k.upper() == "SUB") - ref = read_top.refs[target_name][0] - assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation" - - -def test_dxf_rotated_grid_roundtrip_preserves_basis_and_counts(tmp_path: Path): - lib = Library() - sub = Pattern() - sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) - lib["sub"] = sub - - top = Pattern() - top.ref( - "sub", - offset=(0, 0), - rotation=numpy.pi / 2, - repetition=Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2), - ) - lib["top"] = top - - dxf_file = tmp_path / "rotated_grid.dxf" - dxf.writefile(lib, "top", dxf_file) - - read_lib, _ = dxf.readfile(dxf_file) - read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] - - target_name = next(k for k in read_top.refs if k.upper() == "SUB") - ref = read_top.refs[target_name][0] - assert isinstance(ref.repetition, Grid) - actual = ref.repetition.displacements - expected = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2).displacements - assert_allclose( - actual[numpy.lexsort((actual[:, 1], actual[:, 0]))], - expected[numpy.lexsort((expected[:, 1], expected[:, 0]))], - ) - - -def test_dxf_read_legacy_polyline() -> None: - doc = ezdxf.new() - msp = doc.modelspace() - msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True) - - stream = io.StringIO() - doc.write(stream) - stream.seek(0) - - read_lib, _ = dxf.read(stream) - top_pat = read_lib.get("Model") or list(read_lib.values())[0] - - polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)] - assert len(polys) == 1 - assert _matches_closed_vertices(polys[0].vertices, numpy.array([[0, 0], [10, 0], [10, 10]])) - - -def test_dxf_read_ignores_unreferenced_setup_blocks() -> None: - lib = Library({"top": Pattern()}) - stream = io.StringIO() - - dxf.write(lib, "top", stream) - stream.seek(0) - - read_lib, _ = dxf.read(stream) - - assert set(read_lib) == {"Model"} diff --git a/masque/test/test_dxf_modes.py b/masque/test/test_dxf_modes.py deleted file mode 100644 index 10f7f89..0000000 --- a/masque/test/test_dxf_modes.py +++ /dev/null @@ -1,205 +0,0 @@ -"""DXF geometry checks independent of masque's writer/reader round trips.""" -import io - -import ezdxf -import numpy -import pytest -from numpy.testing import assert_allclose - -from ..file import dxf -from ..library import Library -from ..pattern import Pattern -from ..repetition import Grid -from ..shapes import Path, Polygon - - -def _read(doc: ezdxf.document.Drawing, mode: int = 2, accuracy: float = 0.0) -> Library: - stream = io.StringIO() - doc.write(stream) - stream.seek(0) - return dxf.read(stream, polyline_mode=mode, contour_accuracy=accuracy)[0] - - -def _area(shapes: list) -> float: - total = 0.0 - for shape in shapes: - if isinstance(shape, Polygon): - xx, yy = shape.vertices.T - total += abs(numpy.dot(xx, numpy.roll(yy, 1)) - numpy.dot(yy, numpy.roll(xx, 1))) / 2 - return total - - -def _origins(rows: numpy.ndarray) -> numpy.ndarray: - rounded = numpy.round(rows, 8) - return rounded[numpy.lexsort((rounded[:, 1], rounded[:, 0]))] - - -@pytest.mark.parametrize('legacy', [False, True]) -@pytest.mark.parametrize('flagged', [False, True]) -@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4]) -def test_closed_polylines(legacy: bool, flagged: bool, mode: int) -> None: - doc = ezdxf.new() - points = [(0, 0), (10, 0), (10, 10), (0, 10)] - if not flagged: - points.append(points[0]) - msp = doc.modelspace() - if legacy: - msp.add_polyline2d(points).close(flagged) - else: - msp.add_lwpolyline(points, close=flagged) - shapes = _read(doc, mode)['Model'].shapes['0'] - assert len(shapes) == 1 - assert isinstance(shapes[0], Path if mode == 1 else Polygon) - assert _area(shapes) == (0 if mode == 1 else 100) - if mode == 1: - assert_allclose(shapes[0].vertices[0], shapes[0].vertices[-1]) - - -@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4]) -@pytest.mark.parametrize('closed', [False, True]) -def test_join_shuffled_lines(mode: int, closed: bool) -> None: - doc = ezdxf.new() - segments = [((10, 10), (10, 0)), ((0, 0), (10, 0)), ((0, 10), (10, 10))] - if closed: - segments.append(((0, 0), (0, 10))) - for start, end in segments: - doc.modelspace().add_line(start, end) - shapes = _read(doc, mode)['Model'].shapes['0'] - filled = mode == 4 or (closed and mode in (0, 3)) - assert _area(shapes) == (100 if filled else 0) - assert len(shapes) == (len(segments) if mode in (1, 2) else 1) - - -@pytest.mark.parametrize('entity', ['SOLID', 'HATCH']) -def test_auto_detects_solids_in_child_blocks(entity: str) -> None: - doc = ezdxf.new() - doc.modelspace().add_lwpolyline([(0, 0), (4, 0), (4, 4)], close=True) - block = doc.blocks.new('child') - if entity == 'SOLID': - block.add_solid([(0, 0), (1, 0), (1, 1)]) - else: - block.add_hatch() - doc.modelspace().add_blockref('child', (0, 0)) - assert isinstance(_read(doc, 0)['Model'].shapes['0'][0], Path) - - -@pytest.mark.parametrize(('accuracy', 'expected'), [(0, 0), (0.005, 0), (0.02, 100)]) -def test_contour_tolerance(accuracy: float, expected: float) -> None: - doc = ezdxf.new() - doc.modelspace().add_lwpolyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0.01)]) - shapes = _read(doc, 3, accuracy)['Model'].shapes['0'] - assert _area(shapes) == expected - - -@pytest.mark.parametrize('mode', [3, 4]) -def test_nested_contours_resolve_holes_and_islands(mode: int) -> None: - doc = ezdxf.new() - for lo, hi in [(0, 10), (2, 8), (4, 6)]: - doc.modelspace().add_lwpolyline([(lo, lo), (hi, lo), (hi, hi), (lo, hi)], close=True) - shapes = _read(doc, mode)['Model'].shapes['0'] - assert len(shapes) == 2 - assert _area(shapes) == 68 - - -@pytest.mark.parametrize('mode', [3, 4]) -def test_layers_and_blocks_are_not_joined(mode: int) -> None: - doc = ezdxf.new() - doc.modelspace().add_line((0, 0), (10, 0), dxfattribs={'layer': 'a'}) - doc.modelspace().add_line((10, 0), (0, 10), dxfattribs={'layer': 'b'}) - doc.blocks.new('child').add_line((0, 10), (0, 0), dxfattribs={'layer': 'a'}) - lib = _read(doc, mode) - assert all(isinstance(shape, Path) for pat in lib.values() for shapes in pat.shapes.values() for shape in shapes) - - -@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4]) -@pytest.mark.parametrize('legacy', [False, True]) -def test_width_and_degenerate_paths_are_preserved(mode: int, legacy: bool) -> None: - doc = ezdxf.new() - if legacy: - doc.modelspace().add_polyline2d([(0, 0), (2, 0), (2, 2)], dxfattribs={ - 'default_start_width': 2, 'default_end_width': 2, - }).close(True) - else: - doc.modelspace().add_lwpolyline([(0, 0), (2, 0), (2, 2)], close=True, dxfattribs={'const_width': 2}) - doc.modelspace().add_lwpolyline([(10, 0), (10, 0)]) - doc.modelspace().add_lwpolyline([(20, 0), (21, 0), (22, 0)]) - shapes = _read(doc, mode)['Model'].shapes['0'] - assert len(shapes) == 3 - assert all(isinstance(shape, Path) for shape in shapes) - assert sorted(shape.width for shape in shapes) == [0, 0, 2] - - -@pytest.mark.parametrize('mode', [3, 4]) -@pytest.mark.parametrize('spur', [((10, 0), (12, 0)), ((-2, 0), (0, 0)), ((0, -2), (0, 0))]) -def test_branch_and_shuffling_preserve_square(mode: int, spur: tuple) -> None: - segments = [((0, 0), (10, 0)), ((10, 0), (10, 10)), ((10, 10), (0, 10)), - ((0, 10), (0, 0)), spur] - for order in (segments, [(b, a) for a, b in segments[::-1]]): - doc = ezdxf.new() - for start, end in order: - doc.modelspace().add_line(start, end) - shapes = _read(doc, mode)['Model'].shapes['0'] - assert _area(shapes) == 100 - assert len(shapes) == 2 - - -@pytest.mark.parametrize('mode', [-1, 5, 'closed']) -def test_invalid_polyline_mode(mode: int) -> None: - with pytest.raises(ValueError, match='polyline_mode'): - _read(ezdxf.new(), mode) - - -@pytest.mark.parametrize('mode', [3, 4]) -def test_merge_uses_even_odd_filling_for_overlapping_contours(mode: int) -> None: - doc = ezdxf.new() - for xx in (0, 5): - doc.modelspace().add_lwpolyline([(xx, 0), (xx + 10, 0), (xx + 10, 10), (xx, 10)], close=True) - assert _area(_read(doc, mode)['Model'].shapes['0']) == 100 - - -@pytest.mark.parametrize('mode', [3, 4]) -def test_contours_below_clipping_precision_remain_paths(mode: int) -> None: - doc = ezdxf.new() - doc.modelspace().add_lwpolyline([(0, 0), (1e-7, 0), (0, 1e-7)], close=True) - shapes = _read(doc, mode)['Model'].shapes['0'] - assert len(shapes) == 1 - assert isinstance(shapes[0], Path) - assert_allclose(shapes[0].get_bounds_single(), [[0, 0], [1e-7, 1e-7]], atol=1e-15) - - -@pytest.mark.parametrize('accuracy', [-1, numpy.nan, numpy.inf]) -def test_invalid_contour_accuracy(accuracy: float) -> None: - with pytest.raises(ValueError, match='contour_accuracy'): - _read(ezdxf.new(), 3, accuracy) - - -@pytest.mark.parametrize('angle', [0, numpy.pi / 2, numpy.pi, numpy.pi / 4]) -@pytest.mark.parametrize('local_grid', [False, True]) -@pytest.mark.parametrize('mirrored', [False, True]) -def test_exported_insert_origins(angle: float, local_grid: bool, mirrored: bool) -> None: - lib = Library({'leaf': Pattern(), 'top': Pattern()}) - lib['leaf'].polygon('1', vertices=[(0, 0), (4, 0), (0, 2)]) - rep = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2) - if local_grid: - rep.rotate(angle) - lib['top'].ref('leaf', offset=(5, 7), rotation=angle, scale=2, mirrored=mirrored, repetition=rep) - stream = io.StringIO() - dxf.write(lib, 'top', stream) - stream.seek(0) - inserts = ezdxf.read(stream).modelspace().query('INSERT') - origins = [instance.dxf.insert.xyz[:2] for ins in inserts for instance in ins.multi_insert()] - assert_allclose(_origins(numpy.asarray(origins)), _origins(rep.displacements + (5, 7)), atol=1e-7) - - -@pytest.mark.parametrize('scales', [(1, 1), (-1, 1), (1, -1), (-1, -1), (2, 2)]) -@pytest.mark.parametrize('angle', [0, 30, 90]) -def test_imported_insert_origins(scales: tuple[float, float], angle: float) -> None: - doc = ezdxf.new() - doc.blocks.new('leaf') - insert = doc.modelspace().add_blockref('leaf', (5, 7), dxfattribs={ - 'rotation': angle, 'xscale': scales[0], 'yscale': scales[1], - 'column_count': 3, 'row_count': 2, 'column_spacing': 20, 'row_spacing': -10, - }) - expected = numpy.asarray([ins.dxf.insert.xyz[:2] for ins in insert.multi_insert()]) - ref = _read(doc)['Model'].refs['leaf'][0] - assert_allclose(_origins(ref.as_transforms()[:, :2]), _origins(expected), atol=1e-7) diff --git a/masque/test/test_ellipse.py b/masque/test/test_ellipse.py deleted file mode 100644 index 1125fb1..0000000 --- a/masque/test/test_ellipse.py +++ /dev/null @@ -1,29 +0,0 @@ -from numpy import pi -from numpy.testing import assert_equal, assert_allclose - -from ..shapes import Ellipse - - -def test_ellipse_init() -> None: - e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4) - assert_equal(e.radii, [10, 5]) - assert_equal(e.offset, [1, 2]) - assert e.rotation == pi / 4 - -def test_ellipse_to_polygons() -> None: - e = Ellipse(radii=(10, 5)) - polys = e.to_polygons(num_vertices=64) - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10) - -def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None: - ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200)) - bounds = ellipse.get_bounds_single() - poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single() - assert_allclose(bounds, poly_bounds, atol=1e-3) - -def test_ellipse_integer_radii_scale_cleanly() -> None: - ellipse = Ellipse(radii=(10, 20)) - ellipse.scale_by(0.5) - assert_allclose(ellipse.radii, [5, 10]) diff --git a/masque/test/test_fdfd.py b/masque/test/test_fdfd.py deleted file mode 100644 index 2b4f3d3..0000000 --- a/masque/test/test_fdfd.py +++ /dev/null @@ -1,24 +0,0 @@ -# ruff: noqa -# ruff: noqa: ARG001 - - -import dataclasses -import pytest # type: ignore -import numpy -from numpy import pi -from numpy.typing import NDArray -# from numpy.testing import assert_allclose, assert_array_equal - -from .. import Pattern, Arc, Circle - - -def test_circle_mirror(): - cc = Circle(radius=4, offset=(10, 20)) - cc.flip_across(axis=0) # flip across y=0 - assert cc.offset[0] == 10 - assert cc.offset[1] == -20 - assert cc.radius == 4 - cc.flip_across(axis=1) # flip across x=0 - assert cc.offset[0] == -10 - assert cc.offset[1] == -20 - assert cc.radius == 4 diff --git a/masque/test/test_file_format_roundtrip.py b/masque/test/test_file_format_roundtrip.py deleted file mode 100644 index 36c4ce4..0000000 --- a/masque/test/test_file_format_roundtrip.py +++ /dev/null @@ -1,138 +0,0 @@ -from pathlib import Path -from typing import cast -import pytest -from numpy.testing import assert_allclose - -from ..pattern import Pattern -from ..library import Library -from ..shapes import Path as MPath, Circle, Polygon, RectCollection -from ..repetition import Grid, Arbitrary - -def create_test_library(for_gds: bool = False) -> Library: - lib = Library() - - pat_poly = Pattern() - pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]]) - lib["polygons"] = pat_poly - - pat_paths = Pattern() - pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush) - pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square) - if for_gds: - pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle) - pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5)) - lib["paths"] = pat_paths - - pat_circles = Pattern() - if for_gds: - pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0]) - else: - pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10))) - lib["circles"] = pat_circles - - pat_refs = Pattern() - pat_refs.ref("polygons", offset=(0, 0)) - pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2)) - pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]])) - lib["refs"] = pat_refs - - pat_rep_shapes = Pattern() - poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5)) - pat_rep_shapes.shapes[(4, 0)].append(poly_rep) - lib["rep_shapes"] = pat_rep_shapes - - if for_gds: - lib.wrap_repeated_shapes() - - return lib - -def test_gdsii_full_roundtrip(tmp_path: Path) -> None: - from ..file import gdsii - lib = create_test_library(for_gds=True) - gds_file = tmp_path / "full_test.gds" - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, _ = gdsii.readfile(gds_file) - - for name in lib: - assert name in read_lib - - read_paths = read_lib["paths"] - p_flush = cast("MPath", read_paths.shapes[(2, 0)][0]) - assert p_flush.cap == MPath.Cap.Flush - - p_square = cast("MPath", read_paths.shapes[(2, 1)][0]) - assert p_square.cap == MPath.Cap.Square - - p_circle = cast("MPath", read_paths.shapes[(2, 2)][0]) - assert p_circle.cap == MPath.Cap.Circle - - p_custom = cast("MPath", read_paths.shapes[(2, 3)][0]) - assert p_custom.cap == MPath.Cap.SquareCustom - assert p_custom.cap_extensions is not None - assert_allclose(p_custom.cap_extensions, (1, 5)) - - read_refs = read_lib["refs"] - assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs) - - arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None] - assert len(arefs) == 1 - assert isinstance(arefs[0].repetition, Grid) - assert arefs[0].repetition.a_count == 3 - assert arefs[0].repetition.b_count == 2 - - # GDS stores repeated shapes through refs created by wrap_repeated_shapes(). - assert len(read_lib["rep_shapes"].refs) > 0 - -def test_oasis_full_roundtrip(tmp_path: Path) -> None: - pytest.importorskip("fatamorgana") - from ..file import oasis - lib = create_test_library(for_gds=False) - oas_file = tmp_path / "full_test.oas" - oasis.writefile(lib, oas_file, units_per_micron=1000) - - read_lib, _ = oasis.readfile(oas_file) - - for name in lib: - assert name in read_lib - - read_circles = read_lib["circles"] - assert isinstance(read_circles.shapes[(3, 0)][0], Circle) - assert read_circles.shapes[(3, 0)][0].radius == 5 - - read_paths = read_lib["paths"] - assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush - assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square - - read_rep_shapes = read_lib["rep_shapes"] - poly = read_rep_shapes.shapes[(4, 0)][0] - assert poly.repetition is not None - assert isinstance(poly.repetition, Grid) - assert poly.repetition.a_count == 5 - - -def test_gdsii_rect_collection_roundtrip(tmp_path: Path) -> None: - from ..file import gdsii - - lib = Library() - pat = Pattern() - pat.shapes[(5, 0)].append( - RectCollection( - rects=[[0, 0, 10, 5], [20, -5, 30, 10]], - annotations={'1': ['rects']}, - ) - ) - lib['rects'] = pat - - gds_file = tmp_path / 'rect_collection.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, _ = gdsii.readfile(gds_file) - polys = read_lib['rects'].shapes[(5, 0)] - - assert len(polys) == 2 - assert all(isinstance(poly, Polygon) for poly in polys) - assert_allclose(polys[0].vertices, [[0, 0], [0, 5], [10, 5], [10, 0]]) - assert_allclose(polys[1].vertices, [[20, -5], [20, 10], [30, 10], [30, -5]]) - assert polys[0].annotations == {'1': ['rects']} - assert polys[1].annotations == {'1': ['rects']} diff --git a/masque/test/test_gdsii.py b/masque/test/test_gdsii.py deleted file mode 100644 index 5f655df..0000000 --- a/masque/test/test_gdsii.py +++ /dev/null @@ -1,85 +0,0 @@ -from pathlib import Path -from typing import cast -import numpy -import pytest -from numpy.testing import assert_equal, assert_allclose - -from ..error import LibraryError -from ..pattern import Pattern -from ..library import Library -from ..file import gdsii -from ..shapes import Path as MPath, Polygon - - -def test_gdsii_roundtrip(tmp_path: Path) -> None: - lib = Library() - - # Simple polygon cell - pat1 = Pattern() - pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) - lib["poly_cell"] = pat1 - - # Path cell - pat2 = Pattern() - pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10) - lib["path_cell"] = pat2 - - # Cell with Ref - pat3 = Pattern() - pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2) - lib["ref_cell"] = pat3 - - gds_file = tmp_path / "test.gds" - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, info = gdsii.readfile(gds_file) - - assert "poly_cell" in read_lib - assert "path_cell" in read_lib - assert "ref_cell" in read_lib - - # Check polygon - read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0]) - # GDSII closes polygons, so it might have an extra vertex or different order - assert len(read_poly.vertices) >= 4 - # Check bounds as a proxy for geometry correctness - assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]]) - - # Check path - read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0]) - assert isinstance(read_path, MPath) - assert read_path.width == 10 - assert_equal(read_path.vertices, [[0, 0], [100, 0]]) - - # Check Ref - read_ref = read_lib["ref_cell"].refs["poly_cell"][0] - assert_equal(read_ref.offset, [50, 50]) - assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5) - - -def test_gdsii_annotations(tmp_path: Path) -> None: - lib = Library() - pat = Pattern() - # GDS only supports integer keys in range [1, 126] for properties - pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]}) - lib["cell"] = pat - - gds_file = tmp_path / "test_ann.gds" - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - read_lib, _ = gdsii.readfile(gds_file) - read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations - assert read_ann is not None - assert read_ann["1"] == ["hello"] - - -def test_gdsii_check_valid_names_validates_generator_lengths() -> None: - names = (name for name in ("a" * 40,)) - - with pytest.raises(LibraryError, match="invalid names"): - gdsii.check_valid_names(names) - - -def test_gdsii_does_not_export_codec_helpers() -> None: - assert not hasattr(gdsii, 'read_elements') - assert not hasattr(gdsii, 'rint_cast') diff --git a/masque/test/test_gdsii_arrow.py b/masque/test/test_gdsii_arrow.py deleted file mode 100644 index 0a33453..0000000 --- a/masque/test/test_gdsii_arrow.py +++ /dev/null @@ -1,665 +0,0 @@ -from pathlib import Path -import subprocess -import sys -import textwrap - -import klamath -import numpy -import pytest - -pytest.importorskip('pyarrow') - -from .. import Ref, Label, PatternError -from ..library import Library -from ..pattern import Pattern -from ..repetition import Grid -from ..shapes import Path as MPath, Polygon, PolyCollection, RectCollection -from ..file import gdsii -from ..file.gdsii import arrow as gdsii_arrow -from tools.generate_gds_perf import write_fixture - - -if not gdsii_arrow.is_available(): - pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True) - - -def test_arrow_materialized_coordinates_are_writable_floats(tmp_path: Path) -> None: - original = _make_arrow_test_library() - for annotations, layer in [(None, (30, 0)), ({'1': ['prop']}, (31, 0))]: - for xx in (0, 10): - original['leaf'].polygon(layer, [(xx, 0), (xx + 4, 0), (xx, 3)], annotations=annotations) - filename = tmp_path / 'mutable.gds' - gdsii.writefile(original, filename, meters_per_unit=1e-9) - lib, _ = gdsii_arrow.readfile(filename) - arrays = [] - types = set() - for pattern in lib.values(): - for shapes in pattern.shapes.values(): - for shape in shapes: - types.add(type(shape)) - if isinstance(shape, RectCollection): - coordinates = shape.rects - elif isinstance(shape, PolyCollection): - coordinates = shape.vertex_lists - else: - coordinates = shape.vertices - arrays.append(coordinates) - before = coordinates.copy() - shape.translate((0.25, 0.5)).scale_by(1.5) - shift = (0.25, 0.5, 0.25, 0.5) if isinstance(shape, RectCollection) else (0.25, 0.5) - numpy.testing.assert_allclose(coordinates, (before + shift) * 1.5) - if isinstance(shape, MPath) and shape.cap_extensions is not None: - arrays.append(shape.cap_extensions) - for labels in pattern.labels.values(): - arrays.extend(label.offset for label in labels) - for refs in pattern.refs.values(): - for ref in refs: - arrays.append(ref.offset) - ref.translate((0.25, 0.5)) - if ref.repetition is not None: - arrays.extend((ref.repetition.a_vector, ref.repetition.b_vector)) - ref.repetition.scale_by(1.5) - assert {Polygon, PolyCollection, RectCollection, MPath} <= types - for array in arrays: - assert array.dtype == numpy.float64 - assert array.flags.writeable - - -def test_arrow_path_extensions_are_quantized_for_oasis(tmp_path: Path) -> None: - pytest.importorskip('fatamorgana') - from ..file import oasis # noqa: PLC0415 - - source = Library({'top': Pattern().path((1, 0), [(0, 0), (20, 0)], width=4, - cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))}) - gds_path = tmp_path / 'path.gds' - gdsii.writefile(source, gds_path, meters_per_unit=1e-9) - loaded, _ = gdsii_arrow.readfile(gds_path) - path = loaded['top'].shapes[(1, 0)][0] - path.scale_by(1.25) - exported = oasis.build(loaded, units_per_micron=1000).cells[0].geometry[0] - assert exported.extension_start[1] == 1 - assert exported.extension_end[1] == 6 - assert isinstance(exported.extension_start[1], int | numpy.integer) - assert isinstance(exported.extension_end[1], int | numpy.integer) - numpy.testing.assert_allclose(path.cap_extensions, (1.25, 6.25)) - - -def _annotations_key(annotations: dict[str, list[object]] | None) -> tuple[tuple[str, tuple[object, ...]], ...] | None: - if not annotations: - return None - return tuple(sorted((key, tuple(values)) for key, values in annotations.items())) - - -def _coord_key(values: object) -> tuple[int, ...] | tuple[tuple[int, int], ...]: - arr = numpy.rint(numpy.asarray(values, dtype=float)).astype(int) - if arr.ndim == 1: - return tuple(arr.tolist()) - return tuple(tuple(row.tolist()) for row in arr) - - -def _canonical_polygon_key(vertices: object) -> tuple[tuple[int, int], ...]: - arr = numpy.rint(numpy.asarray(vertices, dtype=float)).astype(int) - rows = [tuple(tuple(row.tolist()) for row in numpy.roll(arr, -shift, axis=0)) for shift in range(arr.shape[0])] - rev = arr[::-1] - rows.extend(tuple(tuple(row.tolist()) for row in numpy.roll(rev, -shift, axis=0)) for shift in range(rev.shape[0])) - return min(rows) - - -def _shape_key(shape: object, layer: tuple[int, int]) -> list[tuple[object, ...]]: - if isinstance(shape, MPath): - cap_extensions = None if shape.cap_extensions is None else _coord_key(shape.cap_extensions) - return [( - 'path', - layer, - _coord_key(shape.vertices), - _coord_key(shape.offset), - int(round(float(shape.width))), - shape.cap.name, - cap_extensions, - _annotations_key(shape.annotations), - )] - - keys = [] - for poly in shape.to_polygons(): - keys.append(( - 'polygon', - layer, - _canonical_polygon_key(poly.vertices), - _coord_key(poly.offset), - _annotations_key(poly.annotations), - )) - return keys - - -def _ref_keys(target: str, ref: object) -> list[tuple[object, ...]]: - keys = [] - for transform in ref.as_transforms(): - keys.append(( - target, - _coord_key(transform[:2]), - round(float(transform[2]), 8), - round(float(transform[4]), 8), - bool(int(round(float(transform[3])))), - _annotations_key(ref.annotations), - )) - return keys - - -def _label_key(layer: tuple[int, int], label: object) -> tuple[object, ...]: - return ( - layer, - label.string, - _coord_key(label.offset), - _annotations_key(label.annotations), - ) - - -def _pattern_summary(pattern: Pattern) -> dict[str, object]: - shape_keys: list[tuple[object, ...]] = [] - for layer, shapes in pattern.shapes.items(): - for shape in shapes: - shape_keys.extend(_shape_key(shape, layer)) - - ref_keys: list[tuple[object, ...]] = [] - for target, refs in pattern.refs.items(): - for ref in refs: - ref_keys.extend(_ref_keys(target, ref)) - - label_keys = [ - _label_key(layer, label) - for layer, labels in pattern.labels.items() - for label in labels - ] - - return { - 'shapes': sorted(shape_keys), - 'refs': sorted(ref_keys), - 'labels': sorted(label_keys), - } - - -def _library_summary(lib: Library) -> dict[str, dict[str, object]]: - return {name: _pattern_summary(pattern) for name, pattern in lib.items()} - - -def _make_arrow_test_library() -> Library: - lib = Library() - - leaf = Pattern() - leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]], annotations={'1': ['leaf-poly']}) - leaf.polygon((2, 0), vertices=[[40, 0], [50, 0], [50, 10], [40, 10]]) - leaf.polygon((1, 0), vertices=[[20, 0], [30, 0], [30, 10], [20, 10]]) - leaf.polygon((1, 0), vertices=[[80, 0], [90, 0], [90, 10], [80, 10]]) - leaf.polygon((2, 0), vertices=[[60, 0], [70, 0], [70, 10], [60, 10]], annotations={'18': ['leaf-poly-2']}) - leaf.label((10, 0), string='LEAF', offset=(3, 4), annotations={'10': ['leaf-label']}) - lib['leaf'] = leaf - - child = Pattern() - child.path( - (2, 0), - vertices=[[0, 0], [15, 5], [30, 5]], - width=6, - cap=MPath.Cap.SquareCustom, - cap_extensions=(2, 4), - annotations={'2': ['child-path']}, - ) - child.label((11, 0), string='CHILD', offset=(7, 8), annotations={'11': ['child-label']}) - child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25, annotations={'12': ['child-ref']}) - lib['child'] = child - - sibling = Pattern() - sibling.polygon((3, 0), vertices=[[0, 0], [5, 0], [5, 6], [0, 6]]) - sibling.label((12, 0), string='SIB', offset=(1, 2), annotations={'13': ['sib-label']}) - sibling.ref( - 'leaf', - offset=(-50, 60), - repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2), - annotations={'14': ['sib-ref']}, - ) - lib['sibling'] = sibling - - fanout = Pattern() - fanout.ref('leaf', offset=(0, 0)) - fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1) - fanout.ref('leaf', offset=(20, 0)) - fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3)) - fanout.ref('child', offset=(40, 0), mirrored=True, rotation=numpy.pi / 4, scale=1.2, - repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2)) - fanout.ref('leaf', offset=(50, 0), repetition=Grid(a_vector=(6, 0), a_count=3, b_vector=(0, 8), b_count=2)) - fanout.ref('leaf', offset=(60, 0), annotations={'19': ['fanout-sref']}) - fanout.ref('child', offset=(70, 0), repetition=Grid(a_vector=(4, 0), a_count=2, b_vector=(0, 5), b_count=2), - annotations={'20': ['fanout-aref']}) - lib['fanout'] = fanout - - top = Pattern() - top.ref('child', offset=(500, 600), annotations={'15': ['top-child-ref']}) - top.ref('sibling', offset=(-100, 50), rotation=numpy.pi, annotations={'16': ['top-sibling-ref']}) - top.ref('fanout', offset=(250, -75)) - top.label((13, 0), string='TOP', offset=(0, 0), annotations={'17': ['top-label']}) - lib['top'] = top - - return lib - - -def _write_invalid_path_type_fixture(path: Path) -> None: - with path.open('wb') as stream: - header = klamath.library.FileHeader( - name=b'test', - user_units_per_db_unit=1.0, - meters_per_db_unit=1e-9, - ) - header.write(stream) - elem = klamath.elements.Path( - layer=(1, 0), - path_type=3, - width=10, - extension=(0, 0), - xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32), - properties={}, - ) - klamath.library.write_struct(stream, name=b'top', elements=[elem]) - klamath.records.ENDLIB.write(stream, None) - - -def test_gdsii_arrow_matches_gdsii_readfile(tmp_path: Path) -> None: - lib = _make_arrow_test_library() - gds_file = tmp_path / 'arrow_roundtrip.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - canonical_lib, canonical_info = gdsii.readfile(gds_file) - arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file) - - assert canonical_info == arrow_info - assert _library_summary(canonical_lib) == _library_summary(arrow_lib) - - -def test_gdsii_arrow_matches_gdsii_readfile_for_gzipped_file(tmp_path: Path) -> None: - lib = _make_arrow_test_library() - gds_file = tmp_path / 'arrow_roundtrip.gds.gz' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - canonical_lib, canonical_info = gdsii.readfile(gds_file) - arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file) - - assert canonical_info == arrow_info - assert _library_summary(canonical_lib) == _library_summary(arrow_lib) - - -def test_gdsii_arrow_readfile_arrow_returns_native_payload(tmp_path: Path) -> None: - gds_file = tmp_path / 'many_cells_native.gds' - manifest = write_fixture(gds_file, preset='many_cells', scale=0.001) - - libarr, info = gdsii_arrow.readfile_arrow(gds_file) - - assert info['name'] == manifest.library_name - assert libarr['lib_name'].as_py() == manifest.library_name - assert len(libarr['cells']) == manifest.cells - assert 0 < len(libarr['layers']) <= manifest.layers - - -def test_gdsii_arrow_readfile_arrow_reads_gzipped_file(tmp_path: Path) -> None: - lib = _make_arrow_test_library() - gds_file = tmp_path / 'native_payload.gds.gz' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - libarr, info = gdsii_arrow.readfile_arrow(gds_file) - - assert info['name'] == 'masque-klamath' - assert libarr['lib_name'].as_py() == 'masque-klamath' - assert len(libarr['cells']) == len(lib) - assert len(libarr['layers']) > 0 - - -def test_gdsii_arrow_removed_raw_mode_arg(tmp_path: Path) -> None: - lib = _make_arrow_test_library() - gds_file = tmp_path / 'removed_raw_mode.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - libarr, _ = gdsii_arrow.readfile_arrow(gds_file) - - with pytest.raises(TypeError): - gdsii_arrow.readfile(gds_file, raw_mode=False) - - with pytest.raises(TypeError): - gdsii_arrow.read_arrow(libarr, raw_mode=False) - - -def test_gdsii_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None: - gds_file = tmp_path / 'invalid.gds' - gds_file.write_bytes(b'not-a-gds') - - script = textwrap.dedent(f""" - from masque.file.gdsii import arrow as gdsii_arrow - try: - gdsii_arrow.readfile({str(gds_file)!r}) - except Exception as exc: - print(type(exc).__module__) - print(type(exc).__qualname__) - print(exc) - else: - raise SystemExit('expected gdsii_arrow.readfile() to fail') - """) - result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False) - - assert result.returncode == 0, result.stderr - assert 'klamath.basic' in result.stdout - assert 'KlamathError' in result.stdout - - -def test_gdsii_arrow_reads_small_perf_fixture(tmp_path: Path) -> None: - gds_file = tmp_path / 'many_cells_smoke.gds' - manifest = write_fixture(gds_file, preset='many_cells', scale=0.001) - - lib, info = gdsii_arrow.readfile(gds_file) - - assert info['name'] == manifest.library_name - assert len(lib) == manifest.cells - assert 'TOP' in lib - assert sum(len(refs) for refs in lib['TOP'].refs.values()) > 0 - - -def test_gdsii_arrow_degenerate_aref_decodes_as_single_transform(tmp_path: Path) -> None: - lib = Library() - leaf = Pattern() - leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) - lib['leaf'] = leaf - - top = Pattern() - top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1)) - lib['top'] = top - - gds_file = tmp_path / 'degenerate_aref.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - canonical_lib, _ = gdsii.readfile(gds_file) - arrow_lib, _ = gdsii_arrow.readfile(gds_file) - assert _library_summary(arrow_lib) == _library_summary(canonical_lib) - - decoded_ref = arrow_lib['top'].refs['leaf'][0] - assert decoded_ref.repetition is None - - -def test_gdsii_arrow_plain_srefs_decode_without_arbitrary(tmp_path: Path) -> None: - lib = _make_arrow_test_library() - gds_file = tmp_path / 'plain_srefs.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - arrow_lib, _ = gdsii_arrow.readfile(gds_file) - fanout = arrow_lib['fanout'] - - plain_leaf_refs = [ - ref - for ref in fanout.refs['leaf'] - if ref.annotations is None and ref.repetition is None - ] - assert len(plain_leaf_refs) == 2 - assert all(type(ref.repetition) is not Grid for ref in plain_leaf_refs) - - -def test_gdsii_arrow_degenerate_aref_schema_normalizes_to_sref(tmp_path: Path) -> None: - lib = Library() - leaf = Pattern() - leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) - lib['leaf'] = leaf - - top = Pattern() - top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1)) - lib['top'] = top - - gds_file = tmp_path / 'degenerate_aref_schema.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - libarr = gdsii_arrow._read_to_arrow(gds_file)[0] - cells = libarr['cells'].values - cell_ids = cells.field('id').to_numpy() - cell_names = libarr['cell_names'].as_py() - top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top') - - srefs = cells.field('srefs')[top_index].as_py() - arefs = cells.field('arefs')[top_index].as_py() - - assert len(srefs) == 1 - assert len(arefs) == 0 - assert cell_names[srefs[0]['target']] == 'leaf' - - -def test_gdsii_arrow_boundary_batch_schema(tmp_path: Path) -> None: - lib = _make_arrow_test_library() - gds_file = tmp_path / 'arrow_batches.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - libarr = gdsii_arrow._read_to_arrow(gds_file)[0] - cells = libarr['cells'].values - cell_ids = cells.field('id').to_numpy() - cell_names = libarr['cell_names'].as_py() - layer_table = [ - ((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF) - for layer in libarr['layers'].values.to_numpy() - ] - - leaf_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'leaf') - - rect_batches = cells.field('rect_batches')[leaf_index].as_py() - boundary_batches = cells.field('boundary_batches')[leaf_index].as_py() - boundary_props = cells.field('boundary_props')[leaf_index].as_py() - - assert len(rect_batches) == 2 - assert len(boundary_batches) == 0 - assert len(boundary_props) == 2 - - rects_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in rect_batches} - assert rects_by_layer[(1, 0)]['rects'] == [20, 0, 30, 10, 80, 0, 90, 10] - assert rects_by_layer[(2, 0)]['rects'] == [40, 0, 50, 10] - - props_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in boundary_props} - assert sorted(props_by_layer) == [(1, 0), (2, 0)] - assert props_by_layer[(1, 0)]['properties'][0]['value'] == 'leaf-poly' - assert props_by_layer[(2, 0)]['properties'][0]['value'] == 'leaf-poly-2' - - -def test_gdsii_arrow_rect_batch_schema_for_mixed_layer(tmp_path: Path) -> None: - lib = Library() - top = Pattern() - top.shapes[(1, 0)].append(RectCollection(rects=[[0, 0, 10, 10], [20, 0, 30, 10], [40, 0, 50, 10], [60, 0, 70, 10]])) - top.polygon((1, 0), vertices=[[80, 0], [85, 10], [90, 0]]) - top.polygon((1, 0), vertices=[[100, 0], [105, 10], [110, 0]]) - lib['top'] = top - - gds_file = tmp_path / 'arrow_rect_batches.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - libarr = gdsii_arrow._read_to_arrow(gds_file)[0] - cells = libarr['cells'].values - cell_ids = cells.field('id').to_numpy() - cell_names = libarr['cell_names'].as_py() - layer_table = [ - ((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF) - for layer in libarr['layers'].values.to_numpy() - ] - top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top') - - rect_batches = cells.field('rect_batches')[top_index].as_py() - boundary_batches = cells.field('boundary_batches')[top_index].as_py() - - assert len(rect_batches) == 1 - assert tuple(layer_table[rect_batches[0]['layer']]) == (1, 0) - assert rect_batches[0]['rects'] == [ - 0, 0, 10, 10, - 20, 0, 30, 10, - 40, 0, 50, 10, - 60, 0, 70, 10, - ] - - assert len(boundary_batches) == 1 - assert tuple(layer_table[boundary_batches[0]['layer']]) == (1, 0) - assert boundary_batches[0]['vertex_offsets'] == [0, 3] - - -def test_gdsii_arrow_ref_schema(tmp_path: Path) -> None: - lib = _make_arrow_test_library() - gds_file = tmp_path / 'arrow_ref_batches.gds' - gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) - - libarr = gdsii_arrow._read_to_arrow(gds_file)[0] - cells = libarr['cells'].values - cell_ids = cells.field('id').to_numpy() - cell_names = libarr['cell_names'].as_py() - - fanout_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'fanout') - - srefs = cells.field('srefs')[fanout_index].as_py() - arefs = cells.field('arefs')[fanout_index].as_py() - sref_props = cells.field('sref_props')[fanout_index].as_py() - aref_props = cells.field('aref_props')[fanout_index].as_py() - - sref_target_ids = [entry['target'] for entry in srefs] - sref_targets = [cell_names[target] for target in sref_target_ids] - assert sorted(sref_targets) == ['child', 'leaf', 'leaf'] - assert sref_target_ids == sorted(sref_target_ids) - sref_by_target = {} - for entry in srefs: - sref_by_target.setdefault(cell_names[entry['target']], []).append(entry) - assert [entry['invert_y'] for entry in sref_by_target['child']] == [True] - assert [entry['scale'] for entry in sref_by_target['child']] == pytest.approx([1.1]) - assert len(sref_by_target['leaf']) == 2 - - aref_target_ids = [entry['target'] for entry in arefs] - aref_targets = [cell_names[target] for target in aref_target_ids] - assert sorted(aref_targets) == ['child', 'leaf', 'leaf'] - assert aref_target_ids == sorted(aref_target_ids) - aref_by_target = {} - for entry in arefs: - aref_by_target.setdefault(cell_names[entry['target']], []).append(entry) - assert [entry['invert_y'] for entry in aref_by_target['child']] == [True] - assert [entry['scale'] for entry in aref_by_target['child']] == pytest.approx([1.2]) - assert len(aref_by_target['leaf']) == 2 - - assert len(sref_props) == 1 - assert cell_names[sref_props[0]['target']] == 'leaf' - assert sref_props[0]['properties'][0]['value'] == 'fanout-sref' - - assert len(aref_props) == 1 - assert cell_names[aref_props[0]['target']] == 'child' - assert aref_props[0]['properties'][0]['value'] == 'fanout-aref' - - -def test_gdsii_arrow_invalid_path_type_matches_gdsii(tmp_path: Path) -> None: - gds_file = tmp_path / 'invalid_path_type.gds' - _write_invalid_path_type_fixture(gds_file) - - with pytest.raises(PatternError, match='Unrecognized path type: 3'): - gdsii.readfile(gds_file) - - with pytest.raises(PatternError, match='Unrecognized path type: 3'): - gdsii_arrow.readfile(gds_file) - - -def test_raw_ref_grid_label_constructors_match_public() -> None: - raw_grid = Grid._from_raw( - a_vector=numpy.array([20, 0]), - a_count=3, - b_vector=numpy.array([0, 30]), - b_count=2, - ) - public_grid = Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2) - assert raw_grid == public_grid - - raw_poly = Polygon._from_raw( - vertices=numpy.array([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]), - annotations={'1': ['poly']}, - ) - public_poly = Polygon( - vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], - annotations={'1': ['poly']}, - ) - assert raw_poly == public_poly - - raw_poly_collection = PolyCollection._from_raw( - vertex_lists=numpy.array([ - [0.0, 0.0], [2.0, 0.0], [2.0, 2.0], - [10.0, 10.0], [12.0, 10.0], [12.0, 12.0], - ]), - vertex_offsets=numpy.array([0, 3], dtype=numpy.uint32), - annotations={'2': ['pc']}, - ) - public_poly_collection = PolyCollection( - vertex_lists=[[0, 0], [2, 0], [2, 2], [10, 10], [12, 10], [12, 12]], - vertex_offsets=[0, 3], - annotations={'2': ['pc']}, - ) - assert raw_poly_collection == public_poly_collection - assert [tuple(s.indices(len(raw_poly_collection.vertex_lists))) for s in raw_poly_collection.vertex_slices] == [(0, 3, 1), (3, 6, 1)] - - raw_rect_collection = RectCollection._from_raw( - rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]), - annotations={'3': ['rects']}, - ) - public_rect_collection = RectCollection( - rects=[[0, 0, 5, 5], [10, 10, 12, 12]], - annotations={'3': ['rects']}, - ) - assert raw_rect_collection == public_rect_collection - - raw_ref_empty = Ref._from_raw( - offset=numpy.array([100, 200]), - rotation=numpy.pi / 2, - mirrored=False, - scale=1.0, - repetition=None, - annotations=None, - ) - public_ref_empty = Ref( - offset=(100, 200), - rotation=numpy.pi / 2, - mirrored=False, - scale=1.0, - repetition=None, - annotations=None, - ) - assert raw_ref_empty.annotations is None - assert raw_ref_empty == public_ref_empty - - raw_ref = Ref._from_raw( - offset=numpy.array([100, 200]), - rotation=numpy.pi / 2, - mirrored=True, - scale=1.25, - repetition=raw_grid, - annotations={'12': ['child-ref']}, - ) - public_ref = Ref( - offset=(100, 200), - rotation=numpy.pi / 2, - mirrored=True, - scale=1.25, - repetition=public_grid, - annotations={'12': ['child-ref']}, - ) - assert raw_ref == public_ref - assert numpy.array_equal(raw_ref.as_transforms(), public_ref.as_transforms()) - - raw_label_empty = Label._from_raw( - 'LEAF', - offset=numpy.array([3, 4]), - annotations=None, - ) - public_label_empty = Label( - 'LEAF', - offset=(3, 4), - annotations=None, - ) - assert raw_label_empty.annotations is None - assert raw_label_empty == public_label_empty - - raw_label = Label._from_raw( - 'LEAF', - offset=numpy.array([3, 4]), - annotations={'10': ['leaf-label']}, - ) - public_label = Label( - 'LEAF', - offset=(3, 4), - annotations={'10': ['leaf-label']}, - ) - assert raw_label == public_label - assert numpy.array_equal(raw_label.get_bounds_single(), public_label.get_bounds_single()) diff --git a/masque/test/test_gdsii_lazy.py b/masque/test/test_gdsii_lazy.py deleted file mode 100644 index 610d743..0000000 --- a/masque/test/test_gdsii_lazy.py +++ /dev/null @@ -1,580 +0,0 @@ -from pathlib import Path -import gzip -import io - -import numpy -import pytest -from numpy.testing import assert_allclose - -from ..file import gdsii -from ..file.gdsii import lazy as gdsii_lazy -from ..file.gdsii import writer as gdsii_writer -from ..file.utils import preflight_source_aware -from ..error import LibraryError -from ..pattern import Pattern -from ..ports import Port -from ..library import ( - IBorrowing, IMaterializable, LayerMappedView, LazyLibrary, Library, LibraryView, - OverlayLibrary, PortLoadView, - ) - - -def _make_lazy_port_library() -> Library: - lib = Library() - - leaf = Pattern() - leaf.label(layer=(10, 0), string='A:type1 0', offset=(5, 0)) - lib['leaf'] = leaf - - child = Pattern() - child.ref('leaf', offset=(10, 20), rotation=numpy.pi / 2) - lib['child'] = child - - top = Pattern() - top.ref('child', offset=(100, 200)) - lib['top'] = top - - return lib - - -def test_gdsii_write_requires_units_without_gds_metadata() -> None: - lib = Library({'top': Pattern()}) - lib.library_info = None # type: ignore[attr-defined] - - with pytest.raises(LibraryError, match='meters_per_unit is required'): - gdsii.write(lib, io.BytesIO()) - - -def test_gdsii_write_plain_library_defaults() -> None: - lib = _make_lazy_port_library() - stream = io.BytesIO() - gdsii.write(lib, stream, 1e-9) - stream.seek(0) - _roundtrip, info = gdsii.read(stream) - assert info == { - 'name': 'masque-klamath', - 'meters_per_unit': 1e-9, - 'logical_units_per_unit': 1, - } - - -def test_gdsii_lazy_write_materializes_transiently() -> None: - lib = LazyLibrary() - lib['top'] = Pattern() - - stream = io.BytesIO() - gdsii.write( - lib, - stream, - meters_per_unit=1e-9, - logical_units_per_unit=1, - library_name='transient', - ) - - assert not lib.cache - stream.seek(0) - roundtrip, info = gdsii.read(stream) - assert set(roundtrip) == {'top'} - assert info['name'] == 'transient' - - -def test_gdsii_raw_copy_provenance_cycle_falls_back() -> None: - class SelfBorrowingView(LibraryView, IBorrowing): - def borrowed_sources(self) -> tuple['SelfBorrowingView', ...]: - return (self,) - - def source_cell(self, name: str) -> tuple['SelfBorrowingView', str] | None: - return self, name - - view = SelfBorrowingView({'top': Pattern()}) - - assert gdsii_writer._resolve_raw_struct(view, 'top') is None - - -def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_source.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-lazy') - - lib, info = gdsii_lazy.readfile(gds_file) - - assert info['name'] == 'classic-lazy' - assert lib.source_order() == ('leaf', 'child', 'top') - assert lib.child_graph(dangling='ignore') == { - 'leaf': set(), - 'child': {'leaf'}, - 'top': {'child'}, - } - assert lib.parent_graph() == { - 'leaf': {'child'}, - 'child': {'top'}, - 'top': set(), - } - assert lib.tops() == ['top'] - global_refs = lib.find_refs_global('leaf') - assert_allclose(global_refs[('top', 'child', 'leaf')], [[110, 220, numpy.pi / 2, 0, 1]]) - assert not lib._cache - - with pytest.raises(ValueError, match='dangling-reference mode'): - lib.child_graph(dangling='typo') - with pytest.raises(ValueError, match='dangling-reference mode'): - lib.find_refs_local('leaf', dangling='typo') - - child = lib['child'] - assert list(child.refs.keys()) == ['leaf'] - assert set(lib._cache) == {'child'} - - -def test_gdsii_lazy_graph_hooks_observe_cached_edits(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_cached_graph.gds' - gdsii.writefile(_make_lazy_port_library(), gds_file, meters_per_unit=1e-9) - - lib, _ = gdsii_lazy.readfile(gds_file) - del lib['child'].refs['leaf'] - - assert lib.child_graph(dangling='ignore')['child'] == set() - assert lib.find_refs_local('leaf') == {} - - -def test_gdsii_lazy_subtree_stays_borrowed_and_preserves_write_metadata(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_subtree_source.gds' - src = _make_lazy_port_library() - src['unused'] = Pattern() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-subtree') - - raw, _ = gdsii_lazy.readfile(gds_file) - subtree = raw.subtree('top') - - assert isinstance(raw, IMaterializable) - assert not isinstance(raw, IBorrowing) - assert isinstance(subtree, IMaterializable) - assert isinstance(subtree, IBorrowing) - assert subtree.source_order() == ('leaf', 'child', 'top') - assert not hasattr(subtree, 'library_info') - assert not raw._cache - - out_file = tmp_path / 'lazy_subtree_out.gds' - gdsii.writefile(subtree, out_file) - assert not raw._cache - - roundtrip, info = gdsii.readfile(out_file) - assert info['name'] == 'classic-subtree' - assert set(roundtrip) == {'leaf', 'child', 'top'} - - -def test_gdsii_lazy_borrowed_sources_require_matching_units(tmp_path: Path) -> None: - gds_a = tmp_path / 'units_a.gds' - gds_b = tmp_path / 'units_b.gds' - source = Library({'top': Pattern()}) - gdsii.writefile(source, gds_a, meters_per_unit=1e-9, library_name='units-a') - gdsii.writefile(source, gds_b, meters_per_unit=2e-9, library_name='units-b') - - lazy_a, _ = gdsii_lazy.readfile(gds_a) - lazy_b, _ = gdsii_lazy.readfile(gds_b) - overlay = OverlayLibrary() - overlay.add_source(lazy_a) - overlay.add_source(lazy_b, rename_theirs=lambda lib, name: lib.get_name(name)) - - with pytest.raises(LibraryError, match='identical units'): - gdsii.write(overlay, io.BytesIO()) - - -def test_gdsii_lazy_overlay_subtree_preserves_source_laziness(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_overlay_subtree_source.gds' - src = _make_lazy_port_library() - src['unused'] = Pattern() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-subtree') - - raw, _ = gdsii_lazy.readfile(gds_file) - overlay = OverlayLibrary() - overlay.add_source(raw) - subtree = overlay.subtree('top') - - assert isinstance(subtree, OverlayLibrary) - assert subtree.borrowed_sources() == (raw,) - - out_file = tmp_path / 'lazy_overlay_subtree_out.gds' - gdsii.writefile(subtree, out_file) - - assert not raw._cache - roundtrip, info = gdsii.readfile(out_file) - assert info['name'] == 'overlay-subtree' - assert set(roundtrip) == {'leaf', 'child', 'top'} - - -def test_gdsii_lazy_port_load_view_keeps_raw_source_unmodified(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_ports.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-ports') - - raw, _ = gdsii_lazy.readfile(gds_file) - processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2) - assert not hasattr(processed, 'library_info') - - top = processed['top'] - assert set(top.ports) == {'A'} - assert_allclose(top.ports['A'].offset, [110, 225], atol=1e-10) - assert not raw._cache - - raw_top = raw['top'] - assert not raw_top.ports - - -def test_gdsii_lazy_port_load_view_detaches_previously_cached_source(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_cached_ports.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-cached-ports') - - raw, _ = gdsii_lazy.readfile(gds_file) - raw_top = raw['top'] - processed = PortLoadView(raw, ports={ - 'top': { - 'P': Port((1, 2), rotation=0, ptype='wire'), - }, - }) - - processed_top = processed['top'] - - assert processed_top is not raw_top - assert not raw_top.ports - assert set(processed_top.ports) == {'P'} - assert raw['top'] is raw_top - assert not hasattr(processed, 'close') - - -def test_gdsii_lazy_port_overrides_without_data_stay_lazy(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_port_overrides.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overrides') - - raw, _ = gdsii_lazy.readfile(gds_file) - processed = PortLoadView(raw, ports={ - 'top': { - 'P': Port((1, 2), rotation=0, ptype='wire'), - }, - }) - - top = processed['top'] - assert set(top.ports) == {'P'} - assert_allclose(top.ports['P'].offset, [1, 2], atol=1e-10) - assert top.ports['P'].rotation == 0 - assert top.ports['P'].ptype == 'wire' - assert not raw._cache - - raw_top = raw['top'] - assert not raw_top.ports - - -def test_gdsii_lazy_port_overrides_apply_after_extraction(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_ports_override_extracted.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-override-extracted') - - raw, _ = gdsii_lazy.readfile(gds_file) - processed = PortLoadView( - raw, - layers=[(10, 0)], - max_depth=2, - ports={ - 'top': { - 'A': Port((1, 2), rotation=numpy.pi, ptype='manual'), - 'B': Port((3, 4), rotation=None, ptype=None), - }, - }, - ) - - top = processed['top'] - assert set(top.ports) == {'A', 'B'} - assert_allclose(top.ports['A'].offset, [1, 2], atol=1e-10) - assert top.ports['A'].rotation == numpy.pi - assert top.ports['A'].ptype == 'manual' - assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10) - assert top.ports['B'].rotation is None - assert top.ports['B'].ptype is None - assert not raw._cache - - -def test_gdsii_lazy_port_overrides_replace_extracted_ports(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_ports_replace.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-replace-ports') - - raw, _ = gdsii_lazy.readfile(gds_file) - processed = PortLoadView( - raw, - layers=[(10, 0)], - max_depth=2, - ports={ - 'top': { - 'B': Port((3, 4), rotation=None, ptype=None), - }, - }, - replace=True, - ) - - top = processed['top'] - assert set(top.ports) == {'B'} - assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10) - assert not raw._cache - - -def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_overlay.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay') - - raw, _ = gdsii_lazy.readfile(gds_file) - processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2) - - overlay = OverlayLibrary() - overlay.add_source(processed) - - assert not raw._cache - assert not processed._cache - - abstract = overlay.abstract('top') - assert set(abstract.ports) == {'A'} - - -def test_gdsii_lazy_overlay_add_source_sees_port_overrides(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_overlay_override.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay-override') - - raw, _ = gdsii_lazy.readfile(gds_file) - processed = PortLoadView(raw, ports={ - 'top': { - 'P': Port((1, 2), rotation=0, ptype='wire'), - }, - }) - - overlay = OverlayLibrary() - overlay.add_source(processed) - - assert not raw._cache - assert not processed._cache - - abstract = overlay.abstract('top') - assert set(abstract.ports) == {'P'} - assert_allclose(abstract.ports['P'].offset, [1, 2], atol=1e-10) - - -def test_gdsii_lazy_overlay_add_source_can_rename_every_source_cell() -> None: - src = _make_lazy_port_library() - overlay = OverlayLibrary() - - rename_map = overlay.add_source( - src, - rename_theirs=lambda _lib, name: f'mapped_{name}', - rename_when='always', - ) - - assert rename_map == { - 'leaf': 'mapped_leaf', - 'child': 'mapped_child', - 'top': 'mapped_top', - } - assert tuple(overlay.keys()) == ('mapped_leaf', 'mapped_child', 'mapped_top') - assert 'mapped_leaf' in overlay['mapped_child'].refs - - -def test_gdsii_lazy_overlay_add_source_rename_when_validation() -> None: - src = _make_lazy_port_library() - - with pytest.raises(TypeError, match='rename_theirs'): - OverlayLibrary().add_source(src, rename_theirs=None, rename_when='always') - - with pytest.raises(ValueError, match='rename mode'): - OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type] - - -def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_roundtrip.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-roundtrip') - - raw, _ = gdsii_lazy.readfile(gds_file) - processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2) - - out_file = tmp_path / 'lazy_roundtrip_out.gds' - gdsii.writefile(processed, out_file) - - assert out_file.read_bytes() == gds_file.read_bytes() - - -def test_gdsii_lazy_source_aware_write_copies_untouched_structures( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - gds_file = tmp_path / 'classic_copy_source.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-copy') - - raw, _ = gdsii_lazy.readfile(gds_file) - ports = PortLoadView(raw, layers=[(10, 0)], max_depth=2) - mapped = LayerMappedView(ports, lambda layer: (20, 0) if layer == (10, 0) else layer, copy_through=True) - prepared = preflight_source_aware(mapped) - copied: list[str] = [] - raw_reader = raw.raw_struct_bytes - - def record_raw_read(name: str) -> bytes: - copied.append(name) - return raw_reader(name) - - def fail_materialize(_name: str, *, persist: bool = True) -> Pattern: # noqa: ARG001 - raise AssertionError('untouched cells must not be materialized') - - monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read) - monkeypatch.setattr(raw, 'materialize', fail_materialize) - out_file = tmp_path / 'classic_copy_out.gds' - gdsii.writefile(prepared, out_file) - - assert copied == ['leaf', 'child', 'top'] - assert not raw._cache - assert not ports._cache - assert not mapped._cache - assert out_file.read_bytes() == gds_file.read_bytes() - - -@pytest.mark.parametrize('use_mmap', [False, True]) -def test_gdsii_lazy_raw_copy_stream_backends(tmp_path: Path, use_mmap: bool) -> None: - gds_file = tmp_path / 'classic_stream_source.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-stream') - - raw, _ = gdsii_lazy.readfile(gds_file, use_mmap=use_mmap) - out_file = tmp_path / f'classic_stream_{use_mmap}.gds' - gdsii.writefile(raw, out_file) - - assert not raw._cache - assert out_file.read_bytes() == gds_file.read_bytes() - - -def test_gdsii_lazy_raw_copy_gzipped_source(tmp_path: Path) -> None: - gds_file = tmp_path / 'classic_gzip_source.gds' - gz_file = tmp_path / 'classic_gzip_source.gds.gz' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-gzip') - with gzip.open(gz_file, 'wb') as stream: - stream.write(gds_file.read_bytes()) - - raw, _ = gdsii_lazy.readfile(gz_file, use_mmap=False) - for name in raw.source_order(): - raw.raw_struct_bytes(name) - assert raw._source.stream.tell() == raw._cells[name].struct_end - out_file = tmp_path / 'classic_gzip_out.gds' - gdsii.writefile(raw, out_file) - - assert not raw._cache - assert out_file.read_bytes() == gds_file.read_bytes() - - -def test_gdsii_lazy_cached_source_cell_disables_only_its_raw_copy( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - gds_file = tmp_path / 'classic_cached_source.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-cached') - - raw, _ = gdsii_lazy.readfile(gds_file) - assert raw.can_copy_raw_struct('leaf') - raw['leaf'].label((30, 0), string='cached', offset=(1, 2)) - assert not raw.can_copy_raw_struct('leaf') - copied: list[str] = [] - raw_reader = raw.raw_struct_bytes - - def record_raw_read(name: str) -> bytes: - copied.append(name) - return raw_reader(name) - - monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read) - out_file = tmp_path / 'classic_cached_out.gds' - gdsii.writefile(raw, out_file) - - assert copied == ['child', 'top'] - roundtrip, _ = gdsii.readfile(out_file) - assert set(roundtrip['leaf'].labels) == {(10, 0), (30, 0)} - - -def test_gdsii_lazy_detached_processing_copies_only_cached_patterns( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - gds_file = tmp_path / 'classic_detached_source.gds' - gdsii.writefile(_make_lazy_port_library(), gds_file, meters_per_unit=1e-9) - raw, _ = gdsii_lazy.readfile(gds_file) - mapped = LayerMappedView(raw, lambda layer: layer) - - original_deepcopy = Pattern.deepcopy - copied: list[Pattern] = [] - - def count_deepcopy(pattern: Pattern) -> Pattern: - copied.append(pattern) - return original_deepcopy(pattern) - - monkeypatch.setattr(Pattern, 'deepcopy', count_deepcopy) - fresh = mapped.materialize_detached('top') - assert not copied - assert not raw._cache - assert not mapped._cache - - cached = raw['top'] - copied.clear() - detached = mapped.materialize_detached('top') - assert copied == [cached] - assert detached is not cached - assert detached is not fresh - - -def test_gdsii_lazy_materialized_cell_disables_only_its_raw_copy( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - gds_file = tmp_path / 'classic_partial_copy_source.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-partial-copy') - - raw, _ = gdsii_lazy.readfile(gds_file) - mapped = LayerMappedView(raw, lambda layer: (20, 0) if layer == (10, 0) else layer, copy_through=True) - assert set(mapped['leaf'].labels) == {(20, 0)} - prepared = preflight_source_aware(mapped) - copied: list[str] = [] - raw_reader = raw.raw_struct_bytes - - def record_raw_read(name: str) -> bytes: - copied.append(name) - return raw_reader(name) - - monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read) - out_file = tmp_path / 'classic_partial_copy_out.gds' - gdsii.writefile(prepared, out_file) - - assert copied == ['child', 'top'] - assert not raw._cache - roundtrip, _ = gdsii.readfile(out_file) - assert set(roundtrip['leaf'].labels) == {(20, 0)} - - -def test_gdsii_lazy_layer_mapped_write_materializes_without_source_cache(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_layer_source.gds' - src = _make_lazy_port_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-layers') - - raw, _ = gdsii_lazy.readfile(gds_file) - mapped = LayerMappedView(raw, lambda layer: (20, 0) if layer == (10, 0) else layer) - out_file = tmp_path / 'lazy_layer_mapped.gds' - gdsii.writefile(mapped, out_file) - - assert not raw._cache - roundtrip, info = gdsii.readfile(out_file) - assert info['name'] == 'classic-layers' - assert set(roundtrip['leaf'].labels) == {(20, 0)} - assert (10, 0) not in roundtrip['leaf'].labels - - -def test_gdsii_removed_closure_based_lazy_loader() -> None: - assert not hasattr(gdsii, 'load_library') - assert not hasattr(gdsii, 'load_libraryfile') - assert not hasattr(gdsii_lazy, 'write') - assert not hasattr(gdsii_lazy, 'writefile') diff --git a/masque/test/test_gdsii_lazy_arrow.py b/masque/test/test_gdsii_lazy_arrow.py deleted file mode 100644 index 927f35b..0000000 --- a/masque/test/test_gdsii_lazy_arrow.py +++ /dev/null @@ -1,601 +0,0 @@ -from pathlib import Path -import subprocess -import sys -import textwrap - -import klamath -import numpy -import pytest - -pytest.importorskip('pyarrow') - -from .. import PatternError, LibraryError -from ..library import IBorrowing, IMaterializable, LayerMappedView, Library, OverlayLibrary, PortLoadView -from ..pattern import Pattern -from ..repetition import Grid -from ..file import gdsii -from ..file.utils import preflight_source_aware -from ..file.gdsii import lazy_arrow as gdsii_lazy_arrow -from ..file.gdsii import arrow as gdsii_arrow -from ..file.gdsii import writer as gdsii_writer -from tools.generate_gds_perf import write_fixture - - -if not gdsii_arrow.is_available(): - pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True) - - -@pytest.mark.parametrize('cached', [False, True]) -def test_arrow_detached_mutation_is_isolated(tmp_path: Path, cached: bool) -> None: - filename = tmp_path / 'detached.gds' - gdsii.writefile(_make_small_library(), filename, meters_per_unit=1e-9) - with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib: - if cached: - lib['leaf'] - detached = lib.materialize_detached('leaf') - detached.translate_elements((0.25, 0.5)) - numpy.testing.assert_allclose(detached.get_bounds(), [[0.25, 0.5], [10.25, 5.5]]) - numpy.testing.assert_allclose(lib.materialize_detached('leaf').get_bounds(), [[0, 0], [10, 5]]) - assert lib.can_copy_raw_struct('leaf') == (not cached) - top = lib.materialize_detached('top').flatten(lib) - assert top.get_bounds() is not None - - -def test_arrow_rect_hierarchy_requires_explicit_polygonization(tmp_path: Path) -> None: - original = _make_small_library() - original['mid'].refs['leaf'][0].rotation = numpy.pi / 4 - filename = tmp_path / 'rotated.gds' - gdsii.writefile(original, filename, meters_per_unit=1e-9) - with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib: - for action in ('bounds', 'flatten'): - top = lib.materialize_detached('top') - with pytest.raises(PatternError, match='Pattern.polygonize'): - top.get_bounds(lib) if action == 'bounds' else top.flatten(lib) - lib['leaf'].polygonize() - numpy.testing.assert_allclose(lib['top'].get_bounds(lib), original['top'].get_bounds(original)) - assert lib.materialize_detached('top').flatten(lib).get_bounds() is not None - - -def test_arrow_dangling_ref_queries_are_cache_independent(tmp_path: Path) -> None: - original = Library({'parent': Pattern()}) - original['parent'].ref('missing', offset=(3, 4), rotation=numpy.pi / 3, mirrored=True, scale=2) - original['parent'].ref('missing', offset=(10, 20), repetition=Grid(a_vector=(7, 0), a_count=3)) - filename = tmp_path / 'dangling.gds' - gdsii.writefile(original, filename, meters_per_unit=1e-9) - with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib: - expected = _local_refs_key(original.find_refs_local('missing', dangling='include')) - assert _local_refs_key(lib.find_refs_local('missing', dangling='include')) == expected - assert lib.can_copy_raw_struct('parent') - assert not lib.find_refs_local('missing', dangling='ignore') - with pytest.raises(LibraryError, match='missing'): - lib.find_refs_local('missing', dangling='error') - assert not lib.find_refs_local('unknown', dangling='include') - lib['parent'] - assert _local_refs_key(lib.find_refs_local('missing', dangling='include')) == expected - - -def test_gdsii_lazy_arrow_has_reader_only_surface() -> None: - assert not hasattr(gdsii_lazy_arrow, 'is_available') - assert not hasattr(gdsii_lazy_arrow, 'write') - assert not hasattr(gdsii_lazy_arrow, 'writefile') - - -def _make_small_library() -> Library: - lib = Library() - - leaf = Pattern() - leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 5], [0, 5]]) - lib['leaf'] = leaf - - mid = Pattern() - mid.ref('leaf', offset=(10, 20)) - mid.ref('leaf', offset=(40, 0), repetition=Grid(a_vector=(12, 0), a_count=2, b_vector=(0, 9), b_count=2)) - lib['mid'] = mid - - top = Pattern() - top.ref('mid', offset=(100, 200)) - lib['top'] = top - return lib - - -def _make_complex_ref_library() -> Library: - lib = Library() - - leaf = Pattern() - leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) - lib['leaf'] = leaf - - child = Pattern() - child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25) - lib['child'] = child - - sibling = Pattern() - sibling.ref( - 'leaf', - offset=(-50, 60), - repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2), - ) - lib['sibling'] = sibling - - fanout = Pattern() - fanout.ref('leaf', offset=(0, 0)) - fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1) - fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3)) - fanout.ref( - 'child', - offset=(40, 0), - mirrored=True, - rotation=numpy.pi / 4, - scale=1.2, - repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2), - ) - lib['fanout'] = fanout - - top = Pattern() - top.ref('child', offset=(500, 600)) - top.ref('sibling', offset=(-100, 50), rotation=numpy.pi) - top.ref('fanout', offset=(250, -75)) - lib['top'] = top - - return lib - - -def _write_invalid_path_type_fixture(path: Path) -> None: - with path.open('wb') as stream: - header = klamath.library.FileHeader( - name=b'test', - user_units_per_db_unit=1.0, - meters_per_db_unit=1e-9, - ) - header.write(stream) - elem = klamath.elements.Path( - layer=(1, 0), - path_type=3, - width=10, - extension=(0, 0), - xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32), - properties={}, - ) - klamath.library.write_struct(stream, name=b'top', elements=[elem]) - klamath.records.ENDLIB.write(stream, None) - - -def _transform_rows_key(values: numpy.ndarray) -> tuple[tuple[object, ...], ...]: - arr = numpy.asarray(values, dtype=float) - arr = numpy.atleast_2d(arr) - rows = [ - ( - round(float(row[0]), 8), - round(float(row[1]), 8), - round(float(row[2]), 8), - bool(int(round(float(row[3])))), - round(float(row[4]), 8), - ) - for row in arr - ] - return tuple(sorted(rows)) - - -def _local_refs_key(refs: dict[str, list[numpy.ndarray]]) -> dict[str, tuple[tuple[object, ...], ...]]: - return { - parent: _transform_rows_key(numpy.concatenate(transforms)) - for parent, transforms in refs.items() - } - - -def _global_refs_key(refs: dict[tuple[str, ...], numpy.ndarray]) -> dict[tuple[str, ...], tuple[tuple[object, ...], ...]]: - return { - path: _transform_rows_key(transforms) - for path, transforms in refs.items() - } - - -def test_gdsii_lazy_arrow_loads_perf_fixture(tmp_path: Path) -> None: - gds_file = tmp_path / 'many_cells_lazy.gds' - manifest = write_fixture(gds_file, preset='many_cells', scale=0.001) - - lib, info = gdsii_lazy_arrow.readfile(gds_file) - - assert info['name'] == manifest.library_name - assert len(lib) == manifest.cells - assert lib.top() == 'TOP' - assert 'TOP' in lib.child_graph(dangling='ignore') - - -def test_gdsii_lazy_arrow_local_and_global_refs(tmp_path: Path) -> None: - gds_file = tmp_path / 'refs.gds' - src = _make_small_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-refs') - - lib, _ = gdsii_lazy_arrow.readfile(gds_file) - - local = lib.find_refs_local('leaf') - assert set(local) == {'mid'} - assert sum(arr.shape[0] for arr in local['mid']) == 5 - - global_refs = lib.find_refs_global('leaf') - assert set(global_refs) == {('top', 'mid', 'leaf')} - assert global_refs[('top', 'mid', 'leaf')].shape[0] == 5 - - -def test_gdsii_lazy_arrow_graph_hooks_observe_cached_edits(tmp_path: Path) -> None: - gds_file = tmp_path / 'lazy_arrow_cached_graph.gds' - gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9) - - lib, _ = gdsii_lazy_arrow.readfile(gds_file) - del lib['mid'].refs['leaf'] - - assert lib.child_graph(dangling='ignore')['mid'] == set() - assert lib.find_refs_local('leaf') == {} - - -def test_gdsii_lazy_arrow_ref_queries_match_eager_reader(tmp_path: Path) -> None: - gds_file = tmp_path / 'complex_refs.gds' - src = _make_complex_ref_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-complex-refs') - - eager, _ = gdsii.readfile(gds_file) - lazy, _ = gdsii_lazy_arrow.readfile(gds_file) - - for name in ('leaf', 'child'): - assert _local_refs_key(lazy.find_refs_local(name)) == _local_refs_key(eager.find_refs_local(name)) - assert _global_refs_key(lazy.find_refs_global(name)) == _global_refs_key(eager.find_refs_global(name)) - - -def test_gdsii_lazy_arrow_detached_batch_preserves_native_batching( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - gds_file = tmp_path / 'lazy_arrow_detached_batch.gds' - gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9) - raw, _ = gdsii_lazy_arrow.readfile(gds_file) - mapped = LayerMappedView(raw, lambda layer: layer) - - original_read = gdsii_arrow._read_selected_cells_to_arrow - call_count = 0 - - def count_read(*args, **kwargs) -> object: - nonlocal call_count - call_count += 1 - return original_read(*args, **kwargs) - - monkeypatch.setattr(gdsii_arrow, '_read_selected_cells_to_arrow', count_read) - detached = mapped.materialize_many_detached(('leaf', 'mid', 'leaf')) - - assert tuple(detached) == ('leaf', 'mid') - assert call_count == 1 - assert not raw._cache - assert not mapped._cache - - -def test_gdsii_lazy_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None: - gds_file = tmp_path / 'invalid.gds' - gds_file.write_bytes(b'not-a-gds') - - script = textwrap.dedent(f""" - from masque.file.gdsii import lazy_arrow as gdsii_lazy_arrow - try: - gdsii_lazy_arrow.readfile({str(gds_file)!r}) - except Exception as exc: - print(type(exc).__module__) - print(type(exc).__qualname__) - print(exc) - else: - raise SystemExit('expected gdsii_lazy_arrow.readfile() to fail') - """) - result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False) - - assert result.returncode == 0, result.stderr - assert 'klamath.basic' in result.stdout - assert 'KlamathError' in result.stdout - - -def test_gdsii_lazy_arrow_invalid_path_type_raises_pattern_error(tmp_path: Path) -> None: - gds_file = tmp_path / 'invalid_path_type.gds' - _write_invalid_path_type_fixture(gds_file) - - lib, _ = gdsii_lazy_arrow.readfile(gds_file) - - with pytest.raises(PatternError, match='Unrecognized path type: 3'): - lib['top'] - - -def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - gds_file = tmp_path / 'copy_source.gds' - src = _make_small_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through') - - lib, info = gdsii_lazy_arrow.readfile(gds_file) - - def forbid_materialization(*_args, **_kwargs) -> None: - pytest.fail('Untouched GDS writes must not materialize Arrow coordinates') - - monkeypatch.setattr(gdsii_arrow, 'read_arrow', forbid_materialization) - out_file = tmp_path / 'copy_out.gds' - gdsii.writefile( - lib, - out_file, - meters_per_unit=info['meters_per_unit'], - logical_units_per_unit=info['logical_units_per_unit'], - library_name=info['name'], - ) - - assert out_file.read_bytes() == gds_file.read_bytes() - - -def test_gdsii_raw_copy_resolves_generic_borrowing_views(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - gds_file = tmp_path / 'provenance_source.gds' - gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9, library_name='provenance') - - raw, _ = gdsii_lazy_arrow.readfile(gds_file) - ports = PortLoadView(raw) - subtree = ports.subtree('top') - overlay = OverlayLibrary() - overlay.add_source(subtree) - - copied: list[str] = [] - raw_reader = raw.raw_struct_bytes - - def record_raw_read(name: str) -> bytes: - copied.append(name) - return raw_reader(name) - - monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read) - out_file = tmp_path / 'provenance_out.gds' - gdsii.writefile(overlay, out_file) - - assert copied == ['leaf', 'mid', 'top'] - assert out_file.read_bytes() == gds_file.read_bytes() - - renamed = OverlayLibrary() - renamed.add_source(raw) - renamed.rename('top', 'renamed_top') - assert gdsii_writer._resolve_raw_struct(renamed, 'renamed_top') is None - - remapped = OverlayLibrary() - remapped.add_source(raw) - remapped.rename('leaf', 'renamed_leaf', move_references=True) - assert gdsii_writer._resolve_raw_struct(remapped, 'mid') is None - - -def test_gdsii_layer_mapped_view_controls_raw_copy_through( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - gds_file = tmp_path / 'layer_mapped_source.gds' - gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9, library_name='layer-mapped') - - raw, _ = gdsii_lazy_arrow.readfile(gds_file) - copied: list[str] = [] - raw_reader = raw.raw_struct_bytes - - def record_raw_read(name: str) -> bytes: - copied.append(name) - return raw_reader(name) - - def map_layer(layer): # noqa: ANN001,ANN202 - return (20, 0) if layer == (1, 0) else layer - - monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read) - - mapped = LayerMappedView(raw, map_layer) - mapped_file = tmp_path / 'layer_mapped_all.gds' - gdsii.writefile(mapped, mapped_file) - - assert copied == [] - assert not raw._cache - roundtrip, info = gdsii.readfile(mapped_file) - assert info['name'] == 'layer-mapped' - assert set(roundtrip['leaf'].shapes) == {(20, 0)} - - passthrough = LayerMappedView(raw, map_layer, copy_through=True) - preflighted = preflight_source_aware(passthrough) - assert isinstance(preflighted, OverlayLibrary) - copied_file = tmp_path / 'layer_mapped_copied.gds' - gdsii.writefile(preflighted, copied_file) - - assert copied == ['leaf', 'mid', 'top'] - assert copied_file.read_bytes() == gds_file.read_bytes() - assert not raw._cache - - copied.clear() - assert set(passthrough['leaf'].shapes) == {(20, 0)} - preflighted = preflight_source_aware(passthrough) - materialized_file = tmp_path / 'layer_mapped_materialized.gds' - gdsii.writefile(preflighted, materialized_file) - - assert copied == ['mid', 'top'] - assert not raw._cache - roundtrip, _ = gdsii.readfile(materialized_file) - assert set(roundtrip['leaf'].shapes) == {(20, 0)} - - -def test_gdsii_lazy_arrow_processed_cell_edit_disables_raw_copy( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - gds_file = tmp_path / 'processed_edit_source.gds' - src = _make_small_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='processed-edit') - - raw, _ = gdsii_lazy_arrow.readfile(gds_file) - processed = PortLoadView(raw) - processed['top'].polygon((7, 0), vertices=[[0, 0], [4, 0], [0, 4]]) - - copied: list[str] = [] - raw_reader = raw.raw_struct_bytes - - def record_raw_read(name: str) -> bytes: - copied.append(name) - return raw_reader(name) - - monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read) - - out_file = tmp_path / 'processed_edit_out.gds' - gdsii.writefile(processed, out_file) - - assert 'top' not in copied - roundtrip, _ = gdsii.readfile(out_file) - assert len(roundtrip['top'].shapes[(7, 0)]) == 1 - - -def test_gdsii_lazy_arrow_subtree_preserves_raw_copy_and_ref_queries(tmp_path: Path) -> None: - gds_file = tmp_path / 'subtree_copy_source.gds' - src = _make_small_library() - src['unused'] = Pattern() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='subtree-copy') - - raw, _ = gdsii_lazy_arrow.readfile(gds_file) - subtree = raw.subtree('top') - - assert isinstance(raw, IMaterializable) - assert not isinstance(raw, IBorrowing) - assert isinstance(subtree, IMaterializable) - assert isinstance(subtree, IBorrowing) - assert subtree.source_order() == ('leaf', 'mid', 'top') - assert _global_refs_key(subtree.find_refs_global('leaf')) == _global_refs_key(raw.find_refs_global('leaf')) - assert not raw._cache - - out_file = tmp_path / 'subtree_copy_out.gds' - gdsii.writefile(subtree, out_file) - - assert not raw._cache - roundtrip, info = gdsii.readfile(out_file) - assert info['name'] == 'subtree-copy' - assert set(roundtrip) == {'leaf', 'mid', 'top'} - - -def test_gdsii_lazy_arrow_overlay_subtree_preserves_raw_copy(tmp_path: Path) -> None: - gds_file = tmp_path / 'overlay_subtree_source.gds' - src = _make_small_library() - src['unused'] = Pattern() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-subtree-copy') - - raw, _ = gdsii_lazy_arrow.readfile(gds_file) - overlay = OverlayLibrary() - overlay.add_source(raw) - subtree = overlay.subtree('top') - - assert isinstance(subtree, OverlayLibrary) - assert subtree.borrowed_sources() == (raw,) - assert not raw._cache - - out_file = tmp_path / 'overlay_subtree_out.gds' - gdsii.writefile(subtree, out_file) - - assert not raw._cache - roundtrip, info = gdsii.readfile(out_file) - assert info['name'] == 'overlay-subtree-copy' - assert set(roundtrip) == {'leaf', 'mid', 'top'} - - -def test_gdsii_lazy_arrow_gzipped_copy_through(tmp_path: Path) -> None: - gds_file = tmp_path / 'copy_source.gds.gz' - src = _make_small_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through-gz') - - lib, info = gdsii_lazy_arrow.readfile(gds_file) - out_file = tmp_path / 'copy_out.gds.gz' - gdsii.writefile( - lib, - out_file, - meters_per_unit=info['meters_per_unit'], - logical_units_per_unit=info['logical_units_per_unit'], - library_name=info['name'], - ) - - assert out_file.read_bytes() == gds_file.read_bytes() - - -def test_gdsii_lazy_overlay_merge_and_write(tmp_path: Path) -> None: - base_a = Library() - leaf_a = Pattern() - leaf_a.polygon((1, 0), vertices=[[0, 0], [8, 0], [8, 8], [0, 8]]) - base_a['leaf'] = leaf_a - top_a = Pattern() - top_a.ref('leaf', offset=(0, 0)) - base_a['top_a'] = top_a - - base_b = Library() - leaf_b = Pattern() - leaf_b.polygon((2, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) - base_b['leaf'] = leaf_b - top_b = Pattern() - top_b.ref('leaf', offset=(20, 30)) - base_b['top_b'] = top_b - - gds_a = tmp_path / 'a.gds' - gds_b = tmp_path / 'b.gds' - gdsii.writefile(base_a, gds_a, meters_per_unit=1e-9, library_name='overlay') - gdsii.writefile(base_b, gds_b, meters_per_unit=1e-9, library_name='overlay') - - lib_a, _ = gdsii_lazy_arrow.readfile(gds_a) - lib_b, _ = gdsii_lazy_arrow.readfile(gds_b) - - overlay = OverlayLibrary() - overlay.add_source(lib_a) - rename_map = overlay.add_source(lib_b, rename_theirs=lambda lib, name: lib.get_name(name)) - renamed_leaf = rename_map['leaf'] - - assert rename_map == {'leaf': renamed_leaf} - assert renamed_leaf != 'leaf' - assert len(lib_a._cache) == 0 - assert len(lib_b._cache) == 0 - - overlay.move_references('leaf', renamed_leaf) - - out_file = tmp_path / 'overlay_out.gds' - gdsii.writefile(overlay, out_file) - - roundtrip, _ = gdsii.readfile(out_file) - assert set(roundtrip.keys()) == {'leaf', renamed_leaf, 'top_a', 'top_b'} - assert 'top_b' in roundtrip - assert list(roundtrip['top_b'].refs.keys()) == [renamed_leaf] - - -def test_gdsii_writer_accepts_overlay_library(tmp_path: Path) -> None: - gds_file = tmp_path / 'overlay_source.gds' - src = _make_small_library() - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-src') - - lib, info = gdsii_lazy_arrow.readfile(gds_file) - - overlay = OverlayLibrary() - overlay.add_source(lib) - overlay.rename('leaf', 'leaf_copy', move_references=True) - - out_file = tmp_path / 'overlay_via_eager_writer.gds' - gdsii.writefile( - overlay, - out_file, - meters_per_unit=info['meters_per_unit'], - logical_units_per_unit=info['logical_units_per_unit'], - library_name=info['name'], - ) - - roundtrip, _ = gdsii.readfile(out_file) - assert set(roundtrip.keys()) == {'leaf_copy', 'mid', 'top'} - assert list(roundtrip['mid'].refs.keys()) == ['leaf_copy'] - - -def test_svg_writer_uses_detached_materialized_copy(tmp_path: Path) -> None: - pytest.importorskip('svgwrite') - from ..file import svg - from ..shapes import Path as MPath - - gds_file = tmp_path / 'svg_source.gds' - src = _make_small_library() - src['top'].path((3, 0), vertices=[[0, 0], [0, 20]], width=4) - gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='svg-src') - - lib, _ = gdsii_lazy_arrow.readfile(gds_file) - top_pat = lib['top'] - assert list(top_pat.refs.keys()) == ['mid'] - assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)]) - - svg_path = tmp_path / 'lazy.svg' - svg.writefile(lib, 'top', str(svg_path)) - - assert svg_path.exists() - assert list(top_pat.refs.keys()) == ['mid'] - assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)]) diff --git a/masque/test/test_gdsii_perf.py b/masque/test/test_gdsii_perf.py deleted file mode 100644 index fbad6fc..0000000 --- a/masque/test/test_gdsii_perf.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses import asdict -import json -from pathlib import Path - -from ..file import gdsii -from tools.generate_gds_perf import fixture_manifest, write_fixture - - -def test_gdsii_perf_fixture_smoke(tmp_path: Path) -> None: - output = tmp_path / 'many_cells.gds' - manifest = write_fixture(output, preset='many_cells', scale=0.002) - expected = fixture_manifest(output, preset='many_cells', scale=0.002) - - assert output.exists() - assert manifest == expected - - sidecar = json.loads(output.with_suffix('.gds.json').read_text()) - assert sidecar == asdict(manifest) - - read_lib, info = gdsii.readfile(output) - assert info['name'] == manifest.library_name - assert len(read_lib) == manifest.cells - assert 'TOP' in read_lib - assert len(read_lib['TOP'].refs) > 0 diff --git a/masque/test/test_label.py b/masque/test/test_label.py deleted file mode 100644 index f4f364b..0000000 --- a/masque/test/test_label.py +++ /dev/null @@ -1,54 +0,0 @@ -import copy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..label import Label -from ..repetition import Grid -from ..utils import annotations_eq - - -def test_label_init() -> None: - lbl = Label("test", offset=(10, 20)) - assert lbl.string == "test" - assert_equal(lbl.offset, [10, 20]) - - -def test_label_transform() -> None: - lbl = Label("test", offset=(10, 0)) - # Rotate 90 deg CCW around (0,0) - lbl.rotate_around((0, 0), pi / 2) - assert_allclose(lbl.offset, [0, 10], atol=1e-10) - - # Translate - lbl.translate((5, 5)) - assert_allclose(lbl.offset, [5, 15], atol=1e-10) - - -def test_label_repetition() -> None: - rep = Grid(a_vector=(10, 0), a_count=3) - lbl = Label("rep", offset=(0, 0), repetition=rep) - assert lbl.repetition is rep - assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]]) - # Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't - # seem to automatically include repetition bounds in label.py itself, - # it's handled during pattern bounding. - - -def test_label_copy() -> None: - l1 = Label("test", offset=(1, 2), annotations={"a": [1]}) - l2 = copy.deepcopy(l1) - - print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}") - print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}") - print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}") - - assert l1 == l2 - assert l1 is not l2 - l2.offset[0] = 100 - assert l1.offset[0] == 1 - - -def test_label_eq_unrelated_objects_is_false() -> None: - lbl = Label("test") - assert not (lbl == None) - assert not (lbl == object()) diff --git a/masque/test/test_library.py b/masque/test/test_library.py deleted file mode 100644 index 323bcf2..0000000 --- a/masque/test/test_library.py +++ /dev/null @@ -1,1210 +0,0 @@ -import pytest -from collections.abc import Mapping, MutableMapping -from typing import cast, TYPE_CHECKING -from numpy.testing import assert_allclose -from ..library import ( - IBorrowing, INameView, IMaterializable, LayerMappedView, Library, LibraryView, - LazyLibrary, OverlayLibrary, PortLoadView, - ) -from ..pattern import Pattern -from ..error import LibraryError, PatternError -from ..ports import Port -from ..repetition import Grid -from ..shapes import Arc, Ellipse, Path, Text -from ..file.utils import preflight, preflight_source_aware - -if TYPE_CHECKING: - from ..shapes import Polygon - - -def test_library_basic() -> None: - lib = Library() - pat = Pattern() - lib["cell1"] = pat - - assert isinstance(lib, INameView) - assert "cell1" in lib - assert lib["cell1"] is pat - assert len(lib) == 1 - - with pytest.raises(LibraryError): - lib["cell1"] = Pattern() # Overwriting not allowed - - -@pytest.mark.parametrize("library_cls", [Library, LazyLibrary, OverlayLibrary]) -def test_writable_libraries_are_restricted_mappings( - library_cls: type[Library] | type[LazyLibrary] | type[OverlayLibrary], - ) -> None: - lib = library_cls() - - assert isinstance(lib, Mapping) - assert not isinstance(lib, MutableMapping) - for method in ("update", "setdefault", "pop", "popitem", "clear"): - assert not hasattr(lib, method) - - lib["top"] = Pattern() - del lib["top"] - assert not lib - - -@pytest.mark.parametrize('cached', [False, True]) -def test_overlay_reuses_names_and_keeps_new_sources_independent(cached: bool) -> None: - source = Library({'a': Pattern(), 'parent': Pattern().ref('a')}) - overlay = OverlayLibrary() - overlay.add_source(source) - if cached: - overlay['parent'] - overlay.rename('a', 'b', move_references=True) - overlay.rename('b', 'a', move_references=True) - assert overlay.child_graph()['parent'] == {'a'} - assert set(overlay.materialize('parent', persist=False).refs) == {'a'} - overlay.rename('a', 'b', move_references=True) - overlay.add_source(Library({'a': Pattern(), 'new_parent': Pattern().ref('a')})) - assert overlay.child_graph()['parent'] == {'b'} - assert overlay.child_graph()['new_parent'] == {'a'} - assert set(overlay['new_parent'].refs) == {'a'} - assert set(source['parent'].refs) == {'a'} - - -def test_overlay_reuses_dangling_targets_and_preserves_failed_rename() -> None: - overlay = OverlayLibrary() - overlay.add_source(Library({'parent': Pattern().ref('missing')})) - overlay.move_references('missing', 'other') - overlay.move_references('other', 'missing') - assert set(overlay['parent'].refs) == {'missing'} - overlay['used'] = Pattern() - before = list(overlay) - with pytest.raises(LibraryError, match='already exists'): - overlay.rename('parent', 'used', move_references=True) - assert list(overlay) == before - assert set(overlay['parent'].refs) == {'missing'} - del overlay['used'] - overlay.rename('parent', 'used') - assert list(overlay) == ['used'] - - -def test_library_tops() -> None: - lib = Library() - lib["child"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("child") - - assert set(lib.tops()) == {"parent"} - assert lib.top() == "parent" - - -def test_empty_ref_buckets_do_not_create_hierarchy_edges() -> None: - parent = Pattern() - parent.refs["ghost"] - parent.refs["parent"] - lib = Library({"parent": parent, "ghost": Pattern()}) - - assert not parent.has_refs() - assert parent.referenced_patterns() == set() - assert set(lib.tops()) == {"parent", "ghost"} - assert lib.child_graph() == {"parent": set(), "ghost": set()} - assert lib.parent_graph() == {"parent": set(), "ghost": set()} - - lib.dfs(parent, hierarchy=("parent",)) - flat = lib.flatten("parent")["parent"] - assert not flat.refs - - -def test_library_dangling() -> None: - lib = Library() - lib["parent"] = Pattern() - lib["parent"].ref("missing") - - assert lib.dangling_refs() == {"missing"} - - -def test_library_reachability_ignores_unnamed_refs() -> None: - pattern = Pattern() - pattern.ref(None) - lib = Library({"top": pattern}) - - assert pattern.referenced_patterns() == {None} - assert lib.referenced_patterns() == set() - assert lib.dangling_refs() == set() - - -def test_library_dangling_graph_modes() -> None: - lib = Library() - lib["parent"] = Pattern() - lib["parent"].ref("missing") - - with pytest.raises(LibraryError, match="Dangling refs found"): - lib.child_graph() - with pytest.raises(LibraryError, match="Dangling refs found"): - lib.parent_graph() - with pytest.raises(LibraryError, match="Dangling refs found"): - lib.child_order() - - assert lib.child_graph(dangling="ignore") == {"parent": set()} - assert lib.parent_graph(dangling="ignore") == {"parent": set()} - assert lib.child_order(dangling="ignore") == ["parent"] - - assert lib.child_graph(dangling="include") == {"parent": {"missing"}, "missing": set()} - assert lib.parent_graph(dangling="include") == {"parent": set(), "missing": {"parent"}} - assert lib.child_order(dangling="include") == ["missing", "parent"] - - -@pytest.mark.parametrize( - ("method", "args"), - [ - ("child_graph", ()), - ("parent_graph", ()), - ("child_order", ()), - ("find_refs_local", ("top",)), - ("find_refs_global", ("top",)), - ("prune_empty", ()), - ], -) -def test_library_rejects_unknown_dangling_mode(method: str, args: tuple[object, ...]) -> None: - lib = Library({"top": Pattern()}) - - with pytest.raises(ValueError, match="dangling-reference mode"): - getattr(lib, method)(*args, dangling="typo") - - -def test_find_refs_with_dangling_modes() -> None: - lib = Library() - lib["target"] = Pattern() - - mid = Pattern() - mid.ref("target", offset=(2, 0)) - lib["mid"] = mid - - top = Pattern() - top.ref("mid", offset=(5, 0)) - top.ref("missing", offset=(9, 0)) - lib["top"] = top - - assert lib.find_refs_local("missing", dangling="ignore") == {} - assert lib.find_refs_global("missing", dangling="ignore") == {} - - local_missing = lib.find_refs_local("missing", dangling="include") - assert set(local_missing) == {"top"} - assert_allclose(local_missing["top"][0], [[9, 0, 0, 0, 1]]) - - global_missing = lib.find_refs_global("missing", dangling="include") - assert_allclose(global_missing[("top", "missing")], [[9, 0, 0, 0, 1]]) - - with pytest.raises(LibraryError, match="missing"): - lib.find_refs_local("missing") - with pytest.raises(LibraryError, match="missing"): - lib.find_refs_global("missing") - - global_target = lib.find_refs_global("target") - assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]]) - - -def test_find_refs_global_includes_composed_scale_column() -> None: - lib = Library({"leaf": Pattern(), "child": Pattern(), "top": Pattern()}) - lib["child"].ref("leaf", scale=2) - lib["top"].ref("child", scale=3) - - transforms = lib.find_refs_global("leaf") - - assert_allclose(transforms[("top", "child", "leaf")], [[0, 0, 0, 0, 6]]) - - -def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None: - def make_lib() -> Library: - lib = Library() - lib["empty"] = Pattern() - lib["top"] = Pattern() - lib["top"].ref("missing") - return lib - - caplog.set_level("WARNING") - warned = preflight(make_lib(), allow_dangling_refs=None, prune_empty_patterns=True) - assert "empty" not in warned - assert any("Dangling refs found" in record.message for record in caplog.records) - - allowed = preflight(make_lib(), allow_dangling_refs=True, prune_empty_patterns=True) - assert "empty" not in allowed - - with pytest.raises(LibraryError, match="Dangling refs found"): - preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True) - - -def test_preflight_source_aware_skips_source_backed_patterns() -> None: - source = Library() - source["copied"] = Pattern() - source["materialized"] = Pattern() - mapped_names: list[str] = [] - - def map_layer(layer: int | tuple[int, int] | str) -> int | tuple[int, int] | str: - mapped_names.append(str(layer)) - return layer - - view = LayerMappedView(source, map_layer, copy_through=True) - view["materialized"].rect("named", xmin=0, xmax=1, ymin=0, ymax=1) - - result = preflight_source_aware(view, allow_named_layers=True) - - assert isinstance(result, OverlayLibrary) - assert mapped_names == [] - assert result.source_cell("copied") is not None - assert result.source_cell("materialized") is None - assert not source["materialized"].shapes - assert "named" in result["materialized"].shapes - - -def test_preflight_source_aware_checks_only_cells_without_provenance() -> None: - source = Library() - source["copied"] = Pattern().rect("copied_layer", xmin=0, xmax=1, ymin=0, ymax=1) - source["materialized"] = Pattern().rect("materialized_layer", xmin=0, xmax=1, ymin=0, ymax=1) - view = LayerMappedView(source, lambda layer: layer, copy_through=True) - - preflight_source_aware(view, allow_named_layers=False) - - view["materialized"] - with pytest.raises(PatternError, match="materialized_layer"): - preflight_source_aware(view, allow_named_layers=False) - - -def test_preflight_source_aware_prunes_only_safe_checked_patterns() -> None: - source = Library({"child": Pattern(), "parent": Pattern()}) - source["parent"].ref("child") - view = LayerMappedView(source, lambda layer: layer, copy_through=True) - view["child"] - - result = preflight_source_aware(view, prune_empty_patterns=True) - - assert isinstance(result, OverlayLibrary) - assert "child" in result - assert result.source_cell("parent") is not None - - view["parent"] - result = preflight_source_aware(view, prune_empty_patterns=True) - - assert "child" not in result - assert "parent" not in result - assert "child" in source - assert "child" in source["parent"].refs - - -def test_preflight_source_aware_wraps_only_checked_patterns() -> None: - source = Library({"copied": Pattern(), "materialized": Pattern()}) - for name in source: - source[name].rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1) - source[name].shapes[(1, 0)][0].repetition = Grid(a_vector=(10, 0), a_count=2) - view = LayerMappedView(source, lambda layer: layer, copy_through=True) - view["materialized"] - - result = preflight_source_aware(view, wrap_repeated_shapes=True) - - assert isinstance(result, OverlayLibrary) - assert result.source_cell("copied") is not None - assert source["copied"].shapes[(1, 0)][0].repetition is not None - assert result["materialized"].shapes[(1, 0)] == [] - wrapper_names = result["materialized"].referenced_patterns() - assert len(wrapper_names) == 1 - wrapper_name = next(iter(wrapper_names)) - assert wrapper_name is not None - assert result[wrapper_name].shapes[(1, 0)][0].repetition is None - - -def test_library_flatten() -> None: - lib = Library() - child = Pattern() - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - lib["child"] = child - - parent = Pattern() - parent.ref("child", offset=(10, 10)) - lib["parent"] = parent - - flat_lib = lib.flatten("parent") - flat_parent = flat_lib["parent"] - - assert not flat_parent.has_refs() - assert len(flat_parent.shapes[(1, 0)]) == 1 - # Transformations are baked into vertices for Polygon - assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices - assert tuple(assert_vertices[0]) == (10.0, 10.0) - - -def test_pattern_polygon_traversal_ignores_empty_dangling_bucket() -> None: - child = Pattern() - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - parent = Pattern() - parent.ref("child") - parent.refs["missing"] - lib = Library({"child": child, "parent": parent}) - - polygons = parent.layer_as_polygons((1, 0), flatten=True, library=lib) - - assert len(polygons) == 1 - - -def test_recursive_geometry_rejects_reference_cycles() -> None: - lib = Library({"a": Pattern(), "b": Pattern()}) - lib["a"].ref("b") - lib["b"].ref("a") - - with pytest.raises(PatternError, match=r"calculating bounds: .* -> .* ->"): - lib["a"].get_bounds(library=lib) - with pytest.raises(PatternError, match=r"collecting layer polygons: .* -> .* ->"): - lib["a"].layer_as_polygons((1, 0), library=lib) - with pytest.raises(PatternError, match=r"visualizing pattern hierarchy: .* -> .* ->"): - lib["a"].visualize(library=lib) - with pytest.raises(PatternError, match="Circular reference"): - lib["a"].deepcopy().flatten(library=lib) - - -def test_nonrecursive_geometry_allows_reference_cycles() -> None: - lib = Library({"loop": Pattern()}) - lib["loop"].ref("loop") - - assert lib["loop"].get_bounds(library=lib, recurse=False) is None - assert lib["loop"].layer_as_polygons((1, 0), flatten=False, library=lib) == [] - - -def test_library_flatten_preserves_ports_only_child() -> None: - lib = Library() - child = Pattern(ports={"P1": Port((1, 2), 0)}) - lib["child"] = child - - parent = Pattern() - parent.ref("child", offset=(10, 10)) - lib["parent"] = parent - - flat_parent = lib.flatten("parent", flatten_ports=True)["parent"] - - assert set(flat_parent.ports) == {"P1"} - assert cast("Port", flat_parent.ports["P1"]).rotation == 0 - assert tuple(flat_parent.ports["P1"].offset) == (11.0, 12.0) - - -def test_library_flatten_repeated_ref_with_ports_raises() -> None: - lib = Library() - child = Pattern(ports={"P1": Port((1, 2), 0)}) - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - lib["child"] = child - - parent = Pattern() - parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2)) - lib["parent"] = parent - - with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'): - lib.flatten("parent", flatten_ports=True) - - -def test_library_flatten_dangling_ok_nested_preserves_dangling_refs() -> None: - lib = Library() - child = Pattern() - child.ref("missing") - lib["child"] = child - - parent = Pattern() - parent.ref("child") - lib["parent"] = parent - - flat = lib.flatten("parent", dangling_ok=True) - - assert set(flat["child"].refs) == {"missing"} - assert flat["child"].has_refs() - assert set(flat["parent"].refs) == {"missing"} - assert flat["parent"].has_refs() - - -def test_lazy_library() -> None: - lib = LazyLibrary() - called = 0 - - def make_pat() -> Pattern: - nonlocal called - called += 1 - return Pattern() - - lib["lazy"] = make_pat - assert called == 0 - - pat = lib["lazy"] - assert called == 1 - assert isinstance(pat, Pattern) - - # Second access should be cached - pat2 = lib["lazy"] - assert called == 1 - assert pat is pat2 - - -def test_lazy_library_reachability_loads_only_reachable_cells() -> None: - lib = LazyLibrary() - calls = {"top": 0, "child": 0, "unused": 0} - - def make(name: str, target: str | None = None) -> Pattern: - calls[name] += 1 - pattern = Pattern() - if target is not None: - pattern.ref(target) - return pattern - - lib["top"] = lambda: make("top", "child") - lib["child"] = lambda: make("child") - lib["unused"] = lambda: make("unused") - - assert lib.referenced_patterns("top") == {"child"} - assert calls == {"top": 1, "child": 1, "unused": 0} - - -def test_abstract_view_membership_does_not_materialize_lazy_cells() -> None: - lib = LazyLibrary() - calls = 0 - - def make_pat() -> Pattern: - nonlocal calls - calls += 1 - return Pattern() - - lib["lazy"] = make_pat - abstracts = lib.abstract_view() - - assert "lazy" in abstracts - assert "missing" not in abstracts - assert calls == 0 - - -def test_library_rename() -> None: - lib = Library() - lib["old"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("old") - - lib.rename("old", "new", move_references=True) - - assert "old" not in lib - assert "new" in lib - assert "new" in lib["parent"].refs - assert "old" not in lib["parent"].refs - - -@pytest.mark.parametrize("library_cls", [Library, LazyLibrary]) -def test_library_rename_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: - lib = library_cls() - lib["top"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("top") - - lib.rename("top", "top", move_references=True) - - assert set(lib.keys()) == {"top", "parent"} - assert "top" in lib["parent"].refs - assert len(lib["parent"].refs["top"]) == 1 - - -@pytest.mark.parametrize("library_cls", [Library, LazyLibrary]) -def test_library_rename_top_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: - lib = library_cls() - lib["top"] = Pattern() - - lib.rename_top("top") - - assert list(lib.keys()) == ["top"] - - -@pytest.mark.parametrize("library_cls", [Library, LazyLibrary]) -def test_library_rename_missing_raises_library_error(library_cls: type[Library] | type[LazyLibrary]) -> None: - lib = library_cls() - lib["top"] = Pattern() - - with pytest.raises(LibraryError, match="does not exist"): - lib.rename("missing", "new") - - -@pytest.mark.parametrize("library_cls", [Library, LazyLibrary]) -def test_library_move_references_same_target_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: - lib = library_cls() - lib["top"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("top") - - lib.move_references("top", "top") - - assert "top" in lib["parent"].refs - assert len(lib["parent"].refs["top"]) == 1 - - -def test_library_dfs_can_replace_existing_patterns() -> None: - lib = Library() - child = Pattern() - lib["child"] = child - top = Pattern() - top.ref("child") - lib["top"] = top - - replacement_top = Pattern(ports={"T": Port((1, 2), 0)}) - replacement_child = Pattern(ports={"C": Port((3, 4), 0)}) - - def visit_after(pattern: Pattern, hierarchy: tuple[str | None, ...], **kwargs) -> Pattern: # noqa: ARG001 - if hierarchy[-1] == "child": - return replacement_child - if hierarchy[-1] == "top": - return replacement_top - return pattern - - lib.dfs(lib["top"], visit_after=visit_after, hierarchy=("top",), transform=True) - - assert lib["top"] is replacement_top - assert lib["child"] is replacement_child - - -def test_lazy_library_dfs_can_replace_existing_patterns() -> None: - lib = LazyLibrary() - lib["child"] = lambda: Pattern() - lib["top"] = lambda: Pattern(refs={"child": []}) - - top = lib["top"] - top.ref("child") - - replacement_top = Pattern(ports={"T": Port((1, 2), 0)}) - replacement_child = Pattern(ports={"C": Port((3, 4), 0)}) - - def visit_after(pattern: Pattern, hierarchy: tuple[str | None, ...], **kwargs) -> Pattern: # noqa: ARG001 - if hierarchy[-1] == "child": - return replacement_child - if hierarchy[-1] == "top": - return replacement_top - return pattern - - lib.dfs(top, visit_after=visit_after, hierarchy=("top",), transform=True) - - assert lib["top"] is replacement_top - assert lib["child"] is replacement_child - - -def test_library_add_no_duplicates_respects_mutate_other_false() -> None: - src_pat = Pattern(ports={"A": Port((0, 0), 0)}) - lib = Library({"a": Pattern()}) - - lib.add({"b": src_pat}, mutate_other=False) - - assert lib["b"] is not src_pat - lib["b"].ports["A"].offset[0] = 123 - assert tuple(src_pat.ports["A"].offset) == (0.0, 0.0) - - -def test_library_add_returns_only_renamed_entries() -> None: - lib = Library({"a": Pattern(), "_shape": Pattern()}) - - assert lib.add({"b": Pattern(), "c": Pattern()}, mutate_other=False) == {} - - rename_map = lib.add({"_shape": Pattern(), "keep": Pattern()}, mutate_other=False) - - assert set(rename_map) == {"_shape"} - assert rename_map["_shape"] != "_shape" - assert "keep" not in rename_map - - -def test_library_add_name_failure_is_atomic() -> None: - destination = Library({"x": Pattern(), "y": Pattern()}) - source_parent = Pattern() - source_parent.ref("x") - source = Library({"x": Pattern(), "y": source_parent}) - - with pytest.raises(LibraryError, match="Unresolved duplicate"): - destination.add(source, rename_theirs=lambda _lib, _name: "z", mutate_other=True) - - assert set(destination) == {"x", "y"} - assert set(source) == {"x", "y"} - assert set(source["y"].refs) == {"x"} - - -def test_library_add_callback_sees_earlier_name_reservations() -> None: - destination = Library({"_shape$A": Pattern(), "_shape$B": Pattern()}) - source = Library({"_shape$A": Pattern(), "_shape$B": Pattern()}) - callback_views: list[set[str]] = [] - - def rename(view: INameView, _name: str) -> str: - assert isinstance(view, INameView) - assert not isinstance(view, Mapping) - assert not hasattr(view, "__getitem__") - callback_views.append(set(view)) - return view.get_name("_shape") - - rename_map = destination.add(source, rename_theirs=rename) - - assert len(set(rename_map.values())) == 2 - assert rename_map["_shape$A"] in callback_views[1] - assert set(rename_map.values()) <= set(destination) - - -def test_library_can_add_itself_with_mutate_other() -> None: - lib = Library({"_helper": Pattern()}) - - rename_map = lib.add(lib, mutate_other=True) - - assert rename_map["_helper"] in lib - assert len(lib) == 2 - - -def test_overlay_add_source_callback_sees_earlier_name_reservations() -> None: - overlay = OverlayLibrary() - overlay["_shape$A"] = Pattern() - overlay["_shape$B"] = Pattern() - source = Library({"_shape$A": Pattern(), "_shape$B": Pattern()}) - - rename_map = overlay.add_source( - source, - rename_theirs=lambda view, _name: view.get_name("_shape"), - ) - - assert len(set(rename_map.values())) == 2 - assert set(rename_map.values()) <= set(overlay) - - -def test_overlay_add_source_renames_conflicting_single_use_name_by_default() -> None: - overlay = OverlayLibrary() - overlay['_myCellName$F'] = Pattern() - source = Library({'_myCellName$F': Pattern(), 'source_top': Pattern()}) - source['source_top'].ref('_myCellName$F') - - rename_map = overlay.add_source(source) - - assert rename_map == {'_myCellName$F': '_myCellName'} - assert set(overlay['source_top'].refs) == {'_myCellName'} - - -def test_overlay_add_source_can_explicitly_disable_default_renaming() -> None: - overlay = OverlayLibrary() - overlay['_helper$A'] = Pattern() - - with pytest.raises(LibraryError, match='Conflicting name'): - overlay.add_source(Library({'_helper$A': Pattern()}), rename_theirs=None) - - -def test_port_load_view_detaches_already_materialized_overlay_pattern() -> None: - overlay = OverlayLibrary() - overlay.add_source(Library({"top": Pattern()})) - raw = overlay["top"] - processed = PortLoadView( - overlay, - ports={"top": {"P": Port((1, 2), 0)}}, - ) - - processed_top = processed["top"] - - assert processed_top is not raw - assert not raw.ports - assert set(processed_top.ports) == {"P"} - assert not hasattr(processed, "close") - - -def test_layer_mapped_view_detaches_and_maps_shapes_and_labels() -> None: - import masque - - child = Pattern() - source_pattern = Pattern(ports={'P': Port((1, 2), 0, ptype='wire')}) - source_pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]]) - source_pattern.polygon('B', vertices=[[2, 0], [3, 0], [2, 1]]) - source_pattern.label('TEXT', string='port', offset=(1, 2)) - source_pattern.ref('child') - source_pattern.annotations['note'] = ['unchanged'] - source = Library({'child': child, 'top': source_pattern}) - mapped = LayerMappedView( - source, - lambda layer: {'A': 'DRAW', 'B': 'DRAW', 'TEXT': 'LABEL'}.get(layer, layer), - ) - - mapped_top = mapped['top'] - - assert mapped_top is not source_pattern - assert set(mapped_top.shapes) == {'DRAW'} - assert len(mapped_top.shapes['DRAW']) == 2 - assert set(mapped_top.labels) == {'LABEL'} - assert set(mapped_top.ports) == {'P'} - assert set(mapped_top.refs) == {'child'} - assert mapped_top.annotations == {'note': ['unchanged']} - assert set(source_pattern.shapes) == {'A', 'B'} - assert set(source_pattern.labels) == {'TEXT'} - assert mapped.child_graph() == source.child_graph() - assert mapped.source_order() == source.source_order() - assert masque.LayerMappedView is LayerMappedView - assert masque.PortLoadView is PortLoadView - assert not hasattr(masque, 'PortsLibraryView') - assert not hasattr(masque.library, 'PortsLibraryView') - - -def test_layer_mapped_view_preflight_rejects_none_layer() -> None: - pattern = Pattern() - pattern.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - mapped = LayerMappedView(Library({'top': pattern}), lambda _layer: None) # type: ignore[arg-type] - - with pytest.raises(PatternError, match=r"returned None for source layer \(1, 0\)"): - preflight_source_aware(mapped) - - -def test_layer_mapped_view_materialization_and_copy_through() -> None: - pattern = Pattern() - pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]]) - source = Library({'top': pattern}) - mapped = LayerMappedView(source, lambda _layer: 'B') - passthrough = LayerMappedView(source, lambda _layer: 'B', copy_through=True) - - assert mapped.source_cell('top') is None - assert passthrough.source_cell('top') == (source, 'top') - transient = passthrough.materialize('top', persist=False) - assert set(transient.shapes) == {'B'} - assert passthrough.source_cell('top') == (source, 'top') - - persistent = passthrough['top'] - assert passthrough['top'] is persistent - assert passthrough.source_cell('top') is None - assert set(persistent.shapes) == {'B'} - - -def test_layer_mapped_view_composes_with_ports_and_overlay() -> None: - pattern = Pattern() - pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]]) - source = Library({'top': pattern}) - ports = PortLoadView(source, ports={'top': {'P': Port((1, 2), 0)}}) - mapped = LayerMappedView(ports, lambda _layer: 'B') - overlay = OverlayLibrary() - overlay.add_source(mapped) - - top = overlay['top'] - - assert set(top.shapes) == {'B'} - assert set(top.ports) == {'P'} - assert mapped.borrowed_sources() == (ports,) - assert not pattern.ports - - -def test_library_subtree() -> None: - lib = Library() - lib["a"] = Pattern() - lib["b"] = Pattern() - lib["c"] = Pattern() - lib["a"].ref("b") - - sub = lib.subtree("a") - assert isinstance(sub, Library) - assert "a" in sub - assert "b" in sub - assert "c" not in sub - assert sub["a"] is lib["a"] - - del sub["b"] - assert "b" in lib - - -def test_lazy_library_subtree_preserves_type_and_shares_materialized_patterns() -> None: - lib = LazyLibrary() - child = Pattern() - top = Pattern() - top.ref("child") - lib["child"] = lambda: child - lib["top"] = lambda: top - lib["unused"] = Pattern() - - subtree = lib.subtree("top") - - assert isinstance(subtree, LazyLibrary) - assert set(subtree) == {"child", "top"} - assert subtree["top"] is lib["top"] - del subtree["child"] - assert "child" in lib - - -def test_overlay_subtree_preserves_type_sources_and_independent_materialization() -> None: - source = Library({"child": Pattern(), "top": Pattern(), "unused": Pattern()}) - source["top"].ref("child") - overlay = OverlayLibrary() - overlay.add_source(source) - - subtree = overlay.subtree("top") - - assert isinstance(subtree, OverlayLibrary) - assert set(subtree) == {"child", "top"} - assert subtree.borrowed_sources() == overlay.borrowed_sources() - subtree_top = subtree["top"] - overlay_top = overlay["top"] - assert subtree_top is not overlay_top - - shared_subtree = overlay.subtree("top") - assert shared_subtree["top"] is overlay_top - del shared_subtree["child"] - assert "child" in overlay - - -def test_overlay_subtree_preserves_reference_remaps() -> None: - source = Library({"leaf": Pattern(), "parent": Pattern()}) - source["parent"].ref("leaf") - overlay = OverlayLibrary() - overlay.add_source(source) - overlay.rename("leaf", "renamed", move_references=True) - - subtree = overlay.subtree("parent") - - assert isinstance(subtree, OverlayLibrary) - assert set(subtree) == {"parent", "renamed"} - assert set(subtree["parent"].refs) == {"renamed"} - - -def test_read_only_subtree_is_a_borrowed_subtree_view() -> None: - source = Library({"child": Pattern(), "top": Pattern(), "unused": Pattern()}) - source["top"].ref("child") - view = LibraryView(source) - - subtree = view.subtree("top") - - assert subtree.source_order() == ("child", "top") - assert subtree["top"] is source["top"] - assert "unused" not in subtree - with pytest.raises(KeyError): - _ = subtree["unused"] - - -def test_library_materialization_and_borrowing_capabilities() -> None: - lazy = LazyLibrary() - lazy["top"] = lambda: Pattern() - - assert isinstance(lazy, IMaterializable) - assert not isinstance(lazy, IBorrowing) - assert not hasattr(lazy, "borrowed_sources") - transient = lazy.materialize("top", persist=False) - assert "top" not in lazy.cache - assert transient is not lazy["top"] - - overlay = OverlayLibrary() - overlay.add_source(lazy) - ports = PortLoadView(overlay) - subtree = ports.subtree("top") - - assert isinstance(overlay, IMaterializable) - assert isinstance(overlay, IBorrowing) - assert isinstance(ports, IMaterializable) - assert isinstance(ports, IBorrowing) - assert isinstance(subtree, IMaterializable) - assert isinstance(subtree, IBorrowing) - assert overlay.borrowed_sources() == (lazy,) - assert ports.borrowed_sources() == (overlay,) - assert subtree.borrowed_sources() == (ports,) - eager = Library({"top": Pattern()}) - plain_view = LibraryView(lazy) - assert not isinstance(eager, IMaterializable | IBorrowing) - assert not isinstance(plain_view, IMaterializable | IBorrowing) - - -def test_detached_materialization_default_is_owned_and_deduplicated() -> None: - shared_a = Pattern() - shared_b = Pattern() - lazy = LazyLibrary() - lazy['a'] = lambda: shared_a - lazy['b'] = lambda: shared_b - - detached = lazy.materialize_many_detached(('b', 'a', 'b')) - - assert tuple(detached) == ('b', 'a') - assert detached['a'] is not shared_a - assert detached['b'] is not shared_b - detached['a'].polygon('L', vertices=[[0, 0], [1, 0], [0, 1]]) - assert not shared_a.shapes - assert not lazy.cache - - -def test_nested_detached_views_copy_plain_source_once(monkeypatch: pytest.MonkeyPatch) -> None: - source_pattern = Pattern() - source_pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]]) - source = Library({'top': source_pattern}) - ports = PortLoadView(source, ports={'top': {'P': Port((1, 2), 0)}}) - mapped = LayerMappedView(ports, lambda _layer: 'B') - overlay = OverlayLibrary() - overlay.add_source(mapped) - - original_deepcopy = Pattern.deepcopy - copied: list[Pattern] = [] - - def count_deepcopy(pattern: Pattern) -> Pattern: - copied.append(pattern) - return original_deepcopy(pattern) - - monkeypatch.setattr(Pattern, 'deepcopy', count_deepcopy) - detached = overlay.materialize_detached('top') - - assert copied == [source_pattern] - assert set(detached.shapes) == {'B'} - assert set(detached.ports) == {'P'} - assert not source_pattern.ports - assert not ports._cache - assert not mapped._cache - assert overlay.source_cell('top') == (mapped, 'top') - - -def test_nested_detached_views_copy_cached_pattern_once(monkeypatch: pytest.MonkeyPatch) -> None: - source_pattern = Pattern() - source = Library({'top': source_pattern}) - ports = PortLoadView(source, ports={'top': {'P': Port((1, 2), 0)}}) - cached = ports['top'] - mapped = LayerMappedView(ports, lambda layer: layer) - - original_deepcopy = Pattern.deepcopy - copied: list[Pattern] = [] - - def count_deepcopy(pattern: Pattern) -> Pattern: - copied.append(pattern) - return original_deepcopy(pattern) - - monkeypatch.setattr(Pattern, 'deepcopy', count_deepcopy) - detached = mapped.materialize_detached('top') - - assert copied == [cached] - assert detached is not cached - detached.ports.clear() - assert set(cached.ports) == {'P'} - - -def test_borrowed_source_cell_tracks_persistent_materialization() -> None: - source = Library({"top": Pattern()}) - source.library_info = {"name": "not-forwarded"} # type: ignore[attr-defined] - processed = PortLoadView(source) - - assert processed.source_cell("top") == (source, "top") - assert not hasattr(processed, "library_info") - assert not hasattr(processed, "raw_struct_bytes") - _ = processed.materialize_many(("top",), persist=False) - assert processed.source_cell("top") == (source, "top") - - subtree = processed.subtree("top") - assert subtree.source_cell("top") == (processed, "top") - assert not hasattr(subtree, "library_info") - _ = subtree["top"] - assert processed.source_cell("top") is None - assert subtree.source_cell("top") == (processed, "top") - - -def test_overlay_source_cell_tracks_names_references_and_materialization() -> None: - source = Library({"leaf": Pattern(), "parent": Pattern()}) - source["parent"].ref("leaf") - - overlay = OverlayLibrary() - overlay.add_source(source) - assert overlay.source_cell("parent") == (source, "parent") - - overlay.rename("parent", "renamed_parent") - assert overlay.source_cell("renamed_parent") == (source, "parent") - - overlay.rename("leaf", "renamed_leaf", move_references=True) - assert overlay.source_cell("renamed_parent") is None - - materialized = OverlayLibrary() - materialized.add_source(source) - _ = materialized["parent"] - assert materialized.source_cell("parent") is None - - -@pytest.mark.parametrize("materialize_parent", [False, True]) -@pytest.mark.parametrize("move_references", [False, True]) -def test_overlay_rename_is_independent_of_materialization( - materialize_parent: bool, - move_references: bool, - ) -> None: - source = Library({"leaf": Pattern(), "parent": Pattern()}) - source["parent"].ref("leaf") - overlay = OverlayLibrary() - overlay.add_source(source) - - if materialize_parent: - _ = overlay["parent"] - - overlay.rename("leaf", "renamed", move_references=move_references) - expected_target = "renamed" if move_references else "leaf" - - assert overlay.child_graph(dangling="include")["parent"] == {expected_target} - assert set(overlay["parent"].refs) == {expected_target} - assert overlay.child_graph(dangling="include")["parent"] == {expected_target} - - -def test_overlay_rejects_unknown_dangling_mode() -> None: - overlay = OverlayLibrary() - overlay.add_source(Library({"top": Pattern()})) - - with pytest.raises(ValueError, match="dangling-reference mode"): - overlay.child_graph(dangling="typo") - with pytest.raises(ValueError, match="dangling-reference mode"): - overlay.find_refs_local("top", dangling="typo") - - -def test_overlay_rename_preserves_initial_import_target_without_moving_refs() -> None: - source = Library({"leaf": Pattern(), "parent": Pattern()}) - source["parent"].ref("leaf") - overlay = OverlayLibrary() - overlay["leaf"] = Pattern() - rename_map = overlay.add_source(source, rename_theirs=lambda lib, name: lib.get_name(name)) - imported_leaf = rename_map["leaf"] - - overlay.rename(imported_leaf, "renamed", move_references=False) - - assert set(overlay["parent"].refs) == {imported_leaf} - - -def test_overlay_chained_rename_moves_unmaterialized_references() -> None: - source = Library({"leaf": Pattern(), "parent": Pattern()}) - source["parent"].ref("leaf") - overlay = OverlayLibrary() - overlay.add_source(source) - - overlay.rename("leaf", "middle", move_references=True) - overlay.rename("middle", "final", move_references=True) - - assert set(overlay["parent"].refs) == {"final"} - - -def _assert_tree_merge_rejected(tree: Library) -> None: - destination = Library({"existing": Pattern()}) - - with pytest.raises(LibraryError, match="exactly one topcell"): - destination << tree - - assert set(destination) == {"existing"} - - -def test_library_tree_merge_rejects_empty_tree() -> None: - _assert_tree_merge_rejected(Library()) - - -def test_library_tree_merge_rejects_cycle_without_top() -> None: - tree = Library({"a": Pattern(), "b": Pattern()}) - tree["a"].ref("b") - tree["b"].ref("a") - _assert_tree_merge_rejected(tree) - - -def test_library_tree_merge_rejects_multiple_tops() -> None: - _assert_tree_merge_rejected(Library({"a": Pattern(), "b": Pattern()})) - - -def test_library_child_order_cycle_raises_library_error() -> None: - lib = Library() - lib["a"] = Pattern() - lib["a"].ref("b") - lib["b"] = Pattern() - lib["b"].ref("a") - - with pytest.raises(LibraryError, match="Cycle found while building child order"): - lib.child_order() - - -def test_library_find_refs_global_cycle_raises_library_error() -> None: - lib = Library() - lib["a"] = Pattern() - lib["a"].ref("a") - - with pytest.raises(LibraryError, match="Cycle found while building child order"): - lib.find_refs_global("a") - - -def test_library_get_name() -> None: - lib = Library() - lib["cell"] = Pattern() - - name1 = lib.get_name("cell") - assert name1 != "cell" - assert name1.startswith("cell") - - name2 = lib.get_name("other") - assert name2 == "other" - - -def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None: - lib = Library() - pat = Pattern() - pat.shapes[(1, 0)] += [ - Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)), - Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)), - ] - lib["top"] = pat - - lib.dedup(norm_value=1, threshold=2) - - assert not lib["top"].refs - assert len(lib["top"].shapes[(1, 0)]) == 2 - - -def test_library_dedup_text_preserves_scale_and_mirror_flag() -> None: - lib = Library() - pat = Pattern() - pat.shapes[(1, 0)] += [ - Text("A", 10, "dummy.ttf", offset=(0, 0)), - Text("A", 10, "dummy.ttf", offset=(100, 0)), - ] - lib["top"] = pat - - lib.dedup(exclude_types=(), norm_value=5, threshold=2) - - target_name = next(iter(lib["top"].refs)) - refs = lib["top"].refs[target_name] - assert [ref.mirrored for ref in refs] == [False, False] - assert [ref.scale for ref in refs] == [2.0, 2.0] - assert cast("Text", lib[target_name].shapes[(1, 0)][0]).height == 5 - - flat = lib.flatten("top")["top"] - assert [cast("Text", shape).height for shape in flat.shapes[(1, 0)]] == [10, 10] - - -def test_library_dedup_handles_arc_and_ellipse_labels() -> None: - lib = Library() - pat = Pattern() - pat.shapes[(1, 0)] += [ - Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(0, 0)), - Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(50, 0)), - ] - pat.shapes[(2, 0)] += [ - Ellipse(radii=(10, 20), offset=(0, 0)), - Ellipse(radii=(10, 20), offset=(50, 0)), - ] - lib["top"] = pat - - lib.dedup(exclude_types=(), norm_value=1, threshold=2) - - assert len(lib["top"].refs) == 2 - assert lib["top"].shapes[(1, 0)] == [] - assert lib["top"].shapes[(2, 0)] == [] - - flat = lib.flatten("top")["top"] - assert sum(isinstance(shape, Arc) for shape in flat.shapes[(1, 0)]) == 2 - assert sum(isinstance(shape, Ellipse) for shape in flat.shapes[(2, 0)]) == 2 - - -def test_library_dedup_handles_multiple_duplicate_groups() -> None: - from ..shapes import Circle - - lib = Library() - pat = Pattern() - pat.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))] - pat.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)] - lib["top"] = pat - - lib.dedup(exclude_types=(), norm_value=1, threshold=2) - - assert len(lib["top"].refs) == 2 - assert all(len(refs) == 2 for refs in lib["top"].refs.values()) - assert len(lib["top"].shapes[(1, 0)]) == 0 - assert len(lib["top"].shapes[(2, 0)]) == 0 - - -def test_library_dedup_uses_stable_target_names_per_label() -> None: - from ..shapes import Circle - - lib = Library() - - p1 = Pattern() - p1.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))] - lib["p1"] = p1 - - p2 = Pattern() - p2.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)] - lib["p2"] = p2 - - lib.dedup(exclude_types=(), norm_value=1, threshold=2) - - circle_target = next(iter(lib["p1"].refs)) - path_target = next(iter(lib["p2"].refs)) - - assert circle_target != path_target - assert all(isinstance(shape, Circle) for shapes in lib[circle_target].shapes.values() for shape in shapes) - assert all(isinstance(shape, Path) for shapes in lib[path_target].shapes.values() for shape in shapes) diff --git a/masque/test/test_manhattanize.py b/masque/test/test_manhattanize.py deleted file mode 100644 index d0f4fa7..0000000 --- a/masque/test/test_manhattanize.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest -import numpy - -from ..shapes import Polygon - - -def test_manhattanize() -> None: - pytest.importorskip("float_raster") - pytest.importorskip("skimage.measure") - poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]]) - grid = numpy.arange(0, 11, 1) - - manhattan_polys = poly.manhattanize(grid, grid) - assert len(manhattan_polys) >= 1 - for mp in manhattan_polys: - dv = numpy.diff(mp.vertices, axis=0) - assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0)) diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py deleted file mode 100644 index f549db7..0000000 --- a/masque/test/test_oasis.py +++ /dev/null @@ -1,60 +0,0 @@ -import io -from pathlib import Path -import pytest -from numpy.testing import assert_equal - -from ..error import PatternError -from ..pattern import Pattern -from ..library import Library -from ..shapes import Path as MPath - - -def test_oasis_roundtrip(tmp_path: Path) -> None: - # Skip if fatamorgana is not installed - pytest.importorskip("fatamorgana") - from ..file import oasis - - lib = Library() - pat1 = Pattern() - pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) - lib["cell1"] = pat1 - - oas_file = tmp_path / "test.oas" - # OASIS needs units_per_micron - oasis.writefile(lib, oas_file, units_per_micron=1000) - - read_lib, info = oasis.readfile(oas_file) - assert "cell1" in read_lib - - # Check bounds - assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]]) - - -def test_oasis_properties_to_annotations_merges_repeated_keys() -> None: - pytest.importorskip("fatamorgana") - import fatamorgana.records as fatrec - from ..file.oasis import properties_to_annotations - - annotations = properties_to_annotations( - [ - fatrec.Property("k", [1], is_standard=False), - fatrec.Property("k", [2, 3], is_standard=False), - ], - {}, - {}, - ) - - assert annotations == {"k": [1, 2, 3]} - - -def test_oasis_write_rejects_circle_path_caps() -> None: - pytest.importorskip("fatamorgana") - from ..file import oasis - - lib = Library() - pat = Pattern() - pat.path((1, 0), vertices=[[0, 0], [10, 0]], width=2, cap=MPath.Cap.Circle) - lib["cell1"] = pat - - with pytest.raises(PatternError, match="does not support path cap"): - oasis.write(lib, io.BytesIO(), units_per_micron=1000) diff --git a/masque/test/test_pack2d.py b/masque/test/test_pack2d.py deleted file mode 100644 index 914c23e..0000000 --- a/masque/test/test_pack2d.py +++ /dev/null @@ -1,96 +0,0 @@ -from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns -from ..library import Library -from ..pattern import Pattern - - -def test_maxrects_bssf_simple() -> None: - # Pack two 10x10 squares into one 20x10 container - rects = [[10, 10], [10, 10]] - containers = [[0, 0, 20, 10]] - - locs, rejects = maxrects_bssf(rects, containers) - - assert not rejects - # They should be at (0,0) and (10,0) - assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)} - - -def test_maxrects_bssf_reject() -> None: - # Try to pack a too-large rectangle - rects = [[10, 10], [30, 30]] - containers = [[0, 0, 20, 20]] - - locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True) - assert 1 in rejects # Second rect rejected - assert 0 not in rejects - - -def test_maxrects_bssf_exact_fill_rejects_remaining() -> None: - rects = [[20, 20], [1, 1]] - containers = [[0, 0, 20, 20]] - - locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True) - - assert tuple(locs[0]) == (0.0, 0.0) - assert rejects == {1} - - -def test_maxrects_bssf_presort_reject_mapping() -> None: - rects = [[10, 12], [19, 14], [13, 11]] - containers = [[0, 0, 20, 20]] - - _locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True) - - assert rejects == {0, 2} - - -def test_guillotine_bssf_sas_presort_reject_mapping() -> None: - rects = [[2, 1], [17, 15], [16, 11]] - containers = [[0, 0, 20, 20]] - - _locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True) - - assert rejects == {2} - - -def test_pack_patterns() -> None: - lib = Library() - p1 = Pattern() - p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) - lib["p1"] = p1 - - p2 = Pattern() - p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) - lib["p2"] = p2 - - # Containers: one 20x20 - containers = [[0, 0, 20, 20]] - # 2um spacing - pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2)) - - assert not rejects - assert len(pat.refs) == 2 - assert "p1" in pat.refs - assert "p2" in pat.refs - - # Check that they don't overlap (simple check via bounds) - # p1 size 10x10, effectively 12x12 - # p2 size 5x5, effectively 7x7 - # Both should fit in 20x20 - - -def test_pack_patterns_reject_names_match_original_patterns() -> None: - lib = Library() - for name, (lx, ly) in { - "p0": (10, 12), - "p1": (19, 14), - "p2": (13, 11), - }.items(): - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly) - lib[name] = pat - - pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0)) - - assert set(rejects) == {"p0", "p2"} - assert set(pat.refs) == {"p1"} diff --git a/masque/test/test_path.py b/masque/test/test_path.py deleted file mode 100644 index f8685f4..0000000 --- a/masque/test/test_path.py +++ /dev/null @@ -1,101 +0,0 @@ -from numpy.testing import assert_equal, assert_allclose - -from ..shapes import Path, Path as MPath - - -def test_path_init() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) - assert_equal(p.vertices, [[0, 0], [10, 0]]) - assert p.width == 2 - assert p.cap == Path.Cap.Flush - - -def test_path_to_polygons_flush() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) - polys = p.to_polygons() - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[0, -1], [10, 1]]) - - -def test_path_to_polygons_square() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square) - polys = p.to_polygons() - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[-1, -1], [11, 1]]) - - -def test_path_to_polygons_circle() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle) - polys = p.to_polygons(num_vertices=32) - assert len(polys) >= 3 - - bounds = p.get_bounds_single() - assert_equal(bounds, [[-1, -1], [11, 1]]) - - -def test_path_custom_cap() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10)) - polys = p.to_polygons() - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[-5, -1], [20, 1]]) - - -def test_path_bend() -> None: - p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2) - polys = p.to_polygons() - assert len(polys) == 1 - bounds = polys[0].get_bounds_single() - assert_equal(bounds, [[0, -1], [11, 10]]) - - -def test_path_mirror() -> None: - p = Path(vertices=[[10, 5], [20, 10]], width=2) - p.mirror(0) - assert_equal(p.vertices, [[10, -5], [20, -10]]) - - -def test_path_scale() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2) - p.scale_by(2) - assert_equal(p.vertices, [[0, 0], [20, 0]]) - assert p.width == 4 - - -def test_path_scale_custom_cap_extensions() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) - p.scale_by(3) - - assert_equal(p.vertices, [[0, 0], [30, 0]]) - assert p.width == 6 - assert p.cap_extensions is not None - assert_allclose(p.cap_extensions, [3, 6]) - assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]]) - - -def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None: - p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) - - intrinsic, _extrinsic, ctor = p.normalized_form(5) - q = ctor() - - assert intrinsic[-1] == (0.2, 0.4) - assert q.width == 2 - assert q.cap_extensions is not None - assert_allclose(q.cap_extensions, [1, 2]) - - -def test_path_normalized_form_distinguishes_custom_caps() -> None: - p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) - p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)) - - assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0] - - -def test_path_edge_cases() -> None: - p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2) - polys = p.to_polygons() - assert len(polys) == 1 - assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]]) diff --git a/masque/test/test_pather_autotool.py b/masque/test/test_pather_autotool.py deleted file mode 100644 index fe8c0a6..0000000 --- a/masque/test/test_pather_autotool.py +++ /dev/null @@ -1,107 +0,0 @@ -import pytest -import numpy -from numpy import pi -from numpy.testing import assert_allclose - -from masque import Pather, Library, Pattern, Port -from masque.builder.tools import AutoTool - - -def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: - pat = Pattern() - pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((length, 0), pi, ptype=ptype) - return pat - -def make_bend(radius: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern: - pat = Pattern() - # Rectangular approximation of a 90 degree bend. - if clockwise: - pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width) - pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype) - else: - pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width) - pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius) - pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype) - return pat - -@pytest.fixture -def multi_bend_tool() -> tuple[AutoTool, Library]: - lib = Library() - - lib["b1"] = make_bend(2, ptype="wire") - b1_abs = lib.abstract("b1") - lib["b2"] = make_bend(5, ptype="wire") - b2_abs = lib.abstract("b2") - - tool = ( - AutoTool() - .add_straight(make_straight, "wire", "A", length_range=(0, 10)) - .add_straight(lambda length: make_straight(length, width=4), "wire", "A", length_range=(10, 1e8)) - .add_bend(b1_abs, "A", "B", clockwise=True, mirror=True) - .add_bend(b2_abs, "A", "B", clockwise=True, mirror=True) - ) - return tool, lib - -def test_autotool_uturn() -> None: - from masque.builder.tools import AutoTool - lib = Library() - - def make_straight(length: float) -> Pattern: - pat = Pattern() - pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000) - pat.ports['in'] = Port((0, 0), 0) - pat.ports['out'] = Port((length, 0), pi) - return pat - - bend_pat = Pattern() - bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)]) - bend_pat.ports['in'] = Port((0, 0), 0) - bend_pat.ports['out'] = Port((500, -500), pi/2) - lib['bend'] = bend_pat - - tool = ( - AutoTool() - .add_straight(make_straight, 'wire', 'in') - .add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True) - ) - - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), 0) - - p.at('A').uturn(offset=-2000, length=1000) - - # U-turn plan output is transformed into the port extension frame. - assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi) - -def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None: - tool, lib = multi_bend_tool - rp = Pather(lib, tools=tool) - rp.ports["A"] = Port((0,0), 0, ptype="wire") - - rp.jog("A", 10, length=20) - - assert_allclose(rp.ports["A"].offset, [-20, -10]) - assert_allclose(rp.ports["A"].rotation, 0) - - rp.render() - assert len(rp.pattern.refs) > 0 - -def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None: - tool, lib = multi_bend_tool - - p = Pather(lib, tools=tool) - p.ports["A"] = Port((0,0), 0, ptype="wire") - - p.uturn("A", 10, length=5) - - # Fallback U-turn uses two CCW bends: (7, 2) then (8, 2) in local tool frames, - # yielding a global endpoint at (-5, -10). - assert_allclose(p.ports["A"].offset, [-5, -10]) - assert_allclose(p.ports["A"].rotation, pi) diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py deleted file mode 100644 index 58ee5f6..0000000 --- a/masque/test/test_pather_constraints.py +++ /dev/null @@ -1,1193 +0,0 @@ -from collections.abc import Sequence -from typing import Any, Literal, Never -from types import FunctionType -import inspect -import traceback - -import pytest -import numpy -from numpy import pi - -from masque import ( - MinimumStatus, Pather, PortPather, Library, Port, RouteError, RouteFailurePolicy, - ) -from masque.builder.planner import RoutePortContext, RoutingPlanner -from masque.builder.planner.planner import NoLegalRouteError -from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer -from masque.error import BuildError -from masque.library import ILibrary - - -class PlanningOnlyTool(Tool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[Any, ...]: - _ = kind, in_ptype, out_ptype, kwargs - return () - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - tree, pat = Library.mktree('planning_only_tool') - pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk') - return tree - - -def _external_failing_ccw(pather: Pather) -> None: - pather.ccw('A', 10, out_ptype='optical') - - -def _external_failing_trace_to(pather: Pather) -> None: - pather.trace_to('A', True, length=10, out_ptype='optical') - - -def _external_failing_portpather_ccw(pather: Pather) -> None: - pather.at('A').ccw(10, out_ptype='optical') - - -class FirstPortOnlyTraceTool(PlanningOnlyTool): - def __init__(self) -> None: - self.render_calls = 0 - - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[StraightOffer | BendOffer, ...]: - _ = out_ptype - if in_ptype != 'wire': - return () - - if kind == 'straight': - def endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype='wire') - - def commit(length: float) -> dict[str, float | str]: - return {'kind': 'straight', 'length': length} - - return (StraightOffer( - in_ptype='wire', - out_ptype='wire', - endpoint_planner=endpoint, - commit_planner=commit, - ),) - - if kind == 'bend': - ccw = bool(kwargs['ccw']) - - def endpoint(length: float) -> Port: - return Port( - (length, 1 if ccw else -1), - rotation=-pi / 2 if ccw else pi / 2, - ptype='wire', - ) - - def commit(length: float) -> dict[str, float | str]: - return {'kind': 'bend', 'length': length} - - return (BendOffer( - in_ptype='wire', - out_ptype='wire', - ccw=ccw, - length_domain=(1, numpy.inf), - endpoint_planner=endpoint, - commit_planner=commit, - ),) - - return () - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs: Any, - ) -> Library: - _ = batch, port_names, kwargs - self.render_calls += 1 - tree, pat = Library.mktree('trace') - pat.add_port_pair(names=port_names, ptype='wire') - return tree - - -class CountingPathTool(PathTool): - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.render_calls = 0 - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs: Any, - ) -> ILibrary: - self.render_calls += 1 - return super().render(batch, port_names=port_names, **kwargs) - - -class RequestCountingTool(PlanningOnlyTool): - def __init__(self) -> None: - self.offer_calls = 0 - - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[Any, ...]: - self.offer_calls += 1 - return super().primitive_offers( - kind, - in_ptype=in_ptype, - out_ptype=out_ptype, - **kwargs, - ) - - -class PreferredMinimumTool(PlanningOnlyTool): - def __init__(self) -> None: - self.commit_calls = 0 - self.diagnostic_context_kwargs: list[Any] = [] - - def _commit(self, parameter: float) -> dict[str, float]: - self.commit_calls += 1 - return {'parameter': parameter} - - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[Any, ...]: - _ = out_ptype - self.diagnostic_context_kwargs.append(kwargs.get('diagnostic_context')) - if kind == 'bend': - ccw = bool(kwargs['ccw']) - rotation = -pi / 2 if ccw else pi / 2 - jog = 1 if ccw else -1 - return ( - BendOffer( - in_ptype=in_ptype, - out_ptype='wide', - cost=100, - ccw=ccw, - length_domain=(2, 2), - endpoint_planner=lambda _length: Port((2, jog), rotation, ptype='wide'), - commit_planner=self._commit, - ), - BendOffer( - in_ptype=in_ptype, - out_ptype='wide', - ccw=ccw, - length_domain=(5, 5), - endpoint_planner=lambda _length: Port((5, jog), rotation, ptype='wide'), - commit_planner=self._commit, - ), - ) - if kind == 'u': - return (UOffer( - in_ptype=in_ptype, - out_ptype='wide', - jog_domain=(4, 4), - endpoint_planner=lambda _jog: Port((5, 4), 0, ptype='wide'), - commit_planner=self._commit, - ),) - return () - - -def test_route_error_reports_preferred_minimum_and_request_details() -> None: - tool = PreferredMinimumTool() - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool, - render='deferred', - ) - - with pytest.raises(RouteError) as exc_info: - p.ccw('A', 1, out_ptype='wide', tool_options={'diagnostic_context': 'tool-value'}) - - details = exc_info.value.details - assert isinstance(exc_info.value, BuildError) - assert details.operation == 'trace_to' - assert details.portspec == 'A' - assert details.in_ptype == 'wire' - assert details.out_ptype == 'wide' - assert details.request == { - 'ccw': True, - 'length': 1, - 'out_ptype': 'wide', - 'tool_options': {'diagnostic_context': 'tool-value'}, - } - assert details.resolved_length == 1 - assert details.resolved_jog is None - # The length-2 route exists, but normal cost ranking prefers length 5. - assert details.minimum_length == 5 - assert details.minimum_status is MinimumStatus.FOUND - assert exc_info.value.policy is RouteFailurePolicy.RECOVERABLE - assert details.minimum_cause is None - assert 'preferred_minimum_length: 5' in str(exc_info.value) - assert tool.commit_calls == 0 - assert tool.diagnostic_context_kwargs - assert set(tool.diagnostic_context_kwargs) == {'tool-value'} - assert not p._paths - with pytest.raises(TypeError): - details.request['new'] = 'value' # type: ignore[index] - - -def test_route_error_reports_uturn_preferred_minimum() -> None: - tool = PreferredMinimumTool() - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool, - render='deferred', - ) - - with pytest.raises(RouteError) as exc_info: - p.uturn('A', 4, length=1, out_ptype='wide') - - details = exc_info.value.details - assert details.operation == 'uturn' - assert details.resolved_length == 1 - assert details.resolved_jog == 4 - assert details.minimum_length == 5 - assert tool.commit_calls == 0 - - -def test_route_error_reports_no_route_at_any_length() -> None: - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=PathTool(layer='M1', width=2, ptype='wire'), - render='deferred', - ) - - with pytest.raises(RouteError) as exc_info: - p.ccw('A', 10, out_ptype='optical') - - details = exc_info.value.details - assert details.minimum_status is MinimumStatus.NO_ROUTE - assert details.minimum_length is None - assert details.minimum_cause is not None - assert 'no legal route exists at any length' in str(exc_info.value) - - -def test_route_error_reports_external_pather_call_site() -> None: - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=PathTool(layer='M1', width=2, ptype='wire'), - render='deferred', - ) - external_call = FunctionType( - _external_failing_ccw.__code__.replace(co_filename='/project/user_layout.py'), - globals(), - ) - - with pytest.raises(RouteError) as exc_info: - external_call(p) - - message = str(exc_info.value) - assert 'Stack trace:' not in message - - route_error = exc_info.value - native_frames = traceback.extract_tb(route_error.__traceback__) - assert native_frames - assert [frame.name for frame in native_frames if frame.filename.endswith('/builder/pather.py')] == [ - 'ccw', - 'bend', - ] - assert not any('/builder/planner/' in frame.filename for frame in native_frames) - - native = ''.join(traceback.format_exception(route_error)) - assert native.count('/project/user_layout.py') == 1 - assert '\nStack trace:\n' not in native - assert route_error.__cause__ is not None - - assert isinstance(route_error._call_stack, tuple) - assert sum(frame.filename == '/project/user_layout.py' for frame in route_error._call_stack) == 1 - assert any(frame.filename.endswith('/builder/pather.py') for frame in route_error._call_stack) - assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack) - - -def test_route_error_direct_trace_to_starts_native_traceback_at_caller() -> None: - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=PathTool(layer='M1', width=2, ptype='wire'), - render='deferred', - ) - external_call = FunctionType( - _external_failing_trace_to.__code__.replace(co_filename='/project/direct_route.py'), - globals(), - ) - - with pytest.raises(RouteError) as exc_info: - external_call(p) - - frames = traceback.extract_tb(exc_info.value.__traceback__) - assert sum(frame.filename == '/project/direct_route.py' for frame in frames) == 1 - assert not any(frame.filename.endswith('/builder/pather.py') for frame in frames) - assert not any('/builder/planner/' in frame.filename for frame in frames) - - -def test_route_error_portpather_keeps_only_forwarding_frames() -> None: - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=PathTool(layer='M1', width=2, ptype='wire'), - render='deferred', - ) - external_call = FunctionType( - _external_failing_portpather_ccw.__code__.replace(co_filename='/project/selected_route.py'), - globals(), - ) - - with pytest.raises(RouteError) as exc_info: - external_call(p) - - frames = traceback.extract_tb(exc_info.value.__traceback__) - assert sum(frame.filename == '/project/selected_route.py' for frame in frames) == 1 - assert [frame.name for frame in frames if frame.filename.endswith('/builder/pather.py')] == [ - 'ccw', - 'bend', - 'trace_to', - ] - assert not any('/builder/planner/' in frame.filename for frame in frames) - - -def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None: - planner = RoutingPlanner() - context = RoutePortContext( - 'A', - Port((0, 0), rotation=0, ptype='wire'), - PlanningOnlyTool(), - ) - solve_count = 0 - - def solver_for_request(_request: Any) -> Any: - nonlocal solve_count - solve_count += 1 - current_solve = solve_count - - class FailingSolver: - def solve(self) -> Never: - if current_solve == 1: - raise NoLegalRouteError('requested length has no route') - raise RuntimeError('minimum diagnosis failed') - - return FailingSolver() - - monkeypatch.setattr(planner, 'solver_for_request', solver_for_request) - - with pytest.raises(RouteError) as exc_info: - planner.plan_leg( - 'bend', - context, - 'trace_to', - {'ccw': True, 'length': 1}, - length=1, - ccw=True, - ) - - details = exc_info.value.details - assert details.minimum_status is MinimumStatus.FAILED - assert details.minimum_length is None - assert details.minimum_cause == 'minimum diagnosis failed' - assert 'minimum-length calculation failed' in str(exc_info.value) - - -@pytest.mark.parametrize('length', [-1, numpy.nan, numpy.inf]) -def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length: float) -> None: - tool = RequestCountingTool() - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool, - render='deferred', - ).set_dead() - - with pytest.raises(RouteError) as exc_info: - p.ccw('A', length) - - details = exc_info.value.details - assert details.minimum_status is MinimumStatus.NOT_EVALUATED - assert exc_info.value.policy is RouteFailurePolicy.FATAL - assert details.minimum_length is None - assert tool.offer_calls == 0 - assert numpy.allclose(p.ports['A'].offset, (0, 0)) - - -def test_negative_position_length_reports_bound_without_offer_query() -> None: - tool = RequestCountingTool() - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool, - render='deferred', - ) - - with pytest.raises(RouteError) as exc_info: - p.ccw('A', x=1) - - details = exc_info.value.details - assert details.request == {'ccw': True, 'x': 1} - assert details.resolved_length == -1 - assert details.minimum_status is MinimumStatus.NOT_EVALUATED - assert tool.offer_calls == 0 - - -def test_negative_bundle_length_reports_failing_port_and_bound() -> None: - tool = RequestCountingTool() - p = Pather( - Library(), - ports={ - 'A': Port((0, 0), rotation=0, ptype='wire'), - 'B': Port((0, 4), rotation=0, ptype='wire'), - }, - tools=tool, - render='deferred', - ) - - with pytest.raises(RouteError) as exc_info: - p.trace(['A', 'B'], True, emax=0, spacing=2) - - details = exc_info.value.details - assert details.portspec == 'A' - assert details.request == {'ccw': True, 'emax': 0, 'spacing': 2} - assert details.resolved_length == -2 - assert details.minimum_status is MinimumStatus.NOT_EVALUATED - assert tool.offer_calls == 0 - - -def test_negative_each_length_reports_failing_port_without_offer_query() -> None: - tool = RequestCountingTool() - p = Pather( - Library(), - ports={ - 'A': Port((0, 0), rotation=0, ptype='wire'), - 'B': Port((0, 4), rotation=0, ptype='wire'), - }, - tools=tool, - render='deferred', - ) - - with pytest.raises(RouteError) as exc_info: - p.trace(['A', 'B'], None, each=-2) - - details = exc_info.value.details - assert details.portspec == 'A' - assert details.request == {'ccw': None, 'each': -2} - assert details.resolved_length == -2 - assert details.minimum_status is MinimumStatus.NOT_EVALUATED - assert tool.offer_calls == 0 - - -@pytest.mark.parametrize( - 'operation', - [ - lambda p: p.trace([], None, length=1), - lambda p: p.trace_to([], None, length=1), - lambda p: p.jog([], 2, length=3), - lambda p: p.uturn([], 2, length=3), - ], - ids=['trace', 'trace_to', 'jog', 'uturn'], - ) -def test_pather_rejects_empty_route_selection_before_planning(operation: Any) -> None: - tool = RequestCountingTool() - p = Pather(Library(), tools=tool, render='deferred') - - with pytest.raises(BuildError, match='at least one port'): - operation(p) - - assert tool.offer_calls == 0 - assert not p.ports - assert not p._paths - - -@pytest.mark.parametrize( - 'operation', - [ - lambda p: p.trace(['A', 'A'], None, each=1), - lambda p: p.trace_to(['A', 'A'], None, xmin=-10), - lambda p: p.jog(['A', 'A'], 2, length=3, spacing=1), - lambda p: p.uturn(['A', 'A'], 2, length=3, spacing=1), - lambda p: p.at(['A', 'A']).straight(1), - ], - ids=['trace', 'trace_to', 'jog', 'uturn', 'port_pather'], - ) -def test_pather_rejects_duplicate_route_selection_before_planning(operation: Any) -> None: - tool = RequestCountingTool() - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool, - render='deferred', - ) - - with pytest.raises(BuildError, match=r"duplicates: \['A'\]"): - operation(p) - - assert tool.offer_calls == 0 - assert numpy.allclose(p.ports['A'].offset, (0, 0)) - assert not p._paths - - -def test_pather_jog_failed_two_bend_route_is_atomic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool, render='immediate') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(RouteError, match='S-bend') as exc_info: - p.jog('A', 1.5, length=1.5) - - assert exc_info.value.details.minimum_length == 2 - assert exc_info.value.details.resolved_jog == 1.5 - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation == 0 - assert len(p._paths['A']) == 0 - -def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool, render='immediate') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.jog('A', 1.5, length=5) - - assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5)) - assert p.pattern.ports['A'].rotation == 0 - assert len(p._paths['A']) == 0 - -def test_pather_immediate_render_batches_multi_step_selected_route_once() -> None: - lib = Library() - tool = CountingPathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool, render='immediate') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.jog('A', 4, length=10) - - assert tool.render_calls == 1 - assert len(p._paths['A']) == 0 - assert p.pattern.has_shapes() - -def test_pather_jog_length_solved_from_single_position_bound() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.jog('A', 2, x=-6) - assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - - q = Pather(Library(), tools=tool) - q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - q.jog('A', 2, p=-6) - assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2)) - - -def test_pather_positional_bound_requires_port_rotation() -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=None, ptype='wire') - - with pytest.raises(BuildError, match='Ports must have rotation'): - p.trace_to('A', None, x=-5) - - -def test_pather_positional_bound_rejects_non_manhattan_rotation() -> None: - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(Library(), tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire') - - with pytest.raises(BuildError, match='nearly Manhattan'): - p.trace_to('A', None, p=-5) - - -def test_pather_positional_bound_accepts_nearly_manhattan_rotation() -> None: - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(Library(), tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=1e-10, ptype='wire') - - p.trace_to('A', None, x=-5) - assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -5e-10), atol=1e-8) - - -def test_pather_bundle_position_bound_rejects_non_manhattan_rotation() -> None: - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(Library(), tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire') - p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire') - - with pytest.raises(BuildError, match='nearly Manhattan'): - p.trace(['A', 'B'], None, pmin=-5) - - -def test_pather_bundle_extension_bound_allows_non_manhattan_rotation() -> None: - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(Library(), tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire') - p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire') - - p.trace(['A', 'B'], None, emin=5) - assert len(p._paths['A']) == 1 - assert len(p._paths['B']) == 1 - - -def test_pather_jog_omitted_length_uses_minimum_length_route() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.jog('A', 2) - - assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -2)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert [step.opcode for step in p._paths['A']] == ['L', 'L', 'L'] - - with pytest.raises(BuildError, match='exactly one positional bound'): - p.jog('A', 2, x=-6, p=-6) - -def test_pather_trace_omitted_length_uses_minimum_offer() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.trace('A', None) - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - - p.trace('A', True) - assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -1)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2) - -def test_pather_trace_to_without_bound_uses_single_port_trace_minimum() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.trace_to('A', False) - - assert numpy.allclose(p.pattern.ports['A'].offset, (-1, 1)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 3 * pi / 2) - -def test_pather_trace_to_rejects_conflicting_position_bounds() -> None: - tool = PathTool(layer='M1', width=1, ptype='wire') - - for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}): - p = Pather(Library(), tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='exactly one positional bound'): - p.trace_to('A', None, **kwargs) - - p = Pather(Library(), tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='length cannot be combined'): - p.trace_to('A', None, x=-5, length=3) - -def test_pather_trace_rejects_length_with_bundle_bound() -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='length cannot be combined'): - p.trace('A', None, length=5, xmin=-100) - -@pytest.mark.parametrize('kwargs', [{'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}]) # noqa: PT007 -def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='exactly one bundle bound'): - p.trace(['A', 'B'], None, **kwargs) - - -def test_planner_constrained_bend_requires_jog() -> None: - tool = PathTool(layer='M1', width=1, ptype='wire') - context = RoutePortContext('A', Port((0, 0), rotation=0, ptype='wire'), tool) - - with pytest.raises(BuildError, match='trace route requires a jog constraint'): - RoutingPlanner().plan_leg('bend', context, length=5, constrain_jog=True) - - -def test_pather_trace_each_plans_all_ports_before_mutation() -> None: - tool = FirstPortOnlyTraceTool() - p = Pather(Library(), tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((-2, 5), rotation=0, ptype='blocked') - - with pytest.raises(BuildError, match='No legal primitive offer for trace'): - p.trace(['A', 'B'], None, each=5) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.allclose(p.pattern.ports['B'].offset, (-2, 5)) - assert p.pattern.ports['A'].ptype == 'wire' - assert p.pattern.ports['B'].ptype == 'blocked' - assert len(p._paths['A']) == 0 - assert len(p._paths['B']) == 0 - -def test_pather_bundle_trace_plans_all_ports_before_mutation_or_render() -> None: - tool = FirstPortOnlyTraceTool() - p = Pather(Library(), tools=tool, render='immediate') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='blocked') - - with pytest.raises(BuildError, match='No legal primitive offer for trace'): - p.trace(['A', 'B'], True, xmin=-10, spacing=2) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4)) - assert p.pattern.ports['A'].rotation == 0 - assert p.pattern.ports['B'].rotation == 0 - assert len(p._paths['A']) == 0 - assert len(p._paths['B']) == 0 - assert tool.render_calls == 0 - assert not p.pattern.has_shapes() - - -def test_pather_route_commit_failure_is_atomic_for_multi_port_trace() -> None: - class CommitFailureTool(PlanningOnlyTool): - def __init__(self) -> None: - self.committed: list[str | None] = [] - self.render_calls = 0 - - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[StraightOffer, ...]: - _ = out_ptype, kwargs - if kind != 'straight': - return () - - def endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype=in_ptype) - - def commit(length: float) -> dict[str, float | str | None]: - _ = length - self.committed.append(in_ptype) - if in_ptype == 'bad': - raise BuildError('selected commit failed') - return {'ptype': in_ptype, 'length': length} - - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=in_ptype, - endpoint_planner=endpoint, - commit_planner=commit, - ),) - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs: Any, - ) -> Library: - _ = batch, port_names, kwargs - self.render_calls += 1 - tree, pat = Library.mktree('commit_failure_tool') - pat.add_port_pair(names=port_names) - return tree - - tool = CommitFailureTool() - p = Pather(Library(), tools=tool, render='immediate') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='bad') - - with pytest.raises(BuildError, match='selected commit failed'): - p.trace(['A', 'B'], None, each=5) - - assert tool.committed == ['wire', 'bad'] - assert tool.render_calls == 0 - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4)) - assert p.pattern.ports['A'].ptype == 'wire' - assert p.pattern.ports['B'].ptype == 'bad' - assert len(p._paths['A']) == 0 - assert len(p._paths['B']) == 0 - assert not p.pattern.has_shapes() - -def test_pather_jog_rejects_length_with_position_bound() -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='length cannot be combined'): - p.jog('A', 2, length=5, x=-999) - -@pytest.mark.parametrize('kwargs', [{'x': -999}, {'xmin': -10}]) # noqa: PT007 -def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None: - p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(TypeError, match='unexpected keyword argument'): - p.uturn('A', 4, **kwargs) - -def test_pather_uturn_omitted_length_uses_minimum_length_route() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.uturn('A', 4) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi) - -def test_pather_uturn_explicit_zero_length_preserves_old_shape() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.uturn('A', 4, length=0) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi) - -def test_pather_uturn_does_not_use_direct_planl_fallback() -> None: - class PlanLOnlyTool(PlanningOnlyTool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> Never: - del kind, in_ptype, out_ptype, kwargs - raise NotImplementedError - - def legacy_planL( - self, - ccw: object, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **_kwargs: Any, - ) -> tuple[Port, dict[str, object]]: - del out_ptype - if ccw is None: - rotation = pi - jog = 0 - elif bool(ccw): - rotation = -pi / 2 - jog = 1 - else: - rotation = pi / 2 - jog = -1 - return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} - - p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='No legal primitive offer for omitted-length U-turn'): - p.uturn('A', 5) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p._paths['A']) == 0 - - with pytest.raises((BuildError, NotImplementedError)): - p.uturn('A', 5, length=10) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p._paths['A']) == 0 - -def test_pather_su_topology_rejects_out_ptype_sensitive_planl_jog() -> None: - class OutPtypeSensitiveTool(PlanningOnlyTool): - def legacy_planL( - self, - ccw: object, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **_kwargs: Any, - ) -> tuple[Port, dict[str, object]]: - radius = 1 if out_ptype is None else 2 - if ccw is None: - rotation = pi - jog = 0 - elif bool(ccw): - rotation = -pi / 2 - jog = radius - else: - rotation = pi / 2 - jog = -radius - ptype = out_ptype or in_ptype or 'wire' - return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} - - p = Pather(Library(), tools=OutPtypeSensitiveTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises((BuildError, NotImplementedError)): - p.jog('A', 5, length=10, out_ptype='wide') - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p._paths['A']) == 0 - -def test_pather_two_l_planl_only_uturn_is_not_supported() -> None: - class PlanLOnlyTool(PlanningOnlyTool): - def legacy_planL( - self, - ccw: object, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **_kwargs: Any, - ) -> tuple[Port, dict[str, object]]: - del out_ptype - if ccw is None: - rotation = pi - jog = 0 - elif bool(ccw): - rotation = -pi / 2 - jog = 1 - else: - rotation = pi / 2 - jog = -1 - return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} - - p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises((BuildError, NotImplementedError)): - p.uturn('A', 5, length=10) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p._paths['A']) == 0 - -def test_pather_two_l_planl_only_jog_is_not_supported() -> None: - class PlanLOnlyTool(PlanningOnlyTool): - def legacy_planL( - self, - ccw: object, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **_kwargs: Any, - ) -> tuple[Port, dict[str, object]]: - del out_ptype - if ccw is None: - rotation = pi - jog = 0 - elif bool(ccw): - rotation = -pi / 2 - jog = 1 - else: - rotation = pi / 2 - jog = -1 - return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} - - p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises((BuildError, NotImplementedError)): - p.jog('A', 5, length=10, out_ptype='unk') - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].ptype == 'wire' - assert len(p._paths['A']) == 0 - -def test_pather_su_topology_rejects_out_ptype_sensitive_planl_uturn() -> None: - class OutPtypeSensitiveTool(PlanningOnlyTool): - def legacy_planL( - self, - ccw: object, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **_kwargs: Any, - ) -> tuple[Port, dict[str, object]]: - radius = 1 if out_ptype is None else 2 - if ccw is None: - rotation = pi - jog = 0 - elif bool(ccw): - rotation = -pi / 2 - jog = radius - else: - rotation = pi / 2 - jog = -radius - ptype = out_ptype or in_ptype or 'wire' - return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} - - p = Pather(Library(), tools=OutPtypeSensitiveTool()) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises((BuildError, NotImplementedError)): - p.uturn('A', 5, length=10, out_ptype='wide') - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p._paths['A']) == 0 - -def test_pather_uturn_failed_two_bend_route_is_atomic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=2, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(RouteError, match='U-turn') as exc_info: - p.uturn('A', 1.5, length=0) - - assert exc_info.value.details.minimum_status is MinimumStatus.NO_ROUTE - assert exc_info.value.details.minimum_length is None - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].rotation == 0 - assert len(p._paths['A']) == 0 - - -@pytest.mark.parametrize('route_type', [Pather, PortPather]) -@pytest.mark.parametrize('name', ['trace', 'trace_to', 'straight', 'bend', 'ccw', 'cw', 'jog', 'uturn', 'trace_into']) -def test_routing_entry_points_have_no_catch_all_kwargs(route_type: type, name: str) -> None: - parameters = inspect.signature(getattr(route_type, name)).parameters.values() - assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in parameters) - - -@pytest.mark.parametrize('route_type', [Pather, PortPather]) -@pytest.mark.parametrize('name', ['trace', 'trace_to', 'straight', 'bend', 'ccw', 'cw', 'jog', 'uturn', 'trace_into']) -def test_routing_entry_points_use_generic_plan_options(route_type: type, name: str) -> None: - parameters = inspect.signature(getattr(route_type, name)).parameters - - assert 'plan_options' in parameters - assert 'strategy' not in parameters - assert 'bend_policy' not in parameters - - -def test_routing_typo_fails_before_tool_lookup() -> None: - tool = RequestCountingTool() - p = Pather(Library(), tools=tool, render='deferred') - - with pytest.raises(TypeError, match='unexpected keyword argument'): - p.straight('A', lenght=10) # type: ignore[call-arg] - - assert tool.offer_calls == 0 - - -@pytest.mark.parametrize('tool_options', [{1: 'bad'}, {'ccw': False}, {'out_ptype': 'bad'}]) -def test_tool_options_reject_invalid_or_reserved_keys(tool_options: dict[Any, Any]) -> None: - tool = RequestCountingTool() - p = Pather(Library(), tools=tool, render='deferred') - - with pytest.raises(BuildError, match='tool_options'): - p.trace('A', None, tool_options=tool_options) - - assert tool.offer_calls == 0 - - -@pytest.mark.parametrize('plan_options', [7, {1: 'bad'}]) -def test_plan_options_reject_invalid_mapping_shape(plan_options: Any) -> None: - tool = RequestCountingTool() - p = Pather(Library(), tools=tool, render='deferred') - - with pytest.raises(BuildError, match='plan_options'): - p.trace('A', None, plan_options=plan_options) - - assert tool.offer_calls == 0 - - -@pytest.mark.parametrize( - 'plan_options', - [ - {'bend_policy': 'flexible'}, - {'unknown': True}, - ], -) -def test_default_planner_rejects_unsupported_plan_options_before_tool_lookup( - plan_options: dict[str, Any], - ) -> None: - tool = RequestCountingTool() - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool, - render='deferred', - ) - - with pytest.raises(BuildError, match='unsupported keys'): - p.trace('A', None, length=1, plan_options=plan_options) - - assert tool.offer_calls == 0 - - -@pytest.mark.parametrize( - 'operation', - [ - lambda p: p.jog('A', numpy.nan, length=1), - lambda p: p.uturn('A', numpy.inf, length=1), - lambda p: p.straight('A', x=numpy.nan), - ], - ids=['jog-offset', 'uturn-offset', 'position-bound'], - ) -def test_nonfinite_route_geometry_fails_before_offer_query(operation: Any) -> None: - tool = RequestCountingTool() - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool, - render='deferred', - ) - - with pytest.raises(BuildError, match='finite'): - operation(p) - - assert tool.offer_calls == 0 - - -@pytest.mark.parametrize('spacing', [-1, numpy.nan, numpy.inf]) -def test_invalid_bundle_spacing_fails_before_offer_query(spacing: float) -> None: - tool = RequestCountingTool() - p = Pather( - Library(), - ports={ - 'A': Port((0, 0), rotation=0, ptype='wire'), - 'B': Port((0, 4), rotation=0, ptype='wire'), - }, - tools=tool, - render='deferred', - ) - - with pytest.raises(BuildError, match='spacing'): - p.trace(['A', 'B'], True, xmin=-10, spacing=spacing) - - assert tool.offer_calls == 0 diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py deleted file mode 100644 index af068b8..0000000 --- a/masque/test/test_pather_core.py +++ /dev/null @@ -1,614 +0,0 @@ -from typing import Any - -import pytest -import numpy -from numpy import pi -from numpy.testing import assert_allclose, assert_equal - -from masque import Pather, Library, Pattern, Port, RouteError -from masque.builder import PathTool, PrimitiveOffer, StraightOffer -from masque.builder.planner import RoutingPlanner -from masque.error import BuildError, PortError - - -@pytest.fixture -def pather_setup() -> tuple[Pather, PathTool, Library]: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool) - # Port rotation points into the device, so path extension moves in the opposite direction. - p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - return p, tool, lib - - -def test_builder_tool_symbol_exports() -> None: - import masque - import masque.builder - import masque.builder.tools as builder_tools - - package_root_exports = ( - 'RenderStep', - 'PortPather', - ) - builder_tool_names = ( - 'PrimitiveOffer', - 'StraightOffer', - 'BendOffer', - 'SOffer', - 'UOffer', - ) - internal_names = ( - 'PTypeMatch', - 'canonicalize_domain_value', - 'ptype_match', - 'ptypes_compatible', - ) - - for name in (*package_root_exports, *builder_tool_names): - assert hasattr(masque.builder, name) - - for name in package_root_exports: - assert hasattr(masque, name) - - for name in (*builder_tool_names, *internal_names): - assert not hasattr(masque, name) - - for name in internal_names: - assert not hasattr(masque.builder, name) - - for name in builder_tool_names: - assert hasattr(masque.builder, name) - assert hasattr(builder_tools, name) - - -def test_pather_pending_render_steps_are_private() -> None: - p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1)) - - assert not hasattr(p, 'paths') - assert hasattr(p, '_paths') - - -def test_pather_accepts_and_reuses_planner_instance() -> None: - class CountingPlanner(RoutingPlanner): - def __init__(self) -> None: - self.trace_to_calls = 0 - - def plan_trace_to_route(self, *args: Any, **kwargs: Any) -> Any: - self.trace_to_calls += 1 - return super().plan_trace_to_route(*args, **kwargs) - - planner = CountingPlanner() - p = Pather( - Library(), - tools=PathTool(layer=(1, 0), width=1), - render='deferred', - planner=planner, - ) - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 1) - p.straight('A', 2) - - assert p.planner is planner - assert planner.trace_to_calls == 2 - - -def test_pather_copies_and_forwards_plan_options_to_planner() -> None: - class RecordingPlanner(RoutingPlanner): - def __init__(self) -> None: - super().__init__() - self.received_plan_options: Any = None - - def plan_trace_to_route( - self, - *args: Any, - plan_options: Any = None, - **kwargs: Any, - ) -> Any: - self.received_plan_options = plan_options - return super().plan_trace_to_route( - *args, - plan_options=plan_options, - **kwargs, - ) - - planner = RecordingPlanner() - p = Pather( - Library(), - tools=PathTool(layer=(1, 0), width=1), - render='deferred', - planner=planner, - ) - p.ports['A'] = Port((0, 0), rotation=0) - supplied = {'strategy': 'turn_first'} - - p.straight('A', 1, plan_options=supplied) - - assert planner.received_plan_options == supplied - assert planner.received_plan_options is not supplied - - -def test_port_tool_policy_and_portpather_selection_follow_names() -> None: - default_tool = PathTool(layer=(1, 0), width=1, ptype='wire') - named_tool = PathTool(layer=(2, 0), width=1, ptype='wire') - p = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools={None: default_tool, 'A': named_tool}, - render='deferred', - ) - selected = p.at('A') - - p.rename_ports({'A': 'B'}) - - assert selected.ports == ['A'] - assert p.tools['A'] is named_tool - assert 'B' not in p.tools - p.straight('B', 1) - assert p._paths['B'][0].tool is default_tool - - p.mkport('A', Port((10, 0), rotation=0, ptype='wire')) - p.straight('A', 1) - assert p._paths['A'][0].tool is named_tool - - -def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - p.straight("start", 10) - - assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) - -def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - p.cw("start", 10) - - assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10) - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) - -def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - p.straight("start", y=-50) - assert_equal(p.ports["start"].offset, [0, -50]) - -def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - p.ports["A"] = Port((0, 0), pi / 2, ptype="wire") - p.ports["B"] = Port((10, 0), pi / 2, ptype="wire") - - p.straight(["A", "B"], ymin=-20) - assert_equal(p.ports["A"].offset, [0, -20]) - assert_equal(p.ports["B"].offset, [10, -20]) - -def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None: - p, tool, lib = pather_setup - p.at("start").straight(10).ccw(10) - assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10) - assert p.ports["start"].rotation is not None - assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) - -def test_pather_dead_ports() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=1) - p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool) - p.set_dead() - - with pytest.raises(RouteError, match='finite and nonnegative'): - p.straight("in", -10) - - assert_allclose(p.ports["in"].offset, [0, 0], atol=1e-10) - - p.straight("in", 20) - assert_allclose(p.ports["in"].offset, [-20, 0], atol=1e-10) - - assert not p.pattern.has_shapes() - -def test_pather_trace_basic() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - # Routing extends opposite the port's inward-facing rotation. - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').trace(None, 5000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) - - p.at('A').trace(True, 5000) # CCW bend - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2) - -def test_pather_trace_to() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').trace_to(None, x=-10000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) - - p.at('A').trace_to(None, p=-20000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0)) - -def test_pather_bundle_trace() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 2000), rotation=0) - - p.at(['A', 'B']).straight(xmin=-10000) - assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) - assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) - - p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) - # The lower port is on the inner bend, so `xmin` applies to that route. - assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000) - assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) - -def test_portpather_default_spacing_matches_explicit_spacing() -> None: - lib_default = Library() - tool_default = PathTool(layer='M1', width=1000) - p_default = Pather(lib_default, tools=tool_default) - p_default.pattern.ports['A'] = Port((0, 0), rotation=0) - p_default.pattern.ports['B'] = Port((0, 2000), rotation=0) - - lib_explicit = Library() - tool_explicit = PathTool(layer='M1', width=1000) - p_explicit = Pather(lib_explicit, tools=tool_explicit) - p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) - p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) - - p_default.at(['A', 'B'], spacing=2000).ccw(xmin=-20000) - p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) - - assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset) - assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset) - -def test_portpather_default_spacing_reused_and_overridden() -> None: - p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000)) - p_default.pattern.ports['A'] = Port((0, 0), rotation=0) - p_default.pattern.ports['B'] = Port((0, 2000), rotation=0) - - p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000)) - p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) - p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) - - pp_default = p_default.at(['A', 'B'], spacing=2000) - pp_default.ccw(xmin=-20000) - pp_default.cw(emin=1000) - p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000).cw(emin=1000, spacing=2000) - - assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset) - assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset) - - p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000)) - p_override.pattern.ports['A'] = Port((0, 0), rotation=0) - p_override.pattern.ports['B'] = Port((0, 2000), rotation=0) - - p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000)) - p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) - p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) - - p_override.at(['A', 'B'], spacing=2000).ccw(xmin=-20000, spacing=3000) - p_override_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=3000) - - assert_allclose(p_override.pattern.ports['A'].offset, p_override_explicit.pattern.ports['A'].offset) - assert_allclose(p_override.pattern.ports['B'].offset, p_override_explicit.pattern.ports['B'].offset) - -def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 2000), rotation=0) - - p.at(['A', 'B'], spacing=2000).straight(xmin=-10000) - - assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) - assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) - -def test_portpather_default_spacing_vector_revalidated_after_selection_change() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 2000), rotation=0) - p.pattern.ports['C'] = Port((0, 4000), rotation=0) - - pp = p.at(['A', 'B', 'C'], spacing=[2000, 3000]) - pp.deselect('C') - - with pytest.raises(BuildError, match='spacing must be scalar or have length 1'): - pp.ccw(xmin=-20000) - -def test_pather_each_bound() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((-1000, 2000), rotation=0) - - p.at(['A', 'B']).trace(None, each=5000) - assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) - assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000)) - -def test_selection_management() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 0), rotation=0) - - pp = p.at('A') - assert pp.ports == ['A'] - - pp.select('B') - assert pp.ports == ['A', 'B'] - - pp.deselect('A') - assert pp.ports == ['B'] - - pp.select(['A']) - assert pp.ports == ['B', 'A'] - - pp.drop() - assert 'A' not in p.pattern.ports - assert 'B' not in p.pattern.ports - assert pp.ports == [] - - -@pytest.mark.parametrize('action', ['plug', 'plugged', 'rename', 'mark', 'fork']) -def test_empty_selection_exact_one_operations_raise_build_error(action: str) -> None: - p = Pather(Library()) - pp = p.at([]) - operations = { - 'plug': lambda: pp.plug('unused', 'A'), - 'plugged': lambda: pp.plugged('A'), - 'rename': lambda: pp.rename('new'), - 'mark': lambda: pp.mark('new'), - 'fork': lambda: pp.fork('new'), - } - - with pytest.raises(BuildError, match='expected exactly one'): - operations[action]() - -def test_mark_fork() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((100, 200), rotation=1) - - pp = p.at('A') - pp.mark('B') - assert 'B' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200)) - assert p.pattern.ports['B'].rotation == 1 - assert pp.ports == ['A'] - - pp.fork('C') - assert 'C' in p.pattern.ports - assert pp.ports == ['C'] - -def test_mark_fork_reject_overwrite_and_duplicate_targets() -> None: - lib = Library() - - p_mark = Pather(lib, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'C': Port((2, 0), rotation=0), - })) - with pytest.raises(PortError, match='overwrite existing ports'): - p_mark.at('A').mark('C') - assert numpy.allclose(p_mark.pattern.ports['C'].offset, (2, 0)) - - p_fork = Pather(lib, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'B': Port((1, 0), rotation=0), - })) - pp = p_fork.at(['A', 'B']) - with pytest.raises(PortError, match='targets would collide'): - pp.fork({'A': 'X', 'B': 'X'}) - assert set(p_fork.pattern.ports) == {'A', 'B'} - assert pp.ports == ['A', 'B'] - -def test_mark_fork_dead_overwrite_and_duplicate_targets() -> None: - lib = Library() - p = Pather(lib, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'B': Port((1, 0), rotation=0), - 'C': Port((2, 0), rotation=0), - })) - p.set_dead() - - p.at('A').mark('C') - assert numpy.allclose(p.pattern.ports['C'].offset, (0, 0)) - - pp = p.at(['A', 'B']) - pp.fork({'A': 'X', 'B': 'X'}) - assert numpy.allclose(p.pattern.ports['X'].offset, (1, 0)) - assert pp.ports == ['X'] - -def test_mark_fork_reject_missing_sources() -> None: - lib = Library() - p = Pather(lib, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'B': Port((1, 0), rotation=0), - })) - - with pytest.raises(PortError, match='selected ports'): - p.at(['A', 'B']).mark({'Z': 'C'}) - - with pytest.raises(PortError, match='selected ports'): - p.at(['A', 'B']).fork({'Z': 'C'}) - -def test_rename() -> None: - lib = Library() - p = Pather(lib) - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').rename('B') - assert 'A' not in p.pattern.ports - assert 'B' in p.pattern.ports - - p.pattern.ports['C'] = Port((0, 0), rotation=0) - pp = p.at(['B', 'C']) - pp.rename({'B': 'D', 'C': 'E'}) - assert 'B' not in p.pattern.ports - assert 'C' not in p.pattern.ports - assert 'D' in p.pattern.ports - assert 'E' in p.pattern.ports - assert set(pp.ports) == {'D', 'E'} - -def test_pather_dead_fallback_preserves_out_ptype() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.set_dead() - - p.straight('A', 1000, out_ptype='other') - - assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 0)) - assert p.pattern.ports['A'].ptype == 'other' - assert len(p._paths['A']) == 0 - -def test_pather_dead_fallback_does_not_hide_fatal_route_errors() -> None: - class MismatchedEndpointTool(PathTool): - def primitive_offers( - self, - kind, # noqa: ANN001 - *, - in_ptype=None, # noqa: ANN001 - out_ptype=None, # noqa: ANN001,ARG002 - **kwargs, # noqa: ANN003 - ) -> tuple[PrimitiveOffer, ...]: - _ = kwargs - if kind != 'straight': - return () - - def endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype='wire') - - return (StraightOffer( - in_ptype=in_ptype, - out_ptype='other', - endpoint_planner=endpoint, - commit_planner=lambda length: {'length': length}, - ),) - - p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.set_dead() - - with pytest.raises(BuildError, match='does not match declared offer out_ptype'): - p.straight('A', 1000) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].ptype == 'wire' - assert len(p._paths['A']) == 0 - - -def test_pather_valid_candidate_does_not_hide_fatal_route_errors() -> None: - class PartlyMismatchedEndpointTool(PathTool): - def primitive_offers( - self, - kind, # noqa: ANN001 - *, - in_ptype=None, # noqa: ANN001 - out_ptype=None, # noqa: ANN001,ARG002 - **kwargs, # noqa: ANN003 - ) -> tuple[PrimitiveOffer, ...]: - _ = kwargs - if kind != 'straight': - return () - - def mismatched_endpoint(length: float) -> Port: - ptype = 'wire' if numpy.isclose(length, 0) else 'other' - return Port((length, 0), rotation=pi, ptype=ptype) - - def valid_endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype='wire') - - return ( - StraightOffer( - in_ptype=in_ptype, - out_ptype='wire', - endpoint_planner=mismatched_endpoint, - commit_planner=lambda length: {'kind': 'mismatched', 'length': length}, - ), - StraightOffer( - in_ptype=in_ptype, - out_ptype='wire', - endpoint_planner=valid_endpoint, - commit_planner=lambda length: {'kind': 'valid', 'length': length}, - ), - ) - - p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire')) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='does not match declared offer out_ptype'): - p.straight('A', 1000) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert p.pattern.ports['A'].ptype == 'wire' - assert len(p._paths['A']) == 0 - - -def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None: - lib = Library() - p = Pather(lib, pattern=Pattern(ports={ - 'A': Port((5, 5), rotation=0), - 'keep': Port((9, 9), rotation=0), - })) - p.set_dead() - - other = Pattern() - other.ports['X'] = Port((1, 0), rotation=0) - other.ports['Y'] = Port((2, 0), rotation=pi / 2) - - p.place(other, port_map={'X': 'A', 'Y': 'A'}) - - assert set(p.pattern.ports) == {'A', 'keep'} - assert numpy.allclose(p.pattern.ports['A'].offset, (2, 0)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2) - -def test_pather_dead_plug_overwrites_colliding_outputs_last_wins() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000, ptype='wire') - p = Pather(lib, tools=tool, pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0, ptype='wire'), - 'B': Port((99, 99), rotation=0, ptype='wire'), - })) - p.set_dead() - - other = Pattern() - other.ports['in'] = Port((0, 0), rotation=pi, ptype='wire') - other.ports['X'] = Port((10, 0), rotation=0, ptype='wire') - other.ports['Y'] = Port((20, 0), rotation=0, ptype='wire') - - p.plug(other, map_in={'A': 'in'}, map_out={'X': 'B', 'Y': 'B'}) - - assert 'A' not in p.pattern.ports - assert 'B' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['B'].offset, (20, 0)) - assert p.pattern.ports['B'].rotation is not None - assert numpy.isclose(p.pattern.ports['B'].rotation, 0) - -def test_pather_dead_rename_overwrites_colliding_ports_last_wins() -> None: - p = Pather(Library(), pattern=Pattern(ports={ - 'A': Port((0, 0), rotation=0), - 'B': Port((1, 0), rotation=0), - 'C': Port((2, 0), rotation=0), - })) - p.set_dead() - - p.rename_ports({'A': 'C', 'B': 'C'}) - - assert set(p.pattern.ports) == {'C'} - assert numpy.allclose(p.pattern.ports['C'].offset, (1, 0)) diff --git a/masque/test/test_pather_place_plug.py b/masque/test/test_pather_place_plug.py deleted file mode 100644 index 660d080..0000000 --- a/masque/test/test_pather_place_plug.py +++ /dev/null @@ -1,120 +0,0 @@ -import pytest -import numpy -from numpy import pi - -from masque import Pather, Library, Pattern, Port -from masque.builder.tools import PathTool -from masque.error import PortError, PatternError - - -def test_pather_place_treeview_resolves_once() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, render='deferred') - - tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})} - - p.place(tree) - - assert len(lib) == 1 - assert 'child' in lib - assert 'child' in p.pattern.refs - assert 'B' in p.pattern.ports - -def test_pather_plug_treeview_resolves_once() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})} - - p.plug(tree, {'A': 'B'}) - - assert len(lib) == 1 - assert 'child' in lib - assert 'child' in p.pattern.refs - assert 'A' not in p.pattern.ports - -def test_pather_failed_plug_does_not_add_break_marker() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, render='deferred') - p.pattern.annotations = {'k': [1]} - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').trace(None, 5000) - assert [step.opcode for step in p._paths['A']] == ['L'] - - other = Pattern( - annotations={'k': [2]}, - ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)}, - ) - - with pytest.raises(PatternError, match='Annotation keys overlap'): - p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True) - - assert [step.opcode for step in p._paths['A']] == ['L'] - assert set(p.pattern.ports) == {'A'} - -def test_pather_place_reused_deleted_name_keeps_break_marker() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').straight(5000) - p.rename_ports({'A': None}) - - other = Pattern(ports={'X': Port((-5000, 0), rotation=0)}) - p.place(other, port_map={'X': 'A'}, append=True) - p.at('A').straight(2000) - - assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L'] - - p.render() - assert p.pattern.has_shapes() - assert 'A' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0)) - -def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((0, 0), rotation=0) - - p.at('A').straight(5000) - p.rename_ports({'A': None}) - - other = Pattern( - ports={ - 'X': Port((0, 0), rotation=pi), - 'Y': Port((-5000, 0), rotation=0), - }, - ) - p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True) - p.at('A').straight(2000) - - assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L'] - - p.render() - assert p.pattern.has_shapes() - assert 'A' in p.pattern.ports - assert 'B' not in p.pattern.ports - assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0)) - -def test_pather_failed_plugged_does_not_add_break_marker() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool, render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0) - - p.at('A').straight(5000) - assert [step.opcode for step in p._paths['A']] == ['L'] - - with pytest.raises(PortError, match='Connection destination ports were not found'): - p.plugged({'A': 'missing'}) - - assert [step.opcode for step in p._paths['A']] == ['L'] - assert set(p._paths) == {'A'} diff --git a/masque/test/test_pather_primitive_offers.py b/masque/test/test_pather_primitive_offers.py deleted file mode 100644 index 0a6abe3..0000000 --- a/masque/test/test_pather_primitive_offers.py +++ /dev/null @@ -1,910 +0,0 @@ -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any, Literal - -import numpy -import pytest -from numpy import pi - -from masque import Library, Path, Port, Pather, ToolContractError -from masque.builder.planner import RoutingPlanner -from masque.builder.planner.planner import Solver, SolverRequest -from masque.builder.tools import ( - BendOffer, - PathTool, - PrimitiveOffer, - SOffer, - StraightOffer, - Tool, - UOffer, -) -from masque.error import BuildError -from masque.utils import PTypeMatch, ptype_match - - -def offer_callbacks(planner: Callable[[float], tuple[Port, Any]]) -> dict[str, Callable[[float], Any]]: - return { - 'endpoint_planner': lambda parameter: planner(parameter)[0], - 'commit_planner': lambda parameter: planner(parameter)[1], - } - - -class PlanningOnlyTool(Tool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kind, in_ptype, out_ptype, kwargs - return () - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - tree, pat = Library.mktree('planning_only_tool') - pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk') - return tree - - -def test_tool_contract_error_is_fatal_even_when_an_alternate_offer_exists() -> None: - class BrokenTool(PlanningOnlyTool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - return ( - StraightOffer( - in_ptype='wire', - out_ptype='wire', - endpoint_planner=lambda length: Port((length, 0), pi, ptype='wrong'), - commit_planner=lambda length: length, - ), - StraightOffer.generated('wire', lambda length: length), - ) - - pather = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=BrokenTool(), - render='deferred', - ) - with pytest.raises(ToolContractError, match='declared offer out_ptype'): - pather.straight('A', 5) - - -def test_callback_build_error_remains_recoverable_candidate_rejection() -> None: - class RecoverableTool(PlanningOnlyTool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - - def rejected(length: float) -> Port: - raise BuildError(f'unsupported length {length}') - - return ( - StraightOffer( - in_ptype='wire', - out_ptype='wire', - endpoint_planner=rejected, - commit_planner=lambda length: length, - ), - StraightOffer.generated('wire', lambda length: length), - ) - - pather = Pather( - Library(), - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=RecoverableTool(), - render='deferred', - ) - pather.straight('A', 5) - assert numpy.allclose(pather.ports['A'].offset, (-5, 0)) - - -def test_solver_offer_cache_accepts_unhashable_request_tool_options() -> None: - class CountingTool(PlanningOnlyTool): - calls = 0 - - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - self.calls += 1 - assert kwargs == {'nested': []} - return () - - tool = CountingTool() - solver = Solver(SolverRequest( - family='straight', - tool=tool, - in_ptype='wire', - tool_options={'nested': []}, - )) - solver.primitive_offers('straight', 'wire') - solver.primitive_offers('straight', 'wire') - assert tool.calls == 1 - - -def test_solver_finalize_chooses_cheapest_parameter_allocation() -> None: - tool = PlanningOnlyTool() - solver = Solver(SolverRequest( - family='straight', - tool=tool, - in_ptype='wire', - tool_options={}, - length=10, - )) - expensive_offer = StraightOffer.generated('wire', lambda length: length, cost=1) - cheap_offer = StraightOffer.generated('wire', lambda length: length, cost=0.3) - expensive = solver.evaluate(expensive_offer, 0, 'wire', out_ptype=None, role='main') - cheap = solver.evaluate(cheap_offer, 0, 'wire', out_ptype=None, role='main') - - for steps in ((expensive, cheap), (cheap, expensive)): - candidate = solver.finalize(steps) - parameters = {step.offer: step.parameter for step in candidate.steps} - - assert parameters[cheap_offer] == pytest.approx(10) - assert parameters[expensive_offer] == pytest.approx(0) - assert candidate.cost == pytest.approx(3) - - -def test_solver_strategy_rank_covers_all_main_steps_and_ignores_adapters() -> None: - tool = PlanningOnlyTool() - straight_first = Solver(SolverRequest( - family='s', - tool=tool, - in_ptype='wire', - tool_options={}, - strategy='straight_first', - )) - turn_first = Solver(SolverRequest( - family='s', - tool=tool, - in_ptype='wire', - tool_options={}, - strategy='turn_first', - )) - straight_offer = StraightOffer.generated('wire', lambda length: length) - bend_offer = BendOffer.prebuilt( - 'wire', - 'wire', - Port((1, 1), 3 * pi / 2, ptype='wire'), - None, - ccw=True, - ) - adapter_offer = StraightOffer.prebuilt( - 'wire', - 'adapted', - Port((1, 0), pi, ptype='adapted'), - None, - ) - straight = straight_first.evaluate(straight_offer, 0, 'wire', out_ptype=None, role='main') - turn = straight_first.evaluate(bend_offer, 1, 'wire', out_ptype=None, role='main') - adapter = straight_first.evaluate(adapter_offer, 1, 'wire', out_ptype=None, role='adapter') - alternating = (straight, turn, adapter, straight, turn) - delayed_straight = (straight, turn, adapter, turn, straight) - - assert straight_first.strategy_rank(alternating) < straight_first.strategy_rank(delayed_straight) - assert turn_first.strategy_rank(delayed_straight) < turn_first.strategy_rank(alternating) - assert straight_first.strategy_rank(alternating) == straight_first.strategy_rank( - (straight, turn, straight, turn), - ) - assert straight_first.strategy_rank((straight,)) < straight_first.strategy_rank((turn,)) - assert turn_first.strategy_rank((turn,)) < turn_first.strategy_rank((straight,)) - - -def test_tool_requires_primitive_offers_override() -> None: - class RenderOnlyTool(Tool): - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - return Library() - - with pytest.raises(TypeError): - RenderOnlyTool() - - -def test_tool_base_primitive_offers_is_not_no_offer_fallback() -> None: - class BaseCallingTool(Tool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - return Tool.primitive_offers(self, kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - return Library() - - with pytest.raises(NotImplementedError): - BaseCallingTool().primitive_offers('straight') - - -def canonicalize_offer_parameter(value: float, domain: tuple[float, float]) -> float: - offer = StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain) - return offer.canonicalize_parameter(value) - - -def test_offer_canonicalize_parameter_half_open_and_singleton() -> None: - assert canonicalize_offer_parameter(-1e-13, (0, 10)) == 0 - assert canonicalize_offer_parameter(3, (0, 10)) == 3 - - with pytest.raises(BuildError, match='outside half-open domain'): - canonicalize_offer_parameter(10, (0, 10)) - - assert canonicalize_offer_parameter(5 + 1e-13, (5, 5)) == 5 - - with pytest.raises(BuildError, match='outside singleton domain'): - canonicalize_offer_parameter(5.1, (5, 5)) - - -@pytest.mark.parametrize('value', [numpy.nan, numpy.inf, -numpy.inf]) -def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float) -> None: - with pytest.raises(BuildError, match='must be finite'): - canonicalize_offer_parameter(value, (0, 10)) - - -def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None: - with pytest.raises(BuildError, match='lower bound must not exceed upper bound'): - StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=(10, 0)) - - -@pytest.mark.parametrize('domain', [(numpy.nan, 1), (1, numpy.nan), (numpy.inf, numpy.inf)]) -def test_offer_rejects_invalid_domain_at_construction(domain: tuple[float, float]) -> None: - with pytest.raises(BuildError, match='domain'): - StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain) - - -@pytest.mark.parametrize('domain', [(-1, 2), (-numpy.inf, 2)]) -def test_length_offer_requires_finite_nonnegative_minimum(domain: tuple[float, float]) -> None: - with pytest.raises(BuildError, match='finite, nonnegative minimum'): - StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain) - - -def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None: - assert ptype_match('wire', 'wire') is PTypeMatch.EXACT - assert ptype_match(None, 'wire') is PTypeMatch.WILDCARD - assert ptype_match('unk', 'wire') is PTypeMatch.WILDCARD - assert ptype_match('wire', 'metal') is PTypeMatch.MISMATCH - - -def test_offer_split_endpoint_and_commit_callbacks_are_independent() -> None: - endpoint_calls: list[float] = [] - commit_calls: list[float] = [] - - def endpoint(length: float) -> Port: - endpoint_calls.append(length) - return Port((length, 2), rotation=pi, ptype='wire') - - def commit(length: float) -> dict[str, float]: - commit_calls.append(length) - return {'length': length} - - offer = StraightOffer( - in_ptype='wire', - out_ptype='wire', - length_domain=(5, 5), - endpoint_planner=endpoint, - commit_planner=commit, - ) - - assert numpy.allclose(offer.endpoint_at(5 + 1e-13).offset, (5, 2)) - assert offer.cost_at(5 + 1e-13) == 5 + pi - assert commit_calls == [] - - assert offer.commit(5 + 1e-13) == {'length': 5} - assert endpoint_calls == [5, 5] - assert commit_calls == [5] - - -def test_straight_offer_generated_factory_uses_default_endpoint_and_data_bbox() -> None: - def data_at(length: float) -> dict[str, float]: - return {'length': length} - - offer = StraightOffer.generated( - 'wire', - data_at, - length_domain=(2, 8), - cost=3, - bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], 1]]), - ) - - endpoint = offer.endpoint_at(4) - - assert offer.length_domain == (2, 8) - assert offer.cost == 3 - assert offer.cost_at(4) == 12 - assert numpy.allclose(endpoint.offset, [4, 0]) - assert endpoint.rotation == pi - assert endpoint.ptype == 'wire' - assert offer.commit(4) == {'length': 4} - assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 1]]) - - -def test_s_offer_generated_factory_keeps_endpoint_and_commit_data_independent() -> None: - def endpoint_at(jog: float) -> Port: - return Port((10, jog), rotation=pi, ptype='wire') - - def data_at(jog: float) -> dict[str, Any]: - return {'jog': abs(jog), 'mirrored': jog < 0} - - offer = SOffer.generated( - 'wire', - endpoint_at, - data_at, - jog_domain=(-5, 5), - bbox_for_data=lambda data: numpy.array([[0, -data['jog']], [10, data['jog']]]), - ) - - endpoint = offer.endpoint_at(-3) - - assert numpy.allclose(endpoint.offset, [10, -3]) - assert offer.commit(-3) == {'jog': 3, 'mirrored': True} - assert numpy.allclose(offer.bbox_at(-3), [[0, -3], [10, 3]]) - - -def test_bend_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None: - def endpoint_at(length: float) -> Port: - return Port((length, length), rotation=-pi / 2, ptype='wire') - - def data_at(length: float) -> dict[str, float]: - return {'length': length} - - offer = BendOffer.generated( - 'wire', - endpoint_at, - data_at, - ccw=True, - length_domain=(4, 4), - bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], data['length']]]), - ) - - endpoint = offer.endpoint_at(4) - - assert offer.ccw - assert offer.length_domain == (4, 4) - assert numpy.allclose(endpoint.offset, [4, 4]) - assert endpoint.rotation == 3 * pi / 2 - assert offer.commit(4) == {'length': 4} - assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 4]]) - - -def test_u_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None: - def endpoint_at(jog: float) -> Port: - return Port((12, jog), rotation=0, ptype='wire') - - def data_at(jog: float) -> dict[str, float]: - return {'jog': jog} - - offer = UOffer.generated( - 'wire', - endpoint_at, - data_at, - jog_domain=(-6, 6), - bbox_for_data=lambda data: numpy.array([[0, min(0, data['jog'])], [12, max(0, data['jog'])]]), - ) - - endpoint = offer.endpoint_at(-4) - - assert offer.jog_domain == (-6, 6) - assert numpy.allclose(endpoint.offset, [12, -4]) - assert endpoint.rotation == 0 - assert offer.commit(-4) == {'jog': -4} - assert numpy.allclose(offer.bbox_at(-4), [[0, -4], [12, 0]]) - - -def test_prebuilt_offer_factories_return_fresh_endpoint_copies() -> None: - data = {'kind': 'prebuilt'} - bbox_for_data = lambda _data: numpy.array([[-1, -2], [6, 3]]) # noqa: E731 - endpoint = Port((5, 2), rotation=pi / 2, ptype='out') - cases = [ - (StraightOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 5), - (BendOffer.prebuilt('in', 'out', endpoint, data, ccw=True, bbox_for_data=bbox_for_data), 5), - (SOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2), - (UOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2), - ] - - for offer, parameter in cases: - first = offer.endpoint_at(parameter) - first.offset[:] = 99 - second = offer.endpoint_at(parameter) - - assert numpy.allclose(second.offset, [5, 2]) - assert second.rotation == pi / 2 - assert second.ptype == 'out' - assert offer.commit(parameter) is data - assert numpy.allclose(offer.bbox_at(parameter), [[-1, -2], [6, 3]]) - - -@pytest.mark.parametrize('cost', [-1, numpy.inf, numpy.nan, 'expensive']) -def test_offer_rejects_invalid_cost_factor(cost: Any) -> None: - with pytest.raises(BuildError, match='cost factor'): - StraightOffer(in_ptype='wire', out_ptype='wire', cost=cost) - - -@pytest.mark.parametrize('result', [-1, numpy.inf, numpy.nan, 'expensive']) -def test_offer_rejects_invalid_callable_cost_result(result: Any) -> None: - offer = StraightOffer( - in_ptype='wire', - out_ptype='wire', - cost=lambda _parameter, _endpoint: result, - **offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), None)), - ) - - with pytest.raises(BuildError, match='Primitive cost'): - offer.cost_at(5) - - -def test_offer_callable_cost_replaces_default_cost() -> None: - seen: list[tuple[float, Port]] = [] - - def cost(parameter: float, endpoint: Port) -> float: - seen.append((parameter, endpoint)) - return parameter + endpoint.y - - offer = SOffer( - in_ptype='wire', - out_ptype='wire', - cost=cost, - jog_domain=(2, 2), - **offer_callbacks(lambda jog: (Port((10, jog), rotation=pi, ptype='wire'), None)), - ) - - assert offer.cost_at(2 + 1e-13) == 4 - assert seen[0][0] == 2 - assert numpy.allclose(seen[0][1].offset, (10, 2)) - - -def test_offer_rejects_one_sided_split_callbacks() -> None: - def endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype='wire') - - with pytest.raises(BuildError, match='require both'): - StraightOffer(in_ptype='wire', out_ptype='wire', endpoint_planner=endpoint) - - -def test_offer_bbox_at_validates_bounds() -> None: - offer = StraightOffer( - in_ptype='wire', - out_ptype='wire', - bbox_planner=lambda _length: numpy.array([[0, 0], [1, 2]]), - **offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})), - ) - - assert numpy.allclose(offer.bbox_at(3), [[0, 0], [1, 2]]) - - bad = StraightOffer( - in_ptype='wire', - out_ptype='wire', - bbox_planner=lambda _length: numpy.array([0, 1]), - **offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})), - ) - with pytest.raises(BuildError, match='shape'): - bad.bbox_at(3) - - -def test_pathtool_straight_offer_bbox_matches_path_bounds() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype='wire') - offer = tool.primitive_offers('straight', in_ptype='wire')[0] - - bounds = offer.bbox_at(10) - expected = Path(vertices=[(0, 0), (10, 0)], width=2).get_bounds_single() - - assert numpy.allclose(bounds, expected) - - -def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype='wire') - offer = tool.primitive_offers('bend', in_ptype='wire', ccw=True)[0] - - bounds = offer.bbox_at(1) - expected = Path(vertices=[(0, 0), (1, 0), (1, 1)], width=2).get_bounds_single() - - assert isinstance(offer, BendOffer) - assert offer.length_domain == (1, 1) - assert numpy.allclose(bounds, expected) - - -def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype='wire') - offer = tool.primitive_offers('s', in_ptype='wire')[0] - - bounds = offer.bbox_at(3) - expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single() - - assert isinstance(offer, SOffer) - assert numpy.allclose(bounds, expected) - - -def test_pathtool_u_offers_remain_unsupported() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype='wire') - - assert tool.primitive_offers('u', in_ptype='wire') == () - - -@pytest.mark.parametrize( - ('kind', 'kwargs'), - [ - ('straight', {}), - ('bend', {'ccw': True}), - ('s', {}), - ], -) -def test_pathtool_out_ptype_unk_is_wildcard( - kind: Literal['straight', 'bend', 's'], - kwargs: dict[str, Any], - ) -> None: - tool = PathTool(layer=(1, 0), width=2, ptype='wire') - - offers = tool.primitive_offers(kind, in_ptype='wire', out_ptype='unk', **kwargs) - - assert offers - assert offers[0].out_ptype == 'wire' - - -def test_pathtool_rejects_unsupported_planning_options() -> None: - tool = PathTool(layer='M1', width=1) - with pytest.raises(BuildError, match='does not support tool options: marker'): - tool.primitive_offers('straight', marker='sentinel') - - -def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None: - class NoOfferTool(PlanningOnlyTool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kind, in_ptype, out_ptype, kwargs - raise NotImplementedError - - p = Pather(Library(), tools=NoOfferTool()) - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError, match='No legal primitive offer for trace'): - p.straight('A', 5) - - -@pytest.mark.parametrize('err_type', [BuildError, KeyError, TypeError]) -def test_pather_propagates_offer_query_errors(err_type: type[Exception]) -> None: - class BrokenTool(PlanningOnlyTool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kind, in_ptype, out_ptype, kwargs - raise err_type('offer query failed') - - p = Pather(Library(), tools=BrokenTool()) - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(err_type): - p.straight('A', 5) - - -def test_pather_selects_lowest_cost_offer() -> None: - class MultiOfferTool(PlanningOnlyTool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kwargs - if kind != 'straight': - return () - - def high(length: float) -> tuple[Port, dict[str, str | float]]: - return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'high'} - - def low(length: float) -> tuple[Port, dict[str, str | float]]: - return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'low'} - - return ( - StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, cost=10, **offer_callbacks(high)), - StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, cost=1, **offer_callbacks(low)), - ) - - p = Pather(Library(), tools=MultiOfferTool(), render='deferred') - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 7) - - assert p._paths['A'][0].data == {'kind': 'low'} - assert numpy.allclose(p.ports['A'].offset, (-7, 0)) - - -def test_planner_deduplication_distinguishes_offer_costs() -> None: - @dataclass(frozen=True, slots=True) - class MarkedStraightOffer(StraightOffer): - marker: str = '' - - def commit(self, parameter: float) -> dict[str, str | float]: - return {'kind': self.marker, 'length': self.canonicalize_parameter(parameter)} - - def endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype='wire') - - def shared_commit(length: float) -> dict[str, float]: - return {'length': length} - - class DuplicateGeometryTool(PlanningOnlyTool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kwargs - if kind != 'straight': - return () - common = { - 'in_ptype': in_ptype, - 'out_ptype': out_ptype, - 'endpoint_planner': endpoint, - 'commit_planner': shared_commit, - } - return ( - MarkedStraightOffer(cost=100, marker='expensive', **common), - MarkedStraightOffer(cost=1, marker='cheap', **common), - ) - - pather = Pather(Library(), tools=DuplicateGeometryTool(), render='deferred') - pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - pather.straight('A', 7) - - assert pather._paths['A'][0].data == {'kind': 'cheap', 'length': 7} - - -class StrategyTieTool(PlanningOnlyTool): - def __init__(self) -> None: - self.seen_kwargs: list[dict[str, Any]] = [] - - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - self.seen_kwargs.append(dict(kwargs)) - endpoint_ptype = out_ptype or in_ptype - marker = kwargs.get('marker') - if kind == 'straight': - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=endpoint_ptype, - **offer_callbacks(lambda length: ( - Port((length, 0), rotation=pi, ptype=endpoint_ptype), - {'kind': 'straight', 'length': length, 'marker': marker}, - )), - ),) - if kind == 's': - return (SOffer( - in_ptype=in_ptype, - out_ptype=endpoint_ptype, - **offer_callbacks(lambda jog: ( - Port((3, jog), rotation=pi, ptype=endpoint_ptype), - {'kind': 's', 'jog': jog, 'marker': marker}, - )), - ),) - return () - - -def pather_with_strategy_tool( - planner: RoutingPlanner | None = None, - ) -> tuple[Pather, StrategyTieTool]: - tool = StrategyTieTool() - pather = Pather(Library(), tools=tool, planner=planner, render='deferred') - pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - return pather, tool - - -def test_pather_route_strategy_defaults_to_straight_first() -> None: - pather, _tool = pather_with_strategy_tool() - - pather.jog('A', 4, length=10) - - assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's'] - assert pather._paths['A'][0].data['length'] == 7 - - -def test_pather_route_strategy_uses_planner_default() -> None: - pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first')) - - pather.jog('A', 4, length=10) - - assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight'] - assert pather._paths['A'][1].data['length'] == 7 - - -def test_pather_route_strategy_per_route_overrides_planner_default() -> None: - pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first')) - - pather.jog('A', 4, length=10, plan_options={'strategy': 'straight_first'}) - - assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's'] - - -def test_pather_route_strategy_per_route_can_request_turn_first() -> None: - pather, _tool = pather_with_strategy_tool() - - pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'}) - - assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight'] - - -def test_pather_plan_options_are_not_forwarded_to_tool() -> None: - pather, tool = pather_with_strategy_tool() - - pather.jog( - 'A', - 4, - length=10, - plan_options={'strategy': 'turn_first'}, - tool_options={'marker': 'sentinel'}, - ) - - assert tool.seen_kwargs - assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs) - assert any(kwargs.get('marker') == 'sentinel' for kwargs in tool.seen_kwargs) - assert all(step.data['marker'] == 'sentinel' for step in pather._paths['A']) - - -def test_pather_route_strategy_rejects_invalid_values() -> None: - with pytest.raises(BuildError, match='Invalid route strategy'): - RoutingPlanner(strategy='sideways') - - pather, _tool = pather_with_strategy_tool() - with pytest.raises(BuildError, match='Invalid route strategy'): - pather.jog('A', 4, length=10, plan_options={'strategy': 'sideways'}) - - -def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None: - invalid_parameters: list[float] = [] - - class RotationOfferTool(PlanningOnlyTool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kwargs - if kind != 'straight': - return () - - def invalid_endpoint(length: float) -> Port: - invalid_parameters.append(length) - return Port((length, 0), rotation=0, ptype=out_ptype or in_ptype) - - def valid_endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype) - - return ( - StraightOffer( - in_ptype=in_ptype, - out_ptype=out_ptype, - endpoint_planner=invalid_endpoint, - commit_planner=lambda length: {'kind': 'invalid', 'length': length}, - ), - StraightOffer( - in_ptype=in_ptype, - out_ptype=out_ptype, - endpoint_planner=valid_endpoint, - commit_planner=lambda length: {'kind': 'valid', 'length': length}, - ), - ) - - p = Pather(Library(), tools=RotationOfferTool(), render='deferred') - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 7) - - assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7} - assert invalid_parameters == [0.0] - - -def test_pather_commits_only_selected_offer() -> None: - committed: list[str] = [] - - class RecordingTool(PlanningOnlyTool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kwargs - if kind != 'straight': - return () - - def make(label: str, cost: float) -> StraightOffer: - def endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype) - - def commit(length: float) -> dict[str, float | str]: - committed.append(label) - return {'kind': label, 'length': length} - - return StraightOffer( - in_ptype=in_ptype, - out_ptype=out_ptype, - cost=cost, - endpoint_planner=endpoint, - commit_planner=commit, - ) - - return (make('expensive', 100), make('cheap', 1)) - - p = Pather(Library(), tools=RecordingTool(), render='deferred') - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 5) - - assert committed == ['cheap'] - assert p._paths['A'][0].data == {'kind': 'cheap', 'length': 5} - - -def test_pather_routes_with_offer_only_s_and_u_tool() -> None: - class OfferOnlyTool(PlanningOnlyTool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kwargs - endpoint_ptype = out_ptype or in_ptype - if kind == 'straight': - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=endpoint_ptype, - **offer_callbacks(lambda length: ( - Port((length, 0), rotation=pi, ptype=endpoint_ptype), - {'kind': 'straight', 'length': length}, - )), - ),) - if kind == 's': - return (SOffer( - in_ptype=in_ptype, - out_ptype=endpoint_ptype, - **offer_callbacks(lambda jog: ( - Port((5, jog), rotation=pi, ptype=endpoint_ptype), - {'kind': 's', 'jog': jog}, - )), - ),) - if kind == 'u': - return (UOffer( - in_ptype=in_ptype, - out_ptype=endpoint_ptype, - **offer_callbacks(lambda jog: ( - Port((2, jog), rotation=0, ptype=endpoint_ptype), - {'kind': 'u', 'jog': jog}, - )), - ),) - return () - - p = Pather(Library(), tools=OfferOnlyTool(), render='deferred') - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.jog('A', 3) - p.uturn('A', 4) - - assert [step.data['kind'] for step in p._paths['A']] == ['s', 'u'] diff --git a/masque/test/test_pather_rendering.py b/masque/test/test_pather_rendering.py deleted file mode 100644 index 70360ed..0000000 --- a/masque/test/test_pather_rendering.py +++ /dev/null @@ -1,627 +0,0 @@ -from typing import TYPE_CHECKING, cast -import logging - -import pytest -import numpy -from numpy import pi -from numpy.testing import assert_allclose - -from ..builder import Pather, RouteError, ToolContractError -from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool -from ..error import BuildError -from ..library import Library -from ..pattern import Pattern -from ..ports import Port - -if TYPE_CHECKING: - from ..shapes import Path - - -@pytest.fixture -def deferred_render_setup() -> tuple[Pather, PathTool, Library]: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - rp = Pather(lib, tools=tool, render='deferred') - rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - return rp, tool, lib - - -def add_pending_straight(pather: Pather) -> None: - pather.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - pather.straight("start", 10) - - -def route_in_context(pather: Pather) -> None: - with pather: - add_pending_straight(pather) - - -def fail_in_context(pather: Pather) -> None: - with pather: - add_pending_straight(pather) - raise RuntimeError('body failed') - - -def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = deferred_render_setup - rp.at("start").straight(10).straight(10) - - assert not rp.pattern.has_shapes() - assert len(rp._paths["start"]) == 2 - - rp.render() - assert rp.pattern.has_shapes() - assert len(rp.pattern.shapes[(1, 0)]) == 1 - - # PathTool renders length steps in the port extension direction. - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert len(path_shape.vertices) == 3 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) - -def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = deferred_render_setup - rp.at("start").straight(10).cw(10) - - rp.render() - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - # The bend route is explicit straight run plus a fixed-size bend. - assert len(path_shape.vertices) == 5 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -19], [0, -20], [-1, -20]], atol=1e-10) - -def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = deferred_render_setup - rp.at("start").jog(4, length=10) - - assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"] - - rp.render() - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, -8], [0, -9], [1, -9], [3, -9], [4, -9], [4, -10]], atol=1e-10) - assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10) - -def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = deferred_render_setup - rp.at("start").straight(10).cw(10) - - rp.mirror(0) - rp.render() - - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 19], [0, 20], [-1, 20]], atol=1e-10) - -def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool1, lib = deferred_render_setup - tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") - - rp.at("start").straight(10) - rp.retool(tool2, keys=["start"]) - rp.at("start").straight(10) - - rp.render() - assert len(rp.pattern.shapes[(1, 0)]) == 1 - assert len(rp.pattern.shapes[(2, 0)]) == 1 - - -def test_deferred_render_batches_tools_by_identity() -> None: - class CountingTool(PathTool): - def __init__(self, *args, **kwargs) -> None: # noqa: ANN002,ANN003 - super().__init__(*args, **kwargs) - self.render_calls = 0 - - def render(self, *args, **kwargs): # noqa: ANN002,ANN003,ANN202 - self.render_calls += 1 - return super().render(*args, **kwargs) - - lib = Library() - tool1 = CountingTool(layer=(1, 0), width=2, ptype='wire') - tool2 = CountingTool(layer=(1, 0), width=2, ptype='wire') - assert tool1 == tool2 - assert tool1 is not tool2 - p = Pather( - lib, - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool1, - render='deferred', - ) - - p.straight('A', 5) - p.retool(tool2, 'A') - p.straight('A', 5) - p.render() - - assert tool1.render_calls == 1 - assert tool2.render_calls == 1 - assert len(p.pattern.shapes[(1, 0)]) == 2 - - -def test_deleted_name_reuse_does_not_retarget_pending_render_steps() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype='wire') - p = Pather( - lib, - ports={'A': Port((0, 0), rotation=0, ptype='wire')}, - tools=tool, - render='deferred', - ) - - p.straight('A', 5) - original_step = p._paths['A'][0] - p.rename_ports({'A': None}) - p.mkport('A', Port((100, 0), rotation=0, ptype='wire')) - p.straight('A', 5) - - assert len(p._paths['A']) == 2 - assert_allclose(original_step.start_port.offset, (0, 0)) - assert_allclose(original_step.end_port.offset, (-5, 0)) - assert_allclose(p._paths['A'][1].start_port.offset, (100, 0)) - -def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = deferred_render_setup - pp = rp.at("start") - pp.straight(10) - pp.translate((5, 0)) - pp.straight(10) - - rp.render() - - shapes = rp.pattern.shapes[(1, 0)] - assert len(shapes) == 2 - assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10) - assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10) - assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10) - -def test_deferred_render_dead_ports() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=1) - rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, render='deferred') - rp.set_dead() - - with pytest.raises(RouteError, match='finite and nonnegative'): - rp.straight("in", -10) - - assert_allclose(rp.ports["in"].offset, [0, 0], atol=1e-10) - - assert len(rp._paths["in"]) == 0 - - rp.render() - assert not rp.pattern.has_shapes() - - -def test_pather_default_auto_policy_renders_immediately_outside_context() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool) - p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") - - p.straight("start", 10) - - assert p.pattern.has_shapes() - assert not any(p._paths.values()) - - -def test_pather_default_auto_policy_defers_until_clean_context_exit() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - - with Pather(lib, tools=tool) as p: - add_pending_straight(p) - assert not p.pattern.has_shapes() - assert len(p._paths["start"]) == 1 - - assert p.pattern.has_shapes() - assert not any(p._paths.values()) - - -def test_pather_context_warn_policy_logs_pending_paths(caplog: pytest.LogCaptureFixture) -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - - with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='warn') as p: - add_pending_straight(p) - - records = [ - record - for record in caplog.records - if 'Pather context exited with 1 pending render step on 1 port' in record.getMessage() - ] - assert len(records) == 1 - assert records[0].stack_info is None - assert len(p._paths["start"]) == 1 - assert not p.pattern.has_shapes() - - -def test_pather_context_error_policy_rejects_pending_paths() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool, render='error') - - with pytest.raises(BuildError, match='1 pending render step on 1 port'): - route_in_context(p) - - assert len(p._paths["start"]) == 1 - assert not p.pattern.has_shapes() - - -def test_pather_context_ignore_policy_leaves_pending_paths_silent(caplog: pytest.LogCaptureFixture) -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - - with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='ignore') as p: - add_pending_straight(p) - - assert not caplog.records - assert len(p._paths["start"]) == 1 - assert not p.pattern.has_shapes() - - -def test_pather_context_policy_does_not_mask_body_exception() -> None: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool, render='error') - - with pytest.raises(RuntimeError, match='body failed'): - fail_in_context(p) - - assert len(p._paths["start"]) == 1 - assert not p.pattern.has_shapes() - - -def test_pather_rejects_invalid_render_policy() -> None: - with pytest.raises(BuildError, match='Invalid render policy'): - Pather(Library(), tools=PathTool(layer=(1, 0), width=2), render='later') # type: ignore[arg-type] - - -def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = deferred_render_setup - rp.at("start").straight(10) - rp.rename_ports({"start": "new_start"}) - rp.at("new_start").straight(10) - - assert "start" not in rp._paths - assert len(rp._paths["new_start"]) == 2 - - rp.render() - assert rp.pattern.has_shapes() - assert len(rp.pattern.shapes[(1, 0)]) == 1 - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) - assert "new_start" in rp.ports - assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10) - -def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: - rp, tool, lib = deferred_render_setup - rp.at("start").straight(10).drop() - - assert "start" not in rp.ports - assert len(rp._paths["start"]) == 1 - - rp.render() - assert rp.pattern.has_shapes() - assert "start" not in rp.ports - path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10) - -def test_pathtool_bend_offer_render_geometry_matches_ports() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - - offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0] - start = Port((0, 0), rotation=pi, ptype="wire") - end = offer.endpoint_at(1) - tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(1)),)) - pat = tree.top_pattern() - path_shape = cast("Path", pat.shapes[(1, 0)][0]) - - assert offer.length_domain == (1, 1) - assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 1]], atol=1e-10) - assert_allclose(pat.ports["B"].offset, [1, 1], atol=1e-10) - -def test_pathtool_s_offer_render_geometry_matches_ports() -> None: - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - - offer = tool.primitive_offers("s", in_ptype="wire")[0] - start = Port((0, 0), rotation=pi, ptype="wire") - end = offer.endpoint_at(4) - tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(4)),)) - pat = tree.top_pattern() - path_shape = cast("Path", pat.shapes[(1, 0)][0]) - - assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10) - assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10) - assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10) - -def test_deferred_render_uturn_fallback() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - rp = Pather(lib, tools=tool, render='deferred') - rp.pattern.ports['A'] = Port((0, 0), rotation=0) - - rp.at('A').uturn(offset=10000, length=5000) - - assert len(rp._paths['A']) == 4 - assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', 'L'] - - rp.render() - assert rp.pattern.ports['A'].rotation is not None - assert numpy.isclose(rp.pattern.ports['A'].rotation, pi) - -def test_pather_render_auto_renames_single_use_tool_children() -> None: - class FullTreeTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - ptype = out_ptype or in_ptype or 'wire' - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=ptype, - endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), - commit_planner=lambda length: {'length': length}, - ),) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - length = batch[0].data['length'] - tree = Library() - top = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), pi, ptype='wire'), - }) - child = Pattern(annotations={'batch': [len(batch)]}) - top.ref('_seg') - tree['_top'] = top - tree['_seg'] = child - return tree - - lib = Library() - p = Pather(lib, tools=FullTreeTool()) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 10) - p.render() - p.straight('A', 10) - p.render() - - assert len(lib) == 2 - assert set(lib.keys()) == set(p.pattern.refs.keys()) - assert len(set(p.pattern.refs.keys())) == 2 - assert all(name.startswith('_seg') for name in lib) - assert p.pattern.referenced_patterns() <= set(lib.keys()) - -def test_custom_tool_render_preserves_segment_subtrees() -> None: - class TraceTreeTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - ptype = out_ptype or in_ptype or 'wire' - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=ptype, - endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), - commit_planner=lambda length: self._trace(length), - ),) - - def _trace(self, length, *, port_names=('A', 'B')) -> Library: # noqa: ANN001 - tree = Library() - top = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), pi, ptype='wire'), - }) - child = Pattern(annotations={'length': [length]}) - top.ref('_seg') - tree['_top'] = top - tree['_seg'] = child - return tree - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - assert len(batch) == 1 - assert isinstance(batch[0].data, Library) - return batch[0].data - - lib = Library() - p = Pather(lib, tools=TraceTreeTool()) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 10) - p.render() - - assert '_seg' in lib - assert '_seg' in p.pattern.refs - assert p.pattern.referenced_patterns() <= set(lib.keys()) - -def test_pather_render_rejects_missing_single_use_tool_refs() -> None: - class MissingSingleUseTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - ptype = out_ptype or in_ptype or 'wire' - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=ptype, - endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), - commit_planner=lambda length: {'length': length}, - ),) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - tree = Library() - top = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((1, 0), pi, ptype='wire'), - }) - top.ref('_seg') - tree['_top'] = top - return tree - - lib = Library() - lib['_seg'] = Pattern(annotations={'stale': [1]}) - p = Pather(lib, tools=MissingSingleUseTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.straight('A', 10) - - with pytest.raises(BuildError, match='missing single-use refs'): - p.render() - - assert list(lib.keys()) == ['_seg'] - assert not p.pattern.refs - -def test_pather_render_allows_missing_non_single_use_tool_refs() -> None: - class SharedRefTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - ptype = out_ptype or in_ptype or 'wire' - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=ptype, - endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), - commit_planner=lambda length: {'length': length}, - ),) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - length = batch[0].data['length'] - tree = Library() - top = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), pi, ptype='wire'), - }) - top.ref('shared') - tree['_top'] = top - return tree - - lib = Library() - lib['shared'] = Pattern(annotations={'shared': [1]}) - p = Pather(lib, tools=SharedRefTool()) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.straight('A', 10) - p.render() - - assert 'shared' in p.pattern.refs - assert p.pattern.referenced_patterns() <= set(lib.keys()) - -@pytest.mark.parametrize('append', [True, False]) -def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: bool) -> None: - class WrongEndpointTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - ptype = out_ptype or in_ptype or 'wire' - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=ptype, - endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), - commit_planner=lambda length: {'length': length}, - ),) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - length = batch[0].data['length'] - tree = Library() - tree['_top'] = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length + 1, 0), pi, ptype='wire'), - }) - return tree - - lib = Library() - p = Pather(lib, tools=WrongEndpointTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.straight('A', 10) - - with pytest.raises(BuildError, match='does not match planned endpoint'): - p.render(append=append) - - assert not p.pattern.refs - assert not lib - - -def test_pather_render_rejects_opposite_output_rotation() -> None: - class WrongRotationTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - return (StraightOffer.generated('wire', lambda length: length),) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - length = batch[0].data - tree = Library() - tree['_top'] = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), 0, ptype='wire'), - }) - return tree - - p = Pather(Library(), tools=WrongRotationTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.straight('A', 10) - - with pytest.raises(ToolContractError, match='does not match planned endpoint'): - p.render() - - -def test_pather_render_allows_unspecified_output_rotation() -> None: - class UnspecifiedRotationTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - return (StraightOffer.generated('wire', lambda length: length),) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - length = batch[0].data - tree = Library() - tree['_top'] = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), None, ptype='wire'), - }) - return tree - - p = Pather(Library(), tools=UnspecifiedRotationTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.straight('A', 10) - p.render() - -@pytest.mark.parametrize('append', [True, False]) -def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None: - class WrongPtypeTool(Tool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - ptype = out_ptype or in_ptype or 'wire' - return (StraightOffer( - in_ptype=in_ptype, - out_ptype=ptype, - endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), - commit_planner=lambda length: {'length': length}, - ),) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - length = batch[0].data['length'] - tree = Library() - tree['_top'] = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), 0, ptype='metal'), - }) - return tree - - lib = Library() - p = Pather(lib, tools=WrongPtypeTool(), render='deferred') - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.straight('A', 10) - - with pytest.raises(BuildError, match='does not match planned endpoint'): - p.render(append=append) - - assert not p.pattern.refs - assert not lib - -def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - rp = Pather(lib, tools=tool, render='deferred') - rp.pattern.ports['A'] = Port((0, 0), rotation=0) - - rp.at('A').straight(5000) - rp.rename_ports({'A': None}) - - assert 'A' not in rp.pattern.ports - assert len(rp._paths['A']) == 1 - - rp.render() - assert rp.pattern.has_shapes() - assert 'A' not in rp.pattern.ports diff --git a/masque/test/test_pather_route_completion.py b/masque/test/test_pather_route_completion.py deleted file mode 100644 index 36fbd69..0000000 --- a/masque/test/test_pather_route_completion.py +++ /dev/null @@ -1,166 +0,0 @@ -from collections.abc import Mapping, Sequence -from typing import Any, Literal - -import numpy -import pytest - -from masque import Library, Pather, Port -from masque.builder import PathTool, RenderStep, RouteCompletionCallback, Tool -from masque.error import BuildError - - -def test_route_completion_can_label_before_automatic_render() -> None: - calls = 0 - - def label_endpoints(pather: Pather, endpoints: Mapping[str, Port]) -> None: - nonlocal calls - calls += 1 - assert not pather.pattern.has_shapes() - assert numpy.allclose(pather.ports['A'].offset, endpoints['A'].offset) - for name, port in endpoints.items(): - pather.label('LABELS', string=name, offset=port.offset) - - callback: RouteCompletionCallback = label_endpoints - p = Pather( - Library(), - tools=PathTool(layer='M1', width=1, ptype='wire'), - on_route_complete=callback, - ) - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.at('A').straight(5) - - assert calls == 1 - assert p.pattern.has_shapes() - assert len(p.pattern.labels['LABELS']) == 1 - assert p.pattern.labels['LABELS'][0].string == 'A' - assert numpy.allclose(p.pattern.labels['LABELS'][0].offset, p.ports['A'].offset) - - -def test_bundle_route_completion_is_ordered_read_only_and_isolated() -> None: - calls: list[Mapping[str, Port]] = [] - - def record(pather: Pather, endpoints: Mapping[str, Port]) -> None: - _ = pather - calls.append(endpoints) - with pytest.raises(TypeError): - endpoints['extra'] = Port((0, 0)) # type: ignore[index] - endpoints['B'].translate((100, 100)) - - p = Pather( - Library(), - tools=PathTool(layer='M1', width=1, ptype='wire'), - render='deferred', - on_route_complete=record, - ) - p.ports['B'] = Port((0, 2), rotation=0, ptype='wire') - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - p.trace(['A', 'B'], None, each=5) - - assert len(calls) == 1 - assert list(calls[0]) == ['A', 'B'] - assert numpy.allclose(p.ports['A'].offset, (-5, 0)) - assert numpy.allclose(p.ports['B'].offset, (-5, 2)) - - -def test_trace_into_completion_retains_consumed_source_endpoint() -> None: - calls: list[Mapping[str, Port]] = [] - - def record(pather: Pather, endpoints: Mapping[str, Port]) -> None: - assert set(pather.ports) == {'src'} - assert numpy.allclose(pather.ports['src'].offset, (20, 0)) - calls.append(endpoints) - - p = Pather( - Library(), - tools=PathTool(layer='M1', width=1, ptype='wire'), - render='deferred', - on_route_complete=record, - ) - p.ports['src'] = Port((0, 0), rotation=0, ptype='wire') - p.ports['dst'] = Port((-10, 0), rotation=numpy.pi, ptype='wire') - p.ports['thru'] = Port((20, 0), rotation=0, ptype='wire') - - p.trace_into('src', 'dst', thru='thru') - - assert len(calls) == 1 - assert list(calls[0]) == ['src'] - assert numpy.allclose(calls[0]['src'].offset, (-10, 0)) - - -def test_route_completion_exception_propagates_before_render() -> None: - def fail(pather: Pather, endpoints: Mapping[str, Port]) -> None: - _ = pather, endpoints - raise RuntimeError('completion failed') - - p = Pather( - Library(), - tools=PathTool(layer='M1', width=1, ptype='wire'), - on_route_complete=fail, - ) - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(RuntimeError, match='completion failed'): - p.straight('A', 5) - - assert numpy.allclose(p.ports['A'].offset, (-5, 0)) - assert p._paths['A'] - assert not p.pattern.has_shapes() - - -class NoRouteTool(Tool): - def primitive_offers( - self, - kind: Literal['straight', 'bend', 's', 'u'], - **kwargs: Any, - ) -> tuple[()]: - _ = kind, kwargs - return () - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs: Any, - ) -> Library: - _ = batch, port_names, kwargs - return Library() - - -def test_dead_bundle_fallback_invokes_route_completion_once() -> None: - calls: list[Mapping[str, Port]] = [] - p = Pather( - Library(), - tools=NoRouteTool(), - on_route_complete=lambda _pather, endpoints: calls.append(endpoints), - ) - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.ports['B'] = Port((0, 2), rotation=0, ptype='wire') - p.set_dead() - - p.trace(['B', 'A'], None, each=5) - - assert len(calls) == 1 - assert list(calls[0]) == ['B', 'A'] - assert numpy.allclose(calls[0]['B'].offset, (-5, 2)) - assert numpy.allclose(calls[0]['A'].offset, (-5, 0)) - assert not p.pattern.has_shapes() - - -def test_failed_route_does_not_invoke_route_completion() -> None: - calls = 0 - - def record(pather: Pather, endpoints: Mapping[str, Port]) -> None: - nonlocal calls - _ = pather, endpoints - calls += 1 - - p = Pather(Library(), tools=NoRouteTool(), on_route_complete=record) - p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - - with pytest.raises(BuildError): - p.straight('A', 5) - - assert calls == 0 diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py deleted file mode 100644 index 1e86ded..0000000 --- a/masque/test/test_pather_trace_into.py +++ /dev/null @@ -1,573 +0,0 @@ -from typing import Any - -import numpy -import pytest -from numpy import pi -from numpy.testing import assert_equal - -from masque import Library, PathTool, Port, Pather, RouteFailurePolicy -from masque.builder.planner import PreparedRouteResult, RoutePlanningError, RoutePortContext, RoutingPlanner -from masque.builder.planner.planner import Candidate, SolverRequest -from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool -from masque.error import BuildError, PortError - - -@pytest.fixture -def trace_into_setup() -> tuple[Pather, PathTool, Library]: - lib = Library() - tool = PathTool(layer=(1, 0), width=2, ptype="wire") - p = Pather(lib, tools=tool, render='immediate', render_append=False) - return p, tool, lib - - -def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = trace_into_setup - p.ports["src"] = Port((0, 0), 0, ptype="wire") - p.ports["dst"] = Port((-20, 0), pi, ptype="wire") - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - assert len(p.pattern.refs) == 1 - - -def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = trace_into_setup - p.ports["src"] = Port((0, 0), 0, ptype="wire") - p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire") - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - assert len(p.pattern.refs) == 1 - - -def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = trace_into_setup - p.ports["src"] = Port((0, 0), 0, ptype="wire") - p.ports["dst"] = Port((-20, -10), pi, ptype="wire") - - p.trace_into("src", "dst") - - assert "src" not in p.ports - assert "dst" not in p.ports - - -def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: - p, _tool, _lib = trace_into_setup - p.ports["src"] = Port((0, 0), 0, ptype="wire") - p.ports["dst"] = Port((-20, 0), pi, ptype="wire") - p.ports["other"] = Port((10, 10), 0) - - p.trace_into("src", "dst", thru="other") - - assert "src" in p.ports - assert_equal(p.ports["src"].offset, [10, 10]) - assert "other" not in p.ports - - -def test_pather_trace_into_shapes() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000) - p = Pather(lib, tools=tool) - - p.pattern.ports['A'] = Port((0, 0), rotation=0) - p.pattern.ports['B'] = Port((-10000, 0), rotation=pi) - p.at('A').trace_into('B', plug_destination=False) - assert 'B' in p.pattern.ports - assert 'A' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) - - p.pattern.ports['C'] = Port((0, 0), rotation=0) - p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi / 2) - p.at('C').trace_into('D', plug_destination=False) - assert 'D' in p.pattern.ports - assert 'C' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000)) - - p.pattern.ports['E'] = Port((0, 0), rotation=0) - p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi) - p.at('E').trace_into('F', plug_destination=False) - assert 'F' in p.pattern.ports - assert 'E' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000)) - - p.pattern.ports['G'] = Port((0, 0), rotation=0) - p.pattern.ports['H'] = Port((-10000, 2000), rotation=0) - p.at('G').trace_into('H', plug_destination=False) - assert 'H' in p.pattern.ports - assert 'G' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000)) - assert p.pattern.ports['G'].rotation is not None - assert numpy.isclose(p.pattern.ports['G'].rotation, pi) - - p.pattern.ports['I'] = Port((0, 0), rotation=pi / 2) - p.pattern.ports['J'] = Port((0, -10000), rotation=3 * pi / 2) - p.at('I').trace_into('J', plug_destination=False) - assert 'J' in p.pattern.ports - assert 'I' in p.pattern.ports - assert numpy.allclose(p.pattern.ports['I'].offset, (0, -10000)) - assert p.pattern.ports['I'].rotation is not None - assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) - - -@pytest.mark.parametrize( - 'dst', - [ - Port((-10_000, 0), rotation=pi), - Port((-10_000, 2_000), rotation=pi), - Port((-5_000, 5_000), rotation=pi / 2), - Port((-10_000, 2_000), rotation=0), - ], -) -def test_pather_trace_into_minimal_policy_accepts_required_topologies(dst: Port) -> None: - pather = Pather( - Library(), - tools=PathTool(layer='M1', width=1_000), - render='deferred', - ) - pather.ports['src'] = Port((0, 0), rotation=0) - pather.ports['dst'] = dst - - pather.trace_into( - 'src', - 'dst', - plug_destination=False, - plan_options={'bend_policy': 'minimal'}, - ) - - assert numpy.allclose(pather.ports['src'].offset, dst.offset) - assert pather.ports['src'].rotation is not None - assert numpy.isclose((pather.ports['src'].rotation - dst.rotation) % (2 * pi), pi) - - -def test_pather_trace_into_bend_policy_changes_real_solver_fallback() -> None: - def make_pather() -> Pather: - pather = Pather( - Library(), - tools=PathTool(layer='M1', width=2, ptype='wire'), - render='deferred', - ) - pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire') - pather.ports['dst'] = Port((2, 0), rotation=pi, ptype='wire') - return pather - - flexible = make_pather() - flexible.at('src').trace_into( - 'dst', - plug_destination=False, - plan_options={'bend_policy': 'flexible'}, - ) - - assert_equal(flexible.ports['src'].offset, (2, 0)) - assert flexible.ports['src'].rotation is not None - assert numpy.isclose(flexible.ports['src'].rotation, 0) - bend_roles = sum( - 1 if step.kind == 'bend' else 2 if step.kind in ('s', 'u') else 0 - for step in flexible._paths['src'] - ) - assert bend_roles == 4 - - minimal = make_pather() - with pytest.raises(BuildError): - minimal.at('src').trace_into( - 'dst', - plug_destination=False, - ) - - assert set(minimal.ports) == {'src', 'dst'} - assert_equal(minimal.ports['src'].offset, (0, 0)) - assert numpy.isclose(minimal.ports['src'].rotation, 0) - assert_equal(minimal.ports['dst'].offset, (2, 0)) - assert numpy.isclose(minimal.ports['dst'].rotation, pi) - assert not minimal._paths - - -def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None: - p = Pather( - Library(), - tools=PathTool(layer='M1', width=1000, ptype='wire'), - render='deferred', - ) - p.ports['src'] = Port((123.25, 456.75), rotation=0, ptype='wire') - p.ports['dst'] = Port((-99_999_876.75, 20_000_456.75), rotation=0, ptype='wire') - - p.trace_into('src', 'dst') - - assert 'src' not in p.ports - assert 'dst' not in p.ports - assert [step.kind for step in p._paths['src']] == ['straight', 'bend', 'straight', 'bend', 'plug'] - - -def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None: - class TransitionTool(Tool): - def primitive_offers( - self, - kind, # noqa: ANN001 - *, - in_ptype=None, # noqa: ANN001 - out_ptype=None, # noqa: ANN001 - **kwargs, # noqa: ANN003 - ) -> tuple[PrimitiveOffer, ...]: - _ = in_ptype - if kind == 'straight': - def native_endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype='m1wire') - - native = StraightOffer( - in_ptype='m1wire', - out_ptype='m1wire', - endpoint_planner=native_endpoint, - commit_planner=lambda length: {'kind': 'straight', 'length': length}, - ) - if out_ptype in ('unk', 'm1wire'): - return (native,) - - def transition_endpoint(length: float) -> Port: - return Port((length, 0), rotation=pi, ptype='m2wire') - - transition = StraightOffer( - in_ptype='m1wire', - out_ptype='m2wire', - length_domain=(2500, numpy.inf), - endpoint_planner=transition_endpoint, - commit_planner=lambda length: {'kind': 'transition', 'length': length}, - ) - return native, transition - - if kind == 'bend': - ccw = bool(kwargs['ccw']) - - def endpoint(length: float) -> Port: - return Port( - (length, 500 if ccw else -500), - rotation=-pi / 2 if ccw else pi / 2, - ptype='m1wire', - ) - - return (BendOffer( - in_ptype='m1wire', - out_ptype='m1wire', - ccw=ccw, - length_domain=(500, numpy.inf), - endpoint_planner=endpoint, - commit_planner=lambda length: {'kind': 'bend', 'length': length}, - ),) - - return () - - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 - tree, pat = Library.mktree('transition_tool') - pat.add_port_pair(names=port_names, ptype=batch[-1].end_port.ptype if batch else 'm1wire') - return tree - - p = Pather(Library(), tools=TransitionTool(), render='deferred') - p.pattern.ports['src'] = Port((-65000, -11500), rotation=pi / 2, ptype='m1wire') - p.pattern.ports['dst'] = Port((-100000, -100000), rotation=pi, ptype='m2wire') - - p.trace_into('src', 'dst') - - assert 'src' not in p.pattern.ports - assert 'dst' not in p.pattern.ports - - -def test_pather_trace_into_dead_updates_ports_without_geometry() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1000, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((-10000, 0), rotation=pi, ptype='wire') - p.set_dead() - - p.trace_into('A', 'B', plug_destination=False) - - assert set(p.pattern.ports) == {'A', 'B'} - assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) - assert p.pattern.ports['A'].rotation is not None - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p._paths['A']) == 0 - assert not p.pattern.has_shapes() - assert not p.pattern.has_refs() - - -def test_pather_trace_into_planning_failure_leaves_state_unchanged() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire') - - with pytest.raises(BuildError): - p.trace_into('A', 'B', plug_destination=False, out_ptype='other') - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5)) - assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2) - assert len(p._paths['A']) == 0 - - -def test_pather_trace_into_rename_failure_propagates() -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire') - p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire') - - with pytest.raises(PortError, match='overwritten'): - p.trace_into('A', 'B', plug_destination=False, thru='other') - - -@pytest.mark.parametrize( - ('dst', 'kwargs', 'match'), - [ - (Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'route arguments: x'), - (Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'route arguments: length'), - (Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'route arguments: length'), - ], -) -def test_pather_trace_into_rejects_reserved_route_kwargs( - dst: Port, - kwargs: dict[str, Any], - match: str, - ) -> None: - lib = Library() - tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) - p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - p.pattern.ports['B'] = dst - - _ = match - with pytest.raises(TypeError, match='unexpected keyword argument'): - p.trace_into('A', 'B', plug_destination=False, **kwargs) - - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset) - assert dst.rotation is not None - assert p.pattern.ports['B'].rotation is not None - assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation) - assert len(p._paths['A']) == 0 - - -class TraceIntoBudgetSolver: - def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None: - self.successes = successes - self.fatal_at = set() if fatal_at is None else fatal_at - self.attempts: list[tuple[int, int]] = [] - - def solve( - self, - *, - min_bends: int = 0, - max_bends: int | None = None, - ) -> Candidate: - assert max_bends is not None - band = (min_bends, max_bends) - self.attempts.append(band) - if band in self.fatal_at: - raise RoutePlanningError('fatal', policy=RouteFailurePolicy.FATAL) - if band not in self.successes: - raise BuildError('try next budget') - return Candidate((), Port((0, 0), rotation=0, ptype='wire'), 0.0, 0, 0.0) - - -class TraceIntoBudgetPlanner(RoutingPlanner): - def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None: - super().__init__() - self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at) - self.solver_requests = 0 - - def solver_for_request(self, request: SolverRequest) -> Any: - _ = request - self.solver_requests += 1 - return self.solver - - def prepared_result_from_legs( - self, - legs: Any, - *, - renames: tuple[tuple[str, str], ...] = (), - ) -> PreparedRouteResult: - _ = legs, renames - return PreparedRouteResult(()) - - -@pytest.mark.parametrize( - ('dst', 'successes', 'attempts'), - [ - (Port((-10, 0), rotation=pi, ptype='wire'), {(0, 2)}, [(0, 2)]), - (Port((-10, 0), rotation=pi, ptype='wire'), {(4, 4)}, [(0, 2), (4, 4)]), - (Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(1, 1)}, [(1, 1)]), - (Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(3, 3)}, [(1, 1), (3, 3)]), - ], -) -def test_trace_into_reuses_solver_across_staged_bend_bands( - dst: Port, - successes: set[tuple[int, int]], - attempts: list[tuple[int, int]], - ) -> None: - planner = TraceIntoBudgetPlanner(successes) - context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) - - planner.plan_trace_into( - context, - 'dst', - dst, - out_ptype=None, - plug_destination=True, - thru=None, - plan_options={'bend_policy': 'flexible'}, - ) - - assert planner.solver.attempts == attempts - assert planner.solver_requests == 1 - - -def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None: - planner = TraceIntoBudgetPlanner({(4, 4)}, fatal_at={(0, 2)}) - context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) - - with pytest.raises(RoutePlanningError, match='fatal'): - planner.plan_trace_into( - context, - 'dst', - Port((-10, 0), rotation=pi, ptype='wire'), - out_ptype=None, - plug_destination=True, - thru=None, - plan_options={'bend_policy': 'flexible'}, - ) - - assert planner.solver.attempts == [(0, 2)] - assert planner.solver_requests == 1 - - -def test_trace_into_bend_bands_respect_max_bends() -> None: - class OneBendPlanner(RoutingPlanner): - TRACE_INTO_MAX_BENDS = 1 - - planner = OneBendPlanner(bend_policy='flexible') - - assert planner.trace_into_bend_bands('straight') == ((0, 0),) - assert planner.trace_into_bend_bands('s') == ((0, 0),) - assert planner.trace_into_bend_bands('bend') == ((1, 1),) - - -@pytest.mark.parametrize( - ('family', 'expected'), - [ - ('straight', ((0, 0),)), - ('bend', ((1, 1),)), - ('s', ((2, 2),)), - ('u', ((2, 2),)), - ], -) -def test_trace_into_default_minimal_bend_bands(family: str, expected: tuple[tuple[int, int], ...]) -> None: - planner = RoutingPlanner() - - assert planner.trace_into_bend_bands(family) == expected - assert planner.trace_into_bend_bands(family, bend_policy='flexible') == ( - ((1, 1), (3, 3)) if family == 'bend' else ((0, 2), (4, 4)) - ) - - -@pytest.mark.parametrize( - ('dst', 'required_band'), - [ - (Port((-10, 0), rotation=pi, ptype='wire'), (0, 0)), - (Port((-10, -5), rotation=pi, ptype='wire'), (2, 2)), - (Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), (1, 1)), - (Port((-10, -5), rotation=0, ptype='wire'), (2, 2)), - ], -) -def test_trace_into_minimal_policy_uses_orientation_required_band( - dst: Port, - required_band: tuple[int, int], - ) -> None: - planner = TraceIntoBudgetPlanner({required_band}) - context = RoutePortContext( - 'src', - Port((0, 0), rotation=0, ptype='wire'), - PathTool(layer='M1', width=1, ptype='wire'), - ) - - planner.plan_trace_into( - context, - 'dst', - dst, - out_ptype=None, - plug_destination=True, - thru=None, - plan_options={'bend_policy': 'minimal'}, - ) - - assert planner.solver.attempts == [required_band] - - -def test_trace_into_minimal_policy_rejects_fallback_without_mutation() -> None: - planner = TraceIntoBudgetPlanner({(4, 4)}) - pather = Pather( - Library(), - tools=PathTool(layer='M1', width=1, ptype='wire'), - planner=planner, - render='deferred', - ) - pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire') - pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire') - - with pytest.raises(BuildError, match='try next budget'): - pather.trace_into('src', 'dst', plan_options={'bend_policy': 'minimal'}) - - assert planner.solver.attempts == [(0, 0)] - assert set(pather.ports) == {'src', 'dst'} - assert_equal(pather.ports['src'].offset, (0, 0)) - assert_equal(pather.ports['dst'].offset, (-10, 0)) - assert not pather._paths - - -def test_trace_into_bend_policy_planner_default_and_route_override() -> None: - context = RoutePortContext( - 'src', - Port((0, 0), rotation=0, ptype='wire'), - PathTool(layer='M1', width=1, ptype='wire'), - ) - dst = Port((-10, 0), rotation=pi, ptype='wire') - minimal_planner = TraceIntoBudgetPlanner({(4, 4)}) - - with pytest.raises(BuildError, match='try next budget'): - minimal_planner.plan_trace_into( - context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None, - ) - assert minimal_planner.solver.attempts == [(0, 0)] - - flexible_planner = TraceIntoBudgetPlanner({(4, 4)}) - flexible_planner.plan_trace_into( - context, - 'dst', - dst, - out_ptype=None, - plug_destination=True, - thru=None, - plan_options={'bend_policy': 'flexible'}, - ) - assert flexible_planner.solver.attempts == [(0, 2), (4, 4)] - - -def test_trace_into_rejects_invalid_bend_policy() -> None: - with pytest.raises(BuildError, match='Invalid trace_into bend policy'): - RoutingPlanner(bend_policy='sideways') # type: ignore[arg-type] - - pather = Pather( - Library(), - tools=PathTool(layer='M1', width=1, ptype='wire'), - render='deferred', - ) - pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire') - pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire') - - with pytest.raises(BuildError, match='Invalid trace_into bend policy'): - pather.trace_into('src', 'dst', plan_options={'bend_policy': 'sideways'}) diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py deleted file mode 100644 index 26b1255..0000000 --- a/masque/test/test_pattern.py +++ /dev/null @@ -1,310 +0,0 @@ -import pytest -import copy -from typing import cast -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..error import PatternError -from ..abstract import Abstract -from ..pattern import Pattern -from ..shapes import Polygon -from ..ref import Ref -from ..ports import Port, PortError -from ..label import Label -from ..repetition import Grid - - -def test_pattern_init() -> None: - pat = Pattern() - assert pat.is_empty() - assert not pat.has_shapes() - assert not pat.has_refs() - assert not pat.has_labels() - assert not pat.has_ports() - - -def test_pattern_with_elements() -> None: - poly = Polygon.square(10) - label = Label("test", offset=(5, 5)) - ref = Ref(offset=(100, 100)) - port = Port((0, 0), 0) - - pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port}) - - assert pat.has_shapes() - assert pat.has_labels() - assert pat.has_refs() - assert pat.has_ports() - assert not pat.is_empty() - assert pat.shapes[(1, 0)] == [poly] - assert pat.labels[(1, 2)] == [label] - assert pat.refs["sub"] == [ref] - assert pat.ports["P1"] == port - - -def test_pattern_append() -> None: - pat1 = Pattern() - pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) - - pat2 = Pattern() - pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]]) - - pat1.append(pat2) - assert len(pat1.shapes[(1, 0)]) == 1 - assert len(pat1.shapes[(2, 0)]) == 1 - - -def test_pattern_translate() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) - pat.ports["P1"] = Port((5, 5), 0) - - pat.translate_elements((10, 20)) - - # Polygon.translate adds to vertices, and offset is always (0,0) - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20]) - assert_equal(pat.ports["P1"].offset, [15, 25]) - - -def test_pattern_scale() -> None: - pat = Pattern() - # Polygon.rect sets an offset in its constructor which is immediately translated into vertices - pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1) - pat.scale_by(2) - - # Vertices should be scaled - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) - - -def test_pattern_rotate() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]]) - # Rotate 90 degrees CCW around (0,0) - pat.rotate_around((0, 0), pi / 2) - - # [10, 0] rotated 90 deg around (0,0) is [0, 10] - assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10) - - -def test_pattern_mirror() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]]) - # Mirror across X axis (y -> -y) - pat.mirror(0) - - assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5]) - - -def test_pattern_get_bounds() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]]) - pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]]) - - bounds = pat.get_bounds() - assert_equal(bounds, [[-5, -5], [10, 10]]) - - -def test_pattern_flatten_preserves_ports_only_child() -> None: - child = Pattern(ports={"P1": Port((1, 2), 0)}) - - parent = Pattern() - parent.ref("child", offset=(10, 10)) - - parent.flatten({"child": child}, flatten_ports=True) - - assert set(parent.ports) == {"P1"} - assert parent.ports["P1"].rotation == 0 - assert tuple(parent.ports["P1"].offset) == (11.0, 12.0) - - -def test_pattern_flatten_repeated_ref_with_ports_raises() -> None: - child = Pattern(ports={"P1": Port((1, 2), 0)}) - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - parent = Pattern() - parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2)) - - with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'): - parent.flatten({"child": child}, flatten_ports=True) - - -def test_pattern_place_requires_abstract_for_reference() -> None: - parent = Pattern() - child = Pattern() - - with pytest.raises(PatternError, match='Must provide an `Abstract`'): - parent.place(child) - - assert not parent.ports - - -def test_pattern_place_append_requires_pattern_atomically() -> None: - parent = Pattern() - child = Abstract("child", {"A": Port((1, 2), 0)}) - - with pytest.raises(PatternError, match='Must provide a full `Pattern`'): - parent.place(child, append=True) - - assert not parent.ports - - -def test_pattern_place_append_annotation_conflict_is_atomic() -> None: - parent = Pattern(annotations={"k": [1]}) - child = Pattern(annotations={"k": [2]}, ports={"A": Port((1, 2), 0)}) - - with pytest.raises(PatternError, match="Annotation keys overlap"): - parent.place(child, append=True) - - assert not parent.ports - assert parent.annotations == {"k": [1]} - - -def test_pattern_place_skip_geometry_overwrites_colliding_ports_last_wins() -> None: - parent = Pattern(ports={ - "A": Port((5, 5), 0), - "keep": Port((9, 9), 0), - }) - child = Pattern(ports={ - "X": Port((1, 0), 0), - "Y": Port((2, 0), pi / 2), - }) - - parent.place(child, port_map={"X": "A", "Y": "A"}, skip_geometry=True, append=True) - - assert set(parent.ports) == {"A", "keep"} - assert_allclose(parent.ports["A"].offset, (2, 0)) - assert parent.ports["A"].rotation is not None - assert_allclose(parent.ports["A"].rotation, pi / 2) - - -def test_pattern_interface() -> None: - source = Pattern() - source.ports["A"] = Port((10, 20), 0, ptype="test") - - iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_") - - assert "in_A" in iface.ports - assert "out_A" in iface.ports - assert iface.ports["in_A"].rotation is not None - assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10) - assert iface.ports["out_A"].rotation is not None - assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10) - assert iface.ports["in_A"].ptype == "test" - assert iface.ports["out_A"].ptype == "test" - - -def test_pattern_interface_duplicate_port_map_targets_raise() -> None: - source = Pattern() - source.ports["A"] = Port((10, 20), 0) - source.ports["B"] = Port((30, 40), pi) - - with pytest.raises(PortError, match='Duplicate targets in `port_map`'): - Pattern.interface(source, port_map={"A": "X", "B": "X"}) - - -def test_pattern_interface_empty_port_map_copies_no_ports() -> None: - source = Pattern() - source.ports["A"] = Port((10, 20), 0) - source.ports["B"] = Port((30, 40), pi) - - assert not Pattern.interface(source, port_map={}).ports - assert not Pattern.interface(source, port_map=[]).ports - - -def test_pattern_plug_requires_abstract_for_reference_atomically() -> None: - parent = Pattern(ports={"X": Port((0, 0), 0)}) - child = Pattern(ports={"A": Port((0, 0), pi)}) - - with pytest.raises(PatternError, match='Must provide an `Abstract`'): - parent.plug(child, {"X": "A"}) - - assert set(parent.ports) == {"X"} - - -def test_pattern_plug_append_annotation_conflict_is_atomic() -> None: - parent = Pattern( - annotations={"k": [1]}, - ports={"X": Port((0, 0), 0), "Q": Port((9, 9), 0)}, - ) - child = Pattern( - annotations={"k": [2]}, - ports={"A": Port((0, 0), pi), "B": Port((5, 0), 0)}, - ) - - with pytest.raises(PatternError, match="Annotation keys overlap"): - parent.plug(child, {"X": "A"}, map_out={"B": "Y"}, append=True) - - assert set(parent.ports) == {"X", "Q"} - assert_allclose(parent.ports["X"].offset, (0, 0)) - assert_allclose(parent.ports["Q"].offset, (9, 9)) - assert parent.annotations == {"k": [1]} - - -def test_pattern_plug_skip_geometry_overwrites_colliding_ports_last_wins() -> None: - parent = Pattern(ports={ - "A": Port((0, 0), 0, ptype="wire"), - "B": Port((99, 99), 0, ptype="wire"), - }) - child = Pattern(ports={ - "in": Port((0, 0), pi, ptype="wire"), - "X": Port((10, 0), 0, ptype="wire"), - "Y": Port((20, 0), 0, ptype="wire"), - }) - - parent.plug(child, {"A": "in"}, map_out={"X": "B", "Y": "B"}, skip_geometry=True, append=True) - - assert "A" not in parent.ports - assert "B" in parent.ports - assert_allclose(parent.ports["B"].offset, (20, 0)) - assert parent.ports["B"].rotation is not None - assert_allclose(parent.ports["B"].rotation, 0) - - -def test_pattern_append_port_conflict_is_atomic() -> None: - pat1 = Pattern() - pat1.ports["A"] = Port((0, 0), 0) - - pat2 = Pattern() - pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - pat2.ports["A"] = Port((1, 0), 0) - - with pytest.raises(PatternError, match="Port names overlap"): - pat1.append(pat2) - - assert not pat1.shapes - assert set(pat1.ports) == {"A"} - - -def test_pattern_append_annotation_conflict_is_atomic() -> None: - pat1 = Pattern(annotations={"k": [1]}) - pat2 = Pattern(annotations={"k": [2]}) - pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - with pytest.raises(PatternError, match="Annotation keys overlap"): - pat1.append(pat2) - - assert not pat1.shapes - assert pat1.annotations == {"k": [1]} - - -def test_pattern_deepcopy_does_not_share_shape_repetitions() -> None: - pat = Pattern() - pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2)) - - pat2 = copy.deepcopy(pat) - pat2.scale_by(2) - - assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).repetition.a_vector, [10, 0]) - assert_allclose(cast("Polygon", pat2.shapes[(1, 0)][0]).repetition.a_vector, [20, 0]) - - -def test_pattern_flatten_does_not_mutate_child_repetitions() -> None: - child = Pattern() - child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2)) - - parent = Pattern() - parent.ref("child", scale=2) - - parent.flatten({"child": child}) - - assert_allclose(cast("Polygon", child.shapes[(1, 0)][0]).repetition.a_vector, [10, 0]) diff --git a/masque/test/test_poly_collection.py b/masque/test/test_poly_collection.py deleted file mode 100644 index 70b17d2..0000000 --- a/masque/test/test_poly_collection.py +++ /dev/null @@ -1,89 +0,0 @@ -import pytest -from numpy.testing import assert_equal - -from ..error import PatternError -from ..shapes import Circle, Ellipse, Polygon, PolyCollection - - -def test_poly_collection_init() -> None: - verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 4] - pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) - assert len(list(pc.polygon_vertices)) == 2 - assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) - -def test_poly_collection_to_polygons() -> None: - verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 4] - pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) - polys = pc.to_polygons() - assert len(polys) == 2 - assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]]) - -def test_poly_collection_holes() -> None: - # PolyCollection represents separate polygon boundaries, including nested boundaries. - verts = [ - [0, 0], - [10, 0], - [10, 10], - [0, 10], # Poly 1 - [2, 2], - [2, 8], - [8, 8], - [8, 2], # Poly 2 - ] - offsets = [0, 4] - pc = PolyCollection(verts, offsets) - polys = pc.to_polygons() - assert len(polys) == 2 - assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]]) - assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]]) - -def test_poly_collection_constituent_empty() -> None: - # Duplicate offsets create an empty constituent slice between valid polygons. - verts = [ - [0, 0], - [1, 0], - [0, 1], # Tri - [10, 10], - [11, 10], - [11, 11], - [10, 11], # Square - ] - offsets = [0, 3, 3] - pc = PolyCollection(verts, offsets) - with pytest.raises(PatternError): - pc.to_polygons() - -def test_poly_collection_valid() -> None: - verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] - offsets = [0, 3] - pc = PolyCollection(verts, offsets) - assert len(pc.to_polygons()) == 2 - shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))] - sorted_shapes = sorted(shapes) - assert len(sorted_shapes) == 4 - assert sorted(sorted_shapes) == sorted_shapes - -def test_poly_collection_normalized_form_reconstruction_is_independent() -> None: - pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0]) - _intrinsic, _extrinsic, rebuild = pc.normalized_form(1) - - clone = rebuild() - clone.vertex_offsets[:] = [5] - - assert_equal(pc.vertex_offsets, [0]) - assert_equal(clone.vertex_offsets, [5]) - -def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None: - pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0]) - _intrinsic, _extrinsic, rebuild = pc.normalized_form(1) - - first = rebuild() - second = rebuild() - first.vertex_offsets[:] = [7] - - assert_equal(first.vertex_offsets, [7]) - assert_equal(second.vertex_offsets, [0]) - assert_equal(pc.vertex_offsets, [0]) diff --git a/masque/test/test_polygon.py b/masque/test/test_polygon.py deleted file mode 100644 index 5d98ad9..0000000 --- a/masque/test/test_polygon.py +++ /dev/null @@ -1,125 +0,0 @@ -import pytest -import numpy -from numpy.testing import assert_equal - - -from ..shapes import Polygon -from ..utils import R90 -from ..error import PatternError - - -@pytest.fixture -def polygon() -> Polygon: - return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) - - -def test_vertices(polygon: Polygon) -> None: - assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) - - -def test_xs(polygon: Polygon) -> None: - assert_equal(polygon.xs, [0, 1, 1, 0]) - - -def test_ys(polygon: Polygon) -> None: - assert_equal(polygon.ys, [0, 0, 1, 1]) - - -def test_offset(polygon: Polygon) -> None: - assert_equal(polygon.offset, [0, 0]) - - -def test_square() -> None: - square = Polygon.square(1) - assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]]) - - -def test_rectangle() -> None: - rectangle = Polygon.rectangle(1, 2) - assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]]) - - -def test_rect() -> None: - rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1) - assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) - - rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2) - assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) - - rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2) - assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]]) - - rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0) - assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]]) - - with pytest.raises(PatternError): - Polygon.rect(xctr=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(xmin=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(xmax=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(lx=0, yctr=-2, ymax=0) - with pytest.raises(PatternError): - Polygon.rect(yctr=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ymin=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ymax=0, xctr=-2, xmax=0) - with pytest.raises(PatternError): - Polygon.rect(ly=0, xctr=-2, xmax=0) - - -def test_octagon() -> None: - octagon = Polygon.octagon(side_length=1) # regular=True - assert_equal(octagon.vertices.shape, (8, 2)) - diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0) - side_len = numpy.sqrt((diff * diff).sum(axis=1)) - assert numpy.allclose(side_len, 1) - - -def test_to_polygons(polygon: Polygon) -> None: - assert polygon.to_polygons() == [polygon] - - -def test_get_bounds_single(polygon: Polygon) -> None: - assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]]) - - -def test_rotate(polygon: Polygon) -> None: - rotated_polygon = polygon.rotate(R90) - assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]]) - - -def test_mirror(polygon: Polygon) -> None: - mirrored_by_y = polygon.deepcopy().mirror(1) - assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]]) - print(polygon.vertices) - mirrored_by_x = polygon.deepcopy().mirror(0) - assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]]) - - -def test_scale_by(polygon: Polygon) -> None: - scaled_polygon = polygon.scale_by(2) - assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]]) - - -def test_clean_vertices(polygon: Polygon) -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices() - assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) - - -def test_remove_duplicate_vertices() -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices() - assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]]) - - -def test_remove_colinear_vertices() -> None: - polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices() - assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) - - -def test_vertices_dtype() -> None: - polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32)) - polygon.scale_by(0.5) - assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]]) diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py deleted file mode 100644 index a4c560a..0000000 --- a/masque/test/test_ports.py +++ /dev/null @@ -1,365 +0,0 @@ -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..ports import Port, PortList -from ..error import PortError -from ..pattern import Pattern - - -def test_port_init() -> None: - p = Port(offset=(10, 20), rotation=pi / 2, ptype="test") - assert_equal(p.offset, [10, 20]) - assert p.rotation == pi / 2 - assert p.ptype == "test" - - -def test_port_transform() -> None: - p = Port(offset=(10, 0), rotation=0) - p.rotate_around((0, 0), pi / 2) - assert_allclose(p.offset, [0, 10], atol=1e-10) - assert p.rotation is not None - assert_allclose(p.rotation, pi / 2, atol=1e-10) - - p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset - assert_allclose(p.offset, [0, 10], atol=1e-10) - # rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2 - assert p.rotation is not None - assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10) - - -def test_port_flip_across() -> None: - p = Port(offset=(10, 0), rotation=0) - p.flip_across(axis=1) # Mirror across x=0: flips x-offset - assert_equal(p.offset, [-10, 0]) - # rotation was 0, mirrored(1) -> pi - assert p.rotation is not None - assert_allclose(p.rotation, pi, atol=1e-10) - - -def test_port_measure_travel() -> None: - p1 = Port((0, 0), 0) - p2 = Port((10, 5), pi) # Facing each other - - (travel, jog), rotation = p1.measure_travel(p2) - assert travel == 10 - assert jog == 5 - assert rotation == pi - - -def test_port_list_measure_travel() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = { - "A": Port((0, 0), 0), - "B": Port((10, 5), pi), - } - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - (travel, jog), rotation = pl.measure_travel("A", "B") - assert travel == 10 - assert jog == 5 - assert rotation == pi - - -def test_port_describe_any_rotation() -> None: - p = Port((0, 0), None) - assert p.describe() == "pos=(0, 0), rot=any" - - -def test_port_list_rename() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((0, 0), 0)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - pl.rename_ports({"A": "B"}) - assert "A" not in pl.ports - assert "B" in pl.ports - - -def test_port_list_rename_missing_port_raises() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((0, 0), 0)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Ports to rename were not found"): - pl.rename_ports({"missing": "B"}) - assert set(pl.ports) == {"A"} - - -def test_port_list_rename_colliding_targets_raises() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((0, 0), 0), "B": Port((1, 0), 0)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Renamed ports would collide"): - pl.rename_ports({"A": "C", "B": "C"}) - assert set(pl.ports) == {"A", "B"} - - -def test_port_list_add_port_pair_requires_distinct_names() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports: dict[str, Port] = {} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Port names must be distinct"): - pl.add_port_pair(names=("A", "A")) - assert not pl.ports - - -def test_port_list_plugged() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - pl.plugged({"A": "B"}) - assert not pl.ports # Both should be removed - - -def test_port_list_plugged_uses_coordinate_relative_tolerance() -> None: - pattern = Pattern(ports={ - "A": Port((10, 10), 0), - "B": Port((10 + 5e-8, 10), pi), - }) - - pattern.plugged({"A": "B"}) - - assert not pattern.ports - - -def test_port_list_plugged_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None: - caplog.set_level("WARNING", logger="masque.ports") - - compatible_cases = [ - ("wire", "wire"), - ("unk", "wire"), - (None, "wire"), - ] - for left, right in compatible_cases: - caplog.clear() - pl = Pattern(ports={ - "A": Port((10, 10), 0, ptype=left), # type: ignore[arg-type] - "B": Port((10, 10), pi, ptype=right), # type: ignore[arg-type] - }) - - pl.plugged({"A": "B"}) - - assert not any("conflicting types" in record.message for record in caplog.records) - - caplog.clear() - pl = Pattern(ports={ - "A": Port((10, 10), 0, ptype="wire"), - "B": Port((10, 10), pi, ptype="metal"), - }) - - pl.plugged({"A": "B"}) - - assert any("conflicting types" in record.message for record in caplog.records) - - -def test_port_list_plugged_empty_raises() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Must provide at least one port connection"): - pl.plugged({}) - assert set(pl.ports) == {"A", "B"} - - -def test_port_list_plugged_missing_port_raises() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Connection source ports were not found"): - pl.plugged({"missing": "B"}) - assert set(pl.ports) == {"A", "B"} - - -def test_port_list_plugged_reused_port_raises_atomically() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((0, 0), None), "B": Port((0, 0), None), "C": Port((0, 0), None)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - for connections in ({"A": "A"}, {"A": "B", "C": "B"}): - pl = MyPorts() - with pytest.raises(PortError, match="Each port may appear in at most one connection"): - pl.plugged(connections) - assert set(pl.ports) == {"A", "B", "C"} - - pl = MyPorts() - with pytest.raises(PortError, match="Connection destination ports were not found"): - pl.plugged({"A": "missing"}) - assert set(pl.ports) == {"A", "B", "C"} - - -def test_port_list_plugged_mismatch() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = { - "A": Port((10, 10), 0), - "B": Port((11, 10), pi), # Offset mismatch - } - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError): - pl.plugged({"A": "B"}) - - -def test_port_list_check_ports_duplicate_map_in_values_raise() -> None: - class MyPorts(PortList): - def __init__(self) -> None: - self._ports = {"A": Port((0, 0), 0), "B": Port((0, 0), 0)} - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, val: dict[str, Port]) -> None: - self._ports = val - - pl = MyPorts() - with pytest.raises(PortError, match="Duplicate values in `map_in`"): - pl.check_ports({"X", "Y"}, map_in={"A": "X", "B": "X"}) - assert set(pl.ports) == {"A", "B"} - - -def test_pattern_plug_rejects_map_out_on_connected_ports_atomically() -> None: - host = Pattern(ports={"A": Port((0, 0), 0)}) - other = Pattern(ports={"X": Port((0, 0), pi), "Y": Port((5, 0), 0)}) - - with pytest.raises(PortError, match="`map_out` keys conflict with connected ports"): - host.plug(other, {"A": "X"}, map_out={"X": "renamed", "Y": "out"}, append=True) - - assert set(host.ports) == {"A"} - - -def test_find_transform_requires_connection_map() -> None: - host = Pattern(ports={"A": Port((0, 0), 0)}) - other = Pattern(ports={"X": Port((0, 0), pi)}) - - with pytest.raises(PortError, match="at least one port connection"): - host.find_transform(other, {}) - - with pytest.raises(PortError, match="at least one port connection"): - Pattern.find_port_transform({}, {}, {}) - - -def test_find_transform_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None: - caplog.set_level("WARNING", logger="masque.ports") - - compatible_cases = [ - ("wire", "wire"), - ("wire", "unk"), - ("wire", None), - ] - for left, right in compatible_cases: - caplog.clear() - host = Pattern(ports={"A": Port((0, 0), 0, ptype=left)}) # type: ignore[arg-type] - other = Pattern(ports={"X": Port((0, 0), pi, ptype=right)}) # type: ignore[arg-type] - - host.find_transform(other, {"A": "X"}) - - assert not any("conflicting types" in record.message for record in caplog.records) - - caplog.clear() - host = Pattern(ports={"A": Port((0, 0), 0, ptype="wire")}) - other = Pattern(ports={"X": Port((0, 0), pi, ptype="metal")}) - - host.find_transform(other, {"A": "X"}) - - assert any("conflicting types" in record.message for record in caplog.records) - - caplog.clear() - host.find_transform(other, {"A": "X"}, ok_connections={("wire", "metal")}) - - assert not any("conflicting types" in record.message for record in caplog.records) diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py deleted file mode 100644 index 11b4619..0000000 --- a/masque/test/test_ports2data.py +++ /dev/null @@ -1,141 +0,0 @@ -import numpy -import pytest -from numpy.testing import assert_allclose - -from ..utils.ports2data import ports_to_data, data_to_ports -from ..pattern import Pattern -from ..ports import Port -from ..library import Library -from ..error import PortError -from ..repetition import Grid - - -def test_ports2data_roundtrip() -> None: - pat = Pattern() - pat.ports["P1"] = Port((10, 20), numpy.pi / 2, ptype="test") - - layer = (10, 0) - ports_to_data(pat, layer) - - assert len(pat.labels[layer]) == 1 - assert pat.labels[layer][0].string == "P1:test 90" - assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0) - - # New pattern, read ports back - pat2 = Pattern() - pat2.labels[layer] = pat.labels[layer] - data_to_ports([layer], {}, pat2) - - assert "P1" in pat2.ports - assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10) - assert pat2.ports["P1"].rotation is not None - assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10) - assert pat2.ports["P1"].ptype == "test" - - -def test_data_to_ports_hierarchical() -> None: - lib = Library() - - # Child has port data in labels - child = Pattern() - layer = (10, 0) - child.label(layer=layer, string="A:type1 0", offset=(5, 0)) - lib["child"] = child - - # Parent references child - parent = Pattern() - parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2) - - # Read ports hierarchically (max_depth > 0) - data_to_ports([layer], lib, parent, max_depth=1) - - # child port A (5,0) rot 0 - # transformed by parent ref: rot pi/2, trans (100, 100) - # (5,0) rot pi/2 -> (0, 5) - # (0, 5) + (100, 100) = (100, 105) - # rot 0 + pi/2 = pi/2 - assert "A" in parent.ports - assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) - assert parent.ports["A"].rotation is not None - assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) - - -def test_data_to_ports_ignores_empty_dangling_ref_bucket() -> None: - parent = Pattern() - parent.refs["missing"] - - data_to_ports([(10, 0)], Library(), parent, max_depth=1) - - assert not parent.ports - - -def test_data_to_ports_hierarchical_scaled_ref() -> None: - lib = Library() - - child = Pattern() - layer = (10, 0) - child.label(layer=layer, string="A:type1 0", offset=(5, 0)) - lib["child"] = child - - parent = Pattern() - parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2, scale=2) - - data_to_ports([layer], lib, parent, max_depth=1) - - assert "A" in parent.ports - assert_allclose(parent.ports["A"].offset, [100, 110], atol=1e-10) - assert parent.ports["A"].rotation is not None - assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) - - -def test_data_to_ports_hierarchical_repeated_ref_warns_and_keeps_best_effort( - caplog: pytest.LogCaptureFixture, - ) -> None: - lib = Library() - - child = Pattern() - layer = (10, 0) - child.label(layer=layer, string="A:type1 0", offset=(5, 0)) - lib["child"] = child - - parent = Pattern() - parent.ref("child", repetition=Grid(a_vector=(100, 0), a_count=3)) - - caplog.set_level("WARNING") - data_to_ports([layer], lib, parent, max_depth=1) - - assert "A" in parent.ports - assert_allclose(parent.ports["A"].offset, [5, 0], atol=1e-10) - assert any("importing only the base instance ports" in record.message for record in caplog.records) - - -def test_data_to_ports_hierarchical_collision_is_atomic() -> None: - lib = Library() - - child = Pattern() - layer = (10, 0) - child.label(layer=layer, string="A:type1 0", offset=(5, 0)) - lib["child"] = child - - parent = Pattern() - parent.ref("child", offset=(0, 0)) - parent.ref("child", offset=(10, 0)) - - with pytest.raises(PortError, match="Device ports conflict with existing ports"): - data_to_ports([layer], lib, parent, max_depth=1) - - assert not parent.ports - - -def test_data_to_ports_flat_bad_angle_warns_and_skips( - caplog: pytest.LogCaptureFixture, - ) -> None: - layer = (10, 0) - pat = Pattern() - pat.label(layer=layer, string="A:type1 nope", offset=(5, 0)) - - caplog.set_level("WARNING") - data_to_ports([layer], {}, pat) - - assert not pat.ports - assert any('bad angle' in record.message for record in caplog.records) diff --git a/masque/test/test_raw_constructors.py b/masque/test/test_raw_constructors.py deleted file mode 100644 index 2f86ba0..0000000 --- a/masque/test/test_raw_constructors.py +++ /dev/null @@ -1,97 +0,0 @@ -import numpy -from numpy import pi -from numpy.testing import assert_allclose - -from ..shapes import Arc, Circle, Ellipse, Path, Text - - -def test_circle_raw_constructor_matches_public() -> None: - raw = Circle._from_raw( - radius=5.0, - offset=numpy.array([1.0, 2.0]), - annotations={'1': ['circle']}, - ) - public = Circle( - radius=5.0, - offset=(1.0, 2.0), - annotations={'1': ['circle']}, - ) - assert raw == public - - -def test_ellipse_raw_constructor_matches_public() -> None: - raw = Ellipse._from_raw( - radii=numpy.array([3.0, 5.0]), - offset=numpy.array([1.0, 2.0]), - rotation=5 * pi / 2, - annotations={'2': ['ellipse']}, - ) - public = Ellipse( - radii=(3.0, 5.0), - offset=(1.0, 2.0), - rotation=5 * pi / 2, - annotations={'2': ['ellipse']}, - ) - assert raw == public - - -def test_arc_raw_constructor_matches_public() -> None: - raw = Arc._from_raw( - radii=numpy.array([10.0, 6.0]), - angles=numpy.array([0.0, pi / 2]), - width=2.0, - offset=numpy.array([1.0, 2.0]), - rotation=5 * pi / 2, - annotations={'3': ['arc']}, - ) - public = Arc( - radii=(10.0, 6.0), - angles=(0.0, pi / 2), - width=2.0, - offset=(1.0, 2.0), - rotation=5 * pi / 2, - annotations={'3': ['arc']}, - ) - assert raw == public - - -def test_path_raw_constructor_matches_public() -> None: - raw = Path._from_raw( - vertices=numpy.array([[0.0, 0.0], [10.0, 0.0], [10.0, 5.0]]), - width=2.0, - cap=Path.Cap.SquareCustom, - cap_extensions=numpy.array([1.0, 3.0]), - annotations={'4': ['path']}, - ) - public = Path( - vertices=((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)), - width=2.0, - cap=Path.Cap.SquareCustom, - cap_extensions=(1.0, 3.0), - annotations={'4': ['path']}, - ) - assert raw == public - assert raw.cap_extensions is not None - assert_allclose(raw.cap_extensions, [1.0, 3.0]) - - -def test_text_raw_constructor_matches_public() -> None: - raw = Text._from_raw( - string='RAW', - height=12.0, - font_path='font.otf', - offset=numpy.array([1.0, 2.0]), - rotation=5 * pi / 2, - mirrored=True, - annotations={'5': ['text']}, - ) - public = Text( - string='RAW', - height=12.0, - font_path='font.otf', - offset=(1.0, 2.0), - rotation=5 * pi / 2, - mirrored=True, - annotations={'5': ['text']}, - ) - assert raw == public diff --git a/masque/test/test_rect_collection.py b/masque/test/test_rect_collection.py deleted file mode 100644 index 449f4fa..0000000 --- a/masque/test/test_rect_collection.py +++ /dev/null @@ -1,70 +0,0 @@ -import copy - -import numpy -import pytest -from numpy.testing import assert_allclose, assert_equal - -from ..error import PatternError -from ..shapes import Polygon, RectCollection - - -def test_rect_collection_init_and_to_polygons() -> None: - rects = RectCollection([[10, 10, 12, 12], [0, 0, 5, 5]]) - assert_equal(rects.rects, [[0, 0, 5, 5], [10, 10, 12, 12]]) - - polys = rects.to_polygons() - assert len(polys) == 2 - assert all(isinstance(poly, Polygon) for poly in polys) - assert_equal(polys[0].vertices, [[0, 0], [0, 5], [5, 5], [5, 0]]) - - -def test_rect_collection_rejects_invalid_rects() -> None: - with pytest.raises(PatternError): - RectCollection([[0, 0, 1]]) - with pytest.raises(PatternError): - RectCollection([[5, 0, 1, 2]]) - with pytest.raises(PatternError): - RectCollection([[0, 5, 1, 2]]) - - -def test_rect_collection_raw_constructor_matches_public() -> None: - raw = RectCollection._from_raw( - rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]), - annotations={'1': ['rects']}, - ) - public = RectCollection( - [[0, 0, 5, 5], [10, 10, 12, 12]], - annotations={'1': ['rects']}, - ) - assert raw == public - assert_equal(raw.get_bounds_single(), [[0, 0], [12, 12]]) - - -def test_rect_collection_manhattan_transforms() -> None: - rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]]) - - mirrored = copy.deepcopy(rects).mirror(1) - assert_equal(mirrored.rects, [[-2, 0, 0, 4], [-12, 20, -10, 22]]) - - scaled = copy.deepcopy(rects).scale_by(-2) - assert_equal(scaled.rects, [[-4, -8, 0, 0], [-24, -44, -20, -40]]) - - rotated = copy.deepcopy(rects).rotate(numpy.pi / 2) - assert_equal(rotated.rects, [[-4, 0, 0, 2], [-22, 10, -20, 12]]) - - -def test_rect_collection_non_manhattan_rotation_raises() -> None: - rects = RectCollection([[0, 0, 2, 4]]) - with pytest.raises(PatternError, match='Manhattan rotations'): - rects.rotate(numpy.pi / 4) - - -def test_rect_collection_normalized_form_rebuild_is_independent() -> None: - rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]]) - _intrinsic, extrinsic, rebuild = rects.normalized_form(2) - - clone = rebuild() - clone.rects[:] = [[1, 1, 2, 2], [3, 3, 4, 4]] - - assert_allclose(extrinsic[0], [6, 11.5]) - assert_equal(rects.rects, [[0, 0, 2, 4], [10, 20, 12, 22]]) diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py deleted file mode 100644 index de330fa..0000000 --- a/masque/test/test_ref.py +++ /dev/null @@ -1,111 +0,0 @@ -from typing import cast, TYPE_CHECKING -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..error import MasqueError -from ..pattern import Pattern -from ..ref import Ref -from ..repetition import Grid - -if TYPE_CHECKING: - from ..shapes import Polygon - - -def test_ref_init() -> None: - ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0) - assert_equal(ref.offset, [10, 20]) - assert ref.rotation == pi / 4 - assert ref.mirrored is True - assert ref.scale == 2.0 - - -def test_ref_as_pattern() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - ref = Ref(offset=(10, 10), rotation=pi / 2, scale=2.0) - transformed_pat = ref.as_pattern(sub_pat) - - # Check transformed shape - shape = cast("Polygon", transformed_pat.shapes[(1, 0)][0]) - # ref.as_pattern deepcopies sub_pat then applies transformations: - # 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]] - # 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]] - # 3. pattern.translate_elements((10,10)) -> vertices [[10,10], [10,12], [8,10]] - - assert_allclose(shape.vertices, [[10, 10], [10, 12], [8, 10]], atol=1e-10) - - -def test_ref_with_repetition() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) - - rep = Grid(a_vector=(10, 0), b_vector=(0, 10), a_count=2, b_count=2) - ref = Ref(repetition=rep) - - repeated_pat = ref.as_pattern(sub_pat) - # Should have 4 shapes - assert len(repeated_pat.shapes[(1, 0)]) == 4 - - first_verts = sorted([tuple(cast("Polygon", s).vertices[0]) for s in repeated_pat.shapes[(1, 0)]]) - assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)] - - -def test_ref_get_bounds() -> None: - sub_pat = Pattern() - sub_pat.polygon((1, 0), vertices=[[0, 0], [5, 0], [0, 5]]) - - ref = Ref(offset=(10, 10), scale=2.0) - bounds = ref.get_bounds_single(sub_pat) - # sub_pat bounds [[0,0], [5,5]] - # scaled [[0,0], [10,10]] - # translated [[10,10], [20,20]] - assert_equal(bounds, [[10, 10], [20, 20]]) - - -def test_ref_get_bounds_single_ignores_repetition_for_non_manhattan_rotation() -> None: - sub_pat = Pattern() - sub_pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=2) - - rep = Grid(a_vector=(5, 0), b_vector=(0, 7), a_count=3, b_count=2) - ref = Ref(offset=(10, 20), rotation=pi / 4, repetition=rep) - - bounds = ref.get_bounds_single(sub_pat) - repeated_bounds = ref.get_bounds(sub_pat) - - assert bounds is not None - assert repeated_bounds is not None - assert repeated_bounds[1, 0] > bounds[1, 0] - assert repeated_bounds[1, 1] > bounds[1, 1] - - -def test_ref_copy() -> None: - ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]}) - ref2 = ref1.copy() - assert ref1 == ref2 - assert ref1 is not ref2 - - ref2.offset[0] = 100 - assert ref1.offset[0] == 1 - - -def test_ref_rejects_nonpositive_scale() -> None: - with pytest.raises(MasqueError, match='Scale must be positive'): - Ref(scale=0) - - with pytest.raises(MasqueError, match='Scale must be positive'): - Ref(scale=-1) - - -def test_ref_scale_by_rejects_nonpositive_scale() -> None: - ref = Ref(scale=2.0) - - with pytest.raises(MasqueError, match='Scale must be positive'): - ref.scale_by(-1) - - -def test_ref_eq_unrelated_objects_is_false() -> None: - ref = Ref() - assert not (ref == None) - assert not (ref == object()) diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py deleted file mode 100644 index 00d2d7a..0000000 --- a/masque/test/test_repetition.py +++ /dev/null @@ -1,87 +0,0 @@ -import pytest -from numpy.testing import assert_equal, assert_allclose -from numpy import pi - -from ..repetition import Grid, Arbitrary -from ..error import PatternError - - -def test_grid_displacements() -> None: - grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) - disps = sorted([tuple(d) for d in grid.displacements]) - assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)] - - -def test_grid_1d() -> None: - grid = Grid(a_vector=(10, 0), a_count=3) - disps = sorted([tuple(d) for d in grid.displacements]) - assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)] - - -def test_grid_rotate() -> None: - grid = Grid(a_vector=(10, 0), a_count=2) - grid.rotate(pi / 2) - assert_allclose(grid.a_vector, [0, 10], atol=1e-10) - - -def test_grid_get_bounds() -> None: - grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) - bounds = grid.get_bounds() - assert_equal(bounds, [[0, 0], [10, 5]]) - - -def test_arbitrary_displacements() -> None: - pts = [[0, 0], [10, 20], [-5, 30]] - arb = Arbitrary(pts) - disps = arb.displacements - assert len(disps) == 3 - assert any((disps == [0, 0]).all(axis=1)) - assert any((disps == [10, 20]).all(axis=1)) - assert any((disps == [-5, 30]).all(axis=1)) - - -def test_arbitrary_transform() -> None: - arb = Arbitrary([[10, 0]]) - arb.rotate(pi / 2) - assert_allclose(arb.displacements, [[0, 10]], atol=1e-10) - - arb.mirror(0) - assert_allclose(arb.displacements, [[0, -10]], atol=1e-10) - - -def test_arbitrary_empty_repetition_is_allowed() -> None: - arb = Arbitrary([]) - assert arb.displacements.shape == (0, 2) - assert arb.get_bounds() is None - - -def test_arbitrary_rejects_non_nx2_displacements() -> None: - for displacements in ([[1], [2]], [[1, 2, 3]], [1, 2, 3]): - with pytest.raises(PatternError, match='displacements must be convertible to an Nx2 ndarray'): - Arbitrary(displacements) - - -def test_grid_count_setters_reject_nonpositive_values() -> None: - for attr, value, message in ( - ('a_count', 0, 'a_count'), - ('a_count', -1, 'a_count'), - ('b_count', 0, 'b_count'), - ('b_count', -1, 'b_count'), - ): - grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) - with pytest.raises(PatternError, match=message): - setattr(grid, attr, value) - - -def test_repetition_less_equal_includes_equality() -> None: - grid_a = Grid(a_vector=(10, 0), a_count=2) - grid_b = Grid(a_vector=(10, 0), a_count=2) - assert grid_a == grid_b - assert grid_a <= grid_b - assert grid_a >= grid_b - - arb_a = Arbitrary([[0, 0], [1, 0]]) - arb_b = Arbitrary([[0, 0], [1, 0]]) - assert arb_a == arb_b - assert arb_a <= arb_b - assert arb_a >= arb_b diff --git a/masque/test/test_rotation_consistency.py b/masque/test/test_rotation_consistency.py deleted file mode 100644 index f574f52..0000000 --- a/masque/test/test_rotation_consistency.py +++ /dev/null @@ -1,133 +0,0 @@ - -from typing import cast -import numpy as np -from numpy.testing import assert_allclose -from ..pattern import Pattern -from ..ref import Ref -from ..label import Label -from ..repetition import Grid - -def test_ref_rotate_intrinsic() -> None: - # Intrinsic rotate() should NOT affect repetition - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep) - - ref.rotate(np.pi/2) - - assert_allclose(ref.rotation, np.pi/2, atol=1e-10) - # Grid vector should still be (10, 0) - assert ref.repetition is not None - assert_allclose(cast('Grid', ref.repetition).a_vector, [10, 0], atol=1e-10) - -def test_ref_rotate_around_extrinsic() -> None: - # Extrinsic rotate_around() SHOULD affect repetition - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep) - - ref.rotate_around((0, 0), np.pi/2) - - assert_allclose(ref.rotation, np.pi/2, atol=1e-10) - # Grid vector should be rotated to (0, 10) - assert ref.repetition is not None - assert_allclose(cast('Grid', ref.repetition).a_vector, [0, 10], atol=1e-10) - -def test_pattern_rotate_around_extrinsic() -> None: - # Pattern.rotate_around() SHOULD affect repetition of its elements - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.rotate_around((0, 0), np.pi/2) - - # Check the ref inside the pattern - ref_in_pat = pat.refs['cell'][0] - assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10) - # Grid vector should be rotated to (0, 10) - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10) - -def test_label_rotate_around_extrinsic() -> None: - # Extrinsic rotate_around() SHOULD affect repetition of labels - rep = Grid(a_vector=(10, 0), a_count=2) - lbl = Label("test", repetition=rep, offset=(5, 0)) - - lbl.rotate_around((0, 0), np.pi/2) - - # Label offset should be (0, 5) - assert_allclose(lbl.offset, [0, 5], atol=1e-10) - # Grid vector should be rotated to (0, 10) - assert lbl.repetition is not None - assert_allclose(cast('Grid', lbl.repetition).a_vector, [0, 10], atol=1e-10) - -def test_pattern_rotate_elements_intrinsic() -> None: - # rotate_elements() should NOT affect repetition - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.rotate_elements(np.pi/2) - - ref_in_pat = pat.refs['cell'][0] - assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10) - # Grid vector should still be (10, 0) - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 0], atol=1e-10) - -def test_pattern_rotate_element_centers_extrinsic() -> None: - # rotate_element_centers() SHOULD affect repetition and offset - rep = Grid(a_vector=(10, 0), a_count=2) - ref = Ref(repetition=rep, offset=(5, 0)) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.rotate_element_centers(np.pi/2) - - ref_in_pat = pat.refs['cell'][0] - # Offset should be (0, 5) - assert_allclose(ref_in_pat.offset, [0, 5], atol=1e-10) - # Grid vector should be rotated to (0, 10) - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10) - # Ref rotation should NOT be changed - assert_allclose(ref_in_pat.rotation, 0, atol=1e-10) - -def test_pattern_mirror_elements_intrinsic() -> None: - # mirror_elements() should NOT affect repetition or offset - rep = Grid(a_vector=(10, 5), a_count=2) - ref = Ref(repetition=rep, offset=(5, 2)) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.mirror_elements(axis=0) # Mirror across x (flip y) - - ref_in_pat = pat.refs['cell'][0] - assert ref_in_pat.mirrored is True - # Repetition and offset should be unchanged - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 5], atol=1e-10) - assert_allclose(ref_in_pat.offset, [5, 2], atol=1e-10) - -def test_pattern_mirror_element_centers_extrinsic() -> None: - # mirror_element_centers() SHOULD affect repetition and offset - rep = Grid(a_vector=(10, 5), a_count=2) - ref = Ref(repetition=rep, offset=(5, 2)) - - pat = Pattern() - pat.refs['cell'].append(ref) - - pat.mirror_element_centers(axis=0) # Mirror across x (flip y) - - ref_in_pat = pat.refs['cell'][0] - # Offset should be (5, -2) - assert_allclose(ref_in_pat.offset, [5, -2], atol=1e-10) - # Grid vector should be (10, -5) - assert ref_in_pat.repetition is not None - assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, -5], atol=1e-10) - # Ref mirrored state should NOT be changed - assert ref_in_pat.mirrored is False diff --git a/masque/test/test_shape_ordering.py b/masque/test/test_shape_ordering.py deleted file mode 100644 index fea4efa..0000000 --- a/masque/test/test_shape_ordering.py +++ /dev/null @@ -1,15 +0,0 @@ -from ..shapes import Circle, Ellipse, Polygon - - -def test_shape_comparisons() -> None: - c1 = Circle(radius=10) - c2 = Circle(radius=20) - assert c1 < c2 - assert not (c2 < c1) - - p1 = Polygon([[0, 0], [10, 0], [10, 10]]) - p2 = Polygon([[0, 0], [10, 0], [10, 11]]) - assert p1 < p2 - - assert c1 < p1 or p1 < c1 - assert (c1 < p1) != (p1 < c1) diff --git a/masque/test/test_shape_transforms.py b/masque/test/test_shape_transforms.py deleted file mode 100644 index 13bb77d..0000000 --- a/masque/test/test_shape_transforms.py +++ /dev/null @@ -1,45 +0,0 @@ -from numpy import pi -from numpy.testing import assert_equal, assert_allclose - -from ..shapes import Arc, Ellipse - - -def test_shape_mirror() -> None: - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) - e.mirror(0) - assert_equal(e.offset, [10, 20]) - assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) - - a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20)) - a.mirror(0) - assert_equal(a.offset, [10, 20]) - assert_allclose(a.angles, [0, -pi / 4], atol=1e-10) - - a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) - a.mirror(1) - # The pi rotation already reflects the X-major focus across the Y axis. - assert a.angle_ref == Arc.AngleRef.FocusPos - - a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) - a.mirror(0) - assert a.angle_ref == Arc.AngleRef.FocusNeg - -def test_shape_flip_across() -> None: - e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) - e.flip_across(axis=0) - assert_equal(e.offset, [10, -20]) - assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) - - e = Ellipse(radii=(10, 5), offset=(10, 20)) - e.flip_across(y=10) - assert_equal(e.offset, [10, 0]) - -def test_shape_scale() -> None: - e = Ellipse(radii=(10, 5)) - e.scale_by(2) - assert_equal(e.radii, [20, 10]) - - a = Arc(radii=(10, 5), angles=(0, pi), width=2) - a.scale_by(0.5) - assert_equal(a.radii, [5, 2.5]) - assert a.width == 1 diff --git a/masque/test/test_svg.py b/masque/test/test_svg.py deleted file mode 100644 index c0dcd97..0000000 --- a/masque/test/test_svg.py +++ /dev/null @@ -1,100 +0,0 @@ -from pathlib import Path -import xml.etree.ElementTree as ET - -import numpy -import pytest -from numpy.testing import assert_allclose - -pytest.importorskip("svgwrite") - -from ..library import Library -from ..pattern import Pattern -from ..file import svg - - -SVG_NS = "{http://www.w3.org/2000/svg}" -XLINK_HREF = "{http://www.w3.org/1999/xlink}href" - - -def _child_transform(svg_path: Path) -> tuple[float, ...]: - root = ET.fromstring(svg_path.read_text()) - for use in root.iter(f"{SVG_NS}use"): - if use.attrib.get(XLINK_HREF) == "#child": - raw = use.attrib["transform"] - assert raw.startswith("matrix(") and raw.endswith(")") - return tuple(float(value) for value in raw[7:-1].split()) - raise AssertionError("No child reference found in SVG output") - - -def test_svg_ref_rotation_uses_correct_affine_transform(tmp_path: Path) -> None: - lib = Library() - child = Pattern() - child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) - lib["child"] = child - - top = Pattern() - top.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2) - lib["top"] = top - - svg_path = tmp_path / "rotation.svg" - svg.writefile(lib, "top", str(svg_path)) - - assert_allclose(_child_transform(svg_path), (0, 2, -2, 0, 3, 4), atol=1e-10) - - -def test_svg_ref_mirroring_changes_affine_transform(tmp_path: Path) -> None: - base = Library() - child = Pattern() - child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) - base["child"] = child - - top_plain = Pattern() - top_plain.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=False) - base["plain"] = top_plain - - plain_path = tmp_path / "plain.svg" - svg.writefile(base, "plain", str(plain_path)) - plain_transform = _child_transform(plain_path) - - mirrored = Library() - mirrored["child"] = child.deepcopy() - top_mirrored = Pattern() - top_mirrored.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=True) - mirrored["mirrored"] = top_mirrored - - mirrored_path = tmp_path / "mirrored.svg" - svg.writefile(mirrored, "mirrored", str(mirrored_path)) - mirrored_transform = _child_transform(mirrored_path) - - assert_allclose(plain_transform, (0, 2, -2, 0, 3, 4), atol=1e-10) - assert_allclose(mirrored_transform, (0, 2, 2, 0, 3, 4), atol=1e-10) - - -def test_svg_uses_unique_ids_for_colliding_mangled_names(tmp_path: Path) -> None: - lib = Library() - first = Pattern() - first.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) - lib["a b"] = first - - second = Pattern() - second.polygon("1", vertices=[[0, 0], [2, 0], [0, 2]]) - lib["a-b"] = second - - top = Pattern() - top.ref("a b") - top.ref("a-b", offset=(5, 0)) - lib["top"] = top - - svg_path = tmp_path / "colliding_ids.svg" - svg.writefile(lib, "top", str(svg_path)) - - root = ET.fromstring(svg_path.read_text()) - ids = [group.attrib["id"] for group in root.iter(f"{SVG_NS}g")] - top_group = next(group for group in root.iter(f"{SVG_NS}g") if group.attrib["id"] == "top") - hrefs = [use.attrib[XLINK_HREF] for use in top_group.iter(f"{SVG_NS}use")] - - assert len(set(ids)) == len(ids) - assert len(hrefs) == 2 - assert len(set(hrefs)) == 2 - assert all(href.startswith("#") for href in hrefs) - assert all(href[1:] in ids for href in hrefs) diff --git a/masque/test/test_text.py b/masque/test/test_text.py deleted file mode 100644 index 1b6770d..0000000 --- a/masque/test/test_text.py +++ /dev/null @@ -1,47 +0,0 @@ -from pathlib import Path - -import pytest -import numpy - -from ..shapes import Polygon, Text - - -def test_text_to_polygons() -> None: - pytest.importorskip("freetype") - font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" - if not Path(font_path).exists(): - pytest.skip("Font file not found") - - t = Text("Hi", height=10, font_path=font_path) - polys = t.to_polygons() - assert len(polys) > 0 - assert all(isinstance(p, Polygon) for p in polys) - - # Each character produces polygons with distinct horizontal placement. - char_x_means = [p.vertices[:, 0].mean() for p in polys] - assert len(set(char_x_means)) >= 2 - -def test_text_bounds_and_normalized_form() -> None: - pytest.importorskip("freetype") - font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" - if not Path(font_path).exists(): - pytest.skip("Font file not found") - - text = Text("Hi", height=10, font_path=font_path) - _intrinsic, extrinsic, ctor = text.normalized_form(5) - normalized = ctor() - - assert extrinsic[1] == 2 - assert normalized.height == 5 - - bounds = text.get_bounds_single() - assert bounds is not None - assert numpy.isfinite(bounds).all() - assert numpy.all(bounds[1] > bounds[0]) - -def test_text_mirroring_affects_comparison() -> None: - text = Text("A", height=10, font_path="dummy.ttf") - mirrored = Text("A", height=10, font_path="dummy.ttf", mirrored=True) - - assert text != mirrored - assert (text < mirrored) != (mirrored < text) diff --git a/masque/test/test_tool_contract.py b/masque/test/test_tool_contract.py deleted file mode 100644 index 275f052..0000000 --- a/masque/test/test_tool_contract.py +++ /dev/null @@ -1,338 +0,0 @@ -from typing import Any - -import numpy -import pytest -from numpy import pi - -from masque.builder import ( - AutoTool, - BendOffer, - PathTool, - PrimitiveKind, - PrimitiveOffer, - RenderStep, - SOffer, - StraightOffer, - Tool, - ToolContractCase, - ToolContractError, - UOffer, - validate_tool_contract, - ) -from masque.error import BuildError -from masque.library import ILibrary, Library, SINGLE_USE_PREFIX -from masque.pattern import Pattern -from masque.ports import Port - - -def make_straight(length: float, *, ptype: str = 'wire') -> Pattern: - return Pattern(ports={ - 'A': Port((0, 0), 0, ptype=ptype), - 'B': Port((length, 0), pi, ptype=ptype), - }) - - -class EmptyTool(Tool): - def primitive_offers( - self, - kind: PrimitiveKind, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs: Any, - ) -> tuple[PrimitiveOffer, ...]: - _ = kind, in_ptype, out_ptype, kwargs - return () - - def render( - self, - batch: tuple[RenderStep, ...], - *, - port_names: tuple[str, str] = ('A', 'B'), - ) -> ILibrary: - _ = batch - tree, pattern = Library.mktree('empty_tool') - pattern.add_port_pair(names=port_names) - return tree - - -@pytest.mark.parametrize( - ('offer', 'kind', 'opcode'), - [ - (StraightOffer('wire', 'wire'), 'straight', 'L'), - (BendOffer('wire', 'wire'), 'bend', 'L'), - (SOffer('wire', 'wire'), 's', 'S'), - (UOffer('wire', 'wire'), 'u', 'U'), - ], - ) -def test_offer_kind_is_canonical_and_opcode_is_derived( - offer: StraightOffer | BendOffer | SOffer | UOffer, - kind: str, - opcode: str, - ) -> None: - assert offer.kind == kind - assert offer.opcode == opcode - - -def test_render_step_stores_kind_and_derives_opcode() -> None: - tool = PathTool(layer='M1', width=1, ptype='wire') - port = Port((0, 0), 0, ptype='wire') - step = RenderStep('straight', tool, port, port, None) - plug = RenderStep('plug', None, port, port, None) - - assert step.kind == 'straight' - assert step.opcode == 'L' - assert step.transformed(numpy.zeros(2), 0, numpy.zeros(2)).kind == 'straight' - assert step.mirrored(0).kind == 'straight' - assert plug.opcode == 'P' - - with pytest.raises(BuildError, match='Unrecognized RenderStep kind'): - RenderStep('L', tool, port, port, None) # type: ignore[arg-type] - with pytest.raises(BuildError, match='requires tool=None'): - RenderStep('plug', tool, port, port, None) - - -def test_standard_offer_endpoint_callback_runs_once_per_solver_evaluation() -> None: - endpoint_calls: list[float] = [] - received_endpoints: list[Port] = [] - - def endpoint(length: float) -> Port: - endpoint_calls.append(length) - return Port((length, 0), pi, ptype='wire') - - def cost(length: float, out_port: Port) -> float: - _ = length - received_endpoints.append(out_port) - return out_port.x - - class OneOfferTool(EmptyTool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - return (StraightOffer( - in_ptype='wire', - out_ptype='wire', - cost=cost, - endpoint_planner=endpoint, - commit_planner=lambda length: length, - ),) - - from masque.builder import Pather - - pather = Pather( - Library(), - ports={'A': Port((0, 0), 0, ptype='wire')}, - tools=OneOfferTool(), - render='deferred', - ) - pather.straight('A', 5) - - assert endpoint_calls.count(5) == 1 - assert len(received_endpoints) >= 1 - - -def test_custom_cost_at_override_remains_authoritative() -> None: - calls: list[float] = [] - - class CustomCostOffer(StraightOffer): - def cost_at(self, parameter: float) -> float: - calls.append(parameter) - return 0 - - offer = CustomCostOffer( - in_ptype='wire', - out_ptype='wire', - endpoint_planner=lambda length: Port((length, 0), pi, ptype='wire'), - commit_planner=lambda length: length, - ) - - class CustomCostTool(EmptyTool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - return (offer,) if kind == 'straight' else () - - from masque.builder import Pather - - pather = Pather( - Library(), - ports={'A': Port((0, 0), 0, ptype='wire')}, - tools=CustomCostTool(), - render='deferred', - ) - pather.straight('A', 5) - assert 5 in calls - - -def test_solver_rejects_offer_kind_mismatch() -> None: - class WrongKindTool(EmptyTool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - return (SOffer.generated( - 'wire', - lambda jog: Port((1, jog), pi, ptype='wire'), - lambda jog: jog, - ),) - - from masque.builder import Pather - - pather = Pather( - Library(), - ports={'A': Port((0, 0), 0, ptype='wire')}, - tools=WrongKindTool(), - render='deferred', - ) - with pytest.raises(ToolContractError, match='returned.*s.*offer'): - pather.straight('A', 5) - - -def test_validate_tool_contract_accepts_pathtool() -> None: - tool = PathTool(layer='M1', width=2, ptype='wire') - validate_tool_contract(tool, ( - ToolContractCase('straight', in_ptype='wire', check_bbox=True), - ToolContractCase('bend', in_ptype='wire', ccw=False, check_bbox=True), - ToolContractCase('bend', in_ptype='wire', ccw=True, check_bbox=True), - ToolContractCase('s', in_ptype='wire', check_bbox=True), - ToolContractCase('u', in_ptype='wire', require_offers=False), - )) - - -def test_validate_tool_contract_accepts_autotool_with_explicit_probe() -> None: - tool = AutoTool().add_straight( - make_straight, - 'wire', - 'A', - length_range=(1, 10), - ) - validate_tool_contract(tool, ( - ToolContractCase('straight', in_ptype='wire', probe_parameters=(7,)), - )) - - -def test_validate_tool_contract_empty_offer_policy() -> None: - tool = EmptyTool() - validate_tool_contract(tool, (ToolContractCase('u', require_offers=False),)) - - with pytest.raises(ExceptionGroup) as exc_info: - validate_tool_contract(tool, (ToolContractCase('u'),)) - assert all(isinstance(err, ToolContractError) for err in exc_info.value.exceptions) - assert any('no offers' in str(err) for err in exc_info.value.exceptions) - - -def test_validate_tool_contract_rejects_unmatched_explicit_probe() -> None: - tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5)) - - with pytest.raises(ExceptionGroup, match='Tool contract validation') as exc_info: - validate_tool_contract(tool, ( - ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)), - )) - assert any('outside every discovered offer domain' in str(err) for err in exc_info.value.exceptions) - - -def test_validate_tool_contract_aggregates_independent_violations() -> None: - class BrokenTool(EmptyTool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - return (StraightOffer( - in_ptype='wire', - out_ptype='wire', - endpoint_planner=lambda length: Port((length + 1, 0), 0, ptype='wrong'), - commit_planner=lambda length: length, - ),) - - def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002 - return Library() - - with pytest.raises(ExceptionGroup) as exc_info: - validate_tool_contract(BrokenTool(), ( - ToolContractCase('straight', in_ptype='wire', label='broken straight'), - )) - - errors = exc_info.value.exceptions - assert len(errors) > 1 - assert all(isinstance(err, ToolContractError) for err in errors) - assert all('broken straight' in str(err) for err in errors) - - -def test_validate_tool_contract_detects_repeated_discovery_changes() -> None: - class ChangingTool(EmptyTool): - calls = 0 - - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - if kind != 'straight': - return () - self.calls += 1 - shift = float(self.calls - 1) - return (StraightOffer( - in_ptype='wire', - out_ptype='wire', - endpoint_planner=lambda length: Port((length + shift, 0), pi, ptype='wire'), - commit_planner=lambda length: length, - ),) - - with pytest.raises(ExceptionGroup) as exc_info: - validate_tool_contract(ChangingTool(), ( - ToolContractCase('straight', in_ptype='wire'), - )) - assert any('changed after repeated discovery' in str(err) for err in exc_info.value.exceptions) - - -def test_validate_tool_contract_checks_render_ports_and_single_use_refs() -> None: - class BrokenRenderTool(EmptyTool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else () - - def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002 - tree = Library() - pattern = Pattern(ports={port_names[0]: Port((0, 0), 0, ptype='wire')}) - pattern.ref(SINGLE_USE_PREFIX + 'missing') - tree['top'] = pattern - return tree - - with pytest.raises(ExceptionGroup) as exc_info: - validate_tool_contract(BrokenRenderTool(), ( - ToolContractCase('straight', in_ptype='wire'), - )) - messages = [str(err) for err in exc_info.value.exceptions] - assert any('missing single-use refs' in message for message in messages) - assert any('missing ports' in message for message in messages) - - -def test_validate_tool_contract_bbox_is_opt_in() -> None: - tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5)) - validate_tool_contract(tool, (ToolContractCase('straight', in_ptype='wire'),)) - - # AutoTool supplies bbox support, so use a minimal custom offer without it. - class NoBBoxTool(EmptyTool): - def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 - return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else () - - def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002 - length = batch[0].data - tree = Library() - tree['top'] = Pattern(ports={ - port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), pi, ptype='wire'), - }) - return tree - - validate_tool_contract(NoBBoxTool(), (ToolContractCase('straight', in_ptype='wire'),)) - with pytest.raises(ExceptionGroup) as exc_info: - validate_tool_contract(NoBBoxTool(), ( - ToolContractCase('straight', in_ptype='wire', check_bbox=True), - )) - assert any('bbox_at()' in str(err) for err in exc_info.value.exceptions) - - -@pytest.mark.parametrize( - 'kwargs', - [ - {'kind': 'straight', 'ccw': True}, - {'kind': 'bend'}, - {'kind': 'straight', 'probe_parameters': (numpy.inf,)}, - {'kind': 'straight', 'tool_options': {'ccw': True}}, - ], - ) -def test_tool_contract_case_validates_configuration(kwargs: dict[str, Any]) -> None: - with pytest.raises(ValueError, match='ccw|requires|finite|reserved'): - ToolContractCase(**kwargs) # type: ignore[arg-type] diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py deleted file mode 100644 index fcef0de..0000000 --- a/masque/test/test_utils.py +++ /dev/null @@ -1,275 +0,0 @@ -from pathlib import Path - -import numpy -from numpy.testing import assert_equal, assert_allclose -from numpy import pi -import pytest - -from ..utils import ( - DeferredDict, - apply_transforms, - normalize_mirror, - poly_contains_points, - remove_colinear_vertices, - remove_duplicate_vertices, - rotation_matrix_2d, -) -from ..file.utils import tmpfile -from ..utils.curves import bezier, circular_arc, euler_bend, euler_spiral -from ..error import PatternError - - -def test_remove_duplicate_vertices() -> None: - # Closed path (default) - v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]] - v_clean = remove_duplicate_vertices(v, closed_path=True) - # The last [0,0] is a duplicate of the first [0,0] if closed_path=True - assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]]) - - # Open path - v_clean_open = remove_duplicate_vertices(v, closed_path=False) - assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]]) - - -def test_remove_duplicate_vertices_tolerance_defaults_to_exact_match() -> None: - v = [[0, 0], [1, 1], [1 + 1e-13, 1], [2, 2], [1e-13, 0]] - - assert_allclose(remove_duplicate_vertices(v, closed_path=True), v, atol=0, rtol=0) - assert_allclose( - remove_duplicate_vertices(v, closed_path=True, tolerance=1e-12), - [[0, 0], [1 + 1e-13, 1], [2, 2]], - atol=0, - rtol=0, - ) - - -def test_remove_duplicate_vertices_rejects_negative_tolerance() -> None: - with pytest.raises(ValueError, match='non-negative'): - remove_duplicate_vertices([[0, 0]], tolerance=-1) - - -def test_remove_colinear_vertices() -> None: - v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]] - v_clean = remove_colinear_vertices(v, closed_path=True) - # [1, 0] is between [0, 0] and [2, 0] - # [2, 1] is between [2, 0] and [2, 2] - # [1, 1] is between [2, 2] and [0, 0] - assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]]) - - -def test_remove_colinear_vertices_exhaustive() -> None: - v = [[0, 0], [10, 0], [0, 0]] - v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True) - assert len(v_clean) == 3 - - v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False) - assert len(v_collapsed) == 2 - - v = [[0, 0], [10, 0], [5, 0]] - v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False) - assert len(v_clean) == 2 - - -def test_poly_contains_points() -> None: - v = [[0, 0], [10, 0], [10, 10], [0, 10]] - pts = [[5, 5], [-1, -1], [10, 10], [11, 5]] - inside = poly_contains_points(v, pts) - assert_equal(inside, [True, False, True, False]) - - -def test_rotation_matrix_2d() -> None: - m = rotation_matrix_2d(pi / 2) - assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10) - - -def test_rotation_matrix_non_manhattan() -> None: - # 45 degrees - m = rotation_matrix_2d(pi / 4) - s = numpy.sqrt(2) / 2 - assert_allclose(m, [[s, -s], [s, s]], atol=1e-10) - - -def test_rotation_matrix_canonicalizes_before_caching() -> None: - expected = rotation_matrix_2d(pi / 2) - - for angle in (pi / 2 + 1e-12, pi / 2 - 1e-12, -3 * pi / 2, 5 * pi / 2): - assert rotation_matrix_2d(angle) is expected - - assert not expected.flags.writeable - - -def test_rotation_matrix_does_not_snap_non_manhattan_angle() -> None: - cardinal = rotation_matrix_2d(pi / 2) - nearby = rotation_matrix_2d(pi / 2 + 2e-3) - - assert nearby is not cardinal - assert not numpy.array_equal(nearby, cardinal) - - -def test_apply_transforms() -> None: - # cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)] - t1 = [10, 20, 0, 0] - t2 = [[5, 0, 0, 0], [0, 5, 0, 0]] - combined = apply_transforms(t1, t2) - assert_equal(combined, [[15, 20, 0, 0, 1], [10, 25, 0, 0, 1]]) - - -def test_apply_transforms_advanced() -> None: - # Ox4: (x, y, rot, mir) - # Outer: mirror x (axis 0), then rotate 90 deg CCW - # apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror) - outer = [0, 0, pi / 2, 1] - - # Inner: (10, 0, 0, 0) - inner = [10, 0, 0, 0] - - combined = apply_transforms(outer, inner) - # 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0) - # 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10) - # 3. add outer offset (0, 0) -> (0, 10) - assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10) - - -def test_apply_transforms_empty_inputs() -> None: - empty_outer = apply_transforms(numpy.empty((0, 5)), [[1, 2, 0, 0, 1]]) - assert empty_outer.shape == (0, 5) - - empty_inner = apply_transforms([[1, 2, 0, 0, 1]], numpy.empty((0, 5))) - assert empty_inner.shape == (0, 5) - - both_empty_tensor = apply_transforms(numpy.empty((0, 5)), numpy.empty((0, 5)), tensor=True) - assert both_empty_tensor.shape == (0, 0, 5) - - -def test_normalize_mirror_validates_length() -> None: - with pytest.raises(ValueError, match='2-item sequence'): - normalize_mirror(()) - - with pytest.raises(ValueError, match='2-item sequence'): - normalize_mirror((True,)) - - with pytest.raises(ValueError, match='2-item sequence'): - normalize_mirror((True, False, True)) - - -def test_bezier_validates_weight_length() -> None: - with pytest.raises(PatternError, match='one entry per control point'): - bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1]) - - with pytest.raises(PatternError, match='one entry per control point'): - bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1, 2, 3]) - - -def test_bezier_accepts_exact_weight_count() -> None: - samples = bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1, 2]) - assert_allclose(samples, [[0, 0], [2 / 3, 2 / 3], [1, 1]], atol=1e-10) - - -def _endpoint_tangent(xy: numpy.ndarray) -> float: - dxy = xy[-1] - xy[-2] - return numpy.arctan2(dxy[1], dxy[0]) - - -@pytest.mark.parametrize( - ('switchover_angle', 'total_angle'), - [ - (pi / 8, pi / 4), - (pi / 8, pi / 2), - (pi / 4, pi), - ], - ) -def test_euler_bend_supports_total_angle(switchover_angle: float, total_angle: float) -> None: - xy = euler_bend(switchover_angle, num_points=2000, total_angle=total_angle) - - assert_allclose(xy[0], [0, 0], atol=1e-12) - assert_allclose(_endpoint_tangent(xy), -total_angle, atol=1e-3) - - -def test_euler_bend_180_degrees_with_90_degree_circular_middle() -> None: - xy = euler_bend(pi / 4, num_points=2000, total_angle=pi) - - assert_allclose(_endpoint_tangent(xy), -pi, atol=1e-3) - assert abs(xy[-1][0]) < 1e-3 - assert xy[-1][1] < 0 - - -def test_euler_bend_rejects_too_large_switchover_angle() -> None: - with pytest.raises(PatternError, match='total_angle / 2'): - euler_bend(pi / 2, total_angle=pi / 2) - - -def test_euler_spiral_and_circular_arc_helpers_match_endpoint_tangent() -> None: - xy_spiral = euler_spiral(pi / 4, num_points=1000) - assert_allclose(_endpoint_tangent(xy_spiral), -pi / 4, atol=1e-3) - - xy_arc = circular_arc( - 10, - pi / 2, - num_points=1000, - start_angle=_endpoint_tangent(xy_spiral), - start=xy_spiral[-1], - ) - assert_allclose(_endpoint_tangent(xy_arc), -3 * pi / 4, atol=2e-3) - - -def test_deferred_dict_accessors_resolve_values_once() -> None: - calls = 0 - - def make_value() -> int: - nonlocal calls - calls += 1 - return 7 - - deferred = DeferredDict[str, int]() - deferred["x"] = make_value - - assert deferred.get("missing", 9) == 9 - assert deferred.get("x") == 7 - assert list(deferred.values()) == [7] - assert list(deferred.items()) == [("x", 7)] - assert calls == 1 - - -def test_deferred_dict_mutating_accessors_preserve_value_semantics() -> None: - calls = 0 - - def make_value() -> int: - nonlocal calls - calls += 1 - return 7 - - deferred = DeferredDict[str, int]() - - assert deferred.setdefault("x", 5) == 5 - assert deferred["x"] == 5 - - assert deferred.setdefault("y", make_value) == 7 - assert deferred["y"] == 7 - assert calls == 1 - - copy_deferred = deferred.copy() - assert isinstance(copy_deferred, DeferredDict) - assert copy_deferred["x"] == 5 - assert copy_deferred["y"] == 7 - assert calls == 1 - - assert deferred.pop("x") == 5 - assert deferred.pop("missing", 9) == 9 - assert deferred.popitem() == ("y", 7) - - -def test_tmpfile_cleans_up_on_exception(tmp_path: Path) -> None: - target = tmp_path / "out.txt" - temp_path = None - - try: - with tmpfile(target) as stream: - temp_path = Path(stream.name) - stream.write(b"hello") - raise RuntimeError("boom") - except RuntimeError: - pass - - assert temp_path is not None - assert not target.exists() - assert not temp_path.exists() diff --git a/masque/test/test_visualize.py b/masque/test/test_visualize.py deleted file mode 100644 index a20286c..0000000 --- a/masque/test/test_visualize.py +++ /dev/null @@ -1,53 +0,0 @@ -import numpy as np -import pytest -from masque.pattern import Pattern -from masque.ports import Port -from masque.repetition import Grid - -try: - import matplotlib - HAS_MATPLOTLIB = True -except ImportError: - HAS_MATPLOTLIB = False - -@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") -def test_visualize_noninteractive(tmp_path) -> None: - """ - Test that visualize() runs and saves a file without error. - This covers the recursive transformation and collection logic. - """ - # Create a hierarchy - child = Pattern() - child.polygon('L1', [[0, 0], [1, 0], [1, 1], [0, 1]]) - child.ports['P1'] = Port((0.5, 0.5), 0) - - parent = Pattern() - # Add some refs with various transforms - parent.ref('child', offset=(10, 0), rotation=np.pi/4, mirrored=True, scale=2.0) - - # Add a repetition - rep = Grid(a_vector=(5, 5), a_count=2) - parent.ref('child', offset=(0, 10), repetition=rep) - - library = {'child': child} - - output_file = tmp_path / "test_plot.png" - - # Run visualize with filename to avoid showing window - parent.visualize(library=library, filename=str(output_file), ports=True) - - assert output_file.exists() - assert output_file.stat().st_size > 0 - -@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") -def test_visualize_empty() -> None: - """ Test visualizing an empty pattern. """ - pat = Pattern() - pat.visualize(overdraw=True) - -@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") -def test_visualize_no_refs() -> None: - """ Test visualizing a pattern with only local shapes (no library needed). """ - pat = Pattern() - pat.polygon('L1', [[0, 0], [1, 0], [0, 1]]) - pat.visualize(overdraw=True) diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py index cca38f3..7c7360c 100644 --- a/masque/traits/__init__.py +++ b/masque/traits/__init__.py @@ -26,11 +26,7 @@ from .scalable import ( Scalable as Scalable, ScalableImpl as ScalableImpl, ) -from .mirrorable import ( - Mirrorable as Mirrorable, - Flippable as Flippable, - FlippableImpl as FlippableImpl, - ) +from .mirrorable import Mirrorable as Mirrorable from .copyable import Copyable as Copyable from .annotatable import ( Annotatable as Annotatable, diff --git a/masque/traits/annotatable.py b/masque/traits/annotatable.py index 1ae41e1..1b2ba23 100644 --- a/masque/traits/annotatable.py +++ b/masque/traits/annotatable.py @@ -45,6 +45,6 @@ class AnnotatableImpl(Annotatable, metaclass=ABCMeta): @annotations.setter def annotations(self, annotations: annotations_t) -> None: - if not isinstance(annotations, dict) and annotations is not None: - raise MasqueError(f'annotations expected dict or None, got {type(annotations)}') + if not isinstance(annotations, dict): + raise MasqueError(f'annotations expected dict, got {type(annotations)}') self._annotations = annotations diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 2a3a9fb..6d4ec3c 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,13 +1,6 @@ from typing import Self from abc import ABCMeta, abstractmethod -import numpy -from numpy.typing import NDArray - -from ..error import MasqueError -from .positionable import Positionable -from .repeatable import Repeatable - class Mirrorable(metaclass=ABCMeta): """ @@ -18,17 +11,11 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - Intrinsic transformation: Mirror the entity across an axis through its origin. - This does NOT affect the object's repetition grid. - - This operation is performed relative to the object's internal origin (ignoring - its offset). For objects like `Polygon` and `Path` where the offset is forced - to (0, 0), this is equivalent to mirroring in the container's coordinate system. + Mirror the entity across an axis. Args: - axis: Axis to mirror across: - 0: X-axis (flip y coords), - 1: Y-axis (flip x coords) + axis: Axis to mirror across. + Returns: self """ @@ -36,11 +23,10 @@ class Mirrorable(metaclass=ABCMeta): def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self: """ - Optionally mirror the entity across both axes through its origin. + Optionally mirror the entity across both axes Args: - across_x: Mirror across the horizontal X-axis (flip Y coordinates). - across_y: Mirror across the vertical Y-axis (flip X coordinates). + axes: (mirror_across_x, mirror_across_y) Returns: self @@ -52,61 +38,30 @@ class Mirrorable(metaclass=ABCMeta): return self -class Flippable(Positionable, metaclass=ABCMeta): - """ - Trait class for entities which can be mirrored relative to an external line. - """ - __slots__ = () - - @staticmethod - def _check_flip_args(axis: int | None = None, *, x: float | None = None, y: float | None = None) -> tuple[int, NDArray[numpy.float64]]: - pivot = numpy.zeros(2) - if axis is not None: - if x is not None or y is not None: - raise MasqueError('Cannot specify both axis and x or y') - return axis, pivot - if x is not None: - if y is not None: - raise MasqueError('Cannot specify both x and y') - return 1, pivot + (x, 0) - if y is not None: - return 0, pivot + (0, y) - raise MasqueError('Must specify one of axis, x, or y') - - @abstractmethod - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - """ - Extrinsic transformation: Mirror the object across a line in the container's - coordinate system. This affects both the object's offset and its repetition grid. - - Unlike `mirror()`, this operation is performed relative to the container's origin - (e.g. the `Pattern` origin, in the case of shapes) and takes the object's offset - into account. - - Args: - axis: Axis to mirror across. 0: x-axis (flip y coord), 1: y-axis (flip x coord). - x: Vertical line x=val to mirror across. - y: Horizontal line y=val to mirror across. - - Returns: - self - """ - pass - - -class FlippableImpl(Flippable, Mirrorable, Repeatable, metaclass=ABCMeta): - """ - Implementation of `Flippable` for objects which are `Mirrorable`, `Positionable`, - and `Repeatable`. - """ - __slots__ = () - - def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: - axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) - self.translate(-pivot) - self.mirror(axis) - if self.repetition is not None: - self.repetition.mirror(axis) - self.offset[1 - axis] *= -1 - self.translate(+pivot) - return self +#class MirrorableImpl(Mirrorable, metaclass=ABCMeta): +# """ +# Simple implementation of `Mirrorable` +# """ +# __slots__ = () +# +# _mirrored: NDArray[numpy.bool] +# """ Whether to mirror the instance across the x and/or y axes. """ +# +# # +# # Properties +# # +# # Mirrored property +# @property +# def mirrored(self) -> NDArray[numpy.bool]: +# """ Whether to mirror across the [x, y] axes, respectively """ +# return self._mirrored +# +# @mirrored.setter +# def mirrored(self, val: Sequence[bool]) -> None: +# if is_scalar(val): +# raise MasqueError('Mirrored must be a 2-element list of booleans') +# self._mirrored = numpy.array(val, dtype=bool) +# +# # +# # Methods +# # diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py index 6779869..66e6e7d 100644 --- a/masque/traits/positionable.py +++ b/masque/traits/positionable.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Self, Any from abc import ABCMeta, abstractmethod import numpy @@ -73,7 +73,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta): # # offset property @property - def offset(self) -> NDArray[numpy.float64]: + def offset(self) -> Any: # mypy#3004 NDArray[numpy.float64]: """ [x, y] offset """ @@ -95,7 +95,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta): return self def translate(self, offset: ArrayLike) -> Self: - self._offset += numpy.asarray(offset) + self._offset += offset # type: ignore # NDArray += ArrayLike should be fine?? return self diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py index dbf4fad..fbd765f 100644 --- a/masque/traits/repeatable.py +++ b/masque/traits/repeatable.py @@ -76,7 +76,7 @@ class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta): @repetition.setter def repetition(self, repetition: 'Repetition | None') -> None: - from ..repetition import Repetition #noqa: PLC0415 + from ..repetition import Repetition if repetition is not None and not isinstance(repetition, Repetition): raise MasqueError(f'{repetition} is not a valid Repetition object!') self._repetition = repetition diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 436d0a2..04816f1 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Self, cast, Any, TYPE_CHECKING from abc import ABCMeta, abstractmethod import numpy @@ -8,7 +8,8 @@ from numpy.typing import ArrayLike from ..error import MasqueError from ..utils import rotation_matrix_2d -from .positionable import Positionable +if TYPE_CHECKING: + from .positionable import Positionable _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass @@ -25,8 +26,7 @@ class Rotatable(metaclass=ABCMeta): @abstractmethod def rotate(self, val: float) -> Self: """ - Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. - This does NOT affect the object's repetition grid. + Rotate the shape around its origin (0, 0), ignoring its offset. Args: val: Angle to rotate by (counterclockwise, radians) @@ -64,10 +64,6 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): # Methods # def rotate(self, rotation: float) -> Self: - """ - Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. - This does NOT affect the object's repetition grid. - """ self.rotation += rotation return self @@ -85,9 +81,9 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): return self -class Pivotable(Positionable, metaclass=ABCMeta): +class Pivotable(metaclass=ABCMeta): """ - Trait class for entities which can be rotated around a point. + Trait class for entites which can be rotated around a point. This requires that they are `Positionable` but not necessarily `Rotatable` themselves. """ __slots__ = () @@ -95,11 +91,7 @@ class Pivotable(Positionable, metaclass=ABCMeta): @abstractmethod def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Extrinsic transformation: Rotate the object around a point in the container's - coordinate system. This affects both the object's offset and its repetition grid. - - For objects that are also `Rotatable`, this also performs an intrinsic - rotation of the object. + Rotate the object around a point. Args: pivot: Point (x, y) to rotate around @@ -111,21 +103,20 @@ class Pivotable(Positionable, metaclass=ABCMeta): pass -class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta): +class PivotableImpl(Pivotable, metaclass=ABCMeta): """ Implementation of `Pivotable` for objects which are `Rotatable` - and `Positionable`. """ __slots__ = () + offset: Any # TODO see if we can get around defining `offset` in PivotableImpl + """ `[x_offset, y_offset]` """ + def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: - from .repeatable import Repeatable #noqa: PLC0415 pivot = numpy.asarray(pivot, dtype=float) - self.translate(-pivot) - self.rotate(rotation) - if isinstance(self, Repeatable) and self.repetition is not None: - self.repetition.rotate(rotation) - self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - self.translate(+pivot) + cast('Positionable', self).translate(-pivot) + cast('Rotatable', self).rotate(rotation) + self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) # type: ignore # mypy#3004 + cast('Positionable', self).translate(+pivot) return self diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py index 8e10ca1..f33142f 100644 --- a/masque/utils/__init__.py +++ b/masque/utils/__init__.py @@ -10,11 +10,6 @@ from .array import is_scalar as is_scalar from .autoslots import AutoSlots as AutoSlots from .deferreddict import DeferredDict as DeferredDict from .decorators import oneshot as oneshot -from .ptypes import ( - PTypeMatch as PTypeMatch, - ptype_match as ptype_match, - ptypes_compatible as ptypes_compatible, - ) from .bitwise import ( get_bit as get_bit, diff --git a/masque/utils/autoslots.py b/masque/utils/autoslots.py index cef8006..e82d3db 100644 --- a/masque/utils/autoslots.py +++ b/masque/utils/autoslots.py @@ -17,12 +17,11 @@ class AutoSlots(ABCMeta): for base in bases: parents |= set(base.mro()) - slots = list(dctn.get('__slots__', ())) + slots = tuple(dctn.get('__slots__', ())) for parent in parents: if not hasattr(parent, '__annotations__'): continue - slots.extend(parent.__annotations__.keys()) + slots += tuple(parent.__annotations__.keys()) - # Deduplicate (dict to preserve order) - dctn['__slots__'] = tuple(dict.fromkeys(slots)) + dctn['__slots__'] = slots return super().__new__(cls, name, bases, dctn) diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py deleted file mode 100644 index 9dd43bd..0000000 --- a/masque/utils/boolean.py +++ /dev/null @@ -1,189 +0,0 @@ -from typing import Any, Literal -from collections.abc import Iterable -import logging - -import numpy -from numpy.typing import NDArray - -from ..shapes.polygon import Polygon -from ..error import PatternError - - -logger = logging.getLogger(__name__) - - -def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]: - """ - Bridge multiple holes into an outer boundary using zero-width slits. - """ - current_outer = outer_path - - # Sort holes by max X to potentially minimize bridge lengths or complexity - # (though not strictly necessary for correctness) - holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True) - - for hole in holes: - # Find max X vertex of hole - max_idx = numpy.argmax(hole[:, 0]) - m = hole[max_idx] - - # Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges - best_t = numpy.inf - best_pt = None - best_edge_idx = -1 - - n = len(current_outer) - for i in range(n): - p1 = current_outer[i] - p2 = current_outer[(i + 1) % n] - - # Check if edge (p1, p2) spans m.y - if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]): - # Intersection x: - # x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y) - t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0] - if 0 <= t < best_t: - best_t = t - best_pt = numpy.array([m[0] + t, m[1]]) - best_edge_idx = i - - if best_edge_idx == -1: - # Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole) - dists = numpy.linalg.norm(current_outer - m, axis=1) - best_edge_idx = int(numpy.argmin(dists)) - best_pt = current_outer[best_edge_idx] - # Adjust best_edge_idx to insert AFTER this vertex - # (treating it as a degenerate edge) - - assert best_pt is not None - - # Reorder hole vertices to start at m - hole_reordered = numpy.roll(hole, -max_idx, axis=0) - - # Construct new outer: - # 1. Start of outer up to best_edge_idx - # 2. Intersection point - # 3. Hole vertices (starting and ending at m) - # 4. Intersection point (to close slit) - # 5. Rest of outer - - new_outer: list[NDArray[numpy.float64]] = [] - new_outer.extend(current_outer[:best_edge_idx + 1]) - new_outer.append(best_pt) - new_outer.extend(hole_reordered) - new_outer.append(hole_reordered[0]) # close hole loop at m - new_outer.append(best_pt) # back to outer - new_outer.extend(current_outer[best_edge_idx + 1:]) - - current_outer = numpy.array(new_outer) - - return current_outer - -def boolean( - subjects: Iterable[Any], - clips: Iterable[Any] | None = None, - operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', - scale: float = 1e6, - ) -> list[Polygon]: - """ - Perform a boolean operation on two sets of polygons. - - Args: - subjects: Subjects (shapes or vertex arrays). Shape repetitions are expanded. - clips: Clips (shapes or vertex arrays). Shape repetitions are expanded. - operation: The boolean operation to perform. - scale: Scaling factor for integer conversion (pyclipper uses integers). - - Returns: - A list of result Polygons. - """ - try: - import pyclipper #noqa: PLC0415 - except ImportError: - raise ImportError( - "Boolean operations require 'pyclipper'. " - "Install it with 'pip install pyclipper' or 'pip install masque[boolean]'." - ) from None - - op_map = { - 'union': pyclipper.CT_UNION, - 'intersection': pyclipper.CT_INTERSECTION, - 'difference': pyclipper.CT_DIFFERENCE, - 'xor': pyclipper.CT_XOR, - } - - def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]: - if objs is None: - return [] - if isinstance(objs, numpy.ndarray): - return [objs] - if hasattr(objs, 'to_polygons'): - verts = [] - for poly in objs.to_polygons(): - if poly.repetition is None: - verts.append(poly.vertices) - else: - verts.extend(poly.vertices + dd for dd in poly.repetition.displacements) - return verts - if isinstance(objs, str | bytes) or not isinstance(objs, Iterable): - raise PatternError(f"Unsupported type for boolean operation: {type(objs)}") - return [vertices for obj in objs for vertices in to_vertices(obj)] - - op = op_map[operation.lower()] - subject_verts = to_vertices(subjects) - clip_verts = to_vertices(clips) - - if not subject_verts: - if op not in (pyclipper.CT_UNION, pyclipper.CT_XOR) or not clip_verts: - return [] - subject_verts, clip_verts = clip_verts, [] - - if not clip_verts and op == pyclipper.CT_INTERSECTION: - return [] - - pc = pyclipper.Pyclipper() - pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) - if clip_verts: - pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True) - - # Use GetPolyTree to distinguish between outers and holes - polytree = pc.Execute2(op, pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) - return _polytree_to_polygons(polytree, scale) - - -def _polytree_to_polygons(polytree: Any, scale: float) -> list[Polygon]: - """Convert a Clipper result, bridging holes for masque's polygon representation.""" - import pyclipper # noqa: PLC0415 - - result_polygons = [] - - def process_node(node: Any) -> None: - if not node.IsHole: - # This is an outer boundary - outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale)) - - # Find immediate holes - holes = [] - for child in node.Childs: - if child.IsHole: - holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale))) - - if holes: - combined_vertices = _bridge_holes(outer_path, holes) - result_polygons.append(Polygon(combined_vertices)) - else: - result_polygons.append(Polygon(outer_path)) - - # Recursively process children of holes (which are nested outers) - for child in node.Childs: - if child.IsHole: - for grandchild in child.Childs: - process_node(grandchild) - else: - # Holes are processed as children of outers - pass - - for top_node in polytree.Childs: - process_node(top_node) - - return result_polygons diff --git a/masque/utils/comparisons.py b/masque/utils/comparisons.py index bb2dfee..63981c9 100644 --- a/masque/utils/comparisons.py +++ b/masque/utils/comparisons.py @@ -9,15 +9,7 @@ def annotation2key(aaa: int | float | str) -> tuple[bool, Any]: return (isinstance(aaa, str), aaa) -def _normalized_annotations(annotations: annotations_t) -> annotations_t: - if not annotations: - return None - return annotations - - def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool: - aa = _normalized_annotations(aa) - bb = _normalized_annotations(bb) if aa is None: return bb is not None elif bb is None: # noqa: RET505 @@ -44,8 +36,6 @@ def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool: def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool: - aa = _normalized_annotations(aa) - bb = _normalized_annotations(bb) if aa is None: return bb is None elif bb is None: # noqa: RET505 @@ -57,7 +47,7 @@ def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool: keys_a = tuple(sorted(aa.keys())) keys_b = tuple(sorted(bb.keys())) if keys_a != keys_b: - return False + return keys_a < keys_b for key in keys_a: va = aa[key] diff --git a/masque/utils/curves.py b/masque/utils/curves.py index dd6450e..1e41c35 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -2,8 +2,10 @@ import numpy from numpy.typing import ArrayLike, NDArray from numpy import pi -from ..error import PatternError -from .vertices import remove_duplicate_vertices +try: + from numpy import trapezoid +except ImportError: + from numpy import trapz as trapezoid def bezier( @@ -29,11 +31,6 @@ def bezier( tt = numpy.asarray(tt) nn = nodes.shape[0] weights = numpy.ones(nn) if weights is None else numpy.asarray(weights) - if weights.ndim != 1 or weights.shape[0] != nn: - raise PatternError( - f'weights must be a 1D array with one entry per control point; ' - f'got shape {weights.shape} for {nn} control points' - ) with numpy.errstate(divide='ignore'): umul = (tt / (1 - tt)).clip(max=1) @@ -49,196 +46,59 @@ def bezier( return qq -def _integrate_tangent( - qq: NDArray[numpy.float64], - theta: NDArray[numpy.float64], - num_points: int, - ) -> NDArray[numpy.float64]: - dx = numpy.cos(theta) - dy = numpy.sin(theta) - - dq = qq[-1] / (qq.size - 1) - ix = numpy.zeros(qq.size) - iy = numpy.zeros(qq.size) - ix[1:] = numpy.cumsum((dx[:-1] + dx[1:]) / 2) * dq - iy[1:] = numpy.cumsum((dy[:-1] + dy[1:]) / 2) * dq - - qq_target = numpy.linspace(0, qq[-1], num_points) - x_target = numpy.interp(qq_target, qq, ix) - y_target = numpy.interp(qq_target, qq, iy) - - return numpy.stack((x_target, y_target), axis=1) - - -def euler_spiral( - switchover_angle: float, - num_points: int = 200, - *, - start_angle: float = 0.0, - start: ArrayLike = (0.0, 0.0), - reverse: bool = False, - ) -> NDArray[numpy.float64]: - """ - Generate one Euler bend transition segment. - - Positive angles bend clockwise, matching `euler_bend()`. When `reverse` is - `False`, curvature ramps from zero to the switchover curvature. When - `reverse` is `True`, curvature ramps from the switchover curvature to zero. - - Args: - switchover_angle: Tangent angle change across the Euler segment, in radians. - num_points: Number of points in the curve. - start_angle: Tangent angle at the first point. - start: First point of the segment. - reverse: If `True`, generate the exit segment of an Euler bend. - - Returns: - `[[x0, y0], ...]` for the curve. - """ - if num_points < 0: - raise PatternError(f'num_points must be non-negative, got {num_points}') - if switchover_angle < 0: - raise PatternError(f'switchover_angle must be non-negative, got {switchover_angle}') - if num_points == 0: - return numpy.empty((0, 2)) - - start = numpy.asarray(start, dtype=float) - if start.shape != (2,): - raise PatternError(f'start must be a 2D point; got shape {start.shape}') - - if switchover_angle == 0: - return numpy.tile(start, (num_points, 1)) - - resolution = 100000 - ll_max = numpy.sqrt(2 * switchover_angle) - qq = numpy.linspace(0, ll_max, resolution) - if reverse: - theta = start_angle - (ll_max * qq - qq * qq / 2) - else: - theta = start_angle - qq * qq / 2 - - return _integrate_tangent(qq, theta, num_points) + start - - -def circular_arc( - radius: float, - arc_angle: float, - num_points: int = 200, - *, - start_angle: float = 0.0, - start: ArrayLike = (0.0, 0.0), - ) -> NDArray[numpy.float64]: - """ - Generate a clockwise circular arc. - - Args: - radius: Arc radius. - arc_angle: Clockwise tangent angle change across the arc, in radians. - num_points: Number of points in the curve, excluding the start point. - start_angle: Tangent angle at the start point. - start: Point where the arc starts. - - Returns: - `[[x0, y0], ...]` for the arc, excluding `start`. - """ - if num_points < 0: - raise PatternError(f'num_points must be non-negative, got {num_points}') - if radius <= 0: - raise PatternError(f'radius must be positive, got {radius}') - if arc_angle < 0: - raise PatternError(f'arc_angle must be non-negative, got {arc_angle}') - if num_points == 0: - return numpy.empty((0, 2)) - - start = numpy.asarray(start, dtype=float) - if start.shape != (2,): - raise PatternError(f'start must be a 2D point; got shape {start.shape}') - - if arc_angle == 0: - return numpy.tile(start, (num_points, 1)) - - angles = numpy.linspace(0, arc_angle, num_points + 1)[1:] - right_normal = numpy.array([numpy.sin(start_angle), -numpy.cos(start_angle)]) - center = start + radius * right_normal - radial = start - center - - cos_t = numpy.cos(-angles) - sin_t = numpy.sin(-angles) - xx = center[0] + cos_t * radial[0] - sin_t * radial[1] - yy = center[1] + sin_t * radial[0] + cos_t * radial[1] - return numpy.stack((xx, yy), axis=1) - def euler_bend( switchover_angle: float, num_points: int = 200, - *, - total_angle: float = pi / 2, ) -> NDArray[numpy.float64]: """ - Generate an Euler bend (AKA Clothoid bend or Cornu spiral). - - Positive angles bend clockwise. By default, this generates the historical - 90 degree bend. + Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral). Args: switchover_angle: After this angle, the bend will transition into a circular arc (and transition back to an Euler spiral on the far side). If this is set to - `total_angle / 2`, no circular arc will be added. - num_points: Number of points in the curve. - total_angle: Total tangent angle change across the bend, in radians. + `>= pi / 4`, no circular arc will be added. + num_points: Number of points in the curve Returns: `[[x0, y0], ...]` for the curve """ - if switchover_angle <= 0: - raise PatternError(f'switchover_angle must be positive, got {switchover_angle}') - if total_angle <= 0: - raise PatternError(f'total_angle must be positive, got {total_angle}') - if switchover_angle > total_angle / 2: - raise PatternError( - f'switchover_angle must be <= total_angle / 2; ' - f'got {switchover_angle} for total_angle {total_angle}' - ) - if num_points < 2: - raise PatternError(f'num_points must be at least 2, got {num_points}') - - arc_angle = total_angle - 2 * switchover_angle ll_max = numpy.sqrt(2 * switchover_angle) # total length of (one) spiral portion - ll_tot = 2 * ll_max + arc_angle - num_points_spiral = max(2, numpy.floor(ll_max / ll_tot * num_points).astype(int)) - num_points_arc = max(0, num_points - 2 * num_points_spiral) - if arc_angle > 0: - num_points_arc = max(1, num_points_arc) + ll_tot = 2 * ll_max + (pi / 2 - 2 * switchover_angle) + num_points_spiral = numpy.floor(ll_max / ll_tot * num_points).astype(int) + num_points_arc = num_points - 2 * num_points_spiral - xy_spiral = euler_spiral(switchover_angle, num_points_spiral) + def gen_spiral(ll_max: float) -> NDArray[numpy.float64]: + xx = [] + yy = [] + for ll in numpy.linspace(0, ll_max, num_points_spiral): + qq = numpy.linspace(0, ll, 1000) # integrate to current arclength + xx.append(trapezoid( numpy.cos(qq * qq / 2), qq)) + yy.append(trapezoid(-numpy.sin(qq * qq / 2), qq)) + xy_part = numpy.stack((xx, yy), axis=1) + return xy_part + + xy_spiral = gen_spiral(ll_max) xy_parts = [xy_spiral] - if arc_angle > 0: + if switchover_angle < pi / 4: # Build a circular segment to join the two euler portions rmin = 1.0 / ll_max - xy_arc = circular_arc( - rmin, - arc_angle, - num_points_arc, - start_angle=-switchover_angle, - start=xy_spiral[-1], - ) - xy_parts.append(xy_arc) + half_angle = pi / 4 - switchover_angle + qq = numpy.linspace(half_angle * 2, 0, num_points_arc + 1) + switchover_angle + xc = rmin * numpy.cos(qq) + yc = rmin * numpy.sin(qq) + xy_spiral[-1, 1] + xc += xy_spiral[-1, 0] - xc[0] + yc += xy_spiral[-1, 1] - yc[0] + xy_parts.append(numpy.stack((xc[1:], yc[1:]), axis=1)) endpoint_xy = xy_parts[-1][-1, :] - second_spiral = euler_spiral( - switchover_angle, - num_points_spiral, - start_angle=-(total_angle - switchover_angle), - start=endpoint_xy, - reverse=True, - ) + second_spiral = xy_spiral[::-1, ::-1] + endpoint_xy - xy_spiral[-1, ::-1] - xy_parts.append(second_spiral[1:]) + xy_parts.append(second_spiral) xy = numpy.concatenate(xy_parts) # Remove any 2x-duplicate points - xy = remove_duplicate_vertices(xy, closed_path=False, tolerance=1e-12) + xy = xy[(numpy.roll(xy, 1, axis=0) != xy).any(axis=1)] return xy diff --git a/masque/utils/deferreddict.py b/masque/utils/deferreddict.py index 70893c0..aff3bcc 100644 --- a/masque/utils/deferreddict.py +++ b/masque/utils/deferreddict.py @@ -1,11 +1,10 @@ from typing import TypeVar, Generic -from collections.abc import Callable, Iterator +from collections.abc import Callable from functools import lru_cache Key = TypeVar('Key') Value = TypeVar('Value') -_MISSING = object() class DeferredDict(dict, Generic[Key, Value]): @@ -26,73 +25,18 @@ class DeferredDict(dict, Generic[Key, Value]): """ def __init__(self, *args, **kwargs) -> None: dict.__init__(self) - if args or kwargs: - self.update(*args, **kwargs) + self.update(*args, **kwargs) def __setitem__(self, key: Key, value: Callable[[], Value]) -> None: - """ - Set a value, which must be a callable that returns the actual value. - The result of the callable is cached after the first access. - """ - if not callable(value): - raise TypeError(f"DeferredDict value must be callable, got {type(value)}") cached_fn = lru_cache(maxsize=1)(value) dict.__setitem__(self, key, cached_fn) def __getitem__(self, key: Key) -> Value: return dict.__getitem__(self, key)() - def get(self, key: Key, default: Value | None = None) -> Value | None: - if key not in self: - return default - return self[key] - - def setdefault(self, key: Key, default: Value | Callable[[], Value] | None = None) -> Value | None: - if key in self: - return self[key] - if callable(default): - self[key] = default - else: - self.set_const(key, default) - return self[key] - - def items(self) -> Iterator[tuple[Key, Value]]: - for key in self.keys(): - yield key, self[key] - - def values(self) -> Iterator[Value]: - for key in self.keys(): - yield self[key] - def update(self, *args, **kwargs) -> None: - """ - Update the DeferredDict. If a value is callable, it is used as a generator. - Otherwise, it is wrapped as a constant. - """ for k, v in dict(*args, **kwargs).items(): - if callable(v): - self[k] = v - else: - self.set_const(k, v) - - def pop(self, key: Key, default: Value | object = _MISSING) -> Value: - if key in self: - value = self[key] - dict.pop(self, key) - return value - if default is _MISSING: - raise KeyError(key) - return default # type: ignore[return-value] - - def popitem(self) -> tuple[Key, Value]: - key, value_func = dict.popitem(self) - return key, value_func() - - def copy(self) -> 'DeferredDict[Key, Value]': - new = DeferredDict[Key, Value]() - for key in self.keys(): - dict.__setitem__(new, key, dict.__getitem__(self, key)) - return new + self[k] = v def __repr__(self) -> str: return '' @@ -102,4 +46,4 @@ class DeferredDict(dict, Generic[Key, Value]): Convenience function to avoid having to manually wrap constant values into callables. """ - self[key] = lambda v=value: v + self[key] = lambda: value diff --git a/masque/utils/pack2d.py b/masque/utils/pack2d.py index 248f408..ce6b006 100644 --- a/masque/utils/pack2d.py +++ b/masque/utils/pack2d.py @@ -60,12 +60,6 @@ def maxrects_bssf( degenerate = (min_more & max_less).any(axis=0) regions = regions[~degenerate] - if regions.shape[0] == 0: - if allow_rejects: - rejected_inds.add(rect_ind) - continue - raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}') - ''' Place the rect ''' # Best short-side fit (bssf) to pick a region region_sizes = regions[:, 2:] - regions[:, :2] @@ -108,7 +102,7 @@ def maxrects_bssf( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} + rejected_inds = set(unsort_order[list(rejected_inds)]) return rect_locs, rejected_inds @@ -193,7 +187,7 @@ def guillotine_bssf_sas( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} + rejected_inds = set(unsort_order[list(rejected_inds)]) return rect_locs, rejected_inds @@ -242,9 +236,7 @@ def pack_patterns( locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects) pat = Pattern() - for ii, (pp, oo, loc) in enumerate(zip(patterns, offsets, locations, strict=True)): - if ii in reject_inds: - continue + for pp, oo, loc in zip(patterns, offsets, locations, strict=True): pat.ref(pp, offset=oo + loc) rejects = [patterns[ii] for ii in reject_inds] diff --git a/masque/utils/ports2data.py b/masque/utils/ports2data.py index 6ecc253..b67fa0a 100644 --- a/masque/utils/ports2data.py +++ b/masque/utils/ports2data.py @@ -57,9 +57,11 @@ def data_to_ports( name: str | None = None, # Note: name optional, but arg order different from read(postprocess=) max_depth: int = 0, skip_subcells: bool = True, - visited: set[int] | None = None, + # TODO missing ok? ) -> Pattern: """ + # TODO fixup documentation in ports2data + # TODO move to utils.file? Examine `pattern` for labels specifying port info, and use that info to fill out its `ports` attribute. @@ -68,30 +70,18 @@ def data_to_ports( Args: layers: Search for labels on all the given layers. - library: Mapping from pattern names to patterns. pattern: Pattern object to scan for labels. - name: Name of the pattern object. - max_depth: Maximum hierarcy depth to search. Default 0. + 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 without interfering with supercells' port data. Default True. - visited: Set of object IDs which have already been processed. Returns: The updated `pattern`. Port labels are not removed. """ - if visited is None: - visited = set() - - # Note: visited uses id(pattern) to detect cycles and avoid redundant processing. - # This may not catch identical patterns if they are loaded as separate object instances. - if id(pattern) in visited: - return pattern - visited.add(id(pattern)) - if pattern.ports: logger.warning(f'Pattern {name if name else pattern} already had ports, skipping data_to_ports') return pattern @@ -105,24 +95,22 @@ def data_to_ports( # Load ports for all subpatterns, and use any we find found_ports = False - for target, refs in pattern.refs.items(): - if target is None or not refs: + for target in pattern.refs: + if target is None: continue pp = data_to_ports( - layers = layers, - library = library, - pattern = library[target], - name = target, - max_depth = max_depth - 1, - skip_subcells = skip_subcells, - visited = visited, + layers=layers, + library=library, + pattern=library[target], + name=target, + max_depth=max_depth - 1, + skip_subcells=skip_subcells, ) found_ports |= bool(pp.ports) if not found_ports: return pattern - imported_ports: dict[str, Port] = {} for target, refs in pattern.refs.items(): if target is None: continue @@ -134,14 +122,9 @@ def data_to_ports( if not aa.ports: break - if ref.repetition is not None: - logger.warning(f'Pattern {name if name else pattern} has repeated ref to {target!r}; ' - 'data_to_ports() is importing only the base instance ports') aa.apply_ref_transform(ref) - Pattern(ports={**pattern.ports, **imported_ports}).check_ports(other_names=aa.ports.keys()) - imported_ports.update(aa.ports) - - pattern.ports.update(imported_ports) + pattern.check_ports(other_names=aa.ports.keys()) + pattern.ports.update(aa.ports) return pattern @@ -177,24 +160,13 @@ def data_to_ports_flat( local_ports = {} for label in labels: - if ':' not in label.string: - logger.warning(f'Invalid port label "{label.string}" in pattern "{pstr}" (missing ":")') - continue - - name, property_string = label.string.split(':', 1) - properties = property_string.split() - ptype = properties[0] if len(properties) > 0 else 'unk' - if len(properties) > 1: - try: - angle_deg = float(properties[1]) - except ValueError: - logger.warning(f'Invalid port label "{label.string}" in pattern "{pstr}" (bad angle)') - continue - else: - angle_deg = numpy.inf + name, property_string = label.string.split(':') + properties = property_string.split(' ') + ptype = properties[0] + angle_deg = float(properties[1]) if len(ptype) else 0 xy = label.offset - angle = numpy.deg2rad(angle_deg) if numpy.isfinite(angle_deg) else None + angle = numpy.deg2rad(angle_deg) if name in local_ports: logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"') @@ -203,3 +175,4 @@ def data_to_ports_flat( pattern.ports.update(local_ports) return pattern + diff --git a/masque/utils/ptypes.py b/masque/utils/ptypes.py deleted file mode 100644 index e5953b2..0000000 --- a/masque/utils/ptypes.py +++ /dev/null @@ -1,22 +0,0 @@ -from enum import Enum - - -class PTypeMatch(Enum): - """Result of comparing two port types.""" - EXACT = 'exact' - WILDCARD = 'wildcard' - MISMATCH = 'mismatch' - - -def ptype_match(left: str | None, right: str | None) -> PTypeMatch: - """Compare ptypes, treating `None` and `"unk"` as wildcards.""" - if left in (None, 'unk') or right in (None, 'unk'): - return PTypeMatch.WILDCARD - if left == right: - return PTypeMatch.EXACT - return PTypeMatch.MISMATCH - - -def ptypes_compatible(left: str | None, right: str | None) -> bool: - """Return true when two ptypes may connect under normal compatibility rules.""" - return ptype_match(left, right) is not PTypeMatch.MISMATCH diff --git a/masque/utils/transform.py b/masque/utils/transform.py index 935b0b3..dfb6492 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -3,7 +3,6 @@ Geometric transforms """ from collections.abc import Sequence from functools import lru_cache -from math import acos import numpy from numpy.typing import NDArray, ArrayLike @@ -14,26 +13,8 @@ from numpy import pi R90 = pi / 2 R180 = pi -# Preserve the effective tolerance of the historical -# ``isclose(cos(4 * theta), 1, atol=1e-12)`` Manhattan check. Expressing it as -# an angular distance lets us canonicalize before consulting the matrix cache. -_MANHATTAN_SNAP_ATOL = acos(1 - (1e-5 + 1e-12)) / 4 -_CARDINAL_ANGLES = (0.0, R90, R180, 3 * R90) - @lru_cache -def _rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: - """Build and cache an immutable matrix for a canonicalized angle.""" - arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)], - [numpy.sin(theta), +numpy.cos(theta)]]) - - if theta in _CARDINAL_ANGLES: - arr = numpy.round(arr) - - arr.flags.writeable = False - return arr - - def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: """ 2D rotation matrix for rotating counterclockwise around the origin. @@ -44,11 +25,15 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: Returns: rotation matrix """ - theta = float(theta) - quarter_turn = round(theta / R90) - if abs(theta - quarter_turn * R90) <= _MANHATTAN_SNAP_ATOL: - theta = _CARDINAL_ANGLES[quarter_turn % 4] - return _rotation_matrix_2d(theta) + arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)], + [numpy.sin(theta), +numpy.cos(theta)]]) + + # If this was a manhattan rotation, round to remove some inacuraccies in sin & cos + if numpy.isclose(theta % (pi / 2), 0): + arr = numpy.round(arr) + + arr.flags.writeable = False + return arr def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]: @@ -64,10 +49,7 @@ def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]: `angle_to_rotate` in radians """ - if len(mirrored) != 2: - raise ValueError(f'mirrored must be a 2-item sequence, got length {len(mirrored)}') - - mirrored_x, mirrored_y = (bool(value) for value in mirrored) + mirrored_x, mirrored_y = mirrored mirror_x = (mirrored_x != mirrored_y) # XOR angle = numpy.pi if mirrored_y else 0 return mirror_x, angle @@ -104,55 +86,37 @@ def apply_transforms( Apply a set of transforms (`outer`) to a second set (`inner`). This is used to find the "absolute" transform for nested `Ref`s. - The two transforms should be of shape Ox5 and Ix5. - Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x, scale)`. - The output will be of the form (O*I)x5 (if `tensor=False`) or OxIx5 (`tensor=True`). + The two transforms should be of shape Ox4 and Ix4. + Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. + The output will be of the form (O*I)x4 (if `tensor=False`) or OxIx4 (`tensor=True`). Args: - outer: Transforms for the container refs. Shape Ox5. - inner: Transforms for the contained refs. Shape Ix5. - tensor: If `True`, an OxIx5 array is returned, with `result[oo, ii, :]` corresponding + outer: Transforms for the container refs. Shape Ox4. + inner: Transforms for the contained refs. Shape Ix4. + tensor: If `True`, an OxIx4 array is returned, with `result[oo, ii, :]` corresponding to the `oo`th `outer` transform applied to the `ii`th inner transform. - If `False` (default), this is concatenated into `(O*I)x5` to allow simple + If `False` (default), this is concatenated into `(O*I)x4` to allow simple chaining into additional `apply_transforms()` calls. Returns: - OxIx5 or (O*I)x5 array. Final dimension is - `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x, total_scale)`. + OxIx4 or (O*I)x4 array. Final dimension is + `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`. """ outer = numpy.atleast_2d(outer).astype(float, copy=False) inner = numpy.atleast_2d(inner).astype(float, copy=False) - if outer.shape[1] == 4: - outer = numpy.pad(outer, ((0, 0), (0, 1)), constant_values=1.0) - if inner.shape[1] == 4: - inner = numpy.pad(inner, ((0, 0), (0, 1)), constant_values=1.0) - - if outer.shape[0] == 0 or inner.shape[0] == 0: - if tensor: - return numpy.empty((outer.shape[0], inner.shape[0], 5)) - return numpy.empty((0, 5)) - # If mirrored, flip y's xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm xy_mir[outer[:, 3].astype(bool), :, 1] *= -1 - # Apply outer scale to inner offset - xy_mir *= outer[:, None, 4, None] - rot_mats = [rotation_matrix_2d(angle) for angle in outer[:, 2]] xy = numpy.einsum('ort,oit->oir', rot_mats, xy_mir) - tot = numpy.empty((outer.shape[0], inner.shape[0], 5)) + tot = numpy.empty((outer.shape[0], inner.shape[0], 4)) tot[:, :, :2] = outer[:, None, :2] + xy - - # If mirrored, flip inner rotation - mirrored_outer = outer[:, None, 3].astype(bool) - rotations = outer[:, None, 2] + numpy.where(mirrored_outer, -inner[None, :, 2], inner[None, :, 2]) - - tot[:, :, 2] = rotations % (2 * pi) - tot[:, :, 3] = (outer[:, None, 3] + inner[None, :, 3]) % 2 # net mirrored - tot[:, :, 4] = outer[:, None, 4] * inner[None, :, 4] # net scale + tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored + tot[:, :, 2] %= 2 * pi # clamp rot + tot[:, :, 3] %= 2 # clamp mirrored if tensor: return tot diff --git a/masque/utils/types.py b/masque/utils/types.py index c06b7b4..4060ac4 100644 --- a/masque/utils/types.py +++ b/masque/utils/types.py @@ -5,7 +5,7 @@ from typing import Protocol layer_t = int | tuple[int, int] | str -annotations_t = dict[str, list[int | float | str]] | None +annotations_t = dict[str, list[int | float | str]] class SupportsBool(Protocol): diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 0eaf13e..23fb601 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -5,11 +5,7 @@ import numpy from numpy.typing import NDArray, ArrayLike -def remove_duplicate_vertices( - vertices: ArrayLike, - closed_path: bool = True, - tolerance: float = 0.0, - ) -> NDArray[numpy.float64]: +def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any consecutive duplicates. @@ -17,36 +13,18 @@ def remove_duplicate_vertices( vertices: `[[x0, y0], [x1, y1], ...]` closed_path: If True, `vertices` is interpreted as an implicity-closed path (i.e. the last vertex will be removed if it is the same as the first) - tolerance: Maximum coordinate-wise absolute difference for two vertices to - be considered duplicates. Default `0` requires exact equality. Returns: `vertices` with no consecutive duplicates. This may be a view into the original array. """ - if tolerance < 0: - raise ValueError(f'tolerance must be non-negative, got {tolerance}') - vertices = numpy.asarray(vertices) - if vertices.shape[0] <= 1: - return vertices - if tolerance == 0: - duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1) - else: - duplicates = (numpy.abs(vertices - numpy.roll(vertices, -1, axis=0)) <= tolerance).all(axis=1) + duplicates = (vertices == numpy.roll(vertices, 1, axis=0)).all(axis=1) if not closed_path: - duplicates[-1] = False - - result = vertices[~duplicates] - if result.shape[0] == 0 and vertices.shape[0] > 0: - return vertices[:1] - return result + duplicates[0] = False + return vertices[~duplicates] -def remove_colinear_vertices( - vertices: ArrayLike, - closed_path: bool = True, - preserve_uturns: bool = False, - ) -> NDArray[numpy.float64]: +def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any superflous vertices (i.e. those which lie along the line formed by their neighbors) @@ -55,40 +33,24 @@ def remove_colinear_vertices( vertices: Nx2 ndarray of vertices closed_path: If `True`, the vertices are assumed to represent an implicitly closed path. If `False`, the path is assumed to be open. Default `True`. - preserve_uturns: If `True`, colinear vertices that correspond to a 180 degree - turn (a "spike") are preserved. Default `False`. Returns: `vertices` with colinear (superflous) vertices removed. May be a view into the original array. """ - vertices = remove_duplicate_vertices(vertices, closed_path=closed_path) + vertices = remove_duplicate_vertices(vertices) # Check for dx0/dy0 == dx1/dy1 - dv = numpy.roll(vertices, -1, axis=0) - vertices - if not closed_path: - dv[-1] = 0 - # dxdy[i] is based on dv[i] and dv[i-1] - # slopes_equal[i] refers to vertex i - dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] + dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]] dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 slopes_equal = (dxdy_diff / err_mult) < 1e-15 - - if preserve_uturns: - # Only merge if segments are in the same direction (avoid collapsing u-turns) - dot_prod = (dv * numpy.roll(dv, 1, axis=0)).sum(axis=1) - slopes_equal &= (dot_prod > 0) - if not closed_path: slopes_equal[[0, -1]] = False - if slopes_equal.all() and vertices.shape[0] > 0: - # All colinear, keep the first and last - return vertices[[0, vertices.shape[0] - 1]] - return vertices[~slopes_equal] @@ -96,7 +58,7 @@ def poly_contains_points( vertices: ArrayLike, points: ArrayLike, include_boundary: bool = True, - ) -> NDArray[numpy.bool_]: + ) -> NDArray[numpy.int_]: """ Tests whether the provided points are inside the implicitly closed polygon described by the provided list of vertices. @@ -115,13 +77,13 @@ def poly_contains_points( vertices = numpy.asarray(vertices, dtype=float) if points.size == 0: - return numpy.zeros(0, dtype=bool) + return numpy.zeros(0, dtype=numpy.int8) min_bounds = numpy.min(vertices, axis=0)[None, :] max_bounds = numpy.max(vertices, axis=0)[None, :] trivially_outside = ((points < min_bounds).any(axis=1) - | (points > max_bounds).any(axis=1)) + | (points > max_bounds).any(axis=1)) # noqa: E128 nontrivial = ~trivially_outside if trivially_outside.all(): @@ -139,10 +101,10 @@ def poly_contains_points( dv = numpy.roll(verts, -1, axis=0) - verts is_left = (dv[:, 0] * (ntpts[..., 1] - verts[:, 1]) # >0 if left of dv, <0 if right, 0 if on the line - - dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) + - dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) # noqa: E128 winding_number = ((upward & (is_left > 0)).sum(axis=0) - - (downward & (is_left < 0)).sum(axis=0)) + - (downward & (is_left < 0)).sum(axis=0)) # noqa: E128 nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number if include_boundary: diff --git a/pyproject.toml b/pyproject.toml index 6ace09f..062098d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "masque" description = "Lithography mask library" @@ -42,54 +46,16 @@ dependencies = [ "klamath~=1.4", ] -[dependency-groups] -dev = [ - "masque[arrow]", - "masque[oasis]", - "masque[dxf]", - "masque[svg]", - "masque[visualize]", - "masque[text]", - "masque[manhattanize]", - "masque[manhattanize_slow]", - "masque[boolean]", - "matplotlib>=3.10.8", - "pytest>=9.0.2", - "ruff>=0.15.5", - "mypy>=1.19.1", - ] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.sdist] -only-include = [ - "masque", - "examples", - "stubs", - "pyproject.toml", - "README.md", - "LICENSE.md", - "MIGRATION.md", - ] - -[tool.hatch.build.targets.wheel] -packages = ["masque"] [tool.hatch.version] path = "masque/__init__.py" [project.optional-dependencies] -arrow = ["pyarrow", "cffi"] -oasis = ["fatamorgana>=0.14"] -dxf = ["ezdxf~=1.4", "pyclipper"] +oasis = ["fatamorgana~=0.11"] +dxf = ["ezdxf~=1.0.2"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] -manhattanize = ["scikit-image"] -manhattanize_slow = ["float_raster"] -boolean = ["pyclipper"] [tool.ruff] @@ -120,20 +86,10 @@ lint.ignore = [ "PLR09", # Too many xxx "PLR2004", # magic number "PLC0414", # import x as x -# "PLC0415", # non-top-level import - "PLW1641", # missing __hash__ with total_ordering "TRY003", # Long exception message ] [tool.pytest.ini_options] addopts = "-rsXx" testpaths = ["masque"] -filterwarnings = [ - "ignore::DeprecationWarning:ezdxf.*", -] -[tool.mypy] -mypy_path = "stubs" -python_version = "3.11" -strict = false -check_untyped_defs = true diff --git a/stubs/ezdxf/__init__.pyi b/stubs/ezdxf/__init__.pyi deleted file mode 100644 index f25475f..0000000 --- a/stubs/ezdxf/__init__.pyi +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Any, TextIO -from collections.abc import Iterable -from .layouts import Modelspace, BlockRecords - -class Drawing: - blocks: BlockRecords - @property - def layers(self) -> Iterable[Any]: ... - def modelspace(self) -> Modelspace: ... - def write(self, stream: TextIO) -> None: ... - -def new(version: str = ..., setup: bool = ...) -> Drawing: ... -def read(stream: TextIO) -> Drawing: ... diff --git a/stubs/ezdxf/entities.pyi b/stubs/ezdxf/entities.pyi deleted file mode 100644 index 2c6efa9..0000000 --- a/stubs/ezdxf/entities.pyi +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Any -from collections.abc import Iterable, Sequence - -class DXFEntity: - def dxfattribs(self) -> dict[str, Any]: ... - def dxftype(self) -> str: ... - -class LWPolyline(DXFEntity): - def get_points(self) -> Iterable[tuple[float, ...]]: ... - -class Polyline(DXFEntity): - def points(self) -> Iterable[Any]: ... # has .xyz - -class Text(DXFEntity): - def get_placement(self) -> tuple[int, tuple[float, float, float]]: ... - def set_placement(self, p: Sequence[float], align: int = ...) -> Text: ... - -class Insert(DXFEntity): ... diff --git a/stubs/ezdxf/enums.pyi b/stubs/ezdxf/enums.pyi deleted file mode 100644 index 0dcf600..0000000 --- a/stubs/ezdxf/enums.pyi +++ /dev/null @@ -1,4 +0,0 @@ -from enum import IntEnum - -class TextEntityAlignment(IntEnum): - BOTTOM_LEFT = ... diff --git a/stubs/ezdxf/layouts.pyi b/stubs/ezdxf/layouts.pyi deleted file mode 100644 index c9d12ad..0000000 --- a/stubs/ezdxf/layouts.pyi +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Any -from collections.abc import Iterator, Sequence, Iterable -from .entities import DXFEntity - -class BaseLayout: - def __iter__(self) -> Iterator[DXFEntity]: ... - def add_lwpolyline(self, points: Iterable[Sequence[float]], dxfattribs: dict[str, Any] = ...) -> Any: ... - def add_text(self, text: str, dxfattribs: dict[str, Any] = ...) -> Any: ... - def add_blockref(self, name: str, insert: Any, dxfattribs: dict[str, Any] = ...) -> Any: ... - -class Modelspace(BaseLayout): - @property - def name(self) -> str: ... - -class BlockLayout(BaseLayout): - @property - def name(self) -> str: ... - -class BlockRecords: - def new(self, name: str) -> BlockLayout: ... - def __iter__(self) -> Iterator[BlockLayout]: ... diff --git a/stubs/pyclipper/__init__.pyi b/stubs/pyclipper/__init__.pyi deleted file mode 100644 index 08d77c8..0000000 --- a/stubs/pyclipper/__init__.pyi +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any -from collections.abc import Iterable, Sequence -import numpy -from numpy.typing import NDArray - - -# Basic types for Clipper integer coordinates -Path = Sequence[tuple[int, int]] -Paths = Sequence[Path] - -# Types for input/output floating point coordinates -FloatPoint = tuple[float, float] | NDArray[numpy.floating] -FloatPath = Sequence[FloatPoint] | NDArray[numpy.floating] -FloatPaths = Iterable[FloatPath] - -# Constants -PT_SUBJECT: int -PT_CLIP: int - -PT_UNION: int -PT_INTERSECTION: int -PT_DIFFERENCE: int -PT_XOR: int - -PFT_EVENODD: int -PFT_NONZERO: int -PFT_POSITIVE: int -PFT_NEGATIVE: int - -# Scaling functions -def scale_to_clipper(paths: FloatPaths, scale: float = ...) -> Paths: ... -def scale_from_clipper(paths: Path | Paths, scale: float = ...) -> Any: ... - -class PolyNode: - Contour: Path - Childs: list[PolyNode] - Parent: PolyNode - IsHole: bool - -class Pyclipper: - def __init__(self) -> None: ... - def AddPath(self, path: Path, poly_type: int, closed: bool) -> None: ... - def AddPaths(self, paths: Paths, poly_type: int, closed: bool) -> None: ... - def Execute(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> Paths: ... - def Execute2(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> PolyNode: ... - def Clear(self) -> None: ... diff --git a/tools/__init__.py b/tools/__init__.py deleted file mode 100644 index 34d1ce8..0000000 --- a/tools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Repository development tools.""" diff --git a/tools/generate_gds_perf.py b/tools/generate_gds_perf.py deleted file mode 100644 index 64bd7ab..0000000 --- a/tools/generate_gds_perf.py +++ /dev/null @@ -1,626 +0,0 @@ -""" -Synthetic GDS fixture generation for reader/writer performance testing. - -The presets here are intentionally hierarchical and deterministic. They aim to -approximate a pair of real-world layout families discussed during GDS reader and -writer work: - -* `many_cells`: tens of thousands of cells, moderate reference count, very heavy - box usage after flattening, and moderate polygon density. -* `many_instances`: a much smaller cell library with very high reference count, - similar box density, and far fewer polygons. - -Fixtures are written by streaming structures through `klamath` directly so large -benchmark files can be produced without first materializing an equally large -`masque.Library` in Python. -""" -from __future__ import annotations - -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Any -import argparse -import json -import math - -import numpy -import klamath -from klamath import elements - - -EMPTY_PROPERTIES: dict[int, bytes] = {} -METERS_PER_DB_UNIT = 1e-9 -USER_UNITS_PER_DB_UNIT = 1e-3 -TOTAL_LAYERS = 200 - - -@dataclass(frozen=True) -class FixturePreset: - name: str - total_layers: int - box_layers: int - heavy_box_layers: int - polygon_layers: int - box_cells: int - poly_cells: int - box_wrappers: int - poly_wrappers: int - box_clusters: int - poly_clusters: int - box_cluster_refs: int - poly_cluster_refs: int - top_direct_box_refs: int - top_direct_poly_refs: int - heavy_boxes_per_cell: int - regular_boxes_per_cell: int - polygons_per_cell: int - path_stride: int - text_stride: int - box_cluster_array: tuple[int, int] - top_box_array: tuple[int, int] - poly_cluster_array: tuple[int, int] - top_poly_array: tuple[int, int] - rare_annotation_stride: int - - -PRESETS: dict[str, FixturePreset] = { - 'many_cells': FixturePreset( - name='many_cells', - total_layers=TOTAL_LAYERS, - box_layers=20, - heavy_box_layers=3, - polygon_layers=20, - box_cells=17_000, - poly_cells=6_000, - box_wrappers=18_000, - poly_wrappers=6_000, - box_clusters=2_000, - poly_clusters=999, - box_cluster_refs=24, - poly_cluster_refs=16, - top_direct_box_refs=21_000, - top_direct_poly_refs=7_000, - heavy_boxes_per_cell=6, - regular_boxes_per_cell=2, - polygons_per_cell=50, - path_stride=2, - text_stride=3, - box_cluster_array=(24, 16), - top_box_array=(8, 8), - poly_cluster_array=(4, 2), - top_poly_array=(3, 2), - rare_annotation_stride=1_250, - ), - 'many_instances': FixturePreset( - name='many_instances', - total_layers=TOTAL_LAYERS, - box_layers=25, - heavy_box_layers=3, - polygon_layers=10, - box_cells=2_500, - poly_cells=500, - box_wrappers=1_000, - poly_wrappers=500, - box_clusters=1_000, - poly_clusters=499, - box_cluster_refs=1_200, - poly_cluster_refs=400, - top_direct_box_refs=102_001, - top_direct_poly_refs=0, - heavy_boxes_per_cell=40, - regular_boxes_per_cell=16, - polygons_per_cell=60, - path_stride=1, - text_stride=2, - box_cluster_array=(1, 1), - top_box_array=(1, 1), - poly_cluster_array=(1, 1), - top_poly_array=(1, 1), - rare_annotation_stride=250, - ), - } - - -@dataclass(frozen=True) -class FixtureManifest: - preset: str - scale: float - gds_path: str - library_name: str - cells: int - refs: int - layers: int - box_layers: int - heavy_box_layers: list[list[int]] - polygon_layers: list[list[int]] - hierarchical_boxes_per_heavy_layer: int - hierarchical_boxes_per_regular_layer: int - hierarchical_polygons_total: int - hierarchical_paths_total: int - hierarchical_texts_total: int - flattened_box_placements: int - flattened_poly_placements: int - estimated_flat_boxes_per_heavy_layer: int - estimated_flat_polygons_per_active_polygon_layer: int - - -def _scaled_count(value: int, scale: float, minimum: int = 0) -> int: - if value == 0: - return 0 - scaled = int(math.ceil(value * scale)) - return max(minimum, scaled) - - -def _scaled_preset(preset: FixturePreset, scale: float) -> FixturePreset: - if scale <= 0: - raise ValueError(f'scale must be positive, got {scale!r}') - - return FixturePreset( - name=preset.name, - total_layers=preset.total_layers, - box_layers=min(preset.box_layers, preset.total_layers), - heavy_box_layers=min(preset.heavy_box_layers, preset.box_layers), - polygon_layers=min(preset.polygon_layers, preset.total_layers), - box_cells=_scaled_count(preset.box_cells, scale, minimum=1), - poly_cells=_scaled_count(preset.poly_cells, scale, minimum=1), - box_wrappers=_scaled_count(preset.box_wrappers, scale), - poly_wrappers=_scaled_count(preset.poly_wrappers, scale), - box_clusters=_scaled_count(preset.box_clusters, scale, minimum=1), - poly_clusters=_scaled_count(preset.poly_clusters, scale, minimum=1), - box_cluster_refs=_scaled_count(preset.box_cluster_refs, scale, minimum=1), - poly_cluster_refs=_scaled_count(preset.poly_cluster_refs, scale, minimum=1), - top_direct_box_refs=_scaled_count(preset.top_direct_box_refs, scale), - top_direct_poly_refs=_scaled_count(preset.top_direct_poly_refs, scale), - heavy_boxes_per_cell=max(1, preset.heavy_boxes_per_cell), - regular_boxes_per_cell=max(1, preset.regular_boxes_per_cell), - polygons_per_cell=max(1, preset.polygons_per_cell), - path_stride=max(1, preset.path_stride), - text_stride=max(1, preset.text_stride), - box_cluster_array=preset.box_cluster_array, - top_box_array=preset.top_box_array, - poly_cluster_array=preset.poly_cluster_array, - top_poly_array=preset.top_poly_array, - rare_annotation_stride=max(1, _scaled_count(preset.rare_annotation_stride, scale, minimum=1)), - ) - - -def _rect_xy(xmin: int, ymin: int, xmax: int, ymax: int) -> numpy.ndarray[Any, numpy.dtype[numpy.int32]]: - return numpy.array( - [[xmin, ymin], [xmin, ymax], [xmax, ymax], [xmax, ymin], [xmin, ymin]], - dtype=numpy.int32, - ) - - -def _poly_xy(points: list[tuple[int, int]]) -> numpy.ndarray[Any, numpy.dtype[numpy.int32]]: - closed = points + [points[0]] - return numpy.array(closed, dtype=numpy.int32) - - -def _sref( - target: str, - xy: tuple[int, int], - properties: dict[int, bytes] | None = None, - ) -> elements.Reference: - return klamath.library.Reference( - struct_name=target.encode('ASCII'), - invert_y=False, - mag=1.0, - angle_deg=0.0, - xy=numpy.array([xy], dtype=numpy.int32), - colrow=None, - properties=EMPTY_PROPERTIES if properties is None else properties, - ) - - -def _aref( - target: str, - origin: tuple[int, int], - counts: tuple[int, int], - step: tuple[int, int], - properties: dict[int, bytes] | None = None, - ) -> elements.Reference: - cols, rows = counts - dx, dy = step - xy = numpy.array( - [ - origin, - (origin[0] + cols * dx, origin[1]), - (origin[0], origin[1] + rows * dy), - ], - dtype=numpy.int32, - ) - return klamath.library.Reference( - struct_name=target.encode('ASCII'), - invert_y=False, - mag=1.0, - angle_deg=0.0, - xy=xy, - colrow=(cols, rows), - properties=EMPTY_PROPERTIES if properties is None else properties, - ) - - -def _annotation(index: int) -> dict[int, bytes]: - return {1: f'perf-{index}'.encode('ASCII')} - - -def _make_box_cell(index: int, cfg: FixturePreset) -> list[elements.Element]: - cell_elements: list[elements.Element] = [] - xbase = (index % 17) * 600 - ybase = (index // 17) * 180 - - for layer in range(cfg.heavy_box_layers): - for box_idx in range(cfg.heavy_boxes_per_cell): - x0 = xbase + box_idx * 22 - y0 = ybase + layer * 40 - width = 10 + ((index + box_idx + layer) % 7) * 6 - height = 10 + ((index * 3 + box_idx + layer) % 5) * 8 - properties = _annotation(index) if index % cfg.rare_annotation_stride == 0 and box_idx == 0 and layer == 0 else EMPTY_PROPERTIES - cell_elements.append(elements.Boundary( - layer=(layer, 0), - xy=_rect_xy(x0, y0, x0 + width, y0 + height), - properties=properties, - )) - - for layer in range(cfg.heavy_box_layers, cfg.box_layers): - for box_idx in range(cfg.regular_boxes_per_cell): - x0 = xbase + box_idx * 38 - y0 = ybase + (layer - cfg.heavy_box_layers) * 28 + 400 - width = 18 + ((index + layer + box_idx) % 9) * 4 - height = 12 + ((index + 2 * layer + box_idx) % 6) * 5 - cell_elements.append(elements.Boundary( - layer=(layer, 0), - xy=_rect_xy(x0, y0, x0 + width, y0 + height), - properties=EMPTY_PROPERTIES, - )) - - return cell_elements - - -def _make_poly_cell(index: int, cfg: FixturePreset) -> list[elements.Element]: - cell_elements: list[elements.Element] = [] - xbase = (index % 19) * 900 - ybase = (index // 19) * 260 - - for poly_idx in range(cfg.polygons_per_cell): - layer = poly_idx % cfg.polygon_layers - dx = xbase + (poly_idx % 5) * 120 - dy = ybase + (poly_idx // 5) * 80 - size = 18 + ((index + poly_idx + layer) % 11) * 7 - points = [ - (dx, dy), - (dx + size, dy + size // 5), - (dx + size + size // 3, dy + size), - (dx + size // 2, dy + size + size // 2), - (dx - size // 4, dy + size // 2), - ] - properties = _annotation(index) if poly_idx == 0 and index % cfg.rare_annotation_stride == 0 else EMPTY_PROPERTIES - cell_elements.append(elements.Boundary( - layer=(layer, 0), - xy=_poly_xy(points), - properties=properties, - )) - - if index % cfg.path_stride == 0: - layer = index % cfg.polygon_layers - cell_elements.append(elements.Path( - layer=(layer, 1), - path_type=2, - width=12 + (index % 5) * 4, - extension=(0, 0), - xy=numpy.array( - [ - [xbase, ybase + 900], - [xbase + 240, ybase + 930], - [xbase + 420, ybase + 960], - ], - dtype=numpy.int32, - ), - properties=EMPTY_PROPERTIES, - )) - - if index % cfg.text_stride == 0: - layer = index % cfg.polygon_layers - properties = _annotation(index) if index % cfg.rare_annotation_stride == 0 else EMPTY_PROPERTIES - cell_elements.append(elements.Text( - layer=(layer, 2), - presentation=0, - path_type=0, - width=0, - invert_y=False, - mag=1.0, - angle_deg=0.0, - xy=numpy.array([[xbase + 64, ybase + 1536]], dtype=numpy.int32), - string=f'T{index:05d}'.encode('ASCII'), - properties=properties, - )) - - return cell_elements - - -def _write_struct(stream: Any, name: str, cell_elements: list[elements.Element]) -> None: - klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=cell_elements) - - -def _box_name(index: int) -> str: - return f'box_{index:05d}' - - -def _poly_name(index: int) -> str: - return f'poly_{index:05d}' - - -def _box_wrapper_name(index: int) -> str: - return f'box_wrap_{index:05d}' - - -def _poly_wrapper_name(index: int) -> str: - return f'poly_wrap_{index:05d}' - - -def _box_cluster_name(index: int) -> str: - return f'box_cluster_{index:05d}' - - -def _poly_cluster_name(index: int) -> str: - return f'poly_cluster_{index:05d}' - - -def _write_box_cells(stream: Any, cfg: FixturePreset) -> None: - for idx in range(cfg.box_cells): - _write_struct(stream, _box_name(idx), _make_box_cell(idx, cfg)) - - -def _write_poly_cells(stream: Any, cfg: FixturePreset) -> None: - for idx in range(cfg.poly_cells): - _write_struct(stream, _poly_name(idx), _make_poly_cell(idx, cfg)) - - -def _write_wrappers(stream: Any, cfg: FixturePreset) -> None: - for idx in range(cfg.box_wrappers): - target = _box_name(idx % cfg.box_cells) - origin = ((idx % 97) * 2_000, (idx // 97) * 2_000) - _write_struct(stream, _box_wrapper_name(idx), [_sref(target, origin)]) - - for idx in range(cfg.poly_wrappers): - target = _poly_name(idx % cfg.poly_cells) - origin = ((idx % 61) * 3_200, (idx // 61) * 3_200) - _write_struct(stream, _poly_wrapper_name(idx), [_sref(target, origin)]) - - -def _write_box_clusters(stream: Any, cfg: FixturePreset) -> None: - array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4)) - for idx in range(cfg.box_clusters): - cell_elements: list[elements.Element] = [] - for ref_idx in range(cfg.box_cluster_refs): - target = _box_name((idx * cfg.box_cluster_refs + ref_idx) % cfg.box_cells) - origin = ( - (ref_idx % 6) * 48_000, - (ref_idx // 6) * 48_000, - ) - if ref_idx < array_refs: - cell_elements.append(_aref(target, origin, cfg.box_cluster_array, (720, 900))) - else: - cell_elements.append(_sref(target, origin)) - _write_struct(stream, _box_cluster_name(idx), cell_elements) - - -def _write_poly_clusters(stream: Any, cfg: FixturePreset) -> None: - array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2) - for idx in range(cfg.poly_clusters): - cell_elements: list[elements.Element] = [] - for ref_idx in range(cfg.poly_cluster_refs): - target = _poly_name((idx * cfg.poly_cluster_refs + ref_idx) % cfg.poly_cells) - origin = ( - (ref_idx % 10) * 96_000, - (ref_idx // 10) * 96_000, - ) - if ref_idx < array_refs: - cell_elements.append(_aref(target, origin, cfg.poly_cluster_array, (12_000, 8_500))) - else: - cell_elements.append(_sref(target, origin)) - _write_struct(stream, _poly_cluster_name(idx), cell_elements) - - -def _top_box_refs(cfg: FixturePreset) -> list[elements.Reference]: - refs: list[elements.Reference] = [] - - for idx in range(cfg.box_wrappers): - refs.append(_sref( - _box_wrapper_name(idx), - ((idx % 240) * 240_000, (idx // 240) * 240_000), - )) - - for idx in range(cfg.box_clusters): - refs.append(_sref( - _box_cluster_name(idx), - ((idx % 100) * 800_000, (idx // 100) * 800_000 + 14_000_000), - )) - - for idx in range(cfg.top_direct_box_refs): - target = _box_name(idx % cfg.box_cells) - origin = ( - (idx % 150) * 160_000, - (idx // 150) * 160_000 + 26_000_000, - ) - if cfg.top_box_array == (1, 1): - refs.append(_sref(target, origin)) - else: - refs.append(_aref(target, origin, cfg.top_box_array, (1_100, 1_350))) - - return refs - - -def _top_poly_refs(cfg: FixturePreset) -> list[elements.Reference]: - refs: list[elements.Reference] = [] - - for idx in range(cfg.poly_wrappers): - refs.append(_sref( - _poly_wrapper_name(idx), - ((idx % 180) * 360_000, (idx // 180) * 360_000 + 44_000_000), - )) - - for idx in range(cfg.poly_clusters): - refs.append(_sref( - _poly_cluster_name(idx), - ((idx % 70) * 1_100_000, (idx // 70) * 1_100_000 + 58_000_000), - )) - - for idx in range(cfg.top_direct_poly_refs): - target = _poly_name(idx % cfg.poly_cells) - origin = ( - (idx % 110) * 420_000, - (idx // 110) * 420_000 + 72_000_000, - ) - if cfg.top_poly_array == (1, 1): - refs.append(_sref(target, origin)) - else: - refs.append(_aref(target, origin, cfg.top_poly_array, (16_000, 14_000))) - - return refs - - -def _write_top(stream: Any, cfg: FixturePreset) -> None: - cell_elements: list[elements.Element] = [] - cell_elements.extend(_top_box_refs(cfg)) - cell_elements.extend(_top_poly_refs(cfg)) - _write_struct(stream, 'TOP', cell_elements) - - -def fixture_manifest(path: str | Path, preset: str, scale: float = 1.0) -> FixtureManifest: - base = PRESETS[preset] - cfg = _scaled_preset(base, scale) - - box_cluster_array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4)) - box_cluster_array_mult = cfg.box_cluster_array[0] * cfg.box_cluster_array[1] - box_cluster_ref_instances = ( - box_cluster_array_refs * box_cluster_array_mult - + (cfg.box_cluster_refs - box_cluster_array_refs) - ) - poly_cluster_array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2) - poly_cluster_array_mult = cfg.poly_cluster_array[0] * cfg.poly_cluster_array[1] - poly_cluster_ref_instances = ( - poly_cluster_array_refs * poly_cluster_array_mult - + (cfg.poly_cluster_refs - poly_cluster_array_refs) - ) - - flattened_box_placements = ( - cfg.box_wrappers - + cfg.box_clusters * box_cluster_ref_instances - + cfg.top_direct_box_refs * cfg.top_box_array[0] * cfg.top_box_array[1] - ) - flattened_poly_placements = ( - cfg.poly_wrappers - + cfg.poly_clusters * poly_cluster_ref_instances - + cfg.top_direct_poly_refs * cfg.top_poly_array[0] * cfg.top_poly_array[1] - ) - polygon_layers = max(1, cfg.polygon_layers) - polys_per_layer = (cfg.poly_cells * cfg.polygons_per_cell) // polygon_layers - - return FixtureManifest( - preset=cfg.name, - scale=scale, - gds_path=str(Path(path)), - library_name=f'masque-perf-{cfg.name}', - cells=cfg.box_cells + cfg.poly_cells + cfg.box_wrappers + cfg.poly_wrappers + cfg.box_clusters + cfg.poly_clusters + 1, - refs=( - cfg.box_wrappers - + cfg.poly_wrappers - + cfg.box_clusters * cfg.box_cluster_refs - + cfg.poly_clusters * cfg.poly_cluster_refs - + cfg.box_wrappers + cfg.poly_wrappers + cfg.box_clusters + cfg.poly_clusters - + cfg.top_direct_box_refs + cfg.top_direct_poly_refs - ), - layers=cfg.total_layers, - box_layers=cfg.box_layers, - heavy_box_layers=[[layer, 0] for layer in range(cfg.heavy_box_layers)], - polygon_layers=[[layer, 0] for layer in range(cfg.polygon_layers)], - hierarchical_boxes_per_heavy_layer=cfg.box_cells * cfg.heavy_boxes_per_cell, - hierarchical_boxes_per_regular_layer=cfg.box_cells * cfg.regular_boxes_per_cell, - hierarchical_polygons_total=cfg.poly_cells * cfg.polygons_per_cell, - hierarchical_paths_total=(cfg.poly_cells - 1) // cfg.path_stride + 1, - hierarchical_texts_total=(cfg.poly_cells - 1) // cfg.text_stride + 1, - flattened_box_placements=flattened_box_placements, - flattened_poly_placements=flattened_poly_placements, - estimated_flat_boxes_per_heavy_layer=flattened_box_placements * cfg.heavy_boxes_per_cell, - estimated_flat_polygons_per_active_polygon_layer=flattened_poly_placements * polys_per_layer // cfg.poly_cells if cfg.poly_cells else 0, - ) - - -def write_fixture( - path: str | Path, - *, - preset: str, - scale: float = 1.0, - write_manifest: bool = True, - ) -> FixtureManifest: - if preset not in PRESETS: - known = ', '.join(sorted(PRESETS)) - raise KeyError(f'unknown preset {preset!r}; expected one of: {known}') - - manifest = fixture_manifest(path, preset, scale) - cfg = _scaled_preset(PRESETS[preset], scale) - output = Path(path) - output.parent.mkdir(parents=True, exist_ok=True) - - with output.open('wb') as stream: - header = klamath.library.FileHeader( - name=manifest.library_name.encode('ASCII'), - user_units_per_db_unit=USER_UNITS_PER_DB_UNIT, - meters_per_db_unit=METERS_PER_DB_UNIT, - ) - header.write(stream) - _write_box_cells(stream, cfg) - _write_poly_cells(stream, cfg) - _write_wrappers(stream, cfg) - _write_box_clusters(stream, cfg) - _write_poly_clusters(stream, cfg) - _write_top(stream, cfg) - klamath.records.ENDLIB.write(stream, None) - - if write_manifest: - manifest_path = output.with_suffix(output.suffix + '.json') - manifest_path.write_text(json.dumps(asdict(manifest), indent=2, sort_keys=True) + '\n') - - return manifest - - -def build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description='Generate synthetic GDS fixtures for GDS reader/writer performance work.') - parser.add_argument( - 'preset', - nargs='?', - default='many_cells', - choices=sorted(PRESETS), - help='Fixture family to generate.', - ) - parser.add_argument( - 'output', - nargs='?', - help='Output .gds path. Defaults to build/gds_perf/.gds', - ) - parser.add_argument( - '--scale', - type=float, - default=1.0, - help='Scale the preset counts down or up while keeping the same shape mix. Default: 1.0', - ) - parser.add_argument( - '--no-manifest', - action='store_true', - help='Do not write the sidecar JSON manifest.', - ) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_arg_parser() - args = parser.parse_args(argv) - output = Path(args.output) if args.output is not None else Path('build/gds_perf') / f'{args.preset}.gds' - manifest = write_fixture(output, preset=args.preset, scale=args.scale, write_manifest=not args.no_manifest) - print(json.dumps(asdict(manifest), indent=2, sort_keys=True)) - return 0 - - -if __name__ == '__main__': - raise SystemExit(main())