Compare commits
91 changed files with 3832 additions and 22274 deletions
703
MIGRATION.md
703
MIGRATION.md
|
|
@ -1,8 +1,7 @@
|
|||
# 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.
|
||||
This guide covers changes between the git tag `release` and the current tree.
|
||||
At `release`, `masque.__version__` was `3.3`; the current tree reports `3.4`.
|
||||
|
||||
Most downstream changes are in `masque/builder/*`, but there are a few other
|
||||
API changes that may require code updates.
|
||||
|
|
@ -23,13 +22,13 @@ The biggest migration point is that the old routing verbs were renamed:
|
|||
| `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)` |
|
||||
| `RenderPather.path(...)` | `Pather(..., auto_render=False).trace(...)` |
|
||||
| `RenderPather.path_to(...)` | `Pather(..., auto_render=False).trace_to(...)` |
|
||||
| `RenderPather.mpath(...)` | `Pather(..., auto_render=False).trace(...)` / `Pather(..., auto_render=False).trace_to(...)` |
|
||||
| `RenderPather.pathS(...)` | `Pather(..., auto_render=False).jog(...)` |
|
||||
| `RenderPather.pathU(...)` | `Pather(..., auto_render=False).uturn(...)` |
|
||||
| `RenderPather.path_into(...)` | `Pather(..., auto_render=False).trace_into(...)` |
|
||||
| `RenderPather.path_from(src, dst)` | `Pather(..., auto_render=False).at(src).trace_into(dst)` |
|
||||
|
||||
There are also new convenience wrappers:
|
||||
|
||||
|
|
@ -124,107 +123,89 @@ from masque.builder.renderpather import RenderPather
|
|||
from masque.builder import Pather
|
||||
|
||||
builder = Pather(...)
|
||||
deferred = Pather(..., render='deferred')
|
||||
deferred = Pather(..., auto_render=False)
|
||||
```
|
||||
|
||||
The new `Pather` remains importable from both `masque` and `masque.builder`.
|
||||
The removed `Builder` and `RenderPather` names are no longer exported from
|
||||
either location.
|
||||
Top-level imports from `masque` also continue to work.
|
||||
|
||||
`Pather` now defaults to `render='auto'`, so plain construction replaces the
|
||||
old `Builder` behavior. Use `Pather(..., render='deferred')` where you
|
||||
`Pather` now defaults to `auto_render=True`, so plain construction replaces the
|
||||
old `Builder` behavior. Use `Pather(..., auto_render=False)` where you
|
||||
previously used `RenderPather`.
|
||||
|
||||
## `SimpleTool` was removed and `AutoTool` registration changed
|
||||
## `BasicTool` was replaced
|
||||
|
||||
`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.
|
||||
`BasicTool` is no longer exported. Use:
|
||||
|
||||
### Old `AutoTool`
|
||||
- `SimpleTool` for the simple "one straight generator + one bend cell" case
|
||||
- `AutoTool` if you need transitions, multiple candidate straights/bends, or
|
||||
S-bends/U-bends
|
||||
|
||||
### Old `BasicTool`
|
||||
|
||||
```python
|
||||
from masque.builder import AutoTool
|
||||
from masque.builder.tools import BasicTool
|
||||
|
||||
tool = AutoTool(
|
||||
straights=[
|
||||
AutoTool.Straight('m1wire', make_straight, 'input', 'output'),
|
||||
],
|
||||
bends=[
|
||||
AutoTool.Bend(lib.abstract('bend'), 'input', 'output'),
|
||||
],
|
||||
sbends=[],
|
||||
tool = BasicTool(
|
||||
straight=(make_straight, 'input', 'output'),
|
||||
bend=(lib.abstract('bend'), 'input', 'output'),
|
||||
transitions={
|
||||
('m2wire', 'm1wire'): AutoTool.Transition(
|
||||
lib.abstract('via'), 'top', 'bottom'
|
||||
),
|
||||
'm2wire': (lib.abstract('via'), 'top', 'bottom'),
|
||||
},
|
||||
default_out_ptype='m1wire',
|
||||
)
|
||||
```
|
||||
|
||||
### New `AutoTool`
|
||||
|
||||
```python
|
||||
from masque.builder import AutoTool
|
||||
from masque.builder.tools 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')
|
||||
tool = AutoTool(
|
||||
straights=[
|
||||
AutoTool.Straight(
|
||||
ptype='m1wire',
|
||||
fn=make_straight,
|
||||
in_port_name='input',
|
||||
out_port_name='output',
|
||||
),
|
||||
],
|
||||
bends=[
|
||||
AutoTool.Bend(
|
||||
abstract=lib.abstract('bend'),
|
||||
in_port_name='input',
|
||||
out_port_name='output',
|
||||
clockwise=True,
|
||||
),
|
||||
],
|
||||
sbends=[],
|
||||
transitions={
|
||||
('m2wire', 'm1wire'): AutoTool.Transition(
|
||||
lib.abstract('via'),
|
||||
'top',
|
||||
'bottom',
|
||||
),
|
||||
},
|
||||
default_out_ptype='m1wire',
|
||||
)
|
||||
```
|
||||
|
||||
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
|
||||
- `BasicTool` -> `SimpleTool` or `AutoTool`
|
||||
- `straight=(fn, in_name, out_name)` -> `straights=[AutoTool.Straight(...)]`
|
||||
- `bend=(abstract, in_name, out_name)` -> `bends=[AutoTool.Bend(...)]`
|
||||
- transition keys are now `(external_ptype, internal_ptype)` tuples
|
||||
- transitions use `AutoTool.Transition(...)` instead of raw tuples
|
||||
|
||||
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`.
|
||||
If your old `BasicTool` usage did not rely on transitions or multiple routing
|
||||
options, `SimpleTool` is the closest replacement.
|
||||
|
||||
## 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
|
||||
- `Tool.path(...)` became `Tool.traceL(...)`
|
||||
- `Tool.traceS(...)` and `Tool.traceU(...)` were added for native S/U routes
|
||||
- `planL()` / `planS()` / `planU()` remain the planning hooks used by deferred rendering
|
||||
|
||||
In practice, a minimal old implementation like:
|
||||
|
||||
|
|
@ -237,269 +218,14 @@ class MyTool(Tool):
|
|||
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]):
|
||||
def traceL(self, ccw, length, **kwargs):
|
||||
...
|
||||
```
|
||||
|
||||
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)
|
||||
```
|
||||
If you do not implement `traceS()` or `traceU()`, the unified pather will
|
||||
either fall back to the planning hooks or synthesize those routes from simpler
|
||||
steps where possible.
|
||||
|
||||
## Transform semantics changed
|
||||
|
||||
|
|
@ -536,305 +262,26 @@ Check code that calls:
|
|||
- `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`
|
||||
- `PortPather`
|
||||
- `SimpleTool`
|
||||
- `AutoTool`
|
||||
- `boolean`
|
||||
|
||||
## Minimal migration checklist
|
||||
|
||||
|
|
@ -842,15 +289,11 @@ 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.
|
||||
2. Replace `BasicTool` with `SimpleTool` or `AutoTool`.
|
||||
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.
|
||||
routing helpers, you may not need any changes beyond the transform audit and any
|
||||
stale imports.
|
||||
|
|
|
|||
|
|
@ -275,6 +275,8 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
|
|||
```
|
||||
|
||||
|
||||
## Development
|
||||
## TODO
|
||||
|
||||
Project-level planned work is tracked in [TODO.md](TODO.md).
|
||||
* PolyCollection & arrow-based read/write
|
||||
* Bus-to-bus connections?
|
||||
* tuple / string layer auto-translation
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -20,16 +20,16 @@ Contents
|
|||
* Use `Pather` to snap ports together into a circuit
|
||||
* Check for dangling references
|
||||
- [library](library.py)
|
||||
* Continue from `devices.py` by declaring a mixed library with `LibraryBuilder`
|
||||
* Import source-backed GDS cells and register python-generated recipes together
|
||||
* Call `build()` to produce a normal library and report for downstream `Pather` usage and writing
|
||||
* Continue from `devices.py` using a lazy library
|
||||
* 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
|
||||
* Use `AutoTool` to generate paths
|
||||
* Use `AutoTool` 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 `Pather(auto_render=False)` 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
|
||||
|
|
|
|||
|
|
@ -1,133 +1,142 @@
|
|||
"""
|
||||
Tutorial: authoring a mixed library with `LibraryBuilder`.
|
||||
Tutorial: using `LazyLibrary` and `Pather.interface()`.
|
||||
|
||||
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.
|
||||
itself, but rather how Masque lets you mix lazily loaded GDS content with
|
||||
python-generated devices inside one library.
|
||||
"""
|
||||
from typing import Any
|
||||
from pprint import pformat
|
||||
|
||||
|
||||
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 Pather, LazyLibrary
|
||||
from masque.file.gdsii import writefile, load_libraryfile
|
||||
|
||||
import basic_shapes
|
||||
import devices
|
||||
from devices import 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)(...)
|
||||
# A `LazyLibrary` delays work until a pattern is actually needed.
|
||||
# That applies both to GDS cells we load from disk and to python callables
|
||||
# that generate patterns on demand.
|
||||
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 those cells into our lazy library.
|
||||
# Nothing is read yet; we are only registering how to fetch and postprocess
|
||||
# each pattern when it is first requested.
|
||||
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 lambdas are only recipes for building the
|
||||
# patterns; they do not execute until someone asks for the cell.
|
||||
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))
|
||||
# Start a new design by copying the ports from an existing library cell.
|
||||
# This gives `circ2` the same external interface as `tri_l3cav`.
|
||||
circ2 = Pather(library=lib, ports='tri_l3cav')
|
||||
|
||||
# First way to specify what we are plugging in: request an explicit abstract.
|
||||
# This works with `Pattern` methods directly as well as with `Pather`.
|
||||
circ2.plug(lib.abstract('wg10'), {'input': 'right'})
|
||||
|
||||
# Second way: use an `AbstractView`, which behaves like a mapping of names
|
||||
# to abstracts.
|
||||
abstracts = lib.abstract_view()
|
||||
circ2.plug(abstracts['wg10'], {'output': 'left'})
|
||||
|
||||
# Third way: let `Pather` resolve a pattern name through its own library.
|
||||
# This shorthand is convenient, but it is specific to helpers that already
|
||||
# carry a library reference.
|
||||
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 second device that is explicitly designed to mate with `circ2`.
|
||||
#
|
||||
|
||||
# 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
|
||||
# `Pather.interface()` makes a new pattern whose ports mirror an existing
|
||||
# design's external interface. That is useful when you want to design an
|
||||
# adapter, continuation, or mating structure.
|
||||
circ3 = Pather.interface(source=circ2)
|
||||
|
||||
# Continue routing outward from those inherited ports.
|
||||
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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,20 +1,10 @@
|
|||
"""
|
||||
Manual wire routing tutorial: Pather and primitive offers
|
||||
Manual wire routing tutorial: Pather and AutoTool
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from masque import Pather, Library, Pattern, Port, layer_t
|
||||
from masque.abstract import Abstract
|
||||
from masque.builder import (
|
||||
BendOffer, RenderStep, StraightOffer, Tool, ToolContractCase, validate_tool_contract,
|
||||
)
|
||||
from masque.error import BuildError
|
||||
from masque.builder.tools import AutoTool, Tool
|
||||
from masque.file.gdsii import writefile
|
||||
from masque.library import ILibrary, SINGLE_USE_PREFIX
|
||||
|
||||
from basic_shapes import GDS_OPTS
|
||||
|
||||
|
|
@ -121,256 +111,6 @@ def map_layer(layer: layer_t) -> layer_t:
|
|||
return layer
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireStraightData:
|
||||
length: float
|
||||
out_transition: 'WireTransitionSpec | None' = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireBendData:
|
||||
straight_length: float
|
||||
ccw: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireTransitionSpec:
|
||||
abstract: Abstract
|
||||
in_port_name: str
|
||||
out_port_name: str
|
||||
|
||||
@property
|
||||
def in_port(self) -> Port:
|
||||
return self.abstract.ports[self.in_port_name]
|
||||
|
||||
@property
|
||||
def out_port(self) -> Port:
|
||||
return self.abstract.ports[self.out_port_name]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireTransitionData:
|
||||
spec: WireTransitionSpec
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrimitiveWireTool(Tool):
|
||||
"""
|
||||
Minimal routing tool that exposes local routing primitives directly.
|
||||
|
||||
The high-level `Pather` methods below still decide how to compose straights,
|
||||
bends, and ptype transitions. This tool only describes which one-step
|
||||
primitives it can draw and how selected primitives should be rendered.
|
||||
"""
|
||||
layer: layer_t
|
||||
width: float
|
||||
ptype: str
|
||||
bend: Abstract
|
||||
transitions: Sequence[WireTransitionSpec]
|
||||
|
||||
def _straight_pattern(self, length: float) -> Pattern:
|
||||
return make_straight_wire(layer=self.layer, width=self.width, ptype=self.ptype, length=length)
|
||||
|
||||
@staticmethod
|
||||
def _transition_length(spec: WireTransitionSpec) -> float | None:
|
||||
dxy, angle = spec.in_port.measure_travel(spec.out_port)
|
||||
if angle is None or not numpy.isclose(angle, pi) or not numpy.isclose(dxy[1], 0):
|
||||
return None
|
||||
return float(dxy[0])
|
||||
|
||||
def _transition_offers(self, in_ptype: str | None) -> tuple[StraightOffer, ...]:
|
||||
offers: list[StraightOffer] = []
|
||||
for spec in self.transitions:
|
||||
if spec.out_port.ptype != self.ptype:
|
||||
continue
|
||||
if in_ptype not in (None, 'unk', spec.in_port.ptype):
|
||||
continue
|
||||
|
||||
length = self._transition_length(spec)
|
||||
if length is None:
|
||||
continue
|
||||
|
||||
def endpoint_planner(
|
||||
parameter: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
length: float = length,
|
||||
) -> Port:
|
||||
_ = parameter
|
||||
return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype)
|
||||
|
||||
def commit_planner(
|
||||
parameter: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
) -> WireTransitionData:
|
||||
_ = parameter
|
||||
return WireTransitionData(spec)
|
||||
|
||||
offers.append(StraightOffer(
|
||||
in_ptype = spec.in_port.ptype,
|
||||
out_ptype = spec.out_port.ptype,
|
||||
length_domain = (length, length),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
))
|
||||
return tuple(offers)
|
||||
|
||||
def _out_transition_offers(self, out_ptype: str | None) -> tuple[StraightOffer, ...]:
|
||||
if out_ptype in ('unk', self.ptype):
|
||||
return ()
|
||||
|
||||
offers: list[StraightOffer] = []
|
||||
for spec in self.transitions:
|
||||
if spec.in_port.ptype != self.ptype:
|
||||
continue
|
||||
if out_ptype is not None and spec.out_port.ptype != out_ptype:
|
||||
continue
|
||||
|
||||
transition_length = self._transition_length(spec)
|
||||
if transition_length is None:
|
||||
continue
|
||||
|
||||
def endpoint_planner(
|
||||
length: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
transition_length: float = transition_length,
|
||||
) -> Port:
|
||||
straight_length = length - transition_length
|
||||
if straight_length < 0:
|
||||
raise BuildError(
|
||||
f'Asked to draw straight path with total length {length:,g}, shorter than required transition: {transition_length:,g}'
|
||||
)
|
||||
return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype)
|
||||
|
||||
def commit_planner(
|
||||
length: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
transition_length: float = transition_length,
|
||||
) -> WireStraightData:
|
||||
endpoint_planner(length)
|
||||
return WireStraightData(length - transition_length, spec)
|
||||
|
||||
offers.append(StraightOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = spec.out_port.ptype,
|
||||
length_domain = (transition_length, numpy.inf),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
))
|
||||
return tuple(offers)
|
||||
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None, # noqa: ARG002 (Pather validates selected output ptypes)
|
||||
**kwargs: Any,
|
||||
) -> tuple[StraightOffer | BendOffer, ...]:
|
||||
if kind == 'straight':
|
||||
tool_options = dict(kwargs)
|
||||
|
||||
def endpoint_planner(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype=self.ptype)
|
||||
|
||||
def commit_planner(length: float) -> WireStraightData:
|
||||
_ = tool_options
|
||||
return WireStraightData(length)
|
||||
|
||||
native_offer = StraightOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = self.ptype,
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
)
|
||||
return (*self._transition_offers(in_ptype), native_offer, *self._out_transition_offers(out_ptype))
|
||||
|
||||
if kind == 'bend':
|
||||
ccw = bool(kwargs.pop('ccw'))
|
||||
bend_forward = self.width / 2
|
||||
bend_run = bend_forward if ccw else -bend_forward
|
||||
bend_rotation = -pi / 2 if ccw else pi / 2
|
||||
|
||||
def endpoint_planner(length: float) -> Port:
|
||||
straight_length = length - bend_forward
|
||||
if straight_length < 0:
|
||||
raise BuildError(
|
||||
f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_forward:,g}'
|
||||
)
|
||||
return Port((length, bend_run), rotation=bend_rotation, ptype=self.ptype)
|
||||
|
||||
def commit_planner(length: float) -> WireBendData:
|
||||
endpoint_planner(length)
|
||||
return WireBendData(straight_length=length - bend_forward, ccw=ccw)
|
||||
|
||||
return (BendOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = self.ptype,
|
||||
ccw = ccw,
|
||||
length_domain = (bend_forward, numpy.inf),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
),)
|
||||
|
||||
if kind in ('s', 'u'):
|
||||
return ()
|
||||
|
||||
raise BuildError(f'Unrecognized primitive offer kind {kind!r}')
|
||||
|
||||
def _render_straight(self, tree: ILibrary, port_names: tuple[str, str], data: WireStraightData) -> None:
|
||||
if numpy.isclose(data.length, 0) and data.out_transition is None:
|
||||
return
|
||||
|
||||
if not numpy.isclose(data.length, 0):
|
||||
tree.top_pattern().plug(
|
||||
self._straight_pattern(data.length),
|
||||
{port_names[1]: 'input'},
|
||||
append=True,
|
||||
)
|
||||
|
||||
if data.out_transition is not None:
|
||||
self._render_transition(tree, port_names, WireTransitionData(data.out_transition))
|
||||
|
||||
def _render_bend(self, tree: ILibrary, port_names: tuple[str, str], data: WireBendData) -> None:
|
||||
self._render_straight(tree, port_names, WireStraightData(data.straight_length))
|
||||
tree.top_pattern().plug(
|
||||
self.bend,
|
||||
{port_names[1]: 'input'},
|
||||
mirrored=data.ccw,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_transition(tree: ILibrary, port_names: tuple[str, str], data: WireTransitionData) -> None:
|
||||
tree.top_pattern().plug(
|
||||
data.spec.abstract,
|
||||
{port_names[1]: data.spec.in_port_name},
|
||||
)
|
||||
|
||||
def render(
|
||||
self,
|
||||
batch: Sequence[RenderStep],
|
||||
*,
|
||||
port_names: tuple[str, str] = ('A', 'B'),
|
||||
) -> ILibrary:
|
||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_wire')
|
||||
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else self.ptype)
|
||||
|
||||
for step in batch:
|
||||
assert step.tool == self
|
||||
if isinstance(step.data, WireTransitionData):
|
||||
self._render_transition(tree, port_names, step.data)
|
||||
elif isinstance(step.data, WireStraightData):
|
||||
self._render_straight(tree, port_names, step.data)
|
||||
elif isinstance(step.data, WireBendData):
|
||||
self._render_bend(tree, port_names, step.data)
|
||||
else:
|
||||
raise BuildError(f'Unexpected primitive render data {type(step.data)}')
|
||||
return tree
|
||||
|
||||
|
||||
def prepare_tools() -> tuple[Library, Tool, Tool]:
|
||||
"""
|
||||
Create some basic library elements and tools for drawing M1 and M2
|
||||
|
|
@ -393,46 +133,71 @@ def prepare_tools() -> tuple[Library, Tool, Tool]:
|
|||
|
||||
#
|
||||
# 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 AutoTool 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 = AutoTool(
|
||||
# First, we need a function which takes in a length and spits out an M1 wire
|
||||
straights = [
|
||||
AutoTool.Straight(
|
||||
ptype = 'm1wire',
|
||||
fn = lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
|
||||
in_port_name = 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
|
||||
out_port_name = 'output', # and use the port named 'output' as the output
|
||||
),
|
||||
],
|
||||
bends = [
|
||||
AutoTool.Bend(
|
||||
abstract = library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
|
||||
in_port_name = 'input',
|
||||
out_port_name = 'output',
|
||||
clockwise = True,
|
||||
),
|
||||
],
|
||||
transitions = { # We can automate transitions for different (normally incompatible) port types
|
||||
('m2wire', 'm1wire'): AutoTool.Transition( # 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
|
||||
),
|
||||
},
|
||||
sbends = [],
|
||||
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 = AutoTool(
|
||||
straights = [
|
||||
# Again, we use make_straight_wire, but this time we set parameters for M2
|
||||
AutoTool.Straight(
|
||||
ptype = 'm2wire',
|
||||
fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
|
||||
in_port_name = 'input',
|
||||
out_port_name = 'output',
|
||||
),
|
||||
],
|
||||
bends = [
|
||||
# and we use an M2 bend
|
||||
AutoTool.Bend(
|
||||
abstract = library.abstract('m2_bend'),
|
||||
in_port_name = 'input',
|
||||
out_port_name = 'output',
|
||||
),
|
||||
],
|
||||
transitions = {
|
||||
('m1wire', 'm2wire'): AutoTool.Transition(
|
||||
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'
|
||||
),
|
||||
},
|
||||
sbends = [],
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -527,7 +292,7 @@ def main() -> None:
|
|||
pather.straight('GND', x=-50_000)
|
||||
|
||||
# Save the pather's pattern into our library
|
||||
library['Pather_and_PrimitiveOffers'] = pather.pattern
|
||||
library['Pather_and_AutoTool'] = pather.pattern
|
||||
|
||||
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||
library.map_layers(map_layer)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ def main() -> None:
|
|||
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 = Pather(library, tools=M2_tool, auto_render=False)
|
||||
|
||||
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'})
|
||||
|
|
@ -157,7 +157,7 @@ def main() -> None:
|
|||
#
|
||||
# Rendering and Saving
|
||||
#
|
||||
# Since routing is deferred, we must call .render() to generate the geometry.
|
||||
# Since we deferred auto-rendering, we must call .render() to generate the geometry.
|
||||
rpather.render()
|
||||
|
||||
library['PortPather_Tutorial'] = rpather.pattern
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Manual wire routing tutorial: deferred Pather and PathTool
|
||||
"""
|
||||
from masque import Pather, Library
|
||||
from masque.builder import PathTool
|
||||
from masque.builder.tools import PathTool
|
||||
from masque.file.gdsii import writefile
|
||||
|
||||
from basic_shapes import GDS_OPTS
|
||||
|
|
@ -13,7 +13,7 @@ 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
|
||||
# but when used with `Pather(auto_render=False)`, 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`
|
||||
|
|
@ -39,7 +39,7 @@ def main() -> None:
|
|||
# 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 = Pather(tools=M2_ptool, library=library, auto_render=False)
|
||||
|
||||
# As in the pather tutorial, we make some pads and labels...
|
||||
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -59,24 +58,14 @@ from .pattern import (
|
|||
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,
|
||||
|
|
@ -85,13 +74,9 @@ from .ports import (
|
|||
from .abstract import Abstract as Abstract
|
||||
from .builder import (
|
||||
Tool as Tool,
|
||||
ToolContractError as ToolContractError,
|
||||
Pather as Pather,
|
||||
RouteError as RouteError,
|
||||
RouteFailureDetails as RouteFailureDetails,
|
||||
RouteFailurePolicy as RouteFailurePolicy,
|
||||
MinimumStatus as MinimumStatus,
|
||||
RenderStep as RenderStep,
|
||||
SimpleTool as SimpleTool,
|
||||
AutoTool as AutoTool,
|
||||
PathTool as PathTool,
|
||||
PortPather as PortPather,
|
||||
|
|
@ -106,5 +91,5 @@ from .utils import (
|
|||
|
||||
__author__ = 'Jan Petykiewicz'
|
||||
|
||||
__version__ = '4.0a2'
|
||||
__version__ = '3.4'
|
||||
version = __version__ # legacy
|
||||
|
|
|
|||
|
|
@ -1,90 +1,13 @@
|
|||
"""
|
||||
Builder helpers for port-based assembly and primitive-offer routing.
|
||||
|
||||
A routing `Tool` describes the primitive route families it can provide by
|
||||
returning `PrimitiveOffer` objects. Each offer is a parameterized planning
|
||||
candidate: it exposes legal parameter domains, endpoint behavior, ptypes, cost,
|
||||
optional footprint metadata, and a commit hook for producing tool-specific
|
||||
render data after a concrete parameter has been selected.
|
||||
|
||||
`Pather` owns user-facing route operations such as `trace()`, `jog()`,
|
||||
`uturn()`, and `trace_into()`. The internal planner resolves each operation into
|
||||
one or more `SolverRequest`s. This normalization is why the public routing API
|
||||
can remain a convenient keyword-based interface without making the solver
|
||||
stringly typed internally. A pure solver search selects a `Candidate`, and a
|
||||
`RouteLeg` attaches that candidate to its copied source port and Tool. Only
|
||||
after selection succeeds are the chosen offers materialized through
|
||||
`offer.commit(parameter)` into a `PreparedRouteResult` containing
|
||||
`RenderStep.data`. `Pather` then applies that prepared result to its live ports
|
||||
and pending render queue.
|
||||
|
||||
Selection is pure with respect to caller-owned Pattern, Library, and Pather
|
||||
state. Tool offer discovery and endpoint/cost/bbox callbacks must likewise be
|
||||
deterministic and must not mutate that state. `commit()` is the first
|
||||
selected-offer materialization hook, but it still must not mutate the live
|
||||
layout. `Tool.render()` is the geometry mutation boundary: later,
|
||||
`Pather.render()` batches compatible `RenderStep`s and inserts the resulting
|
||||
geometry into the Pattern and Library.
|
||||
|
||||
`PrimitiveOffer` and `RenderStep.data` are the tool-facing contract.
|
||||
`RenderStep` is `Pather`'s deferred-render record, and
|
||||
`masque.builder.planner` is an internal planner implementation rather than a
|
||||
stable public API.
|
||||
|
||||
Custom Tool authors should run `validate_tool_contract()` as a development or
|
||||
application-startup preflight. It performs the comprehensive semantic checks
|
||||
that are intentionally not repeated during route selection, keeping the normal
|
||||
routing path focused on search rather than contract verification.
|
||||
|
||||
The practical layering is:
|
||||
- user code drives `Pather` and chooses Tools per port or by default,
|
||||
- Tools describe local legal motion primitives without touching Pather state,
|
||||
- the internal router composes those primitives into high-level route shapes,
|
||||
- Pather applies the prepared result to ports, deferred render queues, and the
|
||||
target pattern/library.
|
||||
|
||||
`Pather` intentionally remains a Pattern-oriented facade rather than exposing
|
||||
separate assembly and routing objects: its user model is a working Pattern with
|
||||
routing tools attached. The ownership phases above are internal boundaries,
|
||||
not additional objects callers must coordinate.
|
||||
|
||||
Code outside the builder package should prefer the exports here over importing
|
||||
from `masque.builder.planner`. The planner package is intentionally available
|
||||
for tests and internal maintenance, but it is not the compatibility boundary
|
||||
for custom Tools.
|
||||
"""
|
||||
|
||||
from .pather import (
|
||||
Pather as Pather,
|
||||
PortPather as PortPather,
|
||||
RouteCompletionCallback as RouteCompletionCallback,
|
||||
)
|
||||
from .error import (
|
||||
ToolContractError as ToolContractError,
|
||||
RouteError as RouteError,
|
||||
RouteFailureDetails as RouteFailureDetails,
|
||||
RouteOperation as RouteOperation,
|
||||
RouteFailurePolicy as RouteFailurePolicy,
|
||||
MinimumStatus as MinimumStatus,
|
||||
)
|
||||
from .utils import ell as ell
|
||||
from .tool_testing import (
|
||||
ToolContractCase as ToolContractCase,
|
||||
validate_tool_contract as validate_tool_contract,
|
||||
)
|
||||
from .tools import (
|
||||
Tool as Tool,
|
||||
RenderStep as RenderStep,
|
||||
SimpleTool as SimpleTool,
|
||||
AutoTool as AutoTool,
|
||||
PathTool as PathTool,
|
||||
RenderStep as RenderStep,
|
||||
RenderStepKind as RenderStepKind,
|
||||
PrimitiveKind as PrimitiveKind,
|
||||
CostCallable as CostCallable,
|
||||
GeneratedEndpointFn as GeneratedEndpointFn,
|
||||
PrimitiveOffer as PrimitiveOffer,
|
||||
StraightOffer as StraightOffer,
|
||||
BendOffer as BendOffer,
|
||||
SOffer as SOffer,
|
||||
UOffer as UOffer,
|
||||
circular_arc_sbend_endpoint as circular_arc_sbend_endpoint,
|
||||
)
|
||||
from .logging import logged_op as logged_op
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
@ -1,13 +1,19 @@
|
|||
"""Logging helpers for Pather."""
|
||||
"""
|
||||
Logging and operation decorators for Pather
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Iterator, Sequence, Callable
|
||||
import logging
|
||||
from functools import wraps
|
||||
import inspect
|
||||
import numpy
|
||||
from contextlib import contextmanager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .pather import Pather
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_log_args(**kwargs) -> str:
|
||||
arg_strs = []
|
||||
|
|
@ -78,3 +84,37 @@ class PatherLogger:
|
|||
|
||||
self.indent -= 1
|
||||
self.depth -= 1
|
||||
|
||||
|
||||
def logged_op(
|
||||
portspec_getter: Callable[[dict[str, Any]], str | Sequence[str] | None] | None = None,
|
||||
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
"""
|
||||
Decorator to wrap Pather methods with logging.
|
||||
"""
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
sig = inspect.signature(func)
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self: 'Pather', *args: Any, **kwargs: Any) -> Any:
|
||||
logger_obj = getattr(self, '_logger', None)
|
||||
if logger_obj is None or not logger_obj.debug:
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
bound = sig.bind(self, *args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
all_args = bound.arguments
|
||||
# remove 'self' from logged args
|
||||
logged_args = {k: v for k, v in all_args.items() if k != 'self'}
|
||||
|
||||
ps = portspec_getter(all_args) if portspec_getter else None
|
||||
|
||||
# Remove portspec from logged_args if it's there to avoid duplicate arg to log_operation
|
||||
logged_args.pop('portspec', None)
|
||||
|
||||
with logger_obj.log_operation(self, func.__name__, ps, **logged_args):
|
||||
if getattr(self, '_dead', False) and func.__name__ in ('plug', 'place'):
|
||||
logger.warning(f"Skipping geometry for {func.__name__}() since device is dead")
|
||||
return func(self, *args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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."""
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
@ -94,18 +82,6 @@ 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):
|
||||
|
|
@ -132,17 +108,9 @@ def ell(
|
|||
else:
|
||||
if set_rotation is 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:
|
||||
|
|
@ -165,11 +133,7 @@ def ell(
|
|||
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)
|
||||
steps = numpy.zeros_like(y_order)
|
||||
if spacing_arr.size == 1:
|
||||
steps[1:] = spacing_arr[0]
|
||||
elif spacing_arr.size == len(ports) - 1:
|
||||
|
|
@ -209,34 +173,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
|
||||
|
|
|
|||
|
|
@ -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, Solid, Trace
|
||||
|
||||
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__)
|
||||
|
|
@ -178,9 +173,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,30 +183,16 @@ 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()
|
||||
|
||||
top_name, top_pat = _read_block(msp)
|
||||
mlib = Library({top_name: top_pat})
|
||||
|
||||
blocks_by_name = {
|
||||
bb.name: bb
|
||||
for bb in lib.blocks
|
||||
|
|
@ -241,27 +219,12 @@ def read(
|
|||
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)
|
||||
for bb in lib.blocks:
|
||||
if bb.is_any_layout:
|
||||
continue
|
||||
if bb.name.startswith('_') and bb.name not in referenced:
|
||||
continue
|
||||
name, pat = _read_block(bb)
|
||||
mlib[name] = pat
|
||||
|
||||
library_info = dict(
|
||||
|
|
@ -271,15 +234,9 @@ 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):
|
||||
|
|
@ -290,9 +247,6 @@ def _read_block(
|
|||
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):
|
||||
|
|
@ -306,44 +260,26 @@ def _read_block(
|
|||
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 width == 0:
|
||||
width = attr.get('const_width', 0)
|
||||
|
||||
verts = points[:, :2]
|
||||
endpoint_closed = numpy.array_equal(verts[0], verts[-1])
|
||||
if is_closed and not endpoint_closed:
|
||||
if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])):
|
||||
verts = numpy.vstack((verts, verts[0]))
|
||||
is_closed = is_closed or endpoint_closed
|
||||
|
||||
shape: Path | Polygon
|
||||
if width == 0 and polyline_mode >= 3:
|
||||
contours[layer].append(verts)
|
||||
continue
|
||||
if width == 0 and is_closed and polyline_mode == 2 and _is_polygon(verts):
|
||||
shape = Polygon(vertices=verts[:-1])
|
||||
if width == 0 and is_closed:
|
||||
# Use Polygon if it has at least 3 unique vertices
|
||||
shape_verts = verts[:-1] if len(verts) > 1 else verts
|
||||
if len(shape_verts) >= 3:
|
||||
shape = Polygon(vertices=shape_verts)
|
||||
else:
|
||||
shape = Path(width=width, vertices=verts)
|
||||
else:
|
||||
shape = Path(width=width, vertices=verts)
|
||||
|
||||
pat.shapes[layer].append(shape)
|
||||
elif isinstance(element, Line):
|
||||
layer = element.dxf.get('layer', DEFAULT_LAYER)
|
||||
verts = numpy.asarray((element.dxf.start.xyz[:2], element.dxf.end.xyz[:2]))
|
||||
if polyline_mode >= 3:
|
||||
contours[layer].append(verts)
|
||||
else:
|
||||
pat.shapes[layer].append(Path(vertices=verts, width=0))
|
||||
elif isinstance(element, Solid | Trace):
|
||||
attr = element.dxfattribs()
|
||||
layer = attr.get('layer', DEFAULT_LAYER)
|
||||
|
|
@ -380,8 +316,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 +328,64 @@ 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)
|
||||
if 'column_count' in attr:
|
||||
col_spacing = attr['column_spacing']
|
||||
row_spacing = attr['row_spacing']
|
||||
col_count = attr['column_count']
|
||||
row_count = attr['row_count']
|
||||
local_x = numpy.array((col_spacing, 0.0))
|
||||
local_y = numpy.array((0.0, row_spacing))
|
||||
# Spacing follows only the original INSERT angle, not its scale
|
||||
# or the extra angle introduced by mirror normalization.
|
||||
rot = rotation_matrix_2d(insert_rotation)
|
||||
args['repetition'] = Grid(
|
||||
a_vector=rot @ local_x, b_vector=rot @ local_y,
|
||||
a_count=col_count, b_count=row_count,
|
||||
inv_rot = rotation_matrix_2d(-rotation)
|
||||
|
||||
candidates = (
|
||||
(inv_rot @ local_x, inv_rot @ local_y, col_count, row_count),
|
||||
(inv_rot @ local_y, inv_rot @ local_x, row_count, col_count),
|
||||
)
|
||||
repetition = None
|
||||
for a_vector, b_vector, a_count, b_count in candidates:
|
||||
rotated_a = rotation_matrix_2d(rotation) @ a_vector
|
||||
rotated_b = rotation_matrix_2d(rotation) @ b_vector
|
||||
if (numpy.isclose(rotated_a[1], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[0], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8)
|
||||
and a_count == col_count
|
||||
and b_count == row_count):
|
||||
repetition = Grid(
|
||||
a_vector=a_vector,
|
||||
b_vector=b_vector,
|
||||
a_count=a_count,
|
||||
b_count=b_count,
|
||||
)
|
||||
break
|
||||
if (numpy.isclose(rotated_a[0], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[1], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8)
|
||||
and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8)
|
||||
and b_count == col_count
|
||||
and a_count == row_count):
|
||||
repetition = Grid(
|
||||
a_vector=a_vector,
|
||||
b_vector=b_vector,
|
||||
a_count=a_count,
|
||||
b_count=b_count,
|
||||
)
|
||||
break
|
||||
|
||||
if repetition is None:
|
||||
repetition = Grid(
|
||||
a_vector=inv_rot @ local_x,
|
||||
b_vector=inv_rot @ local_y,
|
||||
a_count=col_count,
|
||||
b_count=row_count,
|
||||
)
|
||||
args['repetition'] = repetition
|
||||
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]],
|
||||
|
|
@ -552,10 +407,12 @@ def _mrefs_to_drefs(
|
|||
# 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
|
||||
# Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90)
|
||||
# AND the grid is already manhattan.
|
||||
|
||||
# Rotate basis vectors by the reference rotation to see where they end up in the DXF frame
|
||||
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):
|
||||
attribs['column_count'] = rep.a_count
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ 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 +35,79 @@ 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({})
|
||||
RO_EMPTY_DICT: 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 repetition). 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 +115,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 +216,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 +236,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 +318,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 +367,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 +378,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 +390,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,
|
||||
|
|
@ -348,7 +410,7 @@ def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t
|
|||
|
||||
def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]:
|
||||
if annotations is None:
|
||||
return _EMPTY_PROPERTIES
|
||||
return RO_EMPTY_DICT
|
||||
cum_len = 0
|
||||
props = {}
|
||||
for key, vals in annotations.items():
|
||||
|
|
@ -385,13 +447,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(rint_cast(shape.cap_extensions))
|
||||
else:
|
||||
extension = (0, 0)
|
||||
|
||||
|
|
@ -404,20 +466,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 +497,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 +514,117 @@ 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
|
||||
|
||||
try:
|
||||
return load_library(stream, full_load=full_load, postprocess=postprocess)
|
||||
finally:
|
||||
if full_load:
|
||||
stream.close()
|
||||
|
||||
|
||||
def check_valid_names(
|
||||
names: Iterable[str],
|
||||
max_length: int = 32,
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -565,9 +565,8 @@ def _shapes_to_elements(
|
|||
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)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -9,15 +9,14 @@ from numpy.typing import ArrayLike
|
|||
import svgwrite # type: ignore
|
||||
|
||||
from .utils import mangle_name
|
||||
from .. import Pattern, Ref
|
||||
from ..library import IMaterializable
|
||||
from .. import Pattern
|
||||
from ..utils import rotation_matrix_2d
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ref_to_svg_transform(ref: Ref) -> str:
|
||||
def _ref_to_svg_transform(ref) -> str:
|
||||
linear = rotation_matrix_2d(ref.rotation) * ref.scale
|
||||
if ref.mirrored:
|
||||
linear = linear @ numpy.diag((1.0, -1.0))
|
||||
|
|
@ -46,13 +45,6 @@ def _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]:
|
|||
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()}
|
||||
|
||||
|
||||
def writefile(
|
||||
library: Mapping[str, Pattern],
|
||||
top: str,
|
||||
|
|
@ -61,12 +53,13 @@ def writefile(
|
|||
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 (<g>, inside <defs>), polygons as paths (<path>), and refs
|
||||
as <use> 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,21 +71,19 @@ def writefile(
|
|||
prior to calling this function.
|
||||
|
||||
Args:
|
||||
library: Mapping of pattern names to patterns.
|
||||
top: Name of the top-level pattern to render.
|
||||
pattern: Pattern to write to file. Modified by this function.
|
||||
filename: Filename to write to.
|
||||
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
||||
SVG elements.
|
||||
annotate_ports: If True, draw an arrow for each port (similar to
|
||||
`Pattern.visualize(..., ports=True)`).
|
||||
"""
|
||||
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)
|
||||
|
|
@ -105,10 +96,10 @@ def writefile(
|
|||
# Create file
|
||||
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
|
||||
debug=(not custom_attributes))
|
||||
svg_ids = _make_svg_ids(detached)
|
||||
svg_ids = _make_svg_ids(library)
|
||||
|
||||
# Now create a group for each pattern and add in any Boundary and Use elements
|
||||
for name, pat in detached.items():
|
||||
for name, pat in library.items():
|
||||
svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red')
|
||||
|
||||
for layer, shapes in pat.shapes.items():
|
||||
|
|
@ -167,21 +158,21 @@ def writefile_inverted(
|
|||
box and drawing the polygons with reverse vertex order inside it, all within
|
||||
one `<path>` 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)
|
||||
|
|
|
|||
|
|
@ -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,39 @@ def preflight(
|
|||
wrap_repeated_shapes: bool = False,
|
||||
) -> Library:
|
||||
"""
|
||||
Run a standard set of useful operations and checks on an entire library.
|
||||
Run a standard set of useful operations and checks, usually done immediately prior
|
||||
to writing to a file (or immediately after reading).
|
||||
|
||||
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.
|
||||
Note that 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, so
|
||||
later mutating preflight steps such as `prune_empty_patterns` and
|
||||
`wrap_repeated_shapes` may still mutate caller-owned patterns. Callers that need
|
||||
isolation should deep-copy the library before calling `preflight()`.
|
||||
|
||||
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 +72,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 +81,8 @@ 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)
|
||||
prune_dangling = 'error' if allow_dangling_refs is False else 'ignore'
|
||||
pruned = lib.prune_empty(dangling=prune_dangling)
|
||||
if pruned:
|
||||
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
|
||||
logger.debug('Pruned: ' + pformat(pruned))
|
||||
|
|
@ -262,9 +90,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:
|
||||
|
|
|
|||
|
|
@ -53,22 +53,6 @@ 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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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 '<LazyLibrary with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
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)')
|
||||
|
|
@ -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'<LibraryView ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
|
||||
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'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
@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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -30,41 +30,6 @@ 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):
|
||||
"""
|
||||
|
|
@ -544,8 +509,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
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.
|
||||
|
|
@ -561,15 +524,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
|
||||
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] = []
|
||||
|
||||
|
|
@ -586,17 +543,12 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
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:
|
||||
if target is None:
|
||||
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,
|
||||
)
|
||||
ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library)
|
||||
# Apply ref transformations
|
||||
for p in ref_polys:
|
||||
p_pat = ref.as_pattern(Pattern(shapes={layer: [p]}))
|
||||
|
|
@ -614,15 +566,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 +589,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 +628,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:
|
||||
|
|
@ -1188,17 +1127,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
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.
|
||||
"""
|
||||
# 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
|
||||
|
|
@ -1207,6 +1137,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
|
||||
raise
|
||||
|
||||
if self.has_refs() and library is None:
|
||||
raise PatternError('Must provide a library when visualizing a pattern with refs')
|
||||
|
||||
# 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]]] = {}
|
||||
|
|
@ -1270,7 +1203,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
|
||||
# 3. Recurse into refs
|
||||
for target, refs in pat.refs.items():
|
||||
if target is None or not refs:
|
||||
if target is None:
|
||||
continue
|
||||
assert library is not None
|
||||
target_pat = library[target]
|
||||
|
|
@ -1465,9 +1398,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
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.')
|
||||
else:
|
||||
if 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.')
|
||||
|
||||
ports = {}
|
||||
for name, port in other.ports.items():
|
||||
|
|
@ -1593,10 +1527,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)`.
|
||||
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)`.
|
||||
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
|
||||
|
|
@ -1829,8 +1764,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
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ 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 .utils import rotate_offsets_around, rotation_matrix_2d
|
||||
from .error import PortError, format_stacktrace
|
||||
|
||||
|
||||
|
|
@ -542,7 +542,7 @@ class PortList(metaclass=ABCMeta):
|
|||
|
||||
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():
|
||||
|
|
@ -574,10 +574,10 @@ class PortList(metaclass=ABCMeta):
|
|||
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]):
|
||||
if not numpy.allclose(translations[nn], 0):
|
||||
msg += f'{kk} | {translations[nn]} | {vv}\n'
|
||||
raise PortError(msg)
|
||||
|
||||
|
|
@ -641,10 +641,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 log a
|
||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||
`(b, a)`.
|
||||
|
||||
Returns:
|
||||
- The (x, y) translation (performed last)
|
||||
|
|
@ -693,10 +694,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 log a
|
||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||
`(b, a)`.
|
||||
|
||||
Returns:
|
||||
- The (x, y) translation (performed last)
|
||||
|
|
@ -723,10 +725,8 @@ 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()):
|
||||
|
|
|
|||
|
|
@ -86,26 +86,6 @@ 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(),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -11,7 +11,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
|
||||
|
|
|
|||
|
|
@ -197,39 +197,29 @@ class Arc(PositionableImpl, Shape):
|
|||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||
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._angle_ref = ArcAngleRef(angle_ref)
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
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
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
|
||||
memo = {} if memo is None else memo
|
||||
|
|
@ -423,10 +413,11 @@ class Arc(PositionableImpl, Shape):
|
|||
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()
|
||||
if self.angle_ref != ArcAngleRef.Center:
|
||||
x_major = self.radius_x > self.radius_y
|
||||
y_major = self.radius_y > self.radius_x
|
||||
if (axis == 0 and y_major) or (axis == 1 and x_major):
|
||||
self._swap_focus_ref()
|
||||
self.rotation *= -1
|
||||
self.rotation += axis * pi
|
||||
self.angles *= -1
|
||||
|
|
|
|||
|
|
@ -50,27 +50,19 @@ class Circle(PositionableImpl, Shape):
|
|||
offset: ArrayLike = (0.0, 0.0),
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = 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
|
||||
else:
|
||||
self.radius = radius
|
||||
self.offset = offset
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
|
||||
memo = {} if memo is None else memo
|
||||
|
|
|
|||
|
|
@ -95,30 +95,22 @@ class Ellipse(PositionableImpl, Shape):
|
|||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = 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
|
||||
else:
|
||||
self.radii = radii
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
|
|
|
|||
|
|
@ -201,43 +201,34 @@ class Path(Shape):
|
|||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = 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(cap_extensions, numpy.ndarray) or cap_extensions is None
|
||||
self._vertices = vertices
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
self._width = width
|
||||
self._cap = cap
|
||||
self._cap_extensions = cap_extensions
|
||||
else:
|
||||
self.cap_extensions = cap_extensions
|
||||
self.width = width
|
||||
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)
|
||||
else:
|
||||
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
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Path':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class PolyCollection(Shape):
|
|||
_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]]
|
||||
_vertex_offsets: NDArray[numpy.intp]
|
||||
""" 1D NDArray specifying the starting offset for each polygon """
|
||||
|
||||
@property
|
||||
|
|
@ -45,7 +45,7 @@ class PolyCollection(Shape):
|
|||
return self._vertex_lists
|
||||
|
||||
@property
|
||||
def vertex_offsets(self) -> NDArray[numpy.integer[Any]]:
|
||||
def vertex_offsets(self) -> NDArray[numpy.intp]:
|
||||
"""
|
||||
Starting offset (in `vertex_lists`) for each polygon
|
||||
"""
|
||||
|
|
@ -63,7 +63,7 @@ class PolyCollection(Shape):
|
|||
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
|
||||
strict=True,
|
||||
):
|
||||
yield slice(int(ii), int(ff))
|
||||
yield slice(ii, ff)
|
||||
|
||||
@property
|
||||
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||
|
|
@ -100,32 +100,25 @@ class PolyCollection(Shape):
|
|||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
) -> 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 raw:
|
||||
assert isinstance(vertex_lists, numpy.ndarray)
|
||||
assert isinstance(vertex_offsets, numpy.ndarray)
|
||||
self._vertex_lists = vertex_lists
|
||||
self._vertex_offsets = vertex_offsets
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
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)
|
||||
|
|
@ -139,7 +132,7 @@ class PolyCollection(Shape):
|
|||
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 numpy.array_equal(self._vertex_offsets, other._vertex_offsets)
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
|
@ -222,11 +215,11 @@ class PolyCollection(Shape):
|
|||
|
||||
# TODO: normalize mirroring?
|
||||
|
||||
return ((type(self), rotated_vertices.data.tobytes() + self.vertex_offsets.tobytes()),
|
||||
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(),
|
||||
vertex_offsets=self._vertex_offsets.copy(),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -115,29 +115,22 @@ class Polygon(Shape):
|
|||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
self.vertices = vertices
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if raw:
|
||||
assert isinstance(vertices, numpy.ndarray)
|
||||
self._vertices = vertices
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
self.vertices = vertices
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 '<RectCollection r0>'
|
||||
centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:])
|
||||
centroid = centers.mean(axis=0)
|
||||
return f'<RectCollection centroid {centroid} r{self._rects.shape[0]}>'
|
||||
|
|
@ -73,40 +73,27 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
mirrored: bool = False,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = 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._mirrored = mirrored
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
self.offset = offset
|
||||
self.string = string
|
||||
self.height = height
|
||||
self.rotation = rotation
|
||||
self.mirrored = mirrored
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
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]:
|
||||
"""
|
||||
|
|
@ -30,117 +25,3 @@ def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: flo
|
|||
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__}')
|
||||
|
|
|
|||
|
|
@ -14,21 +14,6 @@ def test_arc_init() -> None:
|
|||
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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,40 +15,6 @@ def _poly_area(poly: Polygon) -> float:
|
|||
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]])
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,7 @@ import pytest
|
|||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..builder import MinimumStatus, Pather, RouteFailureDetails
|
||||
from ..builder import Pather
|
||||
from ..builder.utils import ell
|
||||
from ..error import BuildError
|
||||
from ..library import Library
|
||||
|
|
@ -11,59 +11,6 @@ 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")
|
||||
|
|
@ -214,41 +161,3 @@ def test_ell_handles_array_spacing_when_ccw_none() -> None:
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -5,7 +5,7 @@ from numpy.testing import assert_allclose
|
|||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..shapes import Path as MPath, Circle, Polygon, RectCollection
|
||||
from ..shapes import Path as MPath, Circle, Polygon
|
||||
from ..repetition import Grid, Arbitrary
|
||||
|
||||
def create_test_library(for_gds: bool = False) -> Library:
|
||||
|
|
@ -109,30 +109,3 @@ def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
|||
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']}
|
||||
|
|
|
|||
|
|
@ -78,8 +78,3 @@ def test_gdsii_check_valid_names_validates_generator_lengths() -> None:
|
|||
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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')
|
||||
|
|
@ -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)])
|
||||
|
|
@ -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
|
||||
|
|
@ -1,17 +1,13 @@
|
|||
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 ..library import Library, LazyLibrary
|
||||
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
|
||||
from ..file.utils import preflight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..shapes import Polygon
|
||||
|
|
@ -22,7 +18,6 @@ def test_library_basic() -> None:
|
|||
pat = Pattern()
|
||||
lib["cell1"] = pat
|
||||
|
||||
assert isinstance(lib, INameView)
|
||||
assert "cell1" in lib
|
||||
assert lib["cell1"] is pat
|
||||
assert len(lib) == 1
|
||||
|
|
@ -31,58 +26,6 @@ def test_library_basic() -> None:
|
|||
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()
|
||||
|
|
@ -93,23 +36,6 @@ def test_library_tops() -> None:
|
|||
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()
|
||||
|
|
@ -118,16 +44,6 @@ def test_library_dangling() -> None:
|
|||
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()
|
||||
|
|
@ -149,24 +65,6 @@ def test_library_dangling_graph_modes() -> None:
|
|||
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()
|
||||
|
|
@ -199,16 +97,6 @@ def test_find_refs_with_dangling_modes() -> None:
|
|||
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()
|
||||
|
|
@ -229,84 +117,6 @@ def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptu
|
|||
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()
|
||||
|
|
@ -327,42 +137,6 @@ def test_library_flatten() -> None:
|
|||
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)})
|
||||
|
|
@ -433,42 +207,6 @@ def test_lazy_library() -> None:
|
|||
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()
|
||||
|
|
@ -483,7 +221,7 @@ def test_library_rename() -> None:
|
|||
assert "old" not in lib["parent"].refs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary])
|
||||
@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()
|
||||
|
|
@ -497,7 +235,7 @@ def test_library_rename_self_is_noop(library_cls: type[Library] | type[LazyLibra
|
|||
assert len(lib["parent"].refs["top"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary])
|
||||
@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()
|
||||
|
|
@ -507,7 +245,7 @@ def test_library_rename_top_self_is_noop(library_cls: type[Library] | type[LazyL
|
|||
assert list(lib.keys()) == ["top"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary])
|
||||
@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()
|
||||
|
|
@ -516,7 +254,7 @@ def test_library_rename_missing_raises_library_error(library_cls: type[Library]
|
|||
lib.rename("missing", "new")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary])
|
||||
@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()
|
||||
|
|
@ -600,180 +338,6 @@ def test_library_add_returns_only_renamed_entries() -> None:
|
|||
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()
|
||||
|
|
@ -782,300 +346,9 @@ def test_library_subtree() -> None:
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -7,30 +7,30 @@ 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:
|
||||
def make_straight(length, width=2, ptype="wire"):
|
||||
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:
|
||||
def make_bend(R, width=2, ptype="wire", clockwise=True):
|
||||
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.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((radius, -radius), pi/2, ptype=ptype)
|
||||
pat.ports["B"] = Port((R, -R), 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.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((radius, radius), -pi/2, ptype=ptype)
|
||||
pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype)
|
||||
return pat
|
||||
|
||||
@pytest.fixture
|
||||
def multi_bend_tool() -> tuple[AutoTool, Library]:
|
||||
def multi_bend_tool():
|
||||
lib = Library()
|
||||
|
||||
lib["b1"] = make_bend(2, ptype="wire")
|
||||
|
|
@ -38,13 +38,19 @@ def multi_bend_tool() -> tuple[AutoTool, Library]:
|
|||
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)
|
||||
)
|
||||
tool = AutoTool(
|
||||
straights=[
|
||||
AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)),
|
||||
AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8))
|
||||
],
|
||||
bends=[
|
||||
AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True),
|
||||
AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True)
|
||||
],
|
||||
sbends=[],
|
||||
transitions={},
|
||||
default_out_ptype="wire"
|
||||
)
|
||||
return tool, lib
|
||||
|
||||
def test_autotool_uturn() -> None:
|
||||
|
|
@ -64,13 +70,15 @@ def test_autotool_uturn() -> None:
|
|||
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)
|
||||
)
|
||||
tool = AutoTool(
|
||||
straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')],
|
||||
bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)],
|
||||
sbends=[],
|
||||
transitions={},
|
||||
default_out_ptype='wire'
|
||||
)
|
||||
|
||||
p = Pather(lib, tools=tool)
|
||||
p = Pather(lib, tools=tool, auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), 0)
|
||||
|
||||
p.at('A').uturn(offset=-2000, length=1000)
|
||||
|
|
@ -80,9 +88,9 @@ def test_autotool_uturn() -> None:
|
|||
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:
|
||||
def test_deferred_render_autotool_double_L(multi_bend_tool) -> None:
|
||||
tool, lib = multi_bend_tool
|
||||
rp = Pather(lib, tools=tool)
|
||||
rp = Pather(lib, tools=tool, auto_render=False)
|
||||
rp.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||
|
||||
rp.jog("A", 10, length=20)
|
||||
|
|
@ -93,10 +101,22 @@ def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Libr
|
|||
rp.render()
|
||||
assert len(rp.pattern.refs) > 0
|
||||
|
||||
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None:
|
||||
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None:
|
||||
tool, lib = multi_bend_tool
|
||||
|
||||
p = Pather(lib, tools=tool)
|
||||
class BasicTool(AutoTool):
|
||||
def planU(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
tool_basic = BasicTool(
|
||||
straights=tool.straights,
|
||||
bends=tool.bends,
|
||||
sbends=tool.sbends,
|
||||
transitions=tool.transitions,
|
||||
default_out_ptype=tool.default_out_ptype
|
||||
)
|
||||
|
||||
p = Pather(lib, tools=tool_basic)
|
||||
p.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||
|
||||
p.uturn("A", 10, length=5)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,10 @@
|
|||
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 import Pather, Library, Pattern, Port
|
||||
from masque.builder.tools import PathTool
|
||||
from masque.error import BuildError, PortError
|
||||
|
||||
|
||||
|
|
@ -20,138 +17,6 @@ def pather_setup() -> tuple[Pather, PathTool, Library]:
|
|||
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)
|
||||
|
|
@ -195,20 +60,19 @@ def test_pather_dead_ports() -> None:
|
|||
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)
|
||||
p.straight("in", -10)
|
||||
|
||||
assert_allclose(p.ports["in"].offset, [0, 0], atol=1e-10)
|
||||
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
|
||||
|
||||
p.straight("in", 20)
|
||||
assert_allclose(p.ports["in"].offset, [-20, 0], atol=1e-10)
|
||||
assert_allclose(p.ports["in"].offset, [-10, 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)
|
||||
p = Pather(lib, tools=tool, auto_render=False)
|
||||
|
||||
# Routing extends opposite the port's inward-facing rotation.
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
|
@ -224,7 +88,7 @@ def test_pather_trace_basic() -> None:
|
|||
def test_pather_trace_to() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
p = Pather(lib, tools=tool, auto_render=False)
|
||||
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
|
|
@ -237,7 +101,7 @@ def test_pather_trace_to() -> None:
|
|||
def test_pather_bundle_trace() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
p = Pather(lib, tools=tool, auto_render=False)
|
||||
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
|
@ -254,13 +118,13 @@ def test_pather_bundle_trace() -> None:
|
|||
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 = Pather(lib_default, tools=tool_default, auto_render=False)
|
||||
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 = Pather(lib_explicit, tools=tool_explicit, auto_render=False)
|
||||
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
|
|
@ -271,11 +135,11 @@ def test_portpather_default_spacing_matches_explicit_spacing() -> None:
|
|||
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 = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
|
||||
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 = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
|
||||
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
|
|
@ -287,11 +151,11 @@ def test_portpather_default_spacing_reused_and_overridden() -> None:
|
|||
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 = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
|
||||
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 = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
|
||||
p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
|
|
@ -304,7 +168,7 @@ def test_portpather_default_spacing_reused_and_overridden() -> None:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
|
|
@ -316,7 +180,7 @@ def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
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)
|
||||
|
|
@ -330,7 +194,7 @@ def test_portpather_default_spacing_vector_revalidated_after_selection_change()
|
|||
def test_pather_each_bound() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
p = Pather(lib, tools=tool, auto_render=False)
|
||||
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((-1000, 2000), rotation=0)
|
||||
|
|
@ -362,22 +226,6 @@ def test_selection_management() -> None:
|
|||
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)
|
||||
|
|
@ -466,98 +314,15 @@ def test_rename() -> None:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.set_dead()
|
||||
|
||||
p.straight('A', 1000, out_ptype='other')
|
||||
p.straight('A', -1000, out_ptype='other')
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 0))
|
||||
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
|
||||
|
||||
assert len(p.paths['A']) == 0
|
||||
|
||||
def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None:
|
||||
lib = Library()
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
from typing import Any
|
||||
|
||||
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
|
||||
from masque.builder.tools import PathTool, Tool
|
||||
from masque.error import BuildError, 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')
|
||||
p = Pather(lib, tools=tool)
|
||||
|
||||
tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})}
|
||||
|
||||
|
|
@ -24,7 +26,7 @@ def test_pather_place_treeview_resolves_once() -> None:
|
|||
def test_pather_plug_treeview_resolves_once() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})}
|
||||
|
|
@ -39,12 +41,12 @@ def test_pather_plug_treeview_resolves_once() -> None:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
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']
|
||||
assert [step.opcode for step in p.paths['A']] == ['L']
|
||||
|
||||
other = Pattern(
|
||||
annotations={'k': [2]},
|
||||
|
|
@ -54,13 +56,13 @@ def test_pather_failed_plug_does_not_add_break_marker() -> None:
|
|||
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 [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 = Pather(lib, tools=tool, auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').straight(5000)
|
||||
|
|
@ -70,7 +72,7 @@ def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
|
|||
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']
|
||||
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
|
||||
|
||||
p.render()
|
||||
assert p.pattern.has_shapes()
|
||||
|
|
@ -80,7 +82,7 @@ def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
||||
|
||||
|
|
@ -96,7 +98,7 @@ def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
|
|||
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']
|
||||
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
|
||||
|
||||
p.render()
|
||||
assert p.pattern.has_shapes()
|
||||
|
|
@ -107,14 +109,14 @@ def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').straight(5000)
|
||||
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||
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'}
|
||||
assert [step.opcode for step in p.paths['A']] == ['L']
|
||||
assert set(p.paths) == {'A'}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
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 ..builder import Pather
|
||||
from ..builder.tools import PathTool, Tool
|
||||
from ..error import BuildError
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
|
|
@ -21,33 +20,16 @@ if TYPE_CHECKING:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
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
|
||||
assert len(rp.paths["start"]) == 2
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
|
|
@ -64,19 +46,21 @@ def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Lib
|
|||
|
||||
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)
|
||||
# Clockwise bend adds the bend endpoint after the straight segment vertex.
|
||||
assert len(path_shape.vertices) == 4
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [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:
|
||||
def test_deferred_render_jog_uses_native_pathtool_planS(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"]
|
||||
assert len(rp.paths["start"]) == 1
|
||||
assert rp.paths["start"][0].opcode == "S"
|
||||
|
||||
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)
|
||||
# Native PathTool S-bends place the jog width/2 before the route end.
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -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:
|
||||
|
|
@ -87,7 +71,7 @@ def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_
|
|||
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)
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [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
|
||||
|
|
@ -101,60 +85,6 @@ def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, L
|
|||
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")
|
||||
|
|
@ -173,112 +103,26 @@ def test_portpather_translate_only_affects_future_steps(deferred_render_setup: t
|
|||
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 = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, auto_render=False)
|
||||
rp.set_dead()
|
||||
|
||||
with pytest.raises(RouteError, match='finite and nonnegative'):
|
||||
rp.straight("in", -10)
|
||||
rp.straight("in", -10)
|
||||
|
||||
assert_allclose(rp.ports["in"].offset, [0, 0], atol=1e-10)
|
||||
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
|
||||
|
||||
assert len(rp._paths["in"]) == 0
|
||||
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
|
||||
assert "start" not in rp.paths
|
||||
assert len(rp.paths["new_start"]) == 2
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
|
|
@ -293,7 +137,7 @@ def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_rende
|
|||
rp.at("start").straight(10).drop()
|
||||
|
||||
assert "start" not in rp.ports
|
||||
assert len(rp._paths["start"]) == 1
|
||||
assert len(rp.paths["start"]) == 1
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
|
|
@ -301,44 +145,38 @@ def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_rende
|
|||
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:
|
||||
def test_pathtool_traceL_bend_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)),))
|
||||
tree = tool.traceL(True, 10)
|
||||
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)
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10)
|
||||
assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10)
|
||||
|
||||
def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
|
||||
def test_pathtool_traceS_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)),))
|
||||
tree = tool.traceS(10, 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(path_shape.vertices, [[0, 0], [9, 0], [9, 4], [10, 4]], atol=1e-10)
|
||||
assert_allclose(pat.ports["B"].offset, [10, 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 = Pather(lib, tools=tool, auto_render=False)
|
||||
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']
|
||||
assert len(rp.paths['A']) == 2
|
||||
assert rp.paths['A'][0].opcode == 'L'
|
||||
assert rp.paths['A'][1].opcode == 'L'
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.ports['A'].rotation is not None
|
||||
|
|
@ -346,23 +184,15 @@ def test_deferred_render_uturn_fallback() -> None:
|
|||
|
||||
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 ()
|
||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
||||
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},
|
||||
),)
|
||||
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
length = batch[0].data['length']
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
|
||||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), pi, ptype='wire'),
|
||||
port_names[1]: Port((1, 0), pi, ptype='wire'),
|
||||
})
|
||||
child = Pattern(annotations={'batch': [len(batch)]})
|
||||
top.ref('_seg')
|
||||
|
|
@ -371,7 +201,7 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
|
|||
return tree
|
||||
|
||||
lib = Library()
|
||||
p = Pather(lib, tools=FullTreeTool())
|
||||
p = Pather(lib, tools=FullTreeTool(), auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 10)
|
||||
|
|
@ -385,24 +215,13 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
|
|||
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:
|
||||
def test_tool_render_fallback_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
|
||||
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> 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'),
|
||||
port_names[1]: Port((length, 0), pi, ptype='wire'),
|
||||
})
|
||||
child = Pattern(annotations={'length': [length]})
|
||||
top.ref('_seg')
|
||||
|
|
@ -410,13 +229,8 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
|
|||
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 = Pather(lib, tools=TraceTreeTool(), auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 10)
|
||||
|
|
@ -428,18 +242,11 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
|
|||
|
||||
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 ()
|
||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
||||
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},
|
||||
),)
|
||||
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
|
||||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
|
|
@ -451,7 +258,7 @@ def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
|
|||
|
||||
lib = Library()
|
||||
lib['_seg'] = Pattern(annotations={'stale': [1]})
|
||||
p = Pather(lib, tools=MissingSingleUseTool(), render='deferred')
|
||||
p = Pather(lib, tools=MissingSingleUseTool(), auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.straight('A', 10)
|
||||
|
||||
|
|
@ -463,23 +270,15 @@ def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
|
|||
|
||||
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 ()
|
||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
||||
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},
|
||||
),)
|
||||
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
length = batch[0].data['length']
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
|
||||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), pi, ptype='wire'),
|
||||
port_names[1]: Port((1, 0), pi, ptype='wire'),
|
||||
})
|
||||
top.ref('shared')
|
||||
tree['_top'] = top
|
||||
|
|
@ -487,7 +286,7 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
|
|||
|
||||
lib = Library()
|
||||
lib['shared'] = Pattern(annotations={'shared': [1]})
|
||||
p = Pather(lib, tools=SharedRefTool())
|
||||
p = Pather(lib, tools=SharedRefTool(), auto_render=False)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 10)
|
||||
|
|
@ -496,131 +295,17 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
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
|
||||
assert len(rp.paths['A']) == 1
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,25 +1,22 @@
|
|||
from typing import Any
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
import numpy
|
||||
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
|
||||
from masque import Pather, Library, Pattern, Port
|
||||
from masque.builder.tools import PathTool, Tool
|
||||
from masque.error import BuildError, PortError, PatternError
|
||||
|
||||
|
||||
@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)
|
||||
p = Pather(lib, tools=tool, auto_render=True, auto_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")
|
||||
|
|
@ -31,7 +28,6 @@ def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library])
|
|||
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")
|
||||
|
|
@ -41,9 +37,10 @@ def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> N
|
|||
|
||||
assert "src" not in p.ports
|
||||
assert "dst" not in p.ports
|
||||
# `trace_into()` batches internal legs before auto-rendering so the operation
|
||||
# rolls back cleanly on later failures.
|
||||
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")
|
||||
|
|
@ -54,7 +51,6 @@ def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) ->
|
|||
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")
|
||||
|
|
@ -67,11 +63,10 @@ def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> N
|
|||
assert_equal(p.ports["src"].offset, [10, 10])
|
||||
assert "other" not in p.ports
|
||||
|
||||
|
||||
def test_pather_trace_into_shapes() -> None:
|
||||
def test_pather_trace_into() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
p = Pather(lib, tools=tool, auto_render=False)
|
||||
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi)
|
||||
|
|
@ -81,7 +76,7 @@ def test_pather_trace_into_shapes() -> None:
|
|||
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.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
|
||||
|
|
@ -112,171 +107,10 @@ def test_pather_trace_into_shapes() -> None:
|
|||
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 = Pather(lib, tools=tool, auto_render=False)
|
||||
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()
|
||||
|
|
@ -287,29 +121,27 @@ def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
|
|||
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 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:
|
||||
def test_pather_trace_into_failure_rolls_back_ports_and_paths() -> 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):
|
||||
with pytest.raises(BuildError, match='does not match path ptype'):
|
||||
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
|
||||
assert len(p.paths['A']) == 0
|
||||
|
||||
|
||||
def test_pather_trace_into_rename_failure_propagates() -> None:
|
||||
def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
|
|
@ -320,14 +152,19 @@ def test_pather_trace_into_rename_failure_propagates() -> None:
|
|||
with pytest.raises(PortError, match='overwritten'):
|
||||
p.trace_into('A', 'B', plug_destination=False, thru='other')
|
||||
|
||||
assert set(p.pattern.ports) == {'A', 'B', 'other'}
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, (-10, 0))
|
||||
assert numpy.allclose(p.pattern.ports['other'].offset, (3, 4))
|
||||
assert len(p.paths['A']) == 0
|
||||
|
||||
@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'),
|
||||
],
|
||||
(
|
||||
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'trace_to\(\) arguments: x'),
|
||||
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'jog\(\) arguments: length'),
|
||||
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'uturn\(\) arguments: length'),
|
||||
),
|
||||
)
|
||||
def test_pather_trace_into_rejects_reserved_route_kwargs(
|
||||
dst: Port,
|
||||
|
|
@ -340,8 +177,7 @@ def test_pather_trace_into_rejects_reserved_route_kwargs(
|
|||
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'):
|
||||
with pytest.raises(BuildError, match=match):
|
||||
p.trace_into('A', 'B', plug_destination=False, **kwargs)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
|
|
@ -350,224 +186,4 @@ def test_pather_trace_into_rejects_reserved_route_kwargs(
|
|||
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'})
|
||||
assert len(p.paths['A']) == 0
|
||||
|
|
|
|||
|
|
@ -169,47 +169,6 @@ def test_port_list_plugged() -> None:
|
|||
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:
|
||||
|
|
@ -332,34 +291,3 @@ def test_find_transform_requires_connection_map() -> None:
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -60,15 +60,6 @@ def test_data_to_ports_hierarchical() -> 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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]])
|
||||
|
|
@ -17,8 +17,7 @@ def test_shape_mirror() -> None:
|
|||
|
||||
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
|
||||
assert a.angle_ref == Arc.AngleRef.FocusNeg
|
||||
|
||||
a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||
a.mirror(0)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -89,23 +89,6 @@ def test_rotation_matrix_non_manhattan() -> None:
|
|||
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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -89,8 +89,8 @@ def boolean(
|
|||
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.
|
||||
subjects: List of subjects (Polygons or vertex arrays).
|
||||
clips: List of clips (Polygons or vertex arrays).
|
||||
operation: The boolean operation to perform.
|
||||
scale: Scaling factor for integer conversion (pyclipper uses integers).
|
||||
|
||||
|
|
@ -115,45 +115,52 @@ def boolean(
|
|||
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):
|
||||
if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon):
|
||||
objs = (objs,)
|
||||
elif 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)]
|
||||
verts = []
|
||||
for obj in objs:
|
||||
if hasattr(obj, 'to_polygons'):
|
||||
for p in obj.to_polygons():
|
||||
verts.append(p.vertices)
|
||||
elif isinstance(obj, numpy.ndarray):
|
||||
verts.append(obj)
|
||||
elif isinstance(obj, Polygon):
|
||||
verts.append(obj.vertices)
|
||||
else:
|
||||
# Try to iterate if it's an iterable of shapes
|
||||
try:
|
||||
for sub in obj:
|
||||
if hasattr(sub, 'to_polygons'):
|
||||
for p in sub.to_polygons():
|
||||
verts.append(p.vertices)
|
||||
elif isinstance(sub, Polygon):
|
||||
verts.append(sub.vertices)
|
||||
except TypeError:
|
||||
raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None
|
||||
return verts
|
||||
|
||||
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:
|
||||
if operation in ('union', 'xor'):
|
||||
return [Polygon(vertices) for vertices in clip_verts]
|
||||
return []
|
||||
|
||||
if not clip_verts:
|
||||
if operation == 'intersection':
|
||||
return []
|
||||
return [Polygon(vertices) for vertices in subject_verts]
|
||||
|
||||
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
|
||||
polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
|
||||
|
||||
result_polygons = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,16 @@ 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 inaccuracies in sin & cos
|
||||
# cos(4*theta) is 1 for any multiple of pi/2.
|
||||
if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12):
|
||||
arr = numpy.round(arr)
|
||||
|
||||
arr.flags.writeable = False
|
||||
return arr
|
||||
|
||||
|
||||
def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]:
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ dependencies = [
|
|||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"masque[arrow]",
|
||||
"pytest",
|
||||
"masque[oasis]",
|
||||
"masque[dxf]",
|
||||
"masque[svg]",
|
||||
|
|
@ -52,7 +52,6 @@ dev = [
|
|||
"masque[text]",
|
||||
"masque[manhattanize]",
|
||||
"masque[manhattanize_slow]",
|
||||
"masque[boolean]",
|
||||
"matplotlib>=3.10.8",
|
||||
"pytest>=9.0.2",
|
||||
"ruff>=0.15.5",
|
||||
|
|
@ -63,27 +62,12 @@ dev = [
|
|||
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.4"]
|
||||
svg = ["svgwrite"]
|
||||
visualize = ["matplotlib"]
|
||||
text = ["matplotlib", "freetype-py"]
|
||||
|
|
@ -137,3 +121,4 @@ mypy_path = "stubs"
|
|||
python_version = "3.11"
|
||||
strict = false
|
||||
check_untyped_defs = true
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
"""Repository development tools."""
|
||||
|
|
@ -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/<preset>.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())
|
||||
Loading…
Reference in a new issue