Compare commits
19 commits
80a24539f6
...
dc828cf5fc
| Author | SHA1 | Date | |
|---|---|---|---|
| dc828cf5fc | |||
| e8e19a5a9b | |||
| 058f210d31 | |||
| 1679deed4c | |||
| 3831cd4457 | |||
| ab232879c4 | |||
| 7941f01272 | |||
| 3c457e8e0a | |||
| 7398b1d560 | |||
| c0f54fe585 | |||
| 8b0eb2e83c | |||
| 08eb3c82d8 | |||
| 09d37724ae | |||
| 9a8cfce03f | |||
| 91454bbd8d | |||
| c19c820e1c | |||
| bc727bec1b | |||
| 833f5dd159 | |||
| f4df8e0553 |
38 changed files with 6619 additions and 414 deletions
5
examples/generate_gds_perf.py
Normal file
5
examples/generate_gds_perf.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from masque.file.gdsii_perf import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
131
examples/profile_gdsii_readers.py
Normal file
131
examples/profile_gdsii_readers.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
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,10 +20,10 @@ Contents
|
||||||
* Use `Pather` to snap ports together into a circuit
|
* Use `Pather` to snap ports together into a circuit
|
||||||
* Check for dangling references
|
* Check for dangling references
|
||||||
- [library](library.py)
|
- [library](library.py)
|
||||||
* Continue from `devices.py` using a lazy library
|
* Continue from `devices.py` by declaring a mixed library with `BuildLibrary`
|
||||||
* Create a `LazyLibrary`, which loads / generates patterns only when they are first used
|
* Import source-backed GDS cells and register python-generated recipes together
|
||||||
|
* Call `build()` to produce a normal library for downstream `Pather` usage and writing
|
||||||
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
|
* 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)
|
- [pather](pather.py)
|
||||||
* Use `Pather` to route individual wires and wire bundles
|
* Use `Pather` to route individual wires and wire bundles
|
||||||
* Use `AutoTool` to generate paths
|
* Use `AutoTool` to generate paths
|
||||||
|
|
|
||||||
|
|
@ -1,142 +1,114 @@
|
||||||
"""
|
"""
|
||||||
Tutorial: using `LazyLibrary` and `Pather.interface()`.
|
Tutorial: authoring a mixed library with `BuildLibrary`.
|
||||||
|
|
||||||
This example assumes you have already read `devices.py` and generated the
|
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
|
`circuit.gds` file it writes. The goal here is not the photonic-crystal geometry
|
||||||
itself, but rather how Masque lets you mix lazily loaded GDS content with
|
itself, but rather how Masque lets you combine imported GDS cells with
|
||||||
python-generated devices inside one library.
|
python-generated recipes, then turn that declaration set into a normal library
|
||||||
|
for downstream assembly and writing.
|
||||||
"""
|
"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
|
|
||||||
|
|
||||||
from masque import Pather, LazyLibrary
|
from masque import BuildLibrary, Pather, Pattern, cell
|
||||||
from masque.file.gdsii import writefile, load_libraryfile
|
from masque.file.gdsii import writefile
|
||||||
|
from masque.file.gdsii_lazy import readfile
|
||||||
|
|
||||||
import basic_shapes
|
import basic_shapes
|
||||||
import devices
|
import devices
|
||||||
from devices import data_to_ports
|
|
||||||
from basic_shapes import GDS_OPTS
|
from basic_shapes import GDS_OPTS
|
||||||
|
|
||||||
|
|
||||||
|
def make_mixed_waveguide(lib: BuildLibrary) -> 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:
|
def main() -> None:
|
||||||
# A `LazyLibrary` delays work until a pattern is actually needed.
|
builder = BuildLibrary()
|
||||||
# That applies both to GDS cells we load from disk and to python callables
|
cells = builder.cells
|
||||||
# that generate patterns on demand.
|
|
||||||
lib = LazyLibrary()
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Load some devices from a GDS file
|
# Load some devices from a GDS file
|
||||||
#
|
#
|
||||||
|
|
||||||
# Scan circuit.gds and prepare to lazy-load its contents
|
# Scan circuit.gds and prepare to lazy-load its contents. Port labels are
|
||||||
gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports)
|
# 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(gds_lib.with_ports_from_data(layers=[(3, 0)], max_depth=1))
|
||||||
|
|
||||||
# Add those cells into our lazy library.
|
print('Registered imported cells:\n' + pformat(list(gds_lib.keys())))
|
||||||
# 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())))
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Add some new devices to the library, this time from python code rather than GDS
|
# Register some new devices, this time from python code rather than GDS.
|
||||||
#
|
#
|
||||||
|
|
||||||
lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS)
|
cells.triangle = basic_shapes.triangle(devices.RADIUS)
|
||||||
opts: dict[str, Any] = dict(
|
opts: dict[str, Any] = dict(
|
||||||
lattice_constant = devices.LATTICE_CONSTANT,
|
lattice_constant=devices.LATTICE_CONSTANT,
|
||||||
hole = 'triangle',
|
hole='triangle',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Triangle-based variants. These lambdas are only recipes for building the
|
cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts)
|
||||||
# patterns; they do not execute until someone asks for the cell.
|
cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts)
|
||||||
lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts)
|
cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts)
|
||||||
lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts)
|
cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts)
|
||||||
lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts)
|
cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts)
|
||||||
lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts)
|
cells.tri_l3cav = cell(devices.perturbed_l3)(xy_size=(4, 10), **opts, hole_lib=builder)
|
||||||
lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts)
|
cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder)
|
||||||
lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib)
|
|
||||||
|
print('Declared cells waiting to be built:\n' + pformat(list(builder.keys())))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Build a mixed waveguide with an L3 cavity in the middle
|
# Build the declaration set into a normal library.
|
||||||
#
|
#
|
||||||
|
|
||||||
# Start a new design by copying the ports from an existing library cell.
|
built = builder.build()
|
||||||
# This gives `circ2` the same external interface as `tri_l3cav`.
|
print('Built library contains:\n' + pformat(list(built.keys())))
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Build a second device that is explicitly designed to mate with `circ2`.
|
# Continue designing against the built library.
|
||||||
#
|
#
|
||||||
|
|
||||||
# `Pather.interface()` makes a new pattern whose ports mirror an existing
|
# The built result behaves like a normal mutable library, so downstream code
|
||||||
# design's external interface. That is useful when you want to design an
|
# can use Pather, abstract views, and writing without going back through the
|
||||||
# adapter, continuation, or mating structure.
|
# builder interface.
|
||||||
circ3 = Pather.interface(source=circ2)
|
circ = Pather.interface(source='mixed_wg_cav', library=built)
|
||||||
|
circ.plug('tri_bend0', {'input': 'right'})
|
||||||
# Continue routing outward from those inherited ports.
|
circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
||||||
circ3.plug('tri_bend0', {'input': 'right'})
|
circ.plug('tri_bend0', {'input': 'right'})
|
||||||
circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('tri_bend0', {'input': 'right'})
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('tri_wg10', {'input': 'right'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('tri_wg28', {'input': 'right'})
|
||||||
circ3.plug('tri_wg10', {'input': 'right'})
|
circ.plug('tri_wg10', {'input': 'right', 'output': 'left'})
|
||||||
circ3.plug('tri_wg28', {'input': 'right'})
|
built['loop_segment'] = circ.pattern
|
||||||
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...')
|
print('Writing library to file...')
|
||||||
writefile(lib, 'library.gds', **GDS_OPTS)
|
writefile(built, 'library.gds', **GDS_OPTS)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
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
|
|
||||||
#
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from .error import (
|
||||||
from .shapes import (
|
from .shapes import (
|
||||||
Shape as Shape,
|
Shape as Shape,
|
||||||
Polygon as Polygon,
|
Polygon as Polygon,
|
||||||
|
RectCollection as RectCollection,
|
||||||
Path as Path,
|
Path as Path,
|
||||||
Circle as Circle,
|
Circle as Circle,
|
||||||
Arc as Arc,
|
Arc as Arc,
|
||||||
|
|
@ -62,10 +63,15 @@ from .library import (
|
||||||
ILibrary as ILibrary,
|
ILibrary as ILibrary,
|
||||||
LibraryView as LibraryView,
|
LibraryView as LibraryView,
|
||||||
Library as Library,
|
Library as Library,
|
||||||
|
BuiltLibrary as BuiltLibrary,
|
||||||
|
BuildLibrary as BuildLibrary,
|
||||||
|
BuildReport as BuildReport,
|
||||||
|
CellProvenance as CellProvenance,
|
||||||
LazyLibrary as LazyLibrary,
|
LazyLibrary as LazyLibrary,
|
||||||
AbstractView as AbstractView,
|
AbstractView as AbstractView,
|
||||||
TreeView as TreeView,
|
TreeView as TreeView,
|
||||||
Tree as Tree,
|
Tree as Tree,
|
||||||
|
cell as cell,
|
||||||
)
|
)
|
||||||
from .ports import (
|
from .ports import (
|
||||||
Port as Port,
|
Port as Port,
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ Notes:
|
||||||
from typing import IO, cast, Any
|
from typing import IO, cast, Any
|
||||||
from collections.abc import Iterable, Mapping, Callable
|
from collections.abc import Iterable, Mapping, Callable
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
import io
|
|
||||||
import mmap
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import gzip
|
import gzip
|
||||||
|
|
@ -37,10 +35,10 @@ from klamath import records
|
||||||
|
|
||||||
from .utils import is_gzipped, tmpfile
|
from .utils import is_gzipped, tmpfile
|
||||||
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||||
from ..shapes import Polygon, Path
|
from ..shapes import Polygon, Path, RectCollection
|
||||||
from ..repetition import Grid
|
from ..repetition import Grid
|
||||||
from ..utils import layer_t, annotations_t
|
from ..utils import layer_t, annotations_t
|
||||||
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
|
from ..library import Library, ILibrary
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -323,26 +321,40 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_
|
||||||
else:
|
else:
|
||||||
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
|
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
|
||||||
|
|
||||||
mpath = Path(
|
vertices = gpath.xy.astype(float)
|
||||||
vertices=gpath.xy.astype(float),
|
annotations = _properties_to_annotations(gpath.properties)
|
||||||
|
cap_extensions = None
|
||||||
|
if cap == Path.Cap.SquareCustom:
|
||||||
|
cap_extensions = numpy.asarray(gpath.extension, dtype=float)
|
||||||
|
|
||||||
|
if raw_mode:
|
||||||
|
mpath = Path._from_raw(
|
||||||
|
vertices=vertices,
|
||||||
width=gpath.width,
|
width=gpath.width,
|
||||||
cap=cap,
|
cap=cap,
|
||||||
offset=numpy.zeros(2),
|
cap_extensions=cap_extensions,
|
||||||
annotations=_properties_to_annotations(gpath.properties),
|
annotations=annotations,
|
||||||
raw=raw_mode,
|
)
|
||||||
|
else:
|
||||||
|
mpath = Path(
|
||||||
|
vertices=vertices,
|
||||||
|
width=gpath.width,
|
||||||
|
cap=cap,
|
||||||
|
cap_extensions=cap_extensions,
|
||||||
|
offset=numpy.zeros(2),
|
||||||
|
annotations=annotations,
|
||||||
)
|
)
|
||||||
if cap == Path.Cap.SquareCustom:
|
|
||||||
mpath.cap_extensions = gpath.extension
|
|
||||||
return gpath.layer, mpath
|
return gpath.layer, mpath
|
||||||
|
|
||||||
|
|
||||||
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
|
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
|
||||||
return boundary.layer, Polygon(
|
vertices = boundary.xy[:-1].astype(float)
|
||||||
vertices=boundary.xy[:-1].astype(float),
|
annotations = _properties_to_annotations(boundary.properties)
|
||||||
offset=numpy.zeros(2),
|
if raw_mode:
|
||||||
annotations=_properties_to_annotations(boundary.properties),
|
poly = Polygon._from_raw(vertices=vertices, annotations=annotations)
|
||||||
raw=raw_mode,
|
else:
|
||||||
)
|
poly = Polygon(vertices=vertices, offset=numpy.zeros(2), annotations=annotations)
|
||||||
|
return boundary.layer, poly
|
||||||
|
|
||||||
|
|
||||||
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
|
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
|
||||||
|
|
@ -466,6 +478,20 @@ def _shapes_to_elements(
|
||||||
properties=properties,
|
properties=properties,
|
||||||
)
|
)
|
||||||
elements.append(path)
|
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):
|
elif isinstance(shape, Polygon):
|
||||||
polygon = shape
|
polygon = shape
|
||||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||||
|
|
@ -514,117 +540,6 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
|
||||||
return texts
|
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(
|
def check_valid_names(
|
||||||
names: Iterable[str],
|
names: Iterable[str],
|
||||||
max_length: int = 32,
|
max_length: int = 32,
|
||||||
|
|
|
||||||
882
masque/file/gdsii_arrow.py
Normal file
882
masque/file/gdsii_arrow.py
Normal file
|
|
@ -0,0 +1,882 @@
|
||||||
|
# ruff: noqa: ARG001, F401
|
||||||
|
"""
|
||||||
|
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 typing import IO, cast, Any
|
||||||
|
from collections.abc import Iterable, Mapping, Callable
|
||||||
|
from importlib.machinery import EXTENSION_SUFFIXES
|
||||||
|
import importlib.util
|
||||||
|
import mmap
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import gzip
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pprint import pformat
|
||||||
|
|
||||||
|
from klamath.basic import KlamathError
|
||||||
|
import numpy
|
||||||
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
import pyarrow
|
||||||
|
from pyarrow.cffi import ffi
|
||||||
|
|
||||||
|
from .utils import is_gzipped, tmpfile
|
||||||
|
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||||
|
from ..shapes import Polygon, Path, PolyCollection, RectCollection
|
||||||
|
from ..repetition import Grid
|
||||||
|
from ..utils import layer_t, annotations_t
|
||||||
|
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
clib: Any | None = None
|
||||||
|
|
||||||
|
|
||||||
|
path_cap_map = {
|
||||||
|
0: Path.Cap.Flush,
|
||||||
|
1: Path.Cap.Circle,
|
||||||
|
2: Path.Cap.Square,
|
||||||
|
4: Path.Cap.SquareCustom,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
|
||||||
|
return numpy.rint(val).astype(numpy.int32)
|
||||||
|
|
||||||
|
|
||||||
|
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[2]
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _get_clib() -> Any:
|
||||||
|
global clib
|
||||||
|
if clib is None:
|
||||||
|
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.'
|
||||||
|
)
|
||||||
|
clib = ffi.dlopen(str(lib_path))
|
||||||
|
return clib
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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().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().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().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().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()),
|
||||||
|
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()),
|
||||||
|
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()),
|
||||||
|
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()),
|
||||||
|
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()),
|
||||||
|
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()),
|
||||||
|
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()),
|
||||||
|
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),
|
||||||
|
))
|
||||||
|
|
||||||
|
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.integer[Any]],
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
#def _properties_to_annotations(properties: pyarrow.Array) -> annotations_t:
|
||||||
|
# return {prop['key'].as_py(): prop['value'].as_py() for prop in properties}
|
||||||
|
|
||||||
|
|
||||||
|
def check_valid_names(
|
||||||
|
names: Iterable[str],
|
||||||
|
max_length: int = 32,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Check all provided names to see if they're valid GDSII cell names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: Collection of names to check
|
||||||
|
max_length: Max allowed length
|
||||||
|
|
||||||
|
"""
|
||||||
|
allowed_chars = set(string.ascii_letters + string.digits + '_?$')
|
||||||
|
|
||||||
|
bad_chars = [
|
||||||
|
name for name in names
|
||||||
|
if not set(name).issubset(allowed_chars)
|
||||||
|
]
|
||||||
|
|
||||||
|
bad_lengths = [
|
||||||
|
name for name in names
|
||||||
|
if len(name) > max_length
|
||||||
|
]
|
||||||
|
|
||||||
|
if bad_chars:
|
||||||
|
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
|
||||||
|
|
||||||
|
if bad_lengths:
|
||||||
|
logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))
|
||||||
|
|
||||||
|
if bad_chars or bad_lengths:
|
||||||
|
raise LibraryError('Library contains invalid names, see log above')
|
||||||
388
masque/file/gdsii_lazy.py
Normal file
388
masque/file/gdsii_lazy.py
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
- the source can be wrapped in `PortsLibraryView` or merged through
|
||||||
|
`OverlayLibrary`, both of which live in `gdsii_lazy_core`
|
||||||
|
|
||||||
|
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, Any, cast
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterator, Sequence
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import mmap
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import klamath
|
||||||
|
import numpy
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
from klamath import records
|
||||||
|
|
||||||
|
from . import gdsii
|
||||||
|
from .utils import is_gzipped
|
||||||
|
from .gdsii_lazy_core import OverlayLibrary, PortsLibraryView, _pattern_children, write, writefile
|
||||||
|
from ..error import LibraryError
|
||||||
|
from ..library import ILibraryView, LibraryView, dangling_mode_t
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..utils import apply_transforms
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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 stream:
|
||||||
|
data = stream.read()
|
||||||
|
return _SourceHandle(path=path, stream=io.BytesIO(data))
|
||||||
|
stream = cast('IO[bytes]', gzip.open(path, mode='rb'))
|
||||||
|
return _SourceHandle(path=path, stream=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)
|
||||||
|
|
||||||
|
stream = path.open(mode='rb')
|
||||||
|
return _SourceHandle(path=path, stream=stream)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_library(
|
||||||
|
stream: IO[bytes],
|
||||||
|
) -> tuple[dict[str, Any], list[str], dict[str, _CellScan]]:
|
||||||
|
library_info = gdsii._read_header(stream)
|
||||||
|
order: list[str] = []
|
||||||
|
cells: dict[str, _CellScan] = {}
|
||||||
|
|
||||||
|
found_struct = records.BGNSTR.skip_past(stream)
|
||||||
|
while found_struct:
|
||||||
|
name = records.STRNAME.skip_and_read(stream).decode('ASCII')
|
||||||
|
offset = stream.tell()
|
||||||
|
elements = klamath.library.read_elements(stream)
|
||||||
|
children = {
|
||||||
|
element.struct_name.decode('ASCII')
|
||||||
|
for element in elements
|
||||||
|
if isinstance(element, klamath.elements.Reference)
|
||||||
|
}
|
||||||
|
order.append(name)
|
||||||
|
cells[name] = _CellScan(offset=offset, children=children)
|
||||||
|
found_struct = records.BGNSTR.skip_past(stream)
|
||||||
|
|
||||||
|
return library_info, order, cells
|
||||||
|
|
||||||
|
|
||||||
|
class GdsLibrarySource(ILibraryView):
|
||||||
|
"""
|
||||||
|
Read-only library backed by a seekable GDS stream.
|
||||||
|
|
||||||
|
Cells are scanned once up front to discover order and child edges, then
|
||||||
|
materialized one at a time through the classic `gdsii.read_elements` path.
|
||||||
|
|
||||||
|
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_pattern(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 materialize_many(
|
||||||
|
self,
|
||||||
|
names: Sequence[str],
|
||||||
|
*,
|
||||||
|
persist: bool = True,
|
||||||
|
) -> LibraryView:
|
||||||
|
mats = {
|
||||||
|
name: self._materialize_pattern(name, persist=persist)
|
||||||
|
for name in dict.fromkeys(names)
|
||||||
|
}
|
||||||
|
return LibraryView(mats)
|
||||||
|
|
||||||
|
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||||
|
if name in self._cache:
|
||||||
|
return self._cache[name]
|
||||||
|
|
||||||
|
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.read_elements(self._source.stream, raw_mode=True)
|
||||||
|
finally:
|
||||||
|
self._lookups_in_progress.pop()
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
self._cache[name] = pat
|
||||||
|
return pat
|
||||||
|
|
||||||
|
def _raw_children(self, name: str) -> set[str]:
|
||||||
|
return set(self._cells[name].children)
|
||||||
|
|
||||||
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
graph: dict[str, set[str]] = {}
|
||||||
|
for name in self._cell_order:
|
||||||
|
if name in self._cache:
|
||||||
|
graph[name] = _pattern_children(self._cache[name])
|
||||||
|
else:
|
||||||
|
graph[name] = self._raw_children(name)
|
||||||
|
|
||||||
|
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 parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
||||||
|
existing = set(self.keys())
|
||||||
|
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
||||||
|
for parent, children in child_graph.items():
|
||||||
|
for child in children:
|
||||||
|
if child in existing or dangling == 'include':
|
||||||
|
igraph.setdefault(child, set()).add(parent)
|
||||||
|
if dangling == 'error':
|
||||||
|
raw = self.child_graph(dangling='include')
|
||||||
|
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
||||||
|
if dangling_refs:
|
||||||
|
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
||||||
|
return igraph
|
||||||
|
|
||||||
|
def subtree(
|
||||||
|
self,
|
||||||
|
tops: str | Sequence[str],
|
||||||
|
) -> ILibraryView:
|
||||||
|
if isinstance(tops, str):
|
||||||
|
tops = (tops,)
|
||||||
|
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||||
|
keep |= set(tops)
|
||||||
|
return self.materialize_many(tuple(keep), persist=True)
|
||||||
|
|
||||||
|
def tops(self) -> list[str]:
|
||||||
|
graph = self.child_graph(dangling='ignore')
|
||||||
|
names = set(graph)
|
||||||
|
not_toplevel: set[str] = set()
|
||||||
|
for children in graph.values():
|
||||||
|
not_toplevel |= children
|
||||||
|
return list(names - not_toplevel)
|
||||||
|
|
||||||
|
def with_ports_from_data(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
layers: Sequence[tuple[int, int] | int],
|
||||||
|
max_depth: int = 0,
|
||||||
|
skip_subcells: bool = True,
|
||||||
|
) -> PortsLibraryView:
|
||||||
|
return PortsLibraryView(
|
||||||
|
self,
|
||||||
|
layers=layers,
|
||||||
|
max_depth=max_depth,
|
||||||
|
skip_subcells=skip_subcells,
|
||||||
|
)
|
||||||
|
|
||||||
|
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]]]:
|
||||||
|
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()):
|
||||||
|
if parent in self._cache:
|
||||||
|
for ref in self._cache[parent].refs.get(name, []):
|
||||||
|
instances[parent].append(ref.as_transforms())
|
||||||
|
continue
|
||||||
|
pat = self._materialize_pattern(parent, persist=False)
|
||||||
|
for ref in pat.refs.get(name, []):
|
||||||
|
instances[parent].append(ref.as_transforms())
|
||||||
|
return instances
|
||||||
|
|
||||||
|
def find_refs_global(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
order: list[str] | None = None,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
|
if order is None:
|
||||||
|
order = self.child_order(dangling=graph_mode)
|
||||||
|
if parent_graph is None:
|
||||||
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return {}
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return {}
|
||||||
|
|
||||||
|
self_keys = set(self.keys())
|
||||||
|
transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]]
|
||||||
|
transforms = defaultdict(list)
|
||||||
|
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
||||||
|
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||||
|
|
||||||
|
for next_name in order:
|
||||||
|
if next_name not in transforms:
|
||||||
|
continue
|
||||||
|
if not parent_graph.get(next_name, set()) & self_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||||
|
inners = transforms.pop(next_name)
|
||||||
|
for parent, outer in outers.items():
|
||||||
|
outer_tf = numpy.concatenate(outer)
|
||||||
|
for path, inner in inners:
|
||||||
|
combined = apply_transforms(outer_tf, inner)
|
||||||
|
transforms[parent].append(((next_name,) + path, combined))
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for parent, targets in transforms.items():
|
||||||
|
for path, instances in targets:
|
||||||
|
result[(parent,) + path] = instances
|
||||||
|
return result
|
||||||
|
|
||||||
|
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
|
||||||
519
masque/file/gdsii_lazy_arrow.py
Normal file
519
masque/file/gdsii_lazy_arrow.py
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
"""
|
||||||
|
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, Any, cast
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterator, Sequence
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
import mmap
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
import pyarrow
|
||||||
|
|
||||||
|
from . import gdsii_arrow
|
||||||
|
from .utils import is_gzipped
|
||||||
|
from .gdsii_lazy_core import OverlayLibrary, PortsLibraryView, _pattern_children, write, writefile
|
||||||
|
from ..library import ILibraryView, LibraryView, dangling_mode_t
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..utils import apply_transforms
|
||||||
|
|
||||||
|
|
||||||
|
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 is_available() -> bool:
|
||||||
|
return gdsii_arrow.is_available()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_header(libarr: pyarrow.StructScalar) -> dict[str, Any]:
|
||||||
|
return gdsii_arrow._read_header(libarr)
|
||||||
|
|
||||||
|
|
||||||
|
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 = _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 = gdsii_arrow._packed_xy_u64_to_pairs(ref_values.field('xy').to_numpy())
|
||||||
|
xy0 = gdsii_arrow._packed_xy_u64_to_pairs(ref_values.field('xy0').to_numpy())
|
||||||
|
xy1 = gdsii_arrow._packed_xy_u64_to_pairs(ref_values.field('xy1').to_numpy())
|
||||||
|
counts = gdsii_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):
|
||||||
|
"""
|
||||||
|
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._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 = gdsii_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_pattern(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)
|
||||||
|
return LibraryView(mats)
|
||||||
|
|
||||||
|
def _materialize_patterns(
|
||||||
|
self,
|
||||||
|
names: Sequence[str],
|
||||||
|
*,
|
||||||
|
persist: 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 = gdsii_arrow._read_selected_cells_to_arrow(self._source.data, ranges)
|
||||||
|
assert len(arrow_arr) == 1
|
||||||
|
selected_lib, _info = gdsii_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 in self._cache:
|
||||||
|
materialized[name] = self._cache[name]
|
||||||
|
return materialized
|
||||||
|
|
||||||
|
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||||
|
return self._materialize_patterns((name,), persist=persist)[name]
|
||||||
|
|
||||||
|
def _raw_children(self, name: str) -> set[str]:
|
||||||
|
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 child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
graph: dict[str, set[str]] = {}
|
||||||
|
for name in self._payload.cell_order:
|
||||||
|
if name in self._cache:
|
||||||
|
graph[name] = _pattern_children(self._cache[name])
|
||||||
|
else:
|
||||||
|
graph[name] = self._raw_children(name)
|
||||||
|
|
||||||
|
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 parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
||||||
|
existing = set(self.keys())
|
||||||
|
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
||||||
|
for parent, children in child_graph.items():
|
||||||
|
for child in children:
|
||||||
|
if child in existing or dangling == 'include':
|
||||||
|
igraph.setdefault(child, set()).add(parent)
|
||||||
|
if dangling == 'error':
|
||||||
|
raw = self.child_graph(dangling='include')
|
||||||
|
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
||||||
|
if dangling_refs:
|
||||||
|
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
||||||
|
return igraph
|
||||||
|
|
||||||
|
def subtree(
|
||||||
|
self,
|
||||||
|
tops: str | Sequence[str],
|
||||||
|
) -> ILibraryView:
|
||||||
|
if isinstance(tops, str):
|
||||||
|
tops = (tops,)
|
||||||
|
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||||
|
keep |= set(tops)
|
||||||
|
return self.materialize_many(tuple(keep), persist=True)
|
||||||
|
|
||||||
|
def tops(self) -> list[str]:
|
||||||
|
graph = self.child_graph(dangling='ignore')
|
||||||
|
names = set(graph)
|
||||||
|
not_toplevel: set[str] = set()
|
||||||
|
for children in graph.values():
|
||||||
|
not_toplevel |= children
|
||||||
|
return list(names - not_toplevel)
|
||||||
|
|
||||||
|
def with_ports_from_data(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
layers: Sequence[tuple[int, int] | int],
|
||||||
|
max_depth: int = 0,
|
||||||
|
skip_subcells: bool = True,
|
||||||
|
) -> PortsLibraryView:
|
||||||
|
return PortsLibraryView(
|
||||||
|
self,
|
||||||
|
layers=layers,
|
||||||
|
max_depth=max_depth,
|
||||||
|
skip_subcells=skip_subcells,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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]]]:
|
||||||
|
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
|
||||||
|
|
||||||
|
target_id = self._payload.cells.get(name)
|
||||||
|
for parent in parent_graph.get(name, set()):
|
||||||
|
if parent in self._cache:
|
||||||
|
for ref in self._cache[parent].refs.get(name, []):
|
||||||
|
instances[parent].append(ref.as_transforms())
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_id is None or parent not in self._payload.cells:
|
||||||
|
continue
|
||||||
|
rows = self._collect_raw_transforms(self._payload.cells[parent], target_id.cell_id)
|
||||||
|
if rows:
|
||||||
|
instances[parent].extend(rows)
|
||||||
|
return instances
|
||||||
|
|
||||||
|
def find_refs_global(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
order: list[str] | None = None,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
|
if order is None:
|
||||||
|
order = self.child_order(dangling=graph_mode)
|
||||||
|
if parent_graph is None:
|
||||||
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return {}
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return {}
|
||||||
|
|
||||||
|
self_keys = set(self.keys())
|
||||||
|
transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]]
|
||||||
|
transforms = defaultdict(list)
|
||||||
|
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
||||||
|
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||||
|
|
||||||
|
for next_name in order:
|
||||||
|
if next_name not in transforms:
|
||||||
|
continue
|
||||||
|
if not parent_graph.get(next_name, set()) & self_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||||
|
inners = transforms.pop(next_name)
|
||||||
|
for parent, outer in outers.items():
|
||||||
|
outer_tf = numpy.concatenate(outer)
|
||||||
|
for path, inner in inners:
|
||||||
|
combined = apply_transforms(outer_tf, inner)
|
||||||
|
transforms[parent].append(((next_name,) + path, combined))
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for parent, targets in transforms.items():
|
||||||
|
for path, instances in targets:
|
||||||
|
full_path = (parent,) + path
|
||||||
|
result[full_path] = instances
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def readfile(
|
||||||
|
filename: str | pathlib.Path,
|
||||||
|
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
||||||
|
lib = ArrowLibrary.from_file(filename)
|
||||||
|
return lib, lib.library_info
|
||||||
|
|
||||||
|
|
||||||
|
def load_libraryfile(
|
||||||
|
filename: str | pathlib.Path,
|
||||||
|
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
||||||
|
return readfile(filename)
|
||||||
706
masque/file/gdsii_lazy_core.py
Normal file
706
masque/file/gdsii_lazy_core.py
Normal file
|
|
@ -0,0 +1,706 @@
|
||||||
|
"""
|
||||||
|
Shared helpers for source-backed lazy GDS views.
|
||||||
|
|
||||||
|
This module contains the reusable pieces that sit between lazy source readers
|
||||||
|
and ordinary mutable library usage:
|
||||||
|
|
||||||
|
- `PortsLibraryView` layers a processed, ports-importing cache on top of a raw
|
||||||
|
source view without mutating the source itself
|
||||||
|
- `OverlayLibrary` exposes a mutable library surface that can mix source-backed
|
||||||
|
cells with overlay-owned materialized patterns
|
||||||
|
- the write helpers preserve source-backed copy-through behavior where
|
||||||
|
possible, falling back to normal pattern serialization when a cell has been
|
||||||
|
materialized or remapped
|
||||||
|
|
||||||
|
Both the classic and Arrow-backed lazy GDS readers rely on these helpers.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import IO, Any, cast
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||||
|
import copy
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import klamath
|
||||||
|
import numpy
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from . import gdsii
|
||||||
|
from .utils import tmpfile
|
||||||
|
from ..error import LibraryError
|
||||||
|
from ..library import ILibrary, ILibraryView, LibraryView, dangling_mode_t
|
||||||
|
from ..pattern import Pattern, map_targets
|
||||||
|
from ..utils import apply_transforms
|
||||||
|
from ..utils.ports2data import data_to_ports
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _SourceLayer:
|
||||||
|
""" One imported source layer tracked by an `OverlayLibrary`. """
|
||||||
|
library: ILibraryView
|
||||||
|
source_to_visible: dict[str, str]
|
||||||
|
visible_to_source: dict[str, str]
|
||||||
|
child_graph: dict[str, set[str]]
|
||||||
|
order: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SourceEntry:
|
||||||
|
""" Reference to a single visible source-backed cell in an overlay. """
|
||||||
|
layer_index: int
|
||||||
|
source_name: str
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_children(pat: Pattern) -> set[str]:
|
||||||
|
return {child for child, refs in pat.refs.items() if child is not None and refs}
|
||||||
|
|
||||||
|
|
||||||
|
def _remap_pattern_targets(pat: Pattern, remap: Callable[[str | None], str | None]) -> Pattern:
|
||||||
|
if not pat.refs:
|
||||||
|
return pat
|
||||||
|
pat.refs = map_targets(pat.refs, remap)
|
||||||
|
return pat
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_library_view(source: Mapping[str, Pattern] | ILibraryView) -> ILibraryView:
|
||||||
|
if isinstance(source, ILibraryView):
|
||||||
|
return source
|
||||||
|
return LibraryView(source)
|
||||||
|
|
||||||
|
|
||||||
|
def _materialize_detached_pattern(view: ILibraryView, name: str) -> Pattern:
|
||||||
|
func = getattr(view, '_materialize_pattern', None)
|
||||||
|
if callable(func):
|
||||||
|
return cast('Pattern', func(name, persist=False))
|
||||||
|
return view[name].deepcopy()
|
||||||
|
|
||||||
|
|
||||||
|
class PortsLibraryView(ILibraryView):
|
||||||
|
"""
|
||||||
|
Read-only view which imports ports into cells 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.
|
||||||
|
|
||||||
|
Graph queries, source ordering, and copy-through capabilities are delegated
|
||||||
|
to the wrapped source whenever possible, while `__getitem__` and
|
||||||
|
`materialize_many()` return port-imported patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: ILibraryView,
|
||||||
|
*,
|
||||||
|
layers: Sequence[gdsii.layer_t],
|
||||||
|
max_depth: int = 0,
|
||||||
|
skip_subcells: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self._source = source
|
||||||
|
self._layers = tuple(layers)
|
||||||
|
self._max_depth = max_depth
|
||||||
|
self._skip_subcells = skip_subcells
|
||||||
|
self._cache: dict[str, Pattern] = {}
|
||||||
|
self._lookups_in_progress: list[str] = []
|
||||||
|
if hasattr(source, 'library_info'):
|
||||||
|
self.library_info = cast('dict[str, Any]', getattr(source, 'library_info'))
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Pattern:
|
||||||
|
return self._materialize_pattern(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 _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||||
|
if name in self._cache:
|
||||||
|
return self._cache[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.'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._lookups_in_progress.append(name)
|
||||||
|
try:
|
||||||
|
pat = _materialize_detached_pattern(self._source, name)
|
||||||
|
pat = data_to_ports(
|
||||||
|
layers=self._layers,
|
||||||
|
library=self,
|
||||||
|
pattern=pat,
|
||||||
|
name=name,
|
||||||
|
max_depth=self._max_depth,
|
||||||
|
skip_subcells=self._skip_subcells,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._lookups_in_progress.pop()
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
self._cache[name] = pat
|
||||||
|
return pat
|
||||||
|
|
||||||
|
def materialize_many(
|
||||||
|
self,
|
||||||
|
names: Sequence[str],
|
||||||
|
*,
|
||||||
|
persist: bool = True,
|
||||||
|
) -> LibraryView:
|
||||||
|
mats = {
|
||||||
|
name: self._materialize_pattern(name, persist=persist)
|
||||||
|
for name in dict.fromkeys(names)
|
||||||
|
}
|
||||||
|
return LibraryView(mats)
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
return self._source.source_order()
|
||||||
|
|
||||||
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
return self._source.child_graph(dangling=dangling)
|
||||||
|
|
||||||
|
def parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
return self._source.parent_graph(dangling=dangling)
|
||||||
|
|
||||||
|
def subtree(
|
||||||
|
self,
|
||||||
|
tops: str | Sequence[str],
|
||||||
|
) -> ILibraryView:
|
||||||
|
if isinstance(tops, str):
|
||||||
|
tops = (tops,)
|
||||||
|
keep = cast('set[str]', self._source.referenced_patterns(tops) - {None})
|
||||||
|
keep |= set(tops)
|
||||||
|
return self.materialize_many(tuple(keep), persist=True)
|
||||||
|
|
||||||
|
def tops(self) -> list[str]:
|
||||||
|
return self._source.tops()
|
||||||
|
|
||||||
|
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]]]:
|
||||||
|
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)
|
||||||
|
|
||||||
|
def find_refs_global(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
order: list[str] | None = None,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
|
finder = getattr(self._source, 'find_refs_global', None)
|
||||||
|
if callable(finder):
|
||||||
|
return cast(
|
||||||
|
'dict[tuple[str, ...], NDArray[numpy.float64]]',
|
||||||
|
finder(name, order=order, parent_graph=parent_graph, dangling=dangling),
|
||||||
|
)
|
||||||
|
return super().find_refs_global(name, order=order, parent_graph=parent_graph, dangling=dangling)
|
||||||
|
|
||||||
|
def raw_struct_bytes(self, name: str) -> bytes:
|
||||||
|
reader = getattr(self._source, 'raw_struct_bytes', None)
|
||||||
|
if not callable(reader):
|
||||||
|
raise AttributeError('raw_struct_bytes')
|
||||||
|
return cast('bytes', reader(name))
|
||||||
|
|
||||||
|
def can_copy_raw_struct(self, name: str) -> bool:
|
||||||
|
can_copy = getattr(self._source, 'can_copy_raw_struct', None)
|
||||||
|
if not callable(can_copy):
|
||||||
|
return False
|
||||||
|
return bool(can_copy(name))
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
closer = getattr(self._source, 'close', None)
|
||||||
|
if callable(closer):
|
||||||
|
closer()
|
||||||
|
|
||||||
|
def __enter__(self) -> PortsLibraryView:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayLibrary(ILibrary):
|
||||||
|
"""
|
||||||
|
Mutable overlay over one or more source libraries.
|
||||||
|
|
||||||
|
Source-backed cells remain lazy until accessed through `__getitem__`, at
|
||||||
|
which point that visible cell is promoted into an overlay-owned materialized
|
||||||
|
`Pattern`.
|
||||||
|
|
||||||
|
This is the main mutable integration surface for lazy GDS content. It lets
|
||||||
|
callers:
|
||||||
|
- expose one or more source-backed libraries behind a normal `ILibrary`
|
||||||
|
interface
|
||||||
|
- add or replace cells with overlay-owned patterns
|
||||||
|
- rename visible source cells
|
||||||
|
- remap references without immediately rewriting untouched source structs
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._layers: list[_SourceLayer] = []
|
||||||
|
self._entries: dict[str, Pattern | _SourceEntry] = {}
|
||||||
|
self._order: list[str] = []
|
||||||
|
self._target_remap: dict[str, 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_pattern(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[[ILibraryView, str], str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
view = _coerce_library_view(source)
|
||||||
|
source_order = list(view.source_order())
|
||||||
|
child_graph = view.child_graph(dangling='include')
|
||||||
|
|
||||||
|
source_to_visible: dict[str, str] = {}
|
||||||
|
visible_to_source: dict[str, str] = {}
|
||||||
|
rename_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
for name in source_order:
|
||||||
|
visible = name
|
||||||
|
if visible in self._entries or visible in visible_to_source:
|
||||||
|
if rename_theirs is None:
|
||||||
|
raise LibraryError(f'Conflicting name while adding source: {name!r}')
|
||||||
|
visible = rename_theirs(self, name)
|
||||||
|
if visible in self._entries or visible in visible_to_source:
|
||||||
|
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
|
||||||
|
rename_map[name] = visible
|
||||||
|
source_to_visible[name] = visible
|
||||||
|
visible_to_source[visible] = name
|
||||||
|
|
||||||
|
layer = _SourceLayer(
|
||||||
|
library=view,
|
||||||
|
source_to_visible=source_to_visible,
|
||||||
|
visible_to_source=visible_to_source,
|
||||||
|
child_graph=child_graph,
|
||||||
|
order=[source_to_visible[name] for name in source_order],
|
||||||
|
)
|
||||||
|
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 rename_map
|
||||||
|
|
||||||
|
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
|
||||||
|
if isinstance(entry, _SourceEntry):
|
||||||
|
layer = self._layers[entry.layer_index]
|
||||||
|
layer.source_to_visible[entry.source_name] = new_name
|
||||||
|
del layer.visible_to_source[old_name]
|
||||||
|
layer.visible_to_source[new_name] = entry.source_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 _resolve_target(self, target: str) -> str:
|
||||||
|
seen: set[str] = set()
|
||||||
|
current = target
|
||||||
|
while current in self._target_remap:
|
||||||
|
if current in seen:
|
||||||
|
raise LibraryError(f'Cycle encountered while resolving target remap for {target!r}')
|
||||||
|
seen.add(current)
|
||||||
|
current = self._target_remap[current]
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _set_target_remap(self, old_target: str, new_target: str) -> None:
|
||||||
|
resolved_new = self._resolve_target(new_target)
|
||||||
|
if resolved_new == old_target:
|
||||||
|
raise LibraryError(f'Ref target remap would create a cycle: {old_target!r} -> {new_target!r}')
|
||||||
|
self._target_remap[old_target] = resolved_new
|
||||||
|
for key in list(self._target_remap):
|
||||||
|
self._target_remap[key] = self._resolve_target(self._target_remap[key])
|
||||||
|
|
||||||
|
def move_references(self, old_target: str, new_target: str) -> OverlayLibrary:
|
||||||
|
if old_target == new_target:
|
||||||
|
return self
|
||||||
|
self._set_target_remap(old_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:
|
||||||
|
visible = layer.source_to_visible.get(target, target)
|
||||||
|
return self._resolve_target(visible)
|
||||||
|
|
||||||
|
def _materialize_pattern(self, name: str, *, persist: bool) -> 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 = layer.library[entry.source_name].deepcopy()
|
||||||
|
remap = lambda target: None if target is None else self._effective_target(layer, target)
|
||||||
|
pat = _remap_pattern_targets(source_pat, remap)
|
||||||
|
if persist:
|
||||||
|
self._entries[name] = pat
|
||||||
|
return pat
|
||||||
|
|
||||||
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
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] = _pattern_children(entry)
|
||||||
|
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 parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
||||||
|
existing = set(self.keys())
|
||||||
|
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
||||||
|
for parent, children in child_graph.items():
|
||||||
|
for child in children:
|
||||||
|
if child in existing or dangling == 'include':
|
||||||
|
igraph.setdefault(child, set()).add(parent)
|
||||||
|
if dangling == 'error':
|
||||||
|
raw = self.child_graph(dangling='include')
|
||||||
|
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
||||||
|
if dangling_refs:
|
||||||
|
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
||||||
|
return igraph
|
||||||
|
|
||||||
|
def subtree(
|
||||||
|
self,
|
||||||
|
tops: str | Sequence[str],
|
||||||
|
) -> ILibraryView:
|
||||||
|
if isinstance(tops, str):
|
||||||
|
tops = (tops,)
|
||||||
|
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||||
|
keep |= set(tops)
|
||||||
|
return LibraryView({name: self[name] for name in keep})
|
||||||
|
|
||||||
|
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]]]:
|
||||||
|
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_pattern(parent, persist=False)
|
||||||
|
for ref in pat.refs.get(name, []):
|
||||||
|
instances[parent].append(ref.as_transforms())
|
||||||
|
return instances
|
||||||
|
|
||||||
|
def find_refs_global(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
order: list[str] | None = None,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
|
if order is None:
|
||||||
|
order = self.child_order(dangling=graph_mode)
|
||||||
|
if parent_graph is None:
|
||||||
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return {}
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return {}
|
||||||
|
|
||||||
|
self_keys = set(self.keys())
|
||||||
|
transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]]
|
||||||
|
transforms = defaultdict(list)
|
||||||
|
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
||||||
|
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||||
|
|
||||||
|
for next_name in order:
|
||||||
|
if next_name not in transforms:
|
||||||
|
continue
|
||||||
|
if not parent_graph.get(next_name, set()) & self_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||||
|
inners = transforms.pop(next_name)
|
||||||
|
for parent, outer in outers.items():
|
||||||
|
outer_tf = numpy.concatenate(outer)
|
||||||
|
for path, inner in inners:
|
||||||
|
combined = apply_transforms(outer_tf, inner)
|
||||||
|
transforms[parent].append(((next_name,) + path, combined))
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for parent, targets in transforms.items():
|
||||||
|
for path, instances in targets:
|
||||||
|
result[(parent,) + path] = instances
|
||||||
|
return result
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
return tuple(name for name in self._order if name in self._entries)
|
||||||
|
|
||||||
|
|
||||||
|
class BuiltOverlayLibrary(OverlayLibrary):
|
||||||
|
"""
|
||||||
|
Internal overlay output returned by `BuildLibrary.build(output='overlay')`.
|
||||||
|
|
||||||
|
The type is intentionally not part of the public API. It exists so build
|
||||||
|
outputs can carry a `build_report` while still behaving like an
|
||||||
|
`OverlayLibrary`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, build_report: Any | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.build_report = build_report
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_library_infos(library: Mapping[str, Pattern] | ILibraryView) -> Iterator[dict[str, Any]]:
|
||||||
|
info = getattr(library, 'library_info', None)
|
||||||
|
if isinstance(info, dict):
|
||||||
|
yield info
|
||||||
|
if isinstance(library, OverlayLibrary):
|
||||||
|
for layer in library._layers:
|
||||||
|
yield from _iter_library_infos(layer.library)
|
||||||
|
|
||||||
|
|
||||||
|
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(_iter_library_infos(library))
|
||||||
|
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 or logical_units_per_unit is None or library_name is None:
|
||||||
|
raise LibraryError('meters_per_unit, logical_units_per_unit, and library_name are required for non-GDS-backed lazy writes')
|
||||||
|
return meters_per_unit, logical_units_per_unit, library_name
|
||||||
|
|
||||||
|
|
||||||
|
def _can_copy_raw_cell(library: Mapping[str, Pattern] | ILibraryView, name: str) -> bool:
|
||||||
|
can_copy = getattr(library, 'can_copy_raw_struct', None)
|
||||||
|
if not callable(can_copy):
|
||||||
|
return False
|
||||||
|
return bool(can_copy(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_struct_bytes(library: Mapping[str, Pattern] | ILibraryView, name: str) -> bytes:
|
||||||
|
reader = getattr(library, 'raw_struct_bytes', None)
|
||||||
|
if not callable(reader):
|
||||||
|
raise AttributeError('raw_struct_bytes')
|
||||||
|
return cast('bytes', reader(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _can_copy_overlay_cell(library: OverlayLibrary, name: str, entry: _SourceEntry) -> bool:
|
||||||
|
layer = library._layers[entry.layer_index]
|
||||||
|
if name != entry.source_name:
|
||||||
|
return False
|
||||||
|
if not _can_copy_raw_cell(layer.library, entry.source_name):
|
||||||
|
return False
|
||||||
|
children = layer.child_graph.get(entry.source_name, set())
|
||||||
|
return all(library._effective_target(layer, child) == child for child in children)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None:
|
||||||
|
elements: list[klamath.elements.Element] = []
|
||||||
|
elements += gdsii._shapes_to_elements(pat.shapes)
|
||||||
|
elements += gdsii._labels_to_texts(pat.labels)
|
||||||
|
elements += gdsii._mrefs_to_grefs(pat.refs)
|
||||||
|
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
header = klamath.library.FileHeader(
|
||||||
|
name=library_name.encode('ASCII'),
|
||||||
|
user_units_per_db_unit=logical_units_per_unit,
|
||||||
|
meters_per_db_unit=meters_per_unit,
|
||||||
|
)
|
||||||
|
header.write(stream)
|
||||||
|
|
||||||
|
if isinstance(library, OverlayLibrary):
|
||||||
|
for name in library.source_order():
|
||||||
|
entry = library._entries[name]
|
||||||
|
if isinstance(entry, _SourceEntry) and _can_copy_overlay_cell(library, name, entry):
|
||||||
|
layer = library._layers[entry.layer_index]
|
||||||
|
stream.write(_raw_struct_bytes(layer.library, entry.source_name))
|
||||||
|
else:
|
||||||
|
_write_pattern_struct(stream, name, library._materialize_pattern(name, persist=False))
|
||||||
|
klamath.records.ENDLIB.write(stream, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if hasattr(library, 'raw_struct_bytes'):
|
||||||
|
for name in library.source_order():
|
||||||
|
if _can_copy_raw_cell(library, name):
|
||||||
|
stream.write(_raw_struct_bytes(library, name))
|
||||||
|
else:
|
||||||
|
_write_pattern_struct(stream, name, _materialize_detached_pattern(cast('ILibraryView', library), name))
|
||||||
|
klamath.records.ENDLIB.write(stream, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
gdsii.write(cast('Mapping[str, Pattern]', library), stream, meters_per_unit, logical_units_per_unit, library_name)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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,
|
||||||
|
meters_per_unit=meters_per_unit,
|
||||||
|
logical_units_per_unit=logical_units_per_unit,
|
||||||
|
library_name=library_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for ss in streams:
|
||||||
|
ss.close()
|
||||||
633
masque/file/gdsii_perf.py
Normal file
633
masque/file/gdsii_perf.py
Normal file
|
|
@ -0,0 +1,633 @@
|
||||||
|
"""
|
||||||
|
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(name: str, 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(name: str, 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(_box_name(idx), 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(_poly_name(idx), 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 _poly_paths_total(cfg: FixturePreset) -> int:
|
||||||
|
return (cfg.poly_cells - 1) // cfg.path_stride + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _poly_texts_total(cfg: FixturePreset) -> int:
|
||||||
|
return (cfg.poly_cells - 1) // cfg.text_stride + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_instances_per_box_cluster(cfg: FixturePreset) -> int:
|
||||||
|
array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4))
|
||||||
|
array_mult = cfg.box_cluster_array[0] * cfg.box_cluster_array[1]
|
||||||
|
return array_refs * array_mult + (cfg.box_cluster_refs - array_refs)
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_instances_per_poly_cluster(cfg: FixturePreset) -> int:
|
||||||
|
array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2)
|
||||||
|
array_mult = cfg.poly_cluster_array[0] * cfg.poly_cluster_array[1]
|
||||||
|
return array_refs * array_mult + (cfg.poly_cluster_refs - array_refs)
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_manifest(path: str | Path, preset: str, scale: float = 1.0) -> FixtureManifest:
|
||||||
|
base = PRESETS[preset]
|
||||||
|
cfg = _scaled_preset(base, scale)
|
||||||
|
|
||||||
|
flattened_box_placements = (
|
||||||
|
cfg.box_wrappers
|
||||||
|
+ cfg.box_clusters * _ref_instances_per_box_cluster(cfg)
|
||||||
|
+ cfg.top_direct_box_refs * cfg.top_box_array[0] * cfg.top_box_array[1]
|
||||||
|
)
|
||||||
|
flattened_poly_placements = (
|
||||||
|
cfg.poly_wrappers
|
||||||
|
+ cfg.poly_clusters * _ref_instances_per_poly_cluster(cfg)
|
||||||
|
+ 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=_poly_paths_total(cfg),
|
||||||
|
hierarchical_texts_total=_poly_texts_total(cfg),
|
||||||
|
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())
|
||||||
|
|
@ -45,6 +45,10 @@ def _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]:
|
||||||
return svg_ids
|
return svg_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _detached_library(library: Mapping[str, Pattern]) -> dict[str, Pattern]:
|
||||||
|
return {name: pat.deepcopy() for name, pat in library.items()}
|
||||||
|
|
||||||
|
|
||||||
def writefile(
|
def writefile(
|
||||||
library: Mapping[str, Pattern],
|
library: Mapping[str, Pattern],
|
||||||
top: str,
|
top: str,
|
||||||
|
|
@ -53,13 +57,12 @@ def writefile(
|
||||||
annotate_ports: bool = False,
|
annotate_ports: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Write a Pattern to an SVG file, by first calling .polygonize() on it
|
Write a Pattern to an SVG file, by first calling .polygonize() on a detached
|
||||||
|
materialized copy
|
||||||
to change the shapes into polygons, and then writing patterns as SVG
|
to change the shapes into polygons, and then writing patterns as SVG
|
||||||
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
|
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
|
||||||
as <use> elements.
|
as <use> elements.
|
||||||
|
|
||||||
Note that this function modifies the Pattern.
|
|
||||||
|
|
||||||
If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
|
If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
|
||||||
is written to the relevant elements.
|
is written to the relevant elements.
|
||||||
|
|
||||||
|
|
@ -71,19 +74,21 @@ def writefile(
|
||||||
prior to calling this function.
|
prior to calling this function.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pattern: Pattern to write to file. Modified by this function.
|
library: Mapping of pattern names to patterns.
|
||||||
|
top: Name of the top-level pattern to render.
|
||||||
filename: Filename to write to.
|
filename: Filename to write to.
|
||||||
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
||||||
SVG elements.
|
SVG elements.
|
||||||
annotate_ports: If True, draw an arrow for each port (similar to
|
annotate_ports: If True, draw an arrow for each port (similar to
|
||||||
`Pattern.visualize(..., ports=True)`).
|
`Pattern.visualize(..., ports=True)`).
|
||||||
"""
|
"""
|
||||||
pattern = library[top]
|
detached = _detached_library(library)
|
||||||
|
pattern = detached[top]
|
||||||
|
|
||||||
# Polygonize pattern
|
# Polygonize pattern
|
||||||
pattern.polygonize()
|
pattern.polygonize()
|
||||||
|
|
||||||
bounds = pattern.get_bounds(library=library)
|
bounds = pattern.get_bounds(library=detached)
|
||||||
if bounds is None:
|
if bounds is None:
|
||||||
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
||||||
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
||||||
|
|
@ -96,10 +101,10 @@ def writefile(
|
||||||
# Create file
|
# Create file
|
||||||
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
|
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
|
||||||
debug=(not custom_attributes))
|
debug=(not custom_attributes))
|
||||||
svg_ids = _make_svg_ids(library)
|
svg_ids = _make_svg_ids(detached)
|
||||||
|
|
||||||
# Now create a group for each pattern and add in any Boundary and Use elements
|
# Now create a group for each pattern and add in any Boundary and Use elements
|
||||||
for name, pat in library.items():
|
for name, pat in detached.items():
|
||||||
svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red')
|
svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red')
|
||||||
|
|
||||||
for layer, shapes in pat.shapes.items():
|
for layer, shapes in pat.shapes.items():
|
||||||
|
|
@ -158,21 +163,21 @@ def writefile_inverted(
|
||||||
box and drawing the polygons with reverse vertex order inside it, all within
|
box and drawing the polygons with reverse vertex order inside it, all within
|
||||||
one `<path>` element.
|
one `<path>` element.
|
||||||
|
|
||||||
Note that this function modifies the Pattern.
|
|
||||||
|
|
||||||
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
||||||
prior to calling this function.
|
prior to calling this function.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pattern: Pattern to write to file. Modified by this function.
|
library: Mapping of pattern names to patterns.
|
||||||
|
top: Name of the top-level pattern to render.
|
||||||
filename: Filename to write to.
|
filename: Filename to write to.
|
||||||
"""
|
"""
|
||||||
pattern = library[top]
|
detached = _detached_library(library)
|
||||||
|
pattern = detached[top]
|
||||||
|
|
||||||
# Polygonize and flatten pattern
|
# Polygonize and flatten pattern
|
||||||
pattern.polygonize().flatten(library)
|
pattern.polygonize().flatten(detached)
|
||||||
|
|
||||||
bounds = pattern.get_bounds(library=library)
|
bounds = pattern.get_bounds(library=detached)
|
||||||
if bounds is None:
|
if bounds is None:
|
||||||
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
||||||
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,22 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations if annotations is not None else {}
|
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:
|
def __copy__(self) -> Self:
|
||||||
return type(self)(
|
return type(self)(
|
||||||
string=self.string,
|
string=self.string,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ Classes include:
|
||||||
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
|
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
|
||||||
library. Generated with `ILibraryView.abstract_view()`.
|
library. Generated with `ILibraryView.abstract_view()`.
|
||||||
"""
|
"""
|
||||||
from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal
|
from typing import Self, TYPE_CHECKING, Any, cast, TypeAlias, Protocol, Literal
|
||||||
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
|
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
@ -22,12 +22,14 @@ import copy
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
from graphlib import TopologicalSorter, CycleError
|
from graphlib import TopologicalSorter, CycleError
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
from .error import LibraryError, PatternError
|
from .error import BuildError, LibraryError, PatternError
|
||||||
from .utils import layer_t, apply_transforms
|
from .utils import layer_t, apply_transforms
|
||||||
from .shapes import Shape, Polygon
|
from .shapes import Shape, Polygon
|
||||||
from .label import Label
|
from .label import Label
|
||||||
|
|
@ -40,6 +42,11 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ACTIVE_BUILD_SESSIONS: ContextVar[dict[int, '_BuildSessionLibrary'] | None] = ContextVar(
|
||||||
|
'masque_active_build_sessions',
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class visitor_function_t(Protocol):
|
class visitor_function_t(Protocol):
|
||||||
""" Signature for `Library.dfs()` visitor functions. """
|
""" Signature for `Library.dfs()` visitor functions. """
|
||||||
|
|
@ -62,6 +69,69 @@ Tree: TypeAlias = MutableMapping[str, 'Pattern']
|
||||||
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
|
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
|
||||||
""" How helpers should handle refs whose targets are not present in the library. """
|
""" How helpers should handle refs whose targets are not present in the library. """
|
||||||
|
|
||||||
|
emitted_via_t: TypeAlias = Literal['declaration', 'helper_write', 'tree_merge', 'source_import']
|
||||||
|
""" Build-provenance origin tags for emitted cells. """
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CellProvenance:
|
||||||
|
"""
|
||||||
|
Provenance record for one cell in a completed build output.
|
||||||
|
|
||||||
|
Each output name in a `BuildReport` maps to one `CellProvenance`. The
|
||||||
|
record captures both where the cell came from and how its visible name was
|
||||||
|
chosen.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
final_name: Name exposed by the completed library.
|
||||||
|
requested_name: First name requested for this cell during the build.
|
||||||
|
kind: Whether the cell came from a declaration, helper emission, or an
|
||||||
|
imported source library.
|
||||||
|
owner_declared_name: Declared cell responsible for this output cell, if
|
||||||
|
any. Imported source cells leave this as `None`.
|
||||||
|
emitted_via: High-level path by which the cell entered the output.
|
||||||
|
build_chain: Declared-cell dependency chain that was active when the
|
||||||
|
cell was emitted.
|
||||||
|
renamed_from: Original requested name when the final name differs.
|
||||||
|
source_name: Original on-source name for imported cells.
|
||||||
|
source_metadata: Optional source-library metadata copied through from
|
||||||
|
lazy GDS readers.
|
||||||
|
"""
|
||||||
|
final_name: str
|
||||||
|
requested_name: str
|
||||||
|
kind: Literal['declared', 'helper', 'source']
|
||||||
|
owner_declared_name: str | None
|
||||||
|
emitted_via: emitted_via_t
|
||||||
|
build_chain: tuple[str, ...]
|
||||||
|
renamed_from: str | None = None
|
||||||
|
source_name: str | None = None
|
||||||
|
source_metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuildReport:
|
||||||
|
"""
|
||||||
|
Immutable summary of one `BuildLibrary.validate()` or `.build()` run.
|
||||||
|
|
||||||
|
The report is designed to answer two questions after a build completes:
|
||||||
|
which declared cells depended on which other declared cells, and where each
|
||||||
|
output cell came from.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
requested_roots: Roots explicitly requested for the run. A full
|
||||||
|
`build()` uses all declared cells.
|
||||||
|
provenance: Mapping from final output name to provenance metadata.
|
||||||
|
owned_cells: Mapping from declared cell name to all final output cell
|
||||||
|
names it owns, including helper cells emitted while that declared
|
||||||
|
cell was building.
|
||||||
|
dependency_graph: Declared-cell dependency graph discovered through
|
||||||
|
library-mediated reads and explicit recipe hints.
|
||||||
|
"""
|
||||||
|
requested_roots: tuple[str, ...]
|
||||||
|
provenance: Mapping[str, CellProvenance]
|
||||||
|
owned_cells: Mapping[str, tuple[str, ...]]
|
||||||
|
dependency_graph: Mapping[str, frozenset[str]]
|
||||||
|
|
||||||
|
|
||||||
SINGLE_USE_PREFIX = '_'
|
SINGLE_USE_PREFIX = '_'
|
||||||
"""
|
"""
|
||||||
|
|
@ -131,6 +201,15 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
return Abstract(name=name, ports=self[name].ports)
|
return Abstract(name=name, ports=self[name].ports)
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
"""
|
||||||
|
Return names in the library's preferred source order.
|
||||||
|
|
||||||
|
Source-backed views may override this to preserve on-disk ordering
|
||||||
|
without materializing patterns.
|
||||||
|
"""
|
||||||
|
return tuple(self.keys())
|
||||||
|
|
||||||
def dangling_refs(
|
def dangling_refs(
|
||||||
self,
|
self,
|
||||||
tops: str | Sequence[str] | None = None,
|
tops: str | Sequence[str] | None = None,
|
||||||
|
|
@ -1388,6 +1467,819 @@ class Library(ILibrary):
|
||||||
return tree, pat
|
return tree, pat
|
||||||
|
|
||||||
|
|
||||||
|
class BuiltLibrary(Library):
|
||||||
|
"""
|
||||||
|
Eager library returned by `BuildLibrary.build(output='library')`.
|
||||||
|
|
||||||
|
This is a normal materialized `Library` with one additional attribute,
|
||||||
|
`build_report`, which records how the library was assembled from
|
||||||
|
declarations, helper emissions, and imported source-backed cells.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
mapping: MutableMapping[str, 'Pattern'] | None = None,
|
||||||
|
*,
|
||||||
|
build_report: BuildReport | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(mapping=mapping)
|
||||||
|
self.build_report = build_report
|
||||||
|
|
||||||
|
|
||||||
|
class _CellFactory:
|
||||||
|
"""
|
||||||
|
Adapter that turns a plain pattern factory into a deferred recipe factory.
|
||||||
|
|
||||||
|
Calling the wrapper captures arguments and returns a `_BuildRecipe`
|
||||||
|
instead of executing the function immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, func: Callable[..., 'Pattern']) -> None:
|
||||||
|
self.func = func
|
||||||
|
self.__name__ = getattr(func, '__name__', type(self).__name__)
|
||||||
|
self.__doc__ = getattr(func, '__doc__')
|
||||||
|
|
||||||
|
def __call__(self, *args: Any, **kwargs: Any) -> '_BuildRecipe':
|
||||||
|
return _BuildRecipe(func=self.func, args=args, kwargs=kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _BuildRecipe:
|
||||||
|
""" Captured deferred call to a pattern factory. """
|
||||||
|
func: Callable[..., 'Pattern']
|
||||||
|
args: tuple[Any, ...]
|
||||||
|
kwargs: dict[str, Any]
|
||||||
|
explicit_dependencies: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def depends_on(self, *names: str) -> '_BuildRecipe':
|
||||||
|
self.explicit_dependencies += tuple(names)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PatternDeclaration:
|
||||||
|
""" Declared cell backed by an already-built `Pattern`. """
|
||||||
|
pattern: 'Pattern'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _RecipeDeclaration:
|
||||||
|
""" Declared cell backed by a deferred recipe. """
|
||||||
|
recipe: _BuildRecipe
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SourceDeclaration:
|
||||||
|
"""
|
||||||
|
Imported source-backed names registered with a `BuildLibrary`.
|
||||||
|
|
||||||
|
The declaration stores visible-name remapping plus pre-scanned graph
|
||||||
|
metadata. Underlying source cells stay lazy until a build session
|
||||||
|
materializes or copies them through.
|
||||||
|
"""
|
||||||
|
library: ILibraryView
|
||||||
|
source_to_visible: Mapping[str, str]
|
||||||
|
visible_to_source: Mapping[str, str]
|
||||||
|
child_graph: Mapping[str, set[str]]
|
||||||
|
order: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def cell(func: Callable[..., 'Pattern']) -> _CellFactory:
|
||||||
|
"""
|
||||||
|
Wrap a plain pattern factory so calls return deferred build recipes.
|
||||||
|
|
||||||
|
Use as either `cell(fn)(...)` or `@cell`.
|
||||||
|
"""
|
||||||
|
return _CellFactory(func)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildCellsView:
|
||||||
|
"""
|
||||||
|
Attribute-based declaration namespace for `BuildLibrary`.
|
||||||
|
|
||||||
|
This is the ergonomic authoring surface exposed as `builder.cells`. It is
|
||||||
|
intentionally write-focused: attribute assignment and deletion register
|
||||||
|
declarations, while attribute reads fail with guidance to build first and
|
||||||
|
use the returned library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, library: 'BuildLibrary') -> None:
|
||||||
|
object.__setattr__(self, '_library', library)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> 'Pattern':
|
||||||
|
raise BuildError(
|
||||||
|
f'BuildLibrary.cells.{name} is write-only during authoring. '
|
||||||
|
'Call build() and index the returned library instead.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __setattr__(self, name: str, value: 'Pattern | _BuildRecipe') -> None:
|
||||||
|
if name.startswith('_'):
|
||||||
|
object.__setattr__(self, name, value)
|
||||||
|
return
|
||||||
|
self._library[name] = value
|
||||||
|
|
||||||
|
def __delattr__(self, name: str) -> None:
|
||||||
|
if name.startswith('_'):
|
||||||
|
raise AttributeError(name)
|
||||||
|
del self._library[name]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildLibrary(ILibrary):
|
||||||
|
"""
|
||||||
|
Two-phase declaration surface for mixed imported/generated libraries.
|
||||||
|
|
||||||
|
A `BuildLibrary` collects three kinds of inputs:
|
||||||
|
- direct declared `Pattern` objects
|
||||||
|
- deferred recipes created with `cell(...)`
|
||||||
|
- imported source-backed library views added with `add_source(...)`
|
||||||
|
|
||||||
|
The builder itself is not a normal readable library during authoring.
|
||||||
|
Instead, `validate()` and `build()` create a temporary build-session library
|
||||||
|
that recipes can read from and write helper cells into while dependencies
|
||||||
|
are resolved. `build()` then freezes the builder on success and returns a
|
||||||
|
normal library-like object carrying a `build_report`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, check_on_register: bool = False) -> None:
|
||||||
|
self.check_on_register = check_on_register
|
||||||
|
self.cells = BuildCellsView(self)
|
||||||
|
self.last_build_report: BuildReport | None = None
|
||||||
|
self._frozen = False
|
||||||
|
self._declarations: dict[str, _PatternDeclaration | _RecipeDeclaration] = {}
|
||||||
|
self._sources: list[_SourceDeclaration] = []
|
||||||
|
self._names: set[str] = set()
|
||||||
|
self._order: list[str] = []
|
||||||
|
|
||||||
|
def _active_session(self) -> '_BuildSessionLibrary | None':
|
||||||
|
sessions = _ACTIVE_BUILD_SESSIONS.get()
|
||||||
|
if sessions is None:
|
||||||
|
return None
|
||||||
|
return sessions.get(id(self))
|
||||||
|
|
||||||
|
def _require_active_session(self, operation: str) -> '_BuildSessionLibrary':
|
||||||
|
session = self._active_session()
|
||||||
|
if session is None:
|
||||||
|
raise BuildError(
|
||||||
|
f'BuildLibrary.{operation}() is only available while validate() or build() is running. '
|
||||||
|
'Use the built output library for reads.'
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _assert_editable(self) -> None:
|
||||||
|
if self._frozen:
|
||||||
|
raise BuildError('This BuildLibrary has already been built successfully and is now frozen.')
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
return iter(session)
|
||||||
|
return iter(self._order)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
return len(session)
|
||||||
|
return len(self._names)
|
||||||
|
|
||||||
|
def __contains__(self, key: object) -> bool:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
return key in session
|
||||||
|
return key in self._names
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> 'Pattern':
|
||||||
|
return self._require_active_session('__getitem__')[key]
|
||||||
|
|
||||||
|
def __setitem__(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: 'Pattern | _BuildRecipe | Callable[[], Pattern]',
|
||||||
|
) -> None:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
session[key] = value
|
||||||
|
return
|
||||||
|
|
||||||
|
self._assert_editable()
|
||||||
|
if key in self._names:
|
||||||
|
raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!')
|
||||||
|
|
||||||
|
declaration: _PatternDeclaration | _RecipeDeclaration
|
||||||
|
if isinstance(value, _BuildRecipe):
|
||||||
|
declaration = _RecipeDeclaration(value)
|
||||||
|
else:
|
||||||
|
if callable(value):
|
||||||
|
raise TypeError('BuildLibrary recipes must be wrapped with cell(fn)(...) or @cell.')
|
||||||
|
declaration = _PatternDeclaration(value)
|
||||||
|
|
||||||
|
self._declarations[key] = declaration
|
||||||
|
self._names.add(key)
|
||||||
|
self._order.append(key)
|
||||||
|
|
||||||
|
if self.check_on_register:
|
||||||
|
try:
|
||||||
|
self.validate(names=(key,))
|
||||||
|
except Exception:
|
||||||
|
del self._declarations[key]
|
||||||
|
self._names.remove(key)
|
||||||
|
self._order.remove(key)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
del session[key]
|
||||||
|
return
|
||||||
|
|
||||||
|
self._assert_editable()
|
||||||
|
if key not in self._declarations:
|
||||||
|
raise KeyError(key)
|
||||||
|
del self._declarations[key]
|
||||||
|
self._names.remove(key)
|
||||||
|
self._order.remove(key)
|
||||||
|
|
||||||
|
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
session._merge(key_self, other, key_other)
|
||||||
|
return
|
||||||
|
self[key_self] = copy.deepcopy(other[key_other])
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self,
|
||||||
|
other: Mapping[str, 'Pattern'],
|
||||||
|
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
|
||||||
|
mutate_other: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
return session.add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
|
||||||
|
return super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
|
||||||
|
|
||||||
|
def rename(
|
||||||
|
self,
|
||||||
|
old_name: str,
|
||||||
|
new_name: str,
|
||||||
|
move_references: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Rename an imported source-backed visible name during authoring.
|
||||||
|
|
||||||
|
Only imported source-backed cells may be renamed on the builder itself.
|
||||||
|
Declared/generated cells must be registered under their intended final
|
||||||
|
names. `move_references=True` is intentionally unsupported here because
|
||||||
|
deferred recipes and declaration internals cannot be rewritten safely.
|
||||||
|
"""
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
session.rename(old_name, new_name, move_references=move_references)
|
||||||
|
return self
|
||||||
|
|
||||||
|
self._assert_editable()
|
||||||
|
if old_name == new_name:
|
||||||
|
return self
|
||||||
|
if old_name in self._declarations:
|
||||||
|
raise BuildError(
|
||||||
|
f'Cannot rename declared build cell "{old_name}" during authoring. '
|
||||||
|
'Register it under the intended final name instead.'
|
||||||
|
)
|
||||||
|
if old_name not in self._names:
|
||||||
|
raise LibraryError(f'"{old_name}" does not exist in the builder.')
|
||||||
|
if new_name in self._names:
|
||||||
|
raise LibraryError(f'"{new_name}" already exists in the builder.')
|
||||||
|
if move_references:
|
||||||
|
raise BuildError(
|
||||||
|
'BuildLibrary.rename(..., move_references=True) is not supported for imported source cells. '
|
||||||
|
'Builder-level renames only change the visible imported name.'
|
||||||
|
)
|
||||||
|
|
||||||
|
source_index = next(
|
||||||
|
(idx for idx, spec in enumerate(self._sources) if old_name in spec.visible_to_source),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if source_index is None:
|
||||||
|
raise BuildError(
|
||||||
|
f'Cannot rename "{old_name}" during authoring because only imported source-backed '
|
||||||
|
'cells may be renamed on a BuildLibrary.'
|
||||||
|
)
|
||||||
|
|
||||||
|
spec = self._sources[source_index]
|
||||||
|
source_name = spec.visible_to_source[old_name]
|
||||||
|
source_to_visible = dict(spec.source_to_visible)
|
||||||
|
visible_to_source = dict(spec.visible_to_source)
|
||||||
|
order = list(spec.order)
|
||||||
|
|
||||||
|
source_to_visible[source_name] = new_name
|
||||||
|
del visible_to_source[old_name]
|
||||||
|
visible_to_source[new_name] = source_name
|
||||||
|
order[order.index(old_name)] = new_name
|
||||||
|
|
||||||
|
self._sources[source_index] = replace(
|
||||||
|
spec,
|
||||||
|
source_to_visible=source_to_visible,
|
||||||
|
visible_to_source=visible_to_source,
|
||||||
|
order=tuple(order),
|
||||||
|
)
|
||||||
|
self._names.remove(old_name)
|
||||||
|
self._names.add(new_name)
|
||||||
|
self._order[self._order.index(old_name)] = new_name
|
||||||
|
return self
|
||||||
|
|
||||||
|
def abstract(self, name: str) -> Abstract:
|
||||||
|
return self._require_active_session('abstract').abstract(name)
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
other: 'Abstract | str | Pattern | TreeView',
|
||||||
|
append: bool = False,
|
||||||
|
) -> 'Abstract | Pattern':
|
||||||
|
return self._require_active_session('resolve').resolve(other, append=append)
|
||||||
|
|
||||||
|
def add_source(
|
||||||
|
self,
|
||||||
|
source: Mapping[str, 'Pattern'] | ILibraryView,
|
||||||
|
*,
|
||||||
|
rename_theirs: Callable[[ILibraryView, str], str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Register an imported source-backed library with the builder.
|
||||||
|
|
||||||
|
The source is not materialized immediately. Instead, its names and
|
||||||
|
child graph are scanned once and stored as an import declaration. The
|
||||||
|
source may be renamed on entry to avoid collisions with existing
|
||||||
|
declarations or other imported sources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mapping of `{source_name: visible_name}` for imported names that
|
||||||
|
were renamed while being added.
|
||||||
|
"""
|
||||||
|
self._assert_editable()
|
||||||
|
|
||||||
|
view = source if isinstance(source, ILibraryView) else LibraryView(source)
|
||||||
|
source_order = tuple(view.source_order())
|
||||||
|
child_graph = view.child_graph(dangling='include')
|
||||||
|
|
||||||
|
source_to_visible: dict[str, str] = {}
|
||||||
|
visible_to_source: dict[str, str] = {}
|
||||||
|
rename_map: dict[str, str] = {}
|
||||||
|
new_names: list[str] = []
|
||||||
|
|
||||||
|
for name in source_order:
|
||||||
|
visible = name
|
||||||
|
if visible in self._names or visible in visible_to_source:
|
||||||
|
if rename_theirs is None:
|
||||||
|
raise LibraryError(f'Conflicting name while adding source: {name!r}')
|
||||||
|
visible = rename_theirs(self, name)
|
||||||
|
if visible in self._names or visible in visible_to_source:
|
||||||
|
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
|
||||||
|
rename_map[name] = visible
|
||||||
|
source_to_visible[name] = visible
|
||||||
|
visible_to_source[visible] = name
|
||||||
|
new_names.append(visible)
|
||||||
|
|
||||||
|
self._sources.append(_SourceDeclaration(
|
||||||
|
library=view,
|
||||||
|
source_to_visible=dict(source_to_visible),
|
||||||
|
visible_to_source=dict(visible_to_source),
|
||||||
|
child_graph={name: set(children) for name, children in child_graph.items()},
|
||||||
|
order=tuple(source_to_visible[name] for name in source_order),
|
||||||
|
))
|
||||||
|
for visible in new_names:
|
||||||
|
self._names.add(visible)
|
||||||
|
self._order.append(visible)
|
||||||
|
return rename_map
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
names: Sequence[str] | None = None,
|
||||||
|
*,
|
||||||
|
allow_dangling: bool = False,
|
||||||
|
) -> BuildReport:
|
||||||
|
"""
|
||||||
|
Run the full build logic and return a `BuildReport` without producing output.
|
||||||
|
|
||||||
|
This is a dry run over the same dependency resolution and recipe
|
||||||
|
execution path used by `build()`. Any generated library is discarded
|
||||||
|
after validation completes.
|
||||||
|
"""
|
||||||
|
report, _output = self._run_build(names=names, output='overlay', allow_dangling=allow_dangling, persist_output=False)
|
||||||
|
self.last_build_report = report
|
||||||
|
return report
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
output: Literal['overlay', 'library'] = 'overlay',
|
||||||
|
allow_dangling: bool = False,
|
||||||
|
) -> 'BuiltLibrary | ILibrary':
|
||||||
|
"""
|
||||||
|
Materialize declarations and return a usable output library.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output: `'overlay'` preserves imported source-backed cells where
|
||||||
|
possible, while `'library'` eagerly materializes the full
|
||||||
|
result.
|
||||||
|
allow_dangling: If `False`, fail the build when the completed
|
||||||
|
library still contains dangling references.
|
||||||
|
"""
|
||||||
|
self._assert_editable()
|
||||||
|
report, built_output = self._run_build(names=None, output=output, allow_dangling=allow_dangling, persist_output=True)
|
||||||
|
self._frozen = True
|
||||||
|
self.last_build_report = report
|
||||||
|
return built_output
|
||||||
|
|
||||||
|
def _run_build(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
names: Sequence[str] | None,
|
||||||
|
output: Literal['overlay', 'library'],
|
||||||
|
allow_dangling: bool,
|
||||||
|
persist_output: bool,
|
||||||
|
) -> tuple[BuildReport, BuiltLibrary | ILibrary | None]:
|
||||||
|
roots = tuple(dict.fromkeys(names if names is not None else self._declarations.keys()))
|
||||||
|
unknown = [name for name in roots if name not in self._names]
|
||||||
|
if unknown:
|
||||||
|
raise BuildError(f'Unknown build roots requested: {unknown}')
|
||||||
|
|
||||||
|
session = _BuildSessionLibrary(self)
|
||||||
|
sessions = dict(_ACTIVE_BUILD_SESSIONS.get() or {})
|
||||||
|
sessions[id(self)] = session
|
||||||
|
token = _ACTIVE_BUILD_SESSIONS.set(sessions)
|
||||||
|
try:
|
||||||
|
session.materialize_many(roots)
|
||||||
|
if not allow_dangling:
|
||||||
|
session.child_graph(dangling='error')
|
||||||
|
if output == 'library':
|
||||||
|
built_output = session.to_library() if persist_output else None
|
||||||
|
elif persist_output:
|
||||||
|
built_output = session.to_overlay()
|
||||||
|
else:
|
||||||
|
built_output = None
|
||||||
|
finally:
|
||||||
|
_ACTIVE_BUILD_SESSIONS.reset(token)
|
||||||
|
|
||||||
|
report = session.build_report(roots)
|
||||||
|
if built_output is not None:
|
||||||
|
built_output.build_report = report
|
||||||
|
return report, built_output
|
||||||
|
|
||||||
|
|
||||||
|
class _BuildSessionLibrary(ILibrary):
|
||||||
|
"""
|
||||||
|
Internal overlay-backed library used while a `BuildLibrary` is executing.
|
||||||
|
|
||||||
|
This object provides the mutable-library surface that recipes expect while
|
||||||
|
also tracking declared-cell dependencies, helper-cell provenance, and
|
||||||
|
imported source cells. It exists only for the duration of a validation or
|
||||||
|
build run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, builder: BuildLibrary) -> None:
|
||||||
|
from .file.gdsii_lazy_core import BuiltOverlayLibrary, _SourceEntry, _SourceLayer # noqa: PLC0415
|
||||||
|
|
||||||
|
self._builder = builder
|
||||||
|
self._overlay = BuiltOverlayLibrary()
|
||||||
|
self._source_entry_type = _SourceEntry
|
||||||
|
self._source_layer_type = _SourceLayer
|
||||||
|
self._states: dict[str, Literal['unbuilt', 'building', 'built']] = {
|
||||||
|
name: 'unbuilt' for name in builder._declarations
|
||||||
|
}
|
||||||
|
self._declared_stack: list[str] = []
|
||||||
|
self._emission_stack: list[str] = []
|
||||||
|
self._emission_via_stack: list[emitted_via_t] = []
|
||||||
|
self._names = set(builder._names)
|
||||||
|
self._order = list(builder._order)
|
||||||
|
self._provenance: dict[str, CellProvenance] = {}
|
||||||
|
self._owned_cells: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set)
|
||||||
|
self._install_sources()
|
||||||
|
|
||||||
|
def _install_sources(self) -> None:
|
||||||
|
for spec in self._builder._sources:
|
||||||
|
layer = self._source_layer_type(
|
||||||
|
library=spec.library,
|
||||||
|
source_to_visible=dict(spec.source_to_visible),
|
||||||
|
visible_to_source=dict(spec.visible_to_source),
|
||||||
|
child_graph={name: set(children) for name, children in spec.child_graph.items()},
|
||||||
|
order=list(spec.order),
|
||||||
|
)
|
||||||
|
layer_index = len(self._overlay._layers)
|
||||||
|
self._overlay._layers.append(layer)
|
||||||
|
source_info = getattr(spec.library, 'library_info', None)
|
||||||
|
source_meta = dict(source_info) if isinstance(source_info, dict) else None
|
||||||
|
|
||||||
|
for source_name, visible_name in spec.source_to_visible.items():
|
||||||
|
self._overlay._entries[visible_name] = self._source_entry_type(
|
||||||
|
layer_index=layer_index,
|
||||||
|
source_name=source_name,
|
||||||
|
)
|
||||||
|
if visible_name not in self._overlay._order:
|
||||||
|
self._overlay._order.append(visible_name)
|
||||||
|
self._provenance[visible_name] = CellProvenance(
|
||||||
|
final_name=visible_name,
|
||||||
|
requested_name=source_name,
|
||||||
|
kind='source',
|
||||||
|
owner_declared_name=None,
|
||||||
|
emitted_via='source_import',
|
||||||
|
build_chain=(),
|
||||||
|
renamed_from=source_name if visible_name != source_name else None,
|
||||||
|
source_name=source_name,
|
||||||
|
source_metadata=source_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
return (name for name in self._order if name in self._names)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._names)
|
||||||
|
|
||||||
|
def __contains__(self, key: object) -> bool:
|
||||||
|
return key in self._names or key in self._overlay
|
||||||
|
|
||||||
|
def _touch_name(self, key: str) -> None:
|
||||||
|
if key not in self._names:
|
||||||
|
self._names.add(key)
|
||||||
|
self._order.append(key)
|
||||||
|
|
||||||
|
def _current_declared(self) -> str | None:
|
||||||
|
if not self._declared_stack:
|
||||||
|
return None
|
||||||
|
return self._declared_stack[-1]
|
||||||
|
|
||||||
|
def _record_dependency(self, target: str) -> None:
|
||||||
|
current = self._current_declared()
|
||||||
|
if current is None or current == target or target not in self._builder._declarations:
|
||||||
|
return
|
||||||
|
self._dependency_graph[current].add(target)
|
||||||
|
|
||||||
|
def _guard_mutable_output_name(self, key: str, *, operation: str) -> None:
|
||||||
|
if key in self._builder._declarations:
|
||||||
|
raise BuildError(f'Cannot {operation} declared build cell "{key}" during an active build session.')
|
||||||
|
|
||||||
|
provenance = self._provenance.get(key)
|
||||||
|
if provenance is not None and provenance.kind == 'source':
|
||||||
|
raise BuildError(f'Cannot {operation} imported source cell "{key}" during an active build session.')
|
||||||
|
|
||||||
|
def _remove_owned_cell(self, owner: str | None, name: str) -> None:
|
||||||
|
if owner is None or owner not in self._owned_cells:
|
||||||
|
return
|
||||||
|
cells = self._owned_cells[owner]
|
||||||
|
self._owned_cells[owner] = [cell for cell in cells if cell != name]
|
||||||
|
if not self._owned_cells[owner]:
|
||||||
|
del self._owned_cells[owner]
|
||||||
|
|
||||||
|
def rename(
|
||||||
|
self,
|
||||||
|
old_name: str,
|
||||||
|
new_name: str,
|
||||||
|
move_references: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
if old_name == new_name:
|
||||||
|
return self
|
||||||
|
if old_name not in self._overlay:
|
||||||
|
if old_name in self._builder._declarations:
|
||||||
|
self._guard_mutable_output_name(old_name, operation='rename')
|
||||||
|
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
||||||
|
|
||||||
|
self._guard_mutable_output_name(old_name, operation='rename')
|
||||||
|
if new_name in self._names:
|
||||||
|
raise LibraryError(f'"{new_name}" already exists in the library.')
|
||||||
|
|
||||||
|
self._overlay.rename(old_name, new_name, move_references=move_references)
|
||||||
|
self._names.discard(old_name)
|
||||||
|
self._names.add(new_name)
|
||||||
|
if old_name in self._order:
|
||||||
|
idx = self._order.index(old_name)
|
||||||
|
self._order[idx] = new_name
|
||||||
|
|
||||||
|
provenance = self._provenance.pop(old_name)
|
||||||
|
requested_name = provenance.requested_name
|
||||||
|
self._provenance[new_name] = replace(
|
||||||
|
provenance,
|
||||||
|
final_name=new_name,
|
||||||
|
renamed_from=requested_name if new_name != requested_name else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = provenance.owner_declared_name
|
||||||
|
if owner is not None and owner in self._owned_cells:
|
||||||
|
self._owned_cells[owner] = [
|
||||||
|
new_name if cell_name == old_name else cell_name
|
||||||
|
for cell_name in self._owned_cells[owner]
|
||||||
|
]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> 'Pattern':
|
||||||
|
if key in self._builder._declarations:
|
||||||
|
self._record_dependency(key)
|
||||||
|
self._ensure_declared(key)
|
||||||
|
return self._overlay[key]
|
||||||
|
|
||||||
|
def __setitem__(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: 'Pattern | Callable[[], Pattern]',
|
||||||
|
) -> None:
|
||||||
|
if key in self._overlay:
|
||||||
|
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||||
|
current = self._current_declared()
|
||||||
|
if key in self._builder._declarations and key != current:
|
||||||
|
raise LibraryError(f'"{key}" is reserved for a declared cell and cannot be used as a helper name.')
|
||||||
|
|
||||||
|
pattern = value() if callable(value) else value
|
||||||
|
self._overlay[key] = pattern
|
||||||
|
self._touch_name(key)
|
||||||
|
|
||||||
|
kind: Literal['declared', 'helper']
|
||||||
|
via = self._emission_via_stack[-1] if self._emission_via_stack else 'helper_write'
|
||||||
|
if current is not None and key == current:
|
||||||
|
kind = 'declared'
|
||||||
|
via = 'declaration'
|
||||||
|
else:
|
||||||
|
kind = 'helper'
|
||||||
|
if not self._emission_via_stack:
|
||||||
|
via = 'helper_write'
|
||||||
|
|
||||||
|
self._emission_stack.append(key)
|
||||||
|
try:
|
||||||
|
self._record_provenance(
|
||||||
|
final_name=key,
|
||||||
|
requested_name=key,
|
||||||
|
kind=kind,
|
||||||
|
owner_declared_name=current if kind == 'helper' else key,
|
||||||
|
emitted_via=via,
|
||||||
|
build_chain=tuple(self._declared_stack),
|
||||||
|
renamed_from=None,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._emission_stack.pop()
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
if key not in self._overlay:
|
||||||
|
if key in self._builder._declarations:
|
||||||
|
self._guard_mutable_output_name(key, operation='delete')
|
||||||
|
raise KeyError(key)
|
||||||
|
|
||||||
|
self._guard_mutable_output_name(key, operation='delete')
|
||||||
|
provenance = self._provenance.get(key)
|
||||||
|
if key in self._overlay:
|
||||||
|
del self._overlay[key]
|
||||||
|
self._names.discard(key)
|
||||||
|
if key in self._order:
|
||||||
|
self._order.remove(key)
|
||||||
|
self._provenance.pop(key, None)
|
||||||
|
if provenance is not None:
|
||||||
|
self._remove_owned_cell(provenance.owner_declared_name, 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(
|
||||||
|
self,
|
||||||
|
other: Mapping[str, 'Pattern'],
|
||||||
|
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
|
||||||
|
mutate_other: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
self._emission_via_stack.append('tree_merge')
|
||||||
|
try:
|
||||||
|
rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
|
||||||
|
finally:
|
||||||
|
self._emission_via_stack.pop()
|
||||||
|
|
||||||
|
current = self._current_declared()
|
||||||
|
for old_name, new_name in rename_map.items():
|
||||||
|
if new_name in self._provenance:
|
||||||
|
self._provenance[new_name] = replace(
|
||||||
|
self._provenance[new_name],
|
||||||
|
requested_name=old_name,
|
||||||
|
renamed_from=old_name,
|
||||||
|
owner_declared_name=current if current is not None else self._provenance[new_name].owner_declared_name,
|
||||||
|
)
|
||||||
|
return rename_map
|
||||||
|
|
||||||
|
def _record_provenance(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
final_name: str,
|
||||||
|
requested_name: str,
|
||||||
|
kind: Literal['declared', 'helper'],
|
||||||
|
owner_declared_name: str | None,
|
||||||
|
emitted_via: emitted_via_t,
|
||||||
|
build_chain: tuple[str, ...],
|
||||||
|
renamed_from: str | None,
|
||||||
|
) -> None:
|
||||||
|
self._provenance[final_name] = CellProvenance(
|
||||||
|
final_name=final_name,
|
||||||
|
requested_name=requested_name,
|
||||||
|
kind=kind,
|
||||||
|
owner_declared_name=owner_declared_name,
|
||||||
|
emitted_via=emitted_via,
|
||||||
|
build_chain=build_chain,
|
||||||
|
renamed_from=renamed_from,
|
||||||
|
)
|
||||||
|
if owner_declared_name is not None and final_name not in self._owned_cells[owner_declared_name]:
|
||||||
|
self._owned_cells[owner_declared_name].append(final_name)
|
||||||
|
|
||||||
|
def _wrap_error(self, name: str, exc: Exception) -> BuildError:
|
||||||
|
helper = self._emission_stack[-1] if self._emission_stack else None
|
||||||
|
chain = tuple(self._declared_stack)
|
||||||
|
msg = [f'Failed while building declared cell "{name}"']
|
||||||
|
if helper is not None and helper != name:
|
||||||
|
msg.append(f'while materializing helper/output "{helper}"')
|
||||||
|
if chain:
|
||||||
|
msg.append(f'Dependency chain: {" -> ".join(chain)}')
|
||||||
|
msg.append(f'Cause: {exc}')
|
||||||
|
return BuildError('\n'.join(msg))
|
||||||
|
|
||||||
|
def _ensure_named(self, name: str) -> None:
|
||||||
|
if name in self._builder._declarations:
|
||||||
|
self._record_dependency(name)
|
||||||
|
self._ensure_declared(name)
|
||||||
|
return
|
||||||
|
if name in self._overlay:
|
||||||
|
return
|
||||||
|
raise BuildError(f'Missing dependency "{name}"')
|
||||||
|
|
||||||
|
def _ensure_declared(self, name: str) -> None:
|
||||||
|
from .pattern import Pattern # noqa: PLC0415
|
||||||
|
|
||||||
|
state = self._states[name]
|
||||||
|
if state == 'built':
|
||||||
|
return
|
||||||
|
if state == 'building':
|
||||||
|
chain = ' -> '.join(self._declared_stack + [name])
|
||||||
|
raise BuildError(f'Cycle detected while building declared cells: {chain}')
|
||||||
|
|
||||||
|
declaration = self._builder._declarations[name]
|
||||||
|
self._states[name] = 'building'
|
||||||
|
self._declared_stack.append(name)
|
||||||
|
try:
|
||||||
|
if isinstance(declaration, _PatternDeclaration):
|
||||||
|
pattern = declaration.pattern.deepcopy()
|
||||||
|
else:
|
||||||
|
for dep in declaration.recipe.explicit_dependencies:
|
||||||
|
self._ensure_named(dep)
|
||||||
|
pattern = declaration.recipe.func(*declaration.recipe.args, **declaration.recipe.kwargs)
|
||||||
|
if not isinstance(pattern, Pattern):
|
||||||
|
raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern')
|
||||||
|
|
||||||
|
if name in self._overlay:
|
||||||
|
if self._overlay[name] is not pattern:
|
||||||
|
raise BuildError(
|
||||||
|
f'Recipe for "{name}" wrote a different pattern into the session under its own name.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self[name] = pattern
|
||||||
|
self._states[name] = 'built'
|
||||||
|
except Exception as exc:
|
||||||
|
self._states[name] = 'unbuilt'
|
||||||
|
raise self._wrap_error(name, exc) from exc
|
||||||
|
finally:
|
||||||
|
self._declared_stack.pop()
|
||||||
|
|
||||||
|
def materialize_many(self, names: Sequence[str]) -> None:
|
||||||
|
for name in dict.fromkeys(names):
|
||||||
|
self._ensure_named(name)
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
return self._overlay.source_order()
|
||||||
|
|
||||||
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
return self._overlay.child_graph(dangling=dangling)
|
||||||
|
|
||||||
|
def parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
return self._overlay.parent_graph(dangling=dangling)
|
||||||
|
|
||||||
|
def build_report(self, requested_roots: Sequence[str]) -> BuildReport:
|
||||||
|
dependency_graph = {
|
||||||
|
name: frozenset(self._dependency_graph.get(name, set()))
|
||||||
|
for name in self._builder._declarations
|
||||||
|
if name in self._dependency_graph or name in requested_roots
|
||||||
|
}
|
||||||
|
owned_cells = {
|
||||||
|
name: tuple(cells)
|
||||||
|
for name, cells in self._owned_cells.items()
|
||||||
|
}
|
||||||
|
return BuildReport(
|
||||||
|
requested_roots=tuple(dict.fromkeys(requested_roots)),
|
||||||
|
provenance=dict(self._provenance),
|
||||||
|
owned_cells=owned_cells,
|
||||||
|
dependency_graph=dependency_graph,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_overlay(self) -> ILibrary:
|
||||||
|
return self._overlay
|
||||||
|
|
||||||
|
def to_library(self) -> BuiltLibrary:
|
||||||
|
mapping = {name: self._overlay[name] for name in self._overlay.source_order()}
|
||||||
|
return BuiltLibrary(mapping)
|
||||||
|
|
||||||
|
|
||||||
class LazyLibrary(ILibrary):
|
class LazyLibrary(ILibrary):
|
||||||
"""
|
"""
|
||||||
This class is usually used to create a library of Patterns by mapping names to
|
This class is usually used to create a library of Patterns by mapping names to
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,26 @@ class Ref(
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations if annotations is not None else {}
|
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':
|
def __copy__(self) -> 'Ref':
|
||||||
new = Ref(
|
new = Ref(
|
||||||
offset=self.offset.copy(),
|
offset=self.offset.copy(),
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,22 @@ class Grid(Repetition):
|
||||||
self.a_count = a_count
|
self.a_count = a_count
|
||||||
self.b_count = b_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
|
@classmethod
|
||||||
def aligned(
|
def aligned(
|
||||||
cls: type[GG],
|
cls: type[GG],
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from .shape import (
|
||||||
|
|
||||||
from .polygon import Polygon as Polygon
|
from .polygon import Polygon as Polygon
|
||||||
from .poly_collection import PolyCollection as PolyCollection
|
from .poly_collection import PolyCollection as PolyCollection
|
||||||
|
from .rect_collection import RectCollection as RectCollection
|
||||||
from .circle import Circle as Circle
|
from .circle import Circle as Circle
|
||||||
from .ellipse import Ellipse as Ellipse
|
from .ellipse import Ellipse as Ellipse
|
||||||
from .arc import Arc as Arc
|
from .arc import Arc as Arc
|
||||||
|
|
|
||||||
|
|
@ -197,21 +197,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
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.radii = radii
|
||||||
self.angles = angles
|
self.angles = angles
|
||||||
self.width = width
|
self.width = width
|
||||||
|
|
@ -221,6 +207,29 @@ class Arc(PositionableImpl, Shape):
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
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,
|
||||||
|
) -> '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
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
|
|
|
||||||
|
|
@ -50,20 +50,28 @@ class Circle(PositionableImpl, Shape):
|
||||||
offset: ArrayLike = (0.0, 0.0),
|
offset: ArrayLike = (0.0, 0.0),
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
|
||||||
assert isinstance(offset, numpy.ndarray)
|
|
||||||
self._radius = radius
|
|
||||||
self._offset = offset
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
else:
|
|
||||||
self.radius = radius
|
self.radius = radius
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
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
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
|
|
|
||||||
|
|
@ -95,23 +95,31 @@ class Ellipse(PositionableImpl, Shape):
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
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.radii = radii
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
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
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
|
|
|
||||||
|
|
@ -201,20 +201,9 @@ class Path(Shape):
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._cap_extensions = None # Since .cap setter might access it
|
self._cap_extensions = None # Since .cap setter might access it
|
||||||
|
|
||||||
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.vertices = vertices
|
self.vertices = vertices
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
|
|
@ -229,6 +218,26 @@ class Path(Shape):
|
||||||
if numpy.any(offset):
|
if numpy.any(offset):
|
||||||
self.translate(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':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Path':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class PolyCollection(Shape):
|
||||||
_vertex_lists: NDArray[numpy.float64]
|
_vertex_lists: NDArray[numpy.float64]
|
||||||
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
|
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
|
||||||
|
|
||||||
_vertex_offsets: NDArray[numpy.intp]
|
_vertex_offsets: NDArray[numpy.integer[Any]]
|
||||||
""" 1D NDArray specifying the starting offset for each polygon """
|
""" 1D NDArray specifying the starting offset for each polygon """
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -45,7 +45,7 @@ class PolyCollection(Shape):
|
||||||
return self._vertex_lists
|
return self._vertex_lists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vertex_offsets(self) -> NDArray[numpy.intp]:
|
def vertex_offsets(self) -> NDArray[numpy.integer[Any]]:
|
||||||
"""
|
"""
|
||||||
Starting offset (in `vertex_lists`) for each polygon
|
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]]),
|
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
|
||||||
strict=True,
|
strict=True,
|
||||||
):
|
):
|
||||||
yield slice(ii, ff)
|
yield slice(int(ii), int(ff))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||||
|
|
@ -100,16 +100,7 @@ class PolyCollection(Shape):
|
||||||
rotation: float = 0.0,
|
rotation: float = 0.0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
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_lists = numpy.asarray(vertex_lists, dtype=float)
|
||||||
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
|
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
|
|
@ -119,6 +110,22 @@ class PolyCollection(Shape):
|
||||||
if numpy.any(offset):
|
if numpy.any(offset):
|
||||||
self.translate(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:
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
|
|
@ -132,7 +139,7 @@ class PolyCollection(Shape):
|
||||||
return (
|
return (
|
||||||
type(self) is type(other)
|
type(self) is type(other)
|
||||||
and numpy.array_equal(self._vertex_lists, other._vertex_lists)
|
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 self.repetition == other.repetition
|
||||||
and annotations_eq(self.annotations, other.annotations)
|
and annotations_eq(self.annotations, other.annotations)
|
||||||
)
|
)
|
||||||
|
|
@ -215,11 +222,11 @@ class PolyCollection(Shape):
|
||||||
|
|
||||||
# TODO: normalize mirroring?
|
# 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),
|
(offset, scale / norm_value, rotation, False),
|
||||||
lambda: PolyCollection(
|
lambda: PolyCollection(
|
||||||
vertex_lists=rotated_vertices * norm_value,
|
vertex_lists=rotated_vertices * norm_value,
|
||||||
vertex_offsets=self._vertex_offsets.copy(),
|
vertex_offsets=self.vertex_offsets.copy(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,14 +115,7 @@ class Polygon(Shape):
|
||||||
rotation: float = 0.0,
|
rotation: float = 0.0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
|
||||||
assert isinstance(vertices, numpy.ndarray)
|
|
||||||
self._vertices = vertices
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
else:
|
|
||||||
self.vertices = vertices
|
self.vertices = vertices
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
|
|
@ -131,6 +124,20 @@ class Polygon(Shape):
|
||||||
if numpy.any(offset):
|
if numpy.any(offset):
|
||||||
self.translate(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':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Polygon':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
|
|
|
||||||
249
masque/shapes/rect_collection.py
Normal file
249
masque/shapes/rect_collection.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
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('RectCollection only supports Manhattan rotations')
|
||||||
|
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,18 +73,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
mirrored: bool = False,
|
mirrored: bool = False,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
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.offset = offset
|
||||||
self.string = string
|
self.string = string
|
||||||
self.height = height
|
self.height = height
|
||||||
|
|
@ -94,6 +83,30 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
self.font_path = font_path
|
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:
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
|
|
|
||||||
315
masque/test/test_build_library.py
Normal file
315
masque/test/test_build_library.py
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ..builder import Pather
|
||||||
|
from ..error import BuildError
|
||||||
|
from ..library import BuildLibrary, BuiltLibrary, Library, cell
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..ports import Port
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_traces_declared_dependencies_out_of_order() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_parent(lib: BuildLibrary) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ref("child")
|
||||||
|
assert lib.abstract("child").name == "child"
|
||||||
|
return pat
|
||||||
|
|
||||||
|
builder.cells.parent = cell(make_parent)(builder)
|
||||||
|
builder["child"] = Pattern(ports={"p": Port((0, 0), 0)})
|
||||||
|
|
||||||
|
built = builder.build()
|
||||||
|
|
||||||
|
assert "parent" in built
|
||||||
|
assert "child" in built
|
||||||
|
assert built.build_report.dependency_graph["parent"] == frozenset({"child"})
|
||||||
|
assert built.build_report.provenance["parent"].kind == "declared"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
tree = Library({"_helper": Pattern()})
|
||||||
|
name_a = lib << tree
|
||||||
|
name_b = lib << tree
|
||||||
|
top = Pattern()
|
||||||
|
top.ref(name_a)
|
||||||
|
top.ref(name_b)
|
||||||
|
return top
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
report = built.build_report
|
||||||
|
|
||||||
|
helpers = [
|
||||||
|
prov for prov in report.provenance.values()
|
||||||
|
if prov.owner_declared_name == "top" and prov.kind == "helper"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert "top" in report.owned_cells["top"]
|
||||||
|
assert len(helpers) == 2
|
||||||
|
assert all(prov.emitted_via == "tree_merge" for prov in helpers)
|
||||||
|
assert any(prov.renamed_from == "_helper" for prov in helpers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_requires_build_session_for_reads_and_freezes_after_build() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["leaf"] = Pattern()
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match="validate\\(\\) or build\\(\\)"):
|
||||||
|
_ = builder["leaf"]
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match="write-only"):
|
||||||
|
_ = builder.cells.leaf
|
||||||
|
|
||||||
|
built = builder.build(output="library")
|
||||||
|
|
||||||
|
assert isinstance(built, BuiltLibrary)
|
||||||
|
assert built.build_report.requested_roots == ("leaf",)
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match="frozen"):
|
||||||
|
builder["later"] = Pattern()
|
||||||
|
with pytest.raises(BuildError, match="frozen"):
|
||||||
|
builder.build()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_validate_is_retryable_after_failure() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_parent(lib: BuildLibrary) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ref("child")
|
||||||
|
lib.abstract("child")
|
||||||
|
return pat
|
||||||
|
|
||||||
|
builder.cells.parent = cell(make_parent)(builder)
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='Failed while building declared cell "parent"'):
|
||||||
|
builder.validate()
|
||||||
|
|
||||||
|
builder["child"] = Pattern(ports={"p": Port((0, 0), 0)})
|
||||||
|
report = builder.validate()
|
||||||
|
|
||||||
|
assert report.dependency_graph["parent"] == frozenset({"child"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_check_on_register_rolls_back_failed_declarations() -> None:
|
||||||
|
builder = BuildLibrary(check_on_register=True)
|
||||||
|
|
||||||
|
def make_parent(lib: BuildLibrary) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ref("child")
|
||||||
|
lib.abstract("child")
|
||||||
|
return pat
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='Failed while building declared cell "parent"'):
|
||||||
|
builder.cells.parent = cell(make_parent)(builder)
|
||||||
|
|
||||||
|
assert "parent" not in builder
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["child"] = Pattern()
|
||||||
|
|
||||||
|
def make_parent() -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ref("child")
|
||||||
|
return pat
|
||||||
|
|
||||||
|
builder.cells.parent = cell(make_parent)().depends_on("child")
|
||||||
|
report = builder.validate(names=("parent",))
|
||||||
|
|
||||||
|
assert report.requested_roots == ("parent",)
|
||||||
|
assert report.dependency_graph["parent"] == frozenset({"child"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_validate_rejects_removed_output_argument() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["leaf"] = Pattern()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
builder.validate(output="library") # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_allows_helper_writes_via_pather() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)})
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
helper = Pather(library=lib, ports="leaf", name="_route")
|
||||||
|
top = Pattern()
|
||||||
|
top.ref("_route")
|
||||||
|
top.ref("leaf")
|
||||||
|
top.ports.update(helper.pattern.ports)
|
||||||
|
return top
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
|
||||||
|
helper_prov = built.build_report.provenance["_route"]
|
||||||
|
assert helper_prov.kind == "helper"
|
||||||
|
assert helper_prov.owner_declared_name == "top"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_preserves_source_cells_and_records_source_provenance() -> None:
|
||||||
|
source = Library({"src": Pattern()})
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder.add_source(source)
|
||||||
|
builder.cells.top = cell(lambda: Pattern())()
|
||||||
|
|
||||||
|
built = builder.build()
|
||||||
|
|
||||||
|
assert "src" in built
|
||||||
|
assert built.build_report.provenance["src"].kind == "source"
|
||||||
|
assert built.build_report.provenance["src"].emitted_via == "source_import"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_can_rename_imported_source_cells_during_authoring() -> None:
|
||||||
|
source = Library()
|
||||||
|
source["child"] = Pattern()
|
||||||
|
parent = Pattern()
|
||||||
|
parent.ref("child")
|
||||||
|
source["parent"] = parent
|
||||||
|
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder.add_source(source)
|
||||||
|
builder.rename("child", "renamed_child")
|
||||||
|
|
||||||
|
built = builder.build()
|
||||||
|
|
||||||
|
assert "renamed_child" in built
|
||||||
|
assert "child" not in built
|
||||||
|
assert "renamed_child" in built["parent"].refs
|
||||||
|
assert built.build_report.provenance["renamed_child"].source_name == "child"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_rejects_move_references_for_source_rename() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder.add_source(Library({"src": Pattern()}))
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match="move_references=True"):
|
||||||
|
builder.rename("src", "renamed_src", move_references=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_rejects_renaming_declared_cells_during_authoring() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["declared"] = Pattern()
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='Cannot rename declared build cell "declared"'):
|
||||||
|
builder.rename("declared", "renamed_declared")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_helper_rename_updates_provenance_and_owned_cells() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
lib["_helper"] = Pattern()
|
||||||
|
lib.rename("_helper", "final_helper")
|
||||||
|
top = Pattern()
|
||||||
|
top.ref("final_helper")
|
||||||
|
return top
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
report = built.build_report
|
||||||
|
|
||||||
|
assert "final_helper" in built
|
||||||
|
assert "_helper" not in built
|
||||||
|
assert "final_helper" in report.owned_cells["top"]
|
||||||
|
assert "_helper" not in report.owned_cells["top"]
|
||||||
|
prov = report.provenance["final_helper"]
|
||||||
|
assert prov.kind == "helper"
|
||||||
|
assert prov.requested_name == "_helper"
|
||||||
|
assert prov.renamed_from == "_helper"
|
||||||
|
assert prov.final_name == "final_helper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_helper_delete_removes_provenance_and_ownership() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
lib["_helper"] = Pattern()
|
||||||
|
del lib["_helper"]
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
report = built.build_report
|
||||||
|
|
||||||
|
assert "_helper" not in built
|
||||||
|
assert "_helper" not in report.provenance
|
||||||
|
assert report.owned_cells["top"] == ("top",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_helper_rename_after_auto_rename_preserves_requested_name() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
tree = Library({"_helper": Pattern()})
|
||||||
|
_ = lib << tree
|
||||||
|
renamed = lib << tree
|
||||||
|
lib.rename(renamed, "final_helper")
|
||||||
|
top = Pattern()
|
||||||
|
top.ref("_helper")
|
||||||
|
top.ref("final_helper")
|
||||||
|
return top
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
report = built.build_report
|
||||||
|
|
||||||
|
assert "final_helper" in built
|
||||||
|
prov = report.provenance["final_helper"]
|
||||||
|
assert prov.requested_name == "_helper"
|
||||||
|
assert prov.renamed_from == "_helper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None:
|
||||||
|
declared = BuildLibrary()
|
||||||
|
declared["leaf"] = Pattern()
|
||||||
|
|
||||||
|
def rename_declared(lib: BuildLibrary) -> Pattern:
|
||||||
|
lib.rename("leaf", "renamed_leaf")
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
declared.cells.top = cell(rename_declared)(declared)
|
||||||
|
with pytest.raises(BuildError, match='Cannot rename declared build cell "leaf"'):
|
||||||
|
declared.build()
|
||||||
|
|
||||||
|
source = BuildLibrary()
|
||||||
|
source.add_source(Library({"src": Pattern()}))
|
||||||
|
|
||||||
|
def rename_source(lib: BuildLibrary) -> Pattern:
|
||||||
|
lib.rename("src", "renamed_src")
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
source.cells.top = cell(rename_source)(source)
|
||||||
|
with pytest.raises(BuildError, match='Cannot rename imported source cell "src"'):
|
||||||
|
source.build()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_rejects_deleting_declared_or_source_cells_during_build() -> None:
|
||||||
|
declared = BuildLibrary()
|
||||||
|
declared["leaf"] = Pattern()
|
||||||
|
|
||||||
|
def delete_declared(lib: BuildLibrary) -> Pattern:
|
||||||
|
del lib["leaf"]
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
declared.cells.top = cell(delete_declared)(declared)
|
||||||
|
with pytest.raises(BuildError, match='Cannot delete declared build cell "leaf"'):
|
||||||
|
declared.build()
|
||||||
|
|
||||||
|
source = BuildLibrary()
|
||||||
|
source.add_source(Library({"src": Pattern()}))
|
||||||
|
|
||||||
|
def delete_source(lib: BuildLibrary) -> Pattern:
|
||||||
|
del lib["src"]
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
source.cells.top = cell(delete_source)(source)
|
||||||
|
with pytest.raises(BuildError, match='Cannot delete imported source cell "src"'):
|
||||||
|
source.build()
|
||||||
|
|
@ -5,7 +5,7 @@ from numpy.testing import assert_allclose
|
||||||
|
|
||||||
from ..pattern import Pattern
|
from ..pattern import Pattern
|
||||||
from ..library import Library
|
from ..library import Library
|
||||||
from ..shapes import Path as MPath, Circle, Polygon
|
from ..shapes import Path as MPath, Circle, Polygon, RectCollection
|
||||||
from ..repetition import Grid, Arbitrary
|
from ..repetition import Grid, Arbitrary
|
||||||
|
|
||||||
def create_test_library(for_gds: bool = False) -> Library:
|
def create_test_library(for_gds: bool = False) -> Library:
|
||||||
|
|
@ -109,3 +109,30 @@ def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
||||||
assert poly.repetition is not None
|
assert poly.repetition is not None
|
||||||
assert isinstance(poly.repetition, Grid)
|
assert isinstance(poly.repetition, Grid)
|
||||||
assert poly.repetition.a_count == 5
|
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']}
|
||||||
|
|
|
||||||
603
masque/test/test_gdsii_arrow.py
Normal file
603
masque/test/test_gdsii_arrow.py
Normal file
|
|
@ -0,0 +1,603 @@
|
||||||
|
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, gdsii_arrow
|
||||||
|
from ..file.gdsii_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 _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 import 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())
|
||||||
101
masque/test/test_gdsii_lazy.py
Normal file
101
masque/test/test_gdsii_lazy.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
from ..file import gdsii, gdsii_lazy
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..library import Library
|
||||||
|
|
||||||
|
|
||||||
|
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_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 not lib._cache
|
||||||
|
|
||||||
|
child = lib['child']
|
||||||
|
assert list(child.refs.keys()) == ['leaf']
|
||||||
|
assert set(lib._cache) == {'child'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdsii_lazy_ports_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 = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||||
|
|
||||||
|
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_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 = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||||
|
|
||||||
|
overlay = gdsii_lazy.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_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 = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||||
|
|
||||||
|
out_file = tmp_path / 'lazy_roundtrip_out.gds'
|
||||||
|
gdsii_lazy.writefile(processed, out_file)
|
||||||
|
|
||||||
|
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdsii_removed_closure_based_lazy_loader() -> None:
|
||||||
|
assert not hasattr(gdsii, 'load_library')
|
||||||
|
assert not hasattr(gdsii, 'load_libraryfile')
|
||||||
334
masque/test/test_gdsii_lazy_arrow.py
Normal file
334
masque/test/test_gdsii_lazy_arrow.py
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import klamath
|
||||||
|
import numpy
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip('pyarrow')
|
||||||
|
|
||||||
|
from .. import PatternError
|
||||||
|
from ..library import Library
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..repetition import Grid
|
||||||
|
from ..file import gdsii, gdsii_lazy_arrow
|
||||||
|
from ..file.gdsii_perf import write_fixture
|
||||||
|
|
||||||
|
|
||||||
|
if not gdsii_lazy_arrow.is_available():
|
||||||
|
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
|
||||||
|
|
||||||
|
|
||||||
|
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 {path for path in global_refs} == {('top', 'mid', 'leaf')}
|
||||||
|
assert global_refs[('top', 'mid', 'leaf')].shape[0] == 5
|
||||||
|
|
||||||
|
|
||||||
|
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_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 import 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) -> 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)
|
||||||
|
out_file = tmp_path / 'copy_out.gds'
|
||||||
|
gdsii_lazy_arrow.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_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_lazy_arrow.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 = gdsii_lazy_arrow.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_lazy_arrow.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 = gdsii_lazy_arrow.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)])
|
||||||
24
masque/test/test_gdsii_perf.py
Normal file
24
masque/test/test_gdsii_perf.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from dataclasses import asdict
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..file import gdsii
|
||||||
|
from ..file.gdsii_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
|
||||||
97
masque/test/test_raw_constructors.py
Normal file
97
masque/test/test_raw_constructors.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
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
|
||||||
70
masque/test/test_rect_collection.py
Normal file
70
masque/test/test_rect_collection.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
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]])
|
||||||
|
|
@ -5,9 +5,17 @@ from numpy.testing import assert_equal, assert_allclose
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms, normalize_mirror, DeferredDict
|
from ..utils import (
|
||||||
|
DeferredDict,
|
||||||
|
apply_transforms,
|
||||||
|
normalize_mirror,
|
||||||
|
poly_contains_points,
|
||||||
|
remove_colinear_vertices,
|
||||||
|
remove_duplicate_vertices,
|
||||||
|
rotation_matrix_2d,
|
||||||
|
)
|
||||||
from ..file.utils import tmpfile
|
from ..file.utils import tmpfile
|
||||||
from ..utils.curves import bezier
|
from ..utils.curves import bezier, circular_arc, euler_bend, euler_spiral
|
||||||
from ..error import PatternError
|
from ..error import PatternError
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,6 +31,23 @@ def test_remove_duplicate_vertices() -> None:
|
||||||
assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]])
|
assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_duplicate_vertices_tolerance_defaults_to_exact_match() -> None:
|
||||||
|
v = [[0, 0], [1, 1], [1 + 1e-13, 1], [2, 2], [1e-13, 0]]
|
||||||
|
|
||||||
|
assert_allclose(remove_duplicate_vertices(v, closed_path=True), v, atol=0, rtol=0)
|
||||||
|
assert_allclose(
|
||||||
|
remove_duplicate_vertices(v, closed_path=True, tolerance=1e-12),
|
||||||
|
[[0, 0], [1 + 1e-13, 1], [2, 2]],
|
||||||
|
atol=0,
|
||||||
|
rtol=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_duplicate_vertices_rejects_negative_tolerance() -> None:
|
||||||
|
with pytest.raises(ValueError, match='non-negative'):
|
||||||
|
remove_duplicate_vertices([[0, 0]], tolerance=-1)
|
||||||
|
|
||||||
|
|
||||||
def test_remove_colinear_vertices() -> None:
|
def test_remove_colinear_vertices() -> None:
|
||||||
v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]]
|
v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]]
|
||||||
v_clean = remove_colinear_vertices(v, closed_path=True)
|
v_clean = remove_colinear_vertices(v, closed_path=True)
|
||||||
|
|
@ -123,6 +148,53 @@ def test_bezier_accepts_exact_weight_count() -> None:
|
||||||
assert_allclose(samples, [[0, 0], [2 / 3, 2 / 3], [1, 1]], atol=1e-10)
|
assert_allclose(samples, [[0, 0], [2 / 3, 2 / 3], [1, 1]], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_tangent(xy: numpy.ndarray) -> float:
|
||||||
|
dxy = xy[-1] - xy[-2]
|
||||||
|
return numpy.arctan2(dxy[1], dxy[0])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('switchover_angle', 'total_angle'),
|
||||||
|
[
|
||||||
|
(pi / 8, pi / 4),
|
||||||
|
(pi / 8, pi / 2),
|
||||||
|
(pi / 4, pi),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_euler_bend_supports_total_angle(switchover_angle: float, total_angle: float) -> None:
|
||||||
|
xy = euler_bend(switchover_angle, num_points=2000, total_angle=total_angle)
|
||||||
|
|
||||||
|
assert_allclose(xy[0], [0, 0], atol=1e-12)
|
||||||
|
assert_allclose(_endpoint_tangent(xy), -total_angle, atol=1e-3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_euler_bend_180_degrees_with_90_degree_circular_middle() -> None:
|
||||||
|
xy = euler_bend(pi / 4, num_points=2000, total_angle=pi)
|
||||||
|
|
||||||
|
assert_allclose(_endpoint_tangent(xy), -pi, atol=1e-3)
|
||||||
|
assert abs(xy[-1][0]) < 1e-3
|
||||||
|
assert xy[-1][1] < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_euler_bend_rejects_too_large_switchover_angle() -> None:
|
||||||
|
with pytest.raises(PatternError, match='total_angle / 2'):
|
||||||
|
euler_bend(pi / 2, total_angle=pi / 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_euler_spiral_and_circular_arc_helpers_match_endpoint_tangent() -> None:
|
||||||
|
xy_spiral = euler_spiral(pi / 4, num_points=1000)
|
||||||
|
assert_allclose(_endpoint_tangent(xy_spiral), -pi / 4, atol=1e-3)
|
||||||
|
|
||||||
|
xy_arc = circular_arc(
|
||||||
|
10,
|
||||||
|
pi / 2,
|
||||||
|
num_points=1000,
|
||||||
|
start_angle=_endpoint_tangent(xy_spiral),
|
||||||
|
start=xy_spiral[-1],
|
||||||
|
)
|
||||||
|
assert_allclose(_endpoint_tangent(xy_arc), -3 * pi / 4, atol=2e-3)
|
||||||
|
|
||||||
|
|
||||||
def test_deferred_dict_accessors_resolve_values_once() -> None:
|
def test_deferred_dict_accessors_resolve_values_once() -> None:
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,15 @@ def annotation2key(aaa: int | float | str) -> tuple[bool, Any]:
|
||||||
return (isinstance(aaa, str), aaa)
|
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:
|
def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool:
|
||||||
|
aa = _normalized_annotations(aa)
|
||||||
|
bb = _normalized_annotations(bb)
|
||||||
if aa is None:
|
if aa is None:
|
||||||
return bb is not None
|
return bb is not None
|
||||||
elif bb is None: # noqa: RET505
|
elif bb is None: # noqa: RET505
|
||||||
|
|
@ -36,6 +44,8 @@ def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def annotations_eq(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:
|
if aa is None:
|
||||||
return bb is None
|
return bb is None
|
||||||
elif bb is None: # noqa: RET505
|
elif bb is None: # noqa: RET505
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,7 @@ from numpy.typing import ArrayLike, NDArray
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
||||||
from ..error import PatternError
|
from ..error import PatternError
|
||||||
|
from .vertices import remove_duplicate_vertices
|
||||||
try:
|
|
||||||
from numpy import trapezoid
|
|
||||||
except ImportError:
|
|
||||||
from numpy import trapz as trapezoid # type:ignore
|
|
||||||
|
|
||||||
|
|
||||||
def bezier(
|
def bezier(
|
||||||
|
|
@ -53,70 +49,196 @@ def bezier(
|
||||||
return qq
|
return qq
|
||||||
|
|
||||||
|
|
||||||
|
def _integrate_tangent(
|
||||||
|
qq: NDArray[numpy.float64],
|
||||||
|
theta: NDArray[numpy.float64],
|
||||||
|
num_points: int,
|
||||||
|
) -> NDArray[numpy.float64]:
|
||||||
|
dx = numpy.cos(theta)
|
||||||
|
dy = numpy.sin(theta)
|
||||||
|
|
||||||
|
dq = qq[-1] / (qq.size - 1)
|
||||||
|
ix = numpy.zeros(qq.size)
|
||||||
|
iy = numpy.zeros(qq.size)
|
||||||
|
ix[1:] = numpy.cumsum((dx[:-1] + dx[1:]) / 2) * dq
|
||||||
|
iy[1:] = numpy.cumsum((dy[:-1] + dy[1:]) / 2) * dq
|
||||||
|
|
||||||
|
qq_target = numpy.linspace(0, qq[-1], num_points)
|
||||||
|
x_target = numpy.interp(qq_target, qq, ix)
|
||||||
|
y_target = numpy.interp(qq_target, qq, iy)
|
||||||
|
|
||||||
|
return numpy.stack((x_target, y_target), axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def euler_spiral(
|
||||||
|
switchover_angle: float,
|
||||||
|
num_points: int = 200,
|
||||||
|
*,
|
||||||
|
start_angle: float = 0.0,
|
||||||
|
start: ArrayLike = (0.0, 0.0),
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> NDArray[numpy.float64]:
|
||||||
|
"""
|
||||||
|
Generate one Euler bend transition segment.
|
||||||
|
|
||||||
|
Positive angles bend clockwise, matching `euler_bend()`. When `reverse` is
|
||||||
|
`False`, curvature ramps from zero to the switchover curvature. When
|
||||||
|
`reverse` is `True`, curvature ramps from the switchover curvature to zero.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
switchover_angle: Tangent angle change across the Euler segment, in radians.
|
||||||
|
num_points: Number of points in the curve.
|
||||||
|
start_angle: Tangent angle at the first point.
|
||||||
|
start: First point of the segment.
|
||||||
|
reverse: If `True`, generate the exit segment of an Euler bend.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`[[x0, y0], ...]` for the curve.
|
||||||
|
"""
|
||||||
|
if num_points < 0:
|
||||||
|
raise PatternError(f'num_points must be non-negative, got {num_points}')
|
||||||
|
if switchover_angle < 0:
|
||||||
|
raise PatternError(f'switchover_angle must be non-negative, got {switchover_angle}')
|
||||||
|
if num_points == 0:
|
||||||
|
return numpy.empty((0, 2))
|
||||||
|
|
||||||
|
start = numpy.asarray(start, dtype=float)
|
||||||
|
if start.shape != (2,):
|
||||||
|
raise PatternError(f'start must be a 2D point; got shape {start.shape}')
|
||||||
|
|
||||||
|
if switchover_angle == 0:
|
||||||
|
return numpy.tile(start, (num_points, 1))
|
||||||
|
|
||||||
|
resolution = 100000
|
||||||
|
ll_max = numpy.sqrt(2 * switchover_angle)
|
||||||
|
qq = numpy.linspace(0, ll_max, resolution)
|
||||||
|
if reverse:
|
||||||
|
theta = start_angle - (ll_max * qq - qq * qq / 2)
|
||||||
|
else:
|
||||||
|
theta = start_angle - qq * qq / 2
|
||||||
|
|
||||||
|
return _integrate_tangent(qq, theta, num_points) + start
|
||||||
|
|
||||||
|
|
||||||
|
def circular_arc(
|
||||||
|
radius: float,
|
||||||
|
arc_angle: float,
|
||||||
|
num_points: int = 200,
|
||||||
|
*,
|
||||||
|
start_angle: float = 0.0,
|
||||||
|
start: ArrayLike = (0.0, 0.0),
|
||||||
|
) -> NDArray[numpy.float64]:
|
||||||
|
"""
|
||||||
|
Generate a clockwise circular arc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
radius: Arc radius.
|
||||||
|
arc_angle: Clockwise tangent angle change across the arc, in radians.
|
||||||
|
num_points: Number of points in the curve, excluding the start point.
|
||||||
|
start_angle: Tangent angle at the start point.
|
||||||
|
start: Point where the arc starts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`[[x0, y0], ...]` for the arc, excluding `start`.
|
||||||
|
"""
|
||||||
|
if num_points < 0:
|
||||||
|
raise PatternError(f'num_points must be non-negative, got {num_points}')
|
||||||
|
if radius <= 0:
|
||||||
|
raise PatternError(f'radius must be positive, got {radius}')
|
||||||
|
if arc_angle < 0:
|
||||||
|
raise PatternError(f'arc_angle must be non-negative, got {arc_angle}')
|
||||||
|
if num_points == 0:
|
||||||
|
return numpy.empty((0, 2))
|
||||||
|
|
||||||
|
start = numpy.asarray(start, dtype=float)
|
||||||
|
if start.shape != (2,):
|
||||||
|
raise PatternError(f'start must be a 2D point; got shape {start.shape}')
|
||||||
|
|
||||||
|
if arc_angle == 0:
|
||||||
|
return numpy.tile(start, (num_points, 1))
|
||||||
|
|
||||||
|
angles = numpy.linspace(0, arc_angle, num_points + 1)[1:]
|
||||||
|
right_normal = numpy.array([numpy.sin(start_angle), -numpy.cos(start_angle)])
|
||||||
|
center = start + radius * right_normal
|
||||||
|
radial = start - center
|
||||||
|
|
||||||
|
cos_t = numpy.cos(-angles)
|
||||||
|
sin_t = numpy.sin(-angles)
|
||||||
|
xx = center[0] + cos_t * radial[0] - sin_t * radial[1]
|
||||||
|
yy = center[1] + sin_t * radial[0] + cos_t * radial[1]
|
||||||
|
return numpy.stack((xx, yy), axis=1)
|
||||||
|
|
||||||
|
|
||||||
def euler_bend(
|
def euler_bend(
|
||||||
switchover_angle: float,
|
switchover_angle: float,
|
||||||
num_points: int = 200,
|
num_points: int = 200,
|
||||||
|
*,
|
||||||
|
total_angle: float = pi / 2,
|
||||||
) -> NDArray[numpy.float64]:
|
) -> NDArray[numpy.float64]:
|
||||||
"""
|
"""
|
||||||
Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral).
|
Generate an Euler bend (AKA Clothoid bend or Cornu spiral).
|
||||||
|
|
||||||
|
Positive angles bend clockwise. By default, this generates the historical
|
||||||
|
90 degree bend.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
switchover_angle: After this angle, the bend will transition into a circular arc
|
switchover_angle: After this angle, the bend will transition into a circular arc
|
||||||
(and transition back to an Euler spiral on the far side). If this is set to
|
(and transition back to an Euler spiral on the far side). If this is set to
|
||||||
`>= pi / 4`, no circular arc will be added.
|
`total_angle / 2`, no circular arc will be added.
|
||||||
num_points: Number of points in the curve
|
num_points: Number of points in the curve.
|
||||||
|
total_angle: Total tangent angle change across the bend, in radians.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
`[[x0, y0], ...]` for the curve
|
`[[x0, y0], ...]` for the curve
|
||||||
"""
|
"""
|
||||||
|
if switchover_angle <= 0:
|
||||||
|
raise PatternError(f'switchover_angle must be positive, got {switchover_angle}')
|
||||||
|
if total_angle <= 0:
|
||||||
|
raise PatternError(f'total_angle must be positive, got {total_angle}')
|
||||||
|
if switchover_angle > total_angle / 2:
|
||||||
|
raise PatternError(
|
||||||
|
f'switchover_angle must be <= total_angle / 2; '
|
||||||
|
f'got {switchover_angle} for total_angle {total_angle}'
|
||||||
|
)
|
||||||
|
if num_points < 2:
|
||||||
|
raise PatternError(f'num_points must be at least 2, got {num_points}')
|
||||||
|
|
||||||
|
arc_angle = total_angle - 2 * switchover_angle
|
||||||
ll_max = numpy.sqrt(2 * switchover_angle) # total length of (one) spiral portion
|
ll_max = numpy.sqrt(2 * switchover_angle) # total length of (one) spiral portion
|
||||||
ll_tot = 2 * ll_max + (pi / 2 - 2 * switchover_angle)
|
ll_tot = 2 * ll_max + arc_angle
|
||||||
num_points_spiral = numpy.floor(ll_max / ll_tot * num_points).astype(int)
|
num_points_spiral = max(2, numpy.floor(ll_max / ll_tot * num_points).astype(int))
|
||||||
num_points_arc = num_points - 2 * num_points_spiral
|
num_points_arc = max(0, num_points - 2 * num_points_spiral)
|
||||||
|
if arc_angle > 0:
|
||||||
|
num_points_arc = max(1, num_points_arc)
|
||||||
|
|
||||||
def gen_spiral(ll_max: float) -> NDArray[numpy.float64]:
|
xy_spiral = euler_spiral(switchover_angle, num_points_spiral)
|
||||||
if ll_max == 0:
|
|
||||||
return numpy.zeros((num_points_spiral, 2))
|
|
||||||
|
|
||||||
resolution = 100000
|
|
||||||
qq = numpy.linspace(0, ll_max, resolution)
|
|
||||||
dx = numpy.cos(qq * qq / 2)
|
|
||||||
dy = -numpy.sin(qq * qq / 2)
|
|
||||||
|
|
||||||
dq = ll_max / (resolution - 1)
|
|
||||||
ix = numpy.zeros(resolution)
|
|
||||||
iy = numpy.zeros(resolution)
|
|
||||||
ix[1:] = numpy.cumsum((dx[:-1] + dx[1:]) / 2) * dq
|
|
||||||
iy[1:] = numpy.cumsum((dy[:-1] + dy[1:]) / 2) * dq
|
|
||||||
|
|
||||||
ll_target = numpy.linspace(0, ll_max, num_points_spiral)
|
|
||||||
x_target = numpy.interp(ll_target, qq, ix)
|
|
||||||
y_target = numpy.interp(ll_target, qq, iy)
|
|
||||||
|
|
||||||
return numpy.stack((x_target, y_target), axis=1)
|
|
||||||
|
|
||||||
xy_spiral = gen_spiral(ll_max)
|
|
||||||
xy_parts = [xy_spiral]
|
xy_parts = [xy_spiral]
|
||||||
|
|
||||||
if switchover_angle < pi / 4:
|
if arc_angle > 0:
|
||||||
# Build a circular segment to join the two euler portions
|
# Build a circular segment to join the two euler portions
|
||||||
rmin = 1.0 / ll_max
|
rmin = 1.0 / ll_max
|
||||||
half_angle = pi / 4 - switchover_angle
|
xy_arc = circular_arc(
|
||||||
qq = numpy.linspace(half_angle * 2, 0, num_points_arc + 1) + switchover_angle
|
rmin,
|
||||||
xc = rmin * numpy.cos(qq)
|
arc_angle,
|
||||||
yc = rmin * numpy.sin(qq) + xy_spiral[-1, 1]
|
num_points_arc,
|
||||||
xc += xy_spiral[-1, 0] - xc[0]
|
start_angle=-switchover_angle,
|
||||||
yc += xy_spiral[-1, 1] - yc[0]
|
start=xy_spiral[-1],
|
||||||
xy_parts.append(numpy.stack((xc[1:], yc[1:]), axis=1))
|
)
|
||||||
|
xy_parts.append(xy_arc)
|
||||||
|
|
||||||
endpoint_xy = xy_parts[-1][-1, :]
|
endpoint_xy = xy_parts[-1][-1, :]
|
||||||
second_spiral = xy_spiral[::-1, ::-1] + endpoint_xy - xy_spiral[-1, ::-1]
|
second_spiral = euler_spiral(
|
||||||
|
switchover_angle,
|
||||||
|
num_points_spiral,
|
||||||
|
start_angle=-(total_angle - switchover_angle),
|
||||||
|
start=endpoint_xy,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
xy_parts.append(second_spiral)
|
xy_parts.append(second_spiral[1:])
|
||||||
xy = numpy.concatenate(xy_parts)
|
xy = numpy.concatenate(xy_parts)
|
||||||
|
|
||||||
# Remove any 2x-duplicate points
|
# Remove any 2x-duplicate points
|
||||||
xy = xy[(numpy.roll(xy, 1, axis=0) - xy > 1e-12).any(axis=1)]
|
xy = remove_duplicate_vertices(xy, closed_path=False, tolerance=1e-12)
|
||||||
|
|
||||||
return xy
|
return xy
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import numpy
|
||||||
from numpy.typing import NDArray, ArrayLike
|
from numpy.typing import NDArray, ArrayLike
|
||||||
|
|
||||||
|
|
||||||
def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]:
|
def remove_duplicate_vertices(
|
||||||
|
vertices: ArrayLike,
|
||||||
|
closed_path: bool = True,
|
||||||
|
tolerance: float = 0.0,
|
||||||
|
) -> NDArray[numpy.float64]:
|
||||||
"""
|
"""
|
||||||
Given a list of vertices, remove any consecutive duplicates.
|
Given a list of vertices, remove any consecutive duplicates.
|
||||||
|
|
||||||
|
|
@ -13,14 +17,22 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) ->
|
||||||
vertices: `[[x0, y0], [x1, y1], ...]`
|
vertices: `[[x0, y0], [x1, y1], ...]`
|
||||||
closed_path: If True, `vertices` is interpreted as an implicity-closed path
|
closed_path: If True, `vertices` is interpreted as an implicity-closed path
|
||||||
(i.e. the last vertex will be removed if it is the same as the first)
|
(i.e. the last vertex will be removed if it is the same as the first)
|
||||||
|
tolerance: Maximum coordinate-wise absolute difference for two vertices to
|
||||||
|
be considered duplicates. Default `0` requires exact equality.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
`vertices` with no consecutive duplicates. This may be a view into the original array.
|
`vertices` with no consecutive duplicates. This may be a view into the original array.
|
||||||
"""
|
"""
|
||||||
|
if tolerance < 0:
|
||||||
|
raise ValueError(f'tolerance must be non-negative, got {tolerance}')
|
||||||
|
|
||||||
vertices = numpy.asarray(vertices)
|
vertices = numpy.asarray(vertices)
|
||||||
if vertices.shape[0] <= 1:
|
if vertices.shape[0] <= 1:
|
||||||
return vertices
|
return vertices
|
||||||
|
if tolerance == 0:
|
||||||
duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1)
|
duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1)
|
||||||
|
else:
|
||||||
|
duplicates = (numpy.abs(vertices - numpy.roll(vertices, -1, axis=0)) <= tolerance).all(axis=1)
|
||||||
if not closed_path:
|
if not closed_path:
|
||||||
duplicates[-1] = False
|
duplicates[-1] = False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ dependencies = [
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest",
|
"masque[arrow]",
|
||||||
"masque[oasis]",
|
"masque[oasis]",
|
||||||
"masque[dxf]",
|
"masque[dxf]",
|
||||||
"masque[svg]",
|
"masque[svg]",
|
||||||
|
|
@ -52,6 +52,7 @@ dev = [
|
||||||
"masque[text]",
|
"masque[text]",
|
||||||
"masque[manhattanize]",
|
"masque[manhattanize]",
|
||||||
"masque[manhattanize_slow]",
|
"masque[manhattanize_slow]",
|
||||||
|
"masque[boolean]",
|
||||||
"matplotlib>=3.10.8",
|
"matplotlib>=3.10.8",
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.2",
|
||||||
"ruff>=0.15.5",
|
"ruff>=0.15.5",
|
||||||
|
|
@ -66,6 +67,7 @@ build-backend = "hatchling.build"
|
||||||
path = "masque/__init__.py"
|
path = "masque/__init__.py"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
arrow = ["pyarrow", "cffi"]
|
||||||
oasis = ["fatamorgana~=0.11"]
|
oasis = ["fatamorgana~=0.11"]
|
||||||
dxf = ["ezdxf~=1.4"]
|
dxf = ["ezdxf~=1.4"]
|
||||||
svg = ["svgwrite"]
|
svg = ["svgwrite"]
|
||||||
|
|
@ -121,4 +123,3 @@ mypy_path = "stubs"
|
||||||
python_version = "3.11"
|
python_version = "3.11"
|
||||||
strict = false
|
strict = false
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue