masque/masque/file/gdsii_lazy_arrow.py

519 lines
17 KiB
Python

"""
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)