diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 61229f80a..4ee2f30a5 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -23,6 +23,10 @@ jobs: run: | DEBIAN_FRONTEND=noninteractive sudo apt install graphviz graphviz-dev - uses: actions/checkout@v6 + with: + submodules: recursive + ssh-key: ${{ secrets.SUBMODULE_SSH_KEY }} + persist-credentials: false - uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index aeb848500..2c83f32fb 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,5 +28,14 @@ jobs: with: python-version: ${{ matrix.python-version }} - uses: extractions/setup-just@v4 - - name: Test coverage + - name: Cache executed notebooks + uses: actions/cache@v5 + with: + path: | + docs/.build-cache + docs/source-built + key: docs-source-built-${{ matrix.python-version }}-${{ hashFiles('docs/source/**/*.py', 'docs/source/**/*.md', 'src/kfactory/**/*.py', 'docs/scripts/build_docs_source.py') }} + restore-keys: | + docs-source-built-${{ matrix.python-version }}- + - name: Build docs run: just docs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 689327e2b..829ac9c6d 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,46 +1,66 @@ name: docs to gh-pages +# Multi-version deployment via mike (zensical fork, installed at runtime via --with): +# push to main → `dev` version (always tracks latest main) +# push to vX.Y.Z → `` version + `latest` alias (set as default) +# +# Pre-existing v2.5.x version dirs on gh-pages stay intact — mike only +# rewrites the version it's deploying plus versions.json + the alias target. +# +# Requires: GitHub Pages source set to "Deploy from a branch" → gh-pages +# (one-time switch in repo Settings → Pages). + on: push: - # branches: - # - main + branches: + - main tags: - "v[0-9]+.[0-9]+.[0-9]+*" workflow_dispatch: +permissions: + contents: write # mike pushes to gh-pages + +concurrency: + group: docs-deploy + cancel-in-progress: false + jobs: - build-docs: + deploy: runs-on: ubuntu-latest - name: build docs steps: - name: Install graphviz shell: bash -l {0} run: | DEBIAN_FRONTEND=noninteractive sudo apt install graphviz graphviz-dev + - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 with: - python-version: ${{ matrix.python-version }} + fetch-depth: 0 # mike needs full history of gh-pages + + - uses: astral-sh/setup-uv@v7 - uses: extractions/setup-just@v4 - - name: Test coverage - run: just docs - - uses: actions/upload-pages-artifact@v5 + + - name: Cache executed notebooks + uses: actions/cache@v5 with: - name: github-pages - path: "./docs/site/" + path: | + docs/.build-cache + docs/source-built + key: docs-source-built-3.14-${{ hashFiles('docs/source/**/*.py', 'docs/source/**/*.md', 'src/kfactory/**/*.py', 'docs/scripts/build_docs_source.py') }} + restore-keys: | + docs-source-built-3.14- - deploy-docs: - needs: build-docs - permissions: - pages: write - id-token: write + - name: Configure git for mike + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin gh-pages --depth=1 || echo "no gh-pages branch yet" - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + - name: Deploy main → dev version + if: github.ref == 'refs/heads/main' + run: just docs-deploy-dev - runs-on: ubuntu-latest - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 + - name: Deploy tagged release → + latest alias + if: startsWith(github.ref, 'refs/tags/v') + run: just docs-deploy-release "${GITHUB_REF#refs/tags/v}" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 5c785e407..97d8ad1b9 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -12,6 +12,7 @@ permissions: jobs: update_release_draft: + if: github.event_name == 'push' || github.base_ref == 'main' permissions: contents: write pull-requests: write @@ -19,6 +20,8 @@ jobs: steps: # Drafts your next Release notes as Pull Requests are merged into "master" - uses: release-drafter/release-drafter@v7 + with: + commitish: main env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} require_label: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00e5b1f1b..7a28c7f46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: jobs: release_pypi: runs-on: ubuntu-latest + environment: pypi steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1fc46ca1..af464afb5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,12 +16,19 @@ jobs: fail-fast: false matrix: python-version: - - "3.11" - "3.12" - "3.13" + - "3.14" os: [ubuntu-latest, windows-latest, macos-latest] + exclude: # TODO: remove once klayout wheels are generated + - os: windows-latest + python-version: "3.14" steps: - uses: actions/checkout@v6 + with: + submodules: recursive + ssh-key: ${{ secrets.SUBMODULE_SSH_KEY }} + persist-credentials: false - uses: astral-sh/setup-uv@v7 - uses: extractions/setup-just@v4 - name: Install dependencies @@ -38,11 +45,19 @@ jobs: strategy: matrix: python-version: - - "3.11" + - "3.12" - "3.13" + - "3.14" os: [ubuntu-latest, windows-latest, macos-latest] + exclude: # TODO: remove once klayout wheels are generated + - os: windows-latest + python-version: "3.14" steps: - uses: actions/checkout@v6 + with: + submodules: recursive + ssh-key: ${{ secrets.SUBMODULE_SSH_KEY }} + persist-credentials: false - uses: astral-sh/setup-uv@v7 - uses: extractions/setup-just@v4 - name: Install dependencies diff --git a/.gitignore b/.gitignore index 2364cbf52..76bfaabac 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,20 @@ .idea Pipfile docs/_build/ +docs/source-built/ +docs/.build-cache/ site/ +demo_geometry.gds +# Logo is regenerated by docs/scripts/gen_logo.py during the docs build +# (Stage 4 of build_docs_source.py); the staged copy lives in +# docs/source-built/_static/ which is already ignored above. If the +# generator is run standalone with --out-dir docs/source/_static the +# stray copies land here — keep them out of git. +docs/source/_static/logo.gds +docs/source/_static/logo.svg +# Spliced zensical config — built from docs/zensical.yml + the +# auto-generated API nav by docs/scripts/build_docs_source.py. +docs/zensical-built.yml # Packages *.egg @@ -49,3 +62,6 @@ demo.gds *.prof *.obtained.oas *.obtained.gds +*.obtained.gds.gz +*.obtained.yml +*.obtained.yaml diff --git a/.gitmodules b/.gitmodules index f502afccf..9bef7e85b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,8 @@ [submodule "tests/gdsfactory-yaml-pics"] path = tests/gdsfactory-yaml-pics - url = https://github.com/gdsfactory/gdsfactory.git + url = git@github.com:/gdsfactory/gdsfactory.git + branch = main +[submodule "tests/test_data"] + path = tests/test_data + url = git@github.com:gdsfactory/kfactory-test-data.git branch = main diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad23cef08..57613b491 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,10 @@ repos: - id: check-merge-conflict - id: check-symlinks - id: check-yaml - args: [] + # mkdocs.yml uses Material's `!!python/name:` tag for the + # pymdownx.emoji index/generator; pyyaml's safe loader can't + # resolve that, so the safe-loader check would fail. + args: ["--unsafe"] - id: debug-statements - id: end-of-file-fixer exclude: 'changelog\.d/.*|CHANGLEOG\.md' @@ -21,45 +24,63 @@ repos: exclude: 'changelog\.d/.*|CHANGELOG\.md' - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.13 + rev: v0.15.17 hooks: # Run the linter. - - id: ruff + - id: ruff-check + args: [ --fix ] # Run the formatter. - id: ruff-format + - repo: https://github.com/tox-dev/pyproject-fmt + rev: v2.24.0 + hooks: + - id: pyproject-fmt - repo: https://github.com/kynan/nbstripout - rev: 0.9.0 + rev: 0.9.1 hooks: - id: nbstripout files: .ipynb - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell additional_dependencies: - tomli - repo: https://github.com/PyCQA/bandit - rev: 1.9.3 + rev: 1.9.4 hooks: - id: bandit args: [--exit-zero] # ignore all tests, not just tests data exclude: ^tests/ - - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.19.1" # Use the sha / tag you want to point at + - repo: local hooks: - - id: mypy - args: [--ignore-missing-imports, --strict, --config-file=pyproject.toml] + - id: ty + name: ty check + entry: uvx --with-editable .[dev] ty check . + language: python additional_dependencies: + - uv + - aenum - pydantic + - pydantic-extra-types - numpy - pytest - - "klayout>=0.30" + - pytest-regressions + - "klayout>=0.30.8" + - "kfnetlist>=0.2.0" - types-cachetools - loguru - pydantic-settings - typer - types-PyYAML - scipy - exclude: ^docs/|^tests/ + - "ruamel.yaml" + - ruamel.yaml.string + - toolz + - rapidfuzz + - rectangle-packer + - semver + - pygit2 + pass_filenames: false diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml deleted file mode 100644 index 44570aae5..000000000 --- a/.pre-commit-hooks.yaml +++ /dev/null @@ -1,16 +0,0 @@ -- id: towncrier-check - name: towncrier-check - description: Check towncrier changelog updates - entry: towncrier --draft - pass_filenames: false - types: [text] - files: changelog.d/ - language: python -- id: towncrier-update - name: towncrier-update - description: Update changelog with towncrier - entry: towncrier - pass_filenames: false - args: ["--yes"] - files: changelog.d/ - language: python diff --git a/Justfile b/Justfile index 6bffbc7fb..a35c4a19d 100644 --- a/Justfile +++ b/Justfile @@ -1,7 +1,7 @@ # Development setup with all extras dev: uv sync --all-extras - uv pip install -e . + uv pip install -e . -U uv run pre-commit install # Test environment setup @@ -9,35 +9,68 @@ test-venv: uv sync --all-extras uv pip install -e . +# Zensical's tailored fork of mike for versioned docs deployment +MIKE := "mike @ git+https://github.com/squidfunk/mike.git" + # Clean documentation build docs-clean: rm -rf site -# Build documentation -docs python_version="3.12": - uv run -p {{python_version}} --with . --extra docs --isolated mkdocs build -f docs/mkdocs.yml +# Pre-build docs source: convert jupytext .py to .md+.ipynb (with download +# button), generate mkdocstrings API reference stubs into docs/source-built/. +# Cached: re-runs only re-execute notebooks whose source hash changed. +docs-build-source python_version="3.14": + uv run -p {{python_version}} --extra notebooks --with-editable . python docs/scripts/build_docs_source.py + +# Build documentation (zensical) from the pre-built source +docs python_version="3.14": docs-build-source + uv run -p {{python_version}} --with-editable . --extra docs --with "{{MIKE}}" --isolated zensical build -f docs/zensical-built.yml + +# Serve documentation locally (zensical) from the pre-built source +docs-serve python_version="3.14": docs-build-source + uv run -p {{python_version}} --with-editable . --extra docs --with "{{MIKE}}" --isolated zensical serve -f docs/zensical-built.yml + +# Deploy docs to gh-pages as the "dev" version (tracks main) +docs-deploy-dev python_version="3.14": docs-build-source + uv run -p {{python_version}} --with . --extra docs --with "{{MIKE}}" --isolated mike deploy \ + --config-file docs/zensical-built.yml \ + --alias-type=redirect \ + --push \ + --update-aliases \ + dev + +# Deploy docs to gh-pages as a tagged release version + set "latest" as default +docs-deploy-release version python_version="3.14": docs-build-source + uv run -p {{python_version}} --with . --extra docs --with "{{MIKE}}" --isolated mike deploy \ + --config-file docs/zensical-built.yml \ + --alias-type=redirect \ + --push \ + --update-aliases \ + {{version}} latest + uv run -p {{python_version}} --with . --extra docs --with "{{MIKE}}" --isolated mike set-default \ + --config-file docs/zensical-built.yml \ + --push \ + latest -# Serve documentation locally -docs-serve python_version="3.12": - uv run -p {{python_version}} --with . --extra docs --isolated mkdocs serve -f docs/mkdocs.yml # Run tests (depends on init-submodule) -test python_version="3.12": init-submodule +test python_version="3.14": init-submodule uv run -p {{python_version}} --with . --extra ci --isolated pytest -s -n logical -test-gdsfactory python_version="3.12": init-submodule - uv run -p {{python_version}} --no-sync --extra ci --with gdsfactory --with . --isolated pytest -s -n logical tests/test_gdsfactory.py +test-gdsfactory python_version="3.14": init-submodule + # uv run -p {{python_version}} --no-sync --extra ci --with gdsfactory --with . --isolated pytest -s -vvvv -n logical tests/test_gdsfactory.py + uv run -p {{python_version}} --extra ci --with gdsfactory --with jinja2 --with . --isolated pytest -s -vvvv tests/test_gdsfactory.py -x --pdb # Run tests with minimum dependencies -test-min python_version="3.12": +test-min python_version="3.12": init-submodule uv run -p {{python_version}} --with . --extra ci --resolution lowest-direct --isolated pytest -s -n logical # Run tests with coverage report (XML) -cov python_version="3.12": +cov python_version="3.14": init-submodule uv run -p {{python_version}} --with . --extra ci --isolated pytest -n logical -s --cov=kfactory --cov-branch --cov-report=xml # Run tests with coverage report (terminal) -dev-cov python_version="3.12": +dev-cov python_version="3.14": init-submodule uv run -p {{python_version}} --with . --extra ci --isolated pytest -n logical -s --cov=kfactory --cov-report=term-missing:skip-covered # Run linting @@ -48,34 +81,34 @@ lint: format: uv run ruff format . -# Run type checking -mypy: - uv run dmypy run src/kfactory - # Run ty ty: uv run ty check src/kfactory -# Submodule variable -SUBMOD := "tests/gdsfactory-yaml-pics" +# Submodule variables +YAML_PICS := "tests/gdsfactory-yaml-pics" +TEST_DATA := "tests/test_data" -# Initialize submodule init-submodule: - # init shallow - git submodule update --init --depth 1 {{SUBMOD}} - # ensure it tracks main on updates - git submodule set-branch --branch main {{SUBMOD}} - # restrict working tree to the yaml_pics folder - git -C {{SUBMOD}} sparse-checkout init --cone - git -C {{SUBMOD}} sparse-checkout set notebooks/yaml_pics - -update-submodule: - # pull latest main for the submodule, still shallow - git submodule update --remote --depth 1 {{SUBMOD}} + git submodule update --init --recursive --depth 50 + + # sparse checkout only after init + git -C {{YAML_PICS}} sparse-checkout set --no-cone "/docs/notebooks/yaml_pics/" + +# Update all submodules to latest main +update-submodule: update-yaml-pics update-test-data + +# Update gdsfactory-yaml-pics submodule to latest main +update-yaml-pics: + git submodule update --remote --depth 1 {{YAML_PICS}} + +# Update test-data submodule to latest main +update-test-data: + git submodule update --remote --depth 1 {{TEST_DATA}} +# Clean gdsfactory-yaml-pics submodule working tree clean-submodule: - # remove only the checked-out files, keep the submodule entry - git -C {{SUBMOD}} clean -xdf + git -C {{YAML_PICS}} clean -xdf gds-download: gh release download v0.6.0 -D gds/gds_ref/ --clobber diff --git a/README.md b/README.md index 16a1e8b5c..c1a857cd6 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,56 @@ -# KFactory 2.5.1 +# KFactory 3.0.0rc2 [![codecov](https://codecov.io/gh/gdsfactory/kfactory/graph/badge.svg?token=dArcfnQE4w)](https://codecov.io/gh/gdsfactory/kfactory) -Kfactory is the backend for [gdsfactory](https://github.com/gdsfactory/gdsfactory). It is built upon [KLayout](https://klayout.de). -It offers basic operations like gdsfactory, so it can be used on its own as a layout tool as well. +KFactory is a Python framework for photonic and electronic chip layout, built on [KLayout](https://klayout.de)'s C++ geometry engine. +It provides parametric cells with caching, optical and electrical routing, enclosures via Minkowski sums, and schematic-driven design with LVS. -It is recommended to pin the version of KFactory in `requirements.txt` or `pyproject.toml` with `kfactory==2.5.1` for example. - -Features similar to gdsfactory: - -- [x] Cells & decorator for caching & storing cells -- [x] Simple routing (point to point and simpl bundle routes for electrical routes) -- [x] Basic cells like euler/circular bends, taper, waveguide -- [x] Path extrusion (no interface with CrossSections) -- [x] Jupyter integration -- [x] PDK/package configuration -- [x] Plugin system (simulations etc.) - Check [kplugins](https://github.com/gdsfactory/kplugins) -- [x] Generic PDK example - Check [kgeneric](https://github.com/gdsfactory/kgeneric) -- [x] CrossSection -- [x] Netlist/Schematics and LVS - -Notable missing Features: - -- [ ] More advanced routing - - -New/Improved Features: - -- Fully hierarchical bi-directional conversion to YAML -- Automatic snapping to grid thanks to KLayout -- More features for vector geometries due to concept of Point/Edge/Vector/Polygon from Klayout -- Easy booleans thanks to KLayout Regions -- Enclosures: use the concept of enclosures, similar to cross sections, to allow automatic - calculation of boolean layers for structures based on [minkowski sum](https://en.wikipedia.org/wiki/Minkowski_addition), - which are built into KLayout +## Key Features +- **Cell caching** — the `@kf.cell` decorator deduplicates identical components automatically +- **Routing** — optical and electrical bundle routing, Manhattan primitives, all-angle routing, and path-length matching +- **Cross-sections & enclosures** — define waveguide profiles and automatic boolean cladding layers via Minkowski sums +- **Schematics** — place-and-connect workflow with netlist extraction and layout-vs-schematic verification +- **Virtual cells** — hierarchical logical containers for schematic-driven design +- **Dual coordinate systems** — `KCell` (integer DBU) and `DKCell` (float µm) work side by side +- **KLayout integration** — full access to `kdb.Region`, `kdb.Polygon`, DRC, and GDS/OASIS I/O +- **Jupyter & KLive** — live preview in KLayout while editing notebooks +- **PDK system** — bundle layers, factories, cross-sections, and technology into reusable packages ## Getting Started ### Installation -kfactory is available as [`kfactory`](https://pypi.org/project/kfactory/) on PyPI - -Install kfactory with `uv`, or `pip`: +KFactory is available on [PyPI](https://pypi.org/project/kfactory/) and requires Python 3.12+. ```bash -# Add kfactory to your project. uv add kfactory -# With pip. +# or with pip pip install kfactory ``` -At the moment kfactory works only on python 3.11 and above - -### Development Installation - -A development environment can be installed with +### Development ```bash just dev ``` -For committing `pre-commit` should be installed with `pre-commit install` (this is done with `just dev`). +This installs the development environment and sets up pre-commit hooks. + +## Ecosystem + +| Package | Description | +|---|---| +| [gdsfactory](https://github.com/gdsfactory/gdsfactory) | Full-featured chip design framework — KFactory is its layout backend | +| [kfnetlist](https://github.com/gdsfactory/kfnetlist) | Standalone netlist extraction and generation | + +## Documentation + +Full documentation is available at [gdsfactory.github.io/kfactory](https://gdsfactory.github.io/kfactory). + +Upgrading from an earlier version? See the [migration guide](migration.md). + +## License + +[MIT](LICENSE) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml deleted file mode 100644 index 87b4fb773..000000000 --- a/docs/mkdocs.yml +++ /dev/null @@ -1,103 +0,0 @@ -site_name: KFactory -repo_url: https://github.com/gdsfactory/kfactory -site_url: https://gdsfactory.github.io/kfactory -docs_dir: source - -nav: - - Home: - - Intro: index.md - - gdsfactory.md - - dosdonts.md - - First Steps: - - Prerequisites: pre.md - - 5min Intro to KFactory: intro.md - - Creating PCells: pcells.md - - Tutorials: - - Basice Geometry in KLayout: notebooks/00_geometry.py - - KCell & Instance: notebooks/01_references.py - - DRC utils: notebooks/02_DRC.py - - Enclosures: notebooks/03_Enclosures.py - - Multiple KCLs: notebooks/04_KCL.py - - Schematics: notebooks/05_Schematics.py - - Migration: migration.md - - Config Class: config.md - - API: reference/ - - Changelog: changelog.md -theme: - name: "material" - features: - - navigation.tabs - - navigation.tabs.sticky - custom_dir: overrides - - palette: - # Palette toggle for dark mode - - scheme: slate - toggle: - icon: material/brightness-4 - name: Switch to light mode - # Palette toggle for light mode - - scheme: default - toggle: - icon: material/brightness-7 - name: Switch to dark mode -watch: - - ../src/kfactory - -markdown_extensions: - - admonition - - pymdownx.superfences - - pymdownx.tasklist - - pymdownx.tabbed - - pymdownx.emoji - - pymdownx.snippets: - check_paths: true - -plugins: - - gen-files: - scripts: - - scripts/gen_diagrams.py - - scripts/gen_ref_pages.py - - mkdocstrings: - enabled: true - # custom_templates: templates - default_handler: python - handlers: - python: - options: - show_source: true - allow_inspection: true - docstring_style: google - # ignore_init_summary: true - separate_signature: true - show_signature_annotations: true - members_order: alphabetical - extensions: - - griffe_pydantic - - dataclasses - - griffe_inherited_docstrings - - griffe_warnings_deprecated - - mkdocs-video: - is_video: true - video_type: webm - video_muted: true - - search - - mkdocs-jupyter: - include_source: true - # ignore_h1_titles: true - include_requirejs: true - execute: true - allow_errors: false - kernel_name: python3 - execute_ignore: - - "source/*.py" - ignore: ["source/*.py"] - remove_tag_config: - remove_input_tags: - - hide - remove_output_tags: - - hide - - literate-nav: - nav_file: SUMMARY.md - - section-index - - markdown-exec diff --git a/docs/scripts/build_docs_source.py b/docs/scripts/build_docs_source.py new file mode 100644 index 000000000..b34df505c --- /dev/null +++ b/docs/scripts/build_docs_source.py @@ -0,0 +1,696 @@ +"""Pre-build docs source: jupytext .py → executed .md + downloadable .ipynb. + +Replaces runtime mkdocs plugins (mkdocs-jupyter, mkdocs-gen-files) with +deterministic file generation into a staging directory. The static-site +generator (mkdocs or zensical) then sees only plain .md + assets. + +Pipeline per source tree: + docs/source/**/*.md → copy verbatim to docs/source-built/ + docs/source/**/*.py → jupytext.read → nbconvert.execute → + MarkdownExporter (+ TagRemovePreprocessor) + → docs/source-built/.md + + docs/source-built/.ipynb (download) + + extracted output images + docs/source/_static → copy verbatim + src/kfactory/**/*.py → docs/source-built/reference/**/*.md + (mkdocstrings ::: directive stubs) + schematic diagrams → docs/source-built/_static/*.svg + (when --diagrams is passed; needs erdantic) + +Cache: docs/.build-cache/manifest.json keyed by content hash; unchanged +inputs skip re-execution. + +Usage: + python docs/scripts/build_docs_source.py + [--source docs/source] [--out docs/source-built] + [--cache docs/.build-cache] [--workers N] [--no-execute] + [--diagrams] [--clean] +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import hashlib +import json +import re +import shutil +import sys +import time +from pathlib import Path +from typing import Any + +import jupytext +import nbformat +from nbconvert import MarkdownExporter +from nbconvert.preprocessors import ExecutePreprocessor +from traitlets.config import Config + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +# Path to the layer-styles YAML and the generated .lyp. The YAML follows +# kfactory.technology.layer_map.LypModel; we convert it once at build time +# and have every executed notebook load it via `as_png_data`'s +# `layer_properties` parameter. +DOC_STYLES_YAML = REPO_ROOT / "docs" / "source" / "_static" / "doc_styles.yaml" +DOC_STYLES_LYP = REPO_ROOT / "docs" / "source-built" / "_static" / "doc_styles.lyp" + + +def build_doc_styles_lyp() -> Path | None: + """Convert docs/source/_static/doc_styles.yaml → .lyp. + + Returns the .lyp path on success, ``None`` if the YAML file is missing. + """ + if not DOC_STYLES_YAML.exists(): + return None + DOC_STYLES_LYP.parent.mkdir(parents=True, exist_ok=True) + from kfactory.technology.layer_map import yaml_to_lyp + + yaml_to_lyp(DOC_STYLES_YAML, DOC_STYLES_LYP) + return DOC_STYLES_LYP + + +# Setup cell injected at the top of every notebook. Monkey-patches +# `kfactory.utilities.as_png_data` (and the re-export in +# `kfactory.widgets.interactive`) so it loads our generated .lyp by default. +# The .lyp matches each shape by (layer, datatype) — see the layer +# convention documented in `_static/doc_styles.yaml`. +_DOC_STYLE_SETUP = """\ +def _kf_apply_doc_styles() -> None: + from pathlib import Path + import kfactory.utilities + import kfactory.widgets.interactive + + _lyp = Path({lyp_path!r}) + if not _lyp.is_file(): + return + _original = kfactory.utilities.as_png_data + + def _styled_as_png_data(c, layer_properties=None, **kwargs): + return _original(c, layer_properties=layer_properties or str(_lyp), **kwargs) + + kfactory.utilities.as_png_data = _styled_as_png_data + kfactory.widgets.interactive.as_png_data = _styled_as_png_data + + +_kf_apply_doc_styles() +""" + + +def file_hash(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest() + + +def cache_load(cache_dir: Path) -> dict[str, str]: + f = cache_dir / "manifest.json" + if not f.exists(): + return {} + try: + return json.loads(f.read_text()) + except json.JSONDecodeError: + return {} + + +def cache_save(cache_dir: Path, manifest: dict[str, str]) -> None: + cache_dir.mkdir(parents=True, exist_ok=True) + (cache_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + ) + + +def cache_key(input_path: Path, *output_paths: Path) -> str: + return f"{input_path}::{':'.join(str(p) for p in output_paths)}" + + +def compute_source_fingerprint(repo_root: Path) -> dict[str, str | None]: + """Snapshot identifying the current `src/kfactory/` source-tree state. + + Returns ``{"head_oid": ..., "src_hash": ...}``. ``head_oid`` is the + SHA of the current git HEAD or ``None`` if pygit2 can't open the + working tree as a git repo (source tarball, CI checkout without + ``.git``, unborn HEAD, …). ``src_hash`` is an order-independent + SHA256 over every ``.py`` file under ``src/kfactory/`` so working-tree + edits invalidate the notebook cache even on a clean HEAD. + """ + head_oid: str | None = None + try: + import pygit2 + + repo = pygit2.Repository(str(repo_root)) + head_oid = str(repo.head.target) + except Exception: + head_oid = None + + src_root = repo_root / "src" / "kfactory" + src_h = hashlib.sha256() + if src_root.exists(): + for path in sorted(src_root.rglob("*.py")): + src_h.update(str(path.relative_to(repo_root)).encode()) + src_h.update(b"\0") + src_h.update(path.read_bytes()) + src_h.update(b"\0") + return {"head_oid": head_oid, "src_hash": src_h.hexdigest()} + + +def fingerprint_load(cache_dir: Path) -> dict[str, str | None]: + f = cache_dir / "fingerprint.json" + if not f.exists(): + return {} + try: + return json.loads(f.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + +def fingerprint_save(cache_dir: Path, fp: dict[str, str | None]) -> None: + cache_dir.mkdir(parents=True, exist_ok=True) + (cache_dir / "fingerprint.json").write_text(json.dumps(fp, indent=2)) + + +_PY_LINK_RE = re.compile(r"(\]\((?!https?://)[^)]+?)\.py(#[^)]*)?\)") + + +def rewrite_py_links(text: str) -> str: + """Rewrite Markdown links from foo.py → foo.md (skips http(s) URLs).""" + return _PY_LINK_RE.sub(lambda m: f"{m.group(1)}.md{m.group(2) or ''})", text) + + +def fence_indented_blocks(text: str) -> str: + """Convert nbconvert's indented (4-space) code blocks to fenced ```text + blocks. Zensical's CommonMark parser incorrectly parses reference-style + links inside indented blocks, but respects fenced blocks. Lines inside + existing fenced blocks (```) are passed through untouched. + """ + out: list[str] = [] + lines = text.splitlines(keepends=False) + i = 0 + in_fence = False + while i < len(lines): + line = lines[i] + stripped = line.lstrip() + if stripped.startswith(("```", "~~~")): + in_fence = not in_fence + out.append(line) + i += 1 + continue + if ( + not in_fence + and line.startswith(" ") + and (i == 0 or lines[i - 1].strip() == "") + ): + # Collect a contiguous indented block (4-space-prefixed lines, + # blank lines allowed inside as long as the next non-blank also + # starts with 4 spaces). + block: list[str] = [] + while i < len(lines): + cur = lines[i] + if cur.startswith(" "): + block.append(cur[4:]) + i += 1 + elif cur.strip() == "": + # Lookahead: continue only if the next non-blank line + # is also indented (still part of the block). + j = i + 1 + while j < len(lines) and lines[j].strip() == "": + j += 1 + if j < len(lines) and lines[j].startswith(" "): + block.append("") + i += 1 + else: + break + else: + break + out.append("```text") + out.extend(block) + out.append("```") + continue + out.append(line) + i += 1 + return "\n".join(out) + ("\n" if text.endswith("\n") else "") + + +def copy_md(src: Path, dst: Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + text = src.read_text() + new = rewrite_py_links(text) + if new == text: + shutil.copy2(src, dst) + else: + dst.write_text(new) + + +def copy_static(src_dir: Path, dst_dir: Path) -> None: + if not src_dir.exists(): + return + if dst_dir.exists(): + shutil.rmtree(dst_dir) + shutil.copytree(src_dir, dst_dir) + + +def is_jupytext_notebook(path: Path) -> bool: + """Detect jupytext percent-format .py by header signature.""" + if path.suffix != ".py": + return False + try: + head = path.read_text(errors="replace").splitlines()[:20] + except OSError: + return False + return any(line.strip().startswith("# %%") for line in head) or any( + "jupytext:" in line for line in head + ) + + +def convert_notebook( + src: Path, + src_root: Path, + out_root: Path, + *, + execute: bool = True, + timeout: int = 600, +) -> tuple[Path, Path]: + """Convert a jupytext .py to executed .ipynb + .md. + + Returns (md_path, ipynb_path). + """ + rel = src.relative_to(src_root) + md_out = out_root / rel.with_suffix(".md") + # Place .ipynb inside the page's asset directory so the relative + # download link resolves under mkdocs use_directory_urls (page at + # foo/index.html ↔ asset at foo/foo.ipynb). + assets_dir = md_out.with_suffix("") + assets_dir.mkdir(parents=True, exist_ok=True) + ipynb_out = assets_dir / f"{rel.stem}.ipynb" + md_out.parent.mkdir(parents=True, exist_ok=True) + + nb = jupytext.read(src, fmt="py:percent") + if "kernelspec" not in nb.metadata: + nb.metadata["kernelspec"] = { + "display_name": "Python 3", + "language": "python", + "name": "python3", + } + + # Inject a hidden setup cell at the top that points + # `kfactory.utilities.as_png_data` at our generated .lyp so every + # rendered notebook PNG picks up the documentation layer styles. + setup_cell = nbformat.v4.new_code_cell( + _DOC_STYLE_SETUP.format(lyp_path=str(DOC_STYLES_LYP)) + ) + setup_cell.metadata["tags"] = ["hide-input", "hide-output", "hide"] + nb.cells.insert(0, setup_cell) + + if execute: + ep = ExecutePreprocessor(timeout=timeout, kernel_name="python3") + ep.preprocess(nb, {"metadata": {"path": str(src.parent)}}) + + nbformat.write(nb, ipynb_out) + + # Drop text/html and text/latex outputs in favour of text/plain or + # images. nbconvert otherwise inlines Pygments-rendered HTML/LaTeX for + # things like IPython.display.Code(), and the bracketed Python code + # inside those blocks gets mis-parsed by CommonMark as reference-style + # links. Plain-text fallback round-trips cleanly through fenced code. + for cell in nb.cells: + if cell.cell_type != "code": + continue + for out in cell.get("outputs", []): + data = out.get("data") or {} + has_fallback = "text/plain" in data or any( + k.startswith("image/") for k in data + ) + if has_fallback: + for k in ("text/html", "text/latex"): + data.pop(k, None) + + cfg = Config() + cfg.TagRemovePreprocessor.remove_input_tags = ("hide", "hide-input") + cfg.TagRemovePreprocessor.remove_all_outputs_tags = ("hide", "hide-output") + cfg.TagRemovePreprocessor.enabled = True + cfg.MarkdownExporter.preprocessors = [ + "nbconvert.preprocessors.TagRemovePreprocessor" + ] + + exporter = MarkdownExporter(config=cfg) + body, resources = exporter.from_notebook_node(nb) + + # Write extracted output images into the assets directory + outputs: dict[str, bytes] = resources.get("outputs", {}) or {} + for name, data in outputs.items(): + (assets_dir / name).write_bytes(data) + # nbconvert references images by bare basename; rewrite to the + # assets_dir we just wrote (same name as the page, no suffix). + for name in outputs: + body = body.replace(f"]({name})", f"]({assets_dir.name}/{name})") + + # Rewrite cross-page Markdown links from .py → .md so the converted + # notebooks resolve siblings correctly under mkdocs/zensical. Runs + # before the download-button injection so the .ipynb link is safe. + body = rewrite_py_links(body) + # Convert nbconvert's indented cell-output blocks to fenced blocks + # so zensical's stricter CommonMark parser doesn't mis-parse text + # like `['name1', 'name2']` as reference-style links. + body = fence_indented_blocks(body) + + # Link points into the assets dir: foo.md → foo/foo.ipynb. + # Under use_directory_urls=true the rendered page sits at + # foo/index.html so the relative path becomes simply foo.ipynb. + download_btn = ( + f"[:material-download: Download notebook (.ipynb)]" + f"({assets_dir.name}/{ipynb_out.name}){{ .md-button }}\n\n" + ) + md_out.write_text(download_btn + body) + return md_out, ipynb_out + + +def gen_api_reference(out_root: Path, src_pkg: Path) -> list[Path]: + """Mirror src/kfactory/**/*.py to out_root/reference/**/*.md with + mkdocstrings ::: directive stubs. Replaces gen_ref_pages.py. + + URL layout (drops the redundant `kfactory/` prefix): + kfactory/__init__.py → reference/index.md (`::: kfactory`) + kfactory/cells/__init__.py → reference/cells/index.md (`::: kfactory.cells`) + kfactory/cells/bezier.py → reference/cells/bezier.md (`::: kfactory.cells.bezier`) + so that `/reference/` itself is the top-level package API page, + not a "click here to see the API" detour. + """ + # Wipe any previously-generated reference tree so a module removed + # from src/kfactory/ doesn't leave an orphan `::: kfactory.foo` page + # behind. CI restores docs/source-built/ via actions/cache restore-keys + # when the hash changes, and a stale directive for a now-removed + # module crashes mkdocstrings during the zensical build. + ref_root = out_root / "reference" + if ref_root.exists(): + shutil.rmtree(ref_root) + + written: list[Path] = [] + # Tree of (depth, title, doc_rel) preserving traversal order. + # Used to splice the API nav into zensical.yml since zensical 0.0.40 + # doesn't render `literate-nav` SUMMARY.md into the side panel. + api_tree: list[tuple[int, str, str]] = [] + + for py in sorted(src_pkg.rglob("*.py")): + rel = py.relative_to(src_pkg.parent) # e.g. kfactory/cells/bezier.py + parts = list(rel.with_suffix("").parts) + if parts[-1] == "__main__": + continue + is_package = parts[-1] == "__init__" + if is_package: + parts = parts[:-1] + sub_parts = parts[1:] # drop leading "kfactory" + doc_rel = ( + Path("index.md") if not sub_parts else Path(*sub_parts) / "index.md" + ) + else: + sub_parts = parts[1:] # drop leading "kfactory" + doc_rel = Path(*sub_parts).with_suffix(".md") + target = out_root / "reference" / doc_rel + target.parent.mkdir(parents=True, exist_ok=True) + ident = ".".join(parts) + # Package pages: render the package docstring only (members: false). + # Re-exports would otherwise pull in the entire library on the + # top-level page. Leaf modules render full mkdocstrings content. + # Submodule navigation goes through the side nav, which the build + # script splices into zensical.yml from `api_tree` below. + if is_package: + target.write_text(f"::: {ident}\n options:\n members: false\n") + else: + target.write_text(f"::: {ident}\n") + written.append(target) + depth = max(len(parts) - 1, 0) + title = parts[-1] if parts else "kfactory" + api_tree.append((depth, title, doc_rel.as_posix())) + + # Build the YAML fragment for splicing into zensical.yml's nav. + # Format: nested mapping so the API tab in the side nav has a tree. + api_nav_yaml = _api_tree_to_yaml(api_tree, indent=10) + (out_root / "_api_nav.yml").write_text(api_nav_yaml) + return written + + +def _api_tree_to_yaml(tree: list[tuple[int, str, str]], indent: int = 0) -> str: + """Render the flat DFS-order (depth, title, doc_rel) list as a nested + YAML nav fragment. Entries whose next sibling has greater depth are + treated as subpackage headers; their `index.md` is rendered as an + "Overview" child below the header. + + Output (indent=10): + - Overview: reference/index.md + - cells: + - Overview: reference/cells/index.md + - bezier: reference/cells/bezier.md + - virtual: + - Overview: reference/cells/virtual/index.md + - circular: reference/cells/virtual/circular.md + - cli: reference/cli.md + ... + """ + n = len(tree) + is_pkg = [i + 1 < n and tree[i + 1][0] > tree[i][0] for i in range(n)] + pad = " " * indent + lines: list[str] = [] + for i, (depth, title, doc_rel) in enumerate(tree): + # Tree depth 0 = root package (kfactory); its direct children + # become siblings of "Overview" so the side-nav doesn't have + # a redundant "kfactory:" wrapper. Effective indent column: + # depth 0 and 1 → 0, depth 2 → 4, depth 3 → 8, … + col = pad + (" " * max(0, depth - 1)) + link = f"reference/{doc_rel}" + if depth == 0 and is_pkg[i]: + lines.append(f"{col}- Overview: {link}") + elif is_pkg[i]: + lines.append(f"{col}- {title}:") + lines.append(f"{col} - Overview: {link}") + else: + lines.append(f"{col}- {title}: {link}") + return "\n".join(lines) + "\n" + + +def splice_zensical_config( + src_yml: Path, out_yml: Path, api_nav_fragment: Path +) -> None: + """Replace `- API: reference/ # SPLICE_API` in src_yml with + - API: + + written to out_yml. Plain text replacement (preserves the rest of + the YAML's comments + formatting); zensical's YAML loader still + validates it. + """ + text = src_yml.read_text() + marker_re = re.compile( + r"^([ \t]*)- API:[ \t]*reference/[ \t]*#[ \t]*SPLICE_API[ \t]*$", + re.MULTILINE, + ) + match = marker_re.search(text) + if not match: + raise RuntimeError( + f"Could not find `# SPLICE_API` marker in {src_yml}; the " + "API nav block won't be auto-generated. Add the marker back " + "or update the regex in build_docs_source.py." + ) + indent = match.group(1) + fragment = api_nav_fragment.read_text().rstrip("\n") + spliced = f"{indent}- API:\n{fragment}" + out_yml.write_text(marker_re.sub(spliced, text, count=1)) + + +def gen_diagrams(out_root: Path) -> list[Path]: + """Generate erdantic schematic diagrams. Skipped if erdantic is + missing (local dev without graphviz). Replaces gen_diagrams.py. + """ + try: + import erdantic as erd # type: ignore[import-not-found] + except ImportError: + print("[diagrams] erdantic not installed — skipping", flush=True) + return [] + + import kfactory as kf + + static_dir = out_root / "_static" + static_dir.mkdir(parents=True, exist_ok=True) + + class DSchematic(kf.schematic.TSchematic[float]): # type: ignore[name-defined] + __doc__ = kf.Schematic.__doc__ + + class Schematic(kf.schematic.TSchematic[int]): # type: ignore[name-defined] + __doc__ = kf.Schematic.__doc__ + + diagram_dbu = erd.create(Schematic, terminal_models=[kf.KCLayout]) + diagram_dbu.models["kfactory.layout.KCLayout"].fields = {} + out_dbu = static_dir / "schematic.svg" + diagram_dbu.draw(out_dbu) + + diagram_um = erd.create(DSchematic, terminal_models=[kf.KCLayout]) + diagram_um.models["kfactory.layout.KCLayout"].fields = {} + out_um = static_dir / "dschematic.svg" + diagram_um.draw(out_um) + return [out_dbu, out_um] + + +def process_one(args: tuple[Path, Path, Path, bool, int]) -> dict[str, Any]: + src, src_root, out_root, execute, timeout = args + t0 = time.perf_counter() + md, ipynb = convert_notebook( + src, src_root, out_root, execute=execute, timeout=timeout + ) + return { + "src": str(src), + "md": str(md), + "ipynb": str(ipynb), + "elapsed": time.perf_counter() - t0, + "hash": file_hash(src), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", default=str(REPO_ROOT / "docs/source")) + parser.add_argument("--out", default=str(REPO_ROOT / "docs/source-built")) + parser.add_argument("--cache", default=str(REPO_ROOT / "docs/.build-cache")) + parser.add_argument("--workers", type=int, default=4) + parser.add_argument("--timeout", type=int, default=600) + parser.add_argument("--no-execute", action="store_true") + parser.add_argument("--diagrams", action="store_true") + parser.add_argument("--clean", action="store_true") + parser.add_argument( + "--only", + action="append", + default=[], + help="Only convert notebooks whose path contains this substring", + ) + args = parser.parse_args(argv) + + src_root = Path(args.source).resolve() + out_root = Path(args.out).resolve() + cache_dir = Path(args.cache).resolve() + + if args.clean and out_root.exists(): + shutil.rmtree(out_root) + out_root.mkdir(parents=True, exist_ok=True) + + manifest = cache_load(cache_dir) + new_manifest = dict(manifest) + + # Invalidate the notebook cache if the source tree has moved since it + # was built — either a different git HEAD or any edit to + # `src/kfactory/**/*.py` in the working tree. Without this, source + # edits don't trigger a notebook re-execution. + current_fp = compute_source_fingerprint(REPO_ROOT) + cached_fp = fingerprint_load(cache_dir) + if ( + cached_fp.get("head_oid") != current_fp["head_oid"] + or cached_fp.get("src_hash") != current_fp["src_hash"] + ): + if cached_fp: + print( + "[cache] git HEAD or src/kfactory changed → invalidating notebook cache" + ) + manifest = {} + new_manifest = {} + + # Stage 1: copy .md files + md_count = 0 + for md in src_root.rglob("*.md"): + rel = md.relative_to(src_root) + copy_md(md, out_root / rel) + md_count += 1 + + # Stage 1b: copy static asset directories (anything under _static) + for static_dir in src_root.rglob("_static"): + if static_dir.is_dir(): + rel = static_dir.relative_to(src_root) + copy_static(static_dir, out_root / rel) + + # Stage 1c: generate the layer-styles .lyp from YAML. Must run AFTER + # Stage 1b because copy_static wipes the destination _static/ tree. + lyp = build_doc_styles_lyp() + if lyp is not None: + print(f"[stage1c] doc layer styles → {lyp.relative_to(REPO_ROOT)}") + + # Stage 2: jupytext .py → .md + .ipynb + notebooks = [p for p in src_root.rglob("*.py") if is_jupytext_notebook(p)] + if args.only: + notebooks = [p for p in notebooks if any(s in str(p) for s in args.only)] + + work: list[tuple[Path, Path, Path, bool, int]] = [] + skipped = 0 + for nb in notebooks: + h = file_hash(nb) + key = cache_key(nb, out_root) + if manifest.get(key) == h: + rel = nb.relative_to(src_root) + md_path = out_root / rel.with_suffix(".md") + ipynb_path = md_path.with_suffix("") / f"{rel.stem}.ipynb" + if md_path.exists() and ipynb_path.exists(): + skipped += 1 + # Carry forward the cached entry so a later --clean + # of source-built doesn't strand the manifest. + new_manifest[key] = h + continue + work.append((nb, src_root, out_root, not args.no_execute, args.timeout)) + + print( + f"[stage1] copied {md_count} .md files", + f"[stage2] {len(work)} notebooks to convert ({skipped} cached)", + sep="\n", + flush=True, + ) + + failures: list[tuple[Path, BaseException]] = [] + if work: + with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as ex: + futures = {ex.submit(process_one, w): w[0] for w in work} + for fut in concurrent.futures.as_completed(futures): + src = futures[fut] + try: + res = fut.result() + new_manifest[cache_key(Path(res["src"]), out_root)] = res["hash"] + print( + f" ✓ {Path(res['src']).relative_to(src_root)} " + f"({res['elapsed']:.1f}s)", + flush=True, + ) + except BaseException as e: + failures.append((src, e)) + print(f" ✗ {src.relative_to(src_root)}: {e}", flush=True) + + # Stage 3: API reference + print("[stage3] generating API reference …", flush=True) + ref_files = gen_api_reference(out_root, REPO_ROOT / "src" / "kfactory") + print(f" wrote {len(ref_files)} reference pages", flush=True) + + # Stage 3.5: splice API nav into zensical.yml → docs/zensical-built.yml + src_cfg = REPO_ROOT / "docs/zensical.yml" + spliced_cfg = REPO_ROOT / "docs/zensical-built.yml" + fragment = out_root / "_api_nav.yml" + splice_zensical_config(src_cfg, spliced_cfg, fragment) + print(f"[stage3.5] wrote {spliced_cfg.relative_to(REPO_ROOT)}", flush=True) + + # Stage 4: logo (κ generated from a real kfactory KCell → GDS + SVG) + print("[stage4] generating κ logo …", flush=True) + from gen_logo import generate as generate_logo + + logo_gds, logo_svg = generate_logo(out_root / "_static") + print(f" wrote {logo_gds.name} + {logo_svg.name}", flush=True) + + # Stage 5: diagrams (optional) + if args.diagrams: + print("[stage5] generating diagrams …", flush=True) + diag_files = gen_diagrams(out_root) + print(f" wrote {len(diag_files)} diagram(s)", flush=True) + + cache_save(cache_dir, new_manifest) + fingerprint_save(cache_dir, current_fp) + + if failures: + print(f"\n{len(failures)} notebook(s) failed:", file=sys.stderr) + for src, e in failures: + print(f" {src}: {type(e).__name__}: {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/scripts/gen_logo.py b/docs/scripts/gen_logo.py new file mode 100644 index 000000000..4aea4c219 --- /dev/null +++ b/docs/scripts/gen_logo.py @@ -0,0 +1,171 @@ +"""Generate the kfactory κ logo as a real GDS, then export to SVG. + +The κ is constructed in a `kf.KCLayout` using two layers (WG core and +STUB markers); each shape is a `kdb.DPolygon` produced from a centerline ++ width offset (cubic-Bezier centerlines stand in for true clothoids — +they're indistinguishable at favicon size and avoid pulling in the full +Euler-bend factory just to draw four shapes). + +The cell is written to `/logo.gds`, then walked shape-by-shape +to produce `/logo.svg` with one `` per shape and a +per-layer fill (turquoise core, deeper teal stubs). + +Called as a build stage from `build_docs_source.py` (writes into +`docs/source-built/_static/`); can also run standalone to refresh +the assets: + + uv run -p 3.14 --extra docs --with . python docs/scripts/gen_logo.py +""" + +from __future__ import annotations + +import argparse +import math +from pathlib import Path + +import kfactory as kf +from kfactory import kdb + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_OUT_DIR = REPO_ROOT / "docs/source-built/_static" + + +class LogoLayers(kf.LayerInfos): + WG: kdb.LayerInfo = kdb.LayerInfo(1, 0) + STUB: kdb.LayerInfo = kdb.LayerInfo(2, 0) + + +# Per-layer SVG fill. Turquoise reads well on both light and dark +# palettes; the stubs use a slightly deeper teal so the port markers +# show through against the lighter waveguide core. +LAYER_FILL: dict[str, str] = { + "WG": "#14B8A6", # tailwind teal-500 — main waveguide + "STUB": "#0F766E", # tailwind teal-700 — port-stub accents +} + + +def cubic_bezier( + p0: tuple[float, float], + p1: tuple[float, float], + p2: tuple[float, float], + p3: tuple[float, float], + n: int = 48, +) -> list[tuple[float, float]]: + pts: list[tuple[float, float]] = [] + for i in range(n + 1): + t = i / n + u = 1 - t + x = u**3 * p0[0] + 3 * u**2 * t * p1[0] + 3 * u * t**2 * p2[0] + t**3 * p3[0] + y = u**3 * p0[1] + 3 * u**2 * t * p1[1] + 3 * u * t**2 * p2[1] + t**3 * p3[1] + pts.append((x, y)) + return pts + + +def ribbon(centerline: list[tuple[float, float]], width: float) -> kdb.DPolygon: + """Offset a centerline ±width/2 to produce a closed ribbon polygon.""" + half = width / 2 + n = len(centerline) + left: list[tuple[float, float]] = [] + right: list[tuple[float, float]] = [] + for i, (x, y) in enumerate(centerline): + if i == 0: + tx, ty = centerline[1][0] - x, centerline[1][1] - y + elif i == n - 1: + tx, ty = x - centerline[i - 1][0], y - centerline[i - 1][1] + else: + tx = centerline[i + 1][0] - centerline[i - 1][0] + ty = centerline[i + 1][1] - centerline[i - 1][1] + m = math.hypot(tx, ty) or 1 + nx, ny = -ty / m, tx / m + left.append((x + nx * half, y + ny * half)) + right.append((x - nx * half, y - ny * half)) + pts = left + list(reversed(right)) + return kdb.DPolygon([kdb.DPoint(x, y) for x, y in pts]) + + +def build_logo() -> tuple[kf.KCLayout, kf.KCell]: + """Build the κ as a real KCell. Coordinates are in µm with y-up + (klayout convention). The SVG exporter flips y for SVG y-down.""" + kcl = kf.KCLayout("KFACTORY_LOGO", infos=LogoLayers) + layers = LogoLayers() + c = kcl.kcell("kappa_logo") + wg = kcl.find_layer(layers.WG) + stub = kcl.find_layer(layers.STUB) + + W = 5 # waveguide width µm + + # Stem — vertical waveguide at x=14, y=[8, 56] + c.shapes(wg).insert(kdb.DBox(14 - W / 2, 8, 14 + W / 2, 56)) + + # Upper arm — clothoid-like sweep from stem mid (14,32) up to (50,52) + upper = cubic_bezier((14, 32), (14, 40), (28, 50), (50, 52), n=48) + c.shapes(wg).insert(ribbon(upper, W)) + + # Lower arm — mirror across y=32, ending at (50,12) + lower = cubic_bezier((14, 32), (14, 24), (28, 14), (50, 12), n=48) + c.shapes(wg).insert(ribbon(lower, W)) + + # Port stubs — small filled squares at all four open ends + stub_size = 6 + for sx, sy in [(14, 59), (14, 5), (50, 55), (50, 9)]: + half = stub_size / 2 + c.shapes(stub).insert(kdb.DBox(sx - half, sy - half, sx + half, sy + half)) + + return kcl, c + + +def export_svg( + kcl: kf.KCLayout, + cell: kf.KCell, + viewbox: tuple[int, int, int, int] = (0, 0, 64, 64), +) -> str: + """Walk every shape per layer, emit one `` per shape with the + per-layer fill from LAYER_FILL. Flip y to match SVG's y-down axis.""" + _, _, _, vb_h = viewbox + layers = LogoLayers() + parts = [ + f'', + " kfactory", + ] + for layer_name, fill in LAYER_FILL.items(): + layer_info = getattr(layers, layer_name) + idx = kcl.find_layer(layer_info) + for shape in cell.shapes(idx).each(): + poly = shape.dpolygon + pts = " ".join(f"{p.x:g},{vb_h - p.y:g}" for p in poly.each_point_hull()) + parts.append( + f' ' + ) + parts.append("") + return "\n".join(parts) + + +def generate(out_dir: Path) -> tuple[Path, Path]: + """Build the logo, write `/logo.{gds,svg}`, return both paths.""" + out_dir.mkdir(parents=True, exist_ok=True) + out_gds = out_dir / "logo.gds" + out_svg = out_dir / "logo.svg" + kcl, c = build_logo() + c.write(str(out_gds)) + out_svg.write_text(export_svg(kcl, c) + "\n") + return out_gds, out_svg + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--out-dir", + default=str(DEFAULT_OUT_DIR), + help=f"Directory for logo.gds + logo.svg (default: {DEFAULT_OUT_DIR})", + ) + args = parser.parse_args(argv) + gds, svg = generate(Path(args.out_dir).resolve()) + print(f"Wrote {gds}") + print(f"Wrote {svg}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/scripts/gen_ref_pages.py b/docs/scripts/gen_ref_pages.py index 2efc07da8..50e22cac0 100644 --- a/docs/scripts/gen_ref_pages.py +++ b/docs/scripts/gen_ref_pages.py @@ -23,27 +23,27 @@ # MkDocs uses this file to build the site's sidebar. from pathlib import Path -import klayout + import mkdocs_gen_files nav = mkdocs_gen_files.Nav() -for path in sorted(Path("src").rglob("*.py")): # - module_path = path.relative_to("src").with_suffix("") # - doc_path = path.relative_to("src").with_suffix(".md") # - full_doc_path = Path("reference", doc_path) # +for path in sorted(Path("src").rglob("*.py")): + module_path = path.relative_to("src").with_suffix("") + doc_path = path.relative_to("src").with_suffix(".md") + full_doc_path = Path("reference", doc_path) parts = list(module_path.parts) - if parts[-1] == "__init__": # + if parts[-1] == "__init__": parts = parts[:-1] doc_path = doc_path.with_name("index.md") full_doc_path = full_doc_path.with_name("index.md") elif parts[-1] == "__main__": continue - nav[parts] = doc_path.as_posix() # + nav[parts] = doc_path.as_posix() with mkdocs_gen_files.open(full_doc_path, "w") as fd: ident = ".".join(parts) @@ -51,5 +51,5 @@ mkdocs_gen_files.set_edit_path(full_doc_path, path) - with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: # - nav_file.writelines(nav.build_literate_nav()) # + with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/docs/source/_static/doc_styles.yaml b/docs/source/_static/doc_styles.yaml new file mode 100644 index 000000000..c624b035a --- /dev/null +++ b/docs/source/_static/doc_styles.yaml @@ -0,0 +1,88 @@ +# Layer display styles applied to every rendered notebook PNG. +# +# Format follows kfactory.technology.layer_map.LypModel: +# layers: list of {name, layer: [number, datatype], fill_color, frame_color, +# dither_pattern, line_style, ...} +# +# Used by docs/scripts/build_docs_source.py — converted to a .lyp at build +# time and applied via kfactory.utilities.as_png_data. +# +# Documented layer convention (kept consistent across all notebooks): +# ( 1, 0) WG waveguide core +# ( 2, 0) WGCLAD / WGEX cladding / exclusion (and WG_FIXED in drc_fix demo) +# ( 3, 0) SLAB slab (rib process) +# ( 4, 0) NPP / CLAD doping / additional cladding +# ( 5, 0) DEEPOX deep oxide +# (10, 0) FLOORPLAN die outline / keep-out +# (11, 0) METAL1 +# (12, 0) METAL2 +# (20, 0) METAL generic metal +# (20, 1) METALEX metal exclusion / keep-out +# (99, 0) FLOORPLAN alternative die-outline convention +layers: + - name: WG + layer: [1, 0] + fill_color: "#00C000" + frame_color: "#008000" + dither_pattern: dotted + line_style: solid + - name: WGCLAD + layer: [2, 0] + fill_color: "#000000" + frame_color: "#808080" + dither_pattern: hollow + line_style: dotted + - name: SLAB + layer: [3, 0] + fill_color: "#909090" + frame_color: "#606060" + dither_pattern: lightly left-hatched + line_style: solid + - name: NPP + layer: [4, 0] + fill_color: "#A0A0E0" + frame_color: "#5050B0" + dither_pattern: coarsely dotted + line_style: solid + - name: DEEPOX + layer: [5, 0] + fill_color: "#705030" + frame_color: "#503010" + dither_pattern: hollow + line_style: dashed + - name: FLOORPLAN + layer: [10, 0] + fill_color: "#4040FF" + frame_color: "#2020A0" + dither_pattern: left-hatched + line_style: solid + - name: METAL1 + layer: [11, 0] + fill_color: "#FFA500" + frame_color: "#B07000" + dither_pattern: solid + line_style: solid + - name: METAL2 + layer: [12, 0] + fill_color: "#FF6020" + frame_color: "#B04000" + dither_pattern: solid + line_style: solid + - name: METAL + layer: [20, 0] + fill_color: "#FFA500" + frame_color: "#B07000" + dither_pattern: solid + line_style: solid + - name: METALEX + layer: [20, 1] + fill_color: "#000000" + frame_color: "#B07000" + dither_pattern: hollow + line_style: dotted + - name: FLOORPLAN_ALT + layer: [99, 0] + fill_color: "#4040FF" + frame_color: "#2020A0" + dither_pattern: left-hatched + line_style: solid diff --git a/docs/source/complex_cell.py b/docs/source/complex_cell.py deleted file mode 100644 index d35205d9e..000000000 --- a/docs/source/complex_cell.py +++ /dev/null @@ -1,56 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: -all -# text_representation: -# extension: .py -# format_name: light -# format_version: '1.5' -# jupytext_version: 1.16.2 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# This script builds a composite cell by taking a circular bend and a straight waveguide, snapping them together end-to-end, -# and then presenting the combined shape as a single new component with its own input and output ports. -# After creating a new instance of a bend and a new instance of a waveguide and placing it into the cell, -# it automatically connects the ports "o1" and "o2" -# Finally a cleanup is done: -# c.auto_rename_ports(): This renames the new ports "1" and "2" to a standard convention, "o1" and "o2" in this instance, based on their position. -# c.draw_ports(): This adds visual markers to the layout, making it easy to see where the ports are. -# kf.show(composite_cell()): When the script is run, this line calls the function to build the cell and then displays the final, -# connected component in the KLayout viewer. - -from layers import LAYER, si_enc -from straight import straight - -import kfactory as kf - - -@kf.cell -def composite_cell() -> kf.KCell: - c = kf.KCell() - - bend = c.create_inst( - kf.cells.circular.bend_circular( - 1000 * c.kcl.dbu, 20000 * c.kcl.dbu, LAYER.SI, enclosure=si_enc - ) # the standard kf.cells are in um, so we need to convert it to dbu - ) - wg = c << straight(1000, 5000, 5000) - - wg.connect("o1", bend, "o2") - - c.add_port(name="1", port=bend.ports["o1"]) - c.add_port(name="2", port=wg.ports["o2"]) - - c.auto_rename_ports() - - c.draw_ports() - - return c - - -if __name__ == "__main__": - kf.show(composite_cell()) diff --git a/docs/source/components/cells/factories/bezier.py b/docs/source/components/cells/factories/bezier.py new file mode 100644 index 000000000..63ef1c2ac --- /dev/null +++ b/docs/source/components/cells/factories/bezier.py @@ -0,0 +1,104 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Bezier S-Bend Factory +# +# `bend_s_bezier_factory(kcl)` returns a cached cell function for cubic-bezier +# S-bends. Arguments `width`, `height`, and `length` are in **µm**. +# `height` is the lateral offset (negative flips the bend); `length` is the +# longitudinal extent. + +# %% +import kfactory as kf +from kfactory.factories.bezier import bend_s_bezier_factory + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +pdk = kf.KCLayout("FACTORIES_BEZIER_DEMO", infos=LAYER) +L = LAYER() + +# %% [markdown] +# ## Basic call + +# %% +bend_s = bend_s_bezier_factory(pdk) + +b = bend_s(width=0.5, height=5.0, length=20.0, layer=L.WG) +print("S-bend:", b.name) +b + +# %% [markdown] +# ## Negative height +# +# A negative `height` flips the offset direction. + +# %% +b_flip = bend_s(width=0.5, height=-5.0, length=20.0, layer=L.WG) +b_flip + +# %% [markdown] +# ## Curve resolution +# +# `nb_points` controls the polygon resolution of the bezier backbone (default 99). +# Lower values trade smoothness for fewer vertices. + +# %% +b_lo = bend_s(width=0.5, height=5.0, length=20.0, layer=L.WG, nb_points=20) +b_hi = bend_s(width=0.5, height=5.0, length=20.0, layer=L.WG, nb_points=200) +print("low-res vertices:", b_lo.shapes(L.WG).each().__next__().polygon.num_points()) +print("hi-res vertices:", b_hi.shapes(L.WG).each().__next__().polygon.num_points()) + +# %% [markdown] +# ## Cladding via `LayerEnclosure` + +# %% +enc = kf.LayerEnclosure( + sections=[(L.WGCLAD, pdk.to_dbu(2.0))], + main_layer=L.WG, + kcl=pdk, +) + +b_clad = bend_s(width=0.5, height=5.0, length=20.0, layer=L.WG, enclosure=enc) +b_clad + +# %% [markdown] +# ## Adding metadata +# +# The factory accepts `additional_info` (a dict or callable returning a dict) +# that gets merged into `KCell.info`. + +# %% +bend_s_meta = bend_s_bezier_factory( + pdk, + additional_info={"pdk": "FACTORIES_BEZIER_DEMO", "component_type": "bezier_sbend"}, +) + +b_meta = bend_s_meta(width=0.5, height=5.0, length=20.0, layer=L.WG) +print("cell info:", dict(b_meta.info)) + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Factory overview | [Factories: Overview](overview.py) | +# | Euler S-bend (clothoid alternative) | [Factories: Euler](euler.py) | +# | All-angle routing with S-bends | [Routing: All-Angle](../../../routing/all_angle.py) | diff --git a/docs/source/components/cells/factories/circular.py b/docs/source/components/cells/factories/circular.py new file mode 100644 index 000000000..b5b2ab94f --- /dev/null +++ b/docs/source/components/cells/factories/circular.py @@ -0,0 +1,48 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Circular Bends +# +# `bend_circular_factory(kcl)` produces constant-radius arc bends. Unlike euler +# bends, `kf.routing.optical.get_radius` returns exactly the nominal radius. +# Arguments `width` and `radius` are in **µm**. + +# %% +import kfactory as kf +from kfactory.factories.circular import bend_circular_factory + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + + +pdk = kf.KCLayout("FACTORIES_CIRCULAR_DEMO", infos=LAYER) +L = LAYER() + +# %% +bend_circ = bend_circular_factory(pdk) + +bc90 = bend_circ(width=0.5, radius=10.0, layer=L.WG) +print("circular bend:", bc90.name) +print("footprint radius:", kf.routing.optical.get_radius(bc90), "µm (== nominal)") +bc90 + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| diff --git a/docs/source/components/cells/factories/euler.py b/docs/source/components/cells/factories/euler.py new file mode 100644 index 000000000..72602fff0 --- /dev/null +++ b/docs/source/components/cells/factories/euler.py @@ -0,0 +1,94 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Euler Bends +# +# Two factory functions live in `kf.factories.euler`: +# +# - `bend_euler_factory(kcl)` — clothoid 90° / arbitrary-angle bends. +# - `bend_s_euler_factory(kcl)` — S-shaped (laterally offset) clothoid bends. +# +# Unlike the DBU-native straight/taper factories, the euler factories take +# `width` and `radius` in **µm**. The factory handles the µm→DBU conversion +# internally. + +# %% +import kfactory as kf +from kfactory.factories.euler import bend_euler_factory, bend_s_euler_factory + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + + +pdk = kf.KCLayout("FACTORIES_EULER_DEMO", infos=LAYER) +L = LAYER() + +# %% [markdown] +# ## `bend_euler_factory` — 90° bends + +# %% +bend_euler = bend_euler_factory(pdk) + +# 90° bend, 0.5 µm wide, 10 µm radius +b90 = bend_euler(width=0.5, radius=10.0, layer=L.WG) +print("90° euler bend:", b90.name) +b90 + +# %% [markdown] +# ### Arbitrary angle +# +# The `angle` parameter (degrees, default 90) produces partial euler bends. + +# %% +b45 = bend_euler(width=0.5, radius=10.0, layer=L.WG, angle=45.0) +b180 = bend_euler(width=0.5, radius=10.0, layer=L.WG, angle=180.0) +print("45° bend:", b45.name) +print("180° bend:", b180.name) +b180 + +# %% [markdown] +# ### Effective radius +# +# Euler bends are clothoid curves — the actual footprint extends beyond the +# nominal radius. Use `kf.routing.optical.get_radius(bend)` to get the footprint +# radius for routing spacing calculations. + +# %% +footprint_r = kf.routing.optical.get_radius(b90) +print(f"nominal radius: 10.0 µm, footprint radius: {footprint_r:.3f} µm") + +# %% [markdown] +# ## `bend_s_euler_factory` — S-bends +# +# `bend_s_euler_factory(kcl)` creates S-shaped (offset) bends. The `offset` +# argument controls the lateral displacement (µm); a negative value flips the +# direction of the offset. + +# %% +sbend_euler = bend_s_euler_factory(pdk) + +sbend = sbend_euler(offset=5.0, width=0.5, radius=10.0, layer=L.WG) +print("S-bend:", sbend.name) +sbend + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Optical routing with euler bends | [Routing: Optical](../../../routing/optical.py) | diff --git a/docs/source/components/cells/factories/overview.py b/docs/source/components/cells/factories/overview.py new file mode 100644 index 000000000..8f20268e7 --- /dev/null +++ b/docs/source/components/cells/factories/overview.py @@ -0,0 +1,102 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Factory Functions +# +# A **factory** in kfactory is a function that returns another function — a +# *cell-making function* bound to a specific `KCLayout`. Factories are the +# recommended way to build production PDKs because they: +# +# - **Tie cells to a specific layout** — every cell built by the factory lives in +# the same `KCLayout`, so layer indices are consistent. +# - **Cache automatically** — the returned function is decorated with `@kcl.cell`, +# so repeated calls with the same arguments return the *same* cell object. +# - **Carry typed protocols** — each factory returns a typed `Protocol` callable, so +# IDEs and type-checkers can validate arguments at the call site. +# - **Enable routing** — the built-in routers (`route_bundle`, `place_manhattan`) +# require factory functions, not raw cells, so they can create bend/straight +# geometry on demand. +# +# ## Available factories +# +# | Factory | Module | Page | +# |---|---|---| +# | `straight_dbu_factory` | `kf.factories.straight` | [Straight](straight.py) | +# | `bend_euler_factory` / `bend_s_euler_factory` | `kf.factories.euler` | [Euler](euler.py) | +# | `bend_circular_factory` | `kf.factories.circular` | [Circular](circular.py) | +# | `bend_s_bezier_factory` | `kf.factories.bezier` | [Bezier](bezier.py) | +# | `taper_factory` | `kf.factories.taper` | [Taper](taper.py) | + +# %% [markdown] +# ## Factories and routing +# +# The built-in optical router (`kf.routing.optical.route_bundle`) requires factory +# callables — it uses them to instantiate bends and straights while building a +# route. Passing cells directly is not supported. +# +# ```python +# routes = kf.routing.optical.route_bundle( +# c, +# start_ports=[...], +# end_ports=[...], +# bend90_cell=bend_euler, # factory callable +# straight_factory=straight, # factory callable +# ) +# ``` +# +# Because each factory is bound to a `KCLayout`, all route geometry automatically +# lands in the correct layout and uses the correct layer indices. + +# %% [markdown] +# ## Bundling factories in a PDK module +# +# The recommended pattern for a production PDK is to define all factories in one +# place and import them wherever routing or assembly is needed: +# +# ```python +# # my_pdk/factories.py +# import kfactory as kf +# from kfactory.factories.straight import straight_dbu_factory +# from kfactory.factories.euler import bend_euler_factory +# from kfactory.factories.taper import taper_factory +# from my_pdk.layers import LAYER +# +# pdk = kf.KCLayout("MY_PDK", infos=LAYER) +# +# straight = straight_dbu_factory(pdk) +# bend_euler = bend_euler_factory(pdk) +# taper = taper_factory(pdk) +# +# __all__ = ["pdk", "straight", "bend_euler", "taper"] +# ``` + +# %% [markdown] +# ## Key rules +# +# - `straight_dbu_factory` / `taper_factory` — arguments in **DBU** +# - `bend_euler_factory` / `bend_circular_factory` — `width` and `radius` in **µm** +# - Always bind the factory to the same `KCLayout` that owns the cells being routed + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | PCells & caching | [Components: PCells](../pcells.py) | +# | Cross-sections | [Cross-Sections](../../cross_sections.py) | +# | Routing integration | [Routing: Overview](../../../routing/overview.py) | +# | PDK bundling pattern | [PDK: Creating a PDK](../../../pdk/creating_pdk.py) | diff --git a/docs/source/components/cells/factories/straight.py b/docs/source/components/cells/factories/straight.py new file mode 100644 index 000000000..b8520f64e --- /dev/null +++ b/docs/source/components/cells/factories/straight.py @@ -0,0 +1,92 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Straight Waveguide +# +# `straight_dbu_factory(kcl)` returns a cached cell function. Its canonical input +# is a **cross section** (by name, instance, or spec dict); the `length` is in +# **DBU** (database units). Convert µm values with `kcl.to_dbu()`. + +# %% +import kfactory as kf +from kfactory.factories.straight import straight_dbu_factory + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +pdk = kf.KCLayout("FACTORIES_STRAIGHT_DEMO", infos=LAYER) +L = LAYER() + +# %% [markdown] +# ## Build a few waveguides +# +# Each call with the same arguments returns the *same* cell object — that's the +# `@kcl.cell` cache at work. + +# %% +straight = straight_dbu_factory(pdk) + +# Register a symmetric cross section (width in DBU) on the PDK, then build by name. +wg = pdk.get_icross_section( + kf.CrossSectionSpecDict( + layer=L.WG, + width=pdk.to_dbu(0.5), # 500 DBU = 0.5 µm + unit="dbu", + name="WG", + ) +) + +wg_short = straight(cross_section="WG", length=pdk.to_dbu(10.0)) # 10 µm +wg_long = straight(cross_section="WG", length=pdk.to_dbu(20.0)) + +print("short name:", wg_short.name) +print("long name:", wg_long.name) +print( + "same object (same args)?", + wg_short is straight(cross_section=wg, length=pdk.to_dbu(10.0)), +) +wg_short + +# %% [markdown] +# ## Cladding / exclude via the cross section +# +# Extra layers (slab, exclude, cladding) are **sections** of the cross section — +# `(layer, width)` or `(layer, min, max)` offsets around the core, in DBU. + +# %% +wg_clad_xs = pdk.get_icross_section( + kf.CrossSectionSpecDict( + layer=L.WG, + width=pdk.to_dbu(0.5), + sections=[(L.WGCLAD, pdk.to_dbu(3.0))], + unit="dbu", + name="WG_CLAD", + ) +) + +wg_clad = straight(cross_section="WG_CLAD", length=pdk.to_dbu(15.0)) +wg_clad + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Cross-sections (alternative spec) | [Cross-Sections](../../cross_sections.py) | diff --git a/docs/source/components/cells/factories/taper.py b/docs/source/components/cells/factories/taper.py new file mode 100644 index 000000000..efa8dadb1 --- /dev/null +++ b/docs/source/components/cells/factories/taper.py @@ -0,0 +1,52 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Taper +# +# `taper_factory(kcl)` returns a function whose dimensions are all in **DBU**. +# Use `kcl.to_dbu(...)` to convert µm to DBU at the call site. + +# %% +import kfactory as kf +from kfactory.factories.taper import taper_factory + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + + +pdk = kf.KCLayout("FACTORIES_TAPER_DEMO", infos=LAYER) +L = LAYER() + +# %% +taper = taper_factory(pdk) + +tp = taper( + width1=pdk.to_dbu(0.5), + width2=pdk.to_dbu(2.0), + length=pdk.to_dbu(20.0), + layer=L.WG, +) +print("taper:", tp.name) +tp + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Cross-section based taper specification | [Cross-Sections](../../cross_sections.py) | diff --git a/docs/source/components/cells/overview.py b/docs/source/components/cells/overview.py new file mode 100644 index 000000000..024f5da9d --- /dev/null +++ b/docs/source/components/cells/overview.py @@ -0,0 +1,377 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Components & Factories +# +# kfactory ships a library of **built-in photonic components** under `kf.cells` and the +# **factories** that generate them under `kf.factories`. +# +# | Component | Cell function | Factory | +# |---|---|---| +# | Straight waveguide | `kf.cells.straight.straight` | `kf.factories.straight.straight_dbu_factory` | +# | Euler bend | `kf.cells.euler.bend_euler` | `kf.factories.euler.bend_euler_factory` | +# | Euler S-bend | `kf.cells.euler.bend_s_euler` | `kf.factories.euler.bend_s_euler_factory` | +# | Circular bend | `kf.cells.circular.bend_circular` | `kf.factories.circular.bend_circular_factory` | +# | Taper | `kf.cells.taper.taper` | `kf.factories.taper.taper_factory` | +# | Bezier S-bend | `kf.cells.bezier.bend_s` | `kf.factories.bezier.bend_s_bezier_factory` | +# +# ## Cells vs. Factories +# +# Every **cell function** under `kf.cells.*` uses the built-in demo `KCLayout` instance +# (`kf.cells.demo`). These are convenient for quick prototyping and learning. +# +# A **factory** is a function that *creates* a cell function bound to a specific +# `KCLayout`. When you build your own PDK you call the factory once, passing your +# layout, and get back a cell function that creates geometry in that layout: +# +# ```python +# my_straight = kf.factories.straight.straight_dbu_factory(kcl=my_kcl) +# wg = my_straight(width=500, length=10_000, layer=LAYER.WG) +# ``` +# +# This page demonstrates both patterns side by side. + +# %% [markdown] +# ## Setup + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## Straight waveguide +# +# `kf.cells.straight.straight` creates a rectangle of material with optional +# slab/exclude layers defined by a `LayerEnclosure`. +# +# ``` +# ┌──────────────────────────────┐ +# │ Slab/Exclude │ +# ├──────────────────────────────┤ +# │ │ +# │ Core │ +# │ │ +# ├──────────────────────────────┤ +# │ Slab/Exclude │ +# └──────────────────────────────┘ +# ``` +# +# Arguments are in **µm** (the `um` variant). All coordinates are converted to DBU +# internally. There is also a `straight_dbu` function for DBU-native code. + +# %% +from kfactory.cells.straight import straight + +# Simple straight — no cladding +wg = straight(width=0.5, length=10.0, layer=L.WG) +wg.plot() + +# %% [markdown] +# ### With cladding via LayerEnclosure +# +# Wrap the core with an oxide/slab layer using `LayerEnclosure`: + +# %% +enc = kf.LayerEnclosure( + dsections=[(L.WGCLAD, 3)], # 3 µm cladding on all sides + kcl=kf.kcl, +) + +wg_clad = straight(width=0.5, length=10.0, layer=L.WG, enclosure=enc) +wg_clad.plot() + +# %% [markdown] +# ### Using the factory directly +# +# For PDK work, bind a straight factory to your `KCLayout`: + +# %% +from kfactory.factories.straight import straight_dbu_factory + +my_straight = straight_dbu_factory(kcl=kf.kcl) + +# Dimensions in DBU (1 µm = 1000 dbu at default 1 nm/dbu) +wg_dbu = my_straight( + width=kf.kcl.to_dbu(0.5), + length=kf.kcl.to_dbu(20.0), + layer=L.WG, +) +wg_dbu.plot() + +# %% [markdown] +# ## Euler bend +# +# An **Euler bend** (clothoid bend) has a radius that varies continuously from 0 at the +# input to a maximum value at the midpoint and back to 0 at the output. This minimises +# mode mismatch and reflection compared to a circular bend of the same nominal radius. +# +# Key parameters: +# - `width` — waveguide core width \[µm\] +# - `radius` — nominal radius of the backbone \[µm\] +# - `angle` — total angle swept (default 90°) +# - `resolution` — number of backbone segments per 360° (default 150) + +# %% +from kfactory.cells.euler import bend_euler + +bend = bend_euler(width=0.5, radius=10.0, layer=L.WG) +bend.plot() + +# %% [markdown] +# ### Euler bend — custom angle + +# %% +bend_45 = bend_euler(width=0.5, radius=10.0, layer=L.WG, angle=45) +bend_45.plot() + +# %% [markdown] +# ### Euler S-bend +# +# An Euler S-bend offsets two ports laterally by `offset` µm. The backbone consists of +# two Euler quarter-circles joined at their inflection point. + +# %% +from kfactory.cells.euler import bend_s_euler + +sbend = bend_s_euler(offset=2.0, width=0.5, radius=10.0, layer=L.WG) +sbend.plot() + +# %% [markdown] +# ### Euler factory — PDK usage +# +# Bind an Euler factory to your layout to create bends that live in your PDK: + +# %% +from kfactory.factories.euler import bend_euler_factory + +my_bend_euler = bend_euler_factory(kcl=kf.kcl) +pdk_bend = my_bend_euler(width=0.5, radius=10.0, layer=L.WG) +pdk_bend.plot() + +# %% [markdown] +# ## Circular bend +# +# A **circular bend** has a *constant* radius throughout. It is faster to compute than +# an Euler bend but has higher mode mismatch at the junction with a straight waveguide. +# +# Key parameters: +# - `width` — waveguide core width \[µm\] +# - `radius` — constant bend radius \[µm\] +# - `angle` — angle swept (default 90°) +# - `angle_step` — angular resolution (default 1° per point) + +# %% +from kfactory.cells.circular import bend_circular + +circ = bend_circular(width=0.5, radius=10.0, layer=L.WG) +circ.plot() + +# %% [markdown] +# ### Circular bend — 180° + +# %% +circ_180 = bend_circular(width=0.5, radius=5.0, layer=L.WG, angle=180) +circ_180.plot() + +# %% [markdown] +# ## Taper +# +# A **linear taper** transitions between two different waveguide widths. Typical uses: +# - Mode-field adapter between a narrow routing waveguide and a wide MMI port. +# - Spot-size converter at a chip facet. +# +# ``` +# __ +# _/ │ +# _/ __│ +# _/ _/ │ +# │ _/ │ Core +# │_/ │ +# │_ │ +# │ \_ │ +# │_ \_ │ +# \_ \__│ +# \_ │ +# \__│ +# ``` + +# %% +from kfactory.cells.taper import taper + +t = taper(width1=0.5, width2=3.0, length=20.0, layer=L.WG) +t.plot() + +# %% [markdown] +# ### Taper with cladding + +# %% +enc_slab = kf.LayerEnclosure(dsections=[(L.SLAB, 2)], kcl=kf.kcl) + +t_clad = taper(width1=0.5, width2=3.0, length=20.0, layer=L.WG, enclosure=enc_slab) +t_clad.plot() + +# %% [markdown] +# ## Bezier S-bend +# +# A **Bezier S-bend** uses a cubic Bezier curve to create a smooth lateral offset. It +# offers more shape control than an Euler S-bend (via `t_start`/`t_stop`). +# +# Key parameters: +# - `width` — waveguide width \[µm\] +# - `height` — lateral offset between the two ports \[µm\] +# - `length` — horizontal span of the bend \[µm\] +# - `nb_points` — backbone resolution (default 99) + +# %% +from kfactory.cells.bezier import bend_s + +bez = bend_s(width=0.5, height=2.0, length=15.0, layer=L.WG) +bez.plot() + +# %% [markdown] +# ## Building your own PDK components +# +# The factory pattern lets you create component functions that are: +# 1. **Bound to your layout** — geometry lands in the right `KCLayout`. +# 2. **Cached automatically** — calling with the same params returns the same cell. +# 3. **Named consistently** — cell names embed all parameters for traceability. +# +# ### Example: custom PDK with two components + +# %% +# Create a dedicated layout for "MyPDK" +my_pdk = kf.KCLayout("MyPDK") +my_pdk.infos = L # reuse the same layer definitions + + +# Build factory-backed cell functions +@kf.cell +def my_waveguide(width: float, length: float) -> kf.KCell: + """Straight waveguide using MyPDK's layout. + + Args: + width: Core width [µm]. + length: Waveguide length [µm]. + """ + c = kf.KCell() + c.shapes(c.kcl.find_layer(L.WG)).insert( + kf.kdb.DBox(0, -width / 2, length, width / 2) + ) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, 0, 0), + layer=c.kcl.find_layer(L.WG), + width=kf.kcl.to_dbu(width), + port_type="optical", + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, kf.kcl.to_dbu(length), 0), + layer=c.kcl.find_layer(L.WG), + width=kf.kcl.to_dbu(width), + port_type="optical", + ) + ) + return c + + +wg_custom = my_waveguide(width=0.45, length=5.0) +print(f"Cell name: {wg_custom.name!r}") +print(f"Ports: {[p.name for p in wg_custom.ports]}") +wg_custom.plot() + +# %% [markdown] +# ### Caching in action +# +# The `@kf.cell` decorator caches cells by their parameter signature. Calling the same +# function with the same arguments returns the *identical* cell object: + +# %% +wg_a = my_waveguide(width=0.5, length=10.0) +wg_b = my_waveguide(width=0.5, length=10.0) +wg_c = my_waveguide(width=0.5, length=20.0) # different params → new cell + +print(f"wg_a is wg_b: {wg_a is wg_b}") # True — same cached cell +print(f"wg_a is wg_c: {wg_a is wg_c}") # False — different length + +# %% [markdown] +# ## Assembling components +# +# Use the `<<` operator to place cell instances and `connect()` to snap ports: + + +# %% +@kf.cell +def mzi_stub() -> kf.KCell: + """A minimal MZI stub — two bends connected via straights.""" + c = kf.KCell() + + enc = kf.LayerEnclosure(dsections=[(L.WGCLAD, 2)], kcl=kf.kcl) + + # Bend instances (euler) + b1 = c << bend_euler(width=0.5, radius=10.0, layer=L.WG) + b2 = c << bend_euler(width=0.5, radius=10.0, layer=L.WG) + b3 = c << bend_euler(width=0.5, radius=10.0, layer=L.WG) + b4 = c << bend_euler(width=0.5, radius=10.0, layer=L.WG) + + # Connect bends into a U-shape + b2.connect("o1", b1.ports["o2"]) + b3.connect("o1", b2.ports["o2"]) + b4.connect("o1", b3.ports["o2"]) + + # Straight arm between b1 and b4 + arm_length = kf.routing.optical.get_radius( + bend_euler(width=0.5, radius=10.0, layer=L.WG) + ) + s1 = c << straight(width=0.5, length=arm_length * 2, layer=L.WG, enclosure=enc) + s1.connect("o1", b4.ports["o2"]) + + c.add_ports(b1.ports.filter(port_type="optical", regex="o1")) + c.add_ports(s1.ports.filter(port_type="optical", regex="o2")) + c.auto_rename_ports() + return c + + +mzi = mzi_stub() +mzi.plot() + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Straight waveguide deep-dive | [Components: Straight](factories/straight.py) | +# | Euler (clothoid) bends | [Components: Euler Bends](factories/euler.py) | +# | Circular (constant-radius) bends | [Components: Circular Bends](factories/circular.py) | +# | Width tapers | [Components: Tapers](factories/taper.py) | +# | Bezier S-bends | [Components: Bezier](factories/bezier.py) | +# | Virtual (non-physical) cells | [Components: Virtual Cells](virtual.py) | +# | PCells & caching | [Components: PCells](pcells.py) | +# | Factory functions reference | [Components: Factories](factories/overview.py) | +# | KCell / DKCell / VKCell | [Core Concepts: KCell](../../concepts/kcell.py) | diff --git a/docs/source/components/cells/pcells.py b/docs/source/components/cells/pcells.py new file mode 100644 index 000000000..3c28f2746 --- /dev/null +++ b/docs/source/components/cells/pcells.py @@ -0,0 +1,418 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # PCells +# +# A [PCell](https://en.wikipedia.org/wiki/PCell) (short for **parameterized +# cell**) is a reusable cell whose geometry is generated by a Python function. +# In kfactory, any function decorated with `@kf.cell` becomes a PCell. +# +# Key benefits: +# +# - **Automatic caching** — calling the same function with the same arguments +# returns the *identical* cell object (no duplicate shapes or redundant computation). +# - **Auto-naming** — the cell name encodes its parameters (e.g. +# `wg_straight_W0p5_L10`), so GDS exports are always unambiguous. +# - **Settings dict** — each cached cell records its construction parameters in +# `cell.settings`, enabling round-tripping and LVS annotation. +# +# ## API quick-reference +# +# | Decorator | Cell type returned | Unit hint | +# |---|---|---| +# | `@kf.cell` | `KCell` (DBU integers) | DBU | +# | `@kf.cell(output_type=kf.DKCell)` | `DKCell` (µm floats) | µm | +# | `@kf.vcell` | `VKCell` (virtual; geometry is materialised in the layout only when the cell is inserted into a `KCell`) | any | +# | `@pdk.cell` | `KCell` bound to a specific `KCLayout` | DBU | +# +# ## Setup + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## 1 · Basic PCell — DBU API +# +# Decorate a function that creates a `KCell` with `@kf.cell`. All arguments must +# be hashable (integers, floats, strings, `LayerInfo`) so the cache key can be +# computed. + + +# %% +@kf.cell +def wg_straight(width: int, length: int) -> kf.KCell: + """Straight waveguide (DBU coordinates). + + Args: + width: Waveguide core width in DBU. + length: Waveguide length in DBU. + """ + c = kf.KCell() + layer = kf.kcl.find_layer(L.WG) + c.shapes(layer).insert(kf.kdb.Box(0, -width // 2, length, width // 2)) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, 0, 0), + width=width, + layer_info=L.WG, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, length, 0), + width=width, + layer_info=L.WG, + ) + ) + return c + + +# Construct with DBU arguments (1 nm = 1 DBU at default dbu=0.001 µm/DBU) +WG_WIDTH = kf.kcl.to_dbu(0.5) # 500 DBU (0.5 µm) +WG_LEN = kf.kcl.to_dbu(20.0) # 20000 DBU (20 µm) + +s = wg_straight(WG_WIDTH, WG_LEN) +s + +# %% [markdown] +# ### Automatic naming +# +# The cell name is derived from the function name and its arguments. Floats are +# formatted with `p` in place of `.` to keep the name GDS-legal. + +# %% +print(s.name) # → wg_straight_W500_L20000 +print(s.settings) # → KCellSettings(width=500, length=20000) + +# %% [markdown] +# ### Caching +# +# Calling `wg_straight` again with the *same* arguments returns the *exact same +# object* — the body of the function is **not** executed a second time. + +# %% +s2 = wg_straight(WG_WIDTH, WG_LEN) +s3 = wg_straight(WG_WIDTH, kf.kcl.to_dbu(30.0)) # different length → new cell + +print("same args → same object:", s is s2) # True +print("diff args → new object: ", s is s3) # False + +# %% [markdown] +# ## 2 · µm API — `output_type=kf.DKCell` +# +# For a µm-native interface, pass `output_type=kf.DKCell` to `@kf.cell`. The +# decorator wraps the returned `KCell` in a `DKCell` automatically — you can still +# build geometry in DBU inside the function. + + +# %% +@kf.cell(output_type=kf.DKCell) +def wg_straight_um(width: float, length: float) -> kf.KCell: + """Straight waveguide (µm API, DBU internals). + + Args: + width: Waveguide core width in µm. + length: Waveguide length in µm. + """ + c = kf.KCell() + layer = kf.kcl.find_layer(L.WG) + w = kf.kcl.to_dbu(width) + l_ = kf.kcl.to_dbu(length) + c.shapes(layer).insert(kf.kdb.Box(0, -w // 2, l_, w // 2)) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, 0, 0), + width=w, + layer_info=L.WG, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, l_, 0), + width=w, + layer_info=L.WG, + ) + ) + return c + + +wg = wg_straight_um(0.5, 20.0) +print("type:", type(wg).__name__) # DKCell +print("name:", wg.name) # wg_straight_um_W0p5_L20 +print("settings:", wg.settings) +wg + +# %% [markdown] +# ## 3 · Multi-layer PCell +# +# PCells can draw on multiple layers. Here a waveguide with a cladding layer +# (slab) demonstrates layered geometry. + + +# %% +@kf.cell +def wg_clad(width: int, length: int, clad_width: int) -> kf.KCell: + """Waveguide with slab cladding. + + Args: + width: Core width in DBU. + length: Waveguide length in DBU. + clad_width: Extra cladding on each side in DBU. + """ + c = kf.KCell() + wg_layer = kf.kcl.find_layer(L.WG) + clad_layer = kf.kcl.find_layer(L.WGCLAD) + + half_w = width // 2 + half_c = half_w + clad_width + + c.shapes(wg_layer).insert(kf.kdb.Box(0, -half_w, length, half_w)) + c.shapes(clad_layer).insert(kf.kdb.Box(0, -half_c, length, half_c)) + + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, 0, 0), + width=width, + layer_info=L.WG, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, length, 0), + width=width, + layer_info=L.WG, + ) + ) + return c + + +wg_c = wg_clad( + width=kf.kcl.to_dbu(0.5), + length=kf.kcl.to_dbu(20.0), + clad_width=kf.kcl.to_dbu(3.0), +) +wg_c + +# %% [markdown] +# ## 4 · Composing PCells +# +# PCells can reference other PCells via `cell.create_inst(other_cell)`. The inner +# cell is fetched from the cache (or created once and cached), so there is no +# duplication even when many parent cells share the same child. + + +# %% +@kf.cell +def y_branch(width: int, length: int, arm_sep: int) -> kf.KCell: + """Simplified Y-branch: one input, two parallel outputs. + + Args: + width: Waveguide width in DBU. + length: Arm length in DBU. + arm_sep: Centre-to-centre separation of the two output arms in DBU. + """ + c = kf.KCell() + arm = wg_straight(width, length) # fetched from cache + + inst_top = c.create_inst(arm) + inst_bot = c.create_inst(arm) + + inst_top.transform(kf.kdb.Trans(0, False, 0, arm_sep // 2)) + inst_bot.transform(kf.kdb.Trans(0, False, 0, -(arm_sep // 2))) + + c.add_port(port=inst_top.ports["o2"], name="o_top") + c.add_port(port=inst_bot.ports["o2"], name="o_bot") + return c + + +yb = y_branch(WG_WIDTH, kf.kcl.to_dbu(20.0), kf.kcl.to_dbu(10.0)) +yb + +# %% [markdown] +# ## 5 · Virtual cells — `@kf.vcell` +# +# A `VKCell` (virtual cell) is built like a PCell but is **not** registered in the +# KLayout cell database. It is useful for intermediate helper geometry that never +# needs to appear as a standalone cell in the GDS. + + +# %% +@kf.vcell +def marker_cross(size: int) -> kf.VKCell: + """Alignment cross marker (virtual — not stored in the layout DB). + + Args: + size: Half-width of each arm in DBU. + """ + v = kf.VKCell() + layer = kf.kcl.find_layer(L.WG) + v.shapes(layer).insert(kf.kdb.Box(-size // 4, -size, size // 4, size)) + v.shapes(layer).insert(kf.kdb.Box(-size, -size // 4, size, size // 4)) + return v + + +m = marker_cross(kf.kcl.to_dbu(5.0)) +print("VKCell type:", type(m).__name__) # VKCell + +# %% [markdown] +# ## 6 · Per-PDK cells — `@pdk.cell` +# +# When building a PDK, each component should be created inside that PDK's +# `KCLayout` instance rather than the global `kf.kcl`. Use `@pdk.cell` instead +# of `@kf.cell` so that layer indices are looked up from the correct layout. + + +# %% +class PDK_LAYERS(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + + +pdk = kf.KCLayout("DEMO_PDK", infos=PDK_LAYERS) + + +@pdk.cell +def pdk_straight(width: float, length: float) -> kf.KCell: + """Straight waveguide inside DEMO_PDK layout. + + Args: + width: Core width in µm. + length: Waveguide length in µm. + """ + c = pdk.kcell() + layer = pdk.find_layer(PDK_LAYERS().WG) + w = pdk.to_dbu(width) + l_ = pdk.to_dbu(length) + c.shapes(layer).insert(kf.kdb.Box(0, -w // 2, l_, w // 2)) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, 0, 0), + width=w, + layer_info=PDK_LAYERS().WG, + kcl=pdk, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, l_, 0), + width=w, + layer_info=PDK_LAYERS().WG, + kcl=pdk, + ) + ) + return c + + +ps = pdk_straight(0.45, 15.0) +print("name:", ps.name) # pdk_straight_W0p45_L15 +print("kcl: ", ps.kcl.name) # DEMO_PDK +ps + +# %% [markdown] +# ## 7 · Decorator options +# +# The `@kf.cell` decorator accepts several keyword arguments to control naming, +# caching, and validation. +# +# | Option | Default | Effect | +# |---|---|---| +# | `output_type` | `None` (→ `KCell`) | Wrap result in `DKCell` or a subclass | +# | `basename` | function name | Override the name prefix | +# | `set_name` | `True` | Auto-set cell name from params | +# | `set_settings` | `True` | Populate `cell.settings` | +# | `check_ports` | `True` | Warn on duplicate/unnamed ports | +# | `snap_ports` | `True` | Snap port positions to grid | +# | `cache` | shared per-layout dict | Custom cache (e.g. `{}` to disable) | + + +# %% +@kf.cell(basename="WG_STRIP", set_settings=True) +def strip_waveguide(width: float, length: float) -> kf.KCell: + """Strip waveguide with custom basename.""" + c = kf.KCell() + layer = kf.kcl.find_layer(L.WG) + w = kf.kcl.to_dbu(width) + l_ = kf.kcl.to_dbu(length) + c.shapes(layer).insert(kf.kdb.Box(0, -w // 2, l_, w // 2)) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, 0, 0), + width=w, + layer_info=L.WG, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, l_, 0), + width=w, + layer_info=L.WG, + ) + ) + return c + + +sw = strip_waveguide(0.5, 10.0) +print("name: ", sw.name) # WG_STRIP_W0p5_L10 +print("settings:", sw.settings) +sw + +# %% [markdown] +# ## Summary +# +# | Task | API | +# |---|---| +# | DBU PCell | `@kf.cell` on a function returning `KCell` | +# | µm PCell | `@kf.cell(output_type=kf.DKCell)` | +# | Virtual cell | `@kf.vcell` on a function returning `VKCell` | +# | PDK-scoped cell | `@pdk.cell` where `pdk` is a `KCLayout` | +# | Custom name prefix | `@kf.cell(basename="MY_CELL")` | +# | Disable name/settings | `set_name=False`, `set_settings=False` | +# +# **Caching rules:** Two calls with equal arguments produce the same object — `a is b` +# is `True`. Arguments must be hashable (int, float, str, `LayerInfo`, frozen +# containers). Pass mutable state (like a `LayerEnclosure`) by name, not by value, +# to avoid hash errors. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Factory functions reference | [Components: Factories](factories/overview.py) | +# | Virtual (non-physical) cells | [Components: Virtual Cells](virtual.py) | +# | KCell / DKCell / VKCell basics | [Core Concepts: KCell](../../concepts/kcell.py) | +# | DBU vs µm coordinate systems | [Core Concepts: DBU vs µm](../../concepts/dbu_vs_um.py) | +# | Creating a full PDK | [PDK: Creating a PDK](../../pdk/creating_pdk.py) | diff --git a/docs/source/components/cells/virtual.py b/docs/source/components/cells/virtual.py new file mode 100644 index 000000000..f8b68b0e1 --- /dev/null +++ b/docs/source/components/cells/virtual.py @@ -0,0 +1,330 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Virtual PCells +# +# A **Virtual PCell** (`VKCell`, short for *Virtual KCell*) is an in-memory PCell +# whose geometry lives entirely in floating-point micrometres — no KLayout +# database, no DBU conversion, until you explicitly materialise it. +# +# ``` +# ┌──────────────────────────────────────────────┐ +# │ VKCell (µm) │ +# │ ┌──────────┐ ┌──────────┐ │ +# │ │ VInstance│ │ VShapes │ DPolygon, DBox │ +# │ └──────────┘ └──────────┘ │ +# │ │ │ │ +# │ ▼ ▼ │ +# │ insert_into(real_kcell) │ +# └──────────────────────────────────────────────┘ +# │ +# ▼ +# KCell (DBU, KLayout-backed) +# ``` +# +# ## When to use VKCell +# +# | Use case | Recommended cell type | +# |---|---| +# | Production GDS, routing into a final layout | `KCell` | +# | All-angle routing backbone computation | `VKCell` | +# | Preview / inspect before committing geometry | `VKCell` | +# | Factory functions that need composition before materialisation | `VKCell` | +# +# ## Key differences vs KCell +# +# | Property | `VKCell` | `KCell` | +# |---|---|---| +# | Coordinates | float µm | integer DBU | +# | Backed by KLayout cell DB | No — pure Python | Yes | +# | Shape type | `VShapes` (`DPolygon`, `DBox`, …) | `klayout.db.Shapes` | +# | `@kf.vcell` / `kcl.vcell` decorator | Yes | No (`@kf.cell`) | +# | Must call `insert_into()` to materialise | Yes | — | +# +# ## Setup + +# %% +from functools import partial + +import kfactory as kf +from kfactory.factories.virtual.euler import virtual_bend_euler_factory +from kfactory.factories.virtual.straight import virtual_straight_factory + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +L = LAYER() + +# Use a dedicated KCLayout so the virtual factories' layer indices are consistent +pdk = kf.KCLayout("virtual_demo", infos=LAYER) + +# %% [markdown] +# ## Creating a VKCell directly +# +# `kcl.vkcell(name=...)` returns an empty VKCell attached to the given layout. +# Shapes are added via `vkcell.shapes(layer_index).insert(...)` using µm-based +# `DPolygon` / `DBox` objects. + +# %% +vc_box = pdk.vkcell(name="simple_box") + +layer_idx = pdk.layer(L.WG) + +# Insert a rectangle in µm +vc_box.shapes(layer_idx).insert(kf.kdb.DBox(0, -0.25, 10.0, 0.25)) + +print(f"VKCell name : {vc_box.name}") +print(f"bbox (µm) : {vc_box.dbbox(layer_idx)}") +print(f"bbox (DBU) : {vc_box.ibbox(layer_idx)}") +print(f"shapes count : {vc_box.shapes(layer_idx).size()}") + +# %% [markdown] +# Notice that `ibbox` returns the bounding box in DBU (integer units), while +# `dbbox` returns it in µm. The two are consistent — multiplied by `pdk.dbu` (nm/µm). + +# %% [markdown] +# ## Ports on a VKCell +# +# Ports are created with `create_port` using a `DCplxTrans` (µm-based complex +# transform) to specify position and orientation. + +# %% +vc_wg = pdk.vkcell(name="virtual_wg") +wg_layer = pdk.layer(L.WG) + +vc_wg.shapes(wg_layer).insert(kf.kdb.DBox(0, -0.25, 20.0, 0.25)) + +# o1 faces west (angle=180°) +vc_wg.create_port( + name="o1", + dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, 0.0, 0.0), + width=0.5, + layer=wg_layer, +) +# o2 faces east (angle=0°) +vc_wg.create_port( + name="o2", + dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, 20.0, 0.0), + width=0.5, + layer=wg_layer, +) + +for p in vc_wg.ports: + print(f" {p.name}: trans={p.dcplx_trans} width={p.dwidth:.3f} µm") + +# %% [markdown] +# ## Materialising into a KCell +# +# Call `VInstance(vc).insert_into(target)` to convert the virtual geometry to a +# real `KCell` instance inside `target`. This is a one-shot operation — you can +# call it multiple times to place the virtual cell at different locations. + +# %% +target = kf.KCell("wg_from_virtual", kcl=pdk) +vi = kf.VInstance(vc_wg) +vi.insert_into(target) + +print(f"Real cell name : {target.name}") +print(f"Real cell bbox : {target.dbbox()} µm") +target.plot() + +# %% [markdown] +# ## Nesting VKCells +# +# VKCells can contain instances of other VKCells. The whole hierarchy is flattened +# into real KLayout cells when `insert_into` is called on the outermost level. + +# %% +# Build two virtual waveguide arms +arm_a = pdk.vkcell(name="arm_a") +arm_a.shapes(wg_layer).insert(kf.kdb.DBox(0, -0.25, 30.0, 0.25)) + +arm_b = pdk.vkcell(name="arm_b") +arm_b.shapes(wg_layer).insert(kf.kdb.DBox(0, -0.25, 30.0, 0.25)) + +# Compose them inside a parent virtual cell +parent_vc = pdk.vkcell(name="virtual_composed") +inst_a = parent_vc.create_inst(cell=arm_a) # at (0, 0) +inst_b = parent_vc.create_inst( + cell=arm_b, + trans=kf.kdb.DCplxTrans(1, 0, False, 0.0, 2.0), # shift 2 µm up +) + +print(f"parent bbox (µm): {parent_vc.dbbox()}") + +# Materialise the whole hierarchy at once +c_composed = kf.KCell("composed_from_virtual", kcl=pdk) +kf.VInstance(parent_vc).insert_into(c_composed) +c_composed.plot() + +# %% [markdown] +# ## `@kf.vcell` decorator +# +# For reusable virtual component factories use the `@pdk.vcell` decorator — it +# works like `@pdk.cell` but returns a `VKCell` and caches by parameter hash. + + +# %% +@pdk.vcell +def virtual_straight( + width: float, + length: float, + layer: kf.kdb.LayerInfo, +) -> kf.VKCell: + c = pdk.vkcell() + lyr = pdk.layer(layer) + half_w = width / 2 + c.shapes(lyr).insert(kf.kdb.DBox(0, -half_w, length, half_w)) + c.create_port( + name="o1", + dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, 0.0, 0.0), + width=width, + layer=lyr, + ) + c.create_port( + name="o2", + dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, length, 0.0), + width=width, + layer=lyr, + ) + return c + + +s1 = virtual_straight(width=0.5, length=10.0, layer=L.WG) +s2 = virtual_straight(width=0.5, length=10.0, layer=L.WG) # cached + +print(f"s1 is s2 (cached): {s1 is s2}") +print(f"s1 bbox : {s1.dbbox(pdk.layer(L.WG))} µm") + +# %% [markdown] +# ## Virtual factories +# +# kfactory ships built-in virtual factories for straight waveguides and euler bends. +# These are the building blocks for all-angle routing (see +# [All-Angle Routing](../../routing/all_angle.py)). +# +# | Factory | Source module | Parameter units | +# |---|---|---| +# | `virtual_straight_factory(kcl)` | `kfactory.factories.virtual.straight` | µm | +# | `virtual_bend_euler_factory(kcl)` | `kfactory.factories.virtual.euler` | µm | + +# %% +_v_straight_raw = virtual_straight_factory(kcl=pdk) +_v_bend_raw = virtual_bend_euler_factory(kcl=pdk) + +# Bind common parameters with functools.partial +v_straight = partial(_v_straight_raw, layer=L.WG) +v_bend = partial(_v_bend_raw, width=0.5, radius=10.0, layer=L.WG) + +# Produce virtual components +vs = v_straight(width=0.5, length=15.0) +vb = v_bend(angle=90) + +print(f"virtual straight bbox : {vs.dbbox(pdk.layer(L.WG))} µm") +print(f"virtual bend bbox : {vb.dbbox(pdk.layer(L.WG))} µm") +print(f"virtual bend ports : {[p.name for p in vb.ports]}") + +# %% [markdown] +# ## All-angle routing into a VKCell +# +# The most common reason to work with VKCell directly is **all-angle routing** — +# computing the route in virtual space, inspecting it, then materialising once +# satisfied. + +# %% +vc_route = pdk.vkcell(name="aa_preview") + +kf.routing.aa.optical.route( + vc_route, + width=0.5, + backbone=[ + kf.kdb.DPoint(0, 0), + kf.kdb.DPoint(80, 0), + kf.kdb.DPoint(80, 60), + kf.kdb.DPoint(160, 60), + ], + straight_factory=v_straight, + bend_factory=v_bend, +) + +print(f"Virtual route bbox (µm): {vc_route.dbbox()}") +print(f"Ports: {[p.name for p in vc_route.ports]}") + +# Materialise into a real cell +c_aa = kf.KCell("aa_from_vkcell", kcl=pdk) +kf.VInstance(vc_route).insert_into(c_aa) +c_aa.plot() + +# %% [markdown] +# ## `insert_into_flat` +# +# Use `insert_into_flat` when you want the virtual geometry inlined directly into +# the target cell (no sub-cell hierarchy created). + +# %% +c_flat = kf.KCell("aa_flat", kcl=pdk) + +vc_flat = pdk.vkcell(name="route_flat_src") +kf.routing.aa.optical.route( + vc_flat, + width=0.5, + backbone=[ + kf.kdb.DPoint(0, 0), + kf.kdb.DPoint(50, 0), + kf.kdb.DPoint(50, 40), + kf.kdb.DPoint(100, 40), + ], + straight_factory=v_straight, + bend_factory=v_bend, +) + +kf.VInstance(vc_flat).insert_into_flat(c_flat) + +print(f"Instances in c_flat : {list(c_flat.each_inst())}") +print(f"c_flat bbox (µm) : {c_flat.dbbox()}") +c_flat.plot() + +# %% [markdown] +# With `insert_into_flat` all geometry lands directly in `c_flat` — no child cells +# are created. +# +# ## Summary +# +# | Operation | Code | +# |---|---| +# | Create VKCell | `vc = kcl.vkcell(name="...")` | +# | Insert shape | `vc.shapes(layer_idx).insert(kdb.DBox(...))` | +# | Add port | `vc.create_port(name=..., dcplx_trans=..., width=..., layer=...)` | +# | Nest VKCells | `vc.create_inst(cell=child_vc, trans=...)` | +# | Reusable factory | `@pdk.vcell` decorator | +# | Materialise (hierarchical) | `kf.VInstance(vc).insert_into(target)` | +# | Materialise (flat) | `kf.VInstance(vc).insert_into_flat(target)` | +# | Virtual straight factory | `virtual_straight_factory(kcl=pdk)` | +# | Virtual euler bend factory | `virtual_bend_euler_factory(kcl=pdk)` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | PCells & caching | [Components: PCells](pcells.py) | +# | Factory functions reference | [Components: Factories](factories/overview.py) | +# | All-angle routing into VKCell | [Routing: All-Angle](../../routing/all_angle.py) | +# | KCell / DKCell / VKCell basics | [Core Concepts: KCell](../../concepts/kcell.py) | diff --git a/docs/source/components/cross_sections.py b/docs/source/components/cross_sections.py new file mode 100644 index 000000000..98c941857 --- /dev/null +++ b/docs/source/components/cross_sections.py @@ -0,0 +1,504 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Cross-Sections +# +# A **cross-section** bundles everything that describes a waveguide (or wire) profile +# into a single reusable object: +# +# | Field | Meaning | +# |---|---| +# | `width` | Core width (must be even in DBU so geometry stays symmetric) | +# | `layer` | Primary / core layer | +# | `sections` | Extra layers drawn around the core (cladding, slab, keep-out, …) | +# | `radius` | *Hint* for the router: preferred bend radius (not enforced) | +# | `radius_min` | *Hint* for the router: minimum allowed bend radius (DRC) | +# | `bbox_layers` / `bbox_offsets` | Bounding-box expansion layers (floorplan, die outline) | +# +# Cross-sections are stored in the `KCLayout` registry so any part of a design +# can look one up by name. Routers and schematic-driven design both use cross-section +# names to parameterize routing. +# +# ## Relation to `LayerEnclosure` +# +# Under the hood a cross-section wraps a `LayerEnclosure`. The enclosure defines all +# the `sections`; the cross-section adds the core `width` and routing hints. +# See [Layer Enclosures](layer_enclosure.py) for the enclosure details. +# +# ## Three cross-section classes +# +# | Class | Units | Typical use | +# |---|---|---| +# | `SymmetricalCrossSection` | DBU | Immutable data model — the canonical form stored in `KCLayout` | +# | `CrossSection` | DBU | View of a `SymmetricalCrossSection` bound to a `KCLayout` | +# | `DCrossSection` | µm | Human-friendly µm variant — converts to DBU internally | + +# %% [markdown] +# ## Setup + +# %% +import kfactory as kf +from kfactory.cross_section import SymmetricalCrossSection + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## 1 · Building a cross-section with `DCrossSection` (µm) +# +# `DCrossSection` is the easiest starting point — all dimensions in µm. +# +# ``` +# ◄──── 3 µm ────► WGCLAD (cladding) +# ◄── 0.5 µm ──► WG (core) +# +# ┌───────────────┐ +# │ WGCLAD │ +# │ ┌─────────┐ │ +# │ │ WG │ │ +# │ └─────────┘ │ +# │ │ +# └───────────────┘ +# ``` +# +# The `sections` list entries have the form `(layer, d_max)` or +# `(layer, d_min, d_max)` — the distance(s) from the **edge of the core**: + +# %% +# DCrossSection — all dimensions in µm +xs_wg = kf.DCrossSection( + kcl=kf.kcl, + width=0.5, # core width µm + layer=L.WG, + sections=[ + (L.WGCLAD, 2.0), # cladding: 0 → 2 µm from core edge (symmetric) + ], + radius=10.0, # preferred bend radius hint + radius_min=5.0, # minimum bend radius hint (DRC) + name="WG_500", +) + +print(f"name: {xs_wg.name}") +print(f"width (µm): {xs_wg.width}") +print(f"layer: {xs_wg.layer}") +print(f"radius (µm): {xs_wg.radius}") +print(f"sections: {xs_wg.sections}") + +# %% [markdown] +# ## 2 · Storing in `KCLayout` and looking up by name +# +# Call `kf.kcl.get_icross_section()` (DBU view) or `kf.kcl.get_dcross_section()` (µm view) +# to register a cross-section. On first call with a new spec it is stored; subsequent +# calls with the same name return the cached version. + +# %% +# Register — this returns a CrossSection (DBU view) bound to kf.kcl +xs_dbu: kf.CrossSection = kf.kcl.get_icross_section(xs_wg) + +# Retrieve by name later — handy in factory functions that only know the name +xs_retrieved: kf.CrossSection = kf.kcl.get_icross_section("WG_500") + +print(f"same object? {xs_dbu.base == xs_retrieved.base}") +print(f"width (DBU): {xs_dbu.width}") # 500 DBU at 1 nm/DBU + +# %% [markdown] +# ## 3 · DBU variant with `CrossSection` +# +# Use `CrossSection` when you want full control at the database-unit level. +# All dimensions are integers (database units). + +# %% +# CrossSection — dimensions in DBU (1 DBU = 1 nm by default) +xs_dbu_direct = kf.CrossSection( + kcl=kf.kcl, + width=500, # 500 DBU = 0.5 µm + layer=L.WG, + sections=[ + (L.WGCLAD, 0, 2_000), # (layer, d_min, d_max) in DBU + ], + radius=10_000, # 10 µm in DBU + name="WG_500_dbu", +) + +print(f"width (DBU): {xs_dbu_direct.width}") +print(f"width (µm): {kf.kcl.to_um(xs_dbu_direct.width)}") + +# %% [markdown] +# ## 4 · `SymmetricalCrossSection` — the canonical data model +# +# Both `CrossSection` and `DCrossSection` wrap a `SymmetricalCrossSection`, which is +# the immutable Pydantic model that gets stored in `KCLayout.cross_sections`. +# You can build one directly using an existing `LayerEnclosure`: + +# %% +enc = kf.kcl.get_enclosure( + kf.LayerEnclosure( + name="WG_RIB", + main_layer=L.WG, + sections=[ + (L.WGCLAD, 0, 2_000), + (L.SLAB, 0, 5_000), + ], + ) +) + +xs_base = SymmetricalCrossSection( + width=700, # 700 DBU = 0.7 µm + enclosure=enc, + name="WG_700_RIB", + radius=15_000, + radius_min=8_000, +) + +xs_rib: kf.CrossSection = kf.kcl.get_icross_section(xs_base) +print(f"layers: {list(xs_rib.sections.keys())}") +print(f"radius: {kf.kcl.to_um(xs_rib.radius)} µm") + +# %% [markdown] +# ## 5 · Multi-layer and annular sections +# +# Three-element section tuples `(layer, d_min, d_max)` produce **annular** (ring-shaped) +# regions — useful for doping implants or etch-stop layers that must not touch the core +# edge. + +# %% +xs_implant = kf.DCrossSection( + kcl=kf.kcl, + width=0.5, + layer=L.WG, + sections=[ + (L.WGCLAD, 2.0), # cladding: extends 0–2 µm from core + (L.SLAB, 0.5, 3.0), # slab ring: starts 0.5 µm, ends 3 µm from core + ], + name="WG_IMPLANT", +) + +for layer, segs in xs_implant.sections.items(): + for d_min, d_max in segs: + print(f" {layer} {d_min} → {d_max} µm") + +# %% [markdown] +# ## 6 · Bounding-box expansion layers +# +# `bbox_layers` / `bbox_offsets` add layers that expand the component bounding box by a +# fixed amount — commonly used for die outline, exclusion zones, or floorplan tiles. +# These do NOT use Minkowski operations; they simply offset the bounding box polygon. + +# %% +xs_with_fp = kf.DCrossSection( + kcl=kf.kcl, + width=0.5, + layer=L.WG, + sections=[ + (L.WGCLAD, 2.0), + ], + bbox_layers=[L.FLOORPLAN], + bbox_offsets=[5.0], # floorplan 5 µm outside bounding box + name="WG_FP", +) + +xs_fp_dbu = kf.kcl.get_icross_section(xs_with_fp) +print(f"bbox_sections: {xs_fp_dbu.bbox_sections}") + +# %% [markdown] +# ## 7 · Creating ports with a cross-section +# +# A `Port` stores its geometry via its `cross_section`; you can pass a +# `SymmetricalCrossSection` directly to `add_port`. +# Because `@kf.cell` caches by parameters, factory functions should accept +# the cross-section **name** (a string) and look it up inside: + + +# %% +@kf.cell +def mzi_arm(cross_section: str, length_um: float = 20.0) -> kf.KCell: + """A simple straight acting as an MZI arm. + + Args: + cross_section: Name of a registered cross-section. + length_um: Arm length in µm. + """ + c = kf.KCell() + xs = kf.kcl.get_icross_section(cross_section) + length = kf.kcl.to_dbu(length_um) + w = xs.width + + c.shapes(kf.kcl.find_layer(xs.layer)).insert( + kf.kdb.Box(-length // 2, -w // 2, length // 2, w // 2) + ) + + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, -length // 2, 0), # West-facing + cross_section=xs.base, + kcl=kf.kcl, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, length // 2, 0), # East-facing + cross_section=xs.base, + kcl=kf.kcl, + ) + ) + return c + + +arm = mzi_arm(cross_section="WG_500") +print(f"port o1 width: {arm['o1'].width} DBU = {kf.kcl.to_um(arm['o1'].width)} µm") +print(f"port o1 layer: {arm['o1'].layer}") +arm.plot() + +# %% [markdown] +# ## 8 · Cross-sections in routing +# +# Optical and electrical routers accept a `cross_section` argument on start/end ports — +# the router reads `xs.radius` and `xs.radius_min` to choose bend radii automatically. +# +# The typical workflow: +# +# 1. Define one `DCrossSection` per waveguide type at PDK setup time. +# 2. Register it on `kf.kcl` with a human-readable name. +# 3. Pass the name (or the `CrossSection` object) wherever routers need a waveguide spec. +# +# ```python +# # PDK setup (once) +# xs_wg = kf.DCrossSection(kcl=kf.kcl, width=0.5, layer=L.WG, +# sections=[(L.WGCLAD, 2.0)], radius=10.0, name="WG_500") +# kf.kcl.get_icross_section(xs_wg) # register +# +# # In a factory function — look up by name +# def my_bend(cross_section: str = "WG_500") -> kf.KCell: +# xs = kf.kcl.get_icross_section(cross_section) +# ... +# ``` +# +# See [Routing Overview](../routing/overview.py) for full routing examples. + +# %% [markdown] +# ## 9 · Asymmetric cross sections +# +# `SymmetricalCrossSection` rejects odd widths (`width % 2 == 0`) and centers the +# profile on the port axis. For non-symmetric profiles — angled ribs, slot +# waveguides, strip-loaded waveguides, or just any odd-width strip — +# `AsymmetricalCrossSection` is the way. +# +# It describes each layer strip as a signed `[section_min, section_max]` interval +# in dbu relative to the port centerline. Both bounds are integers, so edges are +# always on the dbu grid regardless of width parity. The strip's width is the +# derived property `section_max - section_min`. +# +# Asymmetric cross sections live in a separate registry on `KCLayout` +# (`asymmetrical_cross_sections`) and have their own getters. + +# %% +from kfactory.exceptions import ( + AsymmetricMirrorRequiredError, + CrossSectionSymmetryMismatchError, +) + +# A 301-dbu-wide strip shifted toward +y of the port centerline. +# Width is ODD (would be rejected by SymmetricalCrossSection) and the strip is +# off-center — [-100, 201] rather than [-150, 151]. +acs = kf.kcl.get_asymmetrical_cross_section( + kf.AsymmetricalCrossSection( + layer=L.WG, + section_min=-100, + section_max=201, + name="ASYM_301", + ) +) +print(f"width: {acs.width} DBU (odd!)") +print(f"main strip: [{acs.section_min}, {acs.section_max}]") +print(f"xmin/xmax: ({acs.get_xmin()}, {acs.get_xmax()})") +print(f"is_symmetric: {acs.is_symmetric()}") + +# %% [markdown] +# ### Cell convention for asymmetric profiles +# +# For an asymmetric strip to chain correctly between cells, the cell's two ports +# must have matching profile orientation in cell-local coordinates. With a +# straight-like cell, the standard convention is: +# +# - `o1 = R180` at the left edge (faces -x, no mirror). +# - `o2 = M0` at the right edge (faces +x with `mirror=True`). +# +# The mirror flag on `o2` flips the port-local frame so that "right side of +# profile" maps to the same world-y direction at both ports. + + +# %% +@kf.cell +def asym_straight(cross_section: str = "ASYM_301") -> kf.KCell: + """Asymmetric-cross-section straight. + + Port convention: + o1 → R180 at left, faces -x. + o2 → M0 at right, faces +x with mirror (asymmetric requirement). + """ + c = kf.KCell() + xs = kf.kcl.get_asymmetrical_cross_section(cross_section) + length = 5_000 # 5 µm + c.shapes(kf.kcl.find_layer(xs.layer)).insert( + kf.kdb.Box(0, xs.section_min, length, xs.section_max) + ) + c.create_port(name="o1", trans=kf.kdb.Trans(2, False, 0, 0), cross_section=xs) + c.create_port(name="o2", trans=kf.kdb.Trans(0, True, length, 0), cross_section=xs) + return c + + +straight = asym_straight() +print( + f"o1 trans: {straight['o1'].base.trans} mirror={straight['o1'].base.trans.mirror}" +) +print( + f"o2 trans: {straight['o2'].base.trans} mirror={straight['o2'].base.trans.mirror}" +) +straight.plot() + +# %% [markdown] +# ### Chaining: `o2 → o1` requires `mirror=True` +# +# Connecting `inst_b.o1` to `inst_a.o2` is the natural chain. But with +# asymmetric ports the connect transform must be M90 (mirror) — R180 would +# flip the left/right halves of the profile. +# +# kfactory checks this **geometrically** by computing the to-be-applied trans +# and comparing the world-frame "right" direction of both ports. If they don't +# match, it raises `AsymmetricMirrorRequiredError`. + +# %% +parent_chain = kf.KCell(name="asym_chain_ok") +ia = parent_chain << straight +ib = parent_chain << straight + +# Default (mirror=False) → raises +try: + ib.connect("o1", ia, "o2") +except AsymmetricMirrorRequiredError as e: + print("WITHOUT mirror=True, default connect raises:") + print(f" AsymmetricMirrorRequiredError: {e}\n") + +# With mirror=True → succeeds, neither instance ends up with a mirror flag +ib.connect("o1", ia, "o2", mirror=True) +print("WITH mirror=True:") +print(f" ia.trans: {ia.trans} (mirror={ia.trans.mirror})") +print(f" ib.trans: {ib.trans} (mirror={ib.trans.mirror})") +parent_chain.plot() + +# %% [markdown] +# ### `o1 ↔ o1`: still possible, but one instance ends up mirrored +# +# Connecting two of the same kind of end (both `R180`) needs `mirror=True` +# too, but the result has one of the two instances flipped. + +# %% +parent_o1o1 = kf.KCell(name="asym_chain_o1o1") +ia2 = parent_o1o1 << straight +ib2 = parent_o1o1 << straight + +try: + ib2.connect("o1", ia2, "o1") +except AsymmetricMirrorRequiredError as e: + print("WITHOUT mirror=True, o1↔o1 raises:") + print(f" AsymmetricMirrorRequiredError: {e}\n") + +ib2.connect("o1", ia2, "o1", mirror=True) +print("WITH mirror=True (one instance mirrored):") +print(f" ia2.trans: {ia2.trans} (mirror={ia2.trans.mirror})") +print(f" ib2.trans: {ib2.trans} (mirror={ib2.trans.mirror})") +parent_o1o1.plot() + +# %% [markdown] +# ### Connecting symmetric ↔ asymmetric: structural mismatch +# +# A port carrying a `SymmetricalCrossSection` cannot connect to one carrying an +# `AsymmetricalCrossSection` — the two are structurally different objects. +# This raises `CrossSectionSymmetryMismatchError` **before** the width/layer +# checks, and it's not bypassable by `allow_width_mismatch=True`. + + +# %% +@kf.cell +def sym_straight(cross_section: str = "WG_500") -> kf.KCell: + """Plain symmetric straight for the mismatch demo.""" + c = kf.KCell() + xs = kf.kcl.get_icross_section(cross_section) + length = 5_000 + c.shapes(kf.kcl.find_layer(xs.layer)).insert( + kf.kdb.Box(0, -xs.width // 2, length, xs.width // 2) + ) + c.create_port(name="o1", trans=kf.kdb.Trans(2, False, 0, 0), cross_section=xs) + c.create_port(name="o2", trans=kf.kdb.Trans(0, False, length, 0), cross_section=xs) + return c + + +parent_mix = kf.KCell(name="asym_sym_mismatch") +sym_inst = parent_mix << sym_straight("WG_500") +asym_inst = parent_mix << straight + +try: + asym_inst.connect("o1", sym_inst, "o2") +except CrossSectionSymmetryMismatchError as e: + print("Symmetric ↔ asymmetric connect raises:") + print(f" CrossSectionSymmetryMismatchError: {e}") + +# %% [markdown] +# ### Summary of asymmetric port behavior +# +# | Scenario | Default `connect()` | `connect(mirror=True)` | +# |---|---|---| +# | sym ↔ sym | works | works | +# | asym o2 → asym o1 (chain) | `AsymmetricMirrorRequiredError` | works, no instance mirrored | +# | asym o1 ↔ asym o1 | `AsymmetricMirrorRequiredError` | works, one instance mirrored | +# | sym ↔ asym | `CrossSectionSymmetryMismatchError` (not bypassable) | same error | +# +# The check is a **geometric** one: kfactory computes the would-be instance +# trans, derives the world-frame "right" direction of both ports' profiles, and +# raises if they don't align. + +# %% [markdown] +# ## Summary +# +# | Need | Use | +# |---|---| +# | Human-friendly µm API | `kf.DCrossSection(kcl, width, layer, sections, ...)` | +# | DBU integer precision | `kf.CrossSection(kcl, width, layer, sections, ...)` | +# | Reuse an existing enclosure | `SymmetricalCrossSection(width, enclosure)` | +# | Register / look up by name | `kf.kcl.get_icross_section(name_or_spec)` | +# | µm view of a registered xs | `kf.kcl.get_dcross_section(name_or_spec)` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Layer enclosures (auto-cladding) | [Enclosures: Layer Enclosure](layer_enclosure.py) | +# | Cell-level enclosures (tiling) | [Enclosures: KCell Enclosure](kcell_enclosure.py) | +# | Straight waveguide (uses xs) | [Components: Straight](cells/factories/straight.py) | +# | Width tapers (uses xs) | [Components: Tapers](cells/factories/taper.py) | +# | Routing with cross-sections | [Routing: Overview](../routing/overview.py) | diff --git a/docs/source/concepts/dbu_vs_um.py b/docs/source/concepts/dbu_vs_um.py new file mode 100644 index 000000000..bc3ba4065 --- /dev/null +++ b/docs/source/concepts/dbu_vs_um.py @@ -0,0 +1,243 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # DBU vs µm — coordinate systems +# +# kfactory inherits two parallel coordinate systems from KLayout: +# +# | System | Unit | Type | KLayout prefix | +# |--------|------|------|----------------| +# | **DBU** — database units | 1 nm (by default) | `int` | no prefix / `I` | +# | **µm** — micrometres | 1 µm | `float` | `D` | +# +# Every geometry class exists in both flavours: +# +# | DBU (integer) | µm (float) | +# |---------------|-----------| +# | `kdb.Box` | `kdb.DBox` | +# | `kdb.Polygon` | `kdb.DPolygon` | +# | `kdb.Point` | `kdb.DPoint` | +# | `kdb.Vector` | `kdb.DVector` | +# | `kdb.Trans` | `kdb.DTrans` | +# | `kdb.Edge` | `kdb.DEdge` | +# +# The relationship is fixed: **1 µm = 1 000 DBU** (because `kf.kcl.dbu = 0.001 µm`). +# This page shows how to use both, convert between them, and choose the right one. + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## The `dbu` constant +# +# `kf.kcl.dbu` is the size of one DBU expressed in µm. The default is `0.001`, +# meaning **1 DBU = 0.001 µm = 1 nm**. + +# %% +print(f"dbu = {kf.kcl.dbu} µm/DBU") +print(f"1 µm = {kf.kcl.to_dbu(1.0)} DBU") +print(f"1000 DBU = {kf.kcl.to_um(1000)} µm") + +# %% [markdown] +# ## Geometry objects +# +# ### `Box` vs `DBox` + +# %% +# DBU: integer coordinates, 1 unit = 1 nm +box_dbu = kf.kdb.Box(2000, 1000) # 2 µm × 1 µm +print(f"Box (DBU): {box_dbu}") + +# µm: float coordinates +box_um = kf.kdb.DBox(2.0, 1.0) # same size +print(f"DBox (µm): {box_um}") + +# %% [markdown] +# Both represent the same physical area. KLayout centres boxes at the origin by +# default, so the corners are `(±half_width, ±half_height)`. +# +# ### Converting between DBU and µm + +# %% +# DBU → µm +box_um_from_dbu = box_dbu.to_dtype(kf.kcl.dbu) +print(f"Box → DBox: {box_um_from_dbu}") + +# µm → DBU +box_dbu_from_um = box_um.to_itype(kf.kcl.dbu) +print(f"DBox → Box: {box_dbu_from_um}") + +# %% [markdown] +# ### `Trans` vs `DTrans` +# +# `Trans` / `DTrans` encode a rotation + optional mirror + translation. The +# displacement part uses DBU integers / µm floats respectively. + +# %% +# DBU: displacement in integer DBU +t_dbu = kf.kdb.Trans(0, False, 5000, 0) # 5 µm to the right +print(f"Trans (DBU): {t_dbu}") + +# µm: displacement in float µm +t_um = kf.kdb.DTrans(0, False, 5.0, 0.0) +print(f"DTrans (µm): {t_um}") + +# Convert +print(f"Trans → DTrans: {t_dbu.to_dtype(kf.kcl.dbu)}") +print(f"DTrans → Trans: {t_um.to_itype(kf.kcl.dbu)}") + +# %% [markdown] +# ## Cell bounding boxes +# +# `KCell` exposes bounding boxes in both systems: +# +# | Method | Returns | +# |--------|---------| +# | `cell.bbox()` | `kdb.Box` in DBU | +# | `cell.dbbox()` | `kdb.DBox` in µm | + +# %% +li = kf.kcl.find_layer(L.WG) + +example = kf.KCell("example_bbox") +example.shapes(li).insert(kf.kdb.Box(4000, 2000)) # 4 µm × 2 µm + +print(f"bbox (DBU): {example.bbox()}") # integer coords +print(f"dbbox (µm): {example.dbbox()}") # float coords + +# %% [markdown] +# ## Ports: `width` vs `dwidth` +# +# Port width is stored internally in **DBU** (`int`). The `.dwidth` property +# converts on-the-fly to µm: + +# %% +p = kf.Port( + name="o1", + trans=kf.kdb.Trans(0, False, 2000, 0), + width=500, # 500 DBU = 0.5 µm + layer=li, + port_type="optical", +) +print(f"Port width (DBU): {p.width}") # 500 +print(f"Port dwidth (µm): {p.dwidth}") # 0.5 + +# %% [markdown] +# ## Shapes: inserting DBU and µm geometry +# +# `cell.shapes(layer_index).insert(...)` accepts both DBU and µm objects directly — +# KLayout converts `D`-prefixed shapes automatically using the layout's `dbu`. + +# %% +mixed = kf.KCell("shapes_mixed") + +# Insert a DBU box +mixed.shapes(li).insert(kf.kdb.Box(3000, 500)) + +# Insert a µm DPolygon — automatically converted +dpoly = kf.kdb.DPolygon(kf.kdb.DBox(1.0, 0.25)) +mixed.shapes(li).insert(dpoly) + +print(f"Shapes count: {mixed.shapes(li).size()}") +print(f"Total bbox (DBU): {mixed.bbox()}") +print(f"Total dbbox (µm): {mixed.dbbox()}") + +# %% [markdown] +# ## Choosing the right system +# +# **Use DBU (`int`)** when: +# - Writing internal cell logic — integer arithmetic avoids floating-point drift. +# - Defining port `width` and `trans` — the API stores these as integers. +# - Doing boolean operations (`Region`) — `Region` always works in DBU. +# +# **Use µm (`float`)** when: +# - Accepting user-facing parameters in a factory function — `width=0.5` reads more +# naturally than `width=500`. +# - Reading back coordinates for display or export. +# - Constructing paths or references from physical dimensions. +# +# A common idiom is to accept µm at the function boundary and convert immediately: + + +# %% +@kf.cell +def waveguide(width: float = 0.5, length: float = 10.0) -> kf.KCell: + """Simple waveguide with µm parameters converted to DBU internally.""" + c = kf.KCell() + w = kf.kcl.to_dbu(width) # → int DBU + ll = kf.kcl.to_dbu(length) # → int DBU + + c.shapes(li).insert(kf.kdb.Box(ll, w)) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, -ll // 2, 0), + width=w, + layer=li, + port_type="optical", + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, ll // 2, 0), + width=w, + layer=li, + port_type="optical", + ) + ) + return c + + +wg = waveguide(width=0.5, length=10.0) +print(f"bbox (DBU): {wg.bbox()}") +print(f"dbbox (µm): {wg.dbbox()}") +print(f"Port o1 dwidth: {wg.ports['o1'].dwidth} µm") + +# %% [markdown] +# ## Quick-reference table +# +# | Task | DBU expression | µm expression | +# |------|---------------|---------------| +# | Rectangle | `kdb.Box(w, h)` | `kdb.DBox(w, h)` | +# | Point | `kdb.Point(x, y)` | `kdb.DPoint(x, y)` | +# | Translation | `kdb.Trans(rot, mir, x, y)` | `kdb.DTrans(rot, mir, x, y)` | +# | Cell bbox | `cell.bbox()` | `cell.dbbox()` | +# | Port width | `port.width` | `port.dwidth` | +# | Convert DBU→µm | `kf.kcl.to_um(n)` | — | +# | Convert µm→DBU | `kf.kcl.to_dbu(x)` | — | +# | Shape convert | `dshape.to_itype(kf.kcl.dbu)` | `shape.to_dtype(kf.kcl.dbu)` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | KCell and DKCell side-by-side | [Core Concepts: KCell](kcell.py) | +# | Port width and position in DBU | [Core Concepts: Ports](ports.py) | +# | Factory functions and their unit conventions | [Components: Factory Functions](../components/cells/factories/overview.py) | +# | FAQ — when to use DBU vs µm | [How-To: FAQ](../howto/faq.md) | diff --git a/docs/source/concepts/geometry.py b/docs/source/concepts/geometry.py new file mode 100644 index 000000000..42dd535b8 --- /dev/null +++ b/docs/source/concepts/geometry.py @@ -0,0 +1,285 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Geometry +# +# kfactory uses [KLayout](https://www.klayout.de/)'s geometry primitives directly. +# Every shape lives on an integer grid called the **database unit (DBU)** grid. +# The default grid is 1 nm (1 DBU = 0.001 µm). +# +# Two parallel APIs exist for every shape type: +# +# | DBU (integer) | Microns (float) | Conversion | +# |---------------|-----------------|------------| +# | `Point` | `DPoint` | `kcl.to_dbu(dp)` / `kcl.to_um(p)` | +# | `Box` | `DBox` | same | +# | `Polygon` | `DPolygon` | same | +# | `Edge` | `DEdge` | same | +# | `Region` | — | DBU only | +# +# Use the **D-variants** (microns) when writing component code; use the **integer +# variants** only when you need to interact with `Region` or read back raw DBU +# coordinates. + +# %% +import numpy as np + +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + CLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## Basic shape primitives +# +# ### DBox — axis-aligned rectangle +# +# `DBox(left, bottom, right, top)` or `DBox(width, height)` (centred at origin). + +# %% +box_um = kf.kdb.DBox(-2.5, -5, 2.5, 5) # 5 µm wide, 10 µm tall, centred +box_um + +# %% +# Convert to DBU integers for low-level operations +box_dbu = kf.kcl.to_dbu(box_um) +print(f"DBU: {box_dbu}") +print(f"DBU width : {box_dbu.width()} nm") +print(f"µm width : {box_um.width()} µm") + +# %% [markdown] +# ### DPolygon — arbitrary polygon in microns + +# %% +# From a list of DPoint objects +poly_a = kf.kdb.DPolygon( + [ + kf.kdb.DPoint(-8, -6), + kf.kdb.DPoint(6, 8), + kf.kdb.DPoint(7, 17), + kf.kdb.DPoint(9, 5), + ] +) + +# Convenience wrapper: pass a NumPy array of (x, y) pairs +points = np.array([(-8, -6), (6, 8), (7, 17), (9, 5)]) +poly_b = kf.polygon_from_array(points) + +# Both produce equivalent polygons +c = kf.KCell(name="polygon_demo") +c.shapes(kf.kcl.find_layer(L.WG)).insert(poly_a) +c.shapes(kf.kcl.find_layer(L.WGEX)).insert(poly_b) +c.plot() + +# %% [markdown] +# ### DPolygon — ellipse helper +# +# `DPolygon.ellipse(bbox, n_points)` inscribes an ellipse inside `bbox`. + +# %% +ellipse = kf.kdb.DPolygon.ellipse(kf.kdb.DBox(10, 6), 64) +e_cell = kf.KCell(name="ellipse_demo") +e_cell.shapes(kf.kcl.find_layer(L.WG)).insert(ellipse) +e_cell.plot() + +# %% [markdown] +# ## Inserting shapes into a cell +# +# All shapes live inside a cell on a specific layer. +# `cell.shapes(layer_index).insert(shape)` adds the shape to that layer. + +# %% +c = kf.KCell(name="shapes_on_layers") + +# Box on WG layer +c.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(0, 0, 10, 1)) + +# Polygon on CLAD layer (list-of-points style) +c.shapes(kf.kcl.find_layer(L.CLAD)).insert( + kf.kdb.DPolygon([kf.kdb.DPoint(x, y) for x, y in ((0, 0), (1, 1), (1, 3), (-3, 3))]) +) + +c.plot() + +# %% [markdown] +# ## Transformations +# +# Shapes can be transformed with `shape.transformed(trans)`. +# +# | Class | Description | +# |-------|-------------| +# | `DTrans(x, y)` | Translate by (x, y) µm (and optionally rotate 0/90/180/270 °) | +# | `DCplxTrans(mag, angle_deg, mirror, x, y)` | Arbitrary rotation + magnification | +# +# > **Note:** `DCplxTrans` magnification is only safe on plain shapes; foundries +# > typically disallow it on cell instances. + +# %% +textgen = kf.kdb.TextGenerator.default_generator() +text_region = textgen.text("kfactory", kf.kcl.dbu) + +c = kf.KCell(name="text_transforms") +# Place text at origin +c.shapes(kf.kcl.find_layer(L.WG)).insert(text_region) +# Translate +10 µm in y +c.shapes(kf.kcl.find_layer(L.WGEX)).insert( + text_region.transformed(kf.kdb.DTrans(0, 10.0).to_itype(kf.kcl.dbu)) +) +# Rotate 45° and scale ×2 +c.shapes(kf.kcl.find_layer(L.CLAD)).insert( + text_region.transformed( + kf.kdb.DCplxTrans(2.0, 45.0, False, 5.0, 30.0).to_itrans(kf.kcl.dbu) + ) +) +c.plot() + +# %% [markdown] +# ## Labels (GDS text annotations) +# +# Labels record metadata directly in the GDS file (e.g. port names, cell IDs). +# They are not fabricated — use `kf.kdb.Text` for labels and polygon-based text +# (via `TextGenerator`) for visible text shapes. + +# %% +anno = kf.KCell(name="label_demo") +s = kf.cells.straight.straight(length=10, width=0.5, layer=L.WG) +inst = anno << s + +# Attach a label at the instance origin +anno.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert( + kf.kdb.Text("waveguide_1", inst.trans) +) +# Dynamic label: embed a measurement +anno.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert( + kf.kdb.Text( + f"width={anno.dbbox().width():.1f}um", + kf.kdb.Trans(anno.bbox().right, anno.bbox().top), + ) +) +anno.plot() + +# %% [markdown] +# ## Boolean Region operations +# +# `kf.kdb.Region` is a set of DBU polygons that supports boolean algebra. +# Convert a `DPolygon` to a `Region` with `kcl.to_dbu(poly)` first. +# +# | Operator | Meaning | +# |----------|---------| +# | `A - B` | A NOT B (subtract) | +# | `A & B` | A AND B (intersection) | +# | `A + B` | A OR B (union, may overlap) | +# | `(A + B).merge()` | union, merged into minimal polygon set | +# | `A ^ B` | A XOR B (symmetric difference) | + +# %% +# Two overlapping ellipses +e1 = kf.kdb.DPolygon.ellipse(kf.kdb.DBox(10, 8), 64) +e2 = kf.kdb.DPolygon.ellipse(kf.kdb.DBox(10, 6), 64).transformed( + kf.kdb.DTrans(2.0, 0.0) +) + + +# Helper: make a demo cell with the result of a Region operation +def _show(name: str, region: kf.kdb.Region) -> kf.KCell: + c = kf.KCell(name=name) + c.shapes(kf.kcl.find_layer(L.WG)).insert(region) + return c + + +r1 = kf.kdb.Region(kf.kcl.to_dbu(e1)) +r2 = kf.kdb.Region(kf.kcl.to_dbu(e2)) + +# %% [markdown] +# ### NOT (A − B) + +# %% +_show("not_demo", r1 - r2).plot() + +# %% [markdown] +# ### AND (A ∩ B) + +# %% +_show("and_demo", r1 & r2).plot() + +# %% [markdown] +# ### OR / union (A + B) + +# %% +_show("or_demo", r1 + r2).plot() + +# %% [markdown] +# ### OR merged — single-polygon result after `.merge()` + +# %% +_show("or_merged_demo", (r1 + r2).merge()).plot() + +# %% [markdown] +# ### XOR (A ⊕ B) + +# %% +_show("xor_demo", r1 ^ r2).plot() + +# %% [markdown] +# ## Writing a GDS file +# +# Call `cell.write("output.gds")` to export to GDSII format. + +# %% +import tempfile +from pathlib import Path + +with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "demo_geometry.gds" + anno.write(str(out)) + print(f"Wrote {out.name} ({out.stat().st_size} bytes)") + +# %% [markdown] +# ## Summary +# +# | Task | API | +# |------|-----| +# | Rectangle in µm | `kf.kdb.DBox(left, bottom, right, top)` | +# | Polygon in µm | `kf.kdb.DPolygon([DPoint(...), ...])` | +# | Polygon from array | `kf.polygon_from_array(np_array)` | +# | Ellipse | `kf.kdb.DPolygon.ellipse(bbox, n_pts)` | +# | Convert µm → DBU | `kf.kcl.to_dbu(shape)` | +# | Convert DBU → µm | `kf.kcl.to_um(shape)` | +# | Add shape to cell | `cell.shapes(layer_idx).insert(shape)` | +# | Boolean subtract | `Region(a) - Region(b)` | +# | Boolean intersect | `Region(a) & Region(b)` | +# | Boolean union | `(Region(a) + Region(b)).merge()` | +# | Boolean XOR | `Region(a) ^ Region(b)` | +# | GDS text label | `kf.kdb.Text("label", trans)` | +# | Export GDS | `cell.write("file.gds")` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | DRC spacing fixes (Region-based) | [Utilities: DRC Fixing](../utilities/drc_fix.py) | +# | Auto-cladding via Minkowski sum | [Enclosures: Layer Enclosure](../enclosures/layer_enclosure.py) | +# | Fill tiling with Region exclusions | [Utilities: Fill](../utilities/fill.py) | diff --git a/docs/source/concepts/instances.py b/docs/source/concepts/instances.py new file mode 100644 index 000000000..6bc302c2c --- /dev/null +++ b/docs/source/concepts/instances.py @@ -0,0 +1,248 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Instances +# +# A **cell instance** is a pointer to an existing cell placed inside another cell. +# The instance itself contains no geometry — it only records *which* cell to show, +# and *where* (position, rotation, mirror). +# +# Key benefits: +# +# - **Memory efficiency** — one cell definition, many placements. +# - **Consistency** — modify the original cell and every instance updates automatically. +# - **Hierarchy** — build circuits from subcircuits without flattening. + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## Creating an instance +# +# Use `cell.create_inst(child)` or the `<<` shorthand to place a child cell inside a +# parent. Both forms return an `Instance` object that you can move, rotate, or mirror +# after creation. + +# %% +# Build a simple polygon cell to reuse +poly = kf.KCell(name="polygon_source") +xpts = [0, 0, 5, 6, 9, 12] +ypts = [0, 1, 1, 2, 2, 0] +poly.shapes(kf.kcl.find_layer(L.WGEX)).insert( + kf.kdb.DPolygon([kf.kdb.DPoint(x, y) for x, y in zip(xpts, ypts, strict=False)]) +) +poly + +# %% +# Long form: create_inst +parent = kf.KCell(name="three_instances") +inst1 = parent.create_inst(poly) + +# Short form: << operator (exactly equivalent) +inst2 = parent << poly +inst3 = parent << poly + +parent + +# %% [markdown] +# All three instances overlap because they share the same default position. +# We can move them independently without touching the original cell. + +# %% +inst2.transform(kf.kdb.DCplxTrans(1, 15, False, 0, 0)) # rotate 15° +inst3.transform(kf.kdb.DCplxTrans(1, 30, False, 0, 0)) # rotate 30° +parent + +# %% [markdown] +# ## Modifying the original propagates everywhere +# +# Instances are live pointers. Adding geometry to `poly` is immediately visible in +# every instance — without touching `parent`. + +# %% +poly.shapes(kf.kcl.find_layer(L.WG)).insert( + kf.kdb.DPolygon( + [ + kf.kdb.DPoint(x, y) + for x, y in zip([14, 14, 16, 16], [0, 2, 2, 0], strict=False) + ] + ) +) +parent # all three instances now show the extra rectangle + +# %% [markdown] +# ## Transforming instances +# +# An `Instance` wraps a KLayout `CellInstArray`. You can apply any KLayout +# transformation to reposition it: +# +# | Transform type | Preserves angles? | Floating-point coords? | +# |----------------|-------------------|------------------------| +# | `Trans` | Yes (orthogonal) | No (DBU integers) | +# | `DTrans` | Yes (orthogonal) | Yes (µm) | +# | `DCplxTrans` | No (arbitrary °) | Yes (µm) | + +# %% +c = kf.KCell(name="transform_demo") +s = kf.cells.straight.straight(length=10, width=0.5, layer=L.WG) + +inst_a = c << s +inst_b = c << s +inst_c = c << s + +inst_b.transform(kf.kdb.DTrans(0.0, 5.0)) # shift 5 µm north +inst_c.transform(kf.kdb.DCplxTrans(1, 45, False, 0, 10)) # shift + rotate 45° + +c + +# %% [markdown] +# ## Connecting instances by port +# +# `instance.connect("port_name", target_port)` moves and rotates the instance so that +# the named port aligns face-to-face with `target_port`. This is the standard way to +# assemble photonic circuits. + +# %% +circuit = kf.KCell(name="chained_straights") + +seg1 = circuit << kf.cells.straight.straight(length=10, width=0.5, layer=L.WG) +seg2 = circuit << kf.cells.straight.straight(length=15, width=0.5, layer=L.WG) +seg3 = circuit << kf.cells.straight.straight(length=10, width=0.5, layer=L.WG) + +seg2.connect("o1", seg1.ports["o2"]) +seg3.connect("o1", seg2.ports["o2"]) + +circuit.add_ports(seg1.ports, prefix="in_") +circuit.add_ports(seg3.ports, prefix="out_") +circuit.draw_ports() +circuit + +# %% [markdown] +# ### Mirrored connections +# +# Pass `mirror=True` to reflect the instance before alignment. This is useful when +# two ports face the same direction (e.g. building a U-bend from two 90° bends). + +# %% +bend = kf.cells.euler.bend_euler(radius=5, width=0.5, layer=L.WG, angle=90) + +u_bend = kf.KCell(name="u_bend") +b1 = u_bend << bend +b2 = u_bend << bend +b2.connect("o1", b1.ports["o2"], mirror=True) + +u_bend.add_ports(b1.ports, prefix="left_") +u_bend.add_ports(b2.ports, prefix="right_") +u_bend.draw_ports() +u_bend + +# %% [markdown] +# ## Arrays of instances +# +# `create_inst(cell, na=N, nb=M, a=vec_a, b=vec_b)` creates a regular NxM array +# using a single GDS `AREF` record — very compact in the output file. +# +# > **Note:** Array elements cannot have individual ports; use individual instances +# > when you need per-element port access. + +# %% +tile = kf.cells.straight.straight(length=10, width=0.5, layer=L.WG) + +grid = kf.KCell(name="instance_array") +arr = grid.create_inst( + tile, + na=3, + nb=2, # 3 columns, 2 rows + a=(20_000, 0), # 20 µm step in x (DBU = nm) + b=(0, 10_000), # 10 µm step in y +) +grid.draw_ports() +grid + +# %% [markdown] +# Ports of array elements are accessed via `inst[port_name, col, row]`: + +# %% +print("Port at column 0, row 1:", arr["o1", 0, 1]) +print("Port at column 2, row 0:", arr["o2", 2, 0]) + +# %% [markdown] +# ## Hierarchical nesting +# +# Instances can point to cells that themselves contain instances. There is no practical +# depth limit — kfactory and KLayout handle deep hierarchy natively. + +# %% +top = kf.KCell(name="top_level") +r1 = top << u_bend +r2 = top << u_bend +r3 = top << u_bend + +r1.transform(kf.kdb.DTrans(0.0, 0.0)) +r2.transform(kf.kdb.DTrans(30.0, 0.0)) +r3.transform(kf.kdb.DTrans(60.0, 0.0)) + +top + +# %% [markdown] +# ## Flattening non-Manhattan connections +# +# When you `connect` at a non-90° angle the sub-cell boundary and the parent boundary +# can produce sub-nanometre gaps at the interface. Calling `instance.flatten()` merges +# the instance geometry into the parent, eliminating those gaps. + +# %% +angled = kf.KCell(name="angled_connect") +b30a = angled << kf.cells.euler.bend_euler(radius=5, width=1, layer=L.WG, angle=30) +b30b = angled << kf.cells.euler.bend_euler(radius=5, width=1, layer=L.WG, angle=30) +b30b.connect("o1", b30a.ports["o2"]) +b30b.flatten() # merges geometry into parent to close sub-nm gaps +angled + +# %% [markdown] +# ## Summary +# +# | Task | API | +# |------|-----| +# | Place a child cell | `parent.create_inst(child)` or `inst = parent << child` | +# | Reposition | `inst.transform(kf.kdb.DTrans(x, y))` | +# | Connect face-to-face | `inst.connect("port", other_port)` | +# | Connect with mirror | `inst.connect("port", other_port, mirror=True)` | +# | Regular array | `parent.create_inst(child, na=N, nb=M, a=vec_a, b=vec_b)` | +# | Access array port | `arr_inst["port_name", col, row]` | +# | Expose child ports | `parent.add_ports(inst.ports, prefix="…")` | +# | Flatten into parent | `inst.flatten()` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Port system used by `connect()` | [Core Concepts: Ports](ports.py) | +# | Routing between instance ports | [Routing: Overview](../routing/overview.py) | +# | Assembling multi-cell components | [Components: Overview](../components/cells/overview.py) | +# | Grid and tiling arrays of instances | [Utilities: Grid Layout](../utilities/grid.py) | diff --git a/docs/source/concepts/kcell.py b/docs/source/concepts/kcell.py new file mode 100644 index 000000000..0a03e442a --- /dev/null +++ b/docs/source/concepts/kcell.py @@ -0,0 +1,286 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # KCell — the core building block +# +# `KCell` is kfactory's central class. Every component — from a simple rectangle to a +# full photonic circuit — is a `KCell`. This page explains how to create cells, add +# geometry, attach ports, and use the `@cell` decorator that makes parametric, +# cache-efficient component functions easy to write. + +# %% [markdown] +# ## Setup: layers +# +# Every notebook is self-contained and defines its own layers. +# `LayerInfos` maps human-readable names to KLayout `LayerInfo` objects (layer number + +# datatype). The global layout object `kf.kcl` is made aware of the new layer set so +# that helper methods like `find_layer` work correctly. + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + CLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## Creating a cell manually +# +# `kf.KCell()` returns a new, empty cell. You can add shapes to it using KLayout's +# geometry API. Coordinates can be expressed in **database units (DBU)** or in +# **micrometres (µm)**; kfactory re-exports both variants. +# +# | Class | Coordinate unit | Typical suffix | +# |-------|----------------|----------------| +# | `kdb.Box` / `kdb.Polygon` | DBU (integers) | none | +# | `kdb.DBox` / `kdb.DPolygon` | µm (floats) | `D` prefix | +# +# The default DBU for `kf.kcl` is **1 nm** (i.e. `dbu = 0.001`), so 1 µm = 1000 DBU. + +# %% +c = kf.KCell(name="my_rect") + +# Add a 10 µm × 2 µm rectangle using µm coordinates (DBox) +c.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(-5, -1, 5, 1)) + +# Display in the notebook +c + +# %% [markdown] +# ### DBU vs µm +# +# The same shape expressed in DBU: + +# %% +c_dbu = kf.KCell(name="my_rect_dbu") +# 10 µm = 10000 nm = 10000 DBU; 2 µm = 2000 DBU +c_dbu.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.Box(-5000, -1000, 5000, 1000)) +c_dbu + +# %% [markdown] +# Both cells are identical — choose whichever unit is most convenient. The `D`-prefixed +# classes (`DBox`, `DPolygon`, …) accept floating-point µm values and are snapped to the +# DBU grid automatically. + +# %% [markdown] +# ## Adding ports +# +# Ports mark connection points on a cell. Each port has a position, width, orientation +# (angle in degrees, 0° = east), and a layer. The convention is: +# +# * `0°` = east (right) +# * `90°` = north (up) +# * `180°` = west (left) +# * `270°` = south (down) +# +# Ports can be added in µm (`add_port`) or DBU coordinates (`add_port` with `dbu=True` +# or by setting integer positions directly). + +# %% +wg = kf.KCell(name="wg_with_ports") +wg.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(-5, -0.5, 5, 0.5)) + +# Left port: facing west (180°), centre at (-5, 0), width 1 µm +wg.add_port( + port=kf.Port( + name="o1", + width=kf.kcl.to_dbu(1.0), + dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, -5, 0), + layer=kf.kcl.find_layer(L.WG), + kcl=kf.kcl, + ) +) +# Right port: facing east (0°) +wg.add_port( + port=kf.Port( + name="o2", + width=kf.kcl.to_dbu(1.0), + dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, 5, 0), + layer=kf.kcl.find_layer(L.WG), + kcl=kf.kcl, + ) +) + +wg.draw_ports() +wg + +# %% [markdown] +# ## The `@cell` decorator — parametric cells with automatic caching +# +# Writing a function that creates a `KCell` and decorating it with `@kf.cell` gives you: +# +# 1. **Automatic cell naming** — the cell name is derived from the function name and its +# parameter values, so every unique combination gets a unique GDS cell name. +# 2. **Result caching** — calling the function a second time with the same arguments +# returns the *same* `KCell` object without re-running the function body. +# 3. **Settings storage** — the parameter values are stored in `cell.settings` for +# traceability and serialisation. +# +# This is the recommended way to create any reusable, parametric component. + + +# %% +@kf.cell +def straight( + length: float = 10.0, + width: float = 1.0, + layer: kf.kdb.LayerInfo = L.WG, +) -> kf.KCell: + """A simple straight waveguide. + + Args: + length: Length in µm. + width: Width in µm. + layer: Layer for the waveguide core. + """ + c = kf.KCell() + hw = width / 2 + c.shapes(kf.kcl.find_layer(layer)).insert(kf.kdb.DBox(0, -hw, length, hw)) + c.add_port( + port=kf.Port( + name="o1", + width=kf.kcl.to_dbu(width), + dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, 0, 0), + layer=kf.kcl.find_layer(layer), + kcl=kf.kcl, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + width=kf.kcl.to_dbu(width), + dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, length, 0), + layer=kf.kcl.find_layer(layer), + kcl=kf.kcl, + ) + ) + return c + + +s = straight(length=20, width=0.5) +s + +# %% [markdown] +# ### Cell name encodes parameters + +# %% +print(s.name) + +# %% [markdown] +# ### Caching: same arguments → same object + +# %% +s2 = straight(length=20, width=0.5) +print(f"Same object: {s is s2}") + +s3 = straight(length=30, width=0.5) +print(f"Different length → different object: {s is s3}") +print(f"Different name: {s3.name}") + +# %% [markdown] +# ### Inspecting settings + +# %% +s.settings + +# %% [markdown] +# `settings` stores the resolved parameter values. This is useful when generating +# netlists or reproducing a layout from metadata alone. + +# %% [markdown] +# ## Instances: placing cells inside other cells +# +# Use the `<<` operator (or `create_inst`) to place one cell inside another. +# An instance is a *pointer* — the underlying geometry is stored once; instances just +# carry position/rotation transforms. + +# %% +circuit = kf.KCell(name="two_waveguides") + +wg_a = circuit << straight(length=15, width=0.5) +wg_b = circuit << straight(length=15, width=0.5) + +# Place wg_b 5 µm above wg_a +wg_b.transform(kf.kdb.DTrans(0, 5)) + +circuit.add_ports(wg_a.ports, prefix="top_") +circuit.add_ports(wg_b.ports, prefix="bot_") +circuit + +# %% [markdown] +# ### Port-based connection with `connect` +# +# `instance.connect("port_name", other_instance, "other_port_name")` moves and rotates +# `instance` so that the named port aligns with the other port face-to-face. + +# %% +line = kf.KCell(name="connected_waveguides") +seg1 = line << straight(length=10, width=0.5) +seg2 = line << straight(length=10, width=0.5) +seg2.connect("o1", seg1.ports["o2"]) + +line.add_ports(seg1.ports, prefix="seg1_") +line.add_ports(seg2.ports, prefix="seg2_") +line + +# %% [markdown] +# ## KCell variants +# +# kfactory ships three cell variants that differ in how geometry is stored: +# +# | Class | Geometry storage | Typical use | +# |-------|-----------------|-------------| +# | `KCell` | KLayout database (DBU integers) | Standard physical components | +# | `DKCell` | DBU integers, but DBU-aware µm API | Same as KCell, µm-native convenience | +# | `VKCell` | In-memory only, never committed to the layout DB | Intermediate / throw-away geometry | +# +# Use `VKCell` (and the matching `@vcell` decorator) when you need a temporary cell that +# should not pollute the global cell namespace, for example as a scratch pad during +# routing or in tests. + + +# %% +@kf.vcell +def virtual_scratch_pad(width: float = 2.0) -> kf.VKCell: + """A virtual cell — not registered in the layout database.""" + vc = kf.VKCell() + vc.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(0, -width / 2, 5, width / 2)) + return vc + + +vpad = virtual_scratch_pad(width=1.0) +print(type(vpad)) +# VKCells render the same way in notebooks +vpad + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Port system (position, direction, type) | [Core Concepts: Ports](ports.py) | +# | Placing and connecting instances | [Core Concepts: Instances](instances.py) | +# | DBU vs µm coordinate systems | [Core Concepts: DBU vs µm](dbu_vs_um.py) | +# | PCells and caching | [Components: PCells](../components/cells/pcells.py) | +# | Virtual cells for routing | [Components: Virtual Cells](../components/cells/virtual.py) | diff --git a/docs/source/concepts/kclayout.py b/docs/source/concepts/kclayout.py new file mode 100644 index 000000000..2e82b7223 --- /dev/null +++ b/docs/source/concepts/kclayout.py @@ -0,0 +1,202 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # KCLayout — layout context and PDK +# +# `KCLayout` is the root container for everything kfactory manages: layers, cells, +# factories, enclosures, and cross-sections. It wraps a KLayout `kdb.Layout` object +# and adds kfactory-specific tracking on top. +# +# Key facts: +# +# - `kf.kcl` is the **default, module-level** `KCLayout`. Most single-PDK workflows +# only ever use this one object. +# - You can create additional `KCLayout` instances to model a second PDK or a cell +# library — cells from any layout can be instantiated inside any other. +# - The **`dbu`** attribute controls the database unit (grid size in µm). +# The default is `0.001` (1 nm grid). Changing it on an empty layout is safe; +# changing it after cells have been added causes geometry to shift. +# - The **`factories`** dict maps string names to decorated cell functions registered +# on this layout. Calling `kcl.factories["straight"](...)` is equivalent to calling +# the underlying function directly. + +# %% [markdown] +# ## Setup: layers +# +# Each notebook defines its own layer set. The `kf.kcl.infos = L` line makes the +# default layout aware of these layers so helpers like `find_layer` work. + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + CLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## The default layout: `kf.kcl` +# +# `kf.kcl` is always available after `import kfactory as kf`. It holds the global +# namespace of cells. + +# %% +# Name and grid size of the default layout +print(f"name : {kf.kcl.name}") +print(f"dbu : {kf.kcl.dbu} µm ({kf.kcl.dbu * 1000:.0f} nm grid)") + +# %% +# Create a straight waveguide in the default layout +s = kf.cells.straight.straight(width=1, length=10, layer=L.WG) +s + +# %% +# The layout now contains one registered KCell +kf.kcl.kcells + +# %% [markdown] +# ## Creating a second layout (second PDK) +# +# Pass a name string to `KCLayout(...)`. You can also set a different `dbu` to +# simulate a PDK with a coarser grid (e.g. 5 nm instead of 1 nm). + +# %% +kcl2 = kf.KCLayout("DEMO_PDK", infos=LAYER) +kcl2.dbu = 0.005 # 5 nm grid +print(f"name : {kcl2.name}") +print(f"dbu : {kcl2.dbu} µm ({kcl2.dbu * 1000:.0f} nm grid)") + +# %% +# The new layout starts empty +kcl2.kcells + +# %% [markdown] +# ## Registering a factory on a custom layout +# +# `straight_dbu_factory` returns a cell function that is pre-bound to `kcl2`. +# After registration, the factory is accessible by name via `kcl2.factories`. + +# %% +sf2 = kf.factories.straight.straight_dbu_factory(kcl=kcl2) + +# Register a cross section on kcl2, then call the factory by name with it. +# (`kcl2.factories["straight"]` is cross-section-first — pass `cross_section=`.) +kcl2.get_icross_section( + kf.CrossSectionSpecDict(layer=L.WG, width=200, unit="dbu", name="WG") +) +s2 = kcl2.factories["straight"](length=10_000, cross_section="WG") +s2.settings + +# %% [markdown] +# ## DBU vs µm dimensions across layouts +# +# The same physical width (1 µm) occupies different numbers of database units +# depending on the grid size. The `dbbox()` method always returns µm regardless +# of `dbu`. + +# %% +# Default layout: 1 nm grid → 1 µm = 1000 dbu +print("--- default kcl (1 nm grid) ---") +print(f" height dbu : {s.bbox().height()}") +print(f" height µm : {s.dbbox().height()}") +print(f" width dbu : {s.bbox().width()}") +print(f" width µm : {s.dbbox().width()}") + +# DEMO_PDK: 5 nm grid → 1 µm = 200 dbu +print("--- DEMO_PDK (5 nm grid) ---") +print(f" height dbu : {s2.bbox().height()}") +print(f" height µm : {s2.dbbox().height()}") +print(f" width dbu : {s2.bbox().width()}") +print(f" width µm : {s2.dbbox().width()}") + +# %% [markdown] +# Port widths follow the same rule — `ports.print()` shows DBU values by default; +# pass `unit="um"` to see physical (µm) values instead. + +# %% +print("=== ports in DBU ===") +s.ports.print() +s2.ports.print() + +print("\n=== ports in µm ===") +s.ports.print(unit="um") +s2.ports.print(unit="um") + +# %% [markdown] +# ## Mixing cells from different layouts +# +# Cells from any `KCLayout` can be instantiated inside a cell belonging to another +# layout. kfactory copies the referenced cell's geometry transparently. + +# %% +c = kf.kcl.kcell("mixed_pdks") +si_default = c << s # instance of the 1 nm-grid straight +si_demo = c << s2 # instance of the 5 nm-grid straight + +# Connect port o1 of the demo cell to port o2 of the default cell +si_demo.connect("o1", si_default, "o2") +c + +# %% [markdown] +# ## Saving and loading GDS files +# +# `KCLayout.write()` serialises the layout (and its cell metadata) to a GDS/OASIS +# file. `KCLayout.read()` loads it back. + +# %% +import tempfile +from pathlib import Path + +with tempfile.TemporaryDirectory() as tmp: + gds_path = Path(tmp) / "demo.gds" + kf.kcl.write(gds_path) + print(f"written: {gds_path.stat().st_size} bytes") + + # Load into a fresh layout to verify round-trip + kcl3 = kf.KCLayout("LOADED") + kcl3.read(gds_path, register_cells=True) + print(f"cells read back: {list(kcl3.kcells.keys())[:5]}") + +# %% [markdown] +# ## Key attributes at a glance +# +# | Attribute / method | Type | Description | +# |---|---|---| +# | `kcl.name` | `str` | Layout identifier | +# | `kcl.dbu` | `float` | Grid size in µm (e.g. `0.001` = 1 nm) | +# | `kcl.layout` | `kdb.Layout` | Underlying KLayout object | +# | `kcl.kcells` | `KCells` | Mapping of cell index → `KCell` | +# | `kcl.infos` | `LayerInfos` | Layer definitions | +# | `kcl.factories` | `Factories` | Registered cell-factory functions | +# | `kcl.kcell(name)` | `KCell` | Create a new, empty cell | +# | `kcl.read(path)` | — | Load GDS/OASIS file | +# | `kcl.write(path)` | — | Save GDS/OASIS file | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Building a full PDK with a custom KCLayout | [PDK: Creating a PDK](../pdk/creating_pdk.py) | +# | Session save/load to speed up rebuilds | [Utilities: Session Cache](../utilities/session_cache.py) | +# | Layer definitions and LayerInfos | [Core Concepts: Layers](layers.py) | diff --git a/docs/source/concepts/layers.py b/docs/source/concepts/layers.py new file mode 100644 index 000000000..750e4c37b --- /dev/null +++ b/docs/source/concepts/layers.py @@ -0,0 +1,228 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Layers +# +# In GDS-based photonic and electronic design, a **layer** is an integer pair +# `(layer_number, datatype)` that identifies a fabrication process step — for example +# waveguide core, metal trace, or doping implant. kfactory provides three abstractions +# for working with layers: +# +# | Class | Best for | +# |-------|----------| +# | `LayerInfos` | Defining your process layer palette (the primary approach) | +# | `LayerEnum` | When you need layers to behave as plain integers (KLayout-native style) | +# | `LayerStack` | 3-D simulation / cross-section metadata (thickness, material, z-position) | +# +# This page walks through each. + +# %% +import kfactory as kf +from kfactory.layer import LayerLevel, layerenum_from_dict + +# %% [markdown] +# ## `LayerInfos` — define your layer palette +# +# `LayerInfos` is a [Pydantic](https://docs.pydantic.dev) model. Subclass it and declare +# each layer as a class attribute typed `kf.kdb.LayerInfo`: + + +# %% +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) # waveguide core + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) # waveguide exclusion zone + CLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 0) # cladding + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() + +# %% [markdown] +# Each field is a `kdb.LayerInfo(layer_number, datatype)`. The field name is +# automatically stored as the layer's `.name` attribute, which is useful for DRC +# reports and technology files. +# +# Instantiating the class runs Pydantic validation — it checks that every field is a +# `kdb.LayerInfo` with valid `layer` and `datatype` numbers. + +# %% +print(L.WG) # LayerInfo(1/0) — KLayout's string representation +print(L.WG.layer) # 1 +print(L.WG.datatype) # 0 +print(L.WG.name) # "WG" — auto-set from the field name + +# %% [markdown] +# ### Registering layers with a layout +# +# A `KCLayout` (the global `kf.kcl` by default) must know about your layers so that +# `find_layer` and other helpers work correctly. Assign your `LayerInfos` instance to +# `kcl.infos`: + +# %% +kf.kcl.infos = L + +# %% [markdown] +# ### Looking up the integer layer index +# +# KLayout stores shapes using an integer *layer index* (not the `(layer, datatype)` pair +# directly). Use `kcl.find_layer` to convert a `LayerInfo` to this index: + +# %% +idx_wg = kf.kcl.find_layer(L.WG) +print(f"WG layer index: {idx_wg}") + +# You can also look up by number/datatype directly: +idx_wg2 = kf.kcl.find_layer(1, 0) +print(f"Same index via (layer, datatype): {idx_wg2}") +print(f"Indices match: {idx_wg == idx_wg2}") + +# %% [markdown] +# ### Accessing layers by name +# +# `LayerInfos` supports dict-style access, which is useful in generic code: + +# %% +print(L["CLAD"]) # same as L.CLAD + +# %% [markdown] +# ### Iterating over all layers +# +# Because `LayerInfos` is a Pydantic model, `model_fields` gives you the declared +# layers and `model_dump()` serialises them: + +# %% +for name in L.model_fields: + li = getattr(L, name) + print(f" {name:12s} layer={li.layer} datatype={li.datatype}") + +# %% [markdown] +# ## `LayerEnum` — integer-style layer access +# +# `LayerEnum` is an alternative that maps layer names to KLayout *layer indices* +# (integers). It is useful when interfacing with older KLayout APIs that expect an +# integer directly, or when you want to use a layer as a dict key with O(1) lookup. +# +# Use `layerenum_from_dict` to convert a `LayerInfos` into a `LayerEnum`: + +# %% +LE = layerenum_from_dict(L) + +print(type(LE.WG)) # +print(int(LE.WG)) # integer layer index in kf.kcl.layout +print(LE.WG.layer) # 1 — original layer number +print(LE.WG.datatype) # 0 — original datatype +print(LE.WG[0], LE.WG[1]) # tuple-style access: (layer, datatype) + +# %% [markdown] +# Both `LayerInfos` and `LayerEnum` are valid everywhere kfactory expects a layer — +# `kf.kcl.find_layer` accepts a `LayerInfo`, a `LayerEnum`, or an `(int, int)` tuple. + +# %% +# LayerInfos → find_layer gives the integer index: +print(kf.kcl.find_layer(L.WG)) # from LayerInfos +print(kf.kcl.find_layer(1, 0)) # from (layer, datatype) pair + +# LayerEnum members *are* layer indices already: +print(int(LE.WG)) # same integer, no find_layer needed + +# %% [markdown] +# ## `LayerStack` — 3-D process metadata +# +# `LayerStack` stores per-layer physical properties needed for 3-D simulation, cross- +# section rendering, or fabrication export. Each entry is a `LayerLevel`: + +# %% +stack = kf.LayerStack( + wg_core=LayerLevel( + layer=L.WG, + zmin=0.0, + thickness=0.22, + material="Si", + sidewall_angle=85.0, + ), + cladding=LayerLevel( + layer=L.CLAD, + zmin=-3.0, + thickness=3.22, + material="SiO2", + ), +) + +# %% [markdown] +# `LayerLevel` fields: +# +# | Field | Type | Meaning | +# |-------|------|---------| +# | `layer` | `(int, int)` or `kdb.LayerInfo` | GDS layer | +# | `zmin` | float µm | Bottom of the material | +# | `thickness` | float µm | Material thickness | +# | `material` | str \| None | Material name (for simulation) | +# | `sidewall_angle` | float degrees | Etch sidewall angle (90° = vertical) | +# | `info` | `Info` | Free-form simulation metadata | + +# %% +# Access individual levels by attribute or dict key +print(stack["wg_core"].thickness) # 0.22 +print(stack.cladding.material) # SiO2 + +# Convenience helpers for simulation +print(stack.get_layer_to_thickness()) # {(1,0): 0.22, (4,0): 3.22} +print(stack.get_layer_to_material()) # {(1,0): 'Si', (4,0): 'SiO2'} + +# %% [markdown] +# ## Putting it all together: a minimal PDK layer set +# +# A typical PDK definition combines `LayerInfos` and `LayerStack` in one module: + + +# %% +class PDK_LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WG_TRENCH: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + METAL1: kf.kdb.LayerInfo = kf.kdb.LayerInfo(11, 0) + METAL2: kf.kdb.LayerInfo = kf.kdb.LayerInfo(12, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(99, 0) + + +pdk_layers = PDK_LAYER() + +pdk_stack = kf.LayerStack( + wg=LayerLevel(layer=pdk_layers.WG, zmin=0.0, thickness=0.22, material="Si"), + m1=LayerLevel(layer=pdk_layers.METAL1, zmin=0.5, thickness=0.5, material="Al"), + m2=LayerLevel(layer=pdk_layers.METAL2, zmin=1.2, thickness=0.5, material="Al"), +) + +print("Layer palette:") +for name in pdk_layers.model_fields: + li = getattr(pdk_layers, name) + print(f" {name:12s} ({li.layer}/{li.datatype})") + +print("\n3-D stack:") +for name, level in pdk_stack.layers.items(): + print( + f" {name:6s} z={level.zmin:.1f}…{level.zmin + level.thickness:.2f} µm {level.material}" + ) + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | KCLayout — the layout registry that owns layers | [Core Concepts: KCLayout](kclayout.py) | +# | Cross-sections built on top of layers | [Cross-Sections](../components/cross_sections.py) | +# | LayerLevel and full 3-D stack in a PDK | [PDK: Technology & Layer Stack](../pdk/technology.py) | +# | Assembling a full PDK with layers | [PDK: Creating a PDK](../pdk/creating_pdk.py) | diff --git a/docs/source/concepts/ports.py b/docs/source/concepts/ports.py new file mode 100644 index 000000000..1e540d066 --- /dev/null +++ b/docs/source/concepts/ports.py @@ -0,0 +1,286 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Ports +# +# A **port** marks a connection point on a cell — it records where another cell can +# attach to it. Every port stores: +# +# | Attribute | What it means | +# |-----------|---------------| +# | `name` | Human-readable identifier (`"o1"`, `"e_in"`, …) | +# | `width` | Physical width of the waveguide / wire at this port | +# | `layer` | GDS layer the port belongs to | +# | `port_type` | `"optical"`, `"electrical"`, or any custom string | +# | `trans` / `dcplx_trans` | Position + orientation (one of the two must be set) | +# +# kfactory ships two concrete port classes: +# +# | Class | Coordinate type | When to use | +# |-------|----------------|-------------| +# | `Port` | DBU integers (nm) | Default — used by `KCell` | +# | `DPort` | µm floats | When you prefer floating-point coordinates | + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + METAL: kf.kdb.LayerInfo = kf.kdb.LayerInfo(11, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(99, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## Creating a port +# +# The most explicit way to create a port is to construct it directly. The orientation +# is encoded in a `DCplxTrans` (complex transformation in µm) or a plain `Trans` (DBU). +# The angle convention is: +# +# | Angle | Direction | +# |-------|-----------| +# | `0°` | East (right) — port faces right, signal exits to the right | +# | `90°` | North (up) | +# | `180°` | West (left) | +# | `270°` | South (down) | +# +# `DCplxTrans(magnification, angle_deg, mirror, x_um, y_um)`: + +# %% +port_left = kf.Port( + name="o1", + width=kf.kcl.to_dbu(1.0), # 1 µm → DBU + dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, -5, 0), # facing west at (-5, 0) µm + layer=kf.kcl.find_layer(L.WG), + kcl=kf.kcl, +) + +port_right = kf.Port( + name="o2", + width=kf.kcl.to_dbu(1.0), + dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, 5, 0), # facing east at (+5, 0) µm + layer=kf.kcl.find_layer(L.WG), + kcl=kf.kcl, +) + +print(port_left) +print(port_right) + +# %% [markdown] +# ## Adding ports to a cell +# +# Use `cell.add_port(port=...)` to attach a port to a cell. `draw_ports()` renders +# port markers so you can see direction and position in the notebook. + +# %% +wg = kf.KCell(name="wg_ports_demo") +wg.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(-5, -0.5, 5, 0.5)) +wg.add_port(port=port_left) +wg.add_port(port=port_right) + +wg.draw_ports() +wg + +# %% [markdown] +# ## Inspecting ports +# +# `cell.ports` returns a `Ports` collection (list-like, with filtering helpers). +# Individual ports can be accessed by name using `cell.ports["name"]` or +# `cell.port("name")`. + +# %% +# List all ports +print(wg.ports) + +# %% +# Access by name +p = wg.ports["o1"] +print(f"name={p.name} width={p.width} DBU layer={p.layer_info} angle={p.angle}°") + +# %% +# Inspect position in µm +print(f"x={p.x / wg.kcl.dbu**-1:.3f} µm, y={p.y / wg.kcl.dbu**-1:.3f} µm") +# or use the d-prefix convenience attributes: +print(f"dcplx_trans = {p.dcplx_trans}") + +# %% [markdown] +# ## Port types +# +# The `port_type` string distinguishes signal domains. kfactory uses `"optical"` and +# `"electrical"` by convention, but any string is valid. Routing functions use +# `port_type` to avoid accidentally connecting an optical port to an electrical one. + +# %% +elec_port = kf.Port( + name="e1", + width=kf.kcl.to_dbu(2.0), + dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, -5, 3), + layer=kf.kcl.find_layer(L.METAL), + kcl=kf.kcl, + port_type="electrical", +) +print(f"port_type = {elec_port.port_type}") + +# %% [markdown] +# ## Filtering the `Ports` collection +# +# `Ports` exposes several filter helpers — they all return a plain `list[Port]`. + +# %% +# Build a cell with a mix of optical and electrical ports +mixed = kf.KCell(name="mixed_ports") +mixed.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(-5, -0.5, 5, 0.5)) + +for name, angle, layer, ptype in [ + ("o1", 180, L.WG, "optical"), + ("o2", 0, L.WG, "optical"), + ("e1", 90, L.METAL, "electrical"), + ("e2", 270, L.METAL, "electrical"), +]: + mixed.add_port( + port=kf.Port( + name=name, + width=kf.kcl.to_dbu(1.0), + dcplx_trans=kf.kdb.DCplxTrans(1, angle, False, 0, 0), + layer=kf.kcl.find_layer(layer), + kcl=kf.kcl, + port_type=ptype, + ) + ) + +# %% +# Filter by port_type +optical = mixed.ports.filter(port_type="optical") +print("Optical ports:", [p.name for p in optical]) + +electrical = mixed.ports.filter(port_type="electrical") +print("Electrical ports:", [p.name for p in electrical]) + +# %% +# Filter by angle (facing direction) +west_facing = mixed.ports.filter(angle=180) +print("West-facing ports:", [p.name for p in west_facing]) + +# %% +# Filter by regex on port name +o_ports = mixed.ports.filter(regex="^o") +print("Ports starting with 'o':", [p.name for p in o_ports]) + +# %% [markdown] +# ## Bulk port operations: `add_ports` and `copy_ports` +# +# When placing instances inside a parent cell, you often want to expose the child's +# ports on the parent. `add_ports` copies ports from another collection, optionally +# adding a prefix to avoid name collisions. + +# %% +parent = kf.KCell(name="two_wg_parent") + +s = kf.cells.straight.straight(length=10, width=0.5, layer=L.WG) +wg1 = parent << s +wg2 = parent << s +wg2.transform(kf.kdb.DTrans(0, 5)) # shift 5 µm north (in DBU: 5000 nm) + +parent.add_ports(wg1.ports, prefix="top_") +parent.add_ports(wg2.ports, prefix="bot_") + +print([p.name for p in parent.ports]) +parent.draw_ports() +parent + +# %% [markdown] +# ## Auto-renaming ports +# +# `auto_rename_ports()` reassigns port names following the gdsfactory convention: +# ports are numbered clock-wise starting from the bottom-left west-facing port. +# Optical ports get an `o` prefix, electrical ports get `e`. + +# %% +parent.auto_rename_ports() +print([p.name for p in parent.ports]) + +# %% [markdown] +# ## Port-based placement with `connect` +# +# `instance.connect("port_name", other_port)` moves and rotates the instance so that +# the named port aligns face-to-face with `other_port`. This is the primary way to +# assemble circuits without manually computing positions. + +# %% +circuit = kf.KCell(name="connected_chain") + +seg1 = circuit << kf.cells.straight.straight(length=10, width=0.5, layer=L.WG) +seg2 = circuit << kf.cells.straight.straight(length=15, width=0.5, layer=L.WG) +seg3 = circuit << kf.cells.straight.straight(length=10, width=0.5, layer=L.WG) + +# Chain: seg2's o1 aligns with seg1's o2, then seg3's o1 aligns with seg2's o2 +seg2.connect("o1", seg1.ports["o2"]) +seg3.connect("o1", seg2.ports["o2"]) + +circuit.add_ports(seg1.ports, prefix="in_") +circuit.add_ports(seg3.ports, prefix="out_") +circuit.draw_ports() +circuit + +# %% [markdown] +# ## Mirrored connections +# +# Ports facing the *same* direction (not opposite) can be connected by specifying +# `mirror=True`. This reflects the instance before alignment — useful for symmetrical +# layouts. + +# %% +bend = kf.cells.euler.bend_euler(radius=5, width=0.5, layer=L.WG, angle=90) + +loop = kf.KCell(name="u_bend") +b1 = loop << bend +b2 = loop << bend +# Mirror b2 so both inputs face the same direction, forming a U-shape +b2.connect("o1", b1.ports["o2"], mirror=True) + +loop.add_ports(b1.ports, prefix="left_") +loop.add_ports(b2.ports, prefix="right_") +loop.draw_ports() +loop + +# %% [markdown] +# ## Summary +# +# | Operation | API | +# |-----------|-----| +# | Create a port | `kf.Port(name=…, width=…, dcplx_trans=…, layer=…, kcl=kf.kcl)` | +# | Attach to a cell | `cell.add_port(port=p)` | +# | Access by name | `cell.ports["o1"]` | +# | Filter by type / angle / regex | `cell.ports.filter(port_type=…, angle=…, regex=…)` | +# | Expose child ports on parent | `parent.add_ports(inst.ports, prefix="…")` | +# | Auto-rename | `cell.auto_rename_ports()` | +# | Connect instance by port | `inst.connect("port_name", other_port)` | +# | Visualise | `cell.draw_ports()` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Placing and connecting instances with ports | [Core Concepts: Instances](instances.py) | +# | Cross-sections (port width + enclosure spec) | [Cross-Sections](../components/cross_sections.py) | +# | Optical routing between ports | [Routing: Overview](../routing/overview.py) | +# | Port-based component assembly | [Components: Overview](../components/cells/overview.py) | diff --git a/docs/source/config.md b/docs/source/config.md index 83ec9adaf..fc0221eea 100644 --- a/docs/source/config.md +++ b/docs/source/config.md @@ -4,7 +4,45 @@ The config is managed by a pydantic [`SettingsModel`](https://docs.pydantic.dev/ The config can configure the logger and display type of the jupyter widget among other things. -Setting can be configured through environment variables +Settings can be configured through environment variables or a `.env` file in the working directory. + +## Environment Variable Quick Reference + +All variables use the prefix `KFACTORY_`. Nested fields use `_` as a delimiter. + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `KFACTORY_N_THREADS` | `int` | all cores | Threads used for tiled operations (DRC, fill, enclosures) | +| `KFACTORY_DISPLAY_TYPE` | `"image"` \| `"widget"` | `"image"` | How cells render in Jupyter | +| `KFACTORY_LOGFILTER_LEVEL` | `TRACE`…`CRITICAL` | `INFO` | Minimum log level to emit | +| `KFACTORY_LOGFILTER_REGEX` | `str` | `None` | Suppress messages matching this regex | +| `KFACTORY_SHOW_FUNCTION` | dotted import path | `None` | Custom `show()` function (e.g. `my_pkg.show.show`) | +| `KFACTORY_META_FORMAT` | `"v1"` \| `"v2"` \| `"v3"` | `"v3"` | Metadata encoding in GDS/OASIS files | +| `KFACTORY_ALLOW_WIDTH_MISMATCH` | `bool` | `False` | Allow connecting ports with different widths | +| `KFACTORY_ALLOW_LAYER_MISMATCH` | `bool` | `False` | Allow connecting ports on different layers | +| `KFACTORY_ALLOW_TYPE_MISMATCH` | `bool` | `False` | Allow connecting ports of different types | +| `KFACTORY_ALLOW_UNDEFINED_LAYERS` | `bool` | `False` | Skip errors for layers not in the `LayerInfos` | +| `KFACTORY_CELL_LAYOUT_CACHE` | `bool` | `False` | Cache cells per `KCLayout` instead of globally | +| `KFACTORY_CELL_OVERWRITE_EXISTING` | `bool` | `False` | Overwrite existing cells with the same name | +| `KFACTORY_CONNECT_USE_ANGLE` | `bool` | `True` | Use port angle when computing `connect()` transform | +| `KFACTORY_CONNECT_USE_MIRROR` | `bool` | `True` | Use port mirror flag when computing `connect()` transform | +| `KFACTORY_CHECK_INSTANCES` | `"error"` \| `"flatten"` \| `"vinstances"` \| `"ignore"` | `"error"` | How to handle instance check failures | +| `KFACTORY_CHECK_UNNAMED_CELLS` | `"error"` \| `"warning"` \| `"ignore"` | `"warning"` | How to handle unnamed cells | +| `KFACTORY_MAX_CELLNAME_LENGTH` | `int` | `99` | Maximum length of auto-generated cell names | +| `KFACTORY_DEBUG_NAMES` | `bool` | `False` | Append debug info to cell names | +| `KFACTORY_WRITE_CONTEXT_INFO` | `bool` | `True` | Write call-site context info into GDS/OASIS | +| `KFACTORY_WRITE_CELL_PROPERTIES` | `bool` | `True` | Write cell properties (settings) into GDS/OASIS | +| `KFACTORY_WRITE_FILE_PROPERTIES` | `bool` | `True` | Write file-level properties into GDS/OASIS | +| `KFACTORY_WRITE_TIMESTAMPS` | `bool` | `False` | Write timestamps into GDS/OASIS (reproducible builds need `False`) | +| `KFACTORY_WRITE_KFACTORY_SETTINGS` | `bool` | `True` | Write kfactory version into GDS/OASIS | + +!!! tip "Dotenv support" + Place a `.env` file in your project root to set these persistently without modifying shell profiles: + ```ini + KFACTORY_LOGFILTER_LEVEL=DEBUG + KFACTORY_DISPLAY_TYPE=image + KFACTORY_N_THREADS=4 + ``` ## Logging diff --git a/docs/source/dosdonts.md b/docs/source/dosdonts.md deleted file mode 100644 index cf691c01d..000000000 --- a/docs/source/dosdonts.md +++ /dev/null @@ -1,13 +0,0 @@ -# Dos and Don'ts - -This describes generally what are best practices for kfactory. This should be used in addition to good general python practices. - -## Dos - -* Make parameters restricted types whenever possible. This avoids collisions within caches if they do not get matched exactly - -## Don'ts - -* "function_name" as a function parameter when using `@kf.cell` (or any of its friends in other KCLayout) decorator, - it will be overwritten by the function name used to create the cell -* "self", "cls" in `@cell` functions, they will be dropped. diff --git a/docs/source/enclosures/kcell_enclosure.py b/docs/source/enclosures/kcell_enclosure.py new file mode 100644 index 000000000..5adf36584 --- /dev/null +++ b/docs/source/enclosures/kcell_enclosure.py @@ -0,0 +1,387 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # KCell Enclosure +# +# `KCellEnclosure` is the cell-level counterpart to `LayerEnclosure`. Where +# `LayerEnclosure` processes the geometry of a *single component*, `KCellEnclosure`: +# +# 1. **Recurses into all sub-cells** (`begin_shapes_rec`) to collect geometry. +# 2. **Merges** the collected geometry into one `Region` before computing the expansion. +# 3. Applies one or more `LayerEnclosure` rules in a single pass. +# +# This guarantees a continuous cladding across component joints — no gaps at the +# seams between adjacent waveguides or bends. +# +# | Class | Scope | Gap-free joins | +# |---|---|---| +# | `LayerEnclosure` | one component at a time | no — each component enclosed separately | +# | `KCellEnclosure` | entire assembled cell | yes — geometry merged first | +# +# The [Layer Enclosure](layer_enclosure.md) page introduces `LayerEnclosure` and shows a +# basic `KCellEnclosure` example. This page covers advanced usage: multiple enclosures, +# tiling parameters, and the directional Minkowski methods. + +# %% [markdown] +# ## Setup + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + NPP: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 0) + DEEPOX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(5, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## 1 · Single enclosure +# +# The minimal usage: wrap one `LayerEnclosure` in a `KCellEnclosure` and call +# `apply_minkowski_tiled` on the finished cell. +# +# The key rules are: +# - Call `apply_minkowski_tiled` **inside** the `@kf.cell` function, before `return c`. +# - The `LayerEnclosure` must have `main_layer` set so the processor knows which layer +# to expand. + +# %% +clad_enc = kf.LayerEnclosure( + dsections=[(L.WGCLAD, 2.0)], + name="WG_CLAD", + main_layer=L.WG, + kcl=kf.kcl, +) + +kcell_enc = kf.KCellEnclosure([clad_enc]) + + +@kf.cell +def bend_pair_clad(radius: float, width: float) -> kf.KCell: + """Two euler bends placed close together with unified cell-level cladding.""" + c = kf.KCell() + bend_fn = kf.factories.euler.bend_euler_factory(kcl=kf.kcl) + + b1 = c << bend_fn(width=width, radius=radius, layer=L.WG, angle=90) + b2 = c << bend_fn(width=width, radius=radius, layer=L.WG, angle=90) + + # Mirror b2 so it curves the opposite direction, then offset it by + # 1.5 µm in y. The two WG cores stay well-separated (1.5 µm apart at + # the closest point) but the 2 µm cladding rings overlap by ~2.5 µm — + # exactly the case `KCellEnclosure` is meant to handle: merging + # interacting claddings into a single continuous region without one + # cancelling the other. + b2.dmirror_y() + b2.dmove((0, 0), (0, -1.5)) + + c.add_ports(b1.ports.filter(port_type="optical")) + c.add_ports(b2.ports.filter(port_type="optical")) + c.auto_rename_ports() + + # Apply unified cladding to the finished assembly + kcell_enc.apply_minkowski_tiled(c) + return c + + +bend_pair_clad(radius=10, width=0.5).plot() + +# %% [markdown] +# Both bends' cladding rings would normally be drawn separately by +# `LayerEnclosure` (one per component) and would overlap visibly as two +# distinct annular bands. `KCellEnclosure` merges the cell geometry first, +# so the two interacting claddings come out as a single continuous region +# with no spurious gaps where they meet. + +# %% [markdown] +# ## 2 · Multiple enclosures in one `KCellEnclosure` +# +# Pass a list of `LayerEnclosure` objects to cover several output layers in a single +# `apply_minkowski_tiled` call. The processor iterates over them in order. + +# %% +slab_enc = kf.LayerEnclosure( + dsections=[(L.SLAB, 3.0)], + name="WG_SLAB", + main_layer=L.WG, + kcl=kf.kcl, +) + +npp_enc = kf.LayerEnclosure( + dsections=[(L.NPP, 1.0, 4.0)], # annular: 1 µm to 4 µm from WG edge + name="WG_NPP", + main_layer=L.WG, + kcl=kf.kcl, +) + +# Combine three enclosures: cladding + slab + implant +multi_enc = kf.KCellEnclosure([clad_enc, slab_enc, npp_enc]) + + +@kf.cell +def bend_pair_multi(radius: float, width: float) -> kf.KCell: + """Two euler bends with multi-layer cell-level enclosure.""" + c = kf.KCell() + bend_fn = kf.factories.euler.bend_euler_factory(kcl=kf.kcl) + + b1 = c << bend_fn(width=width, radius=radius, layer=L.WG, angle=90) + b2 = c << bend_fn(width=width, radius=radius, layer=L.WG, angle=90) + # Same close-but-unconnected placement as in section 1. + b2.dmirror_y() + b2.dmove((0, 0), (0, -1.5)) + + c.add_ports(b1.ports.filter(port_type="optical")) + c.add_ports(b2.ports.filter(port_type="optical")) + c.auto_rename_ports() + + multi_enc.apply_minkowski_tiled(c) + return c + + +bend_pair_multi(radius=10, width=0.5).plot() + +# %% [markdown] +# Three generated layers are visible: +# - **WGCLAD** — 2 µm uniform cladding +# - **SLAB** — 3 µm uniform slab +# - **NPP** — annular implant from 1 µm to 4 µm outside the WG edge + +# %% [markdown] +# ## 3 · Tiling parameters +# +# `apply_minkowski_tiled` uses KLayout's `TilingProcessor` for parallel computation. +# The key parameters are: +# +# | Parameter | Default | Effect | +# |---|---|---| +# | `tile_size` | `None` (auto) | Tile edge length in µm. Auto = max(10×max_d, 200 µm). | +# | `n_pts` | 64 | Points in the circular kernel. Fewer = faster but more angular corners. | +# | `n_threads` | `None` (all CPUs) | Override thread count (useful in CI). | +# | `carve_out_ports` | `True` | Remove cladding from port openings so waveguides remain accessible. | +# +# ### Effect of `n_pts` on corner shape + +# %% +enc_coarse = kf.KCellEnclosure([clad_enc]) +enc_fine = kf.KCellEnclosure([clad_enc]) + + +@kf.cell +def single_bend_npts(radius: float, width: float, n_pts: int) -> kf.KCell: + """Euler bend with variable n_pts for corner resolution.""" + c = kf.KCell() + b = c << kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=width, radius=radius, layer=L.WG, angle=90 + ) + c.add_ports(b.ports) + c.auto_rename_ports() + kf.KCellEnclosure([clad_enc]).apply_minkowski_tiled(c, n_pts=n_pts) + return c + + +# n_pts=8 → octagonal corners +single_bend_npts(radius=10, width=0.5, n_pts=8).plot() + +# %% [markdown] +# With `n_pts=8` the corners of the WGCLAD are octagonal. The default `n_pts=64` +# produces near-circular corners. + +# %% +# n_pts=64 → smooth circular corners (default) +single_bend_npts(radius=10, width=0.5, n_pts=64).plot() + +# %% [markdown] +# ### Controlling threads for CI environments +# +# ```python +# # Use a fixed thread count for reproducible timing in CI +# kcell_enc.apply_minkowski_tiled(c, n_threads=1) +# ``` + +# %% [markdown] +# ## 4 · `apply_minkowski_y` — directional enclosure for horizontal waveguides +# +# `apply_minkowski_tiled` uses a circle as the Minkowski kernel, producing rounded +# corners on all sides. For **horizontal straight waveguides** this is often +# undesirable — you want the cladding to extend only *above and below* the waveguide +# (Y direction) without rounding the ends. +# +# `apply_minkowski_y` uses a vertical edge `(0, −d) → (0, d)` as the kernel: +# - Expands in the **Y direction** by `d`. +# - No expansion in the X direction — cladding ends flush with the waveguide ends. +# +# This is useful when the port openings must remain clear, or when the cladding +# rectangle must match the exact waveguide length. + +# %% +clad_enc_y = kf.LayerEnclosure( + dsections=[(L.WGCLAD, 1.5)], + name="CLAD_Y", + main_layer=L.WG, + kcl=kf.kcl, +) +kcell_enc_y = kf.KCellEnclosure([clad_enc_y]) + + +@kf.cell +def straight_clad_y(length: float, width: float) -> kf.KCell: + """Horizontal straight waveguide with Y-only cladding.""" + c = kf.KCell() + s = c << kf.factories.straight.straight_dbu_factory(kcl=kf.kcl)( + length=kf.kcl.to_dbu(length), + width=kf.kcl.to_dbu(width), + layer=L.WG, + ) + c.add_ports(s.ports) + c.auto_rename_ports() + # Expand cladding in Y only — no rounding at port ends + kcell_enc_y.apply_minkowski_y(c) + return c + + +straight_clad_y(length=20.0, width=0.5).plot() + +# %% [markdown] +# The WGCLAD (layer 2/0) extends 1.5 µm above and below the waveguide but ends +# exactly at the port faces — no overextension at the ends. + +# %% [markdown] +# ## 5 · `apply_minkowski_x` — directional enclosure for vertical waveguides +# +# `apply_minkowski_x` is the X-direction counterpart. It uses a horizontal edge +# `(−d, 0) → (d, 0)` as the kernel, expanding only left and right. This is the +# correct choice for waveguides oriented vertically (angle 90° / 270°). + +# %% +kcell_enc_x = kf.KCellEnclosure([clad_enc_y]) + + +@kf.cell +def vertical_straight_clad_x(length: float, width: float) -> kf.KCell: + """Vertical straight waveguide with X-only cladding.""" + c = kf.KCell() + s = c << kf.factories.straight.straight_dbu_factory(kcl=kf.kcl)( + length=kf.kcl.to_dbu(length), + width=kf.kcl.to_dbu(width), + layer=L.WG, + ) + # Rotate 90° so the waveguide runs vertically + s.drotate(90) + c.add_ports(s.ports) + c.auto_rename_ports() + kcell_enc_x.apply_minkowski_x(c) + return c + + +vertical_straight_clad_x(length=20.0, width=0.5).plot() + +# %% [markdown] +# ## 6 · `apply_minkowski_custom` — custom kernel shape +# +# For full control over the expansion shape pass a callable to +# `apply_minkowski_custom`. The callable receives the expansion distance `d` (in DBU) +# and must return a `kdb.Edge`, `kdb.Polygon`, or `kdb.Box`. +# +# ### Diamond kernel +# +# A diamond (rotated square) rounds corners at 45° — a good compromise between a box +# (very angular) and a circle (many points, slower). + + +# %% +def diamond(d: int) -> kf.kdb.Polygon: + """Return a diamond-shaped polygon with half-diagonal d.""" + return kf.kdb.Polygon( + [ + kf.kdb.Point(0, d), + kf.kdb.Point(d, 0), + kf.kdb.Point(0, -d), + kf.kdb.Point(-d, 0), + ] + ) + + +kcell_enc_diamond = kf.KCellEnclosure([clad_enc]) + + +@kf.cell +def bend_pair_diamond(radius: float, width: float) -> kf.KCell: + """Two euler bends with diamond-kernel cell-level cladding.""" + c = kf.KCell() + bend_fn = kf.factories.euler.bend_euler_factory(kcl=kf.kcl) + + b1 = c << bend_fn(width=width, radius=radius, layer=L.WG, angle=90) + b2 = c << bend_fn(width=width, radius=radius, layer=L.WG, angle=90) + # Same close-but-unconnected placement as in section 1. + b2.dmirror_y() + b2.dmove((0, 0), (0, -1.5)) + + c.add_ports(b1.ports.filter(port_type="optical")) + c.add_ports(b2.ports.filter(port_type="optical")) + c.auto_rename_ports() + + kcell_enc_diamond.apply_minkowski_custom(c, shape=diamond) + return c + + +bend_pair_diamond(radius=10, width=0.5).plot() + +# %% [markdown] +# The WGCLAD corners are diamond-shaped (45° chamfers). + +# %% [markdown] +# ## 7 · Method reference +# +# | Method | Kernel | Expands | Best for | +# |---|---|---|---| +# | `apply_minkowski_tiled` | circle (`n_pts` points) | all directions | complex / curved cells | +# | `apply_minkowski_y` | vertical edge | Y only | horizontal straight waveguides | +# | `apply_minkowski_x` | horizontal edge | X only | vertical straight waveguides | +# | `apply_minkowski_custom(c, shape)` | user-supplied | custom | custom corner shapes | +# +# ### Directional method comparison +# +# | Method | Corners | Port openings | Computation | +# |---|---|---|---| +# | `apply_minkowski_tiled` | rounded | carved out (`carve_out_ports=True`) | tiled, parallel | +# | `apply_minkowski_y` | square (no rounding) | flush at port ends | single-pass | +# | `apply_minkowski_x` | square (no rounding) | flush at port ends | single-pass | +# | `apply_minkowski_custom` | matches kernel | depends on kernel | single-pass | +# +# ### Quick-start checklist +# +# 1. Always set `main_layer=` on every `LayerEnclosure` used inside `KCellEnclosure`. +# 2. Call the apply method **inside** the `@kf.cell` function, before `return c`. +# 3. For large or curved cells use `apply_minkowski_tiled` (parallel, circle kernel). +# 4. For straight waveguides use `apply_minkowski_y` / `apply_minkowski_x` to keep +# cladding flush with port faces. +# 5. Adjust `n_pts` in `apply_minkowski_tiled` to trade corner resolution for speed. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Layer enclosures (single-layer) | [Enclosures: Layer Enclosure](layer_enclosure.py) | +# | Cross-sections (port geometry) | [Cross-Sections](../components/cross_sections.py) | +# | Boolean / region operations | [Core Concepts: Geometry](../concepts/geometry.py) | +# | Tile-based fill | [Utilities: Fill](../utilities/fill.py) | diff --git a/docs/source/enclosures/layer_enclosure.py b/docs/source/enclosures/layer_enclosure.py new file mode 100644 index 000000000..62d9be354 --- /dev/null +++ b/docs/source/enclosures/layer_enclosure.py @@ -0,0 +1,286 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Layer Enclosures +# +# An **enclosure** defines how additional layers are generated *around* a core shape. +# Common uses include: +# +# - **Slab / cladding** — oxide or slab that surrounds a waveguide core. +# - **Doping regions** — implant layers offset from a rib waveguide. +# - **Keep-out zones** — exclusion regions around metal traces. +# - **Floorplan** — bounding-box expansions that pad a component's outline. +# +# kfactory implements two enclosure classes: +# +# | Class | Applies to | +# |---|---| +# | `LayerEnclosure` | A single layer's geometry on a component | +# | `KCellEnclosure` | An entire assembled `KCell` (merges sub-cell geometries) | +# +# `LayerEnclosure` is the building block; `KCellEnclosure` wraps one or more +# `LayerEnclosure` objects and applies them to a finished cell as a post-processing step. + +# %% [markdown] +# ## Setup + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + NPP: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 0) + METAL: kf.kdb.LayerInfo = kf.kdb.LayerInfo(20, 0) + METALEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(20, 1) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## 1 · Simple uniform expansion — cladding +# +# The most common enclosure is a single layer that expands symmetrically around the +# waveguide core. +# +# ``` +# sections = [(target_layer, expansion_dbu)] +# ``` +# +# A 2 µm WGCLAD around a WG core (1 nm/DBU → 2 µm = 2 000 DBU): + +# %% +clad_enc = kf.LayerEnclosure( + sections=[(L.WGCLAD, 2_000)], # 2 µm cladding in DBU + name="WGSTD", + main_layer=L.WG, +) + +# Create an Euler bend with cladding applied. +bend_with_clad = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, + radius=10, + layer=L.WG, + enclosure=clad_enc, + angle=90, +) +bend_with_clad.plot() + +# %% [markdown] +# The WG core (layer 1/0) is drawn by the cell itself; the WGCLAD (layer 2/0) is +# automatically computed as the Minkowski expansion of the WG shape. + +# %% [markdown] +# ## 2 · µm-based sections with `dsections` +# +# Specifying DBU manually is error-prone when the grid resolution may change. +# Use `dsections=` together with `kcl=` to specify distances in micrometres: + +# %% +clad_enc_um = kf.LayerEnclosure( + dsections=[(L.WGCLAD, 2.0)], # 2 µm — converted to DBU automatically + name="WGSTD_UM", + main_layer=L.WG, + kcl=kf.kcl, +) + +bend_um = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, + radius=10, + layer=L.WG, + enclosure=clad_enc_um, + angle=90, +) +bend_um.plot() + +# %% [markdown] +# ## 3 · Multi-layer enclosures +# +# A single `LayerEnclosure` can drive *multiple* output layers. Each tuple in +# `sections` specifies one rule. +# +# ### Two-element tuple — symmetric expansion +# +# ```python +# (layer, d_max) # expands by d_max DBU on all sides +# ``` +# +# ### Three-element tuple — annular (ring) region +# +# ```python +# (layer, d_min, d_max) # inner edge at d_min, outer edge at d_max +# ``` +# +# This is useful for doping layers that must stay a minimum distance from the waveguide +# edge: + +# %% +# SLAB: 2 µm uniform cladding. +# NPP: implant ring, 1 µm from WG edge to 3 µm from WG edge. +doped_enc = kf.LayerEnclosure( + dsections=[ + (L.SLAB, 2.0), # 0 → 2 µm + (L.NPP, 1.0, 3.0), # 1 → 3 µm (annular ring) + ], + name="SLAB_DOPED", + main_layer=L.WG, + kcl=kf.kcl, +) + +bend_doped = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, + radius=10, + layer=L.WG, + enclosure=doped_enc, + angle=90, +) +bend_doped.plot() + +# %% [markdown] +# Three layers are now present: +# - **WG** (core waveguide) +# - **SLAB** (uniform 2 µm expansion) +# - **NPP** (annular implant from 1 µm to 3 µm outside the WG edge) + +# %% [markdown] +# ## 4 · Enclosures on straight waveguides and tapers +# +# Enclosures work on any component factory that accepts an `enclosure=` argument. + +# %% +straight_with_enc = kf.factories.straight.straight_dbu_factory(kcl=kf.kcl)( + width=kf.kcl.to_dbu(0.5), + length=kf.kcl.to_dbu(20), + layer=L.WG, + enclosure=clad_enc, +) +straight_with_enc.plot() + +# %% +taper_with_enc = kf.factories.taper.taper_factory(kcl=kf.kcl)( + width1=kf.kcl.to_dbu(0.5), + width2=kf.kcl.to_dbu(1.0), + length=kf.kcl.to_dbu(10), + layer=L.WG, + enclosure=clad_enc, +) +taper_with_enc.plot() + +# %% [markdown] +# ## 5 · `KCellEnclosure` — cell-level enclosures +# +# When a cell contains *multiple* sub-components, applying a `LayerEnclosure` to each +# sub-component separately leaves gaps at the joins. `KCellEnclosure.apply_minkowski_tiled` +# operates on the **merged** geometry of the finished cell, producing a single continuous +# enclosure. +# +# ### Example: two-bend cell with unified cladding + +# %% +kcell_enc = kf.KCellEnclosure([doped_enc]) + + +@kf.cell +def two_bends( + radius: float, + width: float, + layer: kf.kdb.LayerInfo, + enclosure: kf.KCellEnclosure | None = None, +) -> kf.KCell: + """Two euler bends joined at their West ports, with optional cell-level enclosure.""" + c = kf.KCell() + b1 = c << kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=width, radius=radius, layer=layer, angle=90 + ) + b2 = c << kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=width, radius=radius, layer=layer, angle=90 + ) + # Place b2 rotated 90° below b1 + b2.drotate(90) + b2.dmovey(-radius * 2 - width) + b2.dmovex(b2.dxmin, 0) + + c.add_ports(b1.ports) + c.add_ports(b2.ports) + c.auto_rename_ports() + + if enclosure: + enclosure.apply_minkowski_tiled(c) + return c + + +c_two = two_bends(radius=10, width=0.5, layer=L.WG, enclosure=kcell_enc) +c_two.plot() + +# %% [markdown] +# Notice that the SLAB and NPP layers form a *single, continuous* region around both +# bends, with no gap between them — this is the key advantage of `KCellEnclosure` over +# per-component enclosures. + +# %% [markdown] +# ## 6 · `SymmetricalCrossSection` — reusable cross-section +# +# For photonic PDKs, you typically want to encode the waveguide width, cladding, and +# bend radius as a named, reusable object. `SymmetricalCrossSection` bundles a width +# (in DBU) with a `LayerEnclosure` into a single immutable value. + +# %% +from kfactory.cross_section import SymmetricalCrossSection + +wg_xs = SymmetricalCrossSection( + width=kf.kcl.to_dbu(0.5), # 500 nm core + enclosure=clad_enc, + name="WG_STD", +) + +print(f"name: {wg_xs.name}") +print(f"width (DBU): {wg_xs.width}") +print(f"width (µm): {kf.kcl.to_um(wg_xs.width):.3f}") +print(f"main_layer: {wg_xs.main_layer}") + +# %% [markdown] +# The cross-section is used by routing functions to automatically configure the straight +# factory width and bend radius. See the **Routing** section for examples. + +# %% [markdown] +# ## Summary +# +# | Concept | Class | Key parameter | +# |---|---|---| +# | Uniform cladding | `LayerEnclosure` | `sections=[(layer, d_max)]` | +# | Ring / annular region | `LayerEnclosure` | `sections=[(layer, d_min, d_max)]` | +# | µm-specified distances | `LayerEnclosure` | `dsections=` + `kcl=` | +# | Cell-level merged enclosure | `KCellEnclosure` | `.apply_minkowski_tiled(cell)` | +# | Reusable width + enclosure | `SymmetricalCrossSection` | `width=`, `enclosure=` | +# +# The typical PDK workflow is: +# 1. Define a `LayerEnclosure` for each waveguide type (strip, rib, metal, …). +# 2. Wrap it in a `SymmetricalCrossSection` with the nominal width and bend radius. +# 3. Pass the cross-section to routing functions — they handle everything else. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Cross-sections (port geometry) | [Cross-Sections](cross_sections.py) | +# | Cell-level enclosures (tiling) | [Enclosures: KCell Enclosure](kcell_enclosure.py) | +# | Straight waveguide (uses enclosure) | [Components: Straight](../components/cells/factories/straight.py) | +# | Width tapers (uses enclosure) | [Components: Tapers](../components/cells/factories/taper.py) | diff --git a/docs/source/gdsfactory.md b/docs/source/gdsfactory.md index 475e8d2dd..be5188526 100644 --- a/docs/source/gdsfactory.md +++ b/docs/source/gdsfactory.md @@ -1,4 +1,4 @@ -# kfactory vs gdsfactory +# Coming from gdsfactory kfactory is based on KLayout and therefore has quite a few fundamental differences to gdsfactory. @@ -13,7 +13,7 @@ Therefore a KCell will always be attached to a [KCLayout][kfactory.layout.KCLayo This KCLayout object contains all the KCells and also keeps track of the layers. -Similar to the KCell,which cannot exist without a KCLayout, an instance cannot exist without being part of a KCell. It must be created through the KCell +Similar to the KCell, which cannot exist without a KCLayout, an instance cannot exist without being part of a KCell. It must be created through the KCell. ## Layers @@ -44,7 +44,7 @@ The first argument represents the name of the enum that will be used for the `__ It is strongly recommended to name it the same as the variable it is assigned to. This will make sure that the behavior is the same as the one that was constructed first. -The LayerEum also allows mapping from string to layer index and layer number and datatype: +The LayerEnum also allows mapping from string to layer index and layer number and datatype: !!! example "Accessing LayerEnum by index or name and getting layer number & datatype" @@ -71,33 +71,24 @@ The LayerEum also allows mapping from string to layer index and layer number and ## Shapes -In contrast to gdsfactory, every geometrical dimension is represented as an object. All the objects are available in two flavors. Integer based for the mapping to the gridof gds/oasis in database units (dbu) or a floating version, which is measured in micrometer. +In contrast to gdsfactory, every geometrical dimension is represented as an object. All the objects are available in two flavors. Integer based for the mapping to the grid of gds/oasis in database units (dbu) or a floating version, which is measured in micrometer. | Object (dbu) | Object (um) | Description | |--------------|----------------|----------------------------------------------------------------------------------------------------------| | Point | DPoint | Holds x/y coordinate in dbu - | Vector | DVector | Similar to a point, but can be used for geometry operations and can be multiplied - | Edge | DEdge | Connection of two points (p1/p2) and is aware of the two sides - | Box | DBox | A rectangle defined through two points. Rotating a box will result in a bigger box - | SimplePolygon| DSimplePolygon | A polygon that has no holes (this is what all polygons will be converted to when inserting) - | Polygon | DPolygon | Like the simple polygon but this one can have holes and allows operations like sizing - | Text | DText | Labels. They can have a full transformation, but KLayout does not show full transformations by default - | Shape | - | A generalized container for other geometric objects that allows storage and retrieval - | Shapes | - | A flat collection of shapes. Used by KCells to access shapes in a cell - | Region | - | Flat or deep collection of polygons. Any other dbu shape can be inserted (except Texts) In kfactory and KLayout these objects can live outside of a (K)Cell. Therefore it is not possible to create them through the KCell like in gdsfactory. -These objects can be inserted into a KCell with `c.shapes(layer_index).insert(shabpe_like_object)`. +These objects can be inserted into a KCell with `c.shapes(layer_index).insert(shape_like_object)`. ### gdsfactory's `add_polygon` in kfactory @@ -155,7 +146,7 @@ Similar to the `add_polygon` function, `add_label` acts as a text record to a ce kfactory also offers `c.connect(port_name, other_port)` like gdsfactory does. It does not exactly do the same thing as in gdsfactory though. A [Port][kfactory.port.Port] in kfactory will always try to be on a grid. Additionally the port is using `kfactory.kdb.Trans` and `kfactory.kdb.DCplxTrans` by default, similar to an instance. This also means that a port is aware of mirroring. Since a `connect` can be simplified to `instance.trans = other_port.trans * kfactory.kdb.Trans.R180 * port.trans.inversed()` (for the 90° on-grid cases), it can be seen that the center, angle and mirror flag of the `instance` is overwritten. Therefore, any move / rotation / mirror of the instance `connect` is called on, will have no influence on the state after the connect. -Also, as with gdsfactory `connect` , it is not final. It does not imply any shared link between the instances after the `connect`, it is simply a transformation with some checks concerning the layer, width and port type matching. +Also, as with gdsfactory `connect`, it is not final. It does not imply any shared link between the instances after the `connect`, it is simply a transformation with some checks concerning the layer, width and port type matching. !!! example @@ -169,11 +160,72 @@ Also, as with gdsfactory `connect` , it is not final. It does not imply any shar ### If inst2 "o2" had trans.is_mirror() == True, inst1's transformation now also has is_mirror() == True ``` -## LayerEnclosure / KCellEnclosure vs CrossSection +## Cross-Sections & Enclosures + +kfactory has full support for cross-sections. [CrossSection][kfactory.cross_section.CrossSection] and +[DCrossSection][kfactory.cross_section.DCrossSection] define port geometry — width, layer, and an +optional [LayerEnclosure][kfactory.enclosure.LayerEnclosure] for cladding. +[SymmetricalCrossSection][kfactory.cross_section.SymmetricalCrossSection] is the most common form: +it defines a symmetric waveguide profile by combining a core layer with an enclosure. + +Cross-sections can be registered on a `KCLayout` and looked up by name, making them cacheable: + +```python +import kfactory as kf + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 1) + +L = LAYER() +kf.kcl.infos = L + +enc = kf.LayerEnclosure(sections=[(kf.kcl.find_layer(L.WGEX), 2000)], + main_layer=kf.kcl.find_layer(L.WG)) +xs = kf.SymmetricalCrossSection(width=500, enclosure=enc, + layer=kf.kcl.find_layer(L.WG), name="xs_wg") +kf.kcl.get_icross_section(xs) # registers & returns the cross-section + +# Later — retrieve by name (hashable, safe inside @kf.cell): +xs_retrieved = kf.kcl.get_icross_section("xs_wg") +``` + +See [Cross-Sections & Enclosures](components/cross_sections.py) for a full walkthrough. + +Beyond cross-sections, kfactory provides a more generalised cladding system: enclosures are not limited +to path-like backbones. [LayerEnclosure][kfactory.enclosure.LayerEnclosure] can apply +cladding/exclusion to arbitrary regions or entire layers, and +[KCellEnclosure][kfactory.enclosure.KCellEnclosure] can merge all sub-cell geometry before +expanding — useful for complex multi-component assemblies. + +See [Layer Enclosures](enclosures/layer_enclosure.py) and [KCell Enclosures](enclosures/kcell_enclosure.py) +for details. + +## Routing + +kfactory has comprehensive routing support across several sub-modules: + +| Sub-module | Use case | +|---|---| +| `kf.routing.optical` | Bend-based optical routing: `route_bundle`, `place_manhattan`, `route_loopback`, path-length matching | +| `kf.routing.electrical` | Wire routing: `route_bundle`, dual-rail (`route_bundle_dual_rails`) | +| `kf.routing.manhattan` | Low-level Manhattan backbone: `route_manhattan`, `route_manhattan_180`, Steps API | +| `kf.routing.aa.optical` | All-angle (diagonal) routing via `route` / `route_bundle` | + +### Comparing with gdsfactory routing + +| gdsfactory | kfactory equivalent | +|---|---| +| `route_single` | `route_bundle` with one start/end port | +| `route_bundle` | `kf.routing.optical.route_bundle` | +| `get_bundle` | `kf.routing.optical.route_bundle` (returns `ManhattanRoute` objects) | +| `get_route` | `kf.routing.optical.route_bundle` (single route) | +| Cross-section in route | Pass `straight_factory` + `bend90_cell` built from your cross-section | + +!!! note "Effective bend radius" + + Euler bends extend slightly beyond their nominal radius. Always use + `kf.routing.optical.get_radius(bend_cell)` (not the nominal µm value) when passing + `bend90_radius` to routing functions. See [Euler Bends](components/cells/factories/euler.py) for details. -kfactory does not have the concept of cross sections. -Since cross sections are limited to have a path as a backbone, kfactory implemented a more generalized form as enclosures. -[LayerEnclosures][kfactory.enclosure.LayerEnclosure] can use regions or even entire layers as a basis to apply excludes and claddings (or anything that depends on the base form). -Additionally, kfactory has the extended concept of [KCellEnclosure][kfactory.enclosure.KCellEnclosure]. -These can apply enclosures to a whole KCell on all layers the KCellEnclosure is aware of. -For further info, please head over to the [Tutorial](/kfactory/notebooks/03_Enclosures) +See the [Routing](routing/overview.py) section for full examples. diff --git a/docs/source/getting_started/installation.md b/docs/source/getting_started/installation.md new file mode 100644 index 000000000..eb2a4f7e1 --- /dev/null +++ b/docs/source/getting_started/installation.md @@ -0,0 +1,48 @@ +# Installation + +## Install kfactory + +```bash +pip install kfactory +``` + +Or with [uv](https://docs.astral.sh/uv/) (recommended): + +```bash +uv add kfactory +``` + +### Optional extras + +| Extra | What it adds | +|-------|-------------| +| `kfactory[git]` | Git-based layout diffing (`difftest`) | +| `kfactory[ipy]` | Jupyter / IPython display helpers (`cell.plot()`) | +| `kfactory[dev]` | Development tools (pytest, ruff, mypy, …) | + +Install multiple extras at once: + +```bash +pip install "kfactory[git,ipy]" +``` + +## Verify the installation + +```python +import kfactory as kf + +print(kf.__version__) + +# Create a simple cell to confirm everything works +c = kf.KCell(name="hello") +c.shapes(kf.kcl.layer(1, 0)).insert(kf.kdb.Box(0, 0, 5000, 2000)) +print("KCell created:", c.name) +``` + +If no errors appear, kfactory is installed correctly. + +## Next steps + +- [5-Minute Quickstart](quickstart.py) — build and connect your first components +- [KLive Setup](klive_setup.md) — stream layouts live into KLayout +- [Core Concepts: KCell](../concepts/kcell.py) — deep dive into the central class diff --git a/docs/source/getting_started/klive_setup.md b/docs/source/getting_started/klive_setup.md new file mode 100644 index 000000000..50ef37782 --- /dev/null +++ b/docs/source/getting_started/klive_setup.md @@ -0,0 +1,36 @@ +# KLive Setup + +[klive](https://github.com/gdsfactory/klive) is a KLayout plug-in that lets kfactory push GDS +files directly into a running KLayout window. Every call to `kf.show(cell)` will open or refresh +the layout instantly — no manual file export required. + +## Install klive from KLayout + +Open the KLayout package manager under **Tools → Manage Packages**, search for **klive**, and +click Install. + +The video below shows the process: + +![type:video](../_static/klive.webm) + +## How klive works + +klive listens on **localhost:8082**. When you call `kf.show(cell)`, kfactory: + +1. Exports the cell to a temporary GDS file. +2. Sends a JSON message to port 8082 with the file path. +3. klive loads (or reloads) the file in the open KLayout window. + +## Tip: disable KLayout's file-reload dialog + +KLayout occasionally shows a "file changed on disk" dialog that can interfere with klive. +Turn it off in **File → Setup → Application → General** by unchecking +**Check files for updates**. + +## See Also + +| Topic | Where | +|-------|-------| +| Prerequisites (Python, KLayout) | [Getting Started: Prerequisites](prerequisites.md) | +| Installing kfactory | [Getting Started: Installation](installation.md) | +| 5-minute quickstart | [Getting Started: Quickstart](quickstart.py) | diff --git a/docs/source/getting_started/prerequisites.md b/docs/source/getting_started/prerequisites.md new file mode 100644 index 000000000..e5134f4e3 --- /dev/null +++ b/docs/source/getting_started/prerequisites.md @@ -0,0 +1,42 @@ +# Prerequisites + +## Python + +kfactory requires **Python 3.10+**. Familiarity with Python basics — functions, classes, +type hints, and virtual environments — will help you follow the tutorials. + +A good starting point: [learnpython.org](https://www.learnpython.org/) + +## Python environment + +We strongly recommend using a dedicated virtual environment per project. Popular choices: + +| Tool | Notes | +|------|-------| +| [uv](https://docs.astral.sh/uv/) | Fast, modern, Rust-backed. Recommended. | +| [venv](https://docs.python.org/3/library/venv.html) | Built into Python, minimal setup. | +| [conda / miniconda](https://docs.conda.io/en/latest/miniconda.html) | Good when non-Python dependencies are involved. | + +## KLayout + +[KLayout](https://www.klayout.de/intro.html) is the open-source GDS/OASIS viewer that kfactory +builds on. kfactory uses the `klayout` Python package internally, but having the **desktop +application** installed lets you open `.gds` files and use the `kf.show()` live preview feature. + +Download: [klayout.de/build.html](https://www.klayout.de/build.html) + +## klive (optional) + +[klive](https://github.com/gdsfactory/klive) is a KLayout plug-in that streams GDS files +directly from Python into the running KLayout window. It is not required to run kfactory, +but it makes the interactive design loop much faster. + +See [KLive Setup](klive_setup.md) for installation instructions. + +## See Also + +| Topic | Where | +|-------|-------| +| Installing kfactory | [Getting Started: Installation](installation.md) | +| KLive streaming setup | [Getting Started: KLive Setup](klive_setup.md) | +| 5-minute quickstart | [Getting Started: Quickstart](quickstart.py) | diff --git a/docs/source/getting_started/quickstart.py b/docs/source/getting_started/quickstart.py new file mode 100644 index 000000000..0a9476ed0 --- /dev/null +++ b/docs/source/getting_started/quickstart.py @@ -0,0 +1,197 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # 5-Minute Quickstart +# +# This page walks you through the essential kfactory workflow: +# +# 1. Define layers +# 2. Build parametric components with `@kf.cell` +# 3. Assemble components using instances and port connections +# 4. Inspect the result +# +# Everything runs in-memory — no external files needed. + +# %% [markdown] +# ## 1. Import and define layers +# +# kfactory's entry point is `import kfactory as kf`. The first thing to define is a +# **layer set** — a `LayerInfos` subclass that maps human-readable names to KLayout +# `LayerInfo` objects (layer number + datatype). +# +# The default database unit (`dbu`) is **1 nm**, so 1 µm = 1000 DBU. + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) # waveguide core + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) # exclusion zone + + +L = LAYER() +kf.kcl.infos = L # register with the global layout so helpers like find_layer work + +# %% [markdown] +# ## 2. Create a parametric straight waveguide +# +# The `@kf.cell` decorator turns a plain function into a **cached parametric cell +# (PCell)**. Calling it twice with the same arguments returns the *same* object — no +# duplicate geometry in the layout. +# +# Shapes and ports are expressed in **µm** using `DBox` / `DCplxTrans`: +# +# - `kf.kcl.find_layer(info)` converts a `LayerInfo` to the integer layer index needed +# for shapes and ports. +# - `kf.kcl.to_dbu(um)` converts µm to the integer DBU value required for port widths. + + +# %% +@kf.cell +def straight(width: float = 1.0, length: float = 10.0) -> kf.KCell: + """Straight waveguide with WG core and WGEX exclusion zone. + + Args: + width: waveguide width in µm + length: waveguide length in µm + """ + c = kf.KCell() + ex = width + 2.0 # 1 µm exclusion on each side + + # Core geometry (µm coordinates via DBox) + c.shapes(kf.kcl.find_layer(L.WG)).insert( + kf.kdb.DBox(0, -width / 2, length, width / 2) + ) + # Exclusion zone + c.shapes(kf.kcl.find_layer(L.WGEX)).insert(kf.kdb.DBox(0, -ex / 2, length, ex / 2)) + + # Ports — DCplxTrans(mag, angle_deg, mirror, x_um, y_um) + wl = kf.kcl.find_layer(L.WG) + wd = kf.kcl.to_dbu(width) + c.add_port( + port=kf.Port( + name="o1", + width=wd, + dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, 0, 0), + layer=wl, + kcl=kf.kcl, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + width=wd, + dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, length, 0), + layer=wl, + kcl=kf.kcl, + ) + ) + return c + + +wg = straight(width=1.0, length=10.0) +wg.plot() + +# %% [markdown] +# ## 3. Use built-in factory cells +# +# kfactory ships with ready-made factory functions for common photonic primitives. +# `bend_euler_factory` creates an Euler (clothoid) bend factory bound to a specific +# `KCLayout`. Width and radius are in **µm**. + +# %% +bend_euler = kf.factories.euler.bend_euler_factory(kcl=kf.kcl) + +bend = bend_euler(width=1.0, radius=10.0, layer=L.WG) +bend.plot() + +# %% [markdown] +# ## 4. Assemble a circuit +# +# Place instances with the `<<` operator and connect them port-to-port with +# `instance.connect(own_port, other_instance_or_cell, other_port)`. + + +# %% +@kf.cell +def l_shaped_arm( + wg_width: float = 1.0, bend_radius: float = 10.0, arm_length: float = 20.0 +) -> kf.KCell: + """Simple L-shaped arm: straight → 90° Euler bend → straight.""" + c = kf.KCell() + + _wg = straight(width=wg_width, length=arm_length) + _bend = bend_euler(width=wg_width, radius=bend_radius, layer=L.WG) + + # Place instances + i_wg1 = c << _wg + i_bend = c << _bend + i_wg2 = c << _wg + + # Connect in a chain + i_bend.connect("o1", i_wg1, "o2") + i_wg2.connect("o1", i_bend, "o2") + + # Expose the outer ports on the parent cell + c.add_port(port=i_wg1.ports["o1"]) + c.add_port(port=i_wg2.ports["o2"]) + c.auto_rename_ports() + return c + + +arm = l_shaped_arm() +arm.plot() + +# %% [markdown] +# ## 5. Inspect the result +# +# Every cell carries metadata you can inspect: + +# %% +print("Cell name :", arm.name) +print("Bounding box :", arm.dbbox(), "µm") +print() +print("Ports:") +for p in arm.ports: + print( + f" {p.name:4s} layer={p.layer}" + f" width={kf.kcl.to_um(p.width):.3f} µm" + f" @ ({kf.kcl.to_um(p.x):.1f}, {kf.kcl.to_um(p.y):.1f}) µm" + f" angle={p.trans.angle * 90}°" + ) + +# %% [markdown] +# ## 6. Stream to KLayout (optional) +# +# If [klive](klive_setup.md) is installed and KLayout is open, a single call pushes the +# GDS to the viewer: +# +# ```python +# kf.show(arm) +# ``` +# +# ## Next steps +# +# | Topic | Where | +# |-------|-------| +# | Cells in depth | [Core Concepts: KCell](../concepts/kcell.py) | +# | Layers & layer stacks | [Core Concepts: Layers](../concepts/layers.py) | +# | Port system | [Core Concepts: Ports](../concepts/ports.py) | +# | DBU vs µm coordinates | [Core Concepts: DBU vs µm](../concepts/dbu_vs_um.py) | +# | Optical routing | [Routing: Optical](../routing/optical.py) | +# | Enclosures / cladding | [Enclosures: Layer Enclosure](../enclosures/layer_enclosure.py) | diff --git a/docs/source/howto/best_practices.py b/docs/source/howto/best_practices.py new file mode 100644 index 000000000..d4e181374 --- /dev/null +++ b/docs/source/howto/best_practices.py @@ -0,0 +1,546 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Best Practices & Common Pitfalls +# +# This guide collects the most frequent mistakes kfactory users encounter, with +# the correct patterns shown for each one. Every section is self-contained and +# executable. +# +# **Contents** +# +# 1. [Units: DBU vs µm](#1-units-dbu-vs-m) +# 2. [Ports: `port=` keyword is required](#2-ports-port-keyword-is-required) +# 3. [Layers & KCLayout initialisation](#3-layers-kclayout-initialisation) +# 4. [Caching: arguments must be hashable](#4-caching-arguments-must-be-hashable) +# 5. [Cross-sections in cached cells](#5-cross-sections-in-cached-cells) +# 6. [Factory parameter units](#6-factory-parameter-units) +# 7. [Effective bend radius vs nominal radius](#7-effective-bend-radius-vs-nominal-radius) +# 8. [Routing: suppress collision errors in headless builds](#8-routing-suppress-collision-errors-in-headless-builds) +# 9. [PDK: always pass `kcl=` to `kf.Port`](#9-pdk-always-pass-kcl-to-kfport) +# 10. [Enclosures: µm sections need `kcl=`](#10-enclosures-m-sections-need-kcl) +# 11. [fill_tiled: call inside `@kf.cell`, result is in-place](#11-fill_tiled-call-inside-kfcell-result-is-in-place) +# 12. [Packing: spacing and limits are in DBU](#12-packing-spacing-and-limits-are-in-dbu) +# 13. [dmove: pass a tuple, not a DVector](#13-dmove-pass-a-tuple-not-a-dvector) + +# %% +import kfactory as kf +import kfactory.routing.optical as opt + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + + +L = LAYER() +kf.kcl.infos = L + +li_wg = kf.kcl.find_layer(L.WG) +li_wgex = kf.kcl.find_layer(L.WGEX) + +# %% [markdown] +# --- +# ## 1 · Units: DBU vs µm +# +# kfactory (and KLayout) has two parallel coordinate systems: +# +# | System | Unit | Python type | Rule of thumb | +# |--------|------|-------------|---------------| +# | **DBU** (database units) | 1 nm (default) | `int` | Use inside cell logic, ports, boolean ops | +# | **µm** (micrometres) | 1 µm | `float` | Use for user-facing parameters | +# +# The conversion is fixed: **1 µm = 1 000 DBU** (`kf.kcl.dbu = 0.001 µm`). +# +# ### Common mistake — passing µm where DBU is expected +# +# ```python +# # WRONG: 0.5 is treated as 0 DBU (rounds to integer) +# c.shapes(li_wg).insert(kf.kdb.Box(10.0, 0.5)) +# +# # WRONG: width=0.5 sets port to 0 or 1 DBU, not 500 nm +# kf.Port(name="o1", width=0.5, ...) +# ``` +# +# ### Correct pattern + +# %% +# Convert µm → DBU explicitly at the boundary +width_um = 0.5 # user-facing µm value +length_um = 10.0 + +w_dbu = kf.kcl.to_dbu(width_um) # → 500 (int) +l_dbu = kf.kcl.to_dbu(length_um) # → 10000 (int) + +c = kf.KCell("bp_units_demo") +c.shapes(li_wg).insert(kf.kdb.Box(l_dbu, w_dbu)) +c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, -l_dbu // 2, 0), + width=w_dbu, # ← integer DBU + layer=li_wg, + port_type="optical", + ) +) +c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, l_dbu // 2, 0), + width=w_dbu, + layer=li_wg, + port_type="optical", + ) +) + +print(f"dbu setting : {kf.kcl.dbu} µm/DBU") +print(f"width → DBU : {w_dbu}") +print(f"bbox (DBU) : {c.bbox()}") +print(f"bbox (µm) : {c.dbbox()}") + +# %% [markdown] +# --- +# ## 2 · Ports: `port=` keyword is required +# +# `KCell.add_port()` takes the port via the **keyword argument** `port=`. +# Passing it positionally raises a `TypeError`. +# +# ```python +# # WRONG — positional argument +# c.add_port(my_port) +# +# # WRONG — wrong keyword +# c.add_port(p=my_port) +# ``` + +# %% +# CORRECT — always use port= +example = kf.KCell("bp_add_port") +p = kf.Port( + name="o1", trans=kf.kdb.Trans(0), width=500, layer=li_wg, port_type="optical" +) +example.add_port(port=p) # ← keyword required + +# Renaming when exposing a child port on a parent cell: +parent = kf.KCell("bp_parent") +inst = parent << example +parent.add_port(port=inst.ports["o1"], name="in") # ← name= renames +print(f"parent ports: {[p.name for p in parent.ports]}") + +# %% [markdown] +# --- +# ## 3 · Layers & KCLayout initialisation +# +# ### Pass the **class** (not an instance) as `infos=` +# +# `KCLayout.__init__` calls `infos()` internally, so you must pass the class: +# +# ```python +# # WRONG — passes an instance; KCLayout calls LAYER()() which fails +# pdk = kf.KCLayout("mypdk", infos=LAYER()) +# +# # CORRECT — pass the class +# pdk = kf.KCLayout("mypdk", infos=LAYER) +# ``` +# +# ### Setting `infos` after construction does not fully register layers + +# %% +# Full correct initialisation for a new KCLayout +pdk = kf.KCLayout("best_practices_pdk", infos=LAYER) +L2 = pdk.infos # already an instance; use this for layer lookups + +print(f"pdk.dbu : {pdk.dbu}") +print(f"layer WG : {pdk.find_layer(L2.WG)}") + +# %% [markdown] +# ### `layerenum_from_dict` lives in `kfactory.layer`, not top-level `kf` +# +# ```python +# # WRONG +# kf.layerenum_from_dict(...) +# +# # CORRECT +# from kfactory.layer import layerenum_from_dict +# layerenum_from_dict(...) +# ``` + +# %% [markdown] +# --- +# ## 4 · Caching: arguments must be hashable +# +# `@kf.cell` identifies unique cells by hashing all arguments. Any +# unhashable argument (list, dict, `LayerInfo` object, `CrossSection` object) +# raises `TypeError` at call time. +# +# **Rules:** +# - Use `int`, `float`, `str`, `bool`, `tuple`, or frozen containers. +# - Replace a `CrossSection` argument with its **name string**; look it up +# inside the function. +# - Replace a `LayerInfo` with a `str` name or `int` layer index. + +# %% +# Register a cross-section: call get_icross_section() with a spec to store it, +# then retrieve it by name inside the factory. +_xs_spec = kf.DCrossSection( + kcl=kf.kcl, + width=0.5, + layer=L.WG, + sections=[(L.WGEX, 1.0)], # 1 µm cladding + name="wg_500", +) +kf.kcl.get_icross_section(_xs_spec) # ← registers under the name "wg_500" + + +@kf.cell +def waveguide_xs(xs_name: str = "wg_500", length_um: float = 10.0) -> kf.KCell: + """Waveguide using a cross-section looked up by name.""" + xs = kf.kcl.get_icross_section(xs_name) # ← resolve here, not in signature + c = kf.KCell() + l = kf.kcl.to_dbu(length_um) + w = xs.width # already in DBU + li = kf.kcl.find_layer(xs.main_layer) + c.shapes(li).insert(kf.kdb.Box(l, w)) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, -l // 2, 0), + width=w, + layer=li, + port_type="optical", + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, l // 2, 0), + width=w, + layer=li, + port_type="optical", + ) + ) + return c + + +wg_a = waveguide_xs(xs_name="wg_500", length_um=10.0) +wg_b = waveguide_xs(xs_name="wg_500", length_um=10.0) +print(f"Same params → same object: {wg_a is wg_b}") # True + +# %% [markdown] +# --- +# ## 5 · Cross-sections in cached cells +# +# Passing a `CrossSection` or `DCrossSection` **object** directly as a parameter +# raises `TypeError: unhashable type` because cross-section objects are not +# hashable. Always pass the **name string** and resolve inside the function +# (see §4 above). + +# %% [markdown] +# --- +# ## 6 · Factory parameter units +# +# Each factory function uses a specific unit system for its parameters. +# Getting this wrong produces silently wrong geometry. +# +# | Factory | Width | Length / Radius | Unit | +# |---------|-------|-----------------|------| +# | `straight_dbu_factory` | DBU (`int`) | DBU (`int`) | nm | +# | `taper_factory` | DBU (`int`) | DBU (`int`) | nm | +# | `bend_euler_factory` | µm (`float`) | µm (`float`) | µm | +# | `bend_circular_factory` | µm (`float`) | µm (`float`) | µm | +# | `bend_s_euler_factory` | µm (`float`) | µm (`float`) | µm | +# | `bezier_factory` | µm (`float`) | µm (`float`) | µm | + +# %% +# Use a dedicated layout for the factory demo to avoid global state conflicts +fac_pdk = kf.KCLayout("BP_FAC_DEMO", infos=LAYER) + +straight_f = kf.factories.straight.straight_dbu_factory(fac_pdk) +bend_euler_f = kf.factories.euler.bend_euler_factory(fac_pdk) + +# straight_dbu_factory: width and length in DBU (use to_dbu to convert µm) +s = straight_f( + width=fac_pdk.to_dbu(0.5), # 500 DBU = 0.5 µm + length=fac_pdk.to_dbu(10.0), # 10000 DBU = 10 µm + layer=L.WG, +) +print(f"straight bbox (DBU): {s.bbox()}") + +# bend_euler_factory: width and radius in µm (no to_dbu needed) +b = bend_euler_f(width=0.5, radius=10.0, layer=L.WG) +print(f"euler bbox (µm): {b.dbbox()}") + +# %% [markdown] +# --- +# ## 7 · Effective bend radius vs nominal radius +# +# Euler (clothoid) bends extend further than their nominal radius because the +# clothoid transitions ramp up gradually. Always use +# `kf.routing.optical.get_radius(bend_cell)` to get the **footprint radius** +# that routing algorithms need, not the nominal value you passed to the factory. +# +# Circular bends return the exact nominal radius — `get_radius` is still safe +# to use but adds no correction. + +# %% +bend90 = bend_euler_f(width=0.5, radius=10.0, layer=L.WG) + +nominal_radius = 10.0 # what we asked for +footprint_radius = opt.get_radius(bend90) # what routing needs + +print(f"nominal radius : {nominal_radius:.3f} µm") +print(f"footprint radius: {footprint_radius:.3f} µm") +print(f"difference : {footprint_radius - nominal_radius:.3f} µm") + +# %% [markdown] +# > **Rule:** Pass `footprint_radius` (not `nominal_radius`) to +# > `route_loopback(bend90_radius=...)`, `place_manhattan(...)`, and +# > similar functions. Using the nominal value causes "distance too small" errors. + +# %% [markdown] +# --- +# ## 8 · Routing: suppress collision errors in headless builds +# +# By default, `route_bundle` enables collision detection and calls +# `kf.show()` / KLayout's error dialog when routes overlap. In a headless +# documentation build (no display, no KLayout window) this hangs or crashes. +# +# Pass `on_collision=None` to disable the callback: +# +# ```python +# # Headless-safe (docs, CI, testing) +# kf.routing.optical.route_bundle( +# ..., +# on_collision=None, +# ) +# +# # Interactive (development): keep default or pass on_collision="show" +# kf.routing.optical.route_bundle(...) +# ``` +# +# > **Note:** Only suppress when you are confident the geometry is correct. +# > Leave collision detection on during development so errors are caught early. + +# %% [markdown] +# --- +# ## 9 · PDK: always pass `kcl=` to `kf.Port` +# +# When building cells inside a custom `KCLayout` (PDK), ports created with +# `kf.Port(...)` default to the **global** `kf.kcl` layout. Layer indices in +# the PDK layout will differ, causing silent mismatches or runtime errors. +# +# ```python +# # WRONG — port attached to global kf.kcl, not pdk +# p = kf.Port(name="o1", layer=pdk.find_layer(L.WG), ...) +# +# # CORRECT — port attached to the correct layout +# p = kf.Port(name="o1", layer=pdk.find_layer(L.WG), kcl=pdk, ...) +# ``` + + +# %% +# Demonstrate: ports use pdk layout, not global kf.kcl +@pdk.cell +def pdk_straight(width: float = 0.5, length: float = 10.0) -> kf.KCell: + c = kf.KCell(kcl=pdk) + w = pdk.to_dbu(width) + ll = pdk.to_dbu(length) + li = pdk.find_layer(L2.WG) + c.shapes(li).insert(kf.kdb.Box(ll, w)) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, -ll // 2, 0), + width=w, + layer=li, + port_type="optical", + kcl=pdk, # ← attach to PDK layout + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, ll // 2, 0), + width=w, + layer=li, + port_type="optical", + kcl=pdk, # ← attach to PDK layout + ) + ) + return c + + +ps = pdk_straight() +print(f"PDK cell layout : {ps.kcl.name}") +print(f"Port o1 layout : {ps.ports['o1'].kcl.name}") + +# %% [markdown] +# --- +# ## 10 · Enclosures: µm sections need `kcl=` +# +# `LayerEnclosure` accepts sections in either DBU or µm. When using the +# `dsections=` (µm) form, the enclosure needs a reference `KCLayout` to +# convert µm → DBU. Omitting `kcl=` raises `AttributeError` at apply time. +# +# ```python +# # WRONG — dsections without kcl= +# enc = kf.LayerEnclosure(dsections=[(L.WGEX, 1.0)]) +# +# # CORRECT — provide kcl= so conversion is possible +# enc = kf.LayerEnclosure(dsections=[(L.WGEX, 1.0)], kcl=kf.kcl) +# ``` +# +# ### Three-element sections create annular (ring) cladding +# +# ```python +# # Two-element (layer, d) → expand outward by d (DBU) +# enc_solid = kf.LayerEnclosure(sections=[(L.WGEX, 500)]) +# +# # Three-element (layer, d_min, d_max) → ring from d_min to d_max (DBU) +# enc_ring = kf.LayerEnclosure(sections=[(L.SLAB, 0, 2000)]) +# ``` + +# %% +# Correct dsections usage with kcl= +enc_ok = kf.LayerEnclosure(dsections=[(L.WGEX, 1.0)], kcl=kf.kcl) +print(f"enclosure layer_sections: {enc_ok.layer_sections}") + +# Three-element annular section demo (annular ring: d_min=0, d_max=2 µm) +enc_ring = kf.LayerEnclosure(sections=[(L.SLAB, 0, 2000)], kcl=kf.kcl) +print(f"ring layer_sections: {enc_ring.layer_sections}") + +# %% [markdown] +# --- +# ## 11 · `fill_tiled`: call inside `@kf.cell`, result is in-place +# +# Two rules that catch almost everyone: +# +# 1. **Call `fill_tiled` inside the decorated function** — the target cell must +# be unlocked. After `@kf.cell` caches and freezes the cell you can no +# longer modify it. +# 2. **`fill_tiled` returns `None`** — it modifies the cell in-place. Do not +# assign its return value. +# +# ```python +# # WRONG — called after caching (cell is frozen) +# my_cell = make_fill_cell() +# kf.fill_tiled(my_cell, ...) # AttributeError: cell is read-only +# +# # WRONG — return value assigned (it's None) +# region = kf.fill_tiled(c, ...) # region is None +# +# # CORRECT — call inside the factory function +# @kf.cell +# def fill_block(width_um: float, height_um: float) -> kf.KCell: +# c = kf.KCell() +# ... +# kf.fill_tiled(c, ...) # ← in-place, returns None +# return c +# ``` +# +# Also note: +# - `row_step` / `col_step` are `kdb.DVector` in **µm**. +# - `x_space` / `y_space` are µm gaps between bounding boxes. +# - `@kf.cell(kcl=...)` is **not valid syntax** — create cells with +# `kf.KCell(kcl=...)` explicitly inside the function body. + +# %% [markdown] +# --- +# ## 12 · Packing: spacing and limits are in DBU +# +# `kf.packing.pack_kcells` and `kf.packing.pack_instances` live in the +# `kf.packing` **sub-module** (not top-level `kf`). Their `spacing`, +# `max_width`, and `max_height` parameters are all in **DBU**. + +# %% +from kfactory import packing + +# Build a handful of small cells to pack +cells = [kf.KCell(f"pack_demo_{i}") for i in range(5)] +for i, cell in enumerate(cells): + cell.shapes(li_wg).insert( + kf.kdb.Box( + kf.kcl.to_dbu((i + 1) * 5.0), # 5, 10, 15, 20, 25 µm wide + kf.kcl.to_dbu(2.0), # 2 µm tall + ) + ) + +container = kf.KCell("pack_container") +packing.pack_kcells( + kcells=cells, + target=container, + spacing=kf.kcl.to_dbu(2.0), # ← DBU (use to_dbu to convert from µm) + max_width=kf.kcl.to_dbu(100.0), # ← DBU +) +print(f"packed bbox (µm): {container.dbbox()}") + +# %% [markdown] +# --- +# ## 13 · `dmove`: pass a tuple, not a `DVector` +# +# `Instance.dmove()` accepts a `(dx, dy)` tuple of µm floats. Passing a +# `kdb.DVector` raises `TypeError` because the method tries to unpack it as +# a two-element iterable and gets a `CplxTrans` argument error. +# +# ```python +# # WRONG — DVector causes TypeError with DCplxTrans unpacking +# inst.dmove(kf.kdb.DVector(5.0, 0.0)) +# +# # CORRECT — plain tuple +# inst.dmove((5.0, 0.0)) +# ``` + +# %% +tile = kf.KCell("dmove_demo_tile") +tile.shapes(li_wg).insert(kf.kdb.Box(2000, 500)) + +canvas = kf.KCell("dmove_demo_canvas") +for i in range(4): + inst = canvas << tile + inst.dmove((i * 3.0, 0.0)) # ← tuple, not DVector + +print(f"canvas bbox (µm): {canvas.dbbox()}") + +# %% [markdown] +# --- +# ## Quick-reference summary +# +# | Pitfall | Rule | +# |---------|------| +# | µm vs DBU confusion | Convert at function boundary with `kf.kcl.to_dbu()` | +# | `add_port` errors | Always use `c.add_port(port=p)` keyword form | +# | KCLayout `infos=` | Pass the **class** (`infos=LAYER`), not an instance | +# | Unhashable `@kf.cell` args | Pass cross-sections / layers as **name strings** | +# | Wrong factory units | `straight_dbu_factory` → DBU; euler/circular → µm | +# | Routing with euler bends | Use `opt.get_radius(bend)` for footprint radius | +# | Headless collision errors | Pass `on_collision=None` in CI / doc builds | +# | PDK port layout mismatch | Always pass `kcl=pdk` to `kf.Port(...)` | +# | Enclosure µm sections | Pass `kcl=` to `LayerEnclosure(dsections=...)` | +# | `fill_tiled` usage | Call inside `@kf.cell`; return value is `None` | +# | Packing parameters | `spacing`, `max_width`, `max_height` are in **DBU** | +# | `dmove` argument | Pass `(dx, dy)` tuple, not `kdb.DVector` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Common patterns (positive guidance) | [How-To: Patterns](patterns.py) | +# | Frequently asked questions | [How-To: FAQ](faq.md) | +# | DBU vs µm coordinate systems | [Core Concepts: DBU vs µm](../concepts/dbu_vs_um.py) | +# | Creating a full PDK | [PDK: Creating a PDK](../pdk/creating_pdk.py) | diff --git a/docs/source/howto/contributing.md b/docs/source/howto/contributing.md new file mode 100644 index 000000000..c832280cd --- /dev/null +++ b/docs/source/howto/contributing.md @@ -0,0 +1,159 @@ +# Contributing to kfactory + +kfactory is an open-source project — contributions are welcome! + +## Quick links + +- [GitHub repository](https://github.com/gdsfactory/kfactory) +- [Issue tracker](https://github.com/gdsfactory/kfactory/issues) +- [Pull request template](https://github.com/gdsfactory/kfactory/blob/main/.github/PULL_REQUEST_TEMPLATE.md) + +## Development setup + +kfactory uses [uv](https://docs.astral.sh/uv/) for environment management and +[just](https://just.systems/) as a task runner. + +```bash +# Clone the repository +git clone https://github.com/gdsfactory/kfactory.git +cd kfactory + +# Set up the full development environment (installs all extras + pre-commit hooks) +just dev +``` + +`just dev` is equivalent to: + +```bash +uv sync --all-extras +uv pip install -e . -U +uv run pre-commit install +``` + +## Running tests + +```bash +# Full test suite (parallel, uses logical CPU count) +just test + +# Minimum-dependency variant (checks nothing was accidentally removed) +just test-min + +# With coverage report in the terminal +just dev-cov + +# Single test file or test by name +uv run -p 3.14 --with . --extra ci --isolated pytest tests/test_kcell.py -s +``` + +Tests require the git submodules to be initialised (`just init-submodule` is run +automatically before `just test`). + +## Building the docs + +The docs build uses [zensical](https://zensical.org), the successor to +mkdocs by the Material for MkDocs team. + +```bash +# Build once (outputs to docs/site/) +just docs + +# Live-reload server (edit files, browser refreshes automatically) +just docs-serve + +# Remove the build artefact +just docs-clean +``` + +The build runs in two stages: + +1. **Pre-build** (`docs-build-source`): `docs/scripts/build_docs_source.py` + converts every jupytext `.py` notebook under `docs/source/` to + executed `.md` + a downloadable `.ipynb`, and writes mkdocstrings + stub pages under `docs/source-built/reference/`. Outputs go to + `docs/source-built/` (gitignored). Runs are cached by content hash + in `docs/.build-cache/` — only changed notebooks re-execute. +2. **Render**: zensical reads `docs/source-built/` and produces the + final HTML in `docs/site/`. + +Any exception in a notebook stops the build. Always run `just docs` +locally before opening a docs PR. + +When writing a new doc page, follow the jupytext percent format used +throughout the repo — see `docs_overhaul/notes_build_system.md` in the +repository for templates and gotchas. Each rendered notebook page also +gets a "Download notebook (.ipynb)" button at the top so readers can +re-execute locally. + +## Code quality + +```bash +# Lint +just lint # ruff check + +# Format +just format # ruff format + +# Type check (daemon — fast on repeat runs) +just mypy + +# Experimental faster type checker +just ty +``` + +Pre-commit runs `ruff check --fix` and `ruff format` automatically on every +`git commit` once `just dev` has been run. + +## Contribution workflow + +1. **Fork** the repository and create a feature branch from `main`. +2. Make your changes. Add or update tests in `tests/` if touching library code. +3. Run `just test` and `just docs` (if you touched documentation). +4. Open a pull request against `main` with a short summary of *what* and *why*. + +### PR guidelines + +- Keep PRs focused — one logical change per PR is easier to review. +- Add a test for every new public function or behaviour change. +- Documentation notebooks are required for new features exposed to end users. +- The PR title is used by the release drafter; start it with a verb + (*Add*, *Fix*, *Update*, *Remove*). + +## Project layout + +``` +src/kfactory/ # library source +tests/ # pytest tests +docs/ + source/ # documentation pages (Markdown + jupytext .py notebooks) + source-built/ # pre-build output (gitignored — created by docs-build-source) + scripts/ # build_docs_source.py (notebook → .md + .ipynb pipeline) + zensical.yml # navigation + plugin config +Justfile # common dev commands +pyproject.toml # package metadata + dependencies +``` + +## Dependency extras + +| Extra | Purpose | +|-------|---------| +| `kfactory[dev]` | Full dev environment (CI + type stubs + pre-commit) | +| `kfactory[ci]` | Test dependencies (`pytest`, coverage, etc.) | +| `kfactory[notebooks]` | Notebook conversion pipeline (`jupytext`, `nbconvert`, `ipykernel`) | +| `kfactory[docs]` | Documentation build via zensical (pulls in `[notebooks]`) | +| `kfactory[ipy]` | Jupyter / IPython display helpers (`kf.show`, `.plot()`) | + +## Getting help + +- Open a [GitHub issue](https://github.com/gdsfactory/kfactory/issues) for bugs + or feature requests. +- For usage questions, the [How-To Guides](best_practices.py) and + [FAQ](faq.md) are a good starting point. + +## See Also + +| Topic | Where | +|-------|-------| +| Frequently asked questions | [How-To: FAQ](faq.md) | +| Installation instructions | [Getting Started: Installation](../getting_started/installation.md) | +| Common pitfalls to avoid | [How-To: Best Practices](best_practices.py) | diff --git a/docs/source/howto/faq.md b/docs/source/howto/faq.md new file mode 100644 index 000000000..fd96903fc --- /dev/null +++ b/docs/source/howto/faq.md @@ -0,0 +1,327 @@ +# Frequently Asked Questions + +Common questions and "gotcha" moments collected from kfactory users. +For executable examples see [Best Practices](best_practices.py). + +--- + +## Units & coordinates + +### Why does my geometry look 1000× too big (or too small)? + +kfactory uses two coordinate systems in parallel: + +| System | Unit | Used by | +|--------|------|---------| +| DBU (database units) | nm (1 nm = 1 DBU at default 1 nm/DBU) | KLayout internals, `KCell`, `Port`, `kdb.Trans` | +| µm | micrometres | Human-readable APIs, `DKCell`, `DPort`, `kdb.DTrans` | + +The two never mix automatically. Always convert explicitly: + +```python +width_nm = kf.kcl.to_dbu(0.5) # 0.5 µm → 500 DBU +width_um = kf.kcl.to_um(500) # 500 DBU → 0.5 µm +``` + +The integer `to_dbu` conversion **rounds** — widths that are not a multiple of 2 DBU +are silently rounded. Design your grid in multiples of 2 DBU to avoid asymmetry. + +--- + +### Which factory takes DBU and which takes µm? + +| Factory | Width / radius units | +|---------|---------------------| +| `straight_dbu_factory` | **DBU** | +| `taper_factory` | **DBU** | +| `bend_euler_factory` | **µm** | +| `bend_circular_factory` | **µm** | +| `bend_s_euler_factory` | **µm** | +| `bezier_factory` (via `kf.cells.bezier`) | **µm** | + +When in doubt, look at the function name: `_dbu_` → DBU, otherwise µm. + +--- + +## Ports + +### I get a `TypeError` about a missing keyword argument on `add_port` + +`add_port` requires the `port=` keyword — passing a port positionally raises a +`TypeError`: + +```python +# Wrong +c.add_port(instance.ports["o1"]) + +# Correct +c.add_port(port=instance.ports["o1"]) +``` + +--- + +### How do I rename a port when exposing it from a sub-cell? + +```python +c.add_port(port=instance.ports["o1"], name="in") +``` + +Do **not** use `port.copy("new_name")` — that creates a detached copy not linked +to the instance. + +--- + +### What are the port angle integers? + +KLayout integer angles: `0` = East (0°), `1` = North (90°), `2` = West (180°), `3` = South (270°). + +A port's angle is the **outward** direction — the direction a wire *exits* at that port. + +--- + +## Layers & KCLayout + +### My layer indices are wrong when I mix PDK cells with global `kf.kcl` cells + +Each `KCLayout` has its own layer-index table. A port or shape created under `kf.kcl` +carries layer index 0 for `WG`; a different `KCLayout` may map `WG` to index 5. + +**Fix**: always pass `kcl=pdk` when constructing `kf.Port` inside a PDK cell: + +```python +c.add_port(port=kf.Port( + name="o1", + trans=kf.kdb.Trans(0, False, 0, 0), + width=500, + layer=pdk.find_layer(L.WG), + kcl=pdk, # <-- critical +)) +``` + +--- + +### `KCLayout` isn't seeing my layers even though I set `kcl.infos` + +Pass the **class** (not an instance) to `KCLayout.__init__`: + +```python +# Correct — pass the class +pdk = kf.KCLayout("my_pdk", infos=LAYER) + +# Wrong — setting after construction does not fully propagate +pdk = kf.KCLayout("my_pdk") +pdk.infos = LAYER() # ← incomplete +``` + +After construction `pdk.infos` holds the instance; the `infos=` constructor +argument triggers internal layer registration that the post-hoc assignment skips. + +--- + +### Where do I import `LayerLevel`, `LayerStack`, `layerenum_from_dict`? + +These are **not** re-exported from top-level `kf`. Import them from the sub-module: + +```python +from kfactory.layer import LayerLevel, LayerStack, layerenum_from_dict +``` + +--- + +## Caching & `@kf.cell` + +### My `@kf.cell` function raises `TypeError: unhashable type` + +All arguments passed to a `@kf.cell` function must be hashable (Python can cache +them as dict keys). Common offenders: + +| Type | Fix | +|------|-----| +| `list` | Use `tuple` | +| `dict` | Use a frozen dataclass or a named tuple | +| `LayerInfo` object | Use `LayerInfo` directly — it *is* hashable | +| `CrossSection` object | Pass the name string; look it up inside with `kcl.get_icross_section(name)` | + +--- + +### Why does `@pdk.cell` work but `@kf.cell` raises `ValueError: must use same KCLayout`? + +When a cell factory creates `KCell(kcl=pdk)` the decorator must match. +`@kf.cell` registers cells in `kf.kcl`; `@pdk.cell` registers them in `pdk`. +Mix-up causes a layout-ownership mismatch at registration time. + +**Rule**: use `@pdk.cell` (or `kf.cell(kcl=pdk)`) whenever your factory body +creates cells under a custom `KCLayout`. + +--- + +## Routing + +### What is the difference between the nominal radius and the effective radius? + +Euler (clothoid) bends are longer than a circular arc of the same nominal radius +because the curvature ramps up gradually. Their physical footprint extends further +than the radius you passed to the factory. + +Always use `kf.routing.optical.get_radius(bend_cell)` when you need the actual +footprint radius for routing calculations: + +```python +bend90 = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, radius=10, layer=L.WG, angle=90, +) +r_eff = kf.routing.optical.get_radius(bend90) # > 10 µm for euler bends +``` + +Circular bends return the nominal radius unchanged (`get_radius(bend)` == `radius`). + +--- + +### Path-length matching loops collide — how do I fix it? + +Path-length matching inserts S-loops into shorter routes. Each loop needs room: + +- Routes must be spaced ≥ 150 µm apart horizontally. +- Routes must already contain at least one bend (purely straight routes have no + room for loop insertion). + +Increase the horizontal pitch between ports, or add explicit waypoints to force +bends before the matching section. + +--- + +### `route_bundle` shows a KLayout error dialog during headless builds + +Collision detection calls `kdb.show_error()` which spawns a dialog in GUI mode. +Suppress it for CI / notebook execution: + +```python +kf.routing.optical.route_bundle( + c, start_ports, end_ports, + bend90_cell=bend90, + straight_factory=sf, + on_collision=None, # <-- suppress dialog +) +``` + +--- + +### `route_smart` raises a `ValueError` about `BasePort` + +`route_smart` expects kfactory's internal `BasePort` (a Pydantic model), **not** +a `kf.Port`. Use `route_manhattan` + `place_manhattan` directly for low-level +single-route control instead. + +--- + +### All-angle bundle fails with "not enough space" + +Each backbone segment between waypoints must be at least 2× the effective bend +radius. If your waypoints are too close together the router cannot fit the entry +and exit bends. Space backbone waypoints further apart. + +--- + +## Enclosures + +### My cladding doesn't follow the taper profile — it's rectangular + +Use `apply_minkowski_y` which morphologically expands along the Y axis, following +non-rectangular outlines: + +```python +enc = kf.LayerEnclosure(sections=[(L.WGCLAD, 2_000)]) +enc.apply_minkowski_y(c) # call after shapes are drawn +``` + +`apply_minkowski_tiled` (the default) works on the merged bounding region and +can produce rectangular results for tapered shapes. + +--- + +### `LayerEnclosure` with `dsections=` raises a conversion error + +`dsections=` uses µm values and needs a layout context to convert them to DBU. +Pass `kcl=` at construction time: + +```python +enc = kf.LayerEnclosure( + dsections=[(L.WGCLAD, 0, 2.0)], + kcl=kf.kcl, # <-- required for µm→DBU conversion +) +``` + +--- + +## Utilities + +### `fill_tiled` doesn't appear to do anything + +`fill_tiled` modifies the target cell **in-place** and returns `None`. It must be +called while the cell is unlocked, i.e., **inside** the `@kf.cell` function body +(not after the decorator has finalised the cell). + +--- + +### `packing.pack_kcells` ignores my `spacing` value + +`spacing`, `max_width`, and `max_height` are all in **DBU**, not µm. Convert first: + +```python +kf.packing.pack_kcells( + cells, + spacing=kf.kcl.to_dbu(2), # 2 µm → DBU + max_width=kf.kcl.to_dbu(500), +) +``` + +--- + +### `inst.dmove((x, y))` works but `inst.dmove(kf.kdb.DVector(x, y))` raises `TypeError` + +`dmove` unpacks its argument as a 2-tuple internally. Pass a plain Python tuple +or a 2-element sequence, not a `kdb.DVector`: + +```python +inst.dmove((dx, dy)) # correct +inst.dmove(kf.kdb.DVector(dx, dy)) # TypeError +``` + +--- + +## Schematics + +### `create_inst` raises a JSON serialisation error + +Settings passed to `create_inst` must be JSON-serialisable. Do **not** pass +`LayerInfo` objects — use `int`, `float`, or `str` instead: + +```python +# Wrong +sch.create_inst("wg", settings={"layer": L.WG}) + +# Correct +sch.create_inst("wg", settings={"layer": (1, 0)}) +``` + +--- + +## Difftest / regression testing + +### `difftest()` raises `AssertionError` on the first run + +This is expected — on the first run there is no reference GDS file to compare +against. The reference is written by that first run. Re-run your test suite +a second time; it will pass once the reference exists. + +Do **not** call `difftest()` in executable notebook pages — it will always fail +in a clean CI environment. Show it as a code comment instead. + +## See Also + +| Topic | Where | +|-------|-------| +| Common pitfalls with code examples | [How-To: Best Practices](best_practices.py) | +| Common design patterns | [How-To: Patterns](patterns.py) | +| Contributing to kfactory | [How-To: Contributing](contributing.md) | +| DBU vs µm coordinate systems | [Core Concepts: DBU vs µm](../concepts/dbu_vs_um.py) | diff --git a/docs/source/howto/patterns.py b/docs/source/howto/patterns.py new file mode 100644 index 000000000..8c4fc906c --- /dev/null +++ b/docs/source/howto/patterns.py @@ -0,0 +1,388 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Common Design Patterns +# +# This guide presents recurring design patterns that kfactory users apply when +# building real PDKs. Each pattern is a self-contained recipe you can adapt. +# +# **Contents** +# +# 1. [Component composition](#1-component-composition) +# 2. [Port propagation through hierarchy](#2-port-propagation-through-hierarchy) +# 3. [Factory bundle (dataclass)](#3-factory-bundle-dataclass) +# 4. [Cross-section lookup in cached cells](#4-cross-section-lookup-in-cached-cells) +# 5. [Tile / array pattern](#5-tile-array-pattern) +# 6. [VKCell route → KCell materialise](#6-vkcell-route-kcell-materialise) + +# %% +import dataclasses + +import kfactory as kf +from kfactory.factories.euler import bend_euler_factory +from kfactory.factories.straight import straight_dbu_factory + + +# Shared layers for all examples in this notebook +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +pdk = kf.KCLayout("patterns_pdk", infos=LAYER) +L = pdk.infos +# li_* = integer layer indices (for direct shape insertion / port construction) +li_wg = pdk.find_layer(L.WG) +li_wgex = pdk.find_layer(L.WGEX) + +enc = pdk.get_enclosure( + kf.LayerEnclosure( + name="WG_EX", + main_layer=kf.kdb.LayerInfo(1, 0), + sections=[(kf.kdb.LayerInfo(2, 0), 2000)], + ) +) + +# Factory callables accept kdb.LayerInfo objects, not integer indices +straight = straight_dbu_factory(pdk) +bend_euler = bend_euler_factory(pdk) + +# %% [markdown] +# --- +# ## 1 · Component composition +# +# Build complex cells from simpler primitives using the **instance-and-connect** +# pattern. Each primitive is created once (cached by `@kf.cell`) and reused. +# +# The recipe: +# 1. Create the parent cell. +# 2. Instantiate child cells with `<<`. +# 3. Connect the first instance to a fixed position; use `connect()` for the rest. +# 4. Expose the external ports on the parent. + + +# %% +@pdk.cell +def l_arm( + width: float = 0.5, + length1: float = 10.0, + length2: float = 5.0, + radius: float = 5.0, +) -> kf.KCell: + """L-shaped arm: straight → 90° euler bend → straight.""" + c = kf.KCell(kcl=pdk) + + w_dbu = pdk.to_dbu(width) + l1_dbu = pdk.to_dbu(length1) + l2_dbu = pdk.to_dbu(length2) + + # Primitives (cached — same params reuse the same cell object) + s1 = straight(width=w_dbu, length=l1_dbu, layer=L.WG) + b = bend_euler(width=width, radius=radius, layer=L.WG) + s2 = straight(width=w_dbu, length=l2_dbu, layer=L.WG) + + # Instantiate and chain + i1 = c << s1 + i_b = c << b + i2 = c << s2 + + i_b.connect("o1", i1.ports["o2"]) + i2.connect("o1", i_b.ports["o2"]) + + # Expose external ports + c.add_port(port=i1.ports["o1"], name="o1") + c.add_port(port=i2.ports["o2"], name="o2") + return c + + +arm = l_arm() +print(f"ports: {[p.name for p in arm.ports]}") +arm.plot() + +# %% [markdown] +# --- +# ## 2 · Port propagation through hierarchy +# +# When a cell wraps several children you need to surface the right ports to the +# caller. Two sub-patterns cover most cases: +# +# **a) Direct exposure** — forward the child port unchanged: +# +# ```python +# c.add_port(port=inst.ports["o1"], name="in") +# ``` +# +# **b) Bulk exposure with auto-renaming** — expose all ports with a prefix: +# +# ```python +# c.add_ports(inst.ports, prefix="left_") +# ``` +# +# The example below builds a Mach-Zehnder skeleton and exposes four ports +# (`in`, `out`, `arm_top_in`, `arm_top_out`). + + +# %% +@pdk.cell +def mz_skeleton( + width: float = 0.5, + arm_length: float = 20.0, + radius: float = 5.0, +) -> kf.KCell: + """Mach-Zehnder skeleton with exposed arm ports.""" + c = kf.KCell(kcl=pdk) + + w_dbu = pdk.to_dbu(width) + al_dbu = pdk.to_dbu(arm_length) + + s_in = straight(width=w_dbu, length=pdk.to_dbu(5.0), layer=L.WG) + s_arm = straight(width=w_dbu, length=al_dbu, layer=L.WG) + + # Bottom arm + i_in = c << s_in + i_arm = c << s_arm + i_arm.connect("o1", i_in.ports["o2"]) + i_out = c << s_in + i_out.connect("o1", i_arm.ports["o2"]) + + # Top arm (offset upward by 2 × radius µm) + i_arm_top = c << s_arm + i_arm_top.dmove((0.0, radius * 2)) # pure µm offset + + # a) Direct port exposure with rename + c.add_port(port=i_in.ports["o1"], name="in") + c.add_port(port=i_out.ports["o2"], name="out") + + # b) Individual port exposure for arm access points + c.add_port(port=i_arm_top.ports["o1"], name="arm_top_in") + c.add_port(port=i_arm_top.ports["o2"], name="arm_top_out") + + return c + + +mz = mz_skeleton() +print(f"ports: {[p.name for p in mz.ports]}") + +# %% [markdown] +# --- +# ## 3 · Factory bundle (dataclass) +# +# As a PDK grows it becomes hard to pass individual factory functions around. +# The **factory bundle** pattern collects all factories into a frozen `dataclass` +# that can be imported as a single object. +# +# `KCLayout` does not provide built-in slots for factories, so use a dataclass at +# module level instead. + +# %% +from kfactory.factories.euler import BendEulerFactory +from kfactory.factories.straight import StraightFactory + + +@dataclasses.dataclass(frozen=True) +class PDKFactories: + straight: StraightFactory + bend_euler: BendEulerFactory + + +# Build once at module level, import everywhere. +factories = PDKFactories( + straight=straight_dbu_factory(pdk), + bend_euler=bend_euler_factory(pdk), +) + + +# Usage — cells receive the bundle, not the individual factories: +@pdk.cell +def my_component(width: float = 0.5, length: float = 10.0) -> kf.KCell: + c = kf.KCell(kcl=pdk) + w_dbu = pdk.to_dbu(width) + s = factories.straight(width=w_dbu, length=pdk.to_dbu(length), layer=L.WG) + inst = c << s + c.add_port(port=inst.ports["o1"], name="o1") + c.add_port(port=inst.ports["o2"], name="o2") + return c + + +comp = my_component() +print(f"cell: {comp.name}") +print(f"factory bundle type: {type(factories)}") + +# %% [markdown] +# --- +# ## 4 · Cross-section lookup in cached cells +# +# `@pdk.cell` (and `@kf.cell`) require **hashable** arguments. A +# `CrossSection` object is not hashable, so you cannot pass one directly as a +# parameter. +# +# The standard pattern is: +# 1. Register cross-sections on the PDK once at module level. +# 2. Accept a **name string** as the cell parameter. +# 3. Look up the actual `CrossSection` inside the cell body. + +# %% +from kfactory.cross_section import SymmetricalCrossSection + +# Register once +xs_wg = SymmetricalCrossSection( + width=pdk.to_dbu(0.5), + enclosure=enc, + name="WG", +) +pdk.get_icross_section(xs_wg) # registers and returns it (idempotent) + +xs_wide = SymmetricalCrossSection( + width=pdk.to_dbu(1.0), + enclosure=enc, + name="WG_WIDE", +) +pdk.get_icross_section(xs_wide) + + +@pdk.cell +def wg_with_xs(xs_name: str = "WG", length: float = 10.0) -> kf.KCell: + """Straight waveguide parameterised by cross-section name.""" + xs = pdk.get_icross_section(xs_name) # ← lookup inside the cell body + c = kf.KCell(kcl=pdk) + l_dbu = pdk.to_dbu(length) + c.shapes(li_wg).insert(kf.kdb.Box(l_dbu, xs.width)) + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, -l_dbu // 2, 0), + width=xs.width, + layer=li_wg, + port_type="optical", + kcl=pdk, + ) + ) + c.add_port( + port=kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, l_dbu // 2, 0), + width=xs.width, + layer=li_wg, + port_type="optical", + kcl=pdk, + ) + ) + return c + + +wg_std = wg_with_xs(xs_name="WG") +wg_wide = wg_with_xs(xs_name="WG_WIDE") +print(f"WG width (DBU): {pdk.get_icross_section('WG').width}") +print(f"WG_WIDE width (DBU): {pdk.get_icross_section('WG_WIDE').width}") + +# %% [markdown] +# --- +# ## 5 · Tile / array pattern +# +# Use `kf.grid_dbu` (for `KCell`) or `kf.grid` (for `DKCell`) to arrange a +# collection of cells in a regular grid. This is useful for test structures, +# component galleries, and fill tiles. +# +# Key points: +# +# | Function | Input cells | Coordinates | +# |---|---|---| +# | `kf.grid_dbu` | `KCell` (DBU) | DBU integers | +# | `kf.grid` | `DKCell` (µm) | float µm | +# +# The `shape=(rows, cols)` parameter sets the grid dimensions. + +# %% +# Build a small set of components to tile +components = [wg_with_xs(xs_name="WG", length=float(5 + i)) for i in range(4)] + +# grid_dbu places cells into a target KCell and returns an InstanceGroup +target = kf.KCell("wg_array", kcl=pdk) +kf.grid_dbu( + target, + kcells=components, + spacing=pdk.to_dbu(5.0), + shape=(2, 2), + align_x="xmin", + align_y="ymin", +) +print(f"grid cell name: {target.name}") +print(f"grid bbox (µm): {target.dbbox()}") +target.plot() + +# %% [markdown] +# --- +# ## 6 · VKCell route → KCell materialise +# +# All-angle routing produces a **virtual** route (a `VKCell`). The pattern to +# turn it into a permanent `KCell` is: +# +# 1. Create the target cell. +# 2. Build a `VKCell` with virtual straight / bend factories. +# 3. Call `kf.routing.aa.optical.route()` to fill the `VKCell`. +# 4. Insert the virtual instance into the target cell with `VInstance.insert_into()`. +# +# This keeps the route geometry isolated until you explicitly materialise it. + +# %% +from functools import partial + +import kfactory.routing.aa.optical as aa +from kfactory.factories.virtual.euler import virtual_bend_euler_factory +from kfactory.factories.virtual.straight import virtual_straight_factory + +# Virtual factories are pre-configured with partial() — bind layer/width/radius +_v_straight_raw = virtual_straight_factory(kcl=pdk) +_v_bend_raw = virtual_bend_euler_factory(kcl=pdk) + +v_straight = partial(_v_straight_raw, layer=L.WG) +v_bend = partial(_v_bend_raw, width=0.5, radius=10.0, layer=L.WG) + +# Step 1 — route into a VKCell (virtual; geometry stays in-memory only) +# Create the VKCell via the PDK's KCLayout so layer indices match +vc = pdk.vkcell(name="aa_vkcell_demo") +aa.route( + vc, + width=0.5, + backbone=[ + kf.kdb.DPoint(0, 0), + kf.kdb.DPoint(80, 0), # horizontal + kf.kdb.DPoint(80, 60), # 90° corner upward + kf.kdb.DPoint(160, 60), # horizontal to exit + ], + straight_factory=v_straight, + bend_factory=v_bend, +) + +# Step 2 — materialise into a real KCell +c_routed = kf.KCell("aa_vkcell_pattern", kcl=pdk) +vi = kf.VInstance(vc) +vi.insert_into(c_routed) + +print(f"cell: {c_routed.name}") +print(f"bbox (µm): {c_routed.dbbox()}") +c_routed.plot() + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Common pitfalls to avoid | [How-To: Best Practices](best_practices.py) | +# | Frequently asked questions | [How-To: FAQ](faq.md) | +# | PCells & caching | [Components: PCells](../components/cells/pcells.py) | +# | Routing overview | [Routing: Overview](../routing/overview.py) | diff --git a/docs/source/index.md b/docs/source/index.md index 612c7a5e0..957a16dd9 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1 +1,105 @@ --8<-- "README.md" + +--- + +
+ +- :material-rocket-launch:{ .lg .middle } **Getting Started** + + --- + + Install kfactory and build your first component in under 5 minutes. + + [:octicons-arrow-right-24: Installation](getting_started/installation.md) +  ·  + [:octicons-arrow-right-24: Quickstart](getting_started/quickstart.py) + +- :material-book-open-variant:{ .lg .middle } **Core Concepts** + + --- + + Understand KCells, layers, ports, instances, and the DBU ↔ µm coordinate systems. + + [:octicons-arrow-right-24: KCell](concepts/kcell.py) +  ·  + [:octicons-arrow-right-24: Layers](concepts/layers.py) +  ·  + [:octicons-arrow-right-24: Ports](concepts/ports.py) + +- :material-transit-connection-variant:{ .lg .middle } **Routing** + + --- + + Optical and electrical bundle routing, Manhattan primitives, all-angle, and path-length matching. + + [:octicons-arrow-right-24: Overview](routing/overview.py) +  ·  + [:octicons-arrow-right-24: Optical](routing/optical.py) +  ·  + [:octicons-arrow-right-24: All-Angle](routing/all_angle.py) + +- :material-shape:{ .lg .middle } **Components** + + --- + + Straight waveguides, euler/circular bends, tapers, Bezier S-bends, and the factory pattern. + + [:octicons-arrow-right-24: Overview](components/cells/overview.py) +  ·  + [:octicons-arrow-right-24: PCells](components/cells/pcells.py) +  ·  + [:octicons-arrow-right-24: Factories](components/cells/factories/overview.py) + +- :material-layers:{ .lg .middle } **Enclosures** + + --- + + Layer enclosures via Minkowski sums, cross-sections, and KCell-level cladding. + + [:octicons-arrow-right-24: Cross-Sections](components/cross_sections.py) +  ·  + [:octicons-arrow-right-24: Layer Enclosure](enclosures/layer_enclosure.py) + +- :material-tools:{ .lg .middle } **Utilities** + + --- + + Grid layout, packing, DRC fixing, fill, and regression testing. + + [:octicons-arrow-right-24: Grid](utilities/grid.py) +  ·  + [:octicons-arrow-right-24: DRC Fix](utilities/drc_fix.py) +  ·  + [:octicons-arrow-right-24: Fill](utilities/fill.py) + +- :material-package-variant:{ .lg .middle } **PDK** + + --- + + Bundle layers, factories, cross-sections, and technology into a reusable PDK. + + [:octicons-arrow-right-24: Creating a PDK](pdk/creating_pdk.py) +  ·  + [:octicons-arrow-right-24: Layer Stack](pdk/technology.py) + +- :material-sitemap:{ .lg .middle } **Schematics** + + --- + + Schematic-driven layout, netlist extraction, LVS, and YAML/JSON round-trips. + + [:octicons-arrow-right-24: Overview](schematics/overview.py) +  ·  + [:octicons-arrow-right-24: Netlist & I/O](schematics/netlist.py) + +- :material-lightbulb-on:{ .lg .middle } **How-To Guides** + + --- + + Common patterns, best practices, and a comprehensive FAQ. + + [:octicons-arrow-right-24: Best Practices](howto/best_practices.py) +  ·  + [:octicons-arrow-right-24: FAQ](howto/faq.md) + +
diff --git a/docs/source/intro.md b/docs/source/intro.md deleted file mode 100644 index 28d944a92..000000000 --- a/docs/source/intro.md +++ /dev/null @@ -1,134 +0,0 @@ -# Getting Started - -As an example we will build a small waveguide and incorporate a circular bend waveguide and connect them. - -First let us create some layers. We will use the standard library for this. -Create a `layers.py`. The full one can be downloaded here: [`layers.py`](./layers.py). - -Additionally we will create a `kfactory.enclosure.Enclosure`. Enclosures allow to automatically generate claddings with [minkowski sums](https://en.wikipedia.org/wiki/Minkowski_addition) or use a function to apply claddings to a cell or region. This enclosure will add the cladding to the bend we will use later. - -```python -import kfactory as kf - - -class LAYER(kf.LayerEnum): - SI = (1, 0) - SIEXCLUDE = (1, 1) - - -si_enc = kf.utils.LayerEnclosure([(LAYER.SIEXCLUDE, 2000)]) -``` - -This will use the standard Library of KFactory. -A library is the equivalent of a layout object in KLayout and keeps track of the KCells. -It mirrors all the other functionalities of a layout object. - -Ports are created with the `kfactory.kcell.KCell.create_port` function. You can either specify a transformation here or specify them in a similar manner to gdsfactory. See the API doc for more information. - -Now, let us create a KCell for a waveguide. We will use the `kfactory.kcell.autocell`. -This will make sure that if we call the function multiple times that we do not create multiple cells in the layout. -Addiontally, compared to `kfactory.kcell.cell` it will also automatically name the cells using -the function name,as well as the arguments and keyword arguments of the function. -In the end we will let KFactory take care of the naming of the ports we want to add. -This will allow them to depend on the orientation of the port. This will sort the ports by orientation -(0,1,2,3 -> E,N,W,S) and by ascending x (N/S orientation) respectively y (E/W orientation) coordinates. - -First we draw two rectangles centered vertically around the x-axis: -Waveguide Core: A rectangle of the specified length and width is drawn on the LAYER.SI (Silicon) layer. This forms the physical path for light. -Exclusion Zone: A second, slightly wider rectangle (width_exclude) is drawn on the LAYER.SIEXCLUDE layer. This acts as a "keep-out" area, telling the design software or foundry not to place other silicon structures too close to the waveguide, which helps prevent unwanted optical effects. -After that, we define the ports 1 and 2. The first port is rotated by 180 degrees through: -name="1", trans=kf.kdb.Trans(2, False, 0, 0), width=width, layer=LAYER.SI , this makes port 1 face left. -The second port is not rotated and thus faces right. -Lastly, the code cleans up and adds convenience: -c.auto_rename_ports(): This is a convenience function that renames the ports from the temporary names "1" and "2" to a standard convention, like "o1" and "o2" (optical 1 and optical 2), based on their position. -return c: The function returns the completed KCell object, which contains the shapes and ports, ready to be used in a larger design. - -```python -from layers import LAYER - -import kfactory as kf - - -@kf.cell -def straight(width: int, length: int, width_exclude: int) -> kf.KCell: - """Waveguide: Silicon on 1/0, Silicon exclude on 1/1""" - c = kf.KCell() - c.shapes(LAYER.SI).insert(kf.kdb.Box(0, -width // 2, length, width // 2)) - c.shapes(LAYER.SIEXCLUDE).insert( - kf.kdb.Box(0, -width_exclude // 2, length, width_exclude // 2) - ) - - c.create_port( - name="1", trans=kf.kdb.Trans(2, False, 0, 0), width=width, layer=LAYER.SI - ) - c.create_port( - name="2", - trans=kf.kdb.Trans(0, False, length, 0), - width=width, - layer=LAYER.SI, - ) - - c.auto_rename_ports() - - return c - - -if __name__ == "__main__": - kf.show(straight(2000, 50000, 5000)) -``` - -The ``kf.show`` will create a GDS in the temp folder and then send the GDS by klive to KLayout (if klive is installed). -By running this with ``python straight.py``, it should show us a straight like this: - -![straight](./_static/waveguide.png) - -Afterwards let us create the composite cell [`complex_cell.py`](./complex_cell.py). This one incorporates a waveguide and a circular bend and then connects them. - -First, a straight function is defined. Then similarly to the above code, ports are added and renamed. -The second portion of this code only runs when executed directly: -It calls the straight function to build a concrete instance of the waveguide with the following dimensions (in database units, where 1000 dbu = 1 µm): -width: 2000 dbu (2.0 µm) -length: 50000 dbu (50.0 µm) -width_exclude: 5000 dbu (5.0 µm) -Display the Result: The kf.show command takes the component that was just built and opens it in the KLayout application viewer. This allows you to visually inspect the final geometry of the waveguide and its exclusion layer. - -```python -from layers import LAYER - -import kfactory as kf - - -@kf.cell -def straight(width: int, length: int, width_exclude: int) -> kf.KCell: - """Waveguide: Silicon on 1/0, Silicon exclude on 1/1""" - c = kf.KCell() - c.shapes(LAYER.SI).insert(kf.kdb.Box(0, -width // 2, length, width // 2)) - c.shapes(LAYER.SIEXCLUDE).insert( - kf.kdb.Box(0, -width_exclude // 2, length, width_exclude // 2) - ) - - c.create_port( - name="1", trans=kf.kdb.Trans(2, False, 0, 0), width=width, layer=LAYER.SI - ) - c.create_port( - name="2", - trans=kf.kdb.Trans(0, False, length, 0), - width=width, - layer=LAYER.SI, - ) - - c.auto_rename_ports() - - return c - - -if __name__ == "__main__": - kf.show(straight(2000, 50000, 5000)) -``` - -With `kfactory.kcell.KCell.add_port` an existing port of an instance can be added to the parent cell. -`kfactory.kcell.Instance.connect` allows an instance to be transformed so that one of its ports is connected to another port. - -You will get a cell like this: - -![complex_cell](_static/complex.png) diff --git a/docs/source/layers.py b/docs/source/layers.py deleted file mode 100644 index 55a9a8fed..000000000 --- a/docs/source/layers.py +++ /dev/null @@ -1,39 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: -all -# text_representation: -# extension: .py -# format_name: light -# format_version: '1.5' -# jupytext_version: 1.14.5 -# kernelspec: -# display_name: kernel_name -# language: python -# name: kernel_name -# --- - -# This code sets up custom layer definitions and an enclosure rule for a kfactory chip layout - -# SI: This is an alias for GDSII Layer 1, Datatype 0. This layer will be used for the main silicon waveguide structures. -# SIEXCLUDE: This is an alias for GDSII Layer 1, Datatype 1. -# This layer will be used to define "keep-out" zones where other silicon should not be placed. -# kf.kcl.infos = LayerInfos(): This registers the custom LayerInfos class as the globally active set of layers for the kfactory library. -# LAYER = LayerInfos(): This creates a convenient, uppercase variable LAYER that you can use in your code to access the layers -# si_enc = kf.enclosure.LayerEnclosure([(kf.kcl.infos.SIEXCLUDE, 2000)]): -# This line creates a reusable rule for drawing shapes around other shapes. An enclosure is a boundary or buffer zone on a different layer. -# Specifically, this rule (si_enc) says: -# When applied to a shape, automatically draw a new shape on the SIEXCLUDE layer that is 2000 dbu (2.0 µm) larger on all sides than the original shape. - - -import kfactory as kf - - -class LayerInfos(kf.LayerInfos): - SI: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) - SIEXCLUDE: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 1) - - -kf.kcl.infos = LayerInfos() -LAYER = LayerInfos() -si_enc = kf.enclosure.LayerEnclosure([(kf.kcl.infos.SIEXCLUDE, 2000)]) diff --git a/docs/source/migration.md b/docs/source/migration.md index b70431cf8..91e7b78ed 100644 --- a/docs/source/migration.md +++ b/docs/source/migration.md @@ -1,15 +1,181 @@ # Migration -## v0.13 +## kfactory 3.0 -### `KCLayout.cell` +### What's New in 3.0 -Beginning with `kfactory>=0.12`, the [`@cell`][kfactory.layout.KCLayout.cell] decorator is now part of a KCLayout. -This has a minor impact on the [`KCLayout`][kfactory.layout.KCLayout] as the function `kdb.Layout.cell` gets shadowed as a consequence. Please use `KCLayout.layout.cell` to access the function. +#### New Features +- **Routing constraints & path-length matching** — the new `PathLengthMatch` constraint API enables length-matched bundle routing directly. + See [Path-Length Matching](routing/path_length.md). +- **Asymmetrical cross-sections** — `AsymmetricCrossSection` allows non-symmetric waveguide profiles. + See [Cross-Sections](components/cross_sections.md). +- **Netlist extraction as a separate package** — netlist generation now lives in [kfnetlist](https://github.com/gdsfactory/kfnetlist), a standalone package. + See [Netlist & I/O](schematics/netlist.md). -# Deprecation +#### Improved Features -## v 0.11.2 +- **Schematics** — tighter pin integration, virtual schematic connections, and the `@kcl.routing_strategy` registry for schematic-driven routing. + See [Schematics Overview](schematics/overview.md). +- **Bundle routing** — expanded documentation and tutorial. + See [Bundle Routing Tutorial](routing/bundle.md). -- `@kf.cell` is depracted due to the integration into `kf.kcl`, use `@kf.kcl.cell` instead +#### Infrastructure + +- **Python 3.12+** is now required (up from 3.11). +- Complete **documentation overhaul** — new zensical-based build, restructured navigation, and auto-generated API reference. + +--- + +### Migrating from 2.5.x + +The sections below cover breaking changes and how to update your code. + +#### Change Summary + +| Area | What changed | Before (2.x) | After (3.0) | +|---|---|---|---| +| **Module** | `virtual.utils` moved | `kfactory.factories.virtual.utils` | `kfactory.factories.utils` | +| **Routing** | `route_L` / `route_elec` removed | `route_elec(cell, p1, p2)` | `route_bundle(cell, [p1], [p2])` | +| **Routing** | `routing.optical.route` removed | `route(cell, p1, p2, ...)` | `route_bundle(cell, [p1], [p2], ...)` | +| **Routing** | `place90` deprecated | `place90(cell, p1, p2, pts)` | `place_manhattan(cell, p1, p2, pts)` | +| **Parameters** | `start_straights` deprecated | `start_straights=100` | `starts=100` | +| **Parameters** | `end_straights` deprecated | `end_straights=100` | `ends=100` | +| **Schematics** | Routing via `KCLayout` registry | — | `@kcl.routing_strategy` + `schematic.add_route(...)` | + +#### Module Reorganization + +- `kfactory.factories.virtual.utils` has been moved to `kfactory.factories.utils` to unify it with other factory utilities. + +```python +# Before (2.x) +from kfactory.factories.virtual.utils import extrude_backbone, extrude_backbone_dynamic + +# After (3.0) +from kfactory.factories.utils import extrude_backbone, extrude_backbone_dynamic +``` + +#### Removed Routing Functions + +The following routing functions have been removed. Use `route_bundle` instead. + +- `routing.electrical.route_L` — removed +- `routing.electrical.route_elec` — removed +- `routing.optical.route` — removed + +```python +# Before (2.x) — electrical +from kfactory.routing.electrical import route_L, route_elec +route_elec(cell, port1, port2, ...) + +# After (3.0) — electrical +from kfactory.routing.electrical import route_bundle +route_bundle(cell, start_ports=[port1], end_ports=[port2], separation=..., ...) + +# Before (2.x) — optical +from kfactory.routing.optical import route +route(cell, port1, port2, straight_factory=..., bend90_cell=..., ...) + +# After (3.0) — optical +from kfactory.routing.optical import route_bundle +route_bundle( + cell, + start_ports=[port1], + end_ports=[port2], + separation=..., + straight_factory=..., + bend90_cell=..., +) +``` + +#### Deprecated Parameters in `route_bundle` + +In both `routing.optical.route_bundle` and `routing.electrical.route_bundle`: + +- `start_straights` is deprecated — use `starts` instead +- `end_straights` is deprecated — use `ends` instead + +```python +# Before (2.x) +route_bundle(cell, start_ports, end_ports, separation=..., start_straights=100, end_straights=100, ...) + +# After (3.0) +route_bundle(cell, start_ports, end_ports, separation=..., starts=100, ends=100, ...) +``` + +#### Deprecated `place90` + +`routing.optical.place90` is deprecated. Use `place_manhattan` instead. + +```python +# Before (2.x) +from kfactory.routing.optical import place90 +place90(cell, p1, p2, pts, ...) + +# After (3.0) +from kfactory.routing.optical import place_manhattan +place_manhattan(cell, p1, p2, pts, ...) +``` + +#### Routing Interface for Schematics + +In order to enable routing in schematics, routing strategy functions must be registered +on the `KCLayout` instance. Registered strategies can then be referenced by name in +`Schematic.add_route`. + +##### Registering a Routing Strategy + +Use the `@kcl.routing_strategy` decorator or assign directly to `kcl.routing_strategies`: + +```python +import kfactory as kf + +kcl = kf.KCLayout("MY_PDK") + +@kcl.routing_strategy +def route_bundle(cell, start_ports, end_ports, **kwargs): + ... + +# Or register directly: +kcl.routing_strategies["my_custom_route"] = my_routing_function +``` + +##### Using Routes in a Schematic + +The `Schematic.add_route` method creates a route bundle connecting start and end ports +using a named routing strategy: + +```python +schematic = kf.Schematic(kcl=kcl) + +# Add instances +mmi1 = schematic.add_instance("mmi1", mmi_factory, settings={...}) +mmi2 = schematic.add_instance("mmi2", mmi_factory, settings={...}) + +# Place instances +mmi1.place(...) +mmi2.place(...) + +# Route between instances +schematic.add_route( + name="optical_route", + start_ports=[mmi1.ports["o2"]], + end_ports=[mmi2.ports["o1"]], + routing_strategy="route_bundle", + separation=10_000, + # additional **settings are forwarded to the routing strategy function +) + +# Build the cell +cell = schematic.create_cell(output_type=kf.KCell) +``` + +When `create_cell` is called, the schematic looks up the routing strategy by name from +`kcl.routing_strategies` (or from the `routing_strategies` argument to `create_cell`) +and calls it with the resolved ports and settings. + +--- + +## kfactory 2.0 + +kfactory 2.0 was a full rewrite of the library on top of [KLayout](https://klayout.de), replacing the earlier 1.x codebase. There is no supported migration path from 1.x to 2.0. diff --git a/docs/source/notebooks/00_geometry.py b/docs/source/notebooks/00_geometry.py deleted file mode 100644 index fc0c49af8..000000000 --- a/docs/source/notebooks/00_geometry.py +++ /dev/null @@ -1,484 +0,0 @@ -# --- -# jupyter: -# jupytext: -# custom_cell_magics: kql -# formats: ipynb,py:percent -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.16.2 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# %% [markdown] -# # KCell -# -# A `KCell` is like an empty canvas, where you can add polygons and instances to other Cells and ports (which is used to connect to other cells) -# -# In KLayout geometries are in datatabase units (dbu) or microns. GDS uses an integer grid as a basis for geometries. -# The default is `0.001`, i.e. 1nm grid size (0.001 microns) -# -# - `Point`, `Box`, `Polygon`, `Edge`, `Region` are in dbu -# - `DPoint`, `DBox`, `DPolygon`, `DEdge` are in microns -# -# Most Shape types are available as microns and dbu parts. They can be converted with `.to_dtype(dbu)` to microns -# and with `.to_itype(dbu)` where `dbu` is the the conversion of one database unit to microns. -# Alternatively they can be converted with `c.kcl.to_um()` or `c.kcl.to_dbu()` -# where `c.kcl` is the KCell and `kcl` is the `KCLayout` which owns the KCell. - -# Imports: It imports the necessary libraries: kfactory for layout creation and numpy for numerical operations -# LayerInfos Class: In chip fabrication, the design is built up layer by layer. -# Each layer corresponds to a specific material or process step (e.g., silicon, metal, oxide). -# This class creates human-readable names (WG for waveguide, CLAD for cladding) -# and maps them to the GDS layer numbers ((1, 0), (4, 0)) - -# %% -import kfactory as kf -import numpy as np - - -# %% -# Define Layers - -class LayerInfos(kf.LayerInfos): - WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1,0) - WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2,0) # WG Exclude - CLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4,0) # cladding - FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10,0) - -# Make the layout object aware of the new layers: -LAYER = LayerInfos() -kf.kcl.infos = LAYER - -# %% -# Create a blank cell (essentially an empty GDS cell with some special features) -c = kf.KCell() - -# %% -# Create and add a polygon from separate lists of x points and y points -# (Can also be added like [(x1,y1), (x2,y2), (x3,y3), ... ] -poly1 = kf.kdb.DPolygon( - [ - kf.kdb.DPoint(-8, -6), - kf.kdb.DPoint(6, 8), - kf.kdb.DPoint(7, 17), - kf.kdb.DPoint(9, 5), - ] -) - - -# %% -c.shapes(c.kcl.find_layer(1, 0)).insert(poly1) - -# %% -c.show() # show in KLayout -c.plot() - -# %% [markdown] -# **Exercise** : -# -# Make a cell similar to the one above that has a second polygon in layer (2, 0) -# -# **Solution** : - -# %% -c = kf.KCell() -points = np.array([(-8, -6), (6, 8), (7, 17), (9, 5)]) -poly = kf.polygon_from_array(points) -c.shapes(c.kcl.find_layer(1, 0)).insert(poly1) -c.shapes(c.kcl.find_layer(2, 0)).insert(poly1) -c - -# kf.kdb.TextGenerator: This creates a text object. The text "Hello!" is converted into a set of polygons that can be fabricated. -# kf.kdb.DBox(...): This creates a simple rectangle (a box). -# This box is defined by the coordinates of its lower-left corner (-2.5, -5) and upper-right corner (2.5, 5). -# Like before, these shapes are then inserted into specific layers in the cell c. -# %% -c = kf.KCell() -textgenerator = kf.kdb.TextGenerator.default_generator() -t = textgenerator.text("Hello!", c.kcl.dbu) -c.shapes(kf.kcl.find_layer(1, 0)).insert(t) -c.show() # show in KLayout -c.plot() - -# %% -r = kf.kdb.DBox(-2.5, -5, 2.5, 5) -r - -# %% [markdown] -# Add instances to the new geometry to c, our blank cell - -# %% -c = kf.KCell() -c.shapes(c.kcl.find_layer(1, 0)).insert(r) -c.shapes(c.kcl.find_layer(2, 0)).insert(r) -c - -# %% -c = kf.KCell() -textgenerator = kf.kdb.TextGenerator.default_generator() -text1 = t.transformed( - kf.kdb.DTrans(0.0, 10.0).to_itype(c.kcl.dbu) -) # DTrans is a transformation in microns with arguments (, , , ) -# Now that the geometry has been added to "c", we can move everything around: - - -# %% -### complex transformation example:ce -# magnification(float): magnification, DO NOT USE on cells or instances, only on shapes. Most foundries will not allow magnifications on actual cell instances or cells. -# rotation(float): rotation in degrees -# mirror(bool): boolean to mirror at x axis and then rotate if true -# x(float): x coordinate -# y(float): y coordinate -# -text2 = t.transformed( - kf.kdb.DCplxTrans(2.0, 45.0, False, 5.0, 30.0).to_itrans(c.kcl.dbu) -) -# text1.movey(25) -# text2.move([5, 30]) -# text2.rotate(45) -r.move( - -5, 0 -) # boxes can be moved like this, other shapes and cells/refs need to be moved with .transform -r.move(-5, 0) - -# %% -c.shapes(c.kcl.find_layer(1, 0)).insert(text1) -c.shapes(c.kcl.find_layer(2, 0)).insert(text2) -c.shapes(c.kcl.find_layer(2, 0)).insert(r) - -# %% -c.show() # show in KLayout -c.plot() - -# %% [markdown] -# This portion essentially demonstrates how to draw a shape and add connections to it. - -# It defines a function named straight, which accepts the following three arguments: -# Length: The length of a waveguide in microns, the default is 10 µm. -# Width: The width of the waveguide in microns, the default is 1 µm. -# Layer: A tuple (data structure with multiple parts to it), which specifies the GDSII (Graphic Design System II) layer it will draw on. -# layer=(1, 0): This tuple holds the blueprint information. -# *layer: The asterisk * is Python's "unpacking" operator. It turns the tuple (1, 0) into two separate arguments, so kf.kcl.find_layer(*layer) is the same as calling kf.kcl.find_layer(1, 0). -# kf.kcl.find_layer: This function takes the layer and purpose numbers, in this case 1 and 0 and finds the corresponding internal layer index. -# This will then allow the KLayout software uses to manage the data efficiently. -# wg.shapes(_layer).insert(box): This is the "drawing" step. -# It instructs the software to take the rectangular box shape and place it specifically on the blueprint for Layer 1, Purpose 0. -# Without this, the shape would exist only in memory but not be part of the final chip design. -# wg = kf.KCell(): Creates a new, empty cell named wg (short for waveguide). -# box = kf.kdb.DBox(length, width): Creates a rectangular shape object using floating-point coordinates in microns. -# int_box = wg.kcl.to_dbu(box): Chip layout databases use integers for high precision, called database units (dbu). -# This line converts the box's micron coordinates into these integer units. -# Then the function adds connecting ports named "o1" and "o2". -# Finally, the function will return the completed wg cell, which now contains the rectangular shape. - -# %% -@kf.cell -def straight(length=10, width=1, layer=(1, 0)) -> kf.KCell: - wg = kf.KCell() - box = kf.kdb.DBox(length, width) - int_box = wg.kcl.to_dbu(box) - _layer = kf.kcl.find_layer(*layer) - wg.shapes(_layer).insert(box) - wg.add_port( - port=kf.Port( - name="o1", - width=wg.kcl.to_dbu(width), - dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, box.left, box.center().y), - layer=_layer, - ) - ) - wg.create_port( - name="o2", - trans=kf.kdb.Trans(int_box.right, int_box.center().y), - layer=_layer, - width=int_box.height(), - ) - return wg - -# The following code creates and places three separate straight waveguides, each with a different length, into a designated cell called c. - -# %% -c = kf.KCell() -wg1 = c << straight(length=1, width=2.5, layer=(1, 0)) -wg2 = c << straight(length=2, width=2.5, layer=(1, 0)) -wg3 = c << straight(length=3, width=2.5, layer=(1, 0)) -c - - -# %% [markdown] -# Each straight has two ports: 'o1' and 'o2'. These are arbitrary names defined in our straight() function above - -# %% -# Let us keep wg1 in place on the bottom and then connect the other straights to it. -# To do that, on wg2 we will grab the "W0" port and connect it to the "E0" on wg1: -wg2.connect("o1", wg1.ports["o2"]) -# Next, on wg3, take the "W0" port and connect it to the "E0" on wg2: -wg3.connect("o1", wg2.ports["o2"]) -c - -# %% -c.add_port(name="o1", port=wg1.ports["o1"]) -c.add_port(name="o2", port=wg3.ports["o2"]) -c.show() # show in KLayout -c.plot() - -# %% [markdown] -# As you can see the `red` labels are for the cell ports while -# `blue` labels are for the sub-ports (children ports) - -# %% [markdown] -# ## Move and rotate instances -# -# You can move, rotate, and reflect instances to Cells. - - -# %% -c = kf.KCell() - - -# %% -# Create and add a polygon from separate lists of x points and y points -# e.g. [(x1, x2, x3, ...), (y1, y2, y3, ...)] -c.shapes(c.kcl.find_layer(4, 0)).insert( - kf.kdb.DPolygon([kf.kdb.DPoint(x, y) for x, y in zip((8, 6, 7, 9), (6, 8, 9, 5))]) -) - -# %% -# Alternatively, create and add a polygon from a list of points -# e.g. [(x1,y1), (x2,y2), (x3,y3), ...] using the same function -c.shapes(c.kcl.find_layer(4, 0)).insert( - kf.kdb.DPolygon( - [kf.kdb.DPoint(x, y) for (x, y) in ((0, 0), (1, 1), (1, 3), (-3, 3))] - ) -) - - -# %% -c.show() # show in KLayout -c.plot() - -# %% [markdown] -# ## Ports -# -# Your straights wg1/wg2/wg3 are instances to other straight cells. -# -# If you want to add ports to the new cell `c` you can use `add_port`, where you can create a new port or use an instance an existing port from the underlying instance. - -# %% [markdown] -# You can access the ports of a cell or instance - - -# %% -wg2.ports - -# Here we create a new design cell (MultiMultiWaveguide) and place to identical straight waveguides into it. -# We then place the physical copies into the c2 canvas via mwg1_ref = c2.create_inst(wg1) and mwg2_ref = c2.create_inst(wg2) -# After that, we move wg2 by 10 microns in the x and y directions with mwg2_ref.transform(kf.kdb.DTrans(10, 10)) -# In an interactive environment like a Jupyter Notebook, the last line c2 would then show you two waveguides. One at (0, 0) and one at (10, 10) - -# %% -c2 = kf.KCell(name="MultiMultiWaveguide") -wg1 = straight(layer=(2, 0)) -wg2 = straight(layer=(2, 0)) -mwg1_ref = c2.create_inst(wg1) -mwg2_ref = c2.create_inst(wg2) -mwg2_ref.transform(kf.kdb.DTrans(10, 10)) -c2 - -# %% -# Like before, now we connect mwg1 and mwg2 together -mwg1_ref.connect("o2", mwg2_ref.ports["o1"]) - -# %% -c2 - -# This block creates and mirrors 2 Euler bend components horizontally, this creates a U turn. An Euler bend is a curve designed to minimize light loss. -# Setup: A new cell c is created, and a 90-degree Euler bend component is defined. -# Placement: Two identical instances of the bend, b1 and b2, are placed in c at the origin (0, 0), one on top of the other. -# Transformation: b2.mirror_x(x=0) takes the second instance (b2) and mirrors it horizontally across the y-axis (the vertical line where x=0). -# Result: The final cell c contains the original bend (b1) and its horizontal mirror image (b2). -# %% -c = kf.KCell() -bend = kf.cells.euler.bend_euler(radius=10, width=1, layer=LAYER.WG) -b1 = c << bend -b2 = c << bend -b2.mirror_x(x=0) -c - -# Next, we will mirror them vertically, which will create 2 back to back curves. -# Setup & Placement: This is identical to the first block; two bend instances are placed at the origin. -# Transformation: b2.mirror_y(y=0) takes the second instance (b2) and mirrors it vertically across the x-axis (the horizontal line where y=0). -# Result: The cell c now contains the original bend and its vertical mirror image. They are positioned back-to-back, but their connection points are still overlapping at the origin. -# %% -c = kf.KCell() -bend = kf.cells.euler.bend_euler(radius=10, width=1, layer=LAYER.WG) -b1 = c << bend -b2 = c << bend -b2.mirror_y(y=0) -c - -# After that, we expand on the previous structure and make it a S-bend waveguide. -# Setup & Mirroring: The code starts exactly like Block 2, creating two bends (b1, b2) and mirroring b2 vertically. They are still overlapping. -# Alignment: b1.ymin = b2.ymax is the key step. This is a powerful kfactory feature for alignment. -# It moves the entire b1 instance vertically until its bottom edge (ymin) is perfectly aligned with the top edge (ymax) of the mirrored b2 instance. -# Result: The two mirrored bends are now perfectly stitched together to form a seamless S-bend. -# This is a common component for shifting the path of a waveguide. -# %% -c = kf.KCell() -bend = kf.cells.euler.bend_euler(radius=10, width=1, layer=LAYER.WG) -b1 = c << bend -b2 = c << bend -b2.mirror_y(y=0) -b1.ymin = b2.ymax -c - -# %% [markdown] -# -# ## Labels -# -# You can add abstract GDS labels (annotate) to your cells, in order to record information -# directly into the final GDS file without putting any extra geometry onto any layer. -# This label will display in a GDS viewer, but will not be rendered or printed -# like the polygons created by gf.cells.text(). - - -# %% -c2.shapes(c2.kcl.find_layer(1, 0)).insert(kf.kdb.Text("First label", mwg1_ref.trans)) -c2.shapes(c2.kcl.find_layer(1, 0)).insert(kf.kdb.Text("Second label", mwg2_ref.trans)) - -# %% -# First we insert a new shape into the c2 cell: -# c2.kcl.find_layer(10, 0): This specifies that the new shape will be drawn on GDSII layer 10, purpose 0. -# This layer is often used for documentation or labels. -# c2.shapes(...).insert(...): This is the command to add the shape (in this case, a text object) to the specified layer. -# Then we define the text object: -# The String: f"The x size of this\nlayout is {c2.dbbox().width()}" -# The \n creates a line break. -# c2.dbbox().width() is a function call that dynamically calculates the total width of the entire c2 cell in database units (dbu). -# This number then gets embedded directly into the text. -# kf.kdb.Trans(c2.bbox().right, c2.bbox().top) sets the location for the text label, in this case at the top right corner. -# Lastly, there are 2 ways to visualize the final design: -# c2.show(): If you are running the script inside the main KLayout application, this command will render the cell in the layout viewer. -# c2.plot(): This command generates a 2D plot of the cell, which is useful for viewing the layout directly in environments like a Jupyter Notebook. -c2.shapes(c2.kcl.find_layer(10, 0)).insert( - kf.kdb.Text( - f"The x size of this\nlayout is {c2.dbbox().width()}", - kf.kdb.Trans(c2.bbox().right, c2.bbox().top), - ) -) -c2.show() -c2.plot() - -# %% [markdown] -# ## Boolean shapes -# -# If you want to subtract one shape from another, merge two shapes, or -# perform an XOR on them, you can do that with the `boolean()` function. -# -# -# The ``operation`` argument should be {not, and, or, xor, 'A-B', 'B-A', 'A+B'}. -# Note that 'A+B' is equivalent to 'or', 'A-B' is equivalent to 'not', and -# 'B-A' is equivalent to 'not' with the operands switched. - - -# %% -e1 = kf.kdb.DPolygon.ellipse(kf.kdb.DBox(10, 8), 64) -e2 = kf.kdb.DPolygon.ellipse(kf.kdb.DBox(10, 6), 64).transformed( - kf.kdb.DTrans(2.0, 0.0) -) - -# %% -c = kf.KCell() -c.shapes(c.kcl.find_layer(2, 0)).insert(e1) -c.shapes(c.kcl.find_layer(4, 0)).insert(e2) -c - -# %% -# e1 NOT e2 -c = kf.KCell() -e3 = kf.kdb.Region(c.kcl.to_dbu(e1)) - kf.kdb.Region(c.kcl.to_dbu(e2)) -c.shapes(c.kcl.find_layer(1, 0)).insert(e3) -c - -# %% -# e1 AND e2 -c = kf.KCell() -e3 = kf.kdb.Region(c.kcl.to_dbu(e1)) & kf.kdb.Region(c.kcl.to_dbu(e2)) -c.shapes(c.kcl.find_layer(1, 0)).insert(e3) -c - -# %% -# e1 OR e2 -c = kf.KCell() -e3 = kf.kdb.Region(c.kcl.to_dbu(e1)) + kf.kdb.Region(c.kcl.to_dbu(e2)) -c.shapes(c.kcl.find_layer(1, 0)).insert(e3) -c - -# %% -# e1 OR e2 (merged) -c = kf.KCell() -e3 = ( - kf.kdb.Region(c.kcl.to_dbu(e1)) + kf.kdb.Region(c.kcl.to_dbu(e2)) -).merge() -c.shapes(c.kcl.find_layer(1, 0)).insert(e3) -c - -# %% -# e1 XOR e2 -c = kf.KCell() -e3 = kf.kdb.Region(c.kcl.to_dbu(e1)) ^ kf.kdb.Region(c.kcl.to_dbu(e2)) -c.shapes(c.kcl.find_layer(1, 0)).insert(e3) -c - -# %% [markdown] -# ## Move the instance by port - - -# %% -c = kf.KCell() -wg = c << kf.cells.straight.straight(width=0.5, length=1, layer=LAYER.WG) -bend = c << kf.cells.euler.bend_euler(width=0.5, radius=1, layer=LAYER.WG) - -bend.connect("o1", wg.ports["o2"]) # connects follow source, the destination is the syntax -c - -# %% [markdown] -# ## Rotate instance -# -# You can rotate in degrees using `Instance.drotate` or in multiples of 90 deg. - - -# %% -c = kf.KCell(name="mirror_example3") -bend = kf.cells.euler.bend_euler(width=0.5, radius=1, layer=LAYER.WG) -b1 = c << bend -b2 = c << bend -b2.drotate(90) -c - -# %% -c = kf.KCell(name="mirror_example4") -bend = kf.cells.euler.bend_euler(width=0.5, radius=1, layer=LAYER.WG) -b1 = c << bend -b2 = c << bend -b2.rotate(1) -c - -# %% [markdown] -# By default the mirror works along the x=0 axis. - -# %% [markdown] -# ## Write GDS -# -# [GDSII](https://en.wikipedia.org/wiki/GDSII) is the standard format for exchanging CMOS circuits with foundries -# -# You can write your cell to a GDS file. - - -# %% -c.write("demo.gds") diff --git a/docs/source/notebooks/01_references.py b/docs/source/notebooks/01_references.py deleted file mode 100644 index 9afbd8e87..000000000 --- a/docs/source/notebooks/01_references.py +++ /dev/null @@ -1,313 +0,0 @@ -# --- -# jupyter: -# jupytext: -# custom_cell_magics: kql -# formats: ipynb,py:percent -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.16.2 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# %% [markdown] -# # Instances and ports -# -# GDS allows the cell to be used once by the memory and instance or make multiple instances of the cell - -# %% [markdown] -# As you build cells you can invoke other cells. Adding an instance is like having a pointer to a cell. -# -# The GDSII specification allows the use of instances and similarly kfactory uses them (with the `create_inst()` function). -# What is an instance? Simply put: **An instance does not contain any geometry. It only *points* to an existing geometry**. -# -# Say you have a ridiculously large polygon with 100 billion vertices that you call it BigPolygon. -# It is huge, and you need to use it in your design 250 times. -# Well, a single copy of BigPolygon takes up 1MB of memory, so you do not want to make 250 copies of it -# You can instead *instances* the polygon 250 times. -# Each instance only uses a few bytes of memory -- -# It only needs to know the memory address of the BigPolygon, alongside its position, rotation and mirror. -# This way, you can keep one copy of BigPolygon and use it again and again. -# -# You can start by making a blank `KFactory` and add a single polygon to it. - -# %% -import kfactory as kf - -# Define Layers - -class LayerInfos(kf.LayerInfos): - WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1,0) - WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2,0) # WG Exclude - CLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4,0) # cladding - FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10,0) - -# Make the layout object aware of the new layers: -LAYER = LayerInfos() -kf.kcl.infos = LAYER - -# %% -# Create a blank Cell -p = kf.KCell() - -# Add a polygon -xpts = [0, 0, 5, 6, 9, 12] -ypts = [0, 1, 1, 2, 2, 0] -p.shapes(p.kcl.find_layer(2, 0)).insert( - kf.kdb.DPolygon([kf.kdb.DPoint(x, y) for x, y in zip(xpts, ypts)]) -) - -# Plot the cell with the polygon in it -p - -# %% [markdown] -# Now, you want to reuse this polygon repeatedly without creating multiple copies of it. -# -# To do so, you need to make a second blank `Cell`, this time named `c`. -# -# In this new cell you *instance* our cell `p` which contains our polygon. - -# %% -c = kf.KCell(name="Cell_with_instances") # Create a new blank cell -poly_ref = c.create_inst(p) # instance the cell "p" that has the polygon in it -c - -# %% [markdown] -# You just made a copy of your polygon -- but remember, you did not actually -# make a second polygon, you just made an instance (aka pointer) to the original -# polygon. Let us now add two more instances to `c`: - -# %% -poly_ref2 = c.create_inst(p) # instance the Cell "p" that has the polygon in it -poly_ref3 = c.create_inst(p) # instance the Cell "p" that has the polygon in it -c - -# %% [markdown] -# Now you have 3x polygons all on top of each other. Again, this would appear -# useless, except that you can manipulate each instance independently. Notice that -# when you called `c.add_ref(p)` above, we saved the result to a new variable each -# time (`poly_ref`, `poly_ref2`, and `poly_ref3`)? -# You can use those variables to reposition the instances. - -# %% -poly_ref2.transform( - kf.kdb.DCplxTrans(1, 15, False, 0, 0) -) # Rotate the 2nd instance we made 15 degrees -poly_ref3.transform( - kf.kdb.DCplxTrans(1, 30, False, 0, 0) -) # Rotate the 3rd instance we made 30 degrees -c - -# %% [markdown] -# Now you are getting somewhere! You have only had to make the polygon once, but you are -# able to reuse it as many times as you want. -# -# ## Modifying the instances -# -# What happens when you change the original geometry of the instance? In your case, your instances in -# `c` all point to the cell `p`, the cell with the original polygon. Let us now try adding a second polygon to `p`. -# First, you add the second polygon and make sure `P` looks like you expect it to: - -# %% -# Add a 2nd polygon to "p" -xpts = [14, 14, 16, 16] -ypts = [0, 2, 2, 0] -p.shapes(p.kcl.find_layer(1, 0)).insert( - kf.kdb.DPolygon([kf.kdb.DPoint(x, y) for x, y in zip(xpts, ypts)]) -) -p - -# %% [markdown] -# That looks good. Now let us find out what happened to `c` which contains the -# three instances. Keep in mind that you have not modified `c` or executed any -# functions/operations on `c` -- all you have done is modify `p`. - -# %% -c - -# %% [markdown] -# **When you modify the original geometry, all of the -# instances automatically reflect the modifications.** This is very powerful, -# because you can use this to make very complicated designs from relatively simple -# elements in a computation- and memory-efficient way. -# -# Now try making instances a level deeper by referencing `c`. Note here that we use -# the `<<` operator to add the instances -- this is just shorthand, and is exactly equivalent to using `add_ref()` - -# %% -c2 = kf.KCell(name="array_sample") # Create a new blank Cell -d_ref1 = c2.create_inst(c) # instance the cell "c" that has the 3 instances in it -d_ref2 = c2 << c # Use the "<<" operator to create a 2nd instance to c -d_ref3 = c2 << c # Use the "<<" operator to create a 3rd instance to c - -d_ref1.transform(kf.kdb.DTrans(20.0, 0.0)) -d_ref2.transform(kf.kdb.DTrans(40.0, 0.0)) - -c2 - -# %% [markdown] -# As you have seen you have two ways to add an instance to our cell: -# -# 1. Create the instance and add it to the cell - -# %% -c = kf.KCell(name="instance_sample") -w = kf.cells.straight.straight(length=10, width=0.6, layer=LAYER.WG) -wr = kf.kdb.CellInstArray(w.kdb_cell, kf.kdb.Trans.R0) -c.insert(wr) -c - -# %% [markdown] -# 2. Alternatively, you can do it in a single line - -# %% -c = kf.KCell(name="instance_sample_shorter_syntax") -wr = c << kf.cells.straight.straight(length=10, width=0.6, layer=LAYER.WG) -c - -# %% [markdown] -# in both cases you can move the instance `wr` after creating it - -# %% -c = kf.KCell(name="two_instances") -wr1 = c << kf.cells.straight.straight(length=10, width=0.6, layer=LAYER.WG) -wr2 = c << kf.cells.straight.straight(length=10, width=0.6, layer=LAYER.WG) -wr2.transform(kf.kdb.DTrans(0.0, 10.0)) -c.add_ports(wr1.ports, prefix="top_") -c.add_ports(wr2.ports, prefix="bot_") - -# %% -c.ports - -# %% [markdown] -# You can also auto_rename ports using gdsfactory default convention, -# in which ports are numbered clockwise starting from the bottom left. - -# %% -c.auto_rename_ports() - -# %% -c.ports - -# %% -c - -# %% [markdown] -# ## Arrays of instances -# -# In GDS, there is a type of structure called an "Instance", which takes a cell and repeats it NxM times on a fixed grid spacing. -# For convenience, `Cell` includes this functionality with the add_array() function. -# Note that CellArrays are not compatible with ports (since there is no way to access/modify individual elements in a GDS cellarray) -# -# Let us make a new Cell and put a big array of our cell `c` in it: - -# %% -import kfactory as kf - -print(kf.__version__) -c = kf.cells.straight.straight(length=10, width=0.6, layer=LAYER.WG) -c3 = kf.KCell() # Create a new blank Cell -aref = c3.create_inst( - c, na=1, nb=3, a=(20000, 0), b=(0, 15000) -) # Create three copies of the component named 'c' and arrange them in a vertical stack - -c3.add_ports(aref.ports) -c3.draw_ports() -c3.plot() - -# %% [markdown] -# You can still access the ports for each instance - -# %% -aref['o1', 0, 1] - -# %% -c.ports - -# %% [markdown] -# ## Connect the instances -# -# We have seen that once you create a instance you can manipulate the instance to move it to a location. -# Here we are going to connect that instance to a port. -# Remember that we follow the rule that a certain instance `source` connects to a `destination` port. - -# %% -c = kf.KCell() -bend = kf.cells.euler.bend_euler(radius=5, width=1, layer=LAYER.WG) -b1 = c << bend -b2 = c << bend -b2.connect("o1", b1.ports["o2"]) -c - -# %% -c = kf.KCell() -b1 = c << kf.cells.euler.bend_euler(radius=5, width=1, layer=LAYER.WG, angle=30) -b2 = c << kf.cells.euler.bend_euler(radius=5, width=1, layer=LAYER.WG, angle=30) -b2.connect("o1", b1.ports["o2"]) -c.show() -c - -# %% [markdown] -# ![](https://i.imgur.com/oenlUwg.png) -# -# This non-manhattan connect will create less than 1nm gaps that you can fix by flattening the references. - -# %% -c = kf.KCell() -b1 = c << kf.cells.euler.bend_euler(radius=5, width=1, layer=LAYER.WG, angle=30) -b2 = c << kf.cells.euler.bend_euler(radius=5, width=1, layer=LAYER.WG, angle=30) -b2.connect("o1", b1.ports["o2"]) -b2.flatten() -c.show() -c - -# %% [markdown] -# The non-manhattan connect previously mentioned fixes this issue. -# -# ![](https://i.imgur.com/t0J31Wg.png) - -# %% [markdown] -# ## Port naming -# -# You have the freedom to name the ports as you want, and you can use `c.auto_rename_ports` to rename them later on. -# Here is the default naming convention. -# Ports are numbered clock-wise starting from the bottom left corner. -# Optical ports have `o` prefix and Electrical ports `e` prefix. - -# %% [markdown] -# Here is the default one we use (clockwise starting from bottom left west facing port) -# -# ``` -# 3 4 -# |___|_ -# 2 -| |- 5 -# | | -# 1 -|______|- 6 -# | | -# 8 7 -# -# ``` - -# %% [markdown] -# ## pins -# -# You can add pins (port markers) to each port. Each foundry PDK does this differently, so gdsfactory supports all of them. -# -# - square with port inside the cell -# - square centered (half inside, half outside cell) -# - triangular -# - path (SiEPIC) -# -# -# by default `KCell.show()` will add triangular pins, so you can see the direction of the port in KLayout. - -# %% -c = kf.cells.euler.bend_euler(radius=5, width=1, layer=LAYER.WG, angle=90) -c.draw_ports() -c - -# %% diff --git a/docs/source/notebooks/02_DRC.py b/docs/source/notebooks/02_DRC.py deleted file mode 100644 index 3687351c8..000000000 --- a/docs/source/notebooks/02_DRC.py +++ /dev/null @@ -1,214 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: -all -# custom_cell_magics: kql -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.16.2 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# %% [markdown] -# # Fixing DRC Errors -# -# ## Min space violations -# -# You can fix Min space violations. - -# %% -from datetime import datetime -import kfactory as kf - -# Define Layers - -class LayerInfos(kf.LayerInfos): - WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1,0) - WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2,0) # WG Exclude - CLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3,0) # cladding - FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10,0) - -# Make the layout object aware of the new layers: -LAYER = LayerInfos() -kf.kcl.infos = LAYER - -# %% - -# A cell named triangle containing a triangle shape is created. -# A cell named Box containing a rectangular DBox is created. -# A third cell c is created, and instances of the triangle and box are placed into it. -# This part of the code is not directly used in the main demonstration but sets up a basic layout context. - -triangle = kf.KCell() -triangle_poly = kf.kdb.DPolygon( - [kf.kdb.DPoint(0, 10), kf.kdb.DPoint(30, 10), kf.kdb.DPoint(30, 0)] -) -triangle.shapes(triangle.layer(1, 0)).insert(triangle_poly) -triangle - -# %% -box = kf.KCell(name="Box") -box_rect = kf.kdb.DBox(0, 0, 20, 5) -box.shapes(box.kcl.find_layer(1, 0)).insert(box_rect) -box - -# %% -c = kf.KCell(name="fix_accute_angle") -c << triangle -c << box -c - -# %% - -# Two were placed inside one another for loops and are used to draw a huge number of ellipses. -# The placement logic is designed to make many of these ellipses overlap or violate minimum spacing rules, -# this creates a "dirty" layout that needs to be fixed. -# d1 and d2 are used to time how long it takes to generate all these shapes. -# This section intentionally creates a massive, complicated layout with thousands of shapes placed very close to each other. - -c = kf.KCell(name="tiled_test") - - -d1 = datetime.now() - -for i in range(50): - ellipse = kf.kdb.Polygon.ellipse(kf.kdb.Box(10000, 20000), i * 2) - - x0 = 0 - for j in range(5000, 30000, 500): - c.shapes(c.kcl.find_layer(1, 0)).insert( - ellipse.transformed(kf.kdb.Trans(x0, i * 30000)) - ) - c.shapes(c.kcl.find_layer(1, 0)).insert( - ellipse.transformed(kf.kdb.Trans(x0 + j, i * 30000)) - ) - - x0 += 15000 - -d2 = datetime.now() - -# kf.utils.fix_spacing_tiled: This powerful function is designed to enforce a minimum spacing rule across an entire layout. -# How it works: It breaks the massive layout into smaller sections called "tiles" (tile_size=(250, 250)). -# It then processes each tile individually to find any shapes on the WG layer (c.kcl.infos.WG) that are closer than the specified distance of 1000 dbu (1 µm). -# It merges these violating shapes into larger, compliant polygons. -# n_threads=32: This tells the function to use 32 parallel processing threads, dramatically speeding up the operation on modern CPUs. -# The "cleaned" geometry is then inserted onto a new layer, (2, 0), -# so that you can compare the original (dirty) layout on layer 1 with the corrected (clean) layout on layer 2. -# The time taken for this cleaning process is calculated as d3-d2. - -c.shapes(c.kcl.layer(2, 0)).insert( - kf.utils.fix_spacing_tiled( - c, - 1000, - c.kcl.infos.WG, - metrics=kf.kdb.Metrics.Euclidian, - n_threads=32, - tile_size=(250, 250), - ) -) - -d3 = datetime.now() - -print(f"time to draw: {d2-d1}") -print(f"time to clean: {d3-d2}") -print(f"total time: {d3-d1}") - -c - -# %% -c = kf.KCell() -d1 = datetime.now() - -for i in range(50): - ellipse = kf.kdb.Polygon.ellipse(kf.kdb.Box(10000, 20000), i * 2) - - x0 = 0 - for j in range(5000, 30000, 500): - c.shapes(c.kcl.layer(1, 0)).insert( - ellipse.transformed(kf.kdb.Trans(x0, i * 30000)) - ) - c.shapes(c.kcl.layer(1, 0)).insert( - ellipse.transformed(kf.kdb.Trans(x0 + j, i * 30000)) - ) - - x0 += 15000 - -d2 = datetime.now() - -# kf.utils.fix_spacing_minkowski_tiled: This function also fixes minimum spacing violations using a tiling approach. -# However, it is based on the Minkowski sum, a mathematical operation that is very effective for "growing" and merging shapes. -# smooth=5: This is an additional parameter that smooths out the corners of the merged shapes, which can be beneficial for device performance. -# Like before, it generates the cleaned geometry on layer (2, 0) and times the process. -# This method is more advanced than the previous method. - -c.shapes(c.kcl.layer(2, 0)).insert( - kf.utils.fix_spacing_minkowski_tiled( - c, - 1000, - c.kcl.infos.WG, - n_threads=32, - tile_size=(250, 250), - smooth=5, - ) -) - -d3 = datetime.now() - -print(f"time to draw: {d2-d1}") -print(f"time to clean: {d3-d2}") -print(f"total time: {d3-d1}") - -c.show() -c.plot() - -# %% [markdown] -# ## Dummy fill -# -# To keep density constant you can add dummy fill. - -# %% -c = kf.KCell() -c.shapes(kf.kcl.find_layer(1, 0)).insert( - kf.kdb.DPolygon.ellipse(kf.kdb.DBox(5000, 3000), 512) -) -c.shapes(kf.kcl.find_layer(10, 0)).insert( - kf.kdb.DPolygon( - [kf.kdb.DPoint(0, 0), kf.kdb.DPoint(5000, 0), kf.kdb.DPoint(5000, 3000)] - ) -) -c - -# %% -fc = kf.KCell() -fc.shapes(fc.kcl.find_layer(2, 0)).insert(kf.kdb.DBox(20, 40)) -fc.shapes(fc.kcl.find_layer(3, 0)).insert(kf.kdb.DBox(30, 15)) -fc - -# %% -import kfactory.utils.fill as fill - -# %% -# fill.fill_tiled(c, fc, [(kf.kcl.find_layer(1,0), 0)], exclude_layers = [(kf.kcl.find_layer(10,0), 100), (kf.kcl.find_layer(2,0), 0), (kf.kcl.find_layer(3,0),0)], x_space=5, y_space=5) -fill.fill_tiled( - c, - fc, - [(kf.kcl.infos.WG, 0)], - exclude_layers=[ - (LAYER.FLOORPLAN, 100), - (LAYER.WGEX, 0), - (LAYER.CLAD, 0), - ], - x_space=5, - y_space=5, -) - -# %% -c.show() -c.plot() - -# %% diff --git a/docs/source/notebooks/03_Enclosures.py b/docs/source/notebooks/03_Enclosures.py deleted file mode 100644 index f56cc5294..000000000 --- a/docs/source/notebooks/03_Enclosures.py +++ /dev/null @@ -1,121 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: -all -# custom_cell_magics: kql -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.16.2 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# %% [markdown] -# # Enclosures -# This code uses kfactory to demonstrate how to create enclosures, -# which are geometries that surround or are derived from a main shape. -# This is a crucial technique for defining things like cladding layers, doping regions, or keep-out zones around a central component like a waveguide. - -# %% -import kfactory as kf - - -class LayerInfos(kf.LayerInfos): - WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) - SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) - NPP: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) - -LAYER = LayerInfos() -kf.kcl.infos = LAYER - -# %% - -# This first block creates a simple enclosure with one extra layer. -# kf.enclosure.LayerEnclosure(...): This defines a set of rules for creating new layers based on a main layer. -# main_layer=LAYER.WG: This specifies that the enclosure rules will be applied to any shapes on the WG (waveguide) layer. -# (LAYER.SLAB, 2000): This is the core rule. It means: -# "Create a new shape on the SLAB layer by taking the WG shape and expanding it outwards by 2000 database units (which is 2 µm, since the default dbu is 1 nm)." -# kf.cells.euler.bend_euler(...): This function creates an Euler bend, a type of curved waveguide. -# enclosure=enc: By passing our enc rule into the bend component, the component automatically creates the SLAB layer around the WG layer according to the rule. -# The result is a waveguide bend on LAYER.WG surrounded by a larger slab shape on LAYER.SLAB. - -enc = kf.enclosure.LayerEnclosure( - [ - (LAYER.SLAB, 2000), - ], - name="WGSLAB", - main_layer=LAYER.WG, -) -c = kf.cells.euler.bend_euler(radius=5, width=1, layer=LAYER.WG, enclosure=enc) -c.show() -c.plot() - -# %% - -# (LAYER.SLAB, 2000): This rule is the same as before, creating a 2 µm expansion on the SLAB layer. -# (LAYER.NPP, 1000, 2000): This rule is more advanced. It has three parts: (layer, enclosure, offset). -# It targets the NPP layer (likely for N-type doping). -# The enclosure value of 1000 means the new shape will be 1 µm wider than the main WG shape. -# The offset value of 2000 means the new shape will also be shifted outwards by 2 µm from the edge of the WG. -# This creates a doped region that is separated from the waveguide core. -# This more complex enc object is then applied to a new Euler bend, -# which then results in a component with three layers: the core WG, the SLAB, and the offset NPP doping region. -enc = kf.enclosure.LayerEnclosure( - [ - (LAYER.SLAB, 2000), - (LAYER.NPP, 1000, 2000), - ], - name="SLAB_DOPED", - main_layer=LAYER.WG, -) -c = kf.cells.euler.bend_euler(radius=5, width=1, layer=LAYER.WG, enclosure=enc) -c.show() -c.plot() - -# %% - -# kcell_enc = kf.KCellEnclosure([enc]): This creates a wrapper, a KCellEnclosure, that can hold one or more LayerEnclosure rule sets. -# Here, it holds the SLAB_DOPED rules from the previous step. -# @kf.cell def two_bends(...): This defines a new, reusable component that contains two separate Euler bends. -# It is important to note that the bends themselves are created without any enclosure. -# if enclosure:: The function checks if an enclosure object was passed to it. -# enclosure.apply_minkowski_tiled(c): This is the key command. -# It takes the final geometry of the two_bends cell (both bends combined) and applies the enclosure rules to the whole thing at once. -# This correctly merges the enclosures of the two bends into a single, continuous shape, which is often the desired result. -# The final output is the two_bends component, where a single, unified SLAB and NPP region correctly surrounds the combined geometry of both bends. -# This method is more robust for complex cells than applying enclosures to each sub-component individually. - -kcell_enc = kf.KCellEnclosure([enc]) - -@kf.cell -def two_bends( - radius: float, - width: float, - layer: kf.kdb.LayerInfo, - enclosure: kf.KCellEnclosure | None = None, -) -> kf.KCell: - c = kf.KCell() - b1 = c << kf.cells.euler.bend_euler(radius=radius, width=width, layer=layer) - b2 = c << kf.cells.euler.bend_euler(radius=radius, width=width, layer=layer) - b2.drotate(90) - b2.dmovey(-11) - b2.dmovex(b2.dxmin, 0) - - c.add_ports(b1.ports) - c.add_ports(b2.ports) - c.auto_rename_ports() - - if enclosure: - enclosure.apply_minkowski_tiled(c) - return c - - -c = two_bends(radius=5, width=1, layer=LAYER.WG, enclosure=kcell_enc) -c.show() -c.plot() - -# %% diff --git a/docs/source/notebooks/04_KCL.py b/docs/source/notebooks/04_KCL.py deleted file mode 100644 index 5678249d6..000000000 --- a/docs/source/notebooks/04_KCL.py +++ /dev/null @@ -1,112 +0,0 @@ -# --- -# jupyter: -# jupytext: -# custom_cell_magics: kql -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.16.2 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# %% [markdown] -# # Multi - KCLayout / PDK - -# %% [markdown] -# You can also use multiple KCLayout objects as PDKs or Libraries of KCells and parametric KCell-Functions - -# %% [markdown] -# ## Use multiple KCLayout objects as PDKs/Libraries -# -# KCLayouts can act as PDKs. They can be seamlessly incorporated into each other. - -# %% -import kfactory as kf - - -class LayerInfos(kf.LayerInfos): - WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) - WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) # WG Exclude - CLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 0) # cladding - FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) - - -# Make the layout object aware of the new layers: -LAYER = LayerInfos() -kf.kcl.infos = LAYER - -kcl_default = kf.kcl - -# %% [markdown] -# Empty default KCLayout - -# %% -kcl_default.kcells - -# %% -# Create a default straight waveguide in the default KCLayout with dbu==0.001 (1nm grid) -s_default = kf.cells.straight.straight(width=1, length=10, layer=LAYER.WG) - -# %% -# There is now a a KCell in the KCLayout -kcl_default.kcells - -# %% -# Control the dbu is still 1nm -kcl_default.dbu - -# %% -# Create a new KCLayout to simulate PDK (could be package with e.g. `from test_pdk import kcl as pdk` or similar) -kcl2 = kf.KCLayout("TEST_PDK", infos=LayerInfos) -# Set the dbu to 0.005 (5nm) -kcl2.dbu = 0.005 -kcl2.layout.dbu - -# %% -# Since it is a new KCLayout, it is empty -kcl2 - -# %% -# Create a parametric KCell-Function for straights on the new PDK -sf2 = kf.factories.straight.straight_dbu_factory(kcl=kcl2) - -# %% -# Make an instance with -s2 = kcl2.factories["straight"](length=10000, width=200, layer=LAYER.WG) -s2.settings - -# %% -# The default kcl's straight uses 1nm grid and is therefore 1000dbu (1um) high and 10000dbu (10um) wide -print(f"{s_default.bbox().height()=}") -print(f"{s_default.dbbox().height()=}") -print(f"{s_default.bbox().width()=}") -print(f"{s_default.dbbox().width()=}") -# The test PDK uses a 5nm grid, so it will be 200dbu (1um) high and 10000dbu (50um) wide -print(f"{s2.bbox().height()=}") -print(f"{s2.dbbox().height()=}") -print(f"{s2.bbox().width()=}") -print(f"{s2.dbbox().width()=}") - -# %% -# The ports of the default kcl also have different width dbu dimensions, but are the same in um -s_default.ports.print() -s2.ports.print() -# But in um they are the same -s_default.ports.print(unit="um") -s2.ports.print(unit="um") - -# %% -# Both can be incorporated into the same KCell -c = kcl_default.kcell() -si_d = c << s_default -si_2 = c << s2 - -# %% -si_2.connect("o1", si_d, "o2") - -# %% -c diff --git a/docs/source/pcells.md b/docs/source/pcells.md deleted file mode 100644 index 93b7c70d1..000000000 --- a/docs/source/pcells.md +++ /dev/null @@ -1,160 +0,0 @@ -# Creating PCells - -> [PCell](https://en.wikipedia.org/wiki/PCell) stands for parameterized cell. - -PCells are a way to create cells that are parameterized by several variables. - -In kfactory, with the @cell for `KCell` and `DKCell` or @vcell for `VKCell` you can easily create PCells. - -Throughout this tutorial we use this example of a very simple PCell. - -```python -import kfactory as kf - - -@kf.cell -def rectangle(width: int, height: int, layer: int) -> kf.KCell: - """Create a rectangle with the given width, height and layer. - - Args: - width: width of the rectangle in dbu - height: height of the rectangle in dbu - layer: layer of the rectangle - - Returns: - KCell with the rectangle - """ - c = kf.KCell() - c.shapes(layer).insert(kf.kdb.Box(0, 0, width, height)) - return c - - -if __name__ == "__main__": - kcell = rectangle(1000, 1000, kf.kcl.layer(1, 0)) - kcell.show() -``` - -This PCell is not very useful by itself, but it will be used to illustrate the different ways to create PCells. - -In the above example we were working in dbu with the `KCell` class. - -But we can also switch to um very easily. Here are some ways we can do this: - -1. Use the `to_dkcell` method with the dbu rectangle pcell: -```python -dkcell = rectangle(1000, 1000, kf.kcl.layer(1, 0)).to_dkcell() -dkcell.show() -``` - -2. Use the `output_type` argument with the `@cell` decorator. -This performs the conversion automatically. - -```python -import kfactory as kf - - -@kf.cell(output_type=kf.DKCell) -def rectangle(width: int, height: int, layer: int) -> kf.KCell: - """Create a rectangle with the given width, height and layer. - - Args: - width: width of the rectangle - height: height of the rectangle - layer: layer of the rectangle - - Returns: - KCell with the rectangle - """ - c = kf.KCell() - c.shapes(layer).insert(kf.kdb.Box(0, 0, width, height)) - return c - - -if __name__ == "__main__": - dkcell = rectangle(1000, 1000, kf.kcl.layer(1, 0)) - dkcell.show() -``` - -3. Create separate PCells for dbu and um. - -```python -import kfactory as kf - - -@kf.cell -def rectangle_dbu(width: int, height: int, layer: int) -> kf.KCell: - """Create a rectangle with the given width, height and layer. - - Args: - width: width of the rectangle in dbu - height: height of the rectangle in dbu - layer: layer of the rectangle - - Returns: - KCell with the rectangle - """ - c = kf.KCell() - c.shapes(layer).insert(kf.kdb.Box(0, 0, width, height)) - return c - - -@kf.cell -def rectangle_um(width: float, height: float, layer: int) -> kf.DKCell: - """Create a rectangle with the given width, height and layer. - - Args: - width: width of the rectangle in um - height: height of the rectangle in um - layer: layer of the rectangle - - Returns: - DKCell with the rectangle - """ - return rectangle_dbu(kf.kcl.to_dbu(width), kf.kcl.to_dbu(height), layer).to_dkcell() - - -if __name__ == "__main__": - kcell = rectangle_dbu(1000, 1000, kf.kcl.layer(1, 0)) - kcell.show() - dkcell = rectangle_um(1, 1, kf.kcl.layer(1, 0)) - dkcell.show() -``` - -### Complex Example - -There might be a case where you want to create a PCell based on another one, but return a different type of cell. - -First, a new, empty class called DKCellSubclass is defined. It inherits from kf.DKCell (Design Kit Cell), which is a kfactory cell type that can hold extra metadata (essentially data about data). -straight = kf.factories.straight.straight_dbu_factory(kcl=kf.kcl) is a standard factory function (a type of function that creates and returns new objects and functions). and serves as a basic generator that builds a straight waveguide KCell using arguments in database units (dbu). -The kf.cell(output_type=DKCellSubclass) part acts as a "wrapper." It takes the original straight function and creates a new version. -This new version does everything the original did, but it ensures the final component it returns is an instance of our custom DKCellSubclass (Design Kit) instead of the default kf.KCell. -dkcell_straight = dkcell_straight_factory(1000, 5000, Layers().WG): -The new, modified factory is called to create a straight waveguide 1 µm wide (1000 dbu) and 5 µm long (5000 dbu). -assert isinstance(dkcell_straight, DKCellSubclass) is critical as it verifies that the object created by the instance is indeed our custom -DKCellSubclass. If this wrapper fails, it will produce an error. -dkcell_straight.show(): Finally, the custom-typed component is displayed in the KLayout viewer. - -```python -import kfactory as kf - - -class Layers(kf.kcell.LayerInfos): - WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) - - -class DKCellSubclass(kf.DKCell): - pass - - -kf.kcl.infos = Layers() - -straight = kf.factories.straight.straight_dbu_factory(kcl=kf.kcl) - -dkcell_straight_factory = kf.cell(output_type=DKCellSubclass)(straight) - -if __name__ == "__main__": - dkcell_straight = dkcell_straight_factory(1000, 5000, Layers().WG) - assert isinstance(dkcell_straight, DKCellSubclass) - dkcell_straight.show() - -``` diff --git a/docs/source/pdk/creating_pdk.py b/docs/source/pdk/creating_pdk.py new file mode 100644 index 000000000..3a672e981 --- /dev/null +++ b/docs/source/pdk/creating_pdk.py @@ -0,0 +1,430 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Creating a PDK +# +# A **Process Design Kit (PDK)** in kfactory is a `KCLayout` instance plus the layers, +# enclosures, cross-sections, and factories that describe your process. Everything lives +# in one place so every cell you create is consistent. +# +# A minimal PDK has four ingredients: +# +# | Ingredient | What it does | +# |---|---| +# | `KCLayout` | Root container for all cells, dbu setting, and registry objects | +# | `LayerInfos` | Named layer definitions (`layer, datatype` pairs) | +# | `LayerEnclosure` | Cladding/slab geometry rules around waveguide cores | +# | Factories | Functions that stamp cells into the layout (straight, bend, taper, …) | +# +# The section on [KCLayout](../concepts/kclayout.py) covers the layout object in depth. +# The section on [Cross-Sections](../components/cross_sections.py) covers cross-section +# registration. This page shows how to wire everything together into a single reusable +# module. + +# %% [markdown] +# ## 1 · Layers +# +# Define layers with `LayerInfos`. Each field is a `kdb.LayerInfo(layer, datatype)`. +# Pass the **class** (not an instance) to `KCLayout` so the PDK can build its internal +# layer index at construction time. + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) # waveguide core + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) # cladding oxide + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) # slab (rib process) + METAL: kf.kdb.LayerInfo = kf.kdb.LayerInfo(20, 0) # metal layer + METALEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(20, 1) # metal keep-out + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(99, 0) # die outline + + +# %% [markdown] +# ## 2 · Create a named `KCLayout` +# +# `kf.kcl` is the default global layout. For a PDK you create a **named** layout so that +# all your cells carry the PDK name and stay isolated from other layouts in the same +# session. +# +# Pass `infos=LAYER` (the class) so the PDK builds a `LayerEnum` from your layers. The +# instantiated `LayerInfos` is then available as `pdk.infos`. +# +# > **Tip:** Call `KCLayout` once at module level and import the resulting object +# > everywhere in your PDK. Never recreate it — each `KCLayout` call registers a new +# > entry in `kf.kcls`. + +# %% +# Create the PDK layout with layers registered. +pdk = kf.KCLayout("MY_PDK", infos=LAYER) + +# pdk.infos is the LayerInfos instance; use it for layer objects everywhere. +L = pdk.infos + +print(f"PDK: {pdk}") +print(f"dbu: {pdk.dbu} µm/DBU (1 nm grid)") +print(f"WG layer index: {pdk.find_layer(L.WG)}") + +# %% [markdown] +# ## 3 · Enclosures +# +# A `LayerEnclosure` describes the cladding/slab geometry added around waveguide cores. +# Register it with the layout so routers and cross-sections can look it up by name. +# +# The `sections` list uses `(layer, d_max)` for symmetric growth or +# `(layer, d_min, d_max)` for annular (ring) regions. Values are in **DBU**. + +# %% +# Standard strip waveguide enclosure: 2 µm oxide cladding. +enc_strip = pdk.get_enclosure( + kf.LayerEnclosure( + name="STRIP", + main_layer=L.WG, + sections=[ + (L.WGCLAD, 0, 2_000), # 0–2 µm cladding + ], + ) +) + +# Rib waveguide: cladding + partial slab. +enc_rib = pdk.get_enclosure( + kf.LayerEnclosure( + name="RIB", + main_layer=L.WG, + sections=[ + (L.WGCLAD, 0, 2_000), # 0–2 µm cladding + (L.SLAB, 0, 4_000), # 0–4 µm slab + ], + ) +) + +print(f"strip enclosure: {enc_strip.name}") +print(f"rib enclosure: {enc_rib.name}") + +# %% [markdown] +# ## 4 · Cross-sections +# +# A cross-section pairs an enclosure with a core width and bend-radius hints. +# `DCrossSection` accepts µm; it converts to DBU when registered in the layout. +# +# Cross-sections are stored in `pdk.cross_sections` keyed by name. You retrieve +# them later with `pdk.get_icross_section("WG_500")` (DBU view) or +# `pdk.get_dcross_section("WG_500")` (µm view). + +# %% +# Strip waveguide — 500 nm core, 2 µm cladding, 10 µm nominal bend radius. +xs_strip = kf.DCrossSection( + kcl=pdk, + width=0.5, # µm + layer=L.WG, + sections=[ + (L.WGCLAD, 2.0), # cladding: 0 → 2 µm + ], + radius=10.0, # preferred bend radius (µm) — routing hint + radius_min=5.0, # minimum bend radius (µm) — DRC hint + name="WG_500", +) + +# Register and obtain the DBU-unit view. +xs_strip_dbu = pdk.get_icross_section(xs_strip) +print(f"cross-section: {xs_strip_dbu.name}") +print(f"core width DBU: {xs_strip_dbu.width} ({pdk.to_um(xs_strip_dbu.width):.3f} µm)") +print(f"radius (µm): {pdk.to_um(xs_strip_dbu.radius):.1f}") + +# %% +# Rib waveguide — 700 nm core, cladding + slab, 15 µm radius. +xs_rib = kf.DCrossSection( + kcl=pdk, + width=0.7, + layer=L.WG, + sections=[ + (L.WGCLAD, 2.0), + (L.SLAB, 4.0), + ], + radius=15.0, + radius_min=8.0, + name="WG_700_RIB", +) +pdk.get_icross_section(xs_rib) + +print(f"registered cross-sections: {list(pdk.cross_sections.cross_sections)}") + +# %% [markdown] +# ## 5 · Factories +# +# A **factory** is a function that creates cells bound to a specific `KCLayout`. +# Call the factory once with `kcl=pdk` to get a cell-creation function. +# That function is then called with the per-instance parameters. +# +# | Factory | Module | Width/length units | +# |---|---|---| +# | `straight_dbu_factory` | `kf.factories.straight` | **DBU** | +# | `bend_euler_factory` | `kf.factories.euler` | **µm** | +# | `bend_circular_factory` | `kf.factories.circular` | **µm** | +# | `taper_factory` | `kf.factories.taper` | **DBU** | +# +# > **Why two unit systems?** Straight waveguides need exact DBU lengths for DRC-clean +# > port placement; bend radii are naturally specified in µm by process specs. + +# %% +# ── Straight waveguide factory (widths/lengths in DBU) ──────────────────── +straight = kf.factories.straight.straight_dbu_factory(kcl=pdk) + +# ── Euler bend factory (width and radius in µm) ─────────────────────────── +bend_euler = kf.factories.euler.bend_euler_factory(kcl=pdk) + +# ── 90° S-bend factory ──────────────────────────────────────────────────── +bend_s = kf.factories.euler.bend_s_euler_factory(kcl=pdk) + +# ── Taper factory (widths/length in DBU) ───────────────────────────────── +taper = kf.factories.taper.taper_factory(kcl=pdk) + +# %% [markdown] +# ### Stamping cells +# +# Call each factory with the desired parameters. The `@cell` decorator inside every +# factory caches the result — calling with the same parameters returns the same object. + +# %% +wg = straight( + width=pdk.to_dbu(0.5), # 500 nm → DBU + length=pdk.to_dbu(20.0), # 20 µm → DBU + layer=L.WG, + enclosure=enc_strip, +) + +bend = bend_euler( + width=0.5, # µm + radius=10.0, # µm + layer=L.WG, + enclosure=enc_strip, + angle=90, +) + +tp = taper( + width1=pdk.to_dbu(0.5), # narrow end + width2=pdk.to_dbu(1.0), # wide end + length=pdk.to_dbu(10.0), + layer=L.WG, + enclosure=enc_strip, +) + +print(f"straight: {wg}") +print(f"bend90: {bend}") +print(f"taper: {tp}") + +# %% [markdown] +# ## 6 · Custom cells using PDK factories +# +# Build compound cells by combining the primitives. The `@pdk.cell` decorator caches +# the result and enforces port naming. +# +# > **Important:** always pass `kcl=pdk` when constructing `kf.Port` objects inside a +# > custom PDK cell. Without it kfactory defaults to the global `kf.kcl` layout, which +# > makes layer indices inconsistent. + + +# %% +@pdk.cell +def mmi_1x2( + width: float = 0.5, # µm — waveguide core width + gap: float = 0.2, # µm — gap between output waveguides + length: float = 10.0, # µm — MMI body length +) -> kf.KCell: + """1×2 multimode-interference splitter. + + Args: + width: Waveguide core width (µm). + gap: Gap between the two output waveguides (µm). + length: MMI body length (µm). + """ + c = pdk.kcell() + + # Convert µm to DBU for shape/port placement. + width_dbu = pdk.to_dbu(width) + gap_dbu = pdk.to_dbu(gap) + length_dbu = pdk.to_dbu(length) + + # MMI body: wide enough for two waveguides + gap + margin. + body_width_dbu = 2 * width_dbu + gap_dbu + pdk.to_dbu(0.4) + + # Core rectangle. + wg_li = pdk.find_layer(L.WG) + c.shapes(wg_li).insert( + kf.kdb.Box( + -length_dbu // 2, + -body_width_dbu // 2, + length_dbu // 2, + body_width_dbu // 2, + ) + ) + # Cladding rectangle. + clad_margin = pdk.to_dbu(2.0) + c.shapes(pdk.find_layer(L.WGCLAD)).insert( + kf.kdb.Box( + -length_dbu // 2 - clad_margin, + -body_width_dbu // 2 - clad_margin, + length_dbu // 2 + clad_margin, + body_width_dbu // 2 + clad_margin, + ) + ) + + # Input port (West face). Always pass kcl=pdk so the port layer index + # is resolved in the correct layout. + c.add_port( + port=kf.Port( + name="o1", + trans=kf.kdb.Trans(2, False, -length_dbu // 2, 0), + width=width_dbu, + layer=wg_li, + port_type="optical", + kcl=pdk, # required when using a custom KCLayout + ) + ) + + # Two output ports (East face), vertically offset by ±(width+gap)/2. + y_out = (width_dbu + gap_dbu) // 2 + for name, y in [("o2", y_out), ("o3", -y_out)]: + c.add_port( + port=kf.Port( + name=name, + trans=kf.kdb.Trans(0, False, length_dbu // 2, y), + width=width_dbu, + layer=wg_li, + port_type="optical", + kcl=pdk, + ) + ) + + return c + + +splitter = mmi_1x2() +print(f"mmi_1x2 ports: {[p.name for p in splitter.ports]}") +splitter.plot() + +# %% [markdown] +# ## 7 · Assembling a circuit +# +# Use `<<` to place cells (returns an `Instance`) and `connect()` to snap ports together. +# The following builds a simple Y-splitter + arms circuit to show the assembly pattern. + + +# %% +@pdk.cell +def splitter_arms(arm_length: float = 50.0) -> kf.KCell: + """Y-splitter followed by two straight waveguide arms. + + Args: + arm_length: Length of each output arm (µm). + """ + c = pdk.kcell() + + # Place splitter at the origin. + sp = c << mmi_1x2() + + # Straight arm cell — length is the same for both arms. + arm_wg = straight( + width=pdk.to_dbu(0.5), + length=pdk.to_dbu(arm_length), + layer=L.WG, + enclosure=enc_strip, + ) + + top_arm = c << arm_wg + bot_arm = c << arm_wg + + # Snap arm inputs to the splitter outputs. + top_arm.connect("o1", sp, "o2") + bot_arm.connect("o1", sp, "o3") + + # Expose the circuit ports (pass name= to rename on the parent). + c.add_port(port=sp.ports["o1"], name="o1") + c.add_port(port=top_arm.ports["o2"], name="o2") + c.add_port(port=bot_arm.ports["o2"], name="o3") + + return c + + +fork = splitter_arms(arm_length=30.0) +print(f"splitter_arms ports: {[p.name for p in fork.ports]}") +fork.plot() + +# %% [markdown] +# ## 8 · PDK module pattern +# +# In a real project you package the code above as a Python module, for example +# `my_pdk/__init__.py`. Other design files then do: +# +# ```python +# from my_pdk import pdk, L, straight, bend_euler, taper, mmi_1x2 +# +# c = pdk.kcell() +# wg = straight(width=pdk.to_dbu(0.5), length=pdk.to_dbu(20), layer=L.WG) +# ... +# ``` +# +# The recommended structure is: +# +# ``` +# my_pdk/ +# __init__.py — re-exports pdk, L, factories, cells +# layers.py — LAYER(LayerInfos) class +# enclosures.py — enc_strip, enc_rib, … +# cross_sections.py — xs_strip, xs_rib, … +# cells/ +# __init__.py +# mmi.py — mmi_1x2, mmi_2x2, … +# ring.py — ring_resonator, … +# factories.py — straight, bend_euler, taper, … +# ``` +# +# All modules import `pdk` from the top-level `__init__.py` so they share one +# `KCLayout` object. +# +# > **Note:** the `KCLayout` constructor takes `infos=LAYER` (the **class**), not an +# > instance. The resulting `LayerInfos` instance is available as `pdk.infos`. + +# %% [markdown] +# ## 9 · GDS export +# +# When the design is complete, write the whole layout to a GDS file. +# All cells registered in `pdk` (including sub-cells) are written in one call. + +# %% +import pathlib +import tempfile + +with tempfile.TemporaryDirectory() as tmp: + gds_path = pathlib.Path(tmp) / "my_pdk.gds" + pdk.write(str(gds_path)) + size = gds_path.stat().st_size + print(f"Written: {gds_path.name} ({size} bytes)") + print(f"Top cells: {[c.name for c in pdk.top_kcells()]}") + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Layer stacks & technology data | [PDK: Technology](technology.py) | +# | Cross-sections (port geometry) | [Cross-Sections](../components/cross_sections.py) | +# | Layer enclosures (auto-cladding) | [Enclosures: Layer Enclosure](../enclosures/layer_enclosure.py) | +# | PCells & caching | [Components: PCells](../components/cells/pcells.py) | +# | Factory functions reference | [Components: Factories](../components/cells/factories/overview.py) | +# | Session caching (fast reload) | [Utilities: Session Cache](../utilities/session_cache.py) | diff --git a/docs/source/pdk/technology.py b/docs/source/pdk/technology.py new file mode 100644 index 000000000..0a1131fdc --- /dev/null +++ b/docs/source/pdk/technology.py @@ -0,0 +1,245 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Technology & Layer Stack +# +# A **LayerStack** describes the vertical cross-section of your fabrication process: +# which physical materials are deposited on which GDS layers, at what height (`zmin`), +# and with what thickness. This information is used by simulation exporters +# (e.g. Tidy3D, MEEP, Lumerical) that consume kfactory layouts. +# +# This page covers: +# +# | Topic | What you'll learn | +# |---|---| +# | `LayerLevel` | One physical layer in the process | +# | `LayerStack` | Collection of `LayerLevel`s | +# | `Info` | Extra metadata (mesh order, refractive index, etch type, …) | +# | Attaching a stack to a PDK | Best-practice pattern | +# | Querying the stack | Per-layer lookups | + +# %% [markdown] +# ## 1 · LayerLevel — one process step +# +# `LayerLevel` describes a single physical layer: +# +# | Parameter | Type | Meaning | +# |---|---|---| +# | `layer` | `(int, int)` or `kdb.LayerInfo` | GDS layer / datatype | +# | `zmin` | `float` (µm) | Bottom of the slab | +# | `thickness` | `float` (µm) | Vertical thickness | +# | `material` | `str` | Material name (passed to simulator) | +# | `sidewall_angle` | `float` (°) | 0° = vertical, 90° = horizontal | +# | `info` | `Info` | Optional simulation metadata | + +# %% +import kfactory as kf +from kfactory.layer import Info, LayerLevel, LayerStack + +# Silicon waveguide core: 220 nm thick, starts at z=0 +wg_level = LayerLevel( + layer=(1, 0), + zmin=0.0, + thickness=0.22, # µm + material="si", + sidewall_angle=80.0, # near-vertical etch +) + +print(wg_level) + +# %% [markdown] +# ### Info — simulation metadata +# +# Pass an `Info` object to attach extra metadata that simulators can consume. +# Common fields: +# +# | Field | Meaning | +# |---|---| +# | `mesh_order` | Lower = higher priority in mesher (1 overrides 2) | +# | `refractive_index` | `float` (int/float only; complex must be stored as a string) | +# | `type` | `"grow"`, `"etch"`, `"implant"`, or `"background"` | + +# %% +wg_level_with_info = LayerLevel( + layer=(1, 0), + zmin=0.0, + thickness=0.22, + material="si", + sidewall_angle=80.0, + info=Info( + mesh_order=1, + refractive_index=3.47, + type="grow", + ), +) +print(wg_level_with_info.info) + +# %% [markdown] +# ## 2 · LayerStack — the full process +# +# `LayerStack` is a named collection of `LayerLevel`s. Pass levels as keyword arguments; +# the keyword name becomes the layer's logical name in the stack. + +# %% +stack = LayerStack( + # --- core waveguide layers --- + wg=LayerLevel( + layer=(1, 0), + zmin=0.0, + thickness=0.22, + material="si", + sidewall_angle=80.0, + info=Info(mesh_order=1, refractive_index=3.47, type="grow"), + ), + slab=LayerLevel( + layer=(3, 0), + zmin=0.0, + thickness=0.09, # partial etch leaves 90 nm slab + material="si", + sidewall_angle=70.0, + info=Info(mesh_order=2, refractive_index=3.47, type="grow"), + ), + # --- oxide cladding --- + clad=LayerLevel( + layer=(111, 0), + zmin=-3.0, + thickness=3.22, # 3 µm below + 0.22 µm above wg top + material="sio2", + info=Info(mesh_order=3, refractive_index=1.44, type="background"), + ), + # --- metal --- + metal=LayerLevel( + layer=(41, 0), + zmin=0.5, + thickness=1.0, + material="al", + info=Info(mesh_order=1, type="grow"), + ), +) + +print(stack) + +# %% [markdown] +# ## 3 · Querying the stack +# +# `LayerStack` exposes several lookup helpers. Keys are `(layer, datatype)` tuples. + +# %% +print("Thicknesses:") +for layer_tuple, t in stack.get_layer_to_thickness().items(): + print(f" {layer_tuple}: {t} µm") + +print("\nMaterials:") +for layer_tuple, mat in stack.get_layer_to_material().items(): + print(f" {layer_tuple}: {mat}") + +print("\nZ-min positions:") +for layer_tuple, zmin in stack.get_layer_to_zmin().items(): + print(f" {layer_tuple}: {zmin} µm") + +print("\nSidewall angles:") +for layer_tuple, angle in stack.get_layer_to_sidewall_angle().items(): + print(f" {layer_tuple}: {angle}°") + +# %% [markdown] +# Access individual levels by name or by index: + +# %% +wg = stack["wg"] +print( + f"wg zmin={wg.zmin} µm, thickness={wg.thickness} µm, top={wg.zmin + wg.thickness} µm" +) + +# %% [markdown] +# ## 4 · Attaching a stack to a PDK +# +# A `LayerStack` is not built into `KCLayout` — you attach it to your PDK module as a +# module-level constant alongside `LAYER` and the layout object. +# +# Best practice: define everything in one module and import `pdk`, `LAYER`, and `STACK` +# from it. + +# %% +# --- pdk_with_stack.py (inline for demo) --- + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(111, 0) + METAL: kf.kdb.LayerInfo = kf.kdb.LayerInfo(41, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(99, 0) + + +pdk = kf.KCLayout("DEMO_TECH_PDK", infos=LAYER) +L = pdk.infos # LayerInfos instance; use for layer objects + +STACK = LayerStack( + wg=LayerLevel( + layer=(L.WG.layer, L.WG.datatype), + zmin=0.0, + thickness=0.22, + material="si", + sidewall_angle=80.0, + info=Info(mesh_order=1, refractive_index=3.47, type="grow"), + ), + clad=LayerLevel( + layer=(L.WGCLAD.layer, L.WGCLAD.datatype), + zmin=-3.0, + thickness=3.22, + material="sio2", + info=Info(mesh_order=3, refractive_index=1.44, type="background"), + ), +) + +print(f"PDK: {pdk}") +print(f"Stack: {list(STACK.layers.keys())} layers defined") + +# %% [markdown] +# ## 5 · Serialising the stack +# +# `to_dict()` returns a plain Python dict — useful for saving to YAML or JSON, or +# for passing to simulators that don't import kfactory directly. + +# %% +import json + +d = stack.to_dict() +print(json.dumps(d, indent=2, default=str)) + +# %% [markdown] +# ## Summary +# +# | Class | Key role | +# |---|---| +# | `LayerLevel` | Single process step: layer, z position, thickness, material | +# | `Info` | Simulation extras: mesh order, refractive index, etch type | +# | `LayerStack` | Named dict of `LayerLevel`s; exposes per-layer lookup helpers | +# +# Keep `STACK` as a module-level constant in your PDK module alongside `pdk` and `LAYER`. +# Simulators can then `from my_pdk import pdk, LAYER, STACK` and get everything they need +# from a single import. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Creating a full PDK | [PDK: Creating a PDK](creating_pdk.py) | +# | Layer definitions | [Core Concepts: Layers](../concepts/layers.py) | +# | Cross-sections (port geometry) | [Cross-Sections](../components/cross_sections.py) | +# | KCLayout (owns the cell DB) | [Core Concepts: KCLayout](../concepts/kclayout.py) | diff --git a/docs/source/pre.md b/docs/source/pre.md deleted file mode 100644 index 7d1dc11a7..000000000 --- a/docs/source/pre.md +++ /dev/null @@ -1,39 +0,0 @@ -# Installation - -In conjunction with kfactory it is highly recommended to first install KLayout, a GDS and OASIS file viewer, and klive, the plugin for loading gds files from KFactory. -Furthermore, you will need a file editor and viewer in which you can work with Python. Two popular options are: - -- [Pycharm](https://www.jetbrains.com/help/pycharm/quick-start-guide.html) -- [VSCode](https://code.visualstudio.com/docs/getstarted/getting-started) - -# Python - -[Python](https://python.org) Being able to use and understand the basics of Python will be invaluable when trying to follow the tutorials showcased. We would highly recommend obtaining at least the basic knowledge of how Python works and why it works in the way it does. -The following Python tutorial is comprehensive and easy to start out with: https://www.learnpython.org/ - -Make sure you understand at least the basics of python in order to use kfactory. You should be familiar with python virtual environments and how to install packages into an environment. - -## Python Environment(s) - -It is highly recommended to use a way to separate python environments and use one environment per project. There are multiple options to dos so - -- [uv](https://docs.astral.sh/uv/): A modern and fast python package and project manager, backed by rust for speed. -- [venv](https://docs.python.org/3/library/venv.html): The minimalistic way. This will create a new environment based on the base python (usually the system python for MacOS/Linux) -- [Miniconda](https://docs.conda.io/en/latest/miniconda.html): An open-source package and environment management system. Conda is not limited to python only, it also allows installation of other libraries. - JupyterLab for example can also be installed into an environment with `conda -c conda-forge install jupyter-lab` -- [Anaconda](https://www.anaconda.com): A desktop application to manage applications, packages, and environments. This contains Miniconda plus a GUI. - -# KLayout - -[KLayout](https://www.klayout.de/intro.html) is an open source viewer for files produced by kfactory. To produce these files, kfactory uses the [python package](https://pypi.org/project/klayout/) as a basis. -Therefore it is highly recommended to install KLayout. It can be downloaded from its [website](https://www.klayout.de/build.html). - -# klive - -[klive](https://github.com/gdsfactory/klive) is a KLayout package that allows to load or refresh a gds from python. It can be installed with the package manager of KLayout. The following video shows how to install it from the KLayout internal package manager. The package manager can be found under `Tools -> Manage Packages`. - -![type:video](_static/klive.webm) - -klive will listen on port 8082/tcp for incoming connections on localhost. When kfactory (and also gdsfactory) build a cell and want to send it to KLayout, they will send a JSON containing the location of the GDS file and other metainfo to this port. klive will then load the gds and execute other commands sent. This allows for instant displaying of GDS files from a CLI with python. - -Sometimes the reload dialog of KLayout can interfere with klive. It can optionally be turned off in `File -> Setup -> Application -> General` under the option `Check files for updates`. diff --git a/docs/source/routing/all_angle.py b/docs/source/routing/all_angle.py new file mode 100644 index 000000000..526f34273 --- /dev/null +++ b/docs/source/routing/all_angle.py @@ -0,0 +1,291 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # All-Angle Routing +# +# All-angle routing lets you connect ports along an **arbitrary backbone** — segments do +# not have to be axis-aligned. Unlike manhattan routing (which forces horizontal/vertical +# segments), all-angle routes follow any sequence of `kdb.DPoint` waypoints and insert +# euler bends at each corner. +# +# | Feature | API | +# |---|---| +# | Single route | `kf.routing.aa.optical.route(c, width, backbone, ...)` | +# | Bundle of routes | `kf.routing.aa.optical.route_bundle(c, start_ports, end_ports, backbone, ...)` | +# | Result object | `OpticalAllAngleRoute` — `.length`, `.backbone`, `.instances` | +# +# ## When to use all-angle routing +# +# * Diagonal bus sections in large photonic chips +# * Routing around non-rectilinear obstacles +# * Compact fan-outs at arbitrary angles +# +# ## Setup +# +# All-angle routing uses **virtual factories** from `kf.factories.virtual.*`. Virtual +# components live in memory only and are materialised into a real cell when +# `VInstance.insert_into(c)` is called (or when routing directly into a `KCell`). + +# %% +from functools import partial + +import numpy as np + +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +L = LAYER() +kf.kcl.infos = L + +wg_enc = kf.LayerEnclosure(name="WGSTD_AA", sections=[(L.WGCLAD, 0, 2_000)]) + +# Virtual straight: width and length in µm +straight_factory = partial( + kf.factories.virtual.straight.virtual_straight_factory(kcl=kf.kcl), + layer=L.WG, + enclosure=wg_enc, +) + +# Virtual euler bend: width and radius in µm +bend_factory = partial( + kf.factories.virtual.euler.virtual_bend_euler_factory(kcl=kf.kcl), + width=0.5, + radius=10, + layer=L.WG, + enclosure=wg_enc, +) + +# %% [markdown] +# ## 1 · Single all-angle route +# +# `kf.routing.aa.optical.route` places a single route along a **backbone** — an ordered +# list of `kdb.DPoint` values in µm. The router inserts euler bends wherever the +# backbone changes direction and fills the straight segments automatically. +# +# Rules for a valid backbone: +# +# * At least 3 points (the first and last define the port orientations) +# * Each interior segment must be long enough to fit the bend: roughly +# `2 × effective_radius` clearance on either side of a corner +# +# The returned `OpticalAllAngleRoute` exposes: +# +# * `.length` — total path length in µm +# * `.length_straights` — straight-only contribution in µm +# * `.backbone` — the original backbone points +# * `.instances` — list of placed `VInstance` objects + +# %% +backbone_single = [ + kf.kdb.DPoint(0, 0), + kf.kdb.DPoint(120, 0), # horizontal run + kf.kdb.DPoint(120, 80), # 90° turn upward + kf.kdb.DPoint(240, 80), # horizontal run to exit +] + +c_single = kf.kcl.kcell("aa_single_route") +route = kf.routing.aa.optical.route( + c_single, + width=0.5, + backbone=backbone_single, + straight_factory=straight_factory, + bend_factory=bend_factory, +) + +print(f"Total length : {route.length:.2f} µm") +print(f"Straight length: {route.length_straights:.2f} µm") +print(f"Bend count : {route.length - route.length_straights:.2f} µm in bends") + +c_single.plot() + +# %% [markdown] +# ## 2 · Diagonal backbone +# +# The backbone is not restricted to axis-aligned segments. Diagonal segments route +# through tight corridors where a manhattan route would require many detours. + +# %% +backbone_diag = [ + kf.kdb.DPoint(0, 0), + kf.kdb.DPoint(80, 60), # diagonal up-right + kf.kdb.DPoint(200, 60), # horizontal + kf.kdb.DPoint(280, 0), # diagonal back down +] + +c_diag = kf.kcl.kcell("aa_diagonal_route") +route_diag = kf.routing.aa.optical.route( + c_diag, + width=0.5, + backbone=backbone_diag, + straight_factory=straight_factory, + bend_factory=bend_factory, +) + +print(f"Total length: {route_diag.length:.2f} µm") +c_diag.plot() + +# %% [markdown] +# ## 3 · Bundle routing +# +# `kf.routing.aa.optical.route_bundle` routes **multiple ports in parallel** along a +# shared backbone. Each route in the bundle is offset from the backbone by `separation` +# (or a per-route list). +# +# **Port orientation convention** +# +# * `start_ports` — sorted **anti-clockwise** around the start cluster +# * `end_ports` — sorted **clockwise** around the end cluster +# +# This ensures that routes do not cross between the start cluster and the backbone. +# +# **Backbone requirements for bundles** +# +# The backbone must have at least 2 points (it defines the shared bundle axis). Segments +# must be long enough to accommodate the widest route in the bundle plus its separation. + +# %% +# Place start ports fanned out at various angles on the left, end ports on the right +r = 50 # radius of port fan-out cluster (µm) +n = 1 # angle step centre +_l = 5 # number of routes + +c_bundle = kf.kcl.kcell("aa_bundle_route") + +start_ports: list[kf.Port] = [] +end_ports: list[kf.Port] = [] + +for i in range(_l): + a_start = (n - i) * 15 # angles spread around centre + a_rad = np.deg2rad(a_start) + a_end = 270 - n + i * 15 + ae_rad = np.deg2rad(a_end) + + start_ports.append( + c_bundle.create_port( + name=f"s{i}", + dcplx_trans=kf.kdb.DCplxTrans( + 1, + a_start, + False, + -500 + r * np.cos(a_rad), + -100 + r * np.sin(a_rad), + ), + layer=c_bundle.kcl.find_layer(L.WG), + width=c_bundle.kcl.to_dbu(0.5), + ) + ) + end_ports.append( + c_bundle.create_port( + name=f"e{i}", + dcplx_trans=kf.kdb.DCplxTrans( + 1, + a_end, + False, + 1510 + r * np.cos(ae_rad), + 1410 + r * np.sin(ae_rad), + ), + layer=c_bundle.kcl.find_layer(L.WG), + width=c_bundle.kcl.to_dbu(0.5), + ) + ) + +# Shared backbone — each segment long enough for 10 µm-radius euler bends +backbone_bundle = [ + kf.kdb.DPoint(200, -200), + kf.kdb.DPoint(500, 300), + kf.kdb.DPoint(600, 700), + kf.kdb.DPoint(1300, 950), +] + +routes = kf.routing.aa.optical.route_bundle( + c_bundle, + start_ports=start_ports, + end_ports=end_ports, + backbone=backbone_bundle, + separation=2.0, # µm between adjacent routes + straight_factory=straight_factory, + bend_factory=bend_factory, +) + +print(f"Bundle contains {len(routes)} routes") +for i, rt in enumerate(routes): + print(f" route {i}: length = {rt.length:.2f} µm") + +c_bundle.plot() + +# %% [markdown] +# ## 4 · Using VKCell for preview before committing +# +# All-angle routing can also target a `VKCell` (virtual cell) so you can inspect or +# transform the result before materialising it into a real cell with +# `VInstance.insert_into`. + +# %% +vc = kf.kcl.vkcell(name="aa_virtual") +kf.routing.aa.optical.route( + vc, + width=0.5, + backbone=[ + kf.kdb.DPoint(0, 0), + kf.kdb.DPoint(100, 0), + kf.kdb.DPoint(100, 80), + kf.kdb.DPoint(200, 80), + ], + straight_factory=straight_factory, + bend_factory=bend_factory, +) + +# Materialise: place the virtual route into a real KCell +c_real = kf.kcl.kcell("aa_from_virtual") +vi = kf.VInstance(vc) +vi.insert_into(c_real) +c_real.plot() + +# %% [markdown] +# ## Summary +# +# | Parameter | Type | Unit | Notes | +# |---|---|---|---| +# | `backbone` | `list[kdb.DPoint]` | µm | ≥3 points for single route, ≥2 for bundle | +# | `width` | `float` | µm | route width (single route only) | +# | `separation` | `float` or `list[float]` | µm | spacing between bundle routes | +# | `straight_factory` | callable | — | `virtual_straight_factory` partial | +# | `bend_factory` | callable | — | `virtual_bend_euler_factory` partial | +# +# **Key constraints** +# +# * Each backbone segment must be long enough to fit the euler bends on both ends +# (~2 × effective radius per corner). +# * For bundles, segments must also accommodate the full bundle width +# (`N × (wire_width + separation)`). +# * `start_ports` must be sorted anti-clockwise; `end_ports` clockwise. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Manhattan (90°/180° bend) routing | [Routing: Optical](optical.py) | +# | Virtual cells used as intermediate routing containers | [Components: Virtual Cells](../components/cells/virtual.py) | +# | Euler bend cells and `virtual_bend_euler_factory` | [Components: Euler Bends](../components/cells/factories/euler.py) | +# | Routing overview and choosing an approach | [Routing: Overview](overview.py) | +# | Port construction and `DCplxTrans` | [Core Concepts: Ports](../concepts/ports.py) | diff --git a/docs/source/routing/bundle.py b/docs/source/routing/bundle.py new file mode 100644 index 000000000..9542a07ce --- /dev/null +++ b/docs/source/routing/bundle.py @@ -0,0 +1,486 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Bundle Routing — Tutorial +# +# `route_bundle` routes *N* start ports to *N* end ports, maintaining spacing and +# avoiding obstacles. This page covers parameters that are not shown in the +# [Routing Overview](overview.py) or [Optical Routing Deep Dive](optical.py). +# +# | Topic | API | +# |---|---| +# | Automatic port sorting | `sort_ports=True` | +# | Per-bundle separation | `separation=` (DBU / µm) | +# | S-bend compact routing | `sbend_factory=` | +# | Bounding-box strategy | `bbox_routing='minimal'` / `'full'` | +# | Obstacle avoidance | `bboxes=[...]`, `collision_check_layers=` | +# | Mismatch tolerance | `allow_width_mismatch`, `allow_type_mismatch` | +# +# ## Setup + +# %% +from functools import partial + +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + METAL: kf.kdb.LayerInfo = kf.kdb.LayerInfo(20, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +WG_WIDTH = kf.kcl.to_dbu(0.5) # 500 DBU +SEP = kf.kcl.to_dbu(2.0) # 2 µm centre-to-centre extra separation + +wg_enc = kf.kcl.get_enclosure( + kf.LayerEnclosure(name="WGSTD_BND", sections=[(L.WGCLAD, 0, 2_000)]) +) + +bend90 = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, radius=10, layer=L.WG, enclosure=wg_enc, angle=90 +) +straight_factory = partial( + kf.factories.straight.straight_dbu_factory(kcl=kf.kcl), + layer=L.WG, + enclosure=wg_enc, +) +bend_radius = kf.routing.optical.get_radius(bend90) + +wl = kf.kcl.find_layer(L.WG) + +# %% [markdown] +# ## 1 · Port sorting — `sort_ports=True` +# +# By default `route_bundle` pairs `start_ports[i]` with `end_ports[i]`. If the two +# lists are in opposite spatial orders (e.g., start ports run bottom-to-top while end +# ports run top-to-bottom), routes will cross each other. +# +# Setting `sort_ports=True` re-orders both lists by position so that the port closest +# to the bottom on the start side connects to the port closest to the bottom on the +# end side — eliminating crossings. + +# %% +# Start ports are ordered top-to-bottom; end ports are ordered bottom-to-top. +# Without sort_ports the routes would cross. + + +@kf.cell +def bundle_sorted() -> kf.KCell: + c = kf.KCell() + + start_ports = [ + c.create_port( + name=f"in{i}", + trans=kf.kdb.Trans(2, False, -60_000, (2 - i) * 10_000), # reversed Y + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + for i in range(3) + ] + end_ports = [ + c.create_port( + name=f"out{i}", + trans=kf.kdb.Trans(0, False, 60_000, i * 10_000), # normal Y + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + for i in range(3) + ] + + # Use a wider local separation matching the 10µm port pitch — the + # global SEP=2µm would require the router to bend each route to + # compress the bundle, which collides with adjacent route bends. + kf.routing.optical.route_bundle( + c, + start_ports=start_ports, + end_ports=end_ports, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, + sort_ports=True, # ← automatically matches by Y position + ) + return c + + +c_sorted = bundle_sorted() +c_sorted.plot() + +# %% [markdown] +# Without `sort_ports=True` the same port lists would produce three crossing routes. +# Sorting resolves the crossing by pairing ports at the same relative position. + +# %% [markdown] +# ## 2 · Separation between routes +# +# `separation` sets the **minimum centre-to-centre distance** between adjacent routes +# in the bundle. Increase it to open up more space between waveguides, for example +# near dense arrays where cladding layers would otherwise overlap. + + +# %% +@kf.cell +def bundle_wide_sep() -> kf.KCell: + c = kf.KCell() + N = 4 + for i in range(N): + c.create_port( + name=f"in{i}", + trans=kf.kdb.Trans(2, False, -60_000, i * 4_000), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + c.create_port( + name=f"out{i}", + trans=kf.kdb.Trans(0, False, 60_000, i * 4_000), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + + kf.routing.optical.route_bundle( + c, + start_ports=list(c.ports.filter(port_type="optical", regex="^in")), + end_ports=list(c.ports.filter(port_type="optical", regex="^out")), + separation=kf.kcl.to_dbu(5.0), # 5 µm — wider than the default 2 µm + straight_factory=straight_factory, + bend90_cell=bend90, + ) + return c + + +bundle_wide_sep().plot() + +# %% [markdown] +# ## 3 · S-bend factory for compact fan-in / fan-out +# +# When start and end ports face **each other** (e.g. East-facing starts and +# West-facing ends) but their y-coordinates do not line up, a normal 90°-bend +# router has to insert two right-angle bends per route — producing a large +# vertical detour. Passing an `sbend_factory` lets the router substitute a +# compact Euler S-bend for the y mismatch instead. +# +# The router calls the factory with `(c, offset, length, width)` and expects +# back an `InstanceGroup` exposing ports `o1` (left) and `o2` (right). The +# example below wraps `kf.cells.euler.bend_s_euler` to match that protocol +# and pads with a short straight if the requested `length` exceeds the +# S-bend's own footprint. + +# %% +from typing import Any + + +def sbend_factory( + c: kf.ProtoTKCell[Any], + offset: int, + length: int, + width: int, +) -> kf.InstanceGroup: + """SBendFactoryDBU wrapper around `bend_s_euler` + an optional straight.""" + ig = kf.InstanceGroup() + + sbend = c << kf.cells.euler.bend_s_euler( + offset=c.kcl.to_um(offset), + width=c.kcl.to_um(width), + radius=10, + layer=L.WG, + enclosure=wg_enc, + ) + ig.add(sbend) + ig.add_port(name="o1", port=sbend.ports["o1"]) + + pad = length - sbend.ibbox().width() + if pad < 0: + raise ValueError( + f"sbend_factory: requested length={length} dbu is shorter than" + f" the S-bend footprint ({sbend.ibbox().width()} dbu) for" + f" offset={offset} dbu — no room for the S-bend." + ) + if pad > 0: + wg = c << straight_factory(width=width, length=pad) + ig.add(wg) + wg.connect("o1", sbend.ports["o2"]) + ig.add_port(name="o2", port=wg.ports["o2"]) + else: + ig.add_port(name="o2", port=sbend.ports["o2"]) + + return ig + + +@kf.cell +def bundle_sbend() -> kf.KCell: + """Three routes with mismatched y on each side — forces S-bends.""" + c = kf.KCell() + # Start ports face East (angle=0) at x=0. + # End ports face West (angle=2) at x=200 µm. + # Start and end y-coordinates differ by a few µm, so each route needs an + # S-bend to bridge the y mismatch — that is what `sbend_factory` provides. + start_y_um = [0, 50, 100] + end_y_um = [5, 60, 200] + + start_ports = [ + c.create_port( + name=f"in{i}", + trans=kf.kdb.Trans(0, False, 0, kf.kcl.to_dbu(y)), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + for i, y in enumerate(start_y_um) + ] + end_ports = [ + c.create_port( + name=f"out{i}", + trans=kf.kdb.Trans(2, False, kf.kcl.to_dbu(200), kf.kcl.to_dbu(y)), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + for i, y in enumerate(end_y_um) + ] + + kf.routing.optical.route_bundle( + c, + start_ports=start_ports, + end_ports=end_ports, + separation=SEP, + straight_factory=straight_factory, + bend90_cell=bend90, + sbend_factory=sbend_factory, # ← enables S-bend optimisation + ) + return c + + +bundle_sbend().plot() + +# %% [markdown] +# ## 4 · Bounding-box routing strategy — `bbox_routing` +# +# When obstacle boxes (`bboxes=`) are present the router must decide how far around +# them to detour. +# +# | Value | Behaviour | +# |---|---| +# | `'minimal'` (default) | Each route takes the shortest path around the obstacle. | +# | `'full'` | All routes share a single bounding path — the bundle stays intact as it detours. | +# +# `'full'` produces cleaner layouts when you want the bundle to remain grouped around +# obstacles; `'minimal'` produces tighter total area when individual routes can fan +# around obstacles independently. + +# %% +# Each route gets its own obstacle bbox. An obstacle is treated as a +# keep-out region around the port(s) it contains: `route_smart` groups +# ports that fall *inside* the bbox and routes those routers as one bundle +# around it. For that to engage, the bbox must extend past the port +# column on both sides by at least `bend_radius`: +# `xmin < ports.xmin - bend_radius` and `xmax > ports.xmax + bend_radius`. +# +# The y range of each bbox is kept narrower than the port pitch so each +# bbox only contains its own start and end port — adjacent routes' ports +# are excluded, demonstrating that `route_smart` can handle per-route +# obstacles independently. +N_BBOX = 4 +PITCH_BBOX = 3_000 +HALF_Y_BBOX = 1_000 # < PITCH/2 so the bbox excludes adjacent ports +blockers = [ + kf.kdb.Box( + -80_000, + i * PITCH_BBOX - HALF_Y_BBOX, + 80_000, + i * PITCH_BBOX + HALF_Y_BBOX, + ) + for i in range(N_BBOX) +] + + +@kf.cell +def bundle_bbox_minimal() -> kf.KCell: + c = kf.KCell() + for i in range(N_BBOX): + c.create_port( + name=f"in{i}", + trans=kf.kdb.Trans(2, False, -60_000, i * PITCH_BBOX), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + c.create_port( + name=f"out{i}", + trans=kf.kdb.Trans(0, False, 60_000, i * PITCH_BBOX), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + + # Draw each obstacle on the floorplan layer so they are visible alongside + # the routed waveguides. + for blocker in blockers: + c.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert(blocker) + + kf.routing.optical.route_bundle( + c, + start_ports=list(c.ports.filter(port_type="optical", regex="^in")), + end_ports=list(c.ports.filter(port_type="optical", regex="^out")), + separation=SEP, + straight_factory=straight_factory, + bend90_cell=bend90, + bboxes=blockers, + bbox_routing="minimal", + ) + return c + + +@kf.cell +def bundle_bbox_full() -> kf.KCell: + c = kf.KCell() + for i in range(N_BBOX): + c.create_port( + name=f"in{i}", + trans=kf.kdb.Trans(2, False, -60_000, i * PITCH_BBOX), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + c.create_port( + name=f"out{i}", + trans=kf.kdb.Trans(0, False, 60_000, i * PITCH_BBOX), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + + for blocker in blockers: + c.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert(blocker) + + kf.routing.optical.route_bundle( + c, + start_ports=list(c.ports.filter(port_type="optical", regex="^in")), + end_ports=list(c.ports.filter(port_type="optical", regex="^out")), + separation=SEP, + straight_factory=straight_factory, + bend90_cell=bend90, + bboxes=blockers, + bbox_routing="full", + ) + return c + + +bundle_bbox_minimal().plot() + +# %% +bundle_bbox_full().plot() + +# %% [markdown] +# ## 5 · Mismatch tolerance flags +# +# `route_bundle` checks that paired ports share the same width, layer, and port type. +# Relax individual checks when connecting different structures: +# +# | Flag | Effect | +# |---|---| +# | `allow_width_mismatch=True` | Accept ports with different widths | +# | `allow_layer_mismatch=True` | Accept ports on different layers | +# | `allow_type_mismatch=True` | Accept ports with different `port_type` strings | +# +# These flags do not insert tapers — the route simply uses the width/layer of the +# first (start) port. Insert explicit `taper_cell` for mode-converting transitions. + + +# %% +@kf.cell +def bundle_type_mismatch() -> kf.KCell: + """Connect 'optical' start ports to 'pin' end ports.""" + c = kf.KCell() + N = 2 + + start_ports = [ + c.create_port( + name=f"wg{i}", + # East-facing start ports — routes will head east toward the end ports + trans=kf.kdb.Trans(0, False, -50_000, i * 10_000), + width=WG_WIDTH, + layer=wl, + port_type="optical", + ) + for i in range(N) + ] + # End ports face West so the bundle goes straight across. They also carry + # a different `port_type` to exercise `allow_type_mismatch=True`. + end_ports = [ + c.create_port( + name=f"pin{i}", + trans=kf.kdb.Trans(2, False, 50_000, i * 10_000), + width=WG_WIDTH, + layer=wl, + port_type="pin", + ) + for i in range(N) + ] + + kf.routing.optical.route_bundle( + c, + start_ports=start_ports, + end_ports=end_ports, + separation=SEP, + straight_factory=straight_factory, + bend90_cell=bend90, + allow_type_mismatch=True, # ← suppress port_type equality check + ) + return c + + +bundle_type_mismatch().plot() + +# %% [markdown] +# ## Parameter quick-reference +# +# | Parameter | Type | Default | Notes | +# |---|---|---|---| +# | `separation` | DBU / µm | — | Min centre-to-centre spacing | +# | `sort_ports` | `bool` | `False` | Sort both lists by position before pairing | +# | `sbend_factory` | factory or `None` | `None` | Use S-bends for compact lateral shifts | +# | `bbox_routing` | `'minimal'` / `'full'` | `'minimal'` | How the bundle detours around `bboxes` | +# | `bboxes` | `list[kdb.Box]` | `None` | Physical obstacles to route around | +# | `collision_check_layers` | `list[LayerInfo]` | `None` | Layers checked for geometric overlap | +# | `on_collision` | `'error'` / `'show_error'` / `None` | `'show_error'` | Action on collision | +# | `allow_width_mismatch` | `bool` | `None` | Skip width equality check | +# | `allow_layer_mismatch` | `bool` | `None` | Skip layer equality check | +# | `allow_type_mismatch` | `bool` | `None` | Skip port_type equality check | +# | `route_width` | DBU / `list[DBU]` | `None` | Override wire width per route | +# | `starts` / `ends` | DBU / steps | `None` | Entry/exit stub lengths (see [optical.py](optical.py)) | +# | `waypoints` | `Trans` / `list[Point]` | `None` | Force routes through a point (see [optical.py](optical.py)) | +# | `path_length_matching_config` | `PathLengthConfig` | `None` | Equal-length routing (see [optical.py](optical.py)) | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Single-route optical options: waypoints, loopbacks, stubs | [Routing: Optical](optical.py) | +# | Electrical bundle routing | [Routing: Electrical](electrical.py) | +# | Equal path-length loops inside a bundle | [Routing: Path Length](path_length.py) | +# | Manhattan backbone that bundle routing uses internally | [Routing: Manhattan](manhattan.py) | +# | Routing overview and sub-module map | [Routing: Overview](overview.py) | +# | Port sorting and orientation | [Core Concepts: Ports](../concepts/ports.py) | diff --git a/docs/source/routing/electrical.py b/docs/source/routing/electrical.py new file mode 100644 index 000000000..9300de6f0 --- /dev/null +++ b/docs/source/routing/electrical.py @@ -0,0 +1,378 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Electrical Routing — Deep Dive +# +# This page covers electrical (metal-wire) routing in kfactory. +# For a quick intro see [Routing Overview](overview.py). +# +# Electrical routes are plain **Manhattan wires** — no bend cells, no radii. +# The router places a filled rectangular path along the backbone and supports all the +# same waypoint / stub / obstacle-avoidance features as optical routing. +# +# | Topic | API | +# |---|---| +# | Basic wire bundle | `kf.routing.electrical.route_bundle(c, starts, ends, sep, place_layer=...)` | +# | Per-route wire width | `route_bundle(..., route_width=...)` | +# | Entry / exit stubs | `route_bundle(..., starts=..., ends=...)` | +# | Waypoints | `route_bundle(..., waypoints=...)` | +# | Obstacle avoidance | `route_bundle(..., bboxes=[...])` | +# | Coplanar / dual-rail | `kf.routing.electrical.route_bundle_dual_rails(...)` | +# +# ## Setup +# +# All coordinates in `KCell`-based APIs are in **DBU** (database units). +# With the default `dbu = 0.001 µm/DBU`, 1 µm = 1 000 DBU. +# Use `kf.kcl.to_dbu(x_µm)` to convert. + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + METAL1: kf.kdb.LayerInfo = kf.kdb.LayerInfo(11, 0) + METAL2: kf.kdb.LayerInfo = kf.kdb.LayerInfo(12, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(99, 0) + + +L = LAYER() +kf.kcl.infos = L + +# Typical metal wire: 2 µm wide, 2 µm separation between adjacent routes +WIRE_WIDTH = kf.kcl.to_dbu(2) # 2_000 DBU +SEPARATION = kf.kcl.to_dbu(2) # 2_000 DBU + +# %% [markdown] +# ## 1 · Basic wire bundle +# +# `route_bundle` connects *N* start ports to *N* end ports with straight-corner wires. +# Supply `place_layer` to draw all wires on a specific layer. +# +# Port angles follow the KLayout integer convention: +# `0` = East, `1` = North, `2` = West, `3` = South. +# The most common pattern is start facing North (`angle=1`) and end facing South +# (`angle=3`) with end ports above start ports, producing S-shaped wires. + +# %% +c_basic = kf.KCell("elec_basic_bundle") + +n = 4 +starts_basic = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 10), 0), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(n) +] +ends_basic = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(i * 10), kf.kcl.to_dbu(150)), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(n) +] + +kf.routing.electrical.route_bundle( + c_basic, + starts_basic, + ends_basic, + separation=SEPARATION, + place_layer=L.METAL1, +) +c_basic + +# %% [markdown] +# ## 2 · Overriding wire width with `route_width` +# +# By default each route inherits the width of its start port. Pass `route_width` (in +# DBU) to draw all wires at a uniform width, or pass a list to set per-route widths. +# This is useful when routing between ports of different widths or when the route must +# be narrower than the pad. + +# %% +c_rw = kf.KCell("elec_route_width") + +starts_rw = [ + kf.Port( + name=f"pad_{i}", + # Wide pads: 10 µm + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 20), 0), + width=kf.kcl.to_dbu(10), + layer_info=L.METAL1, + ) + for i in range(3) +] +ends_rw = [ + kf.Port( + name=f"wire_{i}", + # Narrow landing pads: 2 µm + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(i * 20), kf.kcl.to_dbu(120)), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(3) +] + +# Route the wire at 1 µm regardless of the port widths. +kf.routing.electrical.route_bundle( + c_rw, + starts_rw, + ends_rw, + separation=SEPARATION, + place_layer=L.METAL1, + route_width=kf.kcl.to_dbu(1), +) +c_rw + +# %% [markdown] +# ## 3 · Entry and exit stubs — `starts` and `ends` +# +# `starts` / `ends` force each route to travel straight for a given distance before the +# router takes over. A scalar applies the same stub to all routes; a list provides +# per-route control (all values in DBU). +# +# Stubs are useful for: +# - maintaining clearance from a dense pad array before the wires fan out, or +# - ensuring a minimum straight segment from a component port before turning. + +# %% +c_stubs = kf.KCell("elec_stubs") + +starts_stub = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 12), 0), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(4) +] +ends_stub = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(i * 12), kf.kcl.to_dbu(200)), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(4) +] + +kf.routing.electrical.route_bundle( + c_stubs, + starts_stub, + ends_stub, + separation=SEPARATION, + place_layer=L.METAL1, + starts=kf.kcl.to_dbu(20), # 20 µm straight after each start port + ends=kf.kcl.to_dbu(10), # 10 µm straight before each end port +) +c_stubs + +# %% [markdown] +# ## 4 · Waypoints — routing through a fixed corridor +# +# Waypoints force all routes to converge at a specific position and direction before +# continuing to their end ports. +# +# - Pass a `kdb.Trans` to specify a single convergence point with a direction (angle +# encoded as `0`=East, `1`=North, `2`=West, `3`=South). +# - Pass a `list[kdb.Point]` to define a multi-point corridor (direction inferred from +# the first two points). +# +# The example below routes 4 wires North from their start pads, forces them through a +# narrow East-facing corridor in the middle of the layout, then continues up to the +# end ports. + +# %% +c_wp = kf.KCell("elec_waypoints") + +starts_wp = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 15), 0), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(4) +] +ends_wp = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(i * 15), kf.kcl.to_dbu(300)), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(4) +] + +# All 4 wires must pass through (x=100 µm, y=150 µm) heading East. +kf.routing.electrical.route_bundle( + c_wp, + starts_wp, + ends_wp, + separation=SEPARATION, + place_layer=L.METAL1, + waypoints=kf.kdb.Trans(0, False, kf.kcl.to_dbu(100), kf.kcl.to_dbu(150)), +) +c_wp + +# %% [markdown] +# ## 5 · Obstacle avoidance +# +# Pass a list of `kdb.Box` objects as `bboxes` to mark keep-out regions. +# The router automatically routes around them. +# +# Obstacle boxes are in DBU and are defined as `kdb.Box(left, bottom, right, top)`. +# +# > **Caveat**: the obstacle bbox must overlap (or touch) the bundle's own +# > bounding box — an isolated box floating in empty space between the bundles +# > is treated as if it weren't there. Place the obstacle so it covers at +# > least one of the start- or end-port regions. + +# %% +c_obs = kf.KCell("elec_obstacle") + +# North-facing start ports at the bottom; East-facing end ports on the right. +# The bend the router must make to get from "north" to "east" gives the +# obstacle something meaningful to deflect. +starts_obs = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 20), 0), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(3) +] +ends_obs = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(0, False, kf.kcl.to_dbu(120), kf.kcl.to_dbu(40 + i * 20)), + width=WIRE_WIDTH, + layer_info=L.METAL1, + ) + for i in range(3) +] + +# Keep-out box covering the left-most start port so the router must detour +# around it. An obstacle floating in the middle (untouched by either port +# group) would not affect the route. +obstacle = kf.kdb.Box( + kf.kcl.to_dbu(-10), + kf.kcl.to_dbu(-5), + kf.kcl.to_dbu(30), + kf.kcl.to_dbu(60), +) +# Draw the obstacle on the floorplan layer for visibility. +c_obs.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert(obstacle) + +kf.routing.electrical.route_bundle( + c_obs, + starts_obs, + ends_obs, + separation=SEPARATION, + place_layer=L.METAL1, + bboxes=[obstacle], +) +c_obs + +# %% [markdown] +# ## 6 · Dual-rail routing +# +# `route_bundle_dual_rails` draws each wire as two parallel rails with a gap in between +# — a common pattern for coplanar waveguide (CPW) transmission lines or differential +# signal pairs. +# +# Key parameters (all in DBU): +# +# | Parameter | Meaning | +# |---|---| +# | `separation` | Minimum edge-to-edge spacing between adjacent *routes* | +# | `width_rails` | Total outer width of the hollow path (rail + gap + rail) | +# | `separation_rails` | Width of the inner gap cutout (must be < `width_rails`) | +# +# For two 1 µm rails separated by a 2 µm gap, set +# `width_rails = 4 µm` (total = rail + gap + rail) and `separation_rails = 2 µm`. + +# %% +c_dr = kf.KCell("elec_dual_rails") + +# Total outer width: rail(1µm) + gap(2µm) + rail(1µm) = 4µm +DR_TOTAL = kf.kcl.to_dbu(4) # outer path width +DR_GAP = kf.kcl.to_dbu(2) # inner hollow width (the gap between rails) + +starts_dr = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 20), 0), + width=DR_TOTAL, + layer_info=L.METAL1, + ) + for i in range(3) +] +ends_dr = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(i * 20), kf.kcl.to_dbu(200)), + width=DR_TOTAL, + layer_info=L.METAL1, + ) + for i in range(3) +] + +kf.routing.electrical.route_bundle_dual_rails( + c_dr, + starts_dr, + ends_dr, + separation=kf.kcl.to_dbu(5), + place_layer=L.METAL1, + width_rails=DR_TOTAL, + separation_rails=DR_GAP, +) +c_dr + +# %% [markdown] +# ## Summary +# +# | Need | Parameter | +# |---|---| +# | Basic wire bundle | `route_bundle(c, starts, ends, sep, place_layer=L.METAL1)` | +# | Uniform wire width | `route_width=kf.kcl.to_dbu(w_µm)` | +# | Per-route widths | `route_width=[w1_dbu, w2_dbu, ...]` | +# | Minimum straight before turn | `starts=dbu_length` (scalar or list) | +# | Route through fixed corridor | `waypoints=kdb.Trans(angle, flip, x, y)` | +# | Avoid keep-out regions | `bboxes=[kdb.Box(l, b, r, t)]` | +# | Coplanar / differential pair | `route_bundle_dual_rails(..., width_rails=..., separation_rails=...)` | +# +# All coordinates in `KCell`-based APIs are in **DBU** (1 nm = 1 DBU with default +# `dbu=0.001`). Convert with `kf.kcl.to_dbu(x_µm)`. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Routing overview (optical + electrical entry point) | [Routing: Overview](overview.py) | +# | Bundle routing details: sort, sbend, bbox modes | [Routing: Bundle](bundle.py) | +# | Optical waveguide routing | [Routing: Optical](optical.py) | +# | Layer definitions and metal layers | [Core Concepts: Layers](../concepts/layers.py) | +# | Cross-sections for wire geometry | [Cross-Sections](../components/cross_sections.py) | +# | Port construction and connection | [Core Concepts: Ports](../concepts/ports.py) | diff --git a/docs/source/routing/manhattan.py b/docs/source/routing/manhattan.py new file mode 100644 index 000000000..38d0f26d5 --- /dev/null +++ b/docs/source/routing/manhattan.py @@ -0,0 +1,330 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Manhattan Routing Primitives +# +# This page covers the low-level building blocks that power all of kfactory's +# manhattan (right-angle) routing. `route_bundle` wraps these internally — read +# this page when you need fine-grained control over backbone paths or want to +# understand how routing works under the hood. +# +# | Function | Purpose | +# |---|---| +# | `kf.routing.manhattan.route_manhattan` | Calculate a 2-port backbone (list of `kdb.Point`) | +# | `kf.routing.manhattan.route_manhattan_180` | Backbone for a U-turn between two co-directional ports | +# | `kf.routing.optical.place_manhattan` | Materialise a backbone into waveguide geometry | +# | `kf.routing.steps.*` | Step objects to control entry / exit behaviour | +# +# ## What is a backbone? +# +# A **backbone** is a list of `kdb.Point` objects (DBU coordinates) that describe the +# centreline of a route — the corners where bends will be placed. Separating path +# *calculation* from *placement* lets you inspect or manipulate the route before +# committing geometry to a cell. +# +# ## Setup + +# %% +from functools import partial + +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +L = LAYER() +kf.kcl.infos = L + +# Euler bend: width=0.5 µm, nominal radius=10 µm. +# The actual routing radius is larger than the nominal — always use get_radius(). +wg_enc = kf.kcl.get_enclosure( + kf.LayerEnclosure(name="WGSTD", sections=[(L.WGCLAD, 0, 2_000)]) +) +bend90 = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, + radius=10, + layer=L.WG, + enclosure=wg_enc, + angle=90, +) +straight_factory = partial( + kf.factories.straight.straight_dbu_factory(kcl=kf.kcl), + layer=L.WG, + enclosure=wg_enc, +) + +WG_WIDTH = kf.kcl.to_dbu(0.5) # 500 DBU +BEND_RADIUS = kf.routing.optical.get_radius(bend90) # actual footprint radius in DBU + +print(f"WG width: {WG_WIDTH} DBU ({WG_WIDTH / 1000:.3f} µm)") +print(f"Bend radius: {BEND_RADIUS} DBU ({BEND_RADIUS / 1000:.3f} µm)") + +# %% [markdown] +# ## 1 · `route_manhattan` — backbone calculation +# +# `route_manhattan(port1, port2, bend90_radius)` returns the shortest set of +# axis-aligned waypoints connecting two ports. No geometry is created yet. +# +# The `bend90_radius` must be the **actual footprint** radius of the bend cell +# (use `kf.routing.optical.get_radius(bend_cell)`). Passing the nominal radius +# of an euler bend will produce collisions because the euler footprint is larger. + +# %% +# Port 1: North-facing at origin +# Port 2: South-facing offset to the right and above +p_start = kf.Port( + name="p1", + trans=kf.kdb.Trans(1, False, 0, 0), # angle=1 → North + width=WG_WIDTH, + layer_info=L.WG, +) +p_end = kf.Port( + name="p2", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(80), kf.kcl.to_dbu(300)), # South + width=WG_WIDTH, + layer_info=L.WG, +) + +backbone = kf.routing.manhattan.route_manhattan( + p_start, p_end, bend90_radius=BEND_RADIUS +) +print("Backbone points (DBU):", backbone) +print("Backbone points (µm): ", [(p.x / 1000, p.y / 1000) for p in backbone]) + +# %% [markdown] +# ## 2 · `place_manhattan` — materialise geometry +# +# `place_manhattan(c, p1, p2, pts, ...)` walks the backbone, inserting bend and +# straight cells at each waypoint. The result is placed inside cell `c`. + +# %% +c_basic = kf.KCell("manhattan_basic") + +kf.routing.optical.place_manhattan( + c_basic, + p_start, + p_end, + backbone, + straight_factory=straight_factory, + bend90_cell=bend90, +) +c_basic + +# %% [markdown] +# ## 3 · Entry / exit stubs with Steps +# +# Steps let you force a minimum straight section **before** the router takes its +# first bend (`start_steps`) or **after** the last bend (`end_steps`). +# +# Common step types: +# +# | Class | Effect | +# |---|---| +# | `Straight(dist=N)` | Go straight for N DBU | +# | `Left()` | Turn left, then continue | +# | `Right()` | Turn right, then continue | +# +# Import from `kfactory.routing.steps`: + +# %% +from kfactory.routing.steps import Left, Straight + +# Force a 30 µm straight stub at both ends before bending +backbone_stubs = kf.routing.manhattan.route_manhattan( + p_start, + p_end, + bend90_radius=BEND_RADIUS, + start_steps=[Straight(dist=kf.kcl.to_dbu(30))], + end_steps=[Straight(dist=kf.kcl.to_dbu(30))], +) +print( + "Backbone with 30 µm stubs (µm):", + [(p.x / 1000, p.y / 1000) for p in backbone_stubs], +) + +c_stubs = kf.KCell("manhattan_stubs") +kf.routing.optical.place_manhattan( + c_stubs, + p_start, + p_end, + backbone_stubs, + straight_factory=straight_factory, + bend90_cell=bend90, +) +c_stubs + +# %% [markdown] +# Forcing a left turn exit before bending: + +# %% +# Start facing East, then force an immediate left (North) exit stub before routing +p_east = kf.Port( + name="east_start", + trans=kf.kdb.Trans(0, False, 0, 0), # angle=0 → East + width=WG_WIDTH, + layer_info=L.WG, +) +p_north_end = kf.Port( + name="north_end", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(200), kf.kcl.to_dbu(200)), + width=WG_WIDTH, + layer_info=L.WG, +) + +backbone_left = kf.routing.manhattan.route_manhattan( + p_east, + p_north_end, + bend90_radius=BEND_RADIUS, + start_steps=[Left(), Straight(dist=kf.kcl.to_dbu(20))], +) + +c_left = kf.KCell("manhattan_left_exit") +kf.routing.optical.place_manhattan( + c_left, + p_east, + p_north_end, + backbone_left, + straight_factory=straight_factory, + bend90_cell=bend90, +) +c_left + +# %% [markdown] +# ## 4 · `route_manhattan_180` — U-turn routing +# +# When two co-directional ports sit close together with one **behind** the +# other, the route must turn through 180°. `route_manhattan_180` produces a +# compact U-shaped backbone whose two parallel legs are spaced +# `bend180_radius` apart. +# +# For a real 180° U-turn the second port must be: +# +# - **behind** the first port by at least `3 × BEND_RADIUS` in the port's +# backward direction (here: `-x`, since the ports face East), and +# - laterally offset by `BEND_RADIUS < |y_offset| < 2 × BEND_RADIUS` — +# tight enough that the route cannot route past with a simple S-bend. +# +# Outside this window the function falls back to a regular Manhattan route +# with no actual 180° turn. +# +# Parameters (all in **DBU**): +# +# - `bend90_radius` — footprint radius for 90° bends (use `get_radius`) +# - `bend180_radius` — distance between the two parallel U-turn legs +# - `start_straight` / `end_straight` — minimum entry / exit straight lengths + +# %% +# Two East-facing ports. p_b sits behind p_a (negative x) by 3 × BEND_RADIUS +# and is offset in y by 1.5 × BEND_RADIUS — within the (BEND_RADIUS, +# 2 × BEND_RADIUS) window that forces a real 180° turn. +y_sep = BEND_RADIUS + BEND_RADIUS // 2 +x_back = -3 * BEND_RADIUS + +p_a = kf.Port( + name="a", + trans=kf.kdb.Trans(0, False, 0, 0), # East at origin + width=WG_WIDTH, + layer_info=L.WG, +) +p_b = kf.Port( + name="b", + trans=kf.kdb.Trans(0, False, x_back, y_sep), # East, behind and offset + width=WG_WIDTH, + layer_info=L.WG, +) + +backbone_180 = kf.routing.manhattan.route_manhattan_180( + p_a, + p_b, + bend90_radius=BEND_RADIUS, + bend180_radius=y_sep, # gap between the parallel U-turn legs + start_straight=kf.kcl.to_dbu(5), + end_straight=kf.kcl.to_dbu(5), +) +print("U-turn backbone (µm):", [(p.x / 1000, p.y / 1000) for p in backbone_180]) + +c_180 = kf.KCell("manhattan_180_uturn") +kf.routing.optical.place_manhattan( + c_180, + p_a, + p_b, + backbone_180, + straight_factory=straight_factory, + bend90_cell=bend90, +) +c_180 + +# %% [markdown] +# ## 5 · Inspecting `ManhattanRoute` +# +# `place_manhattan` returns a `ManhattanRoute` object. You can query it for +# inserted instances and length information. + +# %% +c_info = kf.KCell("manhattan_info") +p1_info = kf.Port( + name="o1", + trans=kf.kdb.Trans(1, False, 0, 0), + width=WG_WIDTH, + layer_info=L.WG, +) +p2_info = kf.Port( + name="o2", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(60), kf.kcl.to_dbu(250)), + width=WG_WIDTH, + layer_info=L.WG, +) + +pts_info = kf.routing.manhattan.route_manhattan( + p1_info, p2_info, bend90_radius=BEND_RADIUS +) +route = kf.routing.optical.place_manhattan( + c_info, + p1_info, + p2_info, + pts_info, + straight_factory=straight_factory, + bend90_cell=bend90, +) + +print(f"Route length: {route.length / 1000:.2f} µm") +print(f"Num instances: {len(route.instances)}") +c_info + +# %% [markdown] +# ## Key pitfalls +# +# | Pitfall | Fix | +# |---|---| +# | Passing nominal bend radius to `route_manhattan` | Use `kf.routing.optical.get_radius(bend_cell)` — euler bends extend beyond their nominal radius | +# | `place_manhattan` raises "distance too small" | The backbone segments are shorter than the bend footprint; increase port separation or use a smaller bend | +# | Steps with `dist` smaller than `bend90_radius` | The step must be at least as large as the bend radius; `Straight(dist=...)` will raise a `ValueError` if violated | +# | Forgetting that all coordinates are DBU | Multiply µm values by 1000 (or use `kf.kcl.to_dbu(x_µm)`) | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | High-level optical routing (`route_bundle`, loopbacks) | [Routing: Optical](optical.py) | +# | Bundle routing built on top of manhattan | [Routing: Bundle](bundle.py) | +# | Euler bend cells and effective radius | [Components: Euler Bends](../components/cells/factories/euler.py) | +# | Port direction conventions (angle 0/1/2/3) | [Core Concepts: Ports](../concepts/ports.py) | +# | DBU vs µm coordinate systems | [Core Concepts: DBU vs µm](../concepts/dbu_vs_um.py) | diff --git a/docs/source/routing/optical.py b/docs/source/routing/optical.py new file mode 100644 index 000000000..62671c06d --- /dev/null +++ b/docs/source/routing/optical.py @@ -0,0 +1,428 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Optical Routing — Deep Dive +# +# This page covers advanced optical routing features. +# For a quick intro see [Routing Overview](overview.py). +# +# | Topic | API | +# |---|---| +# | Waypoints | `route_bundle(..., waypoints=[...])` | +# | Entry / exit stubs | `route_bundle(..., starts=..., ends=...)` | +# | Path-length matching | `route_bundle(..., constraints=[kf.PathLengthMatch(...)], route_name=...)` | +# | Loopback — inside variant | `route_loopback(..., inside=True)` | +# | Direct backbone placement | `place_manhattan(c, p1, p2, pts, ...)` | +# +# ## Setup +# +# All factories and ports must share the same `KCLayout` instance — here the global `kf.kcl`. + +# %% +from functools import partial + +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +wg_enc = kf.kcl.get_enclosure( + kf.LayerEnclosure(name="WGSTD_OPT", sections=[(L.WGCLAD, 0, 2_000)]) +) + +# Euler bend — width and radius in µm +bend90 = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, + radius=10, + layer=L.WG, + enclosure=wg_enc, + angle=90, +) + +# Straight factory — width and length in DBU +straight_factory = partial( + kf.factories.straight.straight_dbu_factory(kcl=kf.kcl), + layer=L.WG, + enclosure=wg_enc, +) + +WG_WIDTH = kf.kcl.to_dbu(0.5) # 500 DBU + +# Effective routing radius of the euler bend (larger than the nominal 10 µm) +bend_radius = kf.routing.optical.get_radius(bend90) + +# %% [markdown] +# ## 1 · Waypoints — guiding routes through fixed points +# +# A `waypoints` value forces all routes to converge at a specific position and direction +# before continuing to their end ports. +# +# - Pass a `kdb.Trans` to specify a single convergence point with a direction. +# - Pass a `list[kdb.Point]` to define a backbone corridor (direction inferred from +# the first two points). +# +# Below, three East–West routes are forced to detour upward through a corridor at +# y = 80 µm (simulating a gap above an obstacle) before descending to their +# West-facing end ports. + +# %% +c_wp = kf.KCell("opt_waypoints") + +n = 3 +wp_starts = [ + kf.Port( + name=f"in_{i}", + # angle=0 → East-facing + trans=kf.kdb.Trans(0, False, 0, kf.kcl.to_dbu(i * 10)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(n) +] +wp_ends = [ + kf.Port( + name=f"out_{i}", + # angle=2 → West-facing (opposite to East, so routes connect left-to-right) + trans=kf.kdb.Trans(2, False, kf.kcl.to_dbu(300), kf.kcl.to_dbu(i * 10)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(n) +] + +# Waypoint: at (150 µm, 80 µm) heading East. +# All routes must pass through this corridor before reaching their end ports. +kf.routing.optical.route_bundle( + c_wp, + wp_starts, + wp_ends, + separation=kf.kcl.to_dbu(5), + straight_factory=straight_factory, + bend90_cell=bend90, + waypoints=kf.kdb.Trans(0, False, kf.kcl.to_dbu(150), kf.kcl.to_dbu(80)), +) +c_wp + +# %% [markdown] +# ## 2 · Entry and exit stubs — `starts` and `ends` +# +# `starts` / `ends` control how far each route must travel straight before the router +# takes over. Supplying a scalar applies the same stub length to all routes; a list +# gives per-route control (DBU). +# +# Stubs are useful when you need clearance from a device before bending, or when routing +# out of a dense array. + +# %% +c_stubs = kf.KCell("opt_stubs") + +stub_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 15), 0), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +# End ports are offset 40 µm to the right of the start ports so each route +# has an S-bend whose entry / exit stubs are clearly visible. +stub_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(40 + i * 15), kf.kcl.to_dbu(200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] + +# Each start port extends 20 µm straight before the bend, each end 10 µm. +kf.routing.optical.route_bundle( + c_stubs, + stub_starts, + stub_ends, + separation=kf.kcl.to_dbu(5), + straight_factory=straight_factory, + bend90_cell=bend90, + starts=kf.kcl.to_dbu(20), # scalar → all routes get the same start stub + ends=kf.kcl.to_dbu(10), +) +c_stubs + +# %% [markdown] +# ## 3 · Path-length matching +# +# When routes travel different distances (e.g. fan-outs from an array where starts are +# staggered), `kf.PathLengthMatch` inserts small "squiggle" loops on the shorter +# routes so that all waveguide lengths become equal. It runs as a *constraint* on +# `route_bundle`: backbones are computed first, then the constraint walks the routers +# of every bundle whose `route_name` it matches and rewrites the shorter ones. +# +# **`PathLengthMatch` fields:** +# +# | Field | Type | Meaning | +# |---|---|---| +# | `route_names` | `list[str]` | `route_name`s of the bundles to equalise | +# | `element` | `int` | Which backbone segment to insert the loop into. `-1` = last segment. | +# | `loop_side` | `int` | `-1` = loop on left, `0` = centered, `1` = loop on right | +# | `loops` | `int` | Number of loop repetitions | +# | `loop_position` | `int` | `-1` = near start of segment, `0` = center, `1` = near end | +# | `length` | `int \| None` | Target path length in DBU. `None` = match the longest router. | +# | `tolerance` | `int` | Maximum allowed length spread (DBU) after enforcement | +# +# > **Note**: All routes must be routed first (the constraint runs after the +# > backbones are generated), so routes need enough room for the inserted loops — +# > increase `separation` or stagger ports as needed. + +# %% +c_plm = kf.KCell("opt_path_length_match") + +# Stagger start ports so that each route naturally has a different path length. +# Routes need sufficient horizontal separation (≥ 2× bend_radius) so the inserted +# loops don't overlap adjacent waveguides. +plm_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 150), kf.kcl.to_dbu(-i * 200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +plm_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(x), kf.kcl.to_dbu(400)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i, x in enumerate([170, 300, 380]) +] + +kf.routing.optical.route_bundle( + c_plm, + plm_starts, + plm_ends, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, + constraints=[ + kf.PathLengthMatch( + route_names=["plm_demo"], + element=-1, # insert loop in the last backbone segment + loop_side=-1, # loops on the left side + loops=1, + loop_position=0, # centered in the segment + ) + ], + route_name="plm_demo", +) +c_plm + +# %% [markdown] +# ## 4 · Loopback — inside and outside variants (with GCs) +# +# A loopback is a U-shaped waveguide tying two grating-couplers (GCs) +# together so that light injected into one fibre comes out the other — +# the canonical fibre-array alignment / reference structure. The two +# variants of `route_loopback` control where the U-turn fold sits +# relative to the GC array: +# +# - `inside=False` (default) places the U-turn fold *beyond* the array +# so the loopback hangs behind the GCs. Use this when the rest of +# the routing area sits next to the array and the loopback must stay +# clear of the device footprint. +# - `inside=True` folds the U-turn fold *between* the two GCs, so the +# loop's horizontal segment lives within the array's horizontal +# footprint. Use this for compact reference structures where the +# loop must not extend past the array. +# +# The fold needs room: aim for a GC pitch ≥ 4 × `bend_radius` for the +# `inside` variant. Below we use North-facing GCs spaced 150 µm apart +# (≈ 8 × bend_radius) so both topologies render cleanly. + +# %% +LB_SEP = kf.kcl.to_dbu(150) + + +# Grating-coupler stand-in: a taper with a single WG port at the narrow +# end. The wide end (fibre side) extends north; the WG port faces south +# so the loopback's U-turn naturally curls *south* of the array. In a +# real PDK this would be replaced by the actual GC component. +@kf.cell +def gc(width: int, length: int) -> kf.KCell: + c = kf.KCell() + wide = kf.kcl.to_dbu(10) + poly = kf.kdb.Polygon( + [ + kf.kdb.Point(-width // 2, 0), + kf.kdb.Point(width // 2, 0), + kf.kdb.Point(wide // 2, length), + kf.kdb.Point(-wide // 2, length), + ] + ) + c.shapes(c.kcl.layer(L.WG)).insert(poly) + c.create_port( + name="o1", + trans=kf.kdb.Trans(3, False, 0, 0), # South-facing WG port + width=width, + layer_info=L.WG, + ) + return c + + +gc_cell = gc(width=WG_WIDTH, length=kf.kcl.to_dbu(20)) + +# Pitch between adjacent GCs in the fibre array. LB_SEP (defined above) +# is the spacing between the OUTERMOST two GCs — the ones the loopback +# connects. The inner two GCs share the same pitch but are left free in +# this demo (in a real chip they would connect to devices). +N_GCS = 4 +GC_PITCH_UM = kf.kcl.to_um(LB_SEP) / (N_GCS - 1) + + +# --- inside=False (outside loopback) --- +c_lb_outside = kf.KCell("opt_loopback_outside") + +gcs_out = [c_lb_outside.create_inst(gc_cell) for _ in range(N_GCS)] +for i, inst in enumerate(gcs_out): + inst.dmove((0, 0), (i * GC_PITCH_UM, 0)) + +# Loop only the two outermost GCs. +backbone_outside = kf.routing.optical.route_loopback( + gcs_out[0].ports["o1"], + gcs_out[-1].ports["o1"], + bend90_radius=bend_radius, + d_loop=bend_radius * 3, + inside=False, +) + +kf.routing.optical.place_manhattan( + c_lb_outside, + gcs_out[0].ports["o1"], + gcs_out[-1].ports["o1"], + backbone_outside, + straight_factory=straight_factory, + bend90_cell=bend90, +) +c_lb_outside + +# %% +# --- inside=True (inner loopback — fold between the two outermost GCs) --- +c_lb_inside = kf.KCell("opt_loopback_inside") + +gcs_in = [c_lb_inside.create_inst(gc_cell) for _ in range(N_GCS)] +for i, inst in enumerate(gcs_in): + inst.dmove((0, 0), (i * GC_PITCH_UM, 0)) + +backbone_inside = kf.routing.optical.route_loopback( + gcs_in[0].ports["o1"], + gcs_in[-1].ports["o1"], + bend90_radius=bend_radius, + d_loop=bend_radius * 2, + inside=True, +) + +kf.routing.optical.place_manhattan( + c_lb_inside, + gcs_in[0].ports["o1"], + gcs_in[-1].ports["o1"], + backbone_inside, + straight_factory=straight_factory, + bend90_cell=bend90, +) +c_lb_inside + +# %% [markdown] +# ## 5 · Direct backbone placement with `place_manhattan` +# +# `route_bundle` is the high-level router. For full control you can build the backbone +# yourself (a list of `kdb.Point`) and call `place_manhattan` directly to materialise +# waveguide segments and bend cells along it. +# +# This is the same function that `route_bundle` calls internally, exposed so that custom +# routing algorithms can drive the placer. + +# %% +c_pm = kf.KCell("opt_place_manhattan_direct") + +pm_p1 = kf.Port( + name="o1", + trans=kf.kdb.Trans(0, False, 0, 0), + width=WG_WIDTH, + layer_info=L.WG, +) +pm_p2 = kf.Port( + name="o2", + trans=kf.kdb.Trans(2, False, kf.kcl.to_dbu(200), kf.kcl.to_dbu(100)), + width=WG_WIDTH, + layer_info=L.WG, +) + +# Hand-crafted Manhattan backbone: go East, then North, then West. +# Points are the *corners* of the route including the start and end positions. +custom_backbone = [ + kf.kdb.Point(0, 0), + kf.kdb.Point(kf.kcl.to_dbu(100), 0), + kf.kdb.Point(kf.kcl.to_dbu(100), kf.kcl.to_dbu(100)), + kf.kdb.Point(kf.kcl.to_dbu(200), kf.kcl.to_dbu(100)), +] + +kf.routing.optical.place_manhattan( + c_pm, + pm_p1, + pm_p2, + custom_backbone, + straight_factory=straight_factory, + bend90_cell=bend90, +) +c_pm + +# %% [markdown] +# ## Summary +# +# | Need | Parameter / function | +# |---|---| +# | Route through a fixed corridor | `waypoints=[kdb.Point(...), ...]` | +# | Guarantee minimum straight before bend | `starts=dbu_length` (scalar or per-route list) | +# | Equalise optical path lengths | `constraints=[kf.PathLengthMatch(route_names=["b"], element=-1, loop_side=-1, loops=1, loop_position=0)]`, `route_name="b"` | +# | Compact inner U-turn | `route_loopback(..., inside=True)` | +# | Custom routing algorithm output | `place_manhattan(c, p1, p2, pts, straight_factory=..., bend90_cell=...)` | +# +# All coordinates passed to `KCell`-based APIs are in **DBU** (1 nm = 1 DBU with default +# `dbu=0.001`). Convert with `kf.kcl.to_dbu(x_µm)`. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Routing overview (optical + electrical entry point) | [Routing: Overview](overview.py) | +# | Bundle routing details: sort, sbend, bbox modes | [Routing: Bundle](bundle.py) | +# | Equal path-length loops | [Routing: Path Length](path_length.py) | +# | Low-level backbone + Steps API | [Routing: Manhattan](manhattan.py) | +# | All-angle (non-manhattan) routing | [Routing: All-Angle](all_angle.py) | +# | Euler bend effective radius | [Components: Euler Bends](../components/cells/factories/euler.py) | +# | Port construction and connection | [Core Concepts: Ports](../concepts/ports.py) | diff --git a/docs/source/routing/overview.py b/docs/source/routing/overview.py new file mode 100644 index 000000000..503158d13 --- /dev/null +++ b/docs/source/routing/overview.py @@ -0,0 +1,324 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Routing Overview +# +# kfactory provides routing utilities for connecting component ports with wires or +# waveguides. All routers live under `kf.routing`: +# +# | Sub-module | Purpose | +# |---|---| +# | `kf.routing.optical` | Photonic/optical waveguides — uses bends (euler, circular) | +# | `kf.routing.electrical` | Metal wires — right-angle corners only | +# | `kf.routing.aa.optical` | All-angle optical routing (non-Manhattan) | +# +# The primary entry point in each module is **`route_bundle`**, which routes *N* start +# ports to *N* end ports while avoiding obstacles. +# +# ## Port angle convention +# +# A port's angle is the **outward** direction — the direction a wire exits the device at +# that port. `route_bundle` connects: +# +# - **Start** ports: wire exits in the direction of the port's angle. +# - **End** ports: wire arrives going in the direction of the port's angle. +# +# The most common pairing is start facing North (angle=1) and end facing South (angle=3) +# with end ports above start ports, which produces S-shaped routes. +# Alternatively, start and end can face the same direction when one port sits directly +# "in front" of the other (e.g., two East-facing ports on a horizontal axis). +# +# KLayout angle integers: `0` = East (0°), `1` = North (90°), `2` = West (180°), +# `3` = South (270°). + +# %% +from functools import partial + +import kfactory as kf + + +# ── Layers ──────────────────────────────────────────────────────────────── +# Use kf.kcl (the global layout) and register all layers on it. +# Ports, cells, and factories all need to share the same KCLayout instance. +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + METAL: kf.kdb.LayerInfo = kf.kdb.LayerInfo(20, 0) + METALEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(20, 1) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# ── Optical waveguide setup ─────────────────────────────────────────────── +wg_enc = kf.kcl.get_enclosure( + kf.LayerEnclosure(name="WGSTD", sections=[(L.WGCLAD, 0, 2_000)]) +) + +# Euler bend cell (angle=90, radius=10 µm). +# bend_euler_factory takes width and radius in µm. +bend90 = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, # µm + radius=10, # µm + layer=L.WG, + enclosure=wg_enc, + angle=90, +) + +# Straight factory: call with width= and length= in DBU +straight_factory = partial( + kf.factories.straight.straight_dbu_factory(kcl=kf.kcl), + layer=L.WG, + enclosure=wg_enc, +) + +# Convenience: 0.5 µm port width in DBU +WG_WIDTH = kf.kcl.to_dbu(0.5) # 500 DBU at 1 nm/DBU + +# %% [markdown] +# ## 1 · Straight (degenerate) route +# +# When two East-facing ports sit on the same horizontal axis, `route_bundle` places +# a single straight waveguide with no bends. + +# %% +c_straight = kf.KCell("route_straight") + +p1 = kf.Port( + name="o1", + trans=kf.kdb.Trans(0, False, 0, 0), + width=WG_WIDTH, + layer_info=L.WG, +) +p2 = kf.Port( + name="o2", + trans=kf.kdb.Trans(0, False, kf.kcl.to_dbu(80), 0), + width=WG_WIDTH, + layer_info=L.WG, +) + +kf.routing.optical.route_bundle( + c_straight, + [p1], + [p2], + separation=kf.kcl.to_dbu(5), + straight_factory=straight_factory, + bend90_cell=bend90, +) +c_straight + +# %% [markdown] +# ## 2 · Single route with bends +# +# Start facing North (angle=1), end facing South (angle=3), end above start. The +# router inserts euler bends to produce an S-shaped path. + +# %% +c1 = kf.KCell("single_route_bend") + +p_start = kf.Port( + name="o1", + trans=kf.kdb.Trans(1, False, 0, 0), + width=WG_WIDTH, + layer_info=L.WG, +) +p_end = kf.Port( + name="o2", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(60), kf.kcl.to_dbu(200)), + width=WG_WIDTH, + layer_info=L.WG, +) + +kf.routing.optical.route_bundle( + c1, + [p_start], + [p_end], + separation=kf.kcl.to_dbu(5), + straight_factory=straight_factory, + bend90_cell=bend90, +) +c1 + +# %% [markdown] +# ## 3 · Bundle routing +# +# Route multiple waveguides in parallel. kfactory fans the routes apart to maintain +# at least `separation` between adjacent route edges. + +# %% +c2 = kf.KCell("bundle_route") + +n = 5 +start_ports = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 15), 0), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(n) +] +end_ports = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(i * 15), kf.kcl.to_dbu(200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(n) +] + +kf.routing.optical.route_bundle( + c2, + start_ports, + end_ports, + separation=kf.kcl.to_dbu(5), + straight_factory=straight_factory, + bend90_cell=bend90, +) +c2 + +# %% [markdown] +# ## 4 · Obstacle avoidance +# +# Pass `bboxes` to mark keep-out zones. The router automatically detours around them. +# +# > **Caveat**: the obstacle bbox must overlap (or touch) the bundle's own +# > bounding box for the detour to take effect — an isolated box floating in +# > empty space between the two port groups is ignored. In the example below +# > the obstacle covers the left-most start port, so the bundle must deflect +# > around it. Rotating the end ports by 90° (East-facing instead of +# > South-facing) makes the resulting detour visually obvious. + +# %% +c3 = kf.KCell("obstacle_avoidance") + +# Start ports face North at the bottom; end ports face East on the right. +start_ports3 = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 30), 0), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +end_ports3 = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(0, False, kf.kcl.to_dbu(250), kf.kcl.to_dbu(80 + i * 30)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] + +# Obstacle covers the left-most start port — it touches the bundle bbox, so +# the router has to route around it. +obstacle = kf.kdb.Box( + kf.kcl.to_dbu(-15), + kf.kcl.to_dbu(-10), + kf.kcl.to_dbu(20), + kf.kcl.to_dbu(60), +) +c3.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert(obstacle) + +kf.routing.optical.route_bundle( + c3, + start_ports3, + end_ports3, + separation=kf.kcl.to_dbu(5), + straight_factory=straight_factory, + bend90_cell=bend90, + bboxes=[obstacle], +) +c3 + +# %% [markdown] +# ## 5 · Electrical routing +# +# Electrical routes are plain Manhattan wires — no bend cells needed. Use +# `kf.routing.electrical.route_bundle` with `place_layer` and `route_width` (in DBU). +# +# To make the routing non-trivial we orient the start ports North-facing along +# the bottom edge and the end ports East-facing on the right edge — the +# resulting L-shaped wires demonstrate that the electrical router does insert +# right-angle corners. + +# %% +METAL_WIDTH = 2_000 # 2 µm in DBU + +ce = kf.KCell("electrical_bundle") + +e_start = [ + kf.Port( + name=f"e_in_{i}", + trans=kf.kdb.Trans(1, False, i * 30_000, 0), + width=METAL_WIDTH, + layer_info=L.METAL, + ) + for i in range(3) +] +# End ports face East, offset along y so each wire must bend to reach its +# target. +e_end = [ + kf.Port( + name=f"e_out_{i}", + trans=kf.kdb.Trans(0, False, 150_000, 60_000 + i * 30_000), + width=METAL_WIDTH, + layer_info=L.METAL, + ) + for i in range(3) +] + +kf.routing.electrical.route_bundle( + ce, + e_start, + e_end, + separation=5_000, + place_layer=L.METAL, +) +ce + +# %% [markdown] +# ## Summary +# +# | Task | API | +# |---|---| +# | Route *N* optical waveguides | `kf.routing.optical.route_bundle(c, starts, ends, sep, straight_factory=..., bend90_cell=...)` | +# | Route *N* metal wires | `kf.routing.electrical.route_bundle(c, starts, ends, sep, place_layer=...)` | +# | Avoid obstacles | add `bboxes=[kdb.Box(...)]` to `route_bundle` | +# | U-turn loopback (with GCs) | see [Routing: Optical § 4](optical.py) | +# +# All coordinates in `KCell`-based APIs are in **DBU** (1 nm = 1 DBU with default `dbu = 0.001`). +# Use `kf.kcl.to_dbu(x_um)` to convert from µm. For a purely µm-native workflow use +# `DKCell` / `DPort` variants instead. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Optical route options: waypoints, path-length matching, loopbacks | [Routing: Optical](optical.py) | +# | Electrical (metal wire) routing | [Routing: Electrical](electrical.py) | +# | Manhattan backbone algorithm and Steps API | [Routing: Manhattan](manhattan.py) | +# | All-angle (diagonal) routing | [Routing: All-Angle](all_angle.py) | +# | Bundle routing: sort, separation, sbend, bbox modes | [Routing: Bundle](bundle.py) | +# | Equal path-length routing | [Routing: Path Length](path_length.py) | +# | Euler bend cells and effective radius | [Components: Euler Bends](../components/cells/factories/euler.py) | +# | DBU vs µm coordinate systems | [Core Concepts: DBU vs µm](../concepts/dbu_vs_um.py) | diff --git a/docs/source/routing/path_length.py b/docs/source/routing/path_length.py new file mode 100644 index 000000000..9c38a6f14 --- /dev/null +++ b/docs/source/routing/path_length.py @@ -0,0 +1,507 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Path-Length Matching +# +# Path-length matching equalises the optical (or electrical) path lengths across a bundle +# of routes. When routes travel different distances — because start ports are staggered, +# or end ports are not symmetric — the shorter routes get small "squiggle" loops inserted +# so that every waveguide in the bundle reaches the same total length. +# +# **Why it matters:** +# +# - **Optical coherence** — interferometers and phased arrays require equal path lengths to +# within a fraction of a wavelength. +# - **Signal timing** — electrical buses must arrive at the same time; unequal lengths cause +# skew. +# - **Array uniformity** — grating coupler arrays and detector arrays often require matched +# lengths for balanced response. +# +# ## How it works in kfactory +# +# Pass `kf.PathLengthMatch(...)` as a routing **constraint** to `route_bundle`, and +# tag the bundle with `route_name=`. After the backbones are computed but before any +# instances are placed, the constraint runs `path_length_match` over the routers +# whose `route_name` it matches. It measures the longest router in the matched set, +# then inserts a "squiggle" loop (a 4-point detour made from two 90° bends) into +# each shorter router until all lengths are equal. +# +# ## Setup + +# %% +from functools import partial + +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +L = LAYER() +kf.kcl.infos = L + +wg_enc = kf.kcl.get_enclosure( + kf.LayerEnclosure(name="WGSTD_PLM", sections=[(L.WGCLAD, 0, 2_000)]) +) + +# Euler bend — width and radius in µm +bend90 = kf.factories.euler.bend_euler_factory(kcl=kf.kcl)( + width=0.5, + radius=10, + layer=L.WG, + enclosure=wg_enc, + angle=90, +) + +# Straight factory — width and length in DBU +straight_factory = partial( + kf.factories.straight.straight_dbu_factory(kcl=kf.kcl), + layer=L.WG, + enclosure=wg_enc, +) + +WG_WIDTH = kf.kcl.to_dbu(0.5) # 500 DBU + +# Effective routing radius of the euler bend (larger than the nominal 10 µm) +bend_radius = kf.routing.optical.get_radius(bend90) + +# %% [markdown] +# ## 1 · Baseline — unequal path lengths +# +# Three North-facing start ports are staggered vertically (each 200 µm shorter than the +# previous) so that each route naturally has a different path length. Without +# path-length matching the routes reach their end ports at different total lengths. + +# %% +c_baseline = kf.KCell("plm_baseline") + +# Staggered starts: port 0 is longest, port 2 is shortest +baseline_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 150), kf.kcl.to_dbu(-i * 200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +baseline_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(x), kf.kcl.to_dbu(400)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i, x in enumerate([170, 300, 380]) +] + +routes_baseline = kf.routing.optical.route_bundle( + c_baseline, + baseline_starts, + baseline_ends, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, +) + +print("Unmatched lengths (µm):", [round(r.length / 1000, 1) for r in routes_baseline]) +c_baseline + +# %% [markdown] +# The three routes have clearly different lengths. Path-length matching will bring them +# all to the longest value. + +# %% [markdown] +# ## 2 · Basic path-length matching +# +# Pass `constraints=[kf.PathLengthMatch(route_names=["..."], element=-1, loop_side=-1, +# loops=1, loop_position=0)]` plus `route_name="..."` to insert loops in the last +# backbone segment (`element=-1`), on the left side, centered in the segment. + +# %% +c_plm = kf.KCell("plm_basic") + +plm_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 150), kf.kcl.to_dbu(-i * 200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +plm_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(x), kf.kcl.to_dbu(400)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i, x in enumerate([170, 300, 380]) +] + +routes_plm = kf.routing.optical.route_bundle( + c_plm, + plm_starts, + plm_ends, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, + constraints=[ + kf.PathLengthMatch( + route_names=["plm_basic"], + element=-1, # last backbone segment + loop_side=-1, # loops protrude to the left (-1=left, 0=center, 1=right) + loops=1, # one squiggle loop per route + loop_position=0, # loop is centered in the segment + ) + ], + route_name="plm_basic", +) + +print("Matched lengths (µm):", [round(r.length / 1000, 1) for r in routes_plm]) +c_plm + +# %% [markdown] +# All three routes now have equal total length. The shorter routes received progressively +# taller squiggle loops. + +# %% [markdown] +# ## 3 · `loop_side` — which side the loop protrudes +# +# | Value | Enum | Effect | +# |---|---|---| +# | `-1` | `LoopSide.left` | Loop extends to the left of the route direction | +# | `0` | `LoopSide.center` | Loop is split symmetrically above and below | +# | `1` | `LoopSide.right` | Loop extends to the right of the route direction | +# +# For routes running northward, "left" means west and "right" means east. Choose the +# side that avoids neighbouring structures. +# +# ### loop_side = right +# +# Right-side loops protrude east, *into* the space of the next route in the bundle, +# so the routes need to be closer in path length than for left/center loops. Here +# we halve the y-stagger (`100` µm instead of `200`) so the squiggle stays compact +# enough to clear the neighbour — verifiable with `c.connectivity_check()`. + +# %% +c_right = kf.KCell("plm_loop_right") + +rs_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 150), kf.kcl.to_dbu(-i * 100)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +rs_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(x), kf.kcl.to_dbu(400)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i, x in enumerate([170, 300, 380]) +] + +kf.routing.optical.route_bundle( + c_right, + rs_starts, + rs_ends, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, + constraints=[ + kf.PathLengthMatch( + route_names=["plm_right"], + element=-1, + loop_side=1, # ← right + loops=1, + loop_position=0, + ) + ], + route_name="plm_right", +) +c_right + +# %% [markdown] +# ### loop_side = center (symmetric) + +# %% +c_center = kf.KCell("plm_loop_center") + +lc_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 150), kf.kcl.to_dbu(-i * 200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +lc_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(x), kf.kcl.to_dbu(400)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i, x in enumerate([170, 300, 380]) +] + +kf.routing.optical.route_bundle( + c_center, + lc_starts, + lc_ends, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, + constraints=[ + kf.PathLengthMatch( + route_names=["plm_center"], + element=-1, + loop_side=0, # ← center (symmetric around route axis) + loops=1, + loop_position=0, + ) + ], + route_name="plm_center", +) +c_center + +# %% [markdown] +# ## 4 · `loop_position` — where in the segment the loop sits +# +# | Value | Enum | Effect | +# |---|---|---| +# | `-1` | `LoopPosition.start` | Loop is placed near the start of the backbone segment | +# | `0` | `LoopPosition.center` | Loop is centered in the backbone segment | +# | `1` | `LoopPosition.end` | Loop is placed near the end of the backbone segment | + +# %% +c_pos_end = kf.KCell("plm_loop_pos_end") + +pe_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 150), kf.kcl.to_dbu(-i * 200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +pe_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(x), kf.kcl.to_dbu(400)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i, x in enumerate([170, 300, 380]) +] + +kf.routing.optical.route_bundle( + c_pos_end, + pe_starts, + pe_ends, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, + constraints=[ + kf.PathLengthMatch( + route_names=["plm_pos_end"], + element=-1, + loop_side=-1, + loops=1, + loop_position=1, # ← near the end of the segment + ) + ], + route_name="plm_pos_end", +) +c_pos_end + +# %% [markdown] +# ## 5 · `loops` — multiple squiggle repetitions +# +# When the length difference between the shortest and longest route is large, a single +# loop may become very tall. Increasing `loops` splits the extra length across multiple +# smaller squiggles, which keeps the loop footprint compact and avoids collision with +# neighbouring structures. + +# %% +c_loops2 = kf.KCell("plm_loops_2") + +l2_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 150), kf.kcl.to_dbu(-i * 200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +l2_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(x), kf.kcl.to_dbu(400)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i, x in enumerate([170, 300, 380]) +] + +routes_l2 = kf.routing.optical.route_bundle( + c_loops2, + l2_starts, + l2_ends, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, + constraints=[ + kf.PathLengthMatch( + route_names=["plm_loops_2"], + element=-1, + loop_side=-1, + loops=2, # ← two squiggle loops per route + loop_position=0, + ) + ], + route_name="plm_loops_2", +) + +print("Matched lengths (µm):", [round(r.length / 1000, 1) for r in routes_l2]) +c_loops2 + +# %% [markdown] +# ## 6 · Inspecting route lengths +# +# `route_bundle` returns a list of `ManhattanRoute` objects. Each route exposes +# several length properties that are useful for diagnostics: +# +# | Attribute | Description | +# |---|---| +# | `route.length` | Total path length in µm (straights + bend arclengths) | +# | `route.length_backbone` | Straight-line length of the backbone | +# | `route.length_straights` | Summed length of all straight segments only | +# | `route.n_bend90` | Number of 90° bends placed | +# | `route.n_taper` | Number of taper transitions placed | + +# %% +c_inspect = kf.KCell("plm_inspect") + +ins_starts = [ + kf.Port( + name=f"in_{i}", + trans=kf.kdb.Trans(1, False, kf.kcl.to_dbu(i * 150), kf.kcl.to_dbu(-i * 200)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i in range(3) +] +ins_ends = [ + kf.Port( + name=f"out_{i}", + trans=kf.kdb.Trans(3, False, kf.kcl.to_dbu(x), kf.kcl.to_dbu(400)), + width=WG_WIDTH, + layer_info=L.WG, + ) + for i, x in enumerate([170, 300, 380]) +] + +routes_ins = kf.routing.optical.route_bundle( + c_inspect, + ins_starts, + ins_ends, + separation=kf.kcl.to_dbu(10), + straight_factory=straight_factory, + bend90_cell=bend90, + constraints=[ + kf.PathLengthMatch( + route_names=["plm_inspect"], + element=-1, + loop_side=-1, + loops=1, + loop_position=0, + ) + ], + route_name="plm_inspect", +) + +print(f"{'Route':<8} {'length (µm)':>14} {'straights (µm)':>16} {'n_bend90':>10}") +print("-" * 52) +for i, r in enumerate(routes_ins): + print( + f" {i:<6} {r.length / 1000:>13.3f}" + f" {r.length_straights / 1000:>15.3f}" + f" {r.n_bend90:>10}" + ) + +# %% [markdown] +# All routes share the same total `length`. The shorter routes have longer `length_straights` +# (the loop adds extra straight segments) and more `n_bend90` (the loop uses four bends). + +# %% [markdown] +# ## 7 · Choosing the right backbone segment (`element`) +# +# The `element` key selects which segment of the backbone gets the loop inserted: +# +# - `element=-1` — last segment (before the end port). Best when the end region has +# ample space and the longest straight is near the end. +# - `element=0` — first segment (after the start port). Useful when the start region +# is spacious. +# - Other integers index into the backbone point list. +# +# The segment must be long enough to contain the loop. A segment shorter than +# `4 × bend_radius` cannot hold a single loop; the router will place the loop but the +# geometry may become cramped. +# +# ## Summary +# +# `kf.PathLengthMatch` fields: +# +# | Field | Type | Meaning | +# |---|---|---| +# | `route_names` | `list[str]` | `route_name`s of the bundles to equalise | +# | `element` | `int` | Backbone segment index. `-1` = last, `0` = first. | +# | `loop_side` | `int` | `-1` left, `0` center (symmetric), `1` right | +# | `loops` | `int` | Number of squiggle repetitions per route | +# | `loop_position` | `int` | `-1` near start, `0` center, `1` near end of segment | +# | `length` | `int \| None` | Target path length (DBU). `None` = match longest. | +# | `tolerance` | `int` | Maximum allowed length spread (DBU) after enforcement | +# +# **Practical tips:** +# +# - Horizontal separation between routes must be ≥ 2× `bend_radius` so loops do not +# overlap adjacent waveguides. +# - For large length differences use `loops=2` or `loops=3` to keep loop heights compact. +# - Use `route.length` after routing to verify that all routes are matched. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Bundle routing that `kf.PathLengthMatch` plugs into | [Routing: Bundle](bundle.py) | +# | Optical route options including waypoints and stubs | [Routing: Optical](optical.py) | +# | Routing overview and sub-module map | [Routing: Overview](overview.py) | +# | Euler bend effective radius (affects loop spacing) | [Components: Euler Bends](../components/cells/factories/euler.py) | +# | DBU vs µm (route.length is in µm) | [Core Concepts: DBU vs µm](../concepts/dbu_vs_um.py) | diff --git a/docs/source/notebooks/05_Schematics.py b/docs/source/schematics/crossing45.py similarity index 53% rename from docs/source/notebooks/05_Schematics.py rename to docs/source/schematics/crossing45.py index 2adf5d22f..030ab9b3b 100644 --- a/docs/source/notebooks/05_Schematics.py +++ b/docs/source/schematics/crossing45.py @@ -1,238 +1,69 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + # %% [markdown] -# # Schematic Cells +# # 45° Crossing with Virtual Cells +# +# This page builds a **grid of 45° waveguide crossings** using schematic-driven design. +# It demonstrates several advanced features working together: # -# This notebook demonstrates how to use `kfactory` for schematic-driven -# photonic design. We will: -# - Build higher-level schematic cells -# - Create basic parametric cells (PCell) such as straights and bends -# - Define routing strategies -# - Compare schematic vs. extracted layouts (LVS) -# - Generate reusable code from schematics +# - Direct polygon construction for the crossing cell +# - Virtual parametric cells (`@pdk.vcell` / `VKCell`) for lightweight bend and straight +# components +# - `DSchematic` (µm-coordinate schematic) with `output_type=DKCell` +# - Hierarchical grid assembly driven entirely by schematic logic +# - LVS verification and code generation from the resulting schematic # %% -# Imports -import kfactory as kf -import numpy as np - -from IPython.core.getipython import get_ipython +import warnings from pprint import pformat -# %% editable=true slideshow={"slide_type": ""} tags=["hide"] -# For jupyter to show the big dicts/jsons correctly, we need a helper function `scrollable_text` -# this is hidden from the docs build - -from IPython.display import display, HTML -import html - - +import numpy as np +from IPython.display import HTML -def scrollable_text( - text: str, max_height: int = 300, width: str = "100%", font_size: str = "14px" -) -> None: - """Render scrollable, searchable text output for Jupyter and mkdocs-jupyter. +import kfactory as kf - This function wraps text inside a scrollable
 block with a search box.
-    It adapts to light/dark mode automatically. The search highlights all matches
-    and pressing Enter jumps to the next match.
 
-    Args:
-        text (str): The text to display.
-        max_height (int, optional): Maximum height in pixels before scrolling.
-            Defaults to 300.
-        width (str, optional): CSS width (e.g. "100%", "800px").
-            Defaults to "100%".
-        font_size (str, optional): CSS font size (e.g. "14px", "0.9em").
-            Defaults to "14px".
-    """
-    safe = html.escape(str(text))
+def scrollable_text(text: str, max_height: str = "400px") -> HTML:
+    """Render long text in a vertically scrollable, monospaced HTML block."""
+    import html
 
-    style = (
-        f"max-height:{max_height}px; overflow:auto; "
-        f"border:1px solid var(--st-border); padding:6px; "
-        f"background:var(--st-bg); color:var(--st-fg); "
-        f"width:{width}; font-size:{font_size}; white-space:pre-wrap;"
+    return HTML(
+        f'
{html.escape(text)}
' ) - html_block = f""" - -
- -
{safe}
-
- """ - display(HTML(html_block)) # %% [markdown] -# ## Basic example with routing +# ## PDK setup # -# In order to avoid name conflicts, let's create a new clean `KCLayout` (our PDK container). -# A **PDK** (Process Design Kit) defines the available layers, devices, and design rules -# for a given process. Here we’ll create a lightweight one for demonstration. +# We create a dedicated `KCLayout` with a wide waveguide cross-section. The +# `SymmetricalCrossSection` bundles the core width, enclosure (cladding), and a name +# that the schematic can reference as a plain string. + # %% class Layers(kf.LayerInfos): WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) - WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2,0) - -layers = Layers() -pdk = kf.KCLayout("SCHEMA_PDK_ROUTING", infos=Layers) - -# %% [markdown] -# ### Cell functions -# -# To begin, we define the **basic building blocks** for routing: -# - A **straight waveguide** of given length and width -# - A **90° Euler bend** for turning corners -# -# These primitives are enough to assemble larger routed networks. - -# %% -@pdk.cell -def straight(width: int, length: int) -> kf.KCell: - c = pdk.kcell() - c.shapes(layers.WG).insert(kf.kdb.Box(0, -width // 2, length, width // 2)) - c.create_port( - name="o1", - width=width, - trans=kf.kdb.Trans(rot=2, mirrx=False, x=0, y=0), - layer_info=layers.WG, - ) - c.create_port( - name="o2", - width=width, - trans=kf.kdb.Trans(x=length, y=0), - layer_info=layers.WG, - ) - - return c - - -bend90_function = kf.factories.euler.bend_euler_factory(kcl=pdk) -bend90 = bend90_function(width=0.500, radius=10, layer=layers.WG) - -# %% [markdown] -# ### Routing strategy -# -# Next we define a **routing strategy** (`route_bundle`). -# This specifies how to connect groups of ports with straight sections and bends. -# - It ensures separation between parallel routes -# - Reuses our basic cells (`straight`, `bend90`) -# - Can be applied consistently across designs - -# %% -@pdk.routing_strategy -def route_bundle( - c: kf.KCell, - start_ports: list[kf.Port], - end_ports: list[kf.Port], - separation: int = 5000, -) -> list[kf.routing.generic.ManhattanRoute]: - return kf.routing.optical.route_bundle( - c=kf.KCell(base=c._base), - start_ports=[kf.Port(base=sp.base) for sp in start_ports], - end_ports=[kf.Port(base=ep.base) for ep in end_ports], - separation=separation, - straight_factory=straight, - bend90_cell=bend90, - ) - -# %% [markdown] -# ### Example schematic with routing -# -# Now we can demonstrate usage in a schematic: -# - Two straight sections (`s1` and `s2`) -# - Positioned apart vertically -# - Connected automatically by our routing strategy -# -# This shows how schematics carry both connectivity **and** layout placement information. - -# %% -@pdk.schematic_cell -def route_example() -> kf.schematic.TSchematic[int]: - schematic = kf.Schematic(kcl=pdk) - - s1 = schematic.create_inst( - name="s1", component="straight", settings={"length": 5000, "width": 500} - ) - s2 = schematic.create_inst( - name="s2", component="straight", settings={"length": 5000, "width": 500} - ) - - s1.place(x=1000, y=10_000) - s2.place(x=1000, y=210_000) - - schematic.add_route( - "s1-s2", [s1["o2"]], [s2["o2"]], "route_bundle", separation=20_000 - ) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) - return schematic - - -route_example() - -# %% [markdown] -# ## Example: 45 Degrees Crossing with virtual cells -# -# We’ll now construct a more advanced schematic: a **grid of 45° crossings**. -# This introduces: -# - Direct polygon construction -# - Use of virtual parametric cells (`vcell` and `VKCell`) -# - Hierarchical design through schematic instantiation - -# %% [markdown] -# ### Setup pdk -# -# Let’s define a new `KCLayout` for this example, including -# a cross-section definition for a wide waveguide. - -# %% -pdk = kf.KCLayout("CROSSING_PDK", infos=Layers) LAYER = Layers() +pdk = kf.KCLayout("CROSSING_PDK", infos=Layers) xs_wg1 = pdk.get_icross_section( kf.SymmetricalCrossSection( @@ -244,17 +75,21 @@ def route_example() -> kf.schematic.TSchematic[int]: ) ) -# Ensure the same cross_section is also available in the target layout as well +# The crossing45 schematic below is registered on kf.kcl, so ensure the same +# cross-section is available there as well. kf.kcl.get_icross_section(xs_wg1) # %% [markdown] -# #### PDK Cells +# ## The `cross` cell +# +# A single 45° crossing: four waveguide arms radiating from the center. # -# The `cross` cell builds a single 45° crossing: -# - Constructs polygons for waveguide arms -# - Ensures spacing rules are met via `fix_spacing_tiled` -# - Adds enclosures for cladding / fill excludes -# - Creates ports in four directions for connectivity +# - Polygon points are generated per arm with a sinusoidal width profile +# - All four rotations are merged with `Region.hulls()` +# - `fix_spacing_tiled` resolves minimum-space DRC violations +# - Enclosures are applied via `Minkowski` for cladding / fill-exclude layers +# - Four ports (`o1`..`o4`) face the cardinal directions + # %% @pdk.cell @@ -262,7 +97,7 @@ def cross(cross_section: str) -> kf.KCell: c = pdk.kcell() xs = c.kcl.get_icross_section(cross_section) - # calculate points for one arm of the cross + # Calculate points for one arm of the cross points = [ kf.kdb.DPoint( x * 0.075, c.kcl.to_um(xs.width // 2) + float(np.sin(x / 125 * np.pi)) @@ -276,7 +111,6 @@ def cross(cross_section: str) -> kf.KCell: ) center_dist = c.kcl.to_dbu(points[-1].y) - base_trans = kf.kdb.Trans(-poly.bbox().right - center_dist, 0) r = kf.kdb.Region( @@ -289,39 +123,35 @@ def cross(cross_section: str) -> kf.KCell: shapes = c.shapes(LAYER.WG) shapes.insert(r) - # fix minimum space violations for 300 dbu (.3um) + # Fix minimum space violations for 300 dbu (0.3 um) fix = kf.utils.fix_spacing_tiled(c, 300, layer=LAYER.WG) - - # remove unfixed polygons shapes.clear() - - # add fixed polygon shapes.insert(fix) bb = c.bbox(c.kcl.layer(xs.main_layer)) - c.create_port(trans=kf.kdb.Trans(0, False, bb.right, 0), cross_section=xs) - c.create_port(trans=kf.kdb.Trans(1, False, 0, bb.top), cross_section=xs) - c.create_port(trans=kf.kdb.Trans(2, False, bb.left, 0), cross_section=xs) - c.create_port(trans=kf.kdb.Trans(3, False, 0, bb.bottom), cross_section=xs) + c.create_port( + name="o1", trans=kf.kdb.Trans(0, False, bb.right, 0), cross_section=xs + ) + c.create_port(name="o2", trans=kf.kdb.Trans(1, False, 0, bb.top), cross_section=xs) + c.create_port(name="o3", trans=kf.kdb.Trans(2, False, bb.left, 0), cross_section=xs) + c.create_port( + name="o4", trans=kf.kdb.Trans(3, False, 0, bb.bottom), cross_section=xs + ) xs.enclosure.apply_minkowski_tiled(c, xs.main_layer) - c.auto_rename_ports() return c + # %% [markdown] -# ### Virtual cells -# -# Unlike physical cells, **virtual cells** are defined parametrically: -# - Only generate geometry when needed -# - Lightweight and efficient +# ## Virtual cells # -# Here we define: -# - `bend_euler`: a parametric Euler bend -# - `euler_term`: a bend tapering to a termination -# - `straight`: a parametric straight section +# Unlike physical cells, **virtual cells** (`VKCell`) are defined parametrically and only +# generate geometry when materialised. They are lightweight and efficient for +# repetitive routing primitives. + # %% @pdk.vcell @@ -331,24 +161,23 @@ def bend_euler( angle: float = 90, resolution: float = 150, ) -> kf.VKCell: - """Create a virtual euler bend. + """Virtual euler bend. Args: radius: Radius of the backbone. [um] - cross_section: Name of the CrossSection of the bend. + cross_section: Name of the CrossSection. angle: Angle of the bend. resolution: Angle resolution for the backbone. """ c = pdk.vkcell() xs = c.kcl.get_dcross_section(cross_section) - dbu = c.kcl.dbu backbone = kf.factories.virtual.euler.euler_bend_points( angle, radius=radius, resolution=resolution ) - kf.factories.virtual.utils.extrude_backbone( + kf.factories.utils.extrude_backbone( c=c, backbone=backbone, width=xs.width, @@ -373,42 +202,18 @@ def bend_euler( return c -@pdk.vcell -def euler_term(radius: float, cross_section: str, term_width: float) -> kf.VKCell: - angle = 45 - resolution = 150 - c = pdk.vkcell() - xs = c.kcl.get_dcross_section(cross_section) - dbu = c.kcl.dbu - backbone = kf.factories.virtual.euler.euler_bend_points( - angle, radius=xs.radius, resolution=resolution - ) - kf.factories.virtual.utils.extrude_backbone_dynamic( - c=c, - backbone=backbone, - width1=xs.width, - width2=term_width, - layer=xs.main_layer, - enclosure=xs.enclosure, - start_angle=0, - end_angle=angle, - dbu=dbu, - ) - - c.create_port( - name="o1", - cross_section=xs, - dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, backbone[0].to_v()), - ) - return c - - @pdk.vcell def straight(length: float, cross_section: str) -> kf.VKCell: + """Virtual straight waveguide. + + Args: + length: Length in um. + cross_section: Name of the CrossSection. + """ c = pdk.vkcell() xs = c.kcl.get_dcross_section(cross_section) - kf.factories.virtual.utils.extrude_backbone( + kf.factories.utils.extrude_backbone( c, [kf.kdb.DPoint(0, 0), kf.kdb.DPoint(length, 0)], start_angle=0, @@ -420,13 +225,13 @@ def straight(length: float, cross_section: str) -> kf.VKCell: ) c.create_port( - name="o2", - dcplx_trans=kf.kdb.DCplxTrans(mag=1, rot=0, mirrx=False, x=length, y=0), + name="o1", + dcplx_trans=kf.kdb.DCplxTrans(mag=1, rot=180, mirrx=False, x=0, y=0), cross_section=xs, ) c.create_port( - name="o1", - dcplx_trans=kf.kdb.DCplxTrans(mag=1, rot=180, mirrx=False, x=0, y=0), + name="o2", + dcplx_trans=kf.kdb.DCplxTrans(mag=1, rot=0, mirrx=False, x=length, y=0), cross_section=xs, ) @@ -434,14 +239,16 @@ def straight(length: float, cross_section: str) -> kf.VKCell: # %% [markdown] -# ### Crossing schematic +# ## Crossing schematic # -# The `crossing45` schematic builds an array of crossings: -# - Tiles multiple `cross` instances -# - Adds spacers and bends to enforce pitch -# - Places input/output ports systematically +# The `crossing45` schematic builds an n-by-n array of crossings: # -# This results in a scalable crossing matrix driven entirely by schematic logic. +# - Tiles `cross` instances on a 45° grid +# - Inserts straight spacers between crossings when the pitch exceeds the crossing size +# - Adds 45° euler bends and I/O straights at the edges so that all ports face +# horizontally +# - Uses `DSchematic` (µm coordinates) and produces a `DKCell` + # %% @kf.kcl.schematic_cell(output_type=kf.DKCell) @@ -549,6 +356,7 @@ def crossing45(n: int, pitch: kf.typings.um, cross_section: str) -> kf.DSchemati crossing.connect( "o1", s.instances[f"crossing_{i - 1}_{j}"].ports["o3"] ) + for i in range(n // 2): spacer_start_top = s.create_inst( name=f"io_spacer_{i}", @@ -604,6 +412,7 @@ def crossing45(n: int, pitch: kf.typings.um, cross_section: str) -> kf.DSchemati "o1", s.instances[f"crossing_{i}_{n // 2 - 1}"].ports["o4"] ) spacer_end_top.connect("o2", bend_end_top.ports["o2"]) + spacer_end_bot = s.create_inst( name=f"io_spacer_{2 * n - i - 1}", component="straight", @@ -677,76 +486,70 @@ def crossing45(n: int, pitch: kf.typings.um, cross_section: str) -> kf.DSchemati return s + c = crossing45(8, pitch=30, cross_section="WG1000") c -# %% -scrollable_text(pformat(c.schematic.model_dump(exclude_defaults=True))) - # %% [markdown] -# ### Sample LVS of schematic vs extracted (Connection) Netlist +# ## Inspecting the schematic model # -# With both schematic and extracted netlists, we can perform a partial **Layout vs. Schematic (LVS)**: -# - `schematic_netlist`: direct from schematic definition -# - `extracted_netlist`: derived from the physical layout -# -# Matching them ensures the layout faithfully implements the intended connectivity. +# The schematic model contains every instance, placement, and connection as a +# serialisable Pydantic structure. # %% -schematic_netlist = c.schematic.netlist() -scrollable_text(pformat(schematic_netlist.model_dump())) - -# %% -extracted_netlist = c.netlist()[ - c.name -] # the extracted netlist is hierarchical by default -scrollable_text(pformat(extracted_netlist.model_dump())) +with warnings.catch_warnings(): + # capture pydantic serialization warnings due to generic type mismatch + warnings.filterwarnings("ignore", category=UserWarning, module="pydantic") + scrollable_text(pformat(c.schematic.model_dump(exclude_defaults=True))) # %% [markdown] -# Let’s make an LVS check, i.e. compare the extracted netlist versus the netlist directly from the schematic. +# ## LVS: schematic vs extracted netlist +# +# With both a schematic netlist (derived from declared connections) and an extracted +# netlist (derived from physical geometry), we can verify they match. # %% -assert schematic_netlist == extracted_netlist +schematic_netlist = c.schematic.netlist().normalize() +extracted_netlist = c.netlist()[c.name].normalize() + +assert schematic_netlist == extracted_netlist, "LVS failed!" +print("LVS passed: schematic and extracted netlists match.") # %% [markdown] -# ## Converting a Schematic to a cell function (parametric cell (PCell)) +# ## Code generation # -# Another powerful feature: **exporting schematics as code**. -# - `code_str()` generates a self-contained Python function -# - The result is a reusable **parametric cell (PCell)** -# - This makes schematics portable and automatable across environments +# `code_str()` exports the schematic as a standalone Python function. The generated +# code re-creates the same layout without the schematic machinery and can be shared +# with collaborators or archived for reproducibility. # %% from IPython.display import Code -Code(c.schematic.code_str()) -# %% [markdown] -# To avoid name conflicts with our existing schematic, -# let’s make a copy and rename it before execution. - -# %% -new_schematic = c.schematic.model_copy() -new_schematic.name = new_schematic.name + "_copy" +Code(c.schematic.code_str()) # %% [markdown] -# ### Executing generated code +# ## Summary # -# We can directly execute the generated code string in this notebook, -# which defines a new PCell function. -# This closes the loop: -# 1. Design a schematic -# 2. Generate its layout and netlist -# 3. Export as reusable code - -# %% -get_ipython().run_cell(new_schematic.code_str()) +# This example combined several advanced kfactory features: +# +# | Feature | Where used | +# |---------|-----------| +# | Direct polygon construction | `cross` cell — sinusoidal arm profile with `Region.hulls()` | +# | DRC fixing | `fix_spacing_tiled` inside the crossing cell | +# | Minkowski enclosures | `apply_minkowski_tiled` for cladding layers | +# | Virtual cells (`VKCell`) | `bend_euler` and `straight` — lightweight parametric geometry | +# | `DSchematic` (µm coordinates) | `crossing45` — floating-point placement | +# | `schematic_cell(output_type=DKCell)` | Produces a µm-based cell from the schematic | +# | Grid assembly via schematic logic | Nested loops tile crossings, spacers, bends, and I/O | +# | LVS verification | `schematic.netlist() == cell.netlist()` | +# | Code generation | `schematic.code_str()` for portable export | # %% [markdown] -# Now we can instantiate the newly generated PCell and visualize it. - -# %% -c_new = crossing45_N8_P30_CSWG1000_copy() -c_new - -# %% -c_new.ports.print() +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Schematic basics (placement, connect, LVS) | [Schematics: Overview](overview.py) | +# | Netlist data model & serialization | [Schematics: Netlist](netlist.py) | +# | Virtual cells in detail | [Components: Virtual Cells](../components/cells/virtual.py) | +# | Cross-sections & enclosures | [Cross-Sections](../components/cross_sections.py) | diff --git a/docs/source/schematics/netlist.py b/docs/source/schematics/netlist.py new file mode 100644 index 000000000..e7a2ff1e8 --- /dev/null +++ b/docs/source/schematics/netlist.py @@ -0,0 +1,404 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Netlist & Schematic I/O +# +# This page dives into the **Netlist data model** — the representation that kfactory uses +# for connectivity — and shows how to: +# +# - Inspect the `Netlist` object (instances, nets, ports) +# - Serialize a schematic to JSON/YAML and reload it with `kf.read_schematic` +# - Compare netlists and sort them for stable equality checks +# - Dump a netlist directly to JSON via `Netlist.to_json` +# +# For the schematic-first design workflow itself (placement, `connect`, LVS basics) see +# the [Schematic-Driven Design](overview.py) page. + +# %% +import json +import tempfile +from pathlib import Path + +from kfnetlist import PortRef + +import kfactory as kf + +# %% [markdown] +# ## PDK setup +# +# We reuse the same minimal PDK from the overview page — a straight waveguide and a 90 ° +# euler bend, both registered on a dedicated `KCLayout`. + + +# %% +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +L = LAYER() +pdk = kf.KCLayout("SCHEM_NETLIST", infos=LAYER) + + +@pdk.cell +def straight(width: int, length: int) -> kf.KCell: + """Straight waveguide segment. + + Args: + width: Width in dbu. + length: Length in dbu. + """ + c = pdk.kcell() + c.shapes(L.WG).insert(kf.kdb.Box(0, -width // 2, length, width // 2)) + c.create_port( + name="o1", + width=width, + trans=kf.kdb.Trans(rot=2, mirrx=False, x=0, y=0), + layer_info=L.WG, + ) + c.create_port( + name="o2", + width=width, + trans=kf.kdb.Trans(x=length, y=0), + layer_info=L.WG, + ) + return c + + +@pdk.cell +def bend90(width: int, radius: int) -> kf.KCell: + """90° Euler bend. + + Args: + width: Width in dbu. + radius: Nominal bend radius in dbu. + """ + return kf.factories.euler.bend_euler_factory(kcl=pdk)( + width=pdk.to_um(width), + radius=pdk.to_um(radius), + layer=L.WG, + ) + + +# %% [markdown] +# ## Building a schematic for inspection +# +# A simple L-shaped path: `s1 → b1 → s2`. + + +# %% +@pdk.schematic_cell +def l_path() -> kf.Schematic: + schematic = kf.Schematic(kcl=pdk) + + s1 = schematic.create_inst("s1", "straight", {"width": 500, "length": 15_000}) + b1 = schematic.create_inst("b1", "bend90", {"width": 500, "radius": 10_000}) + s2 = schematic.create_inst("s2", "straight", {"width": 500, "length": 15_000}) + + s1.place(x=0, y=0) + b1.connect("o1", s1.ports["o2"]) + s2.connect("o1", b1.ports["o2"]) + + return schematic + + +cell = l_path() +cell + +# %% [markdown] +# ## The Netlist data model +# +# `KCell.netlist()` returns a `dict[str, Netlist]` keyed by cell name. Each `Netlist` +# has three attributes: +# +# | Attribute | Type | Meaning | +# |-----------|------|---------| +# | `instances` | `dict[str, NetlistInstance]` | Every sub-cell placed in this cell | +# | `nets` | `list[Net]` | Each net is a list of `PortRef` / `NetlistPort` entries that are connected | +# | `ports` | `list[NetlistPort]` | Top-level ports exposed by this cell | +# +# A `PortRef` identifies a port by instance name and port name: `PortRef(instance="s1", port="o2")`. +# A `NetlistPort` identifies a cell-level port by name. + +# %% +netlists = cell.netlist() +nl = netlists[cell.name].normalize() + +print(f"Instances ({len(nl.instances)}):") +for name, inst in nl.instances.items(): + print(f" {name}: component={inst.component!r} settings={inst.settings}") + +print(f"\nNets ({len(nl.nets)}):") +for i, net in enumerate(nl.nets): + parts = [] + for p in net: + if isinstance(p, PortRef): + parts.append(f"{p.instance}.{p.port}") + else: + parts.append(f"") + print(f" net[{i}]: {' — '.join(parts)}") + +print(f"\nTop-level ports ({len(nl.ports)}): {[p.name for p in nl.ports]}") + +# %% [markdown] +# ### Sorting nets for stable comparison +# +# Port ordering within a net and the order of nets across the netlist can vary between +# runs. `Netlist.sort()` normalises both, making equality checks reproducible. + +# %% +nl_a = cell.netlist()[cell.name] +nl_b = cell.netlist()[cell.name] + +# sort() modifies in-place and returns self +nl_a.sort() +nl_b.sort() + +assert nl_a == nl_b +print("Sorted netlists are equal ✓") + +# %% [markdown] +# ## Schematic serialization +# +# A `Schematic` is a Pydantic model, so standard Pydantic serialization methods work +# directly. + +# %% [markdown] +# ### JSON export + +# %% +model = cell.schematic +raw_json = model.model_dump_json(indent=2) +data = json.loads(raw_json) + +# Show the top-level keys present in the serialized schematic +print("Top-level keys:", list(data.keys())) + +# Show the placements section +print("\nPlacements:") +for name, placement in data.get("placements", {}).items(): + print(f" {name}: {placement}") + +# %% [markdown] +# ### YAML round-trip with `read_schematic` +# +# Schematics are typically stored as YAML files for version control. `kf.read_schematic` +# loads them back into a `Schematic` (or `DSchematic` when `unit="um"`). +# +# `Schematic` is also a Pydantic model, so `model_validate` works directly with a +# dictionary from `yaml.safe_load` — or you can use the convenience `read_schematic` helper. + +# %% +with tempfile.TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "l_path.yaml" + + # Exclude the 'unit' field — it is fixed by the Schematic subclass and must not + # be present in the serialized payload for read_schematic to accept it. + yaml_path.write_text(model.model_dump_json(indent=2, exclude={"unit"})) + + # Read back. NOTE: a known upstream bug currently prevents the JSON + # produced by `model_dump_json` from round-tripping through + # `read_schematic` when nets are present (see kfactory issue tracker — + # the `nets` validator expects `{"p1": ..., "p2": ...}` keys but + # `model_dump_json` serialises them as nested arrays). The pattern is + # shown for documentation; uncomment to test once the upstream fix lands. + try: + reloaded = kf.read_schematic(yaml_path, unit="dbu") + print("Reloaded schematic instances:", list(reloaded.instances.keys())) + print("Reloaded schematic placements:", list(reloaded.placements.keys())) + except KeyError as exc: + print(f"(known upstream round-trip bug — KeyError: {exc})") + +# %% [markdown] +# The reloaded schematic carries the same instance definitions and placements. +# Calling `reloaded.create_cell(output_type=kf.KCell, factories={"straight": straight, "bend90": bend90})` +# would materialise an identical physical cell. + +# %% [markdown] +# ## Netlist as JSON +# +# `Netlist.to_json()` serialises a netlist directly to a JSON string. This is +# the canonical wire format for handing a netlist to external tools or storing +# it in a regression-test fixture. +# +# Below we build a small layout with the **smallest available crossing +# primitive** — a `cross` cell — connected to a second crossing through a +# straight waveguide. We annotate each instance with a text label so that the +# rendered layout matches the names that appear in the JSON output. + + +# %% +@pdk.cell +def cross(width: int, length: int) -> kf.KCell: + """Minimal 4-port waveguide crossing. + + Two perpendicular rectangles meeting at the origin. Real PDK crossings + use tapered arms and an enclosure (see the `crossing45.py` tutorial) but + this minimal version is sufficient for demonstrating the netlist format. + + Args: + width: Waveguide width in dbu. + length: Arm length in dbu (also the cell footprint). + """ + c = pdk.kcell() + c.shapes(L.WG).insert( + kf.kdb.Box(-length // 2, -width // 2, length // 2, width // 2) + ) + c.shapes(L.WG).insert( + kf.kdb.Box(-width // 2, -length // 2, width // 2, length // 2) + ) + c.create_port( + name="o1", + width=width, + trans=kf.kdb.Trans(rot=0, mirrx=False, x=length // 2, y=0), + layer_info=L.WG, + ) + c.create_port( + name="o2", + width=width, + trans=kf.kdb.Trans(rot=1, mirrx=False, x=0, y=length // 2), + layer_info=L.WG, + ) + c.create_port( + name="o3", + width=width, + trans=kf.kdb.Trans(rot=2, mirrx=False, x=-length // 2, y=0), + layer_info=L.WG, + ) + c.create_port( + name="o4", + width=width, + trans=kf.kdb.Trans(rot=3, mirrx=False, x=0, y=-length // 2), + layer_info=L.WG, + ) + return c + + +@pdk.cell +def crossing_demo() -> kf.KCell: + """Two crossings connected by a straight, with instance-name labels.""" + c = pdk.kcell() + + x1 = c.create_inst(cross(width=500, length=10_000)) + x1.name = "x1" + + s_inst = c.create_inst(straight(width=500, length=15_000)) + s_inst.name = "s1" + s_inst.connect("o1", x1.ports["o1"]) + + x2_inst = c.create_inst(cross(width=500, length=10_000)) + x2_inst.name = "x2" + x2_inst.connect("o3", s_inst.ports["o2"]) + + # Annotate each instance with its name so the rendered layout matches the + # JSON output below. + for inst in c.insts: + center = inst.ibbox().center() + c.shapes(c.kcl.layer(L.WGEX)).insert( + kf.kdb.Text(inst.name, kf.kdb.Trans(center.x, center.y)) + ) + + return c + + +crossing_cell = crossing_demo() +crossing_cell + +# %% [markdown] +# Extract the netlist and dump it to JSON. Calling `sort()` first keeps the +# instance ordering stable so the JSON output is reproducible. + +# %% +nl = crossing_cell.netlist()[crossing_cell.name] +nl.sort() + +print(nl.to_json()) + +# %% [markdown] +# The JSON contains three top-level keys: +# +# - `instances` — each instance's `component`, `kcl` (the owning KCLayout name), +# and `settings` (the constructor kwargs). +# - `nets` — each net is a list of `{"instance": ..., "port": ...}` entries +# that are electrically tied together. +# - `ports` — the top-level cell-exposed ports (empty here because +# `crossing_demo` doesn't expose any ports). + +# %% [markdown] +# ## Building a Netlist programmatically +# +# You can also construct a `Netlist` directly without going through a schematic — useful +# for testing or for importing connectivity from an external source. + +# %% +from kfactory import Netlist + +manual_nl = Netlist() + +# Add instance definitions +manual_nl.create_inst( + "wg1", kcl="MY_PDK", component="straight", settings={"width": 500, "length": 10_000} +) +manual_nl.create_inst( + "wg2", kcl="MY_PDK", component="straight", settings={"width": 500, "length": 10_000} +) + +# Add a top-level port +p_in = manual_nl.create_port("in") + +# Connect: cell-level "in" is tied to wg1.o1 +manual_nl.create_net(p_in, PortRef(instance="wg1", port="o1")) + +# Internal net: wg1.o2 connects to wg2.o1 +manual_nl.create_net( + PortRef(instance="wg1", port="o2"), + PortRef(instance="wg2", port="o1"), +) + +manual_nl.sort() + +print("Instances:", list(manual_nl.instances.keys())) +print("Top-level ports:", [p.name for p in manual_nl.ports]) +print("Nets:") +for i, net in enumerate(manual_nl.nets): + parts = [ + f"{p.instance}.{p.port}" if isinstance(p, PortRef) else f"<{p.name}>" + for p in net + ] + print(f" net[{i}]: {parts}") + +# %% [markdown] +# ## Summary +# +# | Task | API | +# |------|-----| +# | Extract netlist from a cell | `cell.netlist()` → `dict[str, Netlist]` | +# | Iterate nets | `for net in nl.nets: for p in net: ...` | +# | Sort for stable comparison | `nl.sort()` | +# | Export schematic to JSON | `schematic.model_dump_json()` | +# | Load schematic from YAML/JSON file | `kf.read_schematic(path, unit="dbu")` | +# | Dump a netlist to JSON | `nl.to_json()` | +# | Build a netlist without a schematic | `Netlist(); nl.create_inst(...); nl.create_net(...)` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Schematic placement & connections | [Schematics: Overview](overview.py) | +# | 45° crossing with virtual cells | [Schematics: 45° Crossing](crossing45.py) | +# | Creating a full PDK | [PDK: Creating a PDK](../pdk/creating_pdk.py) | diff --git a/docs/source/schematics/overview.py b/docs/source/schematics/overview.py new file mode 100644 index 000000000..a1768a02e --- /dev/null +++ b/docs/source/schematics/overview.py @@ -0,0 +1,303 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Schematic-Driven Design +# +# kfactory supports a **schematic-first** design style: you declare what connects to what +# and where instances are placed, and kfactory builds the physical layout from that +# declarative description. +# +# This is distinct from the imperative approach (calling `connect()` on instances inside a +# `@kf.cell` function). Schematics are: +# +# - **Serialisable** — stored as YAML/JSON, not just in-memory +# - **Verifiable** — the extracted netlist can be compared against the schematic (LVS) +# - **Code-generatable** — a schematic can emit a standalone Python function that +# re-creates the same layout without the schematic machinery +# +# ## Key types +# +# | Class | Description | +# |-------|-------------| +# | `kf.Schematic` | DBU-coordinate schematic (placement in database units) | +# | `kf.DSchematic` | µm-coordinate schematic (floating-point placement) | +# | `kf.KCLayout.schematic_cell` | Decorator that turns a schematic factory into a cached cell | + +# %% +import kfactory as kf + +# %% [markdown] +# ## Setting up a PDK +# +# Schematics work inside a `KCLayout` (PDK). Cell functions registered on the PDK are +# looked up by name when `create_inst` is called — so every component you place must be +# registered first. + + +# %% +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + + +L = LAYER() +pdk = kf.KCLayout("SCHEM_OVERVIEW", infos=LAYER) + +# %% [markdown] +# ### Registering PDK cells +# +# PDK cells are plain `@pdk.cell`-decorated functions. Their parameters must be +# JSON-serialisable (int, float, str, bool) so the schematic can store them. + + +# %% +@pdk.cell +def straight(width: int, length: int) -> kf.KCell: + """Waveguide straight segment. + + Args: + width: Width in dbu. + length: Length in dbu. + """ + c = pdk.kcell() + c.shapes(L.WG).insert(kf.kdb.Box(0, -width // 2, length, width // 2)) + c.create_port( + name="o1", + width=width, + trans=kf.kdb.Trans(rot=2, mirrx=False, x=0, y=0), + layer_info=L.WG, + ) + c.create_port( + name="o2", + width=width, + trans=kf.kdb.Trans(x=length, y=0), + layer_info=L.WG, + ) + return c + + +@pdk.cell +def bend90(width: int, radius: int) -> kf.KCell: + """90° Euler bend. + + Args: + width: Width in dbu. + radius: Nominal bend radius in dbu. + """ + return kf.factories.euler.bend_euler_factory(kcl=pdk)( + width=pdk.to_um(width), + radius=pdk.to_um(radius), + layer=L.WG, + ) + + +# %% [markdown] +# ## Basic schematic: placement only +# +# The simplest schematic just places instances at known coordinates. +# `schematic.create_inst` looks up the component by name in the PDK and records the +# settings. `inst.place(x, y)` sets the origin in dbu. + + +# %% +@pdk.schematic_cell +def two_straights() -> kf.Schematic: + schematic = kf.Schematic(kcl=pdk) + + s1 = schematic.create_inst( + name="s1", + component="straight", + settings={"width": 500, "length": 10_000}, + ) + s2 = schematic.create_inst( + name="s2", + component="straight", + settings={"width": 500, "length": 10_000}, + ) + + s1.place(x=0, y=0) + s2.place(x=20_000, y=0) + + return schematic + + +cell = two_straights() +cell + +# %% [markdown] +# The `@pdk.schematic_cell` decorator caches the result just like `@pdk.cell`, so calling +# `two_straights()` a second time returns the same object. + +# %% [markdown] +# ## Connectivity: `connect` +# +# `inst.connect(port_name, other_port)` aligns an instance so that the named port is +# mated with `other_port`. This is the schematic equivalent of the imperative +# `instance.connect()` in a regular cell body. + + +# %% +@pdk.schematic_cell +def chain_of_three() -> kf.Schematic: + schematic = kf.Schematic(kcl=pdk) + + s1 = schematic.create_inst( + name="s1", + component="straight", + settings={"width": 500, "length": 20_000}, + ) + b1 = schematic.create_inst( + name="b1", + component="bend90", + settings={"width": 500, "radius": 10_000}, + ) + s2 = schematic.create_inst( + name="s2", + component="straight", + settings={"width": 500, "length": 20_000}, + ) + + # Fix s1 at the origin + s1.place(x=0, y=0) + + # Snap b1's input port ("o1") onto s1's output port ("o2") + b1.connect("o1", s1.ports["o2"]) + + # Snap s2's input port ("o1") onto b1's output port ("o2") + s2.connect("o1", b1.ports["o2"]) + + return schematic + + +chain = chain_of_three() +chain + +# %% [markdown] +# ## The schematic model +# +# The `schematic` attribute on a schematic cell contains the full declarative description +# as a Pydantic model. This includes instances, placements, nets, and routes. + +# %% + +model = chain.schematic +print("Instances:", list(model.instances.keys())) +print( + "Placements:", {k: (v.x, v.y, v.orientation) for k, v in model.placements.items()} +) + +# %% [markdown] +# ## Netlist extraction +# +# `cell.netlist()` extracts a connectivity netlist from the physical layout. +# Because each `SchematicInstance` places a real KCell into the layout, the extracted +# netlist reflects the actual geometry — not just the schematic intent. + +# %% +netlist = chain.netlist() +for cell_name, net in netlist.items(): + print(f"\n=== {cell_name} ===") + print(f" instances: {list(net.instances.keys())}") + for i, n in enumerate(net.nets): + print(f" net[{i}]: {[f'{p.instance}.{p.port}' for p in n]}") + print(f" ports: {[p.name for p in net.ports]}") + +# %% [markdown] +# ## Schematic netlist vs extracted netlist (LVS) +# +# For a schematic cell with declared nets (`schematic.nets`), kfactory can compare the +# schematic connectivity against the extracted layout connectivity. Here we use a version +# with explicit nets to demonstrate the LVS flow. + + +# %% +@pdk.schematic_cell +def lvs_example() -> kf.Schematic: + schematic = kf.Schematic(kcl=pdk) + + s1 = schematic.create_inst( + name="s1", + component="straight", + settings={"width": 500, "length": 15_000}, + ) + s2 = schematic.create_inst( + name="s2", + component="straight", + settings={"width": 500, "length": 15_000}, + ) + + s1.place(x=0, y=0) + + # connect() both places s2 and records the connectivity in the schematic model + s2.connect("o1", s1.ports["o2"]) + + return schematic + + +lvs_cell = lvs_example() + +# schematic.netlist() derives connectivity from the declared placements/connections +schematic_netlist = lvs_cell.schematic.netlist() + +# cell.netlist() derives connectivity from the physical layout geometry +extracted_netlist = lvs_cell.netlist()[lvs_cell.name] + +assert schematic_netlist == extracted_netlist, "LVS failed!" +print("LVS passed: schematic and extracted netlists match.") + +# %% [markdown] +# ## Code generation +# +# `schematic.code_str()` generates a standalone Python function that re-creates the same +# layout without the schematic machinery. This is useful for: +# +# - Exporting a schematic-designed cell for use in a lower-level flow +# - Archiving a point-in-time snapshot of a parameterised design +# - Sharing a self-contained design with collaborators who do not use schematics + +# %% +from IPython.display import Code + +generated = chain.schematic.code_str() +Code(generated) + +# %% [markdown] +# The generated code is a regular `@kcl.schematic_cell`-decorated function — you can +# copy it into any file that imports the same PDK and it will produce an identical cell. +# +# ## Summary +# +# | Operation | API | +# |-----------|-----| +# | Define a schematic cell | `@pdk.schematic_cell` | +# | Add a component instance | `schematic.create_inst(name, component, settings)` | +# | Place at coordinate | `inst.place(x, y)` | +# | Connect two ports | `inst.connect(port, other_port)` | +# | Declare explicit net | `schematic.add_net(name, [port, ...])` | +# | Extract layout netlist | `cell.netlist()` | +# | Get schematic model | `cell.schematic` | +# | Generate standalone code | `cell.schematic.code_str()` | + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Netlist data model | [Schematics: Netlist](netlist.py) | +# | 45° crossing with virtual cells | [Schematics: 45° Crossing](crossing45.py) | +# | Creating a full PDK | [PDK: Creating a PDK](../pdk/creating_pdk.py) | +# | PCells & caching | [Components: PCells](../components/cells/pcells.py) | diff --git a/docs/source/schematics/ports_and_pins.py b/docs/source/schematics/ports_and_pins.py new file mode 100644 index 000000000..122644585 --- /dev/null +++ b/docs/source/schematics/ports_and_pins.py @@ -0,0 +1,326 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Schematic Ports & Pins +# +# A schematic exposes connectivity to the outside world through two complementary +# objects: +# +# - **Ports** — individual terminals (single waveguide port, single electrical +# contact). They get materialized as `KCell.ports` on the resulting cell. +# - **Pins** — *named bundles of ports*. They group one or more cell-level ports under +# a single logical terminal (e.g. a DC pad's two contacts, a multi-mode bus). +# +# This tutorial covers both APIs end-to-end: how to expose ports at the schematic +# level, how to pin them together, how the schematic stores everything as Pydantic +# data, and how the YAML round-trip works. +# +# For schematic basics (instances, placements, connections) see +# [Schematic-Driven Design](overview.py). + +# %% +import kfactory as kf + +# %% [markdown] +# ## PDK setup +# +# A minimal PDK with a single `straight` cell. The cell exposes two waveguide ports +# (`o1`, `o2`) and groups them under one cell-level pin called `"dc"`. + + +# %% +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + + +L = LAYER() +pdk = kf.KCLayout("SCHEM_PORTS_PINS", infos=LAYER) + + +@pdk.cell +def straight(width: int = 500, length: int = 10_000) -> kf.KCell: + """Straight waveguide. Both optical ports are also grouped under a `"dc"` pin.""" + c = pdk.kcell() + c.shapes(L.WG).insert(kf.kdb.Box(0, -width // 2, length, width // 2)) + p1 = c.create_port( + name="o1", + width=width, + trans=kf.kdb.Trans(rot=2, x=0, y=0), + layer_info=L.WG, + ) + p2 = c.create_port( + name="o2", + width=width, + trans=kf.kdb.Trans(x=length, y=0), + layer_info=L.WG, + ) + c.create_pin(name="dc", ports=[p1, p2], pin_type="DC", info={"role": "bus"}) + return c + + +# %% [markdown] +# ## Part 1 — Ports on a schematic +# +# A `KCell` exposes ports via `c.create_port(...)`. A *schematic* exposes ports via +# `schematic.ports` — a dict mapping name → one of three things: +# +# | Stored as | Created with | Meaning | +# |-----------|--------------|---------| +# | `PortRef` / `PortArrayRef` | `schematic.add_port(name, port=inst.ports[...])` | Forward an instance port up to the top level | +# | `Port[T]` | `schematic.create_port(name, cross_section, x, y, ...)` | A new placeable top-level port whose position is computed at build time | +# +# Schematic ports are materialized as `KCell.ports` when the cell is built. +# For `PortRef`, the underlying instance port is added to the top-level cell under +# the schematic-port key name. + +# %% [markdown] +# ### `add_port` — forward an instance port +# +# Use `add_port` whenever you want a top-level port that simply mirrors an existing +# instance port. The schematic stores a `PortRef` lazily; the cell port is created +# when `create_cell` runs. + + +# %% +@pdk.schematic_cell +def forwarded_ports() -> kf.Schematic: + schematic = kf.Schematic(kcl=pdk) + + s = schematic.create_inst( + name="s", component="straight", settings={"width": 500, "length": 10_000} + ) + s.place(x=0, y=0) + + # forward each instance port up to the top level. the schematic-port key name + # becomes the cell-port name on the resulting KCell — it does not have to match + # the instance's port name. + schematic.add_port(name="left", port=s.ports["o1"]) + schematic.add_port(name="right", port=s.ports["o2"]) + + return schematic + + +cell = forwarded_ports() +print("cell ports:", [p.name for p in cell.ports]) + +# %% [markdown] +# Inside the schematic, the entries are `PortRef` objects pointing at the underlying +# instance: + +# %% +for name, port in cell.schematic.ports.items(): + print(f" {name!r}: {port}") + +# %% [markdown] +# ### `create_port` — placeable top-level port +# +# Use `create_port` when the top-level port is *not* a copy of an instance port — +# typically when you want a port at a fixed location, or whose position/orientation +# is a function of an instance's geometry but not directly any port. +# +# Both absolute coordinates and `PortRef`s are accepted for `x`, `y`, and +# `orientation`, so you can pin the new port to an instance: + + +# %% +@pdk.schematic_cell +def fanout_port() -> kf.Schematic: + schematic = kf.Schematic(kcl=pdk) + + s = schematic.create_inst( + name="s", component="straight", settings={"width": 500, "length": 10_000} + ) + s.place(x=0, y=0) + + # an extra "monitor" port placed 5 µm above the centre of the straight, facing up + schematic.create_port( + name="monitor", + cross_section="WG_500", + x=5_000, + y=5_000, + orientation=90, + ) + + schematic.add_port(name="left", port=s.ports["o1"]) + schematic.add_port(name="right", port=s.ports["o2"]) + return schematic + + +# %% [markdown] +# !!! note +# `create_port` requires a registered cross-section. The example above assumes a +# `WG_500` cross-section was registered on the PDK; in this notebook we don't +# instantiate `fanout_port()` because the PDK has no cross-section table — it's +# here for API illustration only. See the [PDK page](../pdk/creating_pdk.py) for +# setting up cross-sections. + +# %% [markdown] +# ## Part 2 — Pins +# +# A pin is a named bundle of ports. It carries a `pin_type` (`"DC"` by default) and +# free-form `info`. At the schematic level there are two ways to define one: +# +# | Stored as | Created with | Meaning | +# |-----------|--------------|---------| +# | `Pin` | `schematic.create_pin(name, ports=[...])` | Group existing top-level schematic ports | +# | `PinRef` | `schematic.add_pin(name, pin=inst.pins["..."])` | Forward an instance's pin to the top level | +# +# Pins are **structural only** for now — they're not part of nets, connections, or +# routes. They sit alongside ports on the schematic and on the resulting cell. + +# %% [markdown] +# ### `create_pin` — explicit grouping +# +# Use this when the pin's member ports come from different instances or don't align +# with any single instance pin. The constituent port names must already exist in +# `schematic.ports` (typically via `add_port`). + + +# %% +@pdk.schematic_cell +def explicit_pin() -> kf.Schematic: + schematic = kf.Schematic(kcl=pdk) + + s = schematic.create_inst( + name="s", component="straight", settings={"width": 500, "length": 10_000} + ) + s.place(x=0, y=0) + + schematic.add_port(name="left", port=s.ports["o1"]) + schematic.add_port(name="right", port=s.ports["o2"]) + + schematic.create_pin( + name="bus", ports=["left", "right"], pin_type="RF", info={"freq": 5} + ) + + return schematic + + +cell = explicit_pin() +for pin in cell.pins: + print( + f"pin {pin.name!r}:" + f" ports={[p.name for p in pin.ports]}" + f" type={pin.pin_type!r}" + f" info={dict(pin.info)}" + ) + +# %% [markdown] +# ### `add_pin` — forward an instance pin +# +# Use `add_pin` when an instance already exposes a pin you want at the top level. +# `inst.pins["name"]` produces a `PinRef`; pass it to `add_pin`. +# +# **Pre-condition:** every constituent port of the instance pin must be exposed as a +# top-level schematic port beforehand. The materialization step at `create_cell` +# time looks up each port via that exposure — if any port is missing you'll get a +# clear error. + + +# %% +@pdk.schematic_cell +def forwarded_pin() -> kf.Schematic: + schematic = kf.Schematic(kcl=pdk) + + s = schematic.create_inst( + name="s", component="straight", settings={"width": 500, "length": 10_000} + ) + s.place(x=0, y=0) + + # both underlying ports of the instance's "dc" pin are needed at the top level + schematic.add_port(name="left", port=s.ports["o1"]) + schematic.add_port(name="right", port=s.ports["o2"]) + + # forward the pin — pin_type and info are inherited from the instance pin + schematic.add_pin(name="dc", pin=s.pins["dc"]) + return schematic + + +cell = forwarded_pin() +for pin in cell.pins: + print(f"pin {pin.name!r}: type={pin.pin_type!r} info={dict(pin.info)}") + +# %% [markdown] +# ## Part 3 — YAML round-trip +# +# Schematic ports and pins serialize alongside instances and placements. The format +# accepts two shorthands: +# +# - Ports: `","` (forwarded) or a `{x, y, ...}` dict (placeable) +# - Pins: `","` (forwarded) or a `{ports: [...], pin_type, info}` block + +# %% +yaml_str = """ +instances: + s: + component: straight + settings: {width: 500, length: 10000} + +placements: + s: {x: 0, y: 0} + +ports: + left: s,o1 + right: s,o2 + +pins: + bus: + ports: [left, right] + pin_type: RF + fwd: s,dc +""" + +from ruamel.yaml import YAML + +yaml = YAML(typ="safe") +schematic = kf.Schematic.model_validate(yaml.load(yaml_str)) + +print("ports:", {n: type(p).__name__ for n, p in schematic.ports.items()}) +print("pins:", {n: type(p).__name__ for n, p in schematic.pins.items()}) +print("bus.ports:", schematic.pins["bus"].ports) +print("fwd:", schematic.pins["fwd"]) + +# %% [markdown] +# ## Pitfalls +# +# - **Schematic-port key ≠ original port name.** When you forward an instance port +# with `add_port(name="left", port=s.ports["o1"])`, the cell's top-level port is +# named `"left"`, not `"o1"`. The same naming applies to ports referenced from +# pins. +# - **Forwarded pins need exposed underlying ports.** When you call +# `add_pin(pin=inst.pins["x"])`, every constituent port of the instance pin must +# already be a top-level schematic port. The materialization step at `create_cell` +# time raises a clear error otherwise. +# - **Pins are top-level only.** They don't take part in nets, connections, or +# routes. Connect ports the usual way (`inst.connect`, `add_route`); pins exist +# purely to group ports for tooling that consumes them. +# - **Names are unique within their dict.** `add_port`/`create_port` share +# `schematic.ports`; `add_pin`/`create_pin` share `schematic.pins`. Each raises on +# duplicate names. +# +# ## Summary +# +# | Operation | API | +# |-----------|-----| +# | Forward an instance port to the top level | `schematic.add_port(name, port=inst.ports[...])` | +# | Create a placeable top-level port | `schematic.create_port(name, cross_section, x, y, orientation)` | +# | Reference an instance port | `inst.ports["o1"]` → `PortRef` | +# | Reference an array instance port | `inst.ports["o1", ia, ib]` → `PortArrayRef` | +# | Group existing top-level ports into a pin | `schematic.create_pin(name, ports=[...], pin_type, info)` | +# | Forward an instance pin to the top level | `schematic.add_pin(name, pin=inst.pins[...])` | +# | Reference an instance pin | `inst.pins["dc"]` → `PinRef` | +# | Inspect cell-level ports / pins | `cell.ports`, `cell.pins` | diff --git a/docs/source/star.py b/docs/source/star.py deleted file mode 100644 index 28e27ce03..000000000 --- a/docs/source/star.py +++ /dev/null @@ -1,108 +0,0 @@ -# This script uses kfactory to programmatically create a complex chip layout that looks like a starry sky. -# It does this by defining components for stars, placing them randomly, and then subtracting their shapes from a background layer. - -import kfactory as kf -import numpy as np -from layers import LAYER -import random - - -@kf.cell -def star( - size: float, proportion: float, n_diamonds: int = 3, layer: kf.kdb.LayerInfo = LAYER.SI -) -> kf.KCell: - """Create a diamond star cell - - Args: - size: size in um - proportion: width of the center vs size (0 < proportion <= 1) - n_diamonds: number of diamonds in the star (>=1) - - Returns: - Star Cell - """ - - c = kf.KCell() - - # For the first star diamond, we use a box (int based) - - diamond = kf.kdb.DPolygon( - [ - kf.kdb.DPoint(-size / 2, 0), - kf.kdb.DPoint(0, -size / 2 * proportion), - kf.kdb.DPoint(size / 2, 0), - kf.kdb.DPoint(0, size / 2 * proportion), - ] - ) - - li = c.kcl.find_layer(layer) - c.shapes(li).insert(diamond) # place base diamond - - for i in range(1, n_diamonds): - angle = 180 / (n_diamonds) * i - - c.shapes(li).insert( - diamond.transformed(kf.kdb.DCplxTrans(1, angle, False, 0, 0)) - ) - - return c - - -@kf.cell -def merged_star( - size: float, proportion: float, n_diamonds: int = 3, layer: kf.kdb.LayerInfo = LAYER.SI -) -> kf.KCell: - """Same as star but use the star shapes and merge them to one polygon""" - - c = kf.KCell() - - _star = star(size, proportion, n_diamonds, layer) - reg = kf.kdb.Region(_star.begin_shapes_rec(c.kcl.find_layer(layer))) - reg.merge() # merge the region - - # Insert the region - - c.shapes(c.kcl.find_layer(layer)).insert(reg) - - return c - - -@kf.cell -def sky_with_stars() -> kf.KCell: - c = kf.KCell() - - box = kf.kdb.Box(0, 0, 400000, 400000) # 400umx400um sky (default dbu) - sky = kf.kdb.Region(box) - - # Set a custom seed for random - seed = 314159 - random.seed(seed) - - star_layer = LAYER.SI - - # create 50 stars - for _ in range(50): - x = random.uniform(10, 390) - y = random.uniform(10, 390) - angle = random.uniform(0, 360) - _star = c << merged_star( - size=random.uniform(5, 25), - proportion=random.uniform(0.2, 0.3), - n_diamonds=random.randint(2, 5), - layer=star_layer, - ) - _star.transform(kf.kdb.DTrans(angle, False, x, y)) - - # Remove the stars from the sky - - sky -= kf.kdb.Region(c.begin_shapes_rec(c.kcl.find_layer(star_layer))) - - c.shapes(c.kcl.find_layer(LAYER.SIEXCLUDE)).insert(sky) - - return c - - -if __name__ == "__main__": - # kf.show(merged_star(5, 0.25)) - - kf.show(sky_with_stars()) diff --git a/docs/source/straight.py b/docs/source/straight.py deleted file mode 100644 index 1bf4715d2..000000000 --- a/docs/source/straight.py +++ /dev/null @@ -1,57 +0,0 @@ -# This script defines and displays a reusable component for a straight optical waveguide. -# It also includes a secondary "exclusion" layer. -# The function creates a KCell and draws two centered rectangles. -# It then creates and defines two ports, which store the location, orientation and width. -# Also automatically renames ports. E.g. "o1" and "o2". -# It then allows the component to be opened in KLayout, through the kf.show function. - -from layers import LAYER - -import kfactory as kf - -# LAYER.SI: Refers to the Silicon layer (e.g., GDSII layer 1, datatype 0), -# which forms the physical core of the waveguide where light is confined. -# LAYER.SIEXCLUDE: Refers to an Exclusion layer (e.g., GDSII layer 1, datatype 1). -# This is a metadata layer used for Design Rule Checking (DRC). -# It defines a "keep-out" zone around the waveguide, -# essentially instructing automated tools not to place other silicon structures within this boundary. -# This is done to prevent performance degradation from optical crosstalk.(Two light sources interfering with one another) -# Then, two rectangular shapes are drawn: the core and the wider exclusion zone. They are created with kf.kdb.Box(left, bottom, right, top): -# By using -width // 2 and width // 2 for the bottom and top coordinates, the waveguide is centered vertically on the y=0 axis -# trans=kf.kdb.Trans(2, False, 0, 0): The Trans object defines the port's transformation. The arguments are (rotation, mirror, x, y), this means: -# Input port 1 is rotated by 180 degrees(2), not mirrored(false) and at the default 0 position on the x and y-axis (0, 0) -# Input port 2 is not rotated and not mirrored. -# c.auto_rename_ports(): This utility standardizes port names based on their location (e.g., left port becomes "o1", right becomes "o2") -# if __name__ == "__main__": This creates a condition, it will only function when directly executed. -# The result is then shown via kf.show and has the following physical dimensions: -# width: 2000 dbu = 2.0 µm -# length: 50000 dbu = 50.0 µm -# width_exclude: 5000 dbu = 5.0 µm - - -@kf.cell -def straight(width: int, length: int, width_exclude: int) -> kf.KCell: - """Waveguide: Silicon on 1/0, Silicon exclude on 1/1""" - c = kf.KCell() - c.shapes(c.kcl.find_layer(LAYER.SI)).insert(kf.kdb.Box(0, -width // 2, length, width // 2)) - c.shapes(c.kcl.find_layer(LAYER.SIEXCLUDE)).insert( - kf.kdb.Box(0, -width_exclude // 2, length, width_exclude // 2) - ) - - c.create_port( - name="1", trans=kf.kdb.Trans(2, False, 0, 0), width=width, layer_info=LAYER.SI - ) - c.create_port( - name="2", - trans=kf.kdb.Trans(0, False, length, 0), - width=width, - layer_info=LAYER.SI, - ) - - c.auto_rename_ports() - - return c - - -if __name__ == "__main__": - kf.show(straight(2000, 50000, 5000)) diff --git a/docs/source/utilities/drc_fix.py b/docs/source/utilities/drc_fix.py new file mode 100644 index 000000000..a8f85134c --- /dev/null +++ b/docs/source/utilities/drc_fix.py @@ -0,0 +1,199 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # DRC Fixing Utilities +# +# **Design Rule Check (DRC)** violations occur when shapes on a layer violate +# foundry-specified constraints — most commonly: +# +# | Violation type | Description | +# |---|---| +# | **Min space** | Two polygons on the same layer are closer than the allowed gap | +# | **Min width** | A polygon is narrower than the process can reliably fabricate | +# +# kfactory provides tiled utilities that detect and *automatically repair* these +# violations by merging nearby shapes. All fixers live in `kf.utils`: +# +# | Function | Strategy | Best for | +# |---|---|---| +# | `kf.utils.fix_spacing_tiled` | Space-check + merge | General min-space cleanup | +# | `kf.utils.fix_spacing_minkowski_tiled` | Minkowski dilation/erosion | Smoother results on complex geometry | +# +# Both work by splitting the cell into rectangular **tiles**, processing each tile in +# parallel, and collecting the corrected geometry into a new `kdb.Region`. +# This keeps memory usage constant regardless of layout size. + +# %% [markdown] +# ## Setup + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + WG_FIXED: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + WG_FIXED_MK: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## 1 · Minimum-Space Violations +# +# ### Creating a layout with violations +# +# We place a grid of ellipses whose edge-to-edge gap is smaller than the +# minimum space rule. Each ellipse fits in an 8 µm × 12 µm box; adjacent +# columns are placed 8.5 µm apart, leaving a 500 nm gap — half of the +# 1 µm minimum space rule we apply below. + +# %% + +MIN_SPACE_DBU = 1000 # 1 µm minimum space rule + +c = kf.KCell(name="drc_demo") + +# Place ellipses in a grid with a 500 nm gap between columns — violates the +# 1 µm min-space rule. +for row in range(4): + for col in range(5): + ellipse = kf.kdb.Polygon.ellipse(kf.kdb.Box(8000, 12000), 64) + # Offset each column by 8500 dbu — gap of 500 dbu violates the + # 1000 dbu min-space rule. + c.shapes(c.kcl.layer(L.WG)).insert( + ellipse.transformed(kf.kdb.Trans(col * 8500, row * 14000)) + ) + +c + +# %% [markdown] +# ### Applying `fix_spacing_tiled` +# +# `fix_spacing_tiled` detects pairs of polygons that are closer than `min_space` and +# merges them. The function returns a `kdb.Region` with the corrected geometry. +# +# Key parameters: +# - `min_space` — minimum allowed gap in **dbu** +# - `metrics` — `Euclidian` (default) or `Square`; controls how distance is measured +# - `n_threads` — parallelism; `None` uses kfactory's default (CPU count) +# - `tile_size` — tile dimensions in **µm**; auto-selected if `None` + +# %% +fixed_region = kf.utils.fix_spacing_tiled( + c, + MIN_SPACE_DBU, + L.WG, + metrics=kf.kdb.Metrics.Euclidian, +) + +# Insert corrected geometry onto WG_FIXED layer so we can compare +c.shapes(c.kcl.layer(L.WG_FIXED)).insert(fixed_region) + +c + +# %% [markdown] +# **What happened:** polygons that were too close were merged into a single polygon. +# The `WG` layer (layer 1) still has the original violating shapes; `WG_FIXED` +# (layer 2) contains the DRC-clean result. +# +# ### Applying `fix_spacing_minkowski_tiled` +# +# The Minkowski-sum approach dilates each polygon by `min_space/2`, merges overlapping +# shapes, then erodes by the same amount. This produces smoother, more rounded outlines +# compared to the space-check approach — useful when device performance is sensitive to +# sharp corners. +# +# The optional `smooth` parameter controls how aggressively corners are simplified +# after the fix (value in dbu). + +# %% +fixed_mk_region = kf.utils.fix_spacing_minkowski_tiled( + c, + MIN_SPACE_DBU, + L.WG, + smooth=20, +) + +c.shapes(c.kcl.layer(L.WG_FIXED_MK)).insert(fixed_mk_region) + +c + +# %% [markdown] +# ## 2 · Choosing the Right Fixer +# +# | Scenario | Recommended fixer | +# |---|---| +# | Rectilinear (Manhattan) geometry | `fix_spacing_tiled` — faster, exact edges | +# | Curved / free-form geometry | `fix_spacing_minkowski_tiled` — smoother result | +# | Very large layouts (>10 mm²) | Either; both tile automatically | +# +# ## 3 · Workflow Pattern +# +# A typical post-layout DRC fix workflow: +# +# ```python +# import kfactory as kf +# from kfactory.utils.violations import fix_spacing_tiled +# +# # 1. Load or create your cell +# kcl = kf.KCLayout("my_pdk") +# cell = kcl["TOP"] +# +# # 2. Run the fixer (returns a Region, does not modify in-place) +# clean = kf.utils.fix_spacing_tiled(cell, min_space=200, layer=LAYER.WG) +# +# # 3. Replace the original geometry with the fixed version +# li = kcl.layer(LAYER.WG) +# cell.clear(li) +# cell.shapes(li).insert(clean) +# +# # 4. Write out +# kcl.write("fixed.gds") +# ``` +# +# > **Note:** `fix_spacing_tiled` and `fix_spacing_minkowski_tiled` return a +# > `kdb.Region` — they do **not** modify the input cell in-place. Insert the +# > result where you need it, replacing or supplementing the original layer. + +# %% [markdown] +# ## 4 · Performance Notes +# +# Both fixers use KLayout's `TilingProcessor` internally. Tile size is auto-selected +# as `max(25 × min_space_µm, 250 µm)` but can be tuned: +# +# ```python +# # Larger tiles → fewer tiles, more memory per tile +# kf.utils.fix_spacing_tiled(c, 200, LAYER.WG, tile_size=(500, 500)) +# +# # Smaller tiles → lower peak memory, more parallelism benefit +# kf.utils.fix_spacing_tiled(c, 200, LAYER.WG, tile_size=(50, 50)) +# ``` +# +# Thread count defaults to `kf.config.n_threads` (≈ logical CPU count). +# Override with `n_threads=N` when running in a resource-constrained environment. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Boolean / region operations | [Core Concepts: Geometry](../concepts/geometry.py) | +# | Tile-based fill | [Utilities: Fill](fill.py) | +# | Cell-level enclosures | [Enclosures: KCell Enclosure](../enclosures/kcell_enclosure.py) | diff --git a/docs/source/utilities/fill.py b/docs/source/utilities/fill.py new file mode 100644 index 000000000..fcb2b59dd --- /dev/null +++ b/docs/source/utilities/fill.py @@ -0,0 +1,239 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Fill Utilities +# +# Foundry tape-outs typically require a **metal/poly fill** step: small dummy +# shapes are tiled over the floorplan to improve CMP (chemical-mechanical +# planarisation) uniformity. kfactory's `kf.fill` module wraps KLayout's +# `TilingProcessor`-based fill engine into a single high-level call. +# +# | Function | Purpose | +# |---|---| +# | `kf.fill.fill_tiled` | Tile a fill cell over layer-defined or explicit regions, with per-layer or explicit exclusions | +# +# **Key rules** +# +# * Call `fill_tiled` **inside** the `@kf.cell` function so the target cell is +# still unlocked. Calling it on a cached (locked) cell raises `LockedError`. +# * `fill_layers` / `fill_regions` define *where to place* fill. +# * `exclude_layers` / `exclude_regions` define *where not to place* fill. +# * The second element of every `(layer, margin)` tuple is a keepout distance +# in **µm** expanded around that layer's shapes. +# * `x_space` / `y_space` set the gap between adjacent fill-cell bounding +# boxes; both are in **µm**. + +# %% [markdown] +# ## Setup + +# %% +import kfactory as kf +from kfactory.utils.fill import fill_tiled + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + FILL: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0) + METAL: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## 1 · Fill Cell +# +# A fill cell is any ordinary `KCell`. Make it small and symmetric so it +# tiles cleanly. Here we use a 1 µm × 1 µm square centred on the origin. + + +# %% +@kf.cell +def fill_dot() -> kf.KCell: + c = kf.KCell() + c.shapes(kf.kcl.find_layer(L.FILL)).insert(kf.kdb.DBox(-0.5, -0.5, 0.5, 0.5)) + return c + + +fill_dot() + +# %% [markdown] +# ## 2 · Basic Layer Fill +# +# Pass `fill_layers` to fill every polygon on a given layer. The second +# element of the tuple is the keepout in µm expanded around those shapes +# **before** the fill region is computed (use `0` for no keepout on the +# fill layer itself). +# +# `exclude_layers` works the same way for shapes that must *not* be covered. +# A keepout of `2.0` µm means fill cells whose bounding box would overlap +# within 2 µm of a waveguide edge are suppressed. + + +# %% +@kf.cell +def chip_basic() -> kf.KCell: + c = kf.KCell() + # 100 µm × 100 µm floorplan + c.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert(kf.kdb.DBox(0, 0, 100, 100)) + # Horizontal waveguide through the middle — must stay clear of fill + c.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(0, 45, 100, 55)) + + fill_tiled( + c, + fill_dot(), + fill_layers=[ + (L.FLOORPLAN, 0) + ], # fill inside FLOORPLAN; 0 µm keepout on the layer itself + exclude_layers=[(L.WG, 2.0)], # keep 2 µm clear of WG edges + x_space=1.0, # 1 µm gap between fill cells in X + y_space=1.0, # 1 µm gap between fill cells in Y + ) + return c + + +chip_basic() + +# %% [markdown] +# ## 3 · Explicit Region Fill +# +# Instead of relying on layer shapes you can pass a `kdb.Region` directly via +# `fill_regions`. This is useful when the fill boundary is computed +# programmatically rather than stored on a layer. + + +# %% +@kf.cell +def chip_region() -> kf.KCell: + c = kf.KCell() + # Waveguide on WG layer — must stay clear + c.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(10, 20, 90, 30)) + + # Build an explicit fill region: a 100 µm square in DBU + fill_region = kf.kdb.Region(kf.kdb.Box(kf.kcl.to_dbu(100), kf.kcl.to_dbu(100))) + + fill_tiled( + c, + fill_dot(), + fill_regions=[(fill_region, 0)], # (region, keepout µm) + exclude_layers=[(L.WG, 1.5)], + x_space=1.0, + y_space=1.0, + ) + return c + + +chip_region() + +# %% [markdown] +# ## 4 · Custom Step Vectors +# +# By default fill cells are placed on a rectangular grid aligned to the fill +# cell's bounding box. Pass `row_step` and `col_step` as `kdb.DVector` +# objects (in **µm**) for a custom pitch. + + +# %% +@kf.cell +def chip_custom_pitch() -> kf.KCell: + c = kf.KCell() + c.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert(kf.kdb.DBox(0, 0, 80, 80)) + c.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(20, 35, 60, 45)) + + fill_tiled( + c, + fill_dot(), + fill_layers=[(L.FLOORPLAN, 0)], + exclude_layers=[(L.WG, 1.0)], + # 3 µm pitch in X, 2 µm pitch in Y + row_step=kf.kdb.DVector(3.0, 0), + col_step=kf.kdb.DVector(0, 2.0), + ) + return c + + +chip_custom_pitch() + +# %% [markdown] +# ## 5 · Multiple Exclusion Layers +# +# Pass multiple `(layer, keepout)` pairs to `exclude_layers`. Each layer can +# have a different keepout distance. + + +# %% +@kf.cell +def chip_multi_excl() -> kf.KCell: + c = kf.KCell() + c.shapes(kf.kcl.find_layer(L.FLOORPLAN)).insert(kf.kdb.DBox(0, 0, 100, 100)) + # Horizontal waveguide + c.shapes(kf.kcl.find_layer(L.WG)).insert(kf.kdb.DBox(0, 45, 100, 55)) + # Metal pad — needs a wider keepout than the waveguide + c.shapes(kf.kcl.find_layer(L.METAL)).insert(kf.kdb.DBox(30, 10, 70, 30)) + + fill_tiled( + c, + fill_dot(), + fill_layers=[(L.FLOORPLAN, 0)], + exclude_layers=[ + (L.WG, 2.0), # 2 µm keepout around waveguides + (L.METAL, 3.0), # 3 µm keepout around metal pads + ], + x_space=1.0, + y_space=1.0, + ) + return c + + +chip_multi_excl() + +# %% [markdown] +# ## API Summary +# +# ```python +# kf.fill.fill_tiled( +# c, # target KCell (must be unlocked) +# fill_cell, # cell to tile +# fill_layers = [(layer, um)], # layer-defined fill regions +# fill_regions = [(region, um)], # explicit kdb.Region fill regions +# exclude_layers = [(layer, um)], # layers to keep clear +# exclude_regions = [(region, um)],# explicit regions to keep clear +# x_space = 0, # µm gap between fill cells in X +# y_space = 0, # µm gap between fill cells in Y +# row_step = None, # kdb.DVector (µm); default = fill_cell width + x_space +# col_step = None, # kdb.DVector (µm); default = fill_cell height + y_space +# tile_size = None, # (w, h) µm; default = 100× fill-cell pitch +# tile_border = (20, 20), # µm border around each tile for exclusion look-up +# n_threads = None, # defaults to kf.config.n_threads (all logical CPUs) +# multi = False, # use fill_region_multi strategy (no origin alignment) +# ) +# ``` +# +# > **Note:** `fill_tiled` modifies the target cell **in-place** and returns +# > `None`. It must be called while the cell is unlocked, i.e. inside its +# > `@kf.cell`-decorated factory function. + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | DRC violation fixing | [Utilities: DRC Fix](drc_fix.py) | +# | Boolean / region operations | [Core Concepts: Geometry](../concepts/geometry.py) | +# | Cell-level enclosures | [Enclosures: KCell Enclosure](../enclosures/kcell_enclosure.py) | +# | Array / grid layout | [Utilities: Grid](grid.py) | diff --git a/docs/source/utilities/grid.py b/docs/source/utilities/grid.py new file mode 100644 index 000000000..2549d529e --- /dev/null +++ b/docs/source/utilities/grid.py @@ -0,0 +1,258 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Grid Layout +# +# When assembling test chips or comparison arrays, you often need to arrange +# a collection of cells into a regular grid with consistent spacing. +# kfactory's grid functions handle this in one call. +# +# | Function | Units | Cell slot size | +# |---|---|---| +# | `kf.grid` | µm (float) | Uniform — largest bbox in the whole grid | +# | `kf.flexgrid` | µm (float) | Flexible — per-column width, per-row height | +# | `kf.grid_dbu` | DBU (int) | Uniform — largest bbox in the whole grid | +# | `kf.flexgrid_dbu` | DBU (int) | Flexible — per-column width, per-row height | +# +# **`grid` vs `flexgrid`**: `grid` gives every slot the same fixed size (set by +# the largest component), so columns and rows stay perfectly aligned like a +# spreadsheet. `flexgrid` shrinks each column/row to its actual maximum, saving +# area when components vary significantly in size. +# +# All four functions return an `InstanceGroup` so you can apply a common +# transformation to the whole block afterwards. + +# %% [markdown] +# ## Setup + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## 1 · Creating Sample Components +# +# We create a set of `DKCell` components (µm-based) with different sizes. +# The size variation is intentional — it makes the difference between `grid` +# and `flexgrid` visible. + +# %% +# (width_µm, height_µm) pairs +sizes_um = [(5, 10), (10, 5), (8, 8), (6, 4), (3, 7), (12, 3)] + +components: list[kf.DKCell] = [] +for w, h in sizes_um: + c = kf.DKCell(name=f"box_{w}x{h}") + c.shapes(c.kcl.layer(L.WG)).insert(kf.kdb.DBox(w, h)) + components.append(c) + +print(f"Created {len(components)} components") + +# %% [markdown] +# ## 2 · `grid` — 1D (Single Row) +# +# Pass a flat list of `DKCell` objects and a spacing in µm. +# All cells are arranged in a single row by default. +# +# Every slot is sized to fit the **largest** component's bounding box, so all +# cells are equally spaced even when they have different sizes. + +# %% +target_row = kf.KCell(name="grid_row") +ig_row = kf.grid(target_row, components, spacing=2.0) + +print( + f"Row bbox: {target_row.dbbox().width():.1f} µm × {target_row.dbbox().height():.1f} µm" +) +print(f"Placed {len(ig_row.insts)} instances") +target_row + +# %% [markdown] +# ## 3 · `grid` — 2D with `shape=` +# +# Pass `shape=(rows, cols)` to reflow a flat list into a 2-D grid. +# The slot size is still uniform (largest component sets the pitch in each axis). + +# %% +target_2d = kf.KCell(name="grid_2d") +ig_2d = kf.grid(target_2d, components, spacing=2.0, shape=(2, 3)) + +print( + f"2D bbox: {target_2d.dbbox().width():.1f} µm × {target_2d.dbbox().height():.1f} µm" +) +target_2d + +# %% [markdown] +# ## 4 · `grid` — Explicit 2D Input +# +# You can supply a list-of-lists directly to control exactly which cell goes in +# each position. This is useful when you want a hand-curated layout rather than +# a simple reflow. + +# %% +row0 = [components[0], components[1], components[2]] +row1 = [components[3], components[4], components[5]] + +target_2d_explicit = kf.KCell(name="grid_2d_explicit") +ig_2d_explicit = kf.grid(target_2d_explicit, [row0, row1], spacing=2.0) + +print( + f"Explicit 2D bbox: {target_2d_explicit.dbbox().width():.1f} µm × {target_2d_explicit.dbbox().height():.1f} µm" +) +target_2d_explicit + +# %% [markdown] +# ## 5 · `flexgrid` — Compact Layout +# +# `flexgrid` gives each **column** its own width (tallest/widest in that column) +# and each **row** its own height, so overall footprint is smaller when +# components vary in size. +# +# Here the same six components are arranged with `shape=(2, 3)` using both +# functions so you can compare the bounding boxes. + +# %% +target_flex = kf.KCell(name="flexgrid_2d") +ig_flex = kf.flexgrid(target_flex, components, spacing=2.0, shape=(2, 3)) + +grid_area = target_2d.dbbox().width() * target_2d.dbbox().height() +flex_area = target_flex.dbbox().width() * target_flex.dbbox().height() + +print( + f"grid bbox: {target_2d.dbbox().width():.1f} × {target_2d.dbbox().height():.1f} µm ({grid_area:.0f} µm²)" +) +print( + f"flexgrid bbox: {target_flex.dbbox().width():.1f} × {target_flex.dbbox().height():.1f} µm ({flex_area:.0f} µm²)" +) +print(f"Area saving: {100 * (1 - flex_area / grid_area):.0f}%") +target_flex + +# %% [markdown] +# ## 6 · Alignment Options +# +# Both `grid` and `flexgrid` accept `align_x` and `align_y` to control how +# components are positioned within their slot: +# +# | Value | Meaning | +# |---|---| +# | `"center"` (default) | Component centred in its slot | +# | `"xmin"` / `"ymin"` | Left-aligned / bottom-aligned | +# | `"xmax"` / `"ymax"` | Right-aligned / top-aligned | +# | `"origin"` | Component placed so its origin is at the slot position | + +# %% +target_aligned = kf.KCell(name="grid_aligned") +ig_aligned = kf.grid( + target_aligned, + components, + spacing=2.0, + shape=(2, 3), + align_x="xmin", + align_y="ymin", +) +target_aligned + +# %% [markdown] +# ## 7 · `grid_dbu` — DBU Variant +# +# If you work with `KCell` (integer DBU coordinates) instead of `DKCell`, +# use `grid_dbu` and pass the spacing in DBU. The API is otherwise identical. + +# %% +# Build KCell (DBU) components +comps_dbu: list[kf.KCell] = [] +for w_um, h_um in sizes_um: + c = kf.KCell(name=f"kdbox_{w_um}x{h_um}") + c.shapes(c.kcl.layer(L.WG)).insert( + kf.kdb.Box(kf.kcl.to_dbu(w_um), kf.kcl.to_dbu(h_um)) + ) + comps_dbu.append(c) + +target_dbu = kf.KCell(name="grid_dbu_example") +ig_dbu = kf.grid_dbu(target_dbu, comps_dbu, spacing=kf.kcl.to_dbu(2)) + +print( + f"grid_dbu bbox: {target_dbu.dbbox().width():.1f} µm × {target_dbu.dbbox().height():.1f} µm" +) +target_dbu + +# %% [markdown] +# ## 8 · Placing the Grid in a Larger Layout +# +# The returned `InstanceGroup` can be used to move the whole block at once. +# Here we pack two sub-grids and place them side-by-side in a top-level cell. + +# %% +top = kf.KCell(name="top_assembly") + +# First sub-grid: first 3 components +sub_a = kf.KCell(name="sub_a") +kf.grid(sub_a, components[:3], spacing=2.0) +inst_a = top << sub_a + +# Second sub-grid: last 3 components, placed to the right with a 10 µm gap +sub_b = kf.KCell(name="sub_b") +kf.grid(sub_b, components[3:], spacing=2.0) +inst_b = top << sub_b +gap_dbu = kf.kcl.to_dbu(10) +inst_b.transform(kf.kdb.Trans(sub_a.bbox().width() + gap_dbu, 0)) + +print(f"Top bbox: {top.dbbox().width():.1f} µm × {top.dbbox().height():.1f} µm") +top + +# %% [markdown] +# ## Quick Reference +# +# ```python +# import kfactory as kf +# +# # µm-based uniform grid (DKCell components) +# ig = kf.grid(target, cells, spacing=2.0) +# ig = kf.grid(target, cells, spacing=2.0, shape=(rows, cols)) +# ig = kf.grid(target, [row0, row1], spacing=2.0) # explicit 2D +# +# # µm-based flexible grid (per-column/row sizing) +# ig = kf.flexgrid(target, cells, spacing=2.0, shape=(rows, cols)) +# +# # DBU-based variants (KCell components, spacing in dbu) +# ig = kf.grid_dbu(target, cells, spacing=kf.kcl.to_dbu(2)) +# ig = kf.flexgrid_dbu(target, cells, spacing=kf.kcl.to_dbu(2)) +# +# # Alignment within each slot +# ig = kf.grid(target, cells, spacing=2.0, align_x="xmin", align_y="ymin") +# +# # Move the whole block after placement +# inst = top << target +# inst.transform(kf.kdb.DCplxTrans(1, 0, False, x_um, y_um)) +# ``` + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Component packing (free placement) | [Utilities: Packing](packing.py) | +# | Components overview & gallery | [Components: Overview](../components/cells/overview.py) | +# | Creating a full PDK | [PDK: Creating a PDK](../pdk/creating_pdk.py) | +# | Instance placement | [Core Concepts: Instances](../concepts/instances.py) | diff --git a/docs/source/utilities/packing.py b/docs/source/utilities/packing.py new file mode 100644 index 000000000..a209b6dc1 --- /dev/null +++ b/docs/source/utilities/packing.py @@ -0,0 +1,196 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Packing Utilities +# +# When building test chips or demo layouts you often need to arrange many cells +# into a compact rectangular footprint without overlaps. kfactory's +# `kf.packing` module wraps the [rpack](https://github.com/Packer-Guy/rpack) +# rectangle-packer to provide two convenience functions: +# +# | Function | Input | Use case | +# |---|---|---| +# | `kf.packing.pack_kcells` | Sequence of `KCell` | Pack several cells into a new target cell | +# | `kf.packing.pack_instances` | Sequence of `Instance` | Rearrange already-placed instances inside a cell | +# +# Both functions return an `InstanceGroup` that lets you inspect or move the +# placed instances as a unit. + +# %% [markdown] +# ## Setup + +# %% +import kfactory as kf + + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0) + + +L = LAYER() +kf.kcl.infos = L + +# %% [markdown] +# ## 1 · Creating Diverse Components +# +# We build a handful of rectangular cells with different sizes to illustrate +# how the packer fits varied shapes together. + +# %% +# (width_um, height_um) pairs — deliberately varied so the packing is non-trivial +sizes_um = [(5, 10), (20, 8), (3, 3), (15, 15), (8, 4), (12, 6), (6, 18), (9, 5)] + +components = [] +for w, h in sizes_um: + c = kf.KCell(name=f"box_{w}x{h}") + c.shapes(c.kcl.layer(L.WG)).insert(kf.kdb.Box(kf.kcl.to_dbu(w), kf.kcl.to_dbu(h))) + components.append(c) + +print(f"Created {len(components)} components") + +# %% [markdown] +# ## 2 · `pack_kcells` — Basic Packing +# +# `pack_kcells` instantiates each cell inside `target` and uses the +# rectangle-packer to find a tight arrangement. +# +# Key parameters: +# - `max_width` / `max_height` — hard limits on the bounding box (dbu); `None` = unlimited +# - `spacing` — extra gap between bounding boxes (dbu) + +# %% +SPACING_DBU = kf.kcl.to_dbu(2) # 2 µm gap between cells + +target = kf.KCell(name="packed_basic") +ig = kf.packing.pack_kcells(target, components, spacing=SPACING_DBU) + +print( + f"Bounding box: {target.dbbox().width():.1f} µm × {target.dbbox().height():.1f} µm" +) +print(f"Placed {len(ig.insts)} instances") +target + +# %% [markdown] +# The packer places all cells without overlap. The bounding box is much smaller +# than a naive row arrangement would produce. + +# %% [markdown] +# ## 3 · Width-Constrained Packing +# +# Pass `max_width` (in dbu) to force the layout to stay within a given strip +# width — useful for fitting onto a reticle column or a fixed-pitch test array. + +# %% +MAX_WIDTH_DBU = kf.kcl.to_dbu(25) # 25 µm wide strip + +target_narrow = kf.KCell(name="packed_narrow") +ig_narrow = kf.packing.pack_kcells( + target_narrow, + components, + max_width=MAX_WIDTH_DBU, + spacing=SPACING_DBU, +) + +print( + f"Bounding box: {target_narrow.dbbox().width():.1f} µm × " + f"{target_narrow.dbbox().height():.1f} µm" +) +target_narrow + +# %% [markdown] +# The layout is now taller and narrower compared to the unconstrained version. + +# %% [markdown] +# ## 4 · `pack_instances` — Rearranging Existing Instances +# +# If instances are already present in a cell (e.g. placed by other routing +# steps), `pack_instances` rearranges them in-place without creating new +# instances. + +# %% +target_inst = kf.KCell(name="packed_instances") + +# Pre-place instances at arbitrary positions +insts = [target_inst << c for c in components] +for i, inst in enumerate(insts): + # Scatter them diagonally so the "before" layout is spread out + inst.transform(kf.kdb.Trans(i * kf.kcl.to_dbu(30), i * kf.kcl.to_dbu(30))) + +print( + f"Before packing — bbox: {target_inst.dbbox().width():.0f} µm × {target_inst.dbbox().height():.0f} µm" +) + +ig_inst = kf.packing.pack_instances(target_inst, insts, spacing=SPACING_DBU) + +print( + f"After packing — bbox: {target_inst.dbbox().width():.0f} µm × {target_inst.dbbox().height():.0f} µm" +) +target_inst + +# %% [markdown] +# ## 5 · Using the Returned `InstanceGroup` +# +# Both functions return an `InstanceGroup` so you can apply a common +# transformation to the entire packed block — for example, to position it +# relative to other structures in a larger layout. + +# %% +top = kf.KCell(name="top_chip") + +# Pack a subset of components into a block +sub_target = kf.KCell(name="sub_block") +sub_ig = kf.packing.pack_kcells(sub_target, components[:4], spacing=SPACING_DBU) + +# Place the packed block in the top-level cell and offset it +block_inst = top << sub_target +block_inst.transform(kf.kdb.Trans(kf.kcl.to_dbu(50), kf.kcl.to_dbu(20))) + +top + +# %% [markdown] +# ## Quick Reference +# +# ```python +# import kfactory as kf +# +# # Pack a list of KCells into target (creates new instances) +# ig = kf.packing.pack_kcells( +# target, +# cells, +# max_width=kf.kcl.to_dbu(100), # optional width limit in dbu +# max_height=None, # optional height limit in dbu +# spacing=kf.kcl.to_dbu(2), # gap between bboxes in dbu +# ) +# +# # Rearrange already-placed instances (modifies their transforms) +# ig = kf.packing.pack_instances( +# target, +# list_of_instances, +# spacing=kf.kcl.to_dbu(2), +# ) +# ``` + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Array / grid layout | [Utilities: Grid](grid.py) | +# | Components overview & gallery | [Components: Overview](../components/cells/overview.py) | +# | Instance placement | [Core Concepts: Instances](../concepts/instances.py) | +# | Creating a full PDK | [PDK: Creating a PDK](../pdk/creating_pdk.py) | diff --git a/docs/source/utilities/session_cache.py b/docs/source/utilities/session_cache.py new file mode 100644 index 000000000..ed771fc0e --- /dev/null +++ b/docs/source/utilities/session_cache.py @@ -0,0 +1,259 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Session Cache +# +# Computing cells with `@kf.cell` is fast for small components, but a full +# PDK with hundreds of parameterised cells can take seconds to rebuild from +# scratch on every import. The **session cache** persists the factory +# cell-cache to disk so that subsequent runs skip cells whose factory source +# code has not changed. +# +# | Function | What it does | +# |---|---| +# | `kf.save_session(c=None, session_dir=None)` | Serialise all factory caches to `build/session/kcls/` (or a custom path) | +# | `kf.load_session(session_dir=None, warn_missing_dir=True)` | Restore factory caches from disk; cells whose factory source changed are silently skipped | +# +# **Only cells created by a `@kf.cell`-decorated factory** are included in +# the cache. Ad-hoc cells (built without a decorator) are not saved. + +# %% [markdown] +# ## Setup +# +# `save_session` hashes each factory's **source file on disk** so it can detect +# changes on the next load. For this demo we write a small factory module to a +# temporary file and import it. In a real project your PDK is already a proper +# Python package, so factory source files always exist on disk. + +# %% +import importlib.util +import pathlib +import shutil +import tempfile + +import kfactory as kf + +tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="kf_session_demo_")) + +factory_src = '''\ +import kfactory as kf + +class LAYER(kf.LayerInfos): + WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0) + +L = LAYER() +pdk = kf.KCLayout("SESSION_DEMO", infos=LAYER) + + +@pdk.cell +def wg_straight(width: float = 0.5, length: float = 10.0) -> kf.KCell: + """Straight waveguide.""" + c = kf.KCell(kcl=pdk) + wg_layer = pdk.find_layer(L.WG) + w = pdk.to_dbu(width) + length_dbu = pdk.to_dbu(length) + c.shapes(wg_layer).insert( + kf.kdb.Box(-length_dbu // 2, -w // 2, length_dbu // 2, w // 2) + ) + c.add_port(port=kf.Port(name="o1", + trans=kf.kdb.Trans(2, False, -length_dbu // 2, 0), + width=w, layer=wg_layer, kcl=pdk)) + c.add_port(port=kf.Port(name="o2", + trans=kf.kdb.Trans(0, False, length_dbu // 2, 0), + width=w, layer=wg_layer, kcl=pdk)) + return c + + +@pdk.cell +def wg_taper(w1: float = 0.5, w2: float = 1.0, length: float = 20.0) -> kf.KCell: + """Linear taper.""" + c = kf.KCell(kcl=pdk) + wg_layer = pdk.find_layer(L.WG) + w1_dbu = pdk.to_dbu(w1) + w2_dbu = pdk.to_dbu(w2) + l_dbu = pdk.to_dbu(length) + pts = [ + kf.kdb.Point(-l_dbu // 2, -w1_dbu // 2), + kf.kdb.Point(-l_dbu // 2, w1_dbu // 2), + kf.kdb.Point(l_dbu // 2, w2_dbu // 2), + kf.kdb.Point(l_dbu // 2, -w2_dbu // 2), + ] + c.shapes(wg_layer).insert(kf.kdb.Polygon(pts)) + c.add_port(port=kf.Port(name="o1", + trans=kf.kdb.Trans(2, False, -l_dbu // 2, 0), + width=w1_dbu, layer=wg_layer, kcl=pdk)) + c.add_port(port=kf.Port(name="o2", + trans=kf.kdb.Trans(0, False, l_dbu // 2, 0), + width=w2_dbu, layer=wg_layer, kcl=pdk)) + return c +''' + +module_file = tmpdir / "demo_factories.py" +module_file.write_text(factory_src) + +spec = importlib.util.spec_from_file_location("demo_factories", module_file) +assert spec is not None and spec.loader is not None +demo = importlib.util.module_from_spec(spec) +spec.loader.exec_module(demo) # type: ignore[union-attr] + +pdk = demo.pdk +wg_straight = demo.wg_straight +wg_taper = demo.wg_taper + +# %% [markdown] +# ## Step 1 — Call `load_session` at startup (before computing anything) +# +# The canonical usage is to call `load_session` **at the top of your PDK +# module**, before any factory functions are defined or called. On the very +# first run no session exists yet, so `load_session` logs a warning and +# returns immediately. Pass `warn_missing_dir=False` to suppress that warning +# in production. + +# %% +session_dir = tmpdir / "session" + +# First run: no session exists yet. warn_missing_dir=False suppresses the log. +kf.load_session(session_dir=session_dir, warn_missing_dir=False) +print("load_session on first run: no session yet, nothing loaded") + +# %% [markdown] +# ## Step 2 — Build cells as usual + +# %% +wg_500 = wg_straight(width=0.5, length=10.0) +wg_800 = wg_straight(width=0.8, length=20.0) +taper_a = wg_taper(w1=0.5, w2=1.0, length=20.0) +taper_b = wg_taper(w1=0.5, w2=2.0, length=40.0) + +print("Cells in pdk:", [pdk[i].name for i in range(pdk.cells())]) +print("wg_straight cache size:", len(pdk.factories["wg_straight"].cache)) +print("wg_taper cache size:", len(pdk.factories["wg_taper"].cache)) + +# %% [markdown] +# ## Step 3 — Save the session at the end of the build +# +# `kf.save_session()` serialises every populated factory cache across all +# registered `KCLayout` instances. Default location: `build/session/kcls/` +# (auto-created and auto-added to `.gitignore`). + +# %% +kf.save_session(session_dir=session_dir) + +print("Saved files:") +for f in sorted(session_dir.rglob("*")): + print(" ", f.relative_to(tmpdir)) + +# %% [markdown] +# kfactory writes two files per layout: +# +# * **`cells.gds.gz`** — compressed GDS containing geometry of all +# factory-cached cells +# * **`factories.pkl`** — factory name → cached cell names + SHA-256 hash of +# each factory's source file (used for invalidation) +# +# A top-level **`kcl_dependencies.json`** records which layouts depend on +# which, so `load_session` can restore them in the correct dependency order. + +# %% [markdown] +# ## How invalidation works +# +# On each `load_session` call, kfactory re-hashes every registered factory's +# source `.py` file and compares it to the stored hash. If the file has +# changed — or if a factory that depends on the changed factory is found — +# those cells are **skipped** (not loaded from disk) and will be recomputed +# fresh on the next call. This means you never silently serve stale geometry. +# +# ``` +# Factory source unchanged → cells restored from disk (fast path) +# Factory source changed → cells skipped → recomputed on next call +# Factory depends on changed → also skipped (transitive invalidation) +# ``` + +# %% [markdown] +# ## Saving only a subset +# +# Pass `c=` to restrict saving to only the `KCLayout` instances needed +# by that specific cell. Useful in monorepo setups where multiple independent +# PDKs share one Python process. + +# %% +subset_dir = tmpdir / "session_subset" +kf.save_session(c=wg_500, session_dir=subset_dir) + +print("Subset save — files:") +for f in sorted(subset_dir.rglob("*")): + print(" ", f.relative_to(tmpdir)) + +# %% [markdown] +# ## Complete usage pattern +# +# ```python +# # my_pdk/__init__.py +# import kfactory as kf +# +# pdk = kf.KCLayout("my_pdk", infos=LAYER) +# +# # Restore previously-computed cells before defining factories. +# # Silently skips on the very first run when no session exists yet. +# kf.load_session(warn_missing_dir=False) +# +# @pdk.cell +# def wg_straight(...) -> kf.KCell: +# ... +# +# @pdk.cell +# def euler_bend(...) -> kf.KCell: +# ... +# ``` +# +# ```python +# # build_chip.py +# import my_pdk # load_session() runs here at import time +# import kfactory as kf +# +# chip = my_pdk.assemble_chip() +# chip.write_gds("output/chip.gds") +# +# # Persist the cache so the next run is faster. +# kf.save_session() +# ``` + +# %% [markdown] +# ## Summary +# +# | Scenario | Call | +# |---|---| +# | Large PDK, speed up re-imports | `load_session(warn_missing_dir=False)` at module top; `save_session()` at build end | +# | Save only one PDK in a multi-PDK process | `save_session(c=my_top_cell)` | +# | Custom CI cache location | `save_session(session_dir=Path(".cache/kf"))` and matching `load_session(...)` | +# | Suppress "no session dir" warning | `load_session(warn_missing_dir=False)` | +# +# > **Tip:** The default session directory (`build/session/kcls/`) is +# > auto-added to `.gitignore`. Never commit session files — they are +# > machine-specific build artefacts. + +# %% +# Clean up temp directories used in this notebook. +shutil.rmtree(tmpdir, ignore_errors=True) + +# %% [markdown] +# ## See Also +# +# | Topic | Where | +# |-------|-------| +# | Creating a full PDK | [PDK: Creating a PDK](../pdk/creating_pdk.py) | +# | KCLayout (owns the cell DB) | [Core Concepts: KCLayout](../concepts/kclayout.py) | diff --git a/docs/zensical.yml b/docs/zensical.yml new file mode 100644 index 000000000..b8db43c58 --- /dev/null +++ b/docs/zensical.yml @@ -0,0 +1,149 @@ +site_name: KFactory +repo_url: https://github.com/gdsfactory/kfactory +site_url: https://gdsfactory.github.io/kfactory +docs_dir: source-built + +nav: + - Home: index.md + - Getting Started: + - Prerequisites: getting_started/prerequisites.md + - Installation: getting_started/installation.md + - 5-Minute Quickstart: getting_started/quickstart.md + - KLive Setup: getting_started/klive_setup.md + - Concepts: + - KCell: concepts/kcell.md + - Layers: concepts/layers.md + - Ports: concepts/ports.md + - Instances: concepts/instances.md + - Geometry: concepts/geometry.md + - KCLayout: concepts/kclayout.md + - DBU vs µm: concepts/dbu_vs_um.md + - Components: + - Cells: + - Overview: components/cells/overview.md + - PCells: components/cells/pcells.md + - Virtual PCells: components/cells/virtual.md + - Factories: + - Overview: components/cells/factories/overview.md + - Straight: components/cells/factories/straight.md + - Euler: components/cells/factories/euler.md + - Circular: components/cells/factories/circular.md + - Bezier: components/cells/factories/bezier.md + - Taper: components/cells/factories/taper.md + - Cross-Sections: components/cross_sections.md + - Enclosures: + - Layer Enclosure: enclosures/layer_enclosure.md + - KCell Enclosure: enclosures/kcell_enclosure.md + - Routing: + - Overview: routing/overview.md + - Optical: routing/optical.md + - Electrical: routing/electrical.md + - Manhattan Primitives: routing/manhattan.md + - All-Angle: routing/all_angle.md + - Bundle Routing Tutorial: routing/bundle.md + - Path-Length Matching: routing/path_length.md + - PDK: + - Creating a PDK: pdk/creating_pdk.md + - Technology & Layer Stack: pdk/technology.md + - Schematics: + - Overview: schematics/overview.md + - Netlist & I/O: schematics/netlist.md + - Ports & Pins: schematics/ports_and_pins.md + - 45° Crossing: schematics/crossing45.md + - Utilities: + - Grid Layout: utilities/grid.md + - DRC Fixing: utilities/drc_fix.md + - Packing: utilities/packing.md + - Fill: utilities/fill.md + - Session Cache: utilities/session_cache.md + - Guides: + - Best Practices: howto/best_practices.md + - Common Patterns: howto/patterns.md + - FAQ: howto/faq.md + - Contributing: howto/contributing.md + - Coming from gdsfactory: gdsfactory.md + - Reference: + # The "API" sub-tree is auto-generated and spliced in at build time + # by docs/scripts/build_docs_source.py — DO NOT enumerate modules + # here manually. Edit `gen_api_reference` to change the structure. + - API: reference/ # SPLICE_API + - Config Class: config.md + - Migration: migration.md + - Changelog: changelog.md +theme: + name: "material" + logo: _static/logo.svg + favicon: _static/logo.svg + features: + - navigation.tabs + - navigation.tabs.sticky + custom_dir: overrides + + palette: + # Palette toggle for dark mode + - scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + # Palette toggle for light mode + - scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + +# Multi-version docs via mike (zensical fork). The CI workflow deploys +# `main` → dev alias and tagged releases → + latest alias. +# https://zensical.org/docs/setup/versioning/ +extra: + version: + provider: mike + default: latest + +markdown_extensions: + - admonition + - attr_list # required for {.md-button} and {.lg .middle} + - md_in_html # required for grid-card
blocks + - pymdownx.superfences + - pymdownx.tasklist + - pymdownx.tabbed + - pymdownx.emoji: + # Render :material-foo: / :fontawesome-foo: as inline SVG via + # zensical's bundled twemoji index. + emoji_index: !!python/name:zensical.extensions.emoji.twemoji + emoji_generator: !!python/name:zensical.extensions.emoji.to_svg + - pymdownx.snippets: + check_paths: true + +plugins: + - mkdocstrings: + enabled: true + default_handler: python + handlers: + python: + options: + show_source: true + allow_inspection: true + docstring_style: google + separate_signature: true + show_signature_annotations: true + members_order: alphabetical + # Submodules are exposed via the literate-nav side panel, not + # inlined on the parent package page. Keeps the right-side + # "On this page" TOC focused on actual class / function / + # variable definitions in the current module. + show_submodules: false + show_root_heading: true + show_root_full_path: false + extensions: + - griffe_pydantic + - dataclasses + - griffe_inherited_docstrings + - griffe_warnings_deprecated + - mkdocs-video: + is_video: true + video_type: webm + video_muted: true + - search + - literate-nav: + nav_file: SUMMARY.md + - section-index diff --git a/pyproject.toml b/pyproject.toml index 16dc09f87..9e1fdd333 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,273 +1,224 @@ [build-system] -requires = ["setuptools>=74", "wheel", "build", "setuptools_scm[toml]>=8.1"] build-backend = "setuptools.build_meta" +requires = [ "build", "setuptools>=74", "setuptools-scm[toml]>=8.1" ] [project] name = "kfactory" +version = "3.0.0rc4" description = "KLayout API implementation of gdsfactory" readme = "README.md" +authors = [ { name = "gdsfactory community", email = "contact@gdsfactory.com" } ] +requires-python = ">=3.12" classifiers = [ - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Operating System :: OS Independent", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] -requires-python = ">=3.11" - - -version = "2.5.1" -authors = [{ name = "gdsfactory community", email = "contact@gdsfactory.com" }] dependencies = [ - "aenum >= 3.1.16, < 4", - "cachetools >= 6.2.4", - "klayout~=0.30.8", - "loguru >= 0.7.3, < 0.8", - "pydantic >= 2.12.5, < 2.13", - "pydantic-extra-types>=2.11", - "pydantic-settings >= 2.12, < 3", - "pygit2 >= 1.19.1, < 2", - "rapidfuzz >= 3.14.3, < 4", - "rectangle-packer >= 2.0.5, < 3", - "requests >= 2.32.5, < 3", - "ruamel.yaml >= 0.19.1, < 0.20", - "ruamel.yaml.string >= 0.1.1, < 0.2", - "semver >= 3.0.4, < 4", - "scipy >= 1.17.0, < 2", - "toolz >= 1.1.0, < 2", - "typer >= 0.21.1, < 0.25", + "aenum>=3.1.17,<4", + "cachetools>=7.1.4,<7.2", + "kfnetlist>=0.2", + "klayout>=0.30.9,<0.31", + "loguru>=0.7.3,<0.8", + "pydantic>=2.13.4,<2.14", + "pydantic-extra-types>=2.11.1,<2.12", + "pydantic-settings>=2.14.1,<3", + "pygit2>=1.19.2,<2", + "rapidfuzz>=3.14.5,<4", + "rectangle-packer>=2.1,<3", + "requests>=2.34.2,<3", + "ruamel-yaml>=0.19.1,<0.20", + "ruamel-yaml-string>=0.1.1,<0.2", + "scipy>=1.17.1,<2", + "semver>=3.0.4,<4", + "toolz>=1.1,<2", + "typer>=0.26.7,<0.27", ] - -[project.optional-dependencies] -dev = [ - "kfactory[ci]", - "mypy>=1.15.0", - "pre-commit>=4.2.0", - "pylsp-mypy>=0.7.0", - "python-lsp-server[all]>=1.13.1", - "ruff>=0.9.2", - "rust-just>=1.42.4", - "tbump>=6.11.0", - "ty>=0.0.1a17", - "types-cachetools>=5.5.0.20240820", - "types-docutils>=0.21.0.20241128", - "types-pygments>=2.19.0.20250305", - "types-requests>=2.32.0.20250328", - "types-setuptools>=76.0.0.20250328", - "scipy-stubs", +optional-dependencies.ci = [ + "pytest>=9.0.3,<9.1", + "pytest-cov>=7.1,<7.2", + "pytest-datadir>=1.8,<1.9", + "pytest-randomly>=4.1,<4.2", + "pytest-regressions>=2.11,<2.12", + "pytest-xdist>=3.8,<3.9", + "types-cachetools>=7.0.0.20260518,<7.1", + "types-requests>=2.33.0.20260518,<2.34", + "uv>=0.11.19,<0.12", ] -docs = [ - "kfactory[ipy]", - "erdantic>=1.1.1", - "markdown-exec>=1.10.3", - "mkdocs>=1.6.1", - "mkdocs-gen-files>=0.5.0", - "mkdocs-jupyter>=0.25.1", - "mkdocs-literate-nav>=0.6.2", - "mkdocs-material>=9.6.9", - "mkdocs-section-index>=0.3.9", - "mkdocs-video>=1.5.0", - "mkdocstrings[python]>=0.29.0", - "pymdown-extensions>=10.14.3", - "griffe-pydantic>=1.1.4", - "griffe-inherited-docstrings>=1.1.1", - "griffe-warnings-deprecated>=1.1.0", - "ruff>=0.9.2", +optional-dependencies.dev = [ + "kfactory[ci]", + "pre-commit>=4.6,<4.7", + "python-lsp-server[all]>=1.14,<1.15", + "ruff>=0.15.16,<0.16", + "rust-just~=1.51.0", + "scipy-stubs>=1.17.1.5,<1.18", + "tbump>=6.11,<6.12", + "ty>=0.0.43,<0.1", + "types-cachetools>=7.0.0.20260518,<7.1", + "types-docutils>=0.22.3.20260518,<0.23", + "types-pygments>=2.20.0.20260518,<2.21", + "types-requests>=2.33.0.20260518,<2.34", + "types-setuptools>=82.0.0.20260518,<82.1", ] -ci = [ - "pytest >= 8.3.5", - "pytest-cov>=6.0.0", - "pytest-randomly>=3.16.0", - "pytest-regressions>=2.7.0", - "pytest-xdist>=3.6.1", - "pytest-regressions>=2.8.3", - "pytest-datadir>=1.8.0", - "types-cachetools>=5.5.0.20240820", - "types-requests>=2.32.0.20250328", +optional-dependencies.docs = [ + "erdantic>=1.2.1,<1.3", + "griffe-inherited-docstrings>=1.1.3,<1.2", + "griffe-pydantic>=1.3.1,<1.4", + "griffe-warnings-deprecated>=1.1.1,<1.2", + "kfactory[notebooks]", # transitively pulls in kfactory[ipy] + "mkdocs-literate-nav>=0.6.3,<0.7", + "mkdocs-video>=1.5,<1.6", + "mkdocstrings[python]>=1.0.4,<1.1", + "pymdown-extensions>=10.21.3,<10.22", + "ruff>=0.15.16,<0.16", + "zensical>=0.0.44,<0.1", ] -ipy = [ - "ipyevents>=2.0.2", - "ipython>=9.0.2", - "ipytree>=0.2.2", - "ipywidgets>=8.1.5", +optional-dependencies.ipy = [ + "ipyevents>=2.0.4,<2.1", + "ipython>=9.14,<9.15", + "ipytree>=0.2.2,<0.3", + "ipywidgets>=8.1.8,<8.2", ] +optional-dependencies.notebooks = [ + "ipykernel>=7.2,<7.3", + "jupytext>=1.19.3,<1.20", + # Notebook conversion needs the IPython display helpers + # (`c.plot()` lazy-imports kfactory.widgets.interactive, which + # pulls in ipyevents/ipywidgets/ipytree from the [ipy] extra). + "kfactory[ipy]", + "nbconvert>=7.17.1,<7.18", +] +scripts.kf = "kfactory.cli:app" - -[project.scripts] -kf = "kfactory.cli:app" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.mypy] -python_version = "3.11" -strict = true -exclude = ["src/kfactory/widgets/interactive.py"] -plugins = ["pydantic.mypy"] - -follow_imports = "silent" -warn_redundant_casts = true -warn_unused_ignores = true -disallow_any_generics = true -no_implicit_reexport = true -disallow_untyped_defs = true - -[tool.pydantic-mypy] -init_forbid_extra = true -init_typed = true -warn_required_dynamic_aliases = true - -[[tool.mypy.overrides]] -module = ["src.*"] -ignore_missing_imports = true - -[tool.pylsp-mypy] -enabled = true -live_mode = true -strict = true - -[tool.pytest.ini_options] -testpaths = ["src", "tests"] -addopts = '--tb=short' -norecursedirs = ["extra/*.py"] - - -[tool.coverage.html] -directory = "_build/coverage_html_report" - -[tool.codespell] -ignore-words-list = "euclidian,TE,TE/TM,te,ba,FPR,fpr_spacing,ro,nd,donot,schem" -skip = "pyproject.toml, uv.lock" - -[tool.ty.src] -exclude = [ - "src/kfactory/widgets/interactive.py", +[dependency-groups] +dev = [ + "pytest>=9.0.3,<9.1", + "pytest-regressions>=2.11,<2.12", ] +[tool.setuptools] +packages.find.where = [ "src" ] + [tool.ruff] -fix = true +target-version = "py312" line-length = 88 -exclude = ["docs", "src/kfactory/widgets/interactive.py"] indent-width = 4 -target-version = "py311" - -[tool.ruff.lint] -select = ["ALL"] -ignore = [ - "ANN401", - "ARG", - "BLE001", - "C901", - "COM812", - "D100", - "D", - "EM", - "FBT", - "PLR0904", - "PLR0911", - "PLR0912", - "PLR0913", - "PLR0915", - "PT011", - "PT012", - "S101", - "S311", - "SLF001", - "TC004", - "TID252", - "TRY003", - "PLW1641", - "PLC0415", -] -per-file-ignores = { "tests/*.py" = ["D", "PLR2004", "INP001", "EM101"] } - -[tool.ruff.format] -# Like Black, use double quotes for strings. -quote-style = "double" +exclude = [ "src/kfactory/widgets/interactive.py" ] +fix = true # Like Black, indent with spaces, rather than tabs. -indent-style = "space" -# Like Black, respect magic trailing commas. -skip-magic-trailing-comma = false +format.indent-style = "space" +# Like Black, use double quotes for strings. +format.quote-style = "double" # Like Black, automatically detect the appropriate line ending. -line-ending = "auto" - -[tool.ruff.lint.pydocstyle] -convention = "google" - -[tool.coverage.report] -exclude_also = [ - "if self\\.debug", - "raise AssertionError", - "raise NotImplementedError", - "if 0:", - "if __name__ == .__main__.:", - "@(abc\\.)?abstractmethod", - "if TYPE_CHECKING:", - "@overload", - "class .*\\(Protocol(\\[.*\\])?(,.*)?.*\\):", - "def .*:[\\s]*\\.\\.\\.$", +format.line-ending = "auto" +# Like Black, respect magic trailing commas. +format.skip-magic-trailing-comma = false +lint.select = [ "ALL" ] +lint.ignore = [ + "ANN401", + "ARG", + "BLE001", + "C901", + "COM812", + "D", + "D100", + "EM", + "FBT", + "PLC0415", + "PLR0904", + "PLR0911", + "PLR0912", + "PLR0913", + "PLR0915", + "PLR2004", + "PLW1641", + "PT011", + "PT012", + "S101", + "S311", + "SLF001", + "TC004", + "TID252", + "TRY003", ] -ignore_errors = true +lint.per-file-ignores = { "tests/*.py" = [ "D", "PLR2004", "INP001", "EM101" ], "docs/**/*.py" = [ + "T201", + "B018", + "ERA001", + "INP001", + "E402", + "E501", + "N801", + "N806", + "E741", + "RUF001", + "RUF002", + "RUF003", + "PT018" +] } +lint.pydocstyle.convention = "google" -[tool.coverage.run] -omit = ["src/kfactory/cli/**/*.py"] +[tool.codespell] +ignore-words-list = "euclidian,TE,TE/TM,te,ba,FPR,fpr_spacing,ro,nd,donot,schem,commitish" +skip = "pyproject.toml, uv.lock" +[tool.ty] +src.exclude = [ "docs", "src/kfactory/widgets/interactive.py", "tests/gdsfactory-yaml-pics" ] +src.include = [ "docs", "src", "tests" ] + +[tool.pytest] +ini_options.testpaths = [ "src", "tests" ] +ini_options.norecursedirs = [ "extra/*.py" ] +ini_options.addopts = "--tb=short" + +[tool.coverage] +run.omit = [ "src/kfactory/cli/**/*.py" ] +report.exclude_also = [ + "@(abc\\.)?abstractmethod", + "@overload", + "class .*\\(Protocol(\\[.*\\])?(,.*)?.*\\):", + "def .*:[\\s]*\\.\\.\\.$", + "if __name__ == .__main__.:", + "if 0:", + "if self\\.debug", + "if TYPE_CHECKING:", + "raise AssertionError", + "raise NotImplementedError", +] +report.ignore_errors = true +html.directory = "_build/coverage_html_report" [tool.tbump] +file = [ + # For each file to patch, add a [[file]] config + # section containing the path of the file, relative to the + # tbump.toml location. + { src = "pyproject.toml", search = 'version = "{current_version}"' }, + { src = "pyproject.toml", search = 'current = "{current_version}"' }, + { src = "src/kfactory/__init__.py" }, +] +git.message_template = "Bump to {new_version}" +git.tag_template = "v{new_version}" # Uncomment this if your project is hosted on GitHub: # github_url = "https://github.com///" - -[tool.tbump.version] -current = "2.5.1" - +version.current = "3.0.0rc4" # Example of a semver regexp. # Make sure this matches current_version before # using tbump -regex = ''' - (?P\d+) - \. - (?P\d+) - \. - (?P\d+) +version.regex = """ (?P\\d+) + \\. + (?P\\d+) + \\. + (?P\\d+) (?: - (?P
(a|b|rc)\d+)
+    (?P
(a|b|rc)\\d+)
   )?
   (?:
-    \.post(?P\d+)
+    \\.post(?P\\d+)
   )?
   (?:
-    \.dev(?P\d+)
+    \\.dev(?P\\d+)
   )?
-  '''
-
-[tool.tbump.git]
-message_template = "Bump to {new_version}"
-tag_template = "v{new_version}"
-
-# For each file to patch, add a [[file]] config
-# section containing the path of the file, relative to the
-# tbump.toml location.
-[[tool.tbump.file]]
-src = "README.md"
-
-[[tool.tbump.file]]
-src = "pyproject.toml"
-search = 'version = "{current_version}"'
-
-[[tool.tbump.file]]
-src = "pyproject.toml"
-search = 'current = "{current_version}"'
-
-[[tool.tbump.file]]
-src = "src/kfactory/__init__.py"
-# You can specify a list of commands to
-# run after the files have been patched
-# and before the git commit is made
-
-[tool.basedpyright]
-reportUnusedCallResult = false
-reportUnusedExpression = false
-
-[dependency-groups]
-dev = [
-    "ty>=0.0.1a17",
-]
+  """
diff --git a/src/kfactory/__init__.py b/src/kfactory/__init__.py
index 847527f6c..2bce791b1 100644
--- a/src/kfactory/__init__.py
+++ b/src/kfactory/__init__.py
@@ -6,7 +6,7 @@
 # The import order matters, we need to first import the important stuff.
 # isort:skip_file
 
-__version__ = "2.5.1"
+__version__ = "3.0.0rc4"
 
 import klayout.db as kdb
 from klayout import lay
@@ -14,10 +14,18 @@
 
 from .conf import config, logger, CheckInstances
 from .cross_section import (
-    SymmetricalCrossSection,
+    AsymmetricCrossSection,
+    AsymmetricalCrossSection,
     CrossSection,
+    CrossSectionLayer,
     CrossSectionSpec,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+    DAsymmetricCrossSection,
+    DAsymmetricalCrossSection,
     DCrossSection,
+    DCrossSectionLayer,
+    SymmetricalCrossSection,
 )
 from .enclosure import KCellEnclosure, LayerEnclosure
 from .grid import flexgrid, flexgrid_dbu, grid, grid_dbu
@@ -29,14 +37,14 @@
 from .instance import Instance, DInstance, VInstance
 from .instance_group import InstanceGroup, DInstanceGroup, VInstanceGroup
 from .instance_ports import InstancePorts, DInstancePorts, VInstancePorts
-from .netlist import Netlist
+from kfnetlist import Netlist
 from .schematic import (
-    Schematic,
     DSchematic,
-    get_schematic,
-    read_schematic,
-    Schema,
     DSchema,
+    PathLengthMatch,
+    Schematic,
+    Schema,
+    read_schematic,
 )
 from .instances import Instances, DInstances, VInstances
 from .settings import KCellSettings, Info
@@ -52,6 +60,7 @@
 )
 
 from . import (
+    checks,
     enclosure,
     factories,
     packing,
@@ -87,12 +96,20 @@ def __getattr__(name: str) -> ModuleType:
 
 
 __all__ = [
+    "AsymmetricCrossSection",
+    "AsymmetricalCrossSection",
     "BaseKCell",
     "CheckInstances",
     "Constants",
     "CrossSection",
+    "CrossSectionLayer",
     "CrossSectionSpec",
+    "CrossSectionSpecDict",
+    "DAsymmetricCrossSection",
+    "DAsymmetricalCrossSection",
     "DCrossSection",
+    "DCrossSectionLayer",
+    "DCrossSectionSpecDict",
     "DInstance",
     "DInstanceGroup",
     "DInstancePorts",
@@ -118,6 +135,7 @@ def __getattr__(name: str) -> ModuleType:
     "LayerInfos",
     "LayerStack",
     "Netlist",
+    "PathLengthMatch",
     "Pin",
     "Pins",
     "Port",
@@ -137,6 +155,7 @@ def __getattr__(name: str) -> ModuleType:
     "VShapes",
     "cell",
     "cells",
+    "checks",
     "conf",
     "config",
     "dpolygon_from_array",
@@ -144,7 +163,6 @@ def __getattr__(name: str) -> ModuleType:
     "factories",
     "flexgrid",
     "flexgrid_dbu",
-    "get_schematic",
     "grid",
     "grid_dbu",
     "kcell",
diff --git a/src/kfactory/cells/straight.py b/src/kfactory/cells/straight.py
index 1699a6b0e..d389eb841 100644
--- a/src/kfactory/cells/straight.py
+++ b/src/kfactory/cells/straight.py
@@ -12,11 +12,20 @@
     │        Slab/Exclude         │
     └─────────────────────────────┘
 
-The slabs and excludes can be given in the form of an
-[Enclosure][kfactory.enclosure.LayerEnclosure].
+The slabs and excludes are part of the cross section, or can be given for the legacy
+``(width, layer, enclosure)`` call via a
+[`LayerEnclosure`][kfactory.enclosure.LayerEnclosure].
 """
 
+from typing import overload
+
 from .. import KCell, kdb
+from ..cross_section import (
+    CrossSection,
+    CrossSectionSpecDict,
+    DCrossSection,
+    DCrossSectionSpecDict,
+)
 from ..enclosure import LayerEnclosure
 from ..factories.straight import straight_dbu_factory
 from ..typings import um
@@ -25,34 +34,56 @@
 __all__ = ["straight", "straight_dbu"]
 
 straight_dbu = straight_dbu_factory(kcl=demo)
+"""Cross-section-first straight factory on the default KCLayout (length in dbu)."""
 
 
+@overload
 def straight(
+    *,
     width: um,
     length: um,
     layer: kdb.LayerInfo,
     enclosure: LayerEnclosure | None = None,
+) -> KCell: ...
+@overload
+def straight(
+    *,
+    cross_section: str
+    | CrossSection
+    | DCrossSection
+    | CrossSectionSpecDict
+    | DCrossSectionSpecDict,
+    length: um,
+) -> KCell: ...
+def straight(
+    *,
+    length: um,
+    cross_section: str
+    | CrossSection
+    | DCrossSection
+    | CrossSectionSpecDict
+    | DCrossSectionSpecDict
+    | None = None,
+    width: um | None = None,
+    layer: kdb.LayerInfo | None = None,
+    enclosure: LayerEnclosure | None = None,
 ) -> KCell:
     """Straight waveguide in um.
 
-    Visualization::
-
-        ┌─────────────────────────────┐
-        │        Slab/Exclude         │
-        ├─────────────────────────────┤
-        │                             │
-        │            Core             │
-        │                             │
-        ├─────────────────────────────┤
-        │        Slab/Exclude         │
-        └─────────────────────────────┘
+    Either pass a ``cross_section`` (name, spec, or instance) or the legacy
+    ``width``/``layer``/``enclosure``.
 
     Args:
-        width: Width of the straight. [um]
         length: Length of the straight. [um]
-        layer: Main layer of the straight.
-        enclosure: Definition of slabs/excludes. [um]
+        cross_section: Cross section of the straight.
+        width: Width of the core. [um] (legacy; requires ``layer``)
+        layer: Main layer of the straight. (legacy)
+        enclosure: Definition of slabs/excludes. (legacy)
     """
+    if cross_section is not None:
+        return straight_dbu(cross_section=cross_section, length=demo.to_dbu(length))
+    if width is None or layer is None:
+        raise ValueError("Provide a cross_section, or width and layer (legacy call).")
     return straight_dbu(
         width=demo.to_dbu(width),
         length=demo.to_dbu(length),
diff --git a/src/kfactory/cells/taper.py b/src/kfactory/cells/taper.py
index 92a4bd005..1a2cc62c4 100644
--- a/src/kfactory/cells/taper.py
+++ b/src/kfactory/cells/taper.py
@@ -1,10 +1,23 @@
 r"""Tapers, linear only.
 
+A linear taper transitions between two cross sections (two core widths). The slabs
+and excludes are part of the cross sections, or can be given for the legacy
+``(width1, width2, layer, enclosure)`` call via a
+[`LayerEnclosure`][kfactory.enclosure.LayerEnclosure].
+
 TODO: Non-linear tapers.
 
 """
 
+from typing import overload
+
 from .. import KCell, kdb
+from ..cross_section import (
+    CrossSection,
+    CrossSectionSpecDict,
+    DCrossSection,
+    DCrossSectionSpecDict,
+)
 from ..enclosure import LayerEnclosure
 from ..factories.taper import taper_factory
 from ..typings import um
@@ -13,14 +26,52 @@
 __all__ = ["taper", "taper_dbu"]
 
 taper_dbu = taper_factory(kcl=demo)
+"""Cross-section-first taper factory on the default KCLayout (length in dbu)."""
 
 
+@overload
 def taper(
+    *,
     width1: um,
     width2: um,
     length: um,
     layer: kdb.LayerInfo,
     enclosure: LayerEnclosure | None = None,
+) -> KCell: ...
+@overload
+def taper(
+    *,
+    cross_section1: str
+    | CrossSection
+    | DCrossSection
+    | CrossSectionSpecDict
+    | DCrossSectionSpecDict,
+    cross_section2: str
+    | CrossSection
+    | DCrossSection
+    | CrossSectionSpecDict
+    | DCrossSectionSpecDict,
+    length: um,
+) -> KCell: ...
+def taper(
+    *,
+    length: um,
+    cross_section1: str
+    | CrossSection
+    | DCrossSection
+    | CrossSectionSpecDict
+    | DCrossSectionSpecDict
+    | None = None,
+    cross_section2: str
+    | CrossSection
+    | DCrossSection
+    | CrossSectionSpecDict
+    | DCrossSectionSpecDict
+    | None = None,
+    width1: um | None = None,
+    width2: um | None = None,
+    layer: kdb.LayerInfo | None = None,
+    enclosure: LayerEnclosure | None = None,
 ) -> KCell:
     r"""Linear Taper [um].
 
@@ -39,13 +90,29 @@ def taper(
             \_   │
               \__│ Slab/Exclude
 
+    Either pass two cross sections (``cross_section1``/``cross_section2``) or the
+    legacy ``width1``/``width2``/``layer``/``enclosure``.
+
     Args:
-        width1: Width of the core on the left side. [um]
-        width2: Width of the core on the right side. [um]
         length: Length of the taper. [um]
-        layer: Main layer of the taper.
-        enclosure: Definition of the slab/exclude.
+        cross_section1: Cross section of the left side.
+        cross_section2: Cross section of the right side.
+        width1: Width of the core on the left side. [um] (legacy; requires ``layer``)
+        width2: Width of the core on the right side. [um] (legacy; requires ``layer``)
+        layer: Main layer of the taper. (legacy)
+        enclosure: Definition of the slab/exclude. (legacy)
     """
+    if cross_section1 is not None and cross_section2 is not None:
+        return taper_dbu(
+            cross_section1=cross_section1,
+            cross_section2=cross_section2,
+            length=demo.to_dbu(length),
+        )
+    if width1 is None or width2 is None or layer is None:
+        raise ValueError(
+            "Provide cross_section1 and cross_section2, or width1, width2 and layer"
+            " (legacy call)."
+        )
     return taper_dbu(
         width1=demo.to_dbu(width1),
         width2=demo.to_dbu(width2),
diff --git a/src/kfactory/checks.py b/src/kfactory/checks.py
new file mode 100644
index 000000000..5dc024a0f
--- /dev/null
+++ b/src/kfactory/checks.py
@@ -0,0 +1,805 @@
+"""Standalone connectivity / overlap checks producing klayout ReportDatabases.
+
+Each check answers a single conceptual question and writes its findings to an
+`rdb.ReportDatabase`. They are composed by [`ProtoTKCell.connectivity_check`]
+[kfactory.kcell.ProtoTKCell.connectivity_check] for the all-in-one pass-or-fail
+verification, but can be called individually for narrower checks.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from kfnetlist import PortCheck, check_connection
+
+from . import kdb, rdb
+from .layer import LayerEnum
+from .port import create_port_error, port_polygon
+from .ports import Ports
+
+if TYPE_CHECKING:
+    from collections.abc import Callable, Iterable
+
+    from .instance import ProtoTInstance
+    from .kcell import KCell, ProtoTKCell
+    from .port import Port, ProtoPort
+
+
+__all__ = [
+    "dangling_ports_check",
+    "instance_overlap_check",
+    "port_mismatch_check",
+    "shape_instance_overlap_check",
+]
+
+
+type CellPortMap = dict[int, dict[tuple[float, float], list[ProtoPort[Any]]]]
+type InstPortMap = dict[
+    LayerEnum | int,
+    dict[tuple[int, int], list[tuple[Port, KCell, str, ProtoTInstance[Any]]]],
+]
+
+
+def _layer_cat_factory(
+    db: rdb.ReportDatabase, cell: ProtoTKCell[Any]
+) -> Callable[[int], rdb.RdbCategory]:
+    """Return a memoised helper that maps a layer index to its RDB category."""
+    layer_cats: dict[int, rdb.RdbCategory] = {}
+
+    def layer_cat(layer: int) -> rdb.RdbCategory:
+        if layer not in layer_cats:
+            if isinstance(layer, LayerEnum):
+                ln = str(layer.name)
+            else:
+                li = cell.kcl.get_info(layer)
+                ln = str(li).replace("/", "_")
+            layer_cats[layer] = db.category_by_path(ln) or db.create_category(ln)
+        return layer_cats[layer]
+
+    return layer_cat
+
+
+def _get_or_create_subcategory(
+    db: rdb.ReportDatabase, parent: rdb.RdbCategory, name: str
+) -> rdb.RdbCategory:
+    return db.category_by_path(f"{parent.path()}.{name}") or db.create_category(
+        parent, name
+    )
+
+
+def _port_polygon_um(cell: ProtoTKCell[Any], port: ProtoPort[Any]) -> kdb.DPolygon:
+    if port.base.trans:
+        return cell.kcl.to_um(port_polygon(port.iwidth).transformed(port.trans))
+    return cell.kcl.to_um(port_polygon(port.iwidth)).transformed(port.dcplx_trans)
+
+
+def _collect_cell_ports(
+    cell: ProtoTKCell[Any],
+    port_types: list[str],
+    layers: list[int],
+) -> CellPortMap:
+    """Build the layer -> coord -> [cell ports] mapping used by port checks."""
+    cell_ports: CellPortMap = {}
+    for port in Ports(kcl=cell.kcl, bases=cell.ports.bases):
+        if (port_types and port.port_type not in port_types) or (
+            layers and port.layer not in layers
+        ):
+            continue
+        xy = (port.x, port.y)
+        cell_ports.setdefault(port.layer, {}).setdefault(xy, []).append(port)
+    return cell_ports
+
+
+def _collect_inst_ports(
+    cell: ProtoTKCell[Any],
+    port_types: list[str],
+    layers: list[int],
+) -> InstPortMap:
+    """Build the layer -> coord -> [(port, inst_cell, inst_name, inst)] mapping."""
+    inst_ports: InstPortMap = {}
+    for inst in cell.insts:
+        inst_name = inst.name
+        inst_cell = inst.cell.to_itype()
+        for port in Ports(kcl=cell.kcl, bases=[p.base for p in inst.ports]):
+            if (port_types and port.port_type not in port_types) or (
+                layers and port.layer not in layers
+            ):
+                continue
+            xy = (port.x, port.y)
+            inst_ports.setdefault(port.layer, {}).setdefault(xy, []).append(
+                (port, inst_cell, inst_name, inst)
+            )
+    return inst_ports
+
+
+def _recurse(
+    cell: ProtoTKCell[Any],
+    db: rdb.ReportDatabase,
+    check: Callable[..., rdb.ReportDatabase],
+    **kwargs: Any,
+) -> None:
+    """Run `check` on every called child cell, bottom-up, with recursive=False."""
+    called = cell.called_cells()
+    for c in cell.kcl.each_cell_bottom_up():
+        if c in called:
+            check(cell.kcl[c], db=db, recursive=False, **kwargs)
+
+
+def _ensure_db(
+    cell: ProtoTKCell[Any], db: rdb.ReportDatabase | None, label: str
+) -> rdb.ReportDatabase:
+    return db or rdb.ReportDatabase(f"{label} {cell.name}")
+
+
+# ---------------------------------------------------------------------------
+# Port mismatch check (width / angle / type / port_overlap / physical-shape)
+# ---------------------------------------------------------------------------
+
+
+def _emit_cell_port(
+    cell: ProtoTKCell[Any],
+    db: rdb.ReportDatabase,
+    db_cell: rdb.RdbCell,
+    layer_cat: Callable[[int], rdb.RdbCategory],
+    port: ProtoPort[Any],
+) -> None:
+    c_cat = _get_or_create_subcategory(db, layer_cat(port.layer), "CellPorts")
+    it = db.create_item(db_cell, c_cat)
+    if port.name:
+        it.add_value(f"Port name: {port.name}")
+    it.add_value(_port_polygon_um(cell, port))
+
+
+def _emit_physical_shape_issue(
+    cell: ProtoTKCell[Any],
+    db: rdb.ReportDatabase,
+    db_cell: rdb.RdbCell,
+    layer_cat: Callable[[int], rdb.RdbCategory],
+    port: ProtoPort[Any],
+    *,
+    partial: kdb.Edges | None,
+) -> None:
+    if partial is not None:
+        cat = _get_or_create_subcategory(
+            db, layer_cat(port.layer), "PartialPhysicalShape"
+        )
+        it = db.create_item(db_cell, cat)
+        it.add_value(
+            "Insufficient overlap, partial overlap with polygon of"
+            f" {(partial[0].p1 - partial[0].p2).abs()}/{port.width}"
+        )
+    else:
+        cat = _get_or_create_subcategory(
+            db, layer_cat(port.layer), "MissingPhysicalShape"
+        )
+        it = db.create_item(db_cell, cat)
+        it.add_value(f"Found no overlapping Edge with Port {port.name or str(port)}")
+    it.add_value(_port_polygon_um(cell, port))
+
+
+def _check_cell_port_physical_shape(
+    cell: ProtoTKCell[Any], port: ProtoPort[Any]
+) -> tuple[bool, kdb.Edges | None]:
+    """Returns (is_ok, partial_overlap_or_none).
+
+    `partial_overlap_or_none` is non-None iff there is some overlap but it is
+    insufficient. is_ok=True iff a full port-edge overlap is found.
+    """
+    rec_it = kdb.RecursiveShapeIterator(
+        cell.kcl.layout,
+        cell._base.kdb_cell,
+        port.layer,
+        kdb.Box(2, port.width).transformed(port.trans),
+    )
+    edges = kdb.Region(rec_it).merge().edges().merge()
+    port_edge = kdb.Edge(0, port.width // 2, 0, -port.width // 2)
+    if port.base.trans:
+        port_edge = port_edge.transformed(port.trans)
+    else:
+        port_edge = port_edge.transformed(
+            kdb.ICplxTrans(port.dcplx_trans, cell.kcl.dbu)
+        )
+    p_edges = kdb.Edges([port_edge])
+    phys_overlap = p_edges & edges
+    if phys_overlap.is_empty():
+        return False, None
+    if phys_overlap[0] != port_edge:
+        return False, phys_overlap
+    return True, None
+
+
+def _emit_port_overlap(
+    cell: ProtoTKCell[Any],
+    db: rdb.ReportDatabase,
+    db_cell: rdb.RdbCell,
+    layer_cat_for_layer: rdb.RdbCategory,
+    ports: list[tuple[Port, KCell, str, ProtoTInstance[Any]]],
+    cell_port_at_coord: ProtoPort[Any] | None,
+) -> None:
+    cat = _get_or_create_subcategory(db, layer_cat_for_layer, "PortOverlap")
+    it = db.create_item(db_cell, cat)
+    text = "Port Names: "
+    values: list[rdb.RdbItemValue] = []
+    if cell_port_at_coord is not None:
+        text += (
+            f"{cell.name}.{cell_port_at_coord.name or cell_port_at_coord.trans.to_s()}/"
+        )
+        values.append(rdb.RdbItemValue(_port_polygon_um(cell, cell_port_at_coord)))
+    for _port, _cell, _inst_name, _inst in ports:
+        label = f"{_inst_name}." if _inst_name else f"{_cell.name}."
+        text += f"{label}{_port.name or _port.trans.to_s()}/"
+        values.append(
+            rdb.RdbItemValue(
+                cell.kcl.to_um(port_polygon(_port.width).transformed(_port.trans))
+            )
+        )
+    it.add_value(text[:-1])
+    for value in values:
+        it.add_value(value)
+
+
+def _resolve_layer_indexes(
+    kcl: Any, layers: Iterable[int | kdb.LayerInfo | str] | None
+) -> set[int]:
+    """Resolve mixed-spec layers (int / LayerInfo / name) to int indexes."""
+    if not layers:
+        return set()
+    out: set[int] = set()
+    for spec in layers:
+        if isinstance(spec, int):
+            out.add(spec)
+        elif isinstance(spec, kdb.LayerInfo):
+            out.add(kcl.layout.layer(spec))
+        else:
+            out.add(kcl.find_layer(spec))
+    return out
+
+
+def port_mismatch_check(
+    cell: ProtoTKCell[Any],
+    *,
+    port_types: list[str] | None = None,
+    layers: list[int] | None = None,
+    db: rdb.ReportDatabase | None = None,
+    recursive: bool = True,
+    add_cell_ports: bool = False,
+    check_width: bool = True,
+    check_angle: bool = True,
+    check_type: bool = True,
+    check_port_overlap: bool = True,
+    check_missing_physical_shape: bool = True,
+    check_partial_physical_shape: bool = True,
+    width_mismatch_ignore_layers: list[int | kdb.LayerInfo | str] | None = None,
+) -> rdb.ReportDatabase:
+    """Report port-pair / port-shape mismatches as one logical check.
+
+    Aggregates width / angle / port-type mismatches between coincident ports,
+    >2-port overlaps, and missing/partial physical layer shapes under the port
+    region. Individual sub-rules can be toggled, but this is one connectivity
+    check producing one pass/fail outcome for the cell.
+
+    Args:
+        cell: Cell to verify.
+        port_types: If given, only ports whose `port_type` is in this list are
+            considered.
+        layers: If given, only ports on these layers are considered.
+        db: Reuse an existing report database. A new one is created otherwise.
+        recursive: Run the same check on every called child cell as well.
+        add_cell_ports: Add a `CellPorts` category listing the cell's own
+            (filtered) ports for visual inspection in the report.
+        check_width: Emit `WidthMismatch` items.
+        check_angle: Emit `AngleMismatch` items.
+        check_type: Emit `TypeMismatch` items.
+        check_port_overlap: Emit `PortOverlap` items when 2+ instance ports
+            share a coord (and either differ from a cell port or pile up >2).
+        check_missing_physical_shape: Emit `MissingPhysicalShape` items.
+        check_partial_physical_shape: Emit `PartialPhysicalShape` items.
+        width_mismatch_ignore_layers: Layers (specified by int index,
+            ``kdb.LayerInfo`` or name) on which ``WidthMismatch`` items should
+            be suppressed. Useful for metal stacks where mismatched widths at
+            a via stack are intentional.
+    """
+    port_types = port_types or []
+    layers = layers or []
+    db_ = _ensure_db(cell, db, "Port Mismatch Check")
+    ignore_width_layers = _resolve_layer_indexes(cell.kcl, width_mismatch_ignore_layers)
+    if recursive:
+        _recurse(
+            cell,
+            db_,
+            port_mismatch_check,
+            port_types=port_types,
+            layers=layers,
+            add_cell_ports=add_cell_ports,
+            check_width=check_width,
+            check_angle=check_angle,
+            check_type=check_type,
+            check_port_overlap=check_port_overlap,
+            check_missing_physical_shape=check_missing_physical_shape,
+            check_partial_physical_shape=check_partial_physical_shape,
+            width_mismatch_ignore_layers=width_mismatch_ignore_layers,
+        )
+
+    db_cell = db_.create_cell(cell.name)
+    layer_cat = _layer_cat_factory(db_, cell)
+    cell_ports = _collect_cell_ports(cell, port_types, layers)
+
+    # Cell-port physical-shape pass + optional CellPorts annotation.
+    for by_coord in cell_ports.values():
+        for cell_port_list in by_coord.values():
+            for port in cell_port_list:
+                if add_cell_ports:
+                    _emit_cell_port(cell, db_, db_cell, layer_cat, port)
+                if not (check_missing_physical_shape or check_partial_physical_shape):
+                    continue
+                ok, partial = _check_cell_port_physical_shape(cell, port)
+                if ok:
+                    continue
+                if partial is not None and check_partial_physical_shape:
+                    _emit_physical_shape_issue(
+                        cell, db_, db_cell, layer_cat, port, partial=partial
+                    )
+                elif partial is None and check_missing_physical_shape:
+                    _emit_physical_shape_issue(
+                        cell, db_, db_cell, layer_cat, port, partial=None
+                    )
+
+    inst_ports = _collect_inst_ports(cell, port_types, layers)
+
+    def emit_mismatch(
+        result: int,
+        lc: rdb.RdbCategory,
+        p_a: Port,
+        p_b: ProtoPort[Any],
+        c_a: ProtoTKCell[Any],
+        c_b: ProtoTKCell[Any],
+        *,
+        expect_opposite: bool,
+        inst_name1: str | None,
+        inst_name2: str | None = None,
+    ) -> None:
+        angle_ok = bool(
+            result & (PortCheck.opposite if expect_opposite else PortCheck.same)
+        )
+        if (
+            check_width
+            and not result & PortCheck.width
+            and layer not in ignore_width_layers
+        ):
+            subc = _get_or_create_subcategory(db_, lc, "WidthMismatch")
+            create_port_error(
+                p_a,
+                p_b,
+                c_a,
+                c_b,
+                db_,
+                db_cell,
+                subc,
+                cell.kcl.dbu,
+                inst_name1=inst_name1,
+                inst_name2=inst_name2,
+            )
+        if check_angle and not angle_ok:
+            subc = _get_or_create_subcategory(db_, lc, "AngleMismatch")
+            create_port_error(
+                p_a,
+                p_b,
+                c_a,
+                c_b,
+                db_,
+                db_cell,
+                subc,
+                cell.kcl.dbu,
+                inst_name1=inst_name1,
+                inst_name2=inst_name2,
+            )
+        if check_type and not result & PortCheck.port_type:
+            subc = _get_or_create_subcategory(db_, lc, "TypeMismatch")
+            create_port_error(
+                p_a,
+                p_b,
+                c_a,
+                c_b,
+                db_,
+                db_cell,
+                subc,
+                cell.kcl.dbu,
+                inst_name1=inst_name1,
+                inst_name2=inst_name2,
+            )
+
+    for layer, coord_map in inst_ports.items():
+        lc = layer_cat(layer)
+        for coord, ports in coord_map.items():
+            n = len(ports)
+            if n == 1:
+                if layer in cell_ports and coord in cell_ports[layer]:
+                    cell_port = cell_ports[layer][coord][0]
+                    result = check_connection(cell_port, ports[0][0])
+                    emit_mismatch(
+                        result,
+                        lc,
+                        ports[0][0],
+                        cell_port,
+                        ports[0][1],
+                        cell,
+                        expect_opposite=False,
+                        inst_name1=ports[0][2],
+                    )
+                # Dangling case is handled by dangling_ports_check.
+            elif n == 2:
+                result = check_connection(ports[0][0], ports[1][0])
+                emit_mismatch(
+                    result,
+                    lc,
+                    ports[0][0],
+                    ports[1][0],
+                    ports[0][1],
+                    ports[1][1],
+                    expect_opposite=True,
+                    inst_name1=ports[0][2],
+                    inst_name2=ports[1][2],
+                )
+                if (
+                    check_port_overlap
+                    and layer in cell_ports
+                    and coord in cell_ports[layer]
+                ):
+                    _emit_port_overlap(
+                        cell,
+                        db_,
+                        db_cell,
+                        lc,
+                        ports,
+                        cell_port_at_coord=cell_ports[layer][coord][0],
+                    )
+            elif n > 2:
+                if check_port_overlap:
+                    _emit_port_overlap(
+                        cell, db_, db_cell, lc, ports, cell_port_at_coord=None
+                    )
+            else:
+                raise ValueError(f"Unexpected number of ports: {n}")
+
+    return db_
+
+
+# ---------------------------------------------------------------------------
+# Dangling ports check (formerly OrphanPort)
+# ---------------------------------------------------------------------------
+
+
+def _resolve_equivalent_group(
+    equivalent_ports: dict[str, list[list[str]]] | None,
+    port_cell: ProtoTKCell[Any],
+    port_name: str,
+) -> set[str] | None:
+    """Return the equivalent-port group containing ``port_name`` on ``port_cell``.
+
+    Looks up ``equivalent_ports`` first by ``port_cell.name``, then by its
+    ``factory_name`` if available — so callers can key the dict by either the
+    concrete cell name or the factory's canonical name. Returns ``None`` if
+    no group is found or the port is not in any declared group.
+    """
+    if not equivalent_ports:
+        return None
+    groups = equivalent_ports.get(port_cell.name)
+    if groups is None and port_cell.has_factory_name():
+        groups = equivalent_ports.get(port_cell.factory_name)
+    if not groups:
+        return None
+    for g in groups:
+        if port_name in g:
+            return set(g)
+    return None
+
+
+def _is_coord_connected(
+    layer: int,
+    coord: tuple[int, int],
+    cell_ports: CellPortMap,
+    inst_ports: InstPortMap,
+) -> bool:
+    """A coord is 'connected' if a cell port or a second instance port lives there."""
+    if layer in cell_ports and coord in cell_ports[layer]:
+        return True
+    return len(inst_ports.get(layer, {}).get(coord, [])) > 1
+
+
+def _array_element_for_port(
+    inst: Any, kcl: Any, port_name: str, port_coord: tuple[int, int]
+) -> tuple[int, int] | None:
+    """For an array inst, locate the (ia, ib) whose port_name lands at port_coord.
+
+    Returns None for non-array instances (caller treats as the single element).
+    """
+    if not inst.na or not inst.nb:
+        return None
+    for ia in range(inst.na):
+        for ib in range(inst.nb):
+            try:
+                sub = inst[port_name, ia, ib]
+            except KeyError:
+                continue
+            for p in Ports(kcl=kcl, bases=[sub.base]):
+                if (p.x, p.y) == port_coord:
+                    return ia, ib
+    return None
+
+
+def _siblings_connected(
+    inst: Any,
+    kcl: Any,
+    self_port_name: str,
+    self_port_coord: tuple[int, int],
+    group: set[str],
+    cell_ports: CellPortMap,
+    inst_ports: InstPortMap,
+) -> bool:
+    """Whether any equivalent sibling on the same instance / array-element is connected.
+
+    Equivalence is declared per *cell type* via ``equivalent_ports``; the
+    check is scoped per array element so a connection on element 0 doesn't
+    suppress dangling ports on element 3. Works uniformly for scalar
+    instances (treated as a single implicit element) and for ``na``/``nb``
+    arrays.
+    """
+    sibling_names = group - {self_port_name}
+    if not sibling_names:
+        return False
+
+    element = _array_element_for_port(inst, kcl, self_port_name, self_port_coord)
+
+    def sibling_port_at(name: str) -> Any | None:
+        try:
+            return inst[name, *element] if element is not None else inst[name]
+        except (KeyError, ValueError):
+            return None
+
+    for sib_name in sibling_names:
+        sib = sibling_port_at(sib_name)
+        if sib is None:
+            continue
+        for p in Ports(kcl=kcl, bases=[sib.base]):
+            if _is_coord_connected((p.layer), (p.x, p.y), cell_ports, inst_ports):
+                return True
+    return False
+
+
+def dangling_ports_check(
+    cell: ProtoTKCell[Any],
+    *,
+    port_types: list[str] | None = None,
+    layers: list[int] | None = None,
+    db: rdb.ReportDatabase | None = None,
+    recursive: bool = True,
+    equivalent_ports: dict[str, list[list[str]]] | None = None,
+) -> rdb.ReportDatabase:
+    """Report dangling instance ports — ports with no matching counterpart.
+
+    A dangling port is an instance port at a coord where no other instance
+    port and no cell port appears. Emitted under the `DanglingPort` category.
+
+    Args:
+        cell: Cell to verify.
+        port_types: If given, only ports whose `port_type` is in this list are
+            considered.
+        layers: If given, only ports on these layers are considered.
+        db: Reuse an existing report database. A new one is created otherwise.
+        recursive: Run the same check on every called child cell as well.
+        equivalent_ports: Per-cell groups of electrically-equivalent port
+            names (same shape as `Netlist.lvs_equivalent`'s argument).
+            When provided, an instance port is **not** reported as dangling if
+            any other port in its group on the same instance is connected.
+            Typical use is multi-contact pads where ``e1``, ``e2``, ``e3``,
+            ``e4`` and ``pad`` are the same electrical node.
+    """
+    port_types = port_types or []
+    layers = layers or []
+    db_ = _ensure_db(cell, db, "Dangling Ports Check")
+    if recursive:
+        _recurse(
+            cell,
+            db_,
+            dangling_ports_check,
+            port_types=port_types,
+            layers=layers,
+            equivalent_ports=equivalent_ports,
+        )
+
+    db_cell = db_.create_cell(cell.name)
+    layer_cat = _layer_cat_factory(db_, cell)
+    cell_ports = _collect_cell_ports(cell, port_types, layers)
+    inst_ports = _collect_inst_ports(cell, port_types, layers)
+
+    for layer, coord_map in inst_ports.items():
+        lc = layer_cat(layer)
+        for coord, ports in coord_map.items():
+            if len(ports) != 1:
+                continue
+            if layer in cell_ports and coord in cell_ports[layer]:
+                continue
+            port, port_cell, inst_name, inst_obj = ports[0]
+            if equivalent_ports:
+                group = _resolve_equivalent_group(
+                    equivalent_ports, port_cell, port.name or ""
+                )
+                if (
+                    group
+                    and len(group) > 1
+                    and _siblings_connected(
+                        inst_obj,
+                        cell.kcl,
+                        port.name or "",
+                        coord,
+                        group,
+                        cell_ports,
+                        inst_ports,
+                    )
+                ):
+                    continue
+            subc = _get_or_create_subcategory(db_, lc, "DanglingPort")
+            it = db_.create_item(db_cell, subc)
+            port_name = port.name or str(port)
+            if inst_name:
+                it.add_value(
+                    f"Port Name: {inst_name}.{port_name} (cell: {port_cell.name})"
+                )
+            else:
+                it.add_value(f"Port Name: {port_cell.name}.{port_name}")
+            if port._base.trans:
+                it.add_value(
+                    cell.kcl.to_um(
+                        port_polygon(port.width).transformed(port._base.trans)
+                    )
+                )
+            else:
+                it.add_value(
+                    cell.kcl.to_um(port_polygon(port.width)).transformed(
+                        port.dcplx_trans
+                    )
+                )
+
+    return db_
+
+
+# ---------------------------------------------------------------------------
+# Shape ↔ instance-shape overlap checks
+# ---------------------------------------------------------------------------
+
+
+def _iter_check_layers(cell: ProtoTKCell[Any], layers: list[int]) -> list[int]:
+    """Layers to scan for shape/instance overlap.
+
+    If `layers` is given, that exact list is returned; otherwise every layer
+    in the layout is yielded.
+    """
+    if layers:
+        return list(layers)
+    return list(cell.kcl.layout.layer_indexes())
+
+
+def instance_overlap_check(
+    cell: ProtoTKCell[Any],
+    *,
+    layers: list[int] | None = None,
+    db: rdb.ReportDatabase | None = None,
+    recursive: bool = True,
+) -> rdb.ReportDatabase:
+    """Report instance shapes overlapping shapes of other instances.
+
+    For each candidate layer, polygons of one instance that overlap polygons
+    of another instance are reported under `InstanceOverlap`.
+
+    Args:
+        cell: Cell to verify.
+        layers: If given, only check these layers.
+        db: Reuse an existing report database. A new one is created otherwise.
+        recursive: Run the same check on every called child cell as well.
+    """
+    layers = layers or []
+    db_ = _ensure_db(cell, db, "Instance Overlap Check")
+    if recursive:
+        _recurse(cell, db_, instance_overlap_check, layers=layers)
+
+    db_cell = db_.create_cell(cell.name)
+    layer_cat = _layer_cat_factory(db_, cell)
+
+    for layer in _iter_check_layers(cell, layers):
+        error_region = kdb.Region()
+        inst_regions: dict[int, kdb.Region] = {}
+        inst_region = kdb.Region()
+        for i, inst in enumerate(cell.insts):
+            inst_region_ = kdb.Region(inst.ibbox(layer))
+            inst_shapes: kdb.Region | None = None
+            if not (inst_region & inst_region_).is_empty():
+                if inst_shapes is None:
+                    inst_shapes = kdb.Region()
+                    shape_it = cell.begin_shapes_rec_overlapping(
+                        layer, inst.bbox(layer)
+                    )
+                    shape_it.select_cells([inst.cell.cell_index()])
+                    shape_it.min_depth = 1
+                    shape_it.shape_flags = kdb.Shapes.SRegions
+                    for _it in shape_it.each():
+                        if _it.path()[0].inst() == inst.instance:
+                            inst_shapes.insert(
+                                _it.shape().polygon.transformed(_it.trans())
+                            )
+                for j, _reg in inst_regions.items():
+                    if _reg & inst_region_:
+                        reg_ = kdb.Region()
+                        shape_it = cell.begin_shapes_rec_touching(
+                            layer, (_reg & inst_region_).bbox()
+                        )
+                        shape_it.select_cells([cell.insts[j].cell.cell_index()])
+                        shape_it.min_depth = 1
+                        shape_it.shape_flags = kdb.Shapes.SRegions
+                        for _it in shape_it.each():
+                            if _it.path()[0].inst() == cell.insts[j].instance:
+                                reg_.insert(
+                                    _it.shape().polygon.transformed(_it.trans())
+                                )
+                        error_region.insert(reg_ & inst_shapes)
+            inst_region += inst_region_
+            inst_regions[i] = inst_region_
+
+        if not error_region.is_empty():
+            sc = _get_or_create_subcategory(db_, layer_cat(layer), "InstanceOverlap")
+            for poly in error_region.merge().each():
+                it = db_.create_item(db_cell, sc)
+                it.add_value(
+                    "Instance shapes overlapping with shapes of other instances"
+                )
+                it.add_value(cell.kcl.to_um(poly.downcast()))
+
+    return db_
+
+
+def shape_instance_overlap_check(
+    cell: ProtoTKCell[Any],
+    *,
+    layers: list[int] | None = None,
+    db: rdb.ReportDatabase | None = None,
+    recursive: bool = True,
+) -> rdb.ReportDatabase:
+    """Report top-level cell shapes overlapping with shapes of instances.
+
+    Polygons drawn directly into the cell that touch polygons from any of its
+    instances are reported under `CellShapeInstanceOverlap`.
+
+    Args:
+        cell: Cell to verify.
+        layers: If given, only check these layers.
+        db: Reuse an existing report database. A new one is created otherwise.
+        recursive: Run the same check on every called child cell as well.
+    """
+    layers = layers or []
+    db_ = _ensure_db(cell, db, "Shape/Instance Overlap Check")
+    if recursive:
+        _recurse(cell, db_, shape_instance_overlap_check, layers=layers)
+
+    db_cell = db_.create_cell(cell.name)
+    layer_cat = _layer_cat_factory(db_, cell)
+
+    for layer in _iter_check_layers(cell, layers):
+        error_region = kdb.Region()
+        reg = kdb.Region(cell.shapes(layer))
+        for inst in cell.insts:
+            inst_region_ = kdb.Region(inst.ibbox(layer))
+            if (inst_region_ & reg).is_empty():
+                continue
+            rec_it = cell.begin_shapes_rec_touching(layer, (inst_region_ & reg).bbox())
+            rec_it.min_depth = 1
+            error_region += kdb.Region(rec_it) & reg
+
+        if not error_region.is_empty():
+            sc = _get_or_create_subcategory(
+                db_, layer_cat(layer), "CellShapeInstanceOverlap"
+            )
+            for poly in error_region.merge().each():
+                it = db_.create_item(db_cell, sc)
+                it.add_value("Shapes overlapping with shapes of instances")
+                it.add_value(cell.kcl.to_um(poly.downcast()))
+
+    return db_
diff --git a/src/kfactory/cli/build.py b/src/kfactory/cli/build.py
index 8ac621927..4fcdd2bfc 100644
--- a/src/kfactory/cli/build.py
+++ b/src/kfactory/cli/build.py
@@ -8,7 +8,7 @@
 import os
 import runpy
 import sys
-from enum import Enum
+from enum import StrEnum
 from pathlib import Path
 from typing import Annotated
 
@@ -41,7 +41,7 @@ def show(
     kfshow(path, use_libraries=True)
 
 
-class LayoutSuffix(str, Enum):
+class LayoutSuffix(StrEnum):
     gds = "gds"
     gdsgz = "gds.gz"
     oas = "oas"
diff --git a/src/kfactory/conf.py b/src/kfactory/conf.py
index 343b47ac7..bf4e40bd5 100644
--- a/src/kfactory/conf.py
+++ b/src/kfactory/conf.py
@@ -7,7 +7,7 @@
 import re
 import sys
 import traceback
-from enum import Enum, IntEnum
+from enum import IntEnum, StrEnum
 from functools import cached_property
 from itertools import takewhile
 from pathlib import Path
@@ -25,8 +25,9 @@
     from . import kdb, rdb
     from .kcell import AnyKCell
     from .layout import KCLayout
+    from .typings import DShapeLike, MarkerConfig
 
-__all__ = ["LogLevel", "config"]
+__all__ = ["CheckInstances", "LogLevel", "config"]
 
 
 DEFAULT_TRANS: dict[str, str | int | float | dict[str, str | int | float]] = {
@@ -85,6 +86,7 @@ def __call__(
         use_libraries: bool,
         library_save_options: kdb.SaveLayoutOptions,
         technology: str | None = None,
+        markers: list[tuple[DShapeLike, MarkerConfig]] | None = None,
     ) -> None: ...
 
 
@@ -121,7 +123,7 @@ def tracing_formatter(record: loguru.Record) -> str:
     )
 
 
-class LogLevel(str, Enum):
+class LogLevel(StrEnum):
     """KFactory logger levels."""
 
     TRACE = "TRACE"
@@ -133,13 +135,19 @@ class LogLevel(str, Enum):
     CRITICAL = "CRITICAL"
 
 
-class CheckInstances(str, Enum):
+class CheckInstances(StrEnum):
     RAISE = "error"
     FLATTEN = "flatten"
     VINSTANCES = "vinstances"
     IGNORE = "ignore"
 
 
+class CheckUnnamedCells(StrEnum):
+    RAISE = "error"
+    WARNING = "warning"
+    IGNORE = "ignore"
+
+
 class LogFilter(BaseModel):
     """Filter certain messages by log level or regex.
 
@@ -163,7 +171,7 @@ def get_show_function(value: str | ShowFunction) -> ShowFunction:
     if isinstance(value, str):
         mod, f = value.rsplit(".", 1)
         loaded_mod = importlib.import_module(mod)
-        return loaded_mod.__getattribute__(f)  # type: ignore[no-any-return]
+        return loaded_mod.__getattribute__(f)
     return value
 
 
@@ -173,14 +181,14 @@ def get_affinity() -> int:
     On (most) linux we can get it through the scheduling affinity. Otherwise,
     fall back to the multiprocessing cpu count.
     """
-    threads = 0
+    if hasattr(os, "sched_getaffinity"):
+        return len(os.sched_getaffinity(0))
     try:
-        return len(os.sched_getaffinity(0))  # type: ignore[attr-defined,unused-ignore]
-    except AttributeError:
         import multiprocessing
 
-        threads = multiprocessing.cpu_count()
-    return threads
+        return multiprocessing.cpu_count()
+    except ModuleNotFoundError:
+        return 1
 
 
 dotenv_path = find_dotenv(usecwd=True)
@@ -242,6 +250,7 @@ class Settings(BaseSettings):
     connect_use_angle: bool = True
     connect_use_mirror: bool = True
     check_instances: CheckInstances = CheckInstances.RAISE
+    check_unnamed_cells: CheckUnnamedCells = CheckUnnamedCells.WARNING
     max_cellname_length: int = 99
     debug_names: bool = False
 
@@ -250,6 +259,9 @@ class Settings(BaseSettings):
     write_cell_properties: bool = True
     write_file_properties: bool = True
     write_timestamps: bool = False
+    write_kfactory_settings: bool = True
+    """Write kfactory version into the gds/oasis."""
+    multi_xy_records: bool = False
 
     show_function: ShowFunction | None = None
 
@@ -275,7 +287,7 @@ def _validate_logfilter(cls, logfilter: LogFilter) -> LogFilter:
             sys.stdout,
             format=tracing_formatter,
             filter=logfilter,
-            enqueue=True,
+            enqueue=False,
             backtrace=True,
         )
         logger.debug("LogLevel: {}", logfilter.level)
diff --git a/src/kfactory/cross_section.py b/src/kfactory/cross_section.py
index 1a5c70790..d71407aa0 100644
--- a/src/kfactory/cross_section.py
+++ b/src/kfactory/cross_section.py
@@ -3,10 +3,10 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
+from hashlib import sha1
 from typing import (
     TYPE_CHECKING,
     Any,
-    Generic,
     Literal,
     NotRequired,
     Self,
@@ -17,7 +17,8 @@
 from pydantic import BaseModel, Field, PrivateAttr, model_validator
 
 from .enclosure import DLayerEnclosure, LayerEnclosure, LayerEnclosureSpec
-from .typings import TUnit, dbu
+from .exceptions import CrossSectionNamingConflictError
+from .typings import dbu  # noqa: TC001
 
 if TYPE_CHECKING:
     from collections.abc import Mapping, Sequence
@@ -26,13 +27,34 @@
     from .layout import KCLayout
 
 __all__ = [
+    "AnyCrossSection",
+    "AnyCrossSectionInput",
+    "AsymmetricCrossSection",
+    "AsymmetricalCrossSection",
     "CrossSection",
-    "CrossSectionSpec",
+    "CrossSectionLayer",
+    "CrossSectionSpecDict",
+    "DAsymmetricCrossSection",
+    "DAsymmetricalCrossSection",
     "DCrossSection",
-    "DCrossSectionSpec",
+    "DCrossSectionLayer",
+    "DCrossSectionSpecDict",
     "SymmetricalCrossSection",
 ]
 
+type CrossSectionSpec = (
+    CrossSection
+    | DCrossSection
+    | SymmetricalCrossSection
+    | DSymmetricalCrossSection
+    | AsymmetricalCrossSection
+    | AsymmetricCrossSection
+    | DAsymmetricCrossSection
+    | CrossSectionSpecDict
+    | DCrossSectionSpecDict
+    | str
+)
+
 
 class SymmetricalCrossSection(BaseModel, frozen=True, arbitrary_types_allowed=True):
     """CrossSection which is symmetrical to its main_layer/width."""
@@ -42,30 +64,49 @@ class SymmetricalCrossSection(BaseModel, frozen=True, arbitrary_types_allowed=Tr
     name: str = ""
     radius: dbu | None = None
     radius_min: dbu | None = None
-    bbox_sections: dict[kdb.LayerInfo, dbu]
 
     def __init__(
         self,
         width: dbu,
         enclosure: LayerEnclosure,
         name: str | None = None,
-        bbox_sections: dict[kdb.LayerInfo, dbu] | None = None,
         radius: dbu | None = None,
         radius_min: dbu | None = None,
     ) -> None:
-        """Initialized the CrossSection."""
+        """Initialized the CrossSection.
+
+        `bbox_sections` live on the `enclosure` — build the enclosure with them.
+        """
         super().__init__(
             width=width,
             enclosure=enclosure,
             name=name or f"{enclosure.name}_{width}",
-            bbox_sections=bbox_sections or {},
             radius=radius,
             radius_min=radius_min,
         )
 
+    @property
+    def bbox_sections(self) -> dict[kdb.LayerInfo, dbu]:
+        """Bounding-box sections (owned by the enclosure)."""
+        return self.enclosure.bbox_sections
+
+    def auto_name(self) -> str:
+        return f"{self.enclosure.name}_{self.width}"
+
+    @property
+    def is_named(self) -> bool:
+        """Whether an explicit name was given (vs. the enclosure-derived name)."""
+        return self.name != self.auto_name()
+
+    @property
+    def extent(self) -> dbu:
+        return 0
+
     @model_validator(mode="before")
     @classmethod
     def _set_name(cls, data: Any) -> Any:
+        if not isinstance(data, dict):
+            return data
         data["name"] = data.get("name") or f"{data['enclosure'].name}_{data['width']}"
         return data
 
@@ -95,6 +136,10 @@ def main_layer(self) -> kdb.LayerInfo:
         assert self.enclosure.main_layer is not None
         return self.enclosure.main_layer
 
+    def is_symmetric(self) -> bool:
+        """Whether this cross section is symmetric."""
+        return True
+
     def to_dtype(self, kcl: KCLayout) -> DSymmetricalCrossSection:
         """Convert to a um based CrossSection."""
         return DSymmetricalCrossSection(
@@ -110,15 +155,31 @@ def get_xmax(self) -> int:
             for s in sections.sections
         )
 
+    def get_xmin(self) -> int:
+        # Symmetric by construction: the full extent is mirrored about the center line.
+        return -self.get_xmax()
+
     def model_copy(
         self, *, update: Mapping[str, Any] | None = {"name": None}, deep: bool = False
     ) -> SymmetricalCrossSection:
         return super().model_copy(update=update, deep=deep)
 
     def __eq__(self, o: object) -> bool:
+        if isinstance(o, (AsymmetricalCrossSection, TAsymmetricCrossSection)):
+            return False
         if isinstance(o, TCrossSection):
-            return o == self
-        return super().__eq__(o)
+            return self == o.base
+        if isinstance(o, SymmetricalCrossSection):
+            # radius/radius_min are non-identifying metadata.
+            return (
+                self.width == o.width
+                and self.enclosure == o.enclosure
+                and self.name == o.name
+            )
+        return NotImplemented
+
+    def __hash__(self) -> int:
+        return hash((self.width, self.enclosure, self.name))
 
 
 class DSymmetricalCrossSection(BaseModel):
@@ -134,6 +195,10 @@ def _validate_width(self) -> Self:
             raise ValueError("Width must be greater than 0.")
         return self
 
+    def is_symmetric(self) -> bool:
+        """Whether this cross section is symmetric."""
+        return True
+
     def to_itype(self, kcl: KCLayout) -> SymmetricalCrossSection:
         """Convert to a dbu based CrossSection."""
         return SymmetricalCrossSection(
@@ -143,7 +208,693 @@ def to_itype(self, kcl: KCLayout) -> SymmetricalCrossSection:
         )
 
 
-class TCrossSection(ABC, Generic[TUnit]):
+class CrossSectionLayer(BaseModel, frozen=True, arbitrary_types_allowed=True):
+    """Single strip in an asymmetrical cross section.
+
+    A strip on `layer` spanning `[section_min, section_max]` in dbu relative to
+    the port centerline (`offset=0`). Both bounds are signed integer dbu, so
+    edges are always grid-aligned. The strip's width is the derived
+    `section_max - section_min`.
+    """
+
+    layer: kdb.LayerInfo
+    section_min: dbu
+    section_max: dbu
+
+    @model_validator(mode="after")
+    def _validate_bounds(self) -> Self:
+        if self.section_min >= self.section_max:
+            raise ValueError(
+                "section_min must be strictly less than section_max (got"
+                f" section_min={self.section_min},"
+                f" section_max={self.section_max})."
+            )
+        return self
+
+    @property
+    def width(self) -> int:
+        """Width of the strip in dbu (`section_max - section_min`)."""
+        return self.section_max - self.section_min
+
+    def _sort_key(self) -> tuple[str, int, int, int, int]:
+        return (
+            self.layer.name,
+            self.layer.layer,
+            self.layer.datatype,
+            self.section_min,
+            self.section_max,
+        )
+
+    def __lt__(self, other: object) -> bool:
+        if not isinstance(other, CrossSectionLayer):
+            return NotImplemented
+        return self._sort_key() < other._sort_key()
+
+    def __le__(self, other: object) -> bool:
+        if not isinstance(other, CrossSectionLayer):
+            return NotImplemented
+        return self._sort_key() <= other._sort_key()
+
+    def __gt__(self, other: object) -> bool:
+        if not isinstance(other, CrossSectionLayer):
+            return NotImplemented
+        return self._sort_key() > other._sort_key()
+
+    def __ge__(self, other: object) -> bool:
+        if not isinstance(other, CrossSectionLayer):
+            return NotImplemented
+        return self._sort_key() >= other._sort_key()
+
+
+class DCrossSectionLayer(BaseModel, arbitrary_types_allowed=True):
+    """um based CrossSectionLayer."""
+
+    layer: kdb.LayerInfo
+    section_min: float
+    section_max: float
+
+    @model_validator(mode="after")
+    def _validate_bounds(self) -> Self:
+        if self.section_min >= self.section_max:
+            raise ValueError(
+                "section_min must be strictly less than section_max (got"
+                f" section_min={self.section_min},"
+                f" section_max={self.section_max})."
+            )
+        return self
+
+    @property
+    def width(self) -> float:
+        """Width of the strip in um (`section_max - section_min`)."""
+        return self.section_max - self.section_min
+
+    def to_itype(self, kcl: KCLayout) -> CrossSectionLayer:
+        return CrossSectionLayer(
+            layer=self.layer,
+            section_min=kcl.to_dbu(self.section_min),
+            section_max=kcl.to_dbu(self.section_max),
+        )
+
+
+def _layer_sort_key(layer: kdb.LayerInfo) -> tuple[str, int, int]:
+    return (layer.name, layer.layer, layer.datatype)
+
+
+def _asym_auto_name(
+    layer: kdb.LayerInfo,
+    section_min: int,
+    section_max: int,
+    sections: Sequence[CrossSectionLayer],
+    bbox_sections: Mapping[kdb.LayerInfo, int],
+) -> str:
+    """Deterministic structural name for an asymmetric cross section.
+
+    Hashes the geometry (layer, bounds, aux sections, bbox), excluding radius.
+    """
+    parts: list[Any] = [
+        str(layer),
+        section_min,
+        section_max,
+        [(str(s.layer), s.section_min, s.section_max) for s in sections],
+        sorted((str(k), v) for k, v in bbox_sections.items()),
+    ]
+    return "asym_" + sha1(str(parts).encode("UTF-8")).hexdigest()[-8:]  # noqa: S324
+
+
+def _resolve_radius(
+    canonical: SymmetricalCrossSection | AsymmetricalCrossSection,
+    incoming: SymmetricalCrossSection | AsymmetricalCrossSection,
+) -> SymmetricalCrossSection | AsymmetricalCrossSection:
+    """Resolve a re-registration of an already-registered profile.
+
+    Radius is excluded from the structural name, but a registered cross section is
+    a single source of truth: re-registering the same profile with a *different*
+    radius is a conflict and raises. To use a different bend radius for a specific
+    route, override it at the route/bend call — do not register a second profile.
+    """
+    if (incoming.radius is not None and incoming.radius != canonical.radius) or (
+        incoming.radius_min is not None and incoming.radius_min != canonical.radius_min
+    ):
+        raise CrossSectionNamingConflictError(
+            f"Cross section {canonical.name!r} is already registered with "
+            f"radius={canonical.radius}, radius_min={canonical.radius_min}; refusing "
+            f"to re-register the same profile with radius={incoming.radius}, "
+            f"radius_min={incoming.radius_min}. Override the radius at the route/bend "
+            "call instead."
+        )
+    return canonical
+
+
+def _normalize_sections(
+    sections: Sequence[CrossSectionLayer] | tuple[CrossSectionLayer, ...],
+) -> tuple[CrossSectionLayer, ...]:
+    """Canonicalize a section list.
+
+    Sections on the same layer that touch or overlap are merged into a single
+    strip spanning their combined extent. Output is sorted by
+    (layer.name, layer.layer, layer.datatype, section_min).
+    """
+    by_layer: dict[tuple[str, int, int], list[CrossSectionLayer]] = {}
+    layer_for_key: dict[tuple[str, int, int], kdb.LayerInfo] = {}
+    for s in sections:
+        key = _layer_sort_key(s.layer)
+        by_layer.setdefault(key, []).append(s)
+        layer_for_key.setdefault(key, s.layer)
+
+    merged: list[CrossSectionLayer] = []
+    for key in sorted(by_layer.keys()):
+        strips = sorted(by_layer[key], key=lambda s: (s.section_min, s.section_max))
+        run_min = strips[0].section_min
+        run_max = strips[0].section_max
+        for s in strips[1:]:
+            if s.section_min <= run_max:
+                run_max = max(run_max, s.section_max)
+            else:
+                merged.append(
+                    CrossSectionLayer(
+                        layer=layer_for_key[key],
+                        section_min=run_min,
+                        section_max=run_max,
+                    )
+                )
+                run_min = s.section_min
+                run_max = s.section_max
+        merged.append(
+            CrossSectionLayer(
+                layer=layer_for_key[key],
+                section_min=run_min,
+                section_max=run_max,
+            )
+        )
+    return tuple(merged)
+
+
+class AsymmetricalCrossSection(BaseModel, frozen=True, arbitrary_types_allowed=True):
+    """Cross section composed of independent layer strips at signed bounds.
+
+    The main strip (`layer`, `section_min`, `section_max`) is the port
+    reference; `sections` holds any additional strips. All bounds are signed
+    integer dbu relative to the port centerline (`x = 0`). Strip edges are
+    always grid-aligned regardless of width parity.
+    """
+
+    layer: kdb.LayerInfo
+    section_min: dbu
+    section_max: dbu
+    sections: tuple[CrossSectionLayer, ...] = ()
+    name: str = ""
+    radius: dbu | None = None
+    radius_min: dbu | None = None
+    bbox_sections: dict[kdb.LayerInfo, dbu] = Field(default_factory=dict)
+
+    @model_validator(mode="before")
+    @classmethod
+    def _normalize(cls, data: Any) -> Any:
+        if not isinstance(data, dict):
+            return data
+        sections = data.get("sections", ())
+        coerced: list[CrossSectionLayer] = []
+        for s in sections:
+            if isinstance(s, CrossSectionLayer):
+                coerced.append(s)
+            else:
+                coerced.append(CrossSectionLayer.model_validate(s))
+        data["sections"] = _normalize_sections(coerced)
+        if not data.get("name"):
+            data["name"] = _asym_auto_name(
+                data["layer"],
+                data["section_min"],
+                data["section_max"],
+                data["sections"],
+                data.get("bbox_sections") or {},
+            )
+        return data
+
+    def auto_name(self) -> str:
+        """Deterministic structural name (hash of geometry, excluding radius)."""
+        return _asym_auto_name(
+            self.layer,
+            self.section_min,
+            self.section_max,
+            self.sections,
+            self.bbox_sections,
+        )
+
+    @property
+    def is_named(self) -> bool:
+        """Whether an explicit name was given (vs. the derived structural name)."""
+        return self.name != self.auto_name()
+
+    @model_validator(mode="after")
+    def _validate(self) -> Self:
+        if self.section_min >= self.section_max:
+            raise ValueError(
+                "section_min must be strictly less than section_max"
+                f" (got section_min={self.section_min},"
+                f" section_max={self.section_max})."
+            )
+        return self
+
+    @property
+    def width(self) -> int:
+        """Main strip width in dbu (`section_max - section_min`)."""
+        return self.section_max - self.section_min
+
+    @property
+    def main_layer(self) -> kdb.LayerInfo:
+        """Main layer of the cross section (parity with SymmetricalCrossSection)."""
+        return self.layer
+
+    def to_dtype(self, kcl: KCLayout) -> DAsymmetricalCrossSection:
+        return DAsymmetricalCrossSection(
+            layer=self.layer,
+            section_min=kcl.to_um(self.section_min),
+            section_max=kcl.to_um(self.section_max),
+            sections=tuple(
+                DCrossSectionLayer(
+                    layer=s.layer,
+                    section_min=kcl.to_um(s.section_min),
+                    section_max=kcl.to_um(s.section_max),
+                )
+                for s in self.sections
+            ),
+            name=self.name,
+            radius=kcl.to_um(self.radius) if self.radius is not None else None,
+            radius_min=kcl.to_um(self.radius_min)
+            if self.radius_min is not None
+            else None,
+            bbox_sections={k: kcl.to_um(v) for k, v in self.bbox_sections.items()},
+        )
+
+    def is_symmetric(self) -> bool:
+        """Whether this cross section is symmetric."""
+        return False
+
+    def _all_strips(self) -> tuple[tuple[int, int], ...]:
+        """Return (section_min, section_max) for main + every aux section."""
+        return (
+            (self.section_min, self.section_max),
+            *((s.section_min, s.section_max) for s in self.sections),
+        )
+
+    def get_xmin(self) -> int:
+        return min(lo for lo, _ in self._all_strips())
+
+    def get_xmax(self) -> int:
+        return max(hi for _, hi in self._all_strips())
+
+    def model_copy(
+        self, *, update: Mapping[str, Any] | None = {"name": None}, deep: bool = False
+    ) -> AsymmetricalCrossSection:
+        return super().model_copy(update=update, deep=deep)
+
+    def __eq__(self, o: object) -> bool:
+        if isinstance(o, (SymmetricalCrossSection, TCrossSection)):
+            return False
+        if isinstance(o, TAsymmetricCrossSection):
+            return self == o.base
+        if isinstance(o, AsymmetricalCrossSection):
+            # radius/radius_min are non-identifying metadata.
+            return (
+                self.layer == o.layer
+                and self.section_min == o.section_min
+                and self.section_max == o.section_max
+                and self.sections == o.sections
+                and self.name == o.name
+                and self.bbox_sections == o.bbox_sections
+            )
+        return NotImplemented
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self.layer,
+                self.section_min,
+                self.section_max,
+                self.sections,
+                self.name,
+                tuple(sorted(self.bbox_sections.items(), key=lambda kv: kv[0].name)),
+            )
+        )
+
+    def _sort_key(
+        self,
+    ) -> tuple[
+        str, int, int, int, int, tuple[tuple[str, int, int, int, int], ...], str
+    ]:
+        return (
+            self.layer.name,
+            self.layer.layer,
+            self.layer.datatype,
+            self.section_min,
+            self.section_max,
+            tuple(s._sort_key() for s in self.sections),
+            self.name,
+        )
+
+    def __lt__(self, other: object) -> bool:
+        if not isinstance(other, AsymmetricalCrossSection):
+            return NotImplemented
+        return self._sort_key() < other._sort_key()
+
+    def __le__(self, other: object) -> bool:
+        if not isinstance(other, AsymmetricalCrossSection):
+            return NotImplemented
+        return self._sort_key() <= other._sort_key()
+
+    def __gt__(self, other: object) -> bool:
+        if not isinstance(other, AsymmetricalCrossSection):
+            return NotImplemented
+        return self._sort_key() > other._sort_key()
+
+    def __ge__(self, other: object) -> bool:
+        if not isinstance(other, AsymmetricalCrossSection):
+            return NotImplemented
+        return self._sort_key() >= other._sort_key()
+
+
+class DAsymmetricalCrossSection(BaseModel, arbitrary_types_allowed=True):
+    """um based AsymmetricalCrossSection."""
+
+    layer: kdb.LayerInfo
+    section_min: float
+    section_max: float
+    sections: tuple[DCrossSectionLayer, ...] = ()
+    name: str | None = None
+    radius: float | None = None
+    radius_min: float | None = None
+    bbox_sections: dict[kdb.LayerInfo, float] = Field(default_factory=dict)
+
+    @model_validator(mode="after")
+    def _validate_bounds(self) -> Self:
+        if self.section_min >= self.section_max:
+            raise ValueError(
+                "section_min must be strictly less than section_max"
+                f" (got section_min={self.section_min},"
+                f" section_max={self.section_max})."
+            )
+        return self
+
+    @property
+    def width(self) -> float:
+        """Main strip width in um (`section_max - section_min`)."""
+        return self.section_max - self.section_min
+
+    def is_symmetric(self) -> bool:
+        """Whether this cross section is symmetric."""
+        return False
+
+    def to_itype(self, kcl: KCLayout) -> AsymmetricalCrossSection:
+        return AsymmetricalCrossSection(
+            layer=self.layer,
+            section_min=kcl.to_dbu(self.section_min),
+            section_max=kcl.to_dbu(self.section_max),
+            sections=tuple(s.to_itype(kcl) for s in self.sections),
+            name=self.name or "",
+            radius=kcl.to_dbu(self.radius) if self.radius is not None else None,
+            radius_min=kcl.to_dbu(self.radius_min)
+            if self.radius_min is not None
+            else None,
+            bbox_sections={k: kcl.to_dbu(v) for k, v in self.bbox_sections.items()},
+        )
+
+
+type AnyCrossSection = SymmetricalCrossSection | AsymmetricalCrossSection
+type AnyCrossSectionInput = (
+    SymmetricalCrossSection
+    | AsymmetricalCrossSection
+    | TCrossSection[Any]
+    | TAsymmetricCrossSection[Any]
+)
+
+
+class TAsymmetricCrossSection[T: (int, float)](ABC):
+    """Unit-flavored wrapper around an `AsymmetricalCrossSection` base.
+
+    Mirrors `TCrossSection` for the symmetric case: both the dbu wrapper
+    (`AsymmetricCrossSection`) and the um wrapper (`DAsymmetricCrossSection`)
+    hold the same `AsymmetricalCrossSection` as `_base`, so they compare equal
+    across units via `.base`.
+    """
+
+    _base: AsymmetricalCrossSection = PrivateAttr()
+    kcl: KCLayout
+
+    @property
+    def base(self) -> AsymmetricalCrossSection:
+        return self._base
+
+    @property
+    def name(self) -> str:
+        return self._base.name
+
+    @property
+    def layer(self) -> kdb.LayerInfo:
+        return self._base.layer
+
+    @property
+    def main_layer(self) -> kdb.LayerInfo:
+        return self._base.layer
+
+    def is_symmetric(self) -> bool:
+        """Whether this cross section is symmetric."""
+        return False
+
+    @property
+    @abstractmethod
+    def width(self) -> T: ...
+
+    @property
+    @abstractmethod
+    def section_min(self) -> T: ...
+
+    @property
+    @abstractmethod
+    def section_max(self) -> T: ...
+
+    @property
+    @abstractmethod
+    def sections(
+        self,
+    ) -> tuple[CrossSectionLayer, ...] | tuple[DCrossSectionLayer, ...]: ...
+
+    @property
+    @abstractmethod
+    def radius(self) -> T | None: ...
+
+    @property
+    @abstractmethod
+    def radius_min(self) -> T | None: ...
+
+    @property
+    @abstractmethod
+    def bbox_sections(self) -> dict[kdb.LayerInfo, T]: ...
+
+    @abstractmethod
+    def get_xmin_xmax(self) -> tuple[T, T]: ...
+
+    def to_itype(self) -> AsymmetricCrossSection:
+        return AsymmetricCrossSection(kcl=self.kcl, base=self._base)
+
+    def to_dtype(self) -> DAsymmetricCrossSection:
+        return DAsymmetricCrossSection(kcl=self.kcl, base=self._base)
+
+    def __eq__(self, o: object) -> bool:
+        if isinstance(o, TAsymmetricCrossSection):
+            return self.base == o.base
+        if isinstance(o, AsymmetricalCrossSection):
+            return self.base == o
+        return False
+
+    def __hash__(self) -> int:
+        return hash(self._base)
+
+
+class AsymmetricCrossSection(TAsymmetricCrossSection[int]):
+    """dbu-flavored wrapper around an `AsymmetricalCrossSection`."""
+
+    @overload
+    def __init__(self, kcl: KCLayout, *, base: AsymmetricalCrossSection) -> None: ...
+
+    @overload
+    def __init__(
+        self,
+        kcl: KCLayout,
+        section_min: int,
+        section_max: int,
+        layer: kdb.LayerInfo,
+        sections: Sequence[CrossSectionLayer] = (),
+        name: str | None = None,
+        radius: int | None = None,
+        radius_min: int | None = None,
+        bbox_sections: dict[kdb.LayerInfo, int] | None = None,
+    ) -> None: ...
+
+    def __init__(
+        self,
+        kcl: KCLayout,
+        section_min: int | None = None,
+        section_max: int | None = None,
+        layer: kdb.LayerInfo | None = None,
+        sections: Sequence[CrossSectionLayer] = (),
+        name: str | None = None,
+        radius: int | None = None,
+        radius_min: int | None = None,
+        bbox_sections: dict[kdb.LayerInfo, int] | None = None,
+        base: AsymmetricalCrossSection | None = None,
+    ) -> None:
+        if base is None:
+            if section_min is None or section_max is None or layer is None:
+                raise ValueError(
+                    "If no base is given, section_min, section_max, and layer"
+                    " must be defined"
+                )
+            base = kcl.get_asymmetrical_cross_section(
+                AsymmetricalCrossSection(
+                    layer=layer,
+                    section_min=section_min,
+                    section_max=section_max,
+                    sections=tuple(sections),
+                    name=name or "",
+                    radius=radius,
+                    radius_min=radius_min,
+                    bbox_sections=bbox_sections or {},
+                )
+            )
+        self.kcl = kcl
+        self._base = base
+
+    @property
+    def width(self) -> int:
+        return self._base.width
+
+    @property
+    def section_min(self) -> int:
+        return self._base.section_min
+
+    @property
+    def section_max(self) -> int:
+        return self._base.section_max
+
+    @property
+    def sections(self) -> tuple[CrossSectionLayer, ...]:
+        return self._base.sections
+
+    @property
+    def radius(self) -> int | None:
+        return self._base.radius
+
+    @property
+    def radius_min(self) -> int | None:
+        return self._base.radius_min
+
+    @property
+    def bbox_sections(self) -> dict[kdb.LayerInfo, int]:
+        return self._base.bbox_sections.copy()
+
+    def get_xmin_xmax(self) -> tuple[int, int]:
+        return (self._base.get_xmin(), self._base.get_xmax())
+
+
+class DAsymmetricCrossSection(TAsymmetricCrossSection[float]):
+    """um-flavored wrapper around an `AsymmetricalCrossSection`."""
+
+    @overload
+    def __init__(self, kcl: KCLayout, *, base: AsymmetricalCrossSection) -> None: ...
+
+    @overload
+    def __init__(
+        self,
+        kcl: KCLayout,
+        section_min: float,
+        section_max: float,
+        layer: kdb.LayerInfo,
+        sections: Sequence[DCrossSectionLayer] = (),
+        name: str | None = None,
+        radius: float | None = None,
+        radius_min: float | None = None,
+        bbox_sections: dict[kdb.LayerInfo, float] | None = None,
+    ) -> None: ...
+
+    def __init__(
+        self,
+        kcl: KCLayout,
+        section_min: float | None = None,
+        section_max: float | None = None,
+        layer: kdb.LayerInfo | None = None,
+        sections: Sequence[DCrossSectionLayer] = (),
+        name: str | None = None,
+        radius: float | None = None,
+        radius_min: float | None = None,
+        bbox_sections: dict[kdb.LayerInfo, float] | None = None,
+        base: AsymmetricalCrossSection | None = None,
+    ) -> None:
+        if base is None:
+            if section_min is None or section_max is None or layer is None:
+                raise ValueError(
+                    "If no base is given, section_min, section_max, and layer"
+                    " must be defined"
+                )
+            base = kcl.get_asymmetrical_cross_section(
+                DAsymmetricalCrossSection(
+                    layer=layer,
+                    section_min=section_min,
+                    section_max=section_max,
+                    sections=tuple(sections),
+                    name=name,
+                    radius=radius,
+                    radius_min=radius_min,
+                    bbox_sections=bbox_sections or {},
+                ).to_itype(kcl)
+            )
+        self.kcl = kcl
+        self._base = base
+
+    @property
+    def width(self) -> float:
+        return self.kcl.to_um(self._base.width)
+
+    @property
+    def section_min(self) -> float:
+        return self.kcl.to_um(self._base.section_min)
+
+    @property
+    def section_max(self) -> float:
+        return self.kcl.to_um(self._base.section_max)
+
+    @property
+    def sections(self) -> tuple[DCrossSectionLayer, ...]:
+        return tuple(
+            DCrossSectionLayer(
+                layer=s.layer,
+                section_min=self.kcl.to_um(s.section_min),
+                section_max=self.kcl.to_um(s.section_max),
+            )
+            for s in self._base.sections
+        )
+
+    @property
+    def radius(self) -> float | None:
+        r = self._base.radius
+        return self.kcl.to_um(r) if r is not None else None
+
+    @property
+    def radius_min(self) -> float | None:
+        r = self._base.radius_min
+        return self.kcl.to_um(r) if r is not None else None
+
+    @property
+    def bbox_sections(self) -> dict[kdb.LayerInfo, float]:
+        return {k: self.kcl.to_um(v) for k, v in self._base.bbox_sections.items()}
+
+    def get_xmin_xmax(self) -> tuple[float, float]:
+        return (
+            self.kcl.to_um(self._base.get_xmin()),
+            self.kcl.to_um(self._base.get_xmax()),
+        )
+
+
+class TCrossSection[T: (int, float)](ABC):
     _base: SymmetricalCrossSection = PrivateAttr()
     kcl: KCLayout
 
@@ -156,26 +907,26 @@ def __init__(self, kcl: KCLayout, *, base: SymmetricalCrossSection) -> None: ...
     def __init__(
         self,
         kcl: KCLayout,
-        width: TUnit,
+        width: T,
         layer: kdb.LayerInfo,
-        sections: Sequence[tuple[TUnit, TUnit] | tuple[TUnit]],
-        radius: TUnit | None = None,
-        radius_min: TUnit | None = None,
+        sections: Sequence[tuple[T, T] | tuple[T]],
+        radius: T | None = None,
+        radius_min: T | None = None,
         bbox_layers: Sequence[kdb.LayerInfo] | None = None,
-        bbox_offsets: Sequence[TUnit] | None = None,
+        bbox_offsets: Sequence[T] | None = None,
     ) -> None: ...
 
     @abstractmethod
     def __init__(
         self,
         kcl: KCLayout,
-        width: TUnit | None = None,
+        width: T | None = None,
         layer: kdb.LayerInfo | None = None,
-        sections: Sequence[tuple[TUnit, TUnit] | tuple[TUnit]] | None = None,
-        radius: TUnit | None = None,
-        radius_min: TUnit | None = None,
+        sections: Sequence[tuple[T, T] | tuple[T]] | None = None,
+        radius: T | None = None,
+        radius_min: T | None = None,
         bbox_layers: Sequence[kdb.LayerInfo] | None = None,
-        bbox_offsets: Sequence[TUnit] | None = None,
+        bbox_offsets: Sequence[T] | None = None,
         base: SymmetricalCrossSection | None = None,
     ) -> None: ...
 
@@ -185,7 +936,7 @@ def base(self) -> SymmetricalCrossSection:
 
     @property
     @abstractmethod
-    def width(self) -> TUnit: ...
+    def width(self) -> T: ...
 
     @property
     def layer(self) -> kdb.LayerInfo:
@@ -207,24 +958,24 @@ def to_dtype(self) -> DCrossSection:
 
     @property
     @abstractmethod
-    def sections(self) -> dict[kdb.LayerInfo, list[tuple[TUnit | None, TUnit]]]: ...
+    def sections(self) -> dict[kdb.LayerInfo, list[tuple[T | None, T]]]: ...
 
     @property
     @abstractmethod
-    def radius(self) -> TUnit | None: ...
+    def radius(self) -> T | None: ...
 
     @property
     @abstractmethod
-    def radius_min(self) -> TUnit | None: ...
+    def radius_min(self) -> T | None: ...
 
     @property
     @abstractmethod
     def bbox_sections(
         self,
-    ) -> dict[kdb.LayerInfo, TUnit]: ...
+    ) -> dict[kdb.LayerInfo, T]: ...
 
     @abstractmethod
-    def get_xmin_xmax(self) -> tuple[TUnit, TUnit]: ...
+    def get_xmin_xmax(self) -> tuple[T, T]: ...
 
     @abstractmethod
     def model_copy(
@@ -243,6 +994,10 @@ def main_layer(self) -> kdb.LayerInfo:
         """Main Layer of the enclosure and cross section."""
         return self.base.main_layer
 
+    def is_symmetric(self) -> bool:
+        """Whether this cross section is symmetric."""
+        return True
+
 
 class CrossSection(TCrossSection[int]):
     @overload
@@ -296,12 +1051,14 @@ def __init__(
             base = kcl.get_symmetrical_cross_section(
                 SymmetricalCrossSection(
                     width=width,
-                    enclosure=LayerEnclosure(sections=sections, main_layer=layer),
+                    enclosure=LayerEnclosure(
+                        sections=sections,
+                        main_layer=layer,
+                        bbox_sections=list(
+                            zip(bbox_layers, bbox_offsets)  # noqa: B905
+                        ),
+                    ),
                     name=name,
-                    bbox_sections={
-                        s[0]: s[1]
-                        for s in zip(bbox_layers, bbox_offsets)  # noqa: B905
-                    },
                     radius=radius,
                     radius_min=radius_min,
                 )
@@ -334,8 +1091,7 @@ def radius_min(self) -> int | None:
         return self._base.radius_min
 
     def get_xmin_xmax(self) -> tuple[int, int]:
-        xmax = self._base.get_xmax()
-        return (xmax, xmax)
+        return (self._base.get_xmin(), self._base.get_xmax())
 
     def model_copy(
         self, *, update: Mapping[str, Any] = {"name": None}, deep: bool
@@ -401,16 +1157,16 @@ def __init__(
                     width=kcl.to_dbu(width),
                     enclosure=LayerEnclosure(
                         sections=[
-                            (s[0], *[kcl.to_dbu(s[i]) for i in range(1, len(s))])  # type: ignore[misc, arg-type]
+                            (s[0], *[kcl.to_dbu(s[i]) for i in range(1, len(s))])  # ty:ignore[no-matching-overload]
                             for s in sections
                         ],
                         main_layer=layer,
+                        bbox_sections=[
+                            (s[0], kcl.to_dbu(s[1]))
+                            for s in zip(bbox_layers, bbox_offsets)  # noqa: B905
+                        ],
                     ),
                     name=name,
-                    bbox_sections={
-                        s[0]: kcl.to_dbu(s[1])
-                        for s in zip(bbox_layers, bbox_offsets)  # noqa: B905
-                    },
                     radius=kcl.to_dbu(radius) if radius else None,
                     radius_min=kcl.to_dbu(radius_min) if radius_min else None,
                 )
@@ -451,8 +1207,10 @@ def radius_min(self) -> float | None:
         return self.kcl.to_um(self._base.radius_min)
 
     def get_xmin_xmax(self) -> tuple[float, float]:
-        xmax = self.kcl.to_um(self._base.get_xmax())
-        return (xmax, xmax)
+        return (
+            self.kcl.to_um(self._base.get_xmin()),
+            self.kcl.to_um(self._base.get_xmax()),
+        )
 
     def model_copy(
         self, *, update: Mapping[str, Any] = {"name": None}, deep: bool
@@ -462,64 +1220,120 @@ def model_copy(
         )
 
 
-class TCrossSectionSpec(TypedDict, Generic[TUnit]):
+class TCrossSectionSpec[T: (int, float)](TypedDict):
     name: NotRequired[str]
-    sections: NotRequired[
-        list[tuple[kdb.LayerInfo, TUnit] | tuple[kdb.LayerInfo, TUnit, TUnit]]
-    ]
+    sections: NotRequired[list[tuple[kdb.LayerInfo, T] | tuple[kdb.LayerInfo, T, T]]]
     layer: kdb.LayerInfo
-    width: TUnit
+    width: T
     bbox_layers: NotRequired[Sequence[kdb.LayerInfo]]
-    bbox_offsets: NotRequired[Sequence[TUnit]]
+    bbox_offsets: NotRequired[Sequence[T]]
 
 
-class CrossSectionSpec(TCrossSectionSpec[int]):
+class CrossSectionSpecDict(TCrossSectionSpec[int]):
     unit: NotRequired[Literal["dbu"]]
 
 
-class DCrossSectionSpec(TCrossSectionSpec[float]):
+class DCrossSectionSpecDict(TCrossSectionSpec[float]):
     unit: Literal["um"]
 
 
 class CrossSectionModel(BaseModel):
-    cross_sections: dict[str, SymmetricalCrossSection] = Field(default_factory=dict)
+    cross_sections: dict[str, SymmetricalCrossSection | AsymmetricalCrossSection] = (
+        Field(default_factory=dict)
+    )
     kcl: KCLayout
 
-    def __getitem__(self, name: str) -> SymmetricalCrossSection:
+    def __getitem__(
+        self, name: str
+    ) -> SymmetricalCrossSection | AsymmetricalCrossSection:
         return self.cross_sections[name]
 
+    def get_asymmetrical_cross_section(
+        self,
+        cross_section: str | AsymmetricalCrossSection | DAsymmetricalCrossSection,
+    ) -> AsymmetricalCrossSection:
+        if isinstance(cross_section, str):
+            xs = self.cross_sections[cross_section]
+            if not isinstance(xs, AsymmetricalCrossSection):
+                raise TypeError(
+                    f"Cross section {cross_section!r} is symmetric; use "
+                    "get_(symmetrical_)cross_section."
+                )
+            return xs
+        if isinstance(cross_section, DAsymmetricalCrossSection):
+            cross_section = cross_section.to_itype(self.kcl)
+        registered = self._register(cross_section)
+        assert isinstance(registered, AsymmetricalCrossSection)
+        return registered
+
+    def _register(
+        self, cross_section: SymmetricalCrossSection | AsymmetricalCrossSection
+    ) -> SymmetricalCrossSection | AsymmetricalCrossSection:
+        """Register/resolve a cross section by its canonical (structural) name.
+
+        Entries are keyed by their `auto_name()` and, when named, additionally by
+        the explicit name. Existence is a single `get(auto_name())`.
+        """
+        auto = cross_section.auto_name()
+        canonical = self.cross_sections.get(auto)
+        if canonical is None:
+            if cross_section.is_named and cross_section.name in self.cross_sections:
+                raise CrossSectionNamingConflictError(
+                    f"Cross section name {cross_section.name!r} is already "
+                    "registered for a different structural signature."
+                )
+            self.cross_sections[auto] = cross_section
+            if cross_section.is_named:
+                self.cross_sections[cross_section.name] = cross_section
+            return cross_section
+        if not cross_section.is_named:
+            return _resolve_radius(canonical, cross_section)
+        if canonical.is_named:
+            if canonical.name == cross_section.name:
+                return _resolve_radius(canonical, cross_section)
+            raise CrossSectionNamingConflictError(
+                f"Cannot register cross section {cross_section.name!r}: the same "
+                f"structural signature is already registered as {canonical.name!r}."
+                " A structure can have at most one name."
+            )
+        # Promote the unnamed canonical to the named one (radius must match).
+        _resolve_radius(canonical, cross_section)
+        self.cross_sections[auto] = cross_section
+        self.cross_sections[cross_section.name] = cross_section
+        return cross_section
+
     def get_cross_section(
         self,
         cross_section: str
         | SymmetricalCrossSection
         | DSymmetricalCrossSection
-        | CrossSectionSpec
-        | DCrossSectionSpec
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | CrossSection
         | DCrossSection,
     ) -> SymmetricalCrossSection:
         if isinstance(cross_section, str):
-            return self.cross_sections[cross_section]
+            xs = self.cross_sections[cross_section]
+            if not isinstance(xs, SymmetricalCrossSection):
+                raise TypeError(
+                    f"Cross section {cross_section!r} is asymmetric; use "
+                    "get_asymmetrical_cross_section."
+                )
+            return xs
         if isinstance(cross_section, TCrossSection):
             cross_section = cross_section.base
         if isinstance(cross_section, SymmetricalCrossSection):
-            if cross_section.enclosure != self.kcl.get_enclosure(
-                cross_section.enclosure
-            ):
+            canonical_enc = self.kcl.get_enclosure(cross_section.enclosure)
+            if cross_section.enclosure != canonical_enc:
                 return self.get_cross_section(
                     SymmetricalCrossSection(
-                        enclosure=self.kcl.layer_enclosures.get_enclosure(
-                            LayerEnclosureSpec(
-                                sections=cross_section.enclosure.model_dump()[
-                                    "sections"
-                                ],
-                                main_layer=cross_section.main_layer,
-                                name=cross_section.enclosure._name,
-                            ),
-                            kcl=self.kcl,
-                        ),
-                        name=cross_section.name,
+                        enclosure=canonical_enc,
+                        # Preserve named/unnamed provenance: re-derive the auto
+                        # name from the canonical enclosure when unnamed.
+                        name=cross_section.name if cross_section.is_named else None,
                         width=cross_section.width,
+                        radius=cross_section.radius,
+                        radius_min=cross_section.radius_min,
                     )
                 )
         elif isinstance(cross_section, DSymmetricalCrossSection):
@@ -527,12 +1341,12 @@ def get_cross_section(
 
         elif cross_section.get("unit", "dbu") == "dbu":
             cross_section = SymmetricalCrossSection(
-                width=cross_section["width"],  # type: ignore[arg-type]
+                width=cross_section["width"],  # ty:ignore[invalid-argument-type]
                 enclosure=self.kcl.layer_enclosures.get_enclosure(
                     LayerEnclosureSpec(
-                        sections=cross_section.get("sections", []),  # type: ignore[typeddict-item]
+                        sections=cross_section.get("sections", []),  # ty:ignore[invalid-argument-type]
                         main_layer=cross_section["layer"],
-                        name=cross_section.get("enclosure", {}).get("name"),  # type: ignore[attr-defined]
+                        name=cross_section.get("enclosure", {}).get("name"),
                     ),
                     kcl=self.kcl,
                 ),
@@ -543,9 +1357,9 @@ def get_cross_section(
                 width=self.kcl.to_dbu(cross_section["width"]),
                 enclosure=self.kcl.layer_enclosures.get_enclosure(
                     LayerEnclosureSpec(
-                        dsections=[
+                        sections=[
                             (section[0], self.kcl.to_dbu(section[1]))
-                            if len(section) == 2  # noqa: PLR2004
+                            if len(section) == 2
                             else (
                                 section[0],
                                 self.kcl.to_dbu(section[1]),
@@ -554,23 +1368,15 @@ def get_cross_section(
                             for section in cross_section.get("sections", [])
                         ],
                         main_layer=cross_section["layer"],
-                        name=cross_section.get("enclosure", {}).get("name"),  # type: ignore[attr-defined]
+                        name=cross_section.get("enclosure", {}).get("name"),
                     ),
                     kcl=self.kcl,
                 ),
                 name=cross_section.get("name", None),
             )
-        if cross_section.name not in self.cross_sections:
-            self.cross_sections[cross_section.name] = cross_section
-            return cross_section
-        xs = self.cross_sections[cross_section.name]
-        if not xs == cross_section:
-            raise ValueError(
-                "There is already a cross_section defined with name "
-                f"{cross_section.name}. Cannot overwrite cross_sections.\n"
-                f"old_xs={xs.model_dump()}\nnew_xs={cross_section.model_dump()}"
-            )
-        return xs
+        registered = self._register(cross_section)
+        assert isinstance(registered, SymmetricalCrossSection)
+        return registered
 
     def __repr__(self) -> str:
         return repr(self.cross_sections)
diff --git a/src/kfactory/decorators.py b/src/kfactory/decorators.py
index 56bf89645..2ba65f30d 100644
--- a/src/kfactory/decorators.py
+++ b/src/kfactory/decorators.py
@@ -4,72 +4,145 @@
 
 import functools
 import inspect
+import re
 from collections import defaultdict
 from collections.abc import Callable
 from enum import StrEnum
+from operator import attrgetter
 from pathlib import Path
 from threading import RLock
-from types import FunctionType
+from types import FunctionType, UnionType
 from typing import (
     TYPE_CHECKING,
     Annotated,
     Any,
-    Generic,
     Protocol,
+    TypeAliasType,
     TypedDict,
+    Unpack,
+    cast,
+    final,
     get_origin,
+    get_type_hints,
     overload,
 )
 
 from cachetools import Cache, cached
-from typing_extensions import Unpack
-
-from . import kdb
-from .conf import CheckInstances, logger
+from cachetools.keys import hashkey
+
+from . import (
+    AsymmetricalCrossSection,
+    AsymmetricCrossSection,
+    CrossSection,
+    CrossSectionSpec,
+    DAsymmetricCrossSection,
+    DCrossSection,
+    SymmetricalCrossSection,
+    kdb,
+)
+from .conf import CheckInstances, CheckUnnamedCells, logger
 from .exceptions import CellNameError
-from .kcell import AnyKCell, ProtoTKCell, TKCell, VKCell
+from .kcell import AnyKCell, ProtoKCell, ProtoTKCell, TKCell, VKCell
 from .serialization import (
     DecoratorDict,
     DecoratorList,
     get_cell_name,
+    get_function_name,
     hashable_to_original,
+    kcl_cross_section_serializer,
     to_hashable,
 )
 from .settings import KCellSettings, KCellSettingsUnits
-from .typings import (
-    KC,
-    VK,
-    K,
-    K_contra,
-    KC_co,
-    KC_contra,
-    KCellParams,
-    MetaData,
-    VK_contra,
-)
 
 if TYPE_CHECKING:
-    from collections.abc import Callable, Iterable, Sequence
+    from collections.abc import Callable, Hashable, Iterable, Sequence
 
     from .layout import KCLayout
     from .schematic import TSchematic
+    from .typings import (
+        MetaData,
+    )
 
+_fixed_unnamed_pattern = re.compile(r"Unnamed_\d+")
 
-def _parse_params(
-    sig: inspect.Signature, kcl: KCLayout, args: Any, kwargs: Any
-) -> tuple[dict[str, Any], dict[str, Any]]:
-    params: dict[str, Any] = {p.name: p.default for _, p in sig.parameters.items()}
-    param_units: dict[str, str] = {
+
+class SignatureParams:
+    sig: inspect.Signature
+
+    _defaults: dict[str, Any] | None
+    _names: list[str] | None
+    _units: dict[str, str] | None
+
+    def __init__(self, sig: inspect.Signature) -> None:
+        self.sig = sig
+        self._defaults = None
+        self._names = None
+        self._units = None
+
+    @property
+    def defaults(self) -> dict[str, Any]:
+        if self._defaults is None:
+            self._defaults, self._names, self._units = _precompute_sig_metadata(
+                self.sig
+            )
+        return self._defaults
+
+    @property
+    def names(self) -> list[str]:
+        if self._names is None:
+            self._defaults, self._names, self._units = _precompute_sig_metadata(
+                self.sig
+            )
+        return self._names
+
+    @property
+    def units(self) -> dict[str, str]:
+        if self._units is None:
+            self._defaults, self._names, self._units = _precompute_sig_metadata(
+                self.sig
+            )
+        return self._units
+
+
+def _precompute_sig_metadata(
+    sig: inspect.Signature,
+) -> tuple[dict[str, Any], list[str], dict[str, str]]:
+    """Pre-compute static signature metadata at decoration time.
+
+    Args:
+        sig: function signature
+
+    Returns:
+        param_defaults: dict of parameter name -> default value
+        param_names: ordered list of parameter names
+        all_param_units: dict of parameter name -> unit annotation
+    """
+    param_defaults: dict[str, Any] = {
+        p.name: p.default for p in sig.parameters.values()
+    }
+    param_names: list[str] = list(sig.parameters.keys())
+    all_param_units: dict[str, str] = {
         p.name: p.annotation.__metadata__[0]
         for p in sig.parameters.values()
         if get_origin(p.annotation) is Annotated
     }
-    arg_par = list(sig.parameters.items())[: len(args)]
-    for i, (k, _) in enumerate(arg_par):
-        params[k] = args[i]
+    return param_defaults, param_names, all_param_units
+
+
+def _parse_params(
+    param_defaults: dict[str, Any],
+    param_names: list[str],
+    kcl: KCLayout,
+    args: Any,
+    kwargs: Any,
+) -> dict[str, Any]:
+
+    params = param_defaults.copy()
+    for name, value in zip(param_names, args, strict=False):
+        params[name] = value
     params.update(kwargs)
 
-    del_parameters: list[str] = []
+    del_params: list[str] = []
 
     for key, value in params.items():
         if isinstance(value, dict | list):
@@ -77,13 +150,12 @@ def _parse_params(
         elif isinstance(value, kdb.LayerInfo):
             params[key] = kcl.get_info(kcl.layer(value))
         if value is inspect.Parameter.empty:
-            del_parameters.append(key)
+            del_params.append(key)
 
-    for param in del_parameters:
+    for param in del_params:
         params.pop(param, None)
-        param_units.pop(param, None)
 
-    return params, param_units
+    return params
 
 
 def _params_to_original(params: dict[str, Any]) -> None:
@@ -250,17 +322,7 @@ def _check_pins(cell: ProtoTKCell[Any] | VKCell) -> None:
         )
 
 
-def _get_function_name(f: Callable[..., Any]) -> str:
-    if hasattr(f, "__name__"):
-        name = f.__name__
-    elif hasattr(f, "func"):
-        name = f.func.__name__
-    else:
-        raise ValueError(f"Function {f} has no name.")
-    return name
-
-
-def _set_settings(
+def _set_settings[**KCellParams, K: ProtoKCell[Any, Any]](
     cell: K,
     f: Callable[KCellParams, K],
     drop_params: Sequence[str],
@@ -268,7 +330,7 @@ def _set_settings(
     param_units: dict[str, Any],
     basename: str | None,
 ) -> None:
-    cell.function_name = _get_function_name(f)
+    cell.function_name = get_function_name(f)
     cell.basename = basename
 
     for param in drop_params:
@@ -299,33 +361,20 @@ def _check_cell(cell: AnyKCell, kcl: KCLayout) -> None:
         )
 
 
-@overload
-def _post_process(
-    cell: KC_contra,
-    post_process_functions: Iterable[Callable[[KC_contra], None]],
-) -> None: ...
-
-
-@overload
-def _post_process(
-    cell: VK_contra,
-    post_process_functions: Iterable[Callable[[VK_contra], None]],
-) -> None: ...
-
-
-def _post_process(
-    cell: K_contra,
-    post_process_functions: Iterable[Callable[[K_contra], None]],
+def _post_process[K: ProtoKCell[Any, Any]](
+    cell: K,
+    post_process_functions: Iterable[Callable[[K], None]],
 ) -> None:
     for pp in post_process_functions:
         pp(cell)
 
 
-class WrappedKCellFunc(Generic[KCellParams, KC]):
+@final
+class WrappedKCellFunc[**KCellParams, KC: ProtoTKCell[Any]]:
     _f: Callable[KCellParams, KC]
     _f_orig: Callable[KCellParams, ProtoTKCell[Any]]
     _f_schematic: Callable[KCellParams, TSchematic[Any]] | None = None
-    cache: Cache[int, KC] | dict[int, Any]
+    cache: Cache[Hashable, Any] | dict[Hashable, Any]
     name: str
     kcl: KCLayout
     output_type: type[KC]
@@ -340,12 +389,13 @@ def __init__(
         f: Callable[KCellParams, ProtoTKCell[Any]],
         sig: inspect.Signature,
         output_type: type[KC],
-        cache: Cache[int, KC] | dict[int, KC],
+        cache: Cache[Hashable, KC] | dict[Hashable, KC],
         set_settings: bool,
         set_name: bool,
         check_ports: bool,
         check_pins: bool,
         check_instances: CheckInstances,
+        check_unnamed_cells: CheckUnnamedCells,
         snap_ports: bool,
         add_port_layers: bool,
         basename: str | None,
@@ -359,163 +409,56 @@ def __init__(
         ports: PortsDefinition | None = None,
         tags: Sequence[str] | None = None,
         schematic_function: Callable[KCellParams, TSchematic[Any]] | None = None,
+        type_serializers: Sequence[tuple[type | UnionType, Callable[[Any], Any]]] = (
+            (
+                SymmetricalCrossSection
+                | CrossSection
+                | DCrossSection
+                | AsymmetricalCrossSection
+                | AsymmetricCrossSection
+                | DAsymmetricCrossSection,
+                attrgetter("name"),
+            ),
+        ),
+        type_hints_serializer: dict[
+            type | UnionType | TypeAliasType, Callable[[Any], Any]
+        ]
+        | None = None,
     ) -> None:
         self.kcl = kcl
         self.output_type = output_type
-        self.name = basename or _get_function_name(f)
+        self.name = basename or get_function_name(f)
         self.ports_definition = ports.copy() if ports is not None else None
         self.tags = set(tags) if tags else set()
         self._f_schematic = schematic_function
 
+        # Pre-compute static signature metadata once at decoration time
+        sig_params = SignatureParams(sig)
+        # Resolve annotations to concrete types for the type-hint serializers.
+        # Guard against ``NameError`` etc. raised by ``from __future__ import
+        # annotations`` functions whose hints reference ``TYPE_CHECKING``-only
+        # imports; an unresolvable hint simply opts out of hint-based
+        # serialization rather than breaking decoration.
+        try:
+            hints = get_type_hints(f)
+        except Exception:
+            hints = {}
+        if type_hints_serializer is None:
+            # ``cast`` because ty cannot recognize a PEP-695 ``type`` alias used
+            # as a value (the ``CrossSectionSpec`` dict key) as assignable to
+            # ``TypeAliasType``, even though it is one at runtime.
+            type_hints_serializer = cast(
+                "dict[type | UnionType | TypeAliasType, Callable[[Any], Any]]",
+                {CrossSectionSpec: kcl_cross_section_serializer(kcl=kcl)},
+            )
+
         @functools.wraps(f)
         def wrapper_autocell(
             *args: KCellParams.args, **kwargs: KCellParams.kwargs
         ) -> KC:
-            params, param_units = _parse_params(sig, kcl, args, kwargs)
-
-            @cached(cache=cache, lock=RLock())
-            @functools.wraps(f)
-            def wrapped_cell(**params: Any) -> KC:
-                _params_to_original(params)
-                old_future_name: str | None = None
-                if set_name:
-                    if basename is not None:
-                        name = get_cell_name(basename, **params)
-                    else:
-                        name = get_cell_name(self.name, **params)
-                    old_future_name = kcl.future_cell_name
-                    kcl.future_cell_name = name
-                    if layout_cache:
-                        if overwrite_existing:
-                            for c in list(kcl.cells(kcl.future_cell_name)):
-                                kcl[c.cell_index()].delete()
-                        else:
-                            layout_cell = kcl.layout_cell(kcl.future_cell_name)
-                            if layout_cell is not None:
-                                logger.debug(
-                                    "Loading {} from layout cache",
-                                    kcl.future_cell_name,
-                                )
-                                return kcl.get_cell(
-                                    layout_cell.cell_index(), output_type
-                                )
-                    logger.debug(f"Constructing {kcl.future_cell_name}")
-                    name_: str | None = name
-                else:
-                    name_ = None
-                cell = f(**params)  # type: ignore[call-arg]
-                if cell is None:
-                    raise TypeError(
-                        f"The cell function {self.name!r} in {str(self.file)!r}"
-                        " returned None. Did you forget to return the cell or component"
-                        " at the end of the function?"
-                    )
-                if not isinstance(cell, ProtoTKCell):
-                    raise TypeError(
-                        f"The cell function {self.name!r} in {str(self.file)!r}"
-                        f" returned {type(cell)=}. The `@cell` decorator only supports"
-                        " KCell/DKCell or any SubClass such as Component."
-                    )
-
-                logger.debug("Constructed {}", name_ or cell.name)
-
-                if cell.locked:
-                    # If the cell is locked, it comes from a cache (most likely)
-                    # and should be copied first
-                    cell = cell.dup(new_name=kcl.future_cell_name)
-                if overwrite_existing:
-                    _overwrite_existing(name_, cell, kcl)
-                if set_name and name_:
-                    if debug_names and cell.kcl.layout_cell(name_) is not None:
-                        logger.opt(depth=4).error(
-                            "KCell with name {name} exists already. Duplicate "
-                            "occurrence in module '{module}' at "
-                            "line {lno}",
-                            name=name_,
-                            module=f.__module__,
-                            function_name=f.__name__,
-                            lno=inspect.getsourcelines(f)[1],
-                        )
-                        raise CellNameError(f"KCell with name {name_} exists already.")
-
-                    cell.name = name_
-                    kcl.future_cell_name = old_future_name
-                if set_settings:
-                    _set_settings(cell, f, drop_params, params, param_units, basename)
-                if check_ports:
-                    _check_ports(cell)
-                if check_pins:
-                    _check_pins(cell)
-                _check_instances(cell, kcl, check_instances)
-                cell.insert_vinsts(recursive=False)
-                if snap_ports:
-                    _snap_ports(cell, kcl)
-                if add_port_layers:
-                    _add_port_layers(cell, kcl)
-                _post_process(cell, post_process)
-                cell.base.lock()
-                _check_cell(cell, kcl)
-                if self.ports_definition is not None:
-                    port_lengths = 0
-                    for direction in Direction:
-                        port_lengths += len(self.ports_definition.get(direction, []))  # type: ignore[arg-type]
-                    mapping = {0: "right", 1: "top", 2: "left", 3: "bottom"}
-                    if len(cell.ports) != port_lengths:
-                        received_ports = PortsDefinition()
-                        for port in cell.ports:
-                            mapped: Direction = Direction(mapping[port.trans.angle])
-                            if mapped not in received_ports:
-                                received_ports[mapped] = []  # type: ignore[literal-required]
-                            received_ports[mapped].append(port.name)  # type: ignore[literal-required]
-                        raise ValueError(
-                            "The `@cell` decorator defines ports, but they do not match"
-                            " the extracted ports. Declared ports: "
-                            f"{self.ports_definition}"
-                            ", Received ports: "
-                            f"{received_ports}"
-                        )
-
-                    if check_ports:
-                        found_errors = False
-                        for port in cell.ports:
-                            if (
-                                port.name
-                                not in self.ports_definition[mapping[port.trans.angle]]  # type: ignore[literal-required]
-                            ):
-                                found_errors = True
-                        if found_errors:
-                            received_ports = PortsDefinition()
-                            for port in cell.ports:
-                                mapped = Direction(mapping[port.trans.angle])
-                                if mapped not in received_ports:
-                                    received_ports[mapped] = []  # type: ignore[literal-required]
-                                received_ports[mapped].append(port.name)  # type: ignore[literal-required]
-                            raise ValueError(
-                                "The `@cell` decorator defines ports, but they do not"
-                                " match the extracted ports. Declared ports: "
-                                f"{self.ports_definition}"
-                                ", Received ports: "
-                                f"{received_ports}"
-                            )
-                    else:
-                        port_names: list[str | None] = []
-                        for direction in Direction:
-                            if direction in self.ports_definition:
-                                port_names.extend(self.ports_definition[direction])  # type: ignore[literal-required]
-
-                        for port in cell.ports:
-                            if port.name not in port_names:
-                                found_errors = True
-                        if found_errors:
-                            raise ValueError(
-                                "The `@cell` decorator defines ports, but they do not"
-                                " match the extracted ports. Declared ports: "
-                                f"{port_names}"
-                                ", Received ports: "
-                                f"{[p.name for p in cell.ports]}"
-                            )
-
-                return output_type(base=cell.base)
+            params = _parse_params(
+                sig_params.defaults, sig_params.names, kcl, args, kwargs
+            )
 
             with kcl.thread_lock:
                 cell_ = wrapped_cell(**params)
@@ -523,7 +466,7 @@ def wrapped_cell(**params: Any) -> KC:
                     # If any cell has been destroyed, we should clean up the cache.
                     # Delete all the KCell entrances in the cache which have
                     # `destroyed() == True`
-                    deleted_cell_hashes: list[int] = [
+                    deleted_cell_hashes: list[Hashable] = [
                         _hash_item
                         for _hash_item, _cell_item in cache.items()
                         if _cell_item.destroyed()
@@ -537,6 +480,180 @@ def wrapped_cell(**params: Any) -> KC:
 
                 return cell_
 
+        @cached(
+            cache=cache,
+            lock=RLock(),
+            key=get_keys_function(
+                hints=hints,
+                drop_args=drop_params,
+                serialize_types=type_serializers,
+                serialize_hints=type_hints_serializer,
+            ),
+        )
+        def wrapped_cell(**params: Any) -> KC:
+
+            _params_to_original(params)
+
+            old_future_name: str | None = None
+            if set_name:
+                if basename is not None:
+                    name = get_cell_name(basename, **params)
+                else:
+                    name = get_cell_name(self.name, **params)
+                old_future_name = kcl._future_cell_name
+                kcl._future_cell_name = name
+                if layout_cache:
+                    if overwrite_existing:
+                        for c in list(kcl.cells(kcl._future_cell_name)):
+                            kcl[c.cell_index()].delete()
+                    else:
+                        layout_cell = kcl.layout_cell(kcl._future_cell_name)
+                        if layout_cell is not None:
+                            logger.debug(
+                                "Loading {} from layout cache",
+                                kcl._future_cell_name,
+                            )
+                            return kcl.get_cell(layout_cell.cell_index(), output_type)
+                logger.debug(f"Constructing {kcl._future_cell_name}")
+                name_: str | None = name
+            else:
+                name_ = None
+            cell = f(**params)  # ty:ignore[missing-argument]
+            if cell is None:
+                raise TypeError(
+                    f"The cell function {self.name!r} in {str(self.file)!r}"
+                    " returned None. Did you forget to return the cell or component"
+                    " at the end of the function?"
+                )
+            if not isinstance(cell, ProtoTKCell):
+                raise TypeError(
+                    f"The cell function {self.name!r} in {str(self.file)!r}"
+                    f" returned {type(cell)=}. The `@cell` decorator only supports"
+                    " KCell/DKCell or any SubClass such as Component."
+                )
+
+            logger.debug("Constructed {}", name_ or cell.name)
+
+            if cell.locked:
+                # If the cell is locked, it likely comes
+                # from a cache and should be copied first
+                cell = cell.dup(new_name=kcl._future_cell_name)
+            if overwrite_existing:
+                _overwrite_existing(name_, cell, kcl)
+            if set_name and name_:
+                if debug_names and cell.kcl.layout_cell(name_) is not None:
+                    logger.opt(depth=4).error(
+                        "KCell with name {name} exists already. Duplicate "
+                        "occurrence in module '{module}' at "
+                        "line {lno}",
+                        name=name_,
+                        module=f.__module__,
+                        function_name=get_function_name(f),
+                        lno=inspect.getsourcelines(f)[1],
+                    )
+                    raise CellNameError(f"KCell with name {name_} exists already.")
+
+                cell.name = name_
+                kcl._future_cell_name = old_future_name
+            if set_settings:
+                _set_settings(cell, f, drop_params, params, sig_params.units, basename)
+            if check_ports:
+                _check_ports(cell)
+            if check_pins:
+                _check_pins(cell)
+            match check_unnamed_cells:
+                case CheckUnnamedCells.RAISE | CheckUnnamedCells.WARNING:
+                    unnamed_cells: list[str] = []
+                    for ci in cell.kdb_cell.each_child_cell():
+                        c = cell.kcl[ci]
+                        if re.fullmatch(_fixed_unnamed_pattern, c.name):
+                            factory_name = c.basename or c.function_name
+                            factory_string = (
+                                f"factory_name={factory_name!r}"
+                                if factory_name
+                                else "Cell without cell function"
+                            )
+                            unnamed_cells.append(f"{c.name} ({factory_string})")
+                    if unnamed_cells:
+                        msg = (
+                            f"Cell {cell.name!r} has"
+                            " unnamed cells instantiated:\n" + "\n".join(unnamed_cells)
+                        )
+                        if check_unnamed_cells == CheckUnnamedCells.RAISE:
+                            raise ValueError(msg)
+                        logger.warning(msg)
+
+            _check_instances(cell, kcl, check_instances)
+            cell.insert_vinsts(recursive=False)
+            if snap_ports:
+                _snap_ports(cell, kcl)
+            if add_port_layers:
+                _add_port_layers(cell, kcl)
+            _post_process(cell, post_process)
+            cell.base.lock()
+            _check_cell(cell, kcl)
+            if self.ports_definition is not None:
+                port_lengths = 0
+                for direction in Direction:
+                    port_lengths += len(self.ports_definition.get(direction, []))
+                mapping = {0: "right", 1: "top", 2: "left", 3: "bottom"}
+                if len(cell.ports) != port_lengths:
+                    received_ports = PortsDefinition()
+                    for port in cell.ports:
+                        mapped: Direction = Direction(mapping[port.trans.angle])
+                        if mapped not in received_ports:
+                            received_ports[mapped] = []  # ty:ignore[invalid-key]
+                        received_ports[mapped].append(port.name)  # ty:ignore[invalid-key]
+                    raise ValueError(
+                        "The `@cell` decorator defines ports, but they do not match"
+                        " the extracted ports. Declared ports: "
+                        f"{self.ports_definition}"
+                        ", Received ports: "
+                        f"{received_ports}"
+                    )
+
+                if check_ports:
+                    found_errors = False
+                    for port in cell.ports:
+                        if (
+                            port.name
+                            not in self.ports_definition[mapping[port.trans.angle]]  # ty:ignore[invalid-key]
+                        ):
+                            found_errors = True
+                    if found_errors:
+                        received_ports = PortsDefinition()
+                        for port in cell.ports:
+                            mapped = Direction(mapping[port.trans.angle])
+                            if mapped not in received_ports:
+                                received_ports[mapped] = []  # ty:ignore[invalid-key]
+                            received_ports[mapped].append(port.name)  # ty:ignore[invalid-key]
+                        raise ValueError(
+                            "The `@cell` decorator defines ports, but they do not"
+                            " match the extracted ports. Declared ports: "
+                            f"{self.ports_definition}"
+                            ", Received ports: "
+                            f"{received_ports}"
+                        )
+                else:
+                    port_names: list[str | None] = []
+                    for direction in Direction:
+                        if direction in self.ports_definition:
+                            port_names.extend(self.ports_definition[direction])  # ty:ignore[invalid-key]
+
+                    for port in cell.ports:
+                        if port.name not in port_names:
+                            found_errors = True
+                    if found_errors:
+                        raise ValueError(
+                            "The `@cell` decorator defines ports, but they do not"
+                            " match the extracted ports. Declared ports: "
+                            f"{port_names}"
+                            ", Received ports: "
+                            f"{[p.name for p in cell.ports]}"
+                        )
+
+            return output_type(base=cell.base)
+
         self._f = wrapper_autocell
         self._f_orig = f
         self.cache = cache
@@ -555,11 +672,7 @@ def __len__(self) -> int:
 
     @functools.cached_property
     def file(self) -> Path:
-        if isinstance(self._f_orig, FunctionType):
-            return Path(self._f_orig.__code__.co_filename).resolve()
-        if isinstance(self._f_orig, functools.partial):
-            return Path(self._f_orig.func.__code__.co_filename).resolve()
-        return Path(self._f_orig.__code__.co_filename).resolve()
+        return _get_path(self._f_orig)
 
     def prune(self) -> None:
         cells = [c for c in self.cache.values() if not c._destroyed()]
@@ -586,10 +699,11 @@ def get_schematic(
         return self._f_schematic(*args, **kwargs)
 
 
-class WrappedVKCellFunc(Generic[KCellParams, VK]):
-    _f: Callable[KCellParams, VK]
-    _f_orig: Callable[KCellParams, VKCell]
-    cache: Cache[int, VK] | dict[int, Any]
+@final
+class WrappedVKCellFunc[**VKCellParams, VK: VKCell]:
+    _f: Callable[VKCellParams, VK]
+    _f_orig: Callable[VKCellParams, VKCell]
+    cache: Cache[Hashable, VK] | dict[Hashable, Any]
     name: str
     kcl: KCLayout
     output_type: type[VK]
@@ -601,14 +715,15 @@ def __init__(
         self,
         *,
         kcl: KCLayout,
-        f: Callable[KCellParams, VKCell],
+        f: Callable[VKCellParams, VKCell],
         sig: inspect.Signature,
         output_type: type[VK],
-        cache: Cache[int, VK] | dict[int, VK],
+        cache: Cache[Hashable, VK] | dict[Hashable, VK],
         set_settings: bool,
         set_name: bool,
         check_ports: bool,
         check_pins: bool,
+        check_unnamed_cells: CheckUnnamedCells,
         add_port_layers: bool,
         basename: str | None,
         drop_params: Sequence[str],
@@ -617,107 +732,50 @@ def __init__(
         lvs_equivalent_ports: list[list[str]] | None = None,
         ports: PortsDefinition | None = None,
         tags: Sequence[str] | None = None,
+        type_serializers: Sequence[tuple[type | UnionType, Callable[[Any], Any]]] = (
+            (
+                SymmetricalCrossSection
+                | CrossSection
+                | DCrossSection
+                | AsymmetricalCrossSection
+                | AsymmetricCrossSection
+                | DAsymmetricCrossSection,
+                attrgetter("name"),
+            ),
+        ),
+        type_hints_serializer: dict[
+            type | UnionType | TypeAliasType, Callable[[Any], Any]
+        ]
+        | None = None,
     ) -> None:
         self.kcl = kcl
         self.output_type = output_type
-        self.name = basename or _get_function_name(f)
+        self.name = basename or get_function_name(f)
         self.ports_definitions = ports.copy() if ports is not None else None
         self.tags = set(tags) if tags else set()
 
+        # Pre-compute static signature metadata once at decoration time
+        sig_params = SignatureParams(sig)
+        # Resolve annotations to concrete types for the type-hint serializers (see
+        # the equivalent block in ``WrappedKCellFunc``); an unresolvable hint simply
+        # opts out of hint-based serialization rather than breaking decoration.
+        try:
+            hints = get_type_hints(f)
+        except Exception:
+            hints = {}
+        if type_hints_serializer is None:
+            type_hints_serializer = cast(
+                "dict[type | UnionType | TypeAliasType, Callable[[Any], Any]]",
+                {CrossSectionSpec: kcl_cross_section_serializer(kcl=kcl)},
+            )
+
         @functools.wraps(f)
         def wrapper_autocell(
-            *args: KCellParams.args, **kwargs: KCellParams.kwargs
+            *args: VKCellParams.args, **kwargs: VKCellParams.kwargs
         ) -> VK:
-            params, param_units = _parse_params(sig, kcl, args, kwargs)
-
-            @cached(cache=cache, lock=RLock())
-            @functools.wraps(f)
-            def wrapped_cell(**params: Any) -> VK:
-                _params_to_original(params)
-                old_future_name: str | None = None
-                if set_name:
-                    if basename is not None:
-                        name = get_cell_name(basename, **params)
-                    else:
-                        name = get_cell_name(self.name, **params)
-                    old_future_name = kcl.future_cell_name
-                    kcl.future_cell_name = name
-                    logger.debug(f"Constructing {kcl.future_cell_name}")
-                    name_: str | None = name
-                else:
-                    name_ = None
-                cell = f(**params)  # type: ignore[call-arg]
-                if cell is None:
-                    raise TypeError(
-                        f"The cell function {self.name!r} in {str(self.file)!r}"
-                        " returned None. Did you forget to return the cell or component"
-                        " at the end of the function?"
-                    )
-                if not isinstance(cell, VKCell):
-                    raise TypeError(
-                        f"The cell function {self.name!r} in {str(self.file)!r}"
-                        f" returned {type(cell)=}. The `@vcell` decorator only supports"
-                        " VKCell or any SubClass such as ComponentAllAngle."
-                    )
-
-                logger.debug("Constructed {}", name_ or cell.name)
-
-                if cell.locked:
-                    # If the cell is locked, it comes from a cache (most likely)
-                    # and should be copied first
-                    cell = cell.dup(new_name=kcl.future_cell_name)
-                if set_name and name_:
-                    cell.name = name_
-                    kcl.future_cell_name = old_future_name
-                if set_settings:
-                    _set_settings(cell, f, drop_params, params, param_units, basename)
-                if check_ports:
-                    _check_ports(cell)
-                if check_pins:
-                    _check_pins(cell)
-                if add_port_layers:
-                    _add_port_layers_vkcell(cell, kcl)
-                _post_process(cell, post_process)
-                cell.base.lock()
-                _check_cell(cell, kcl)
-                if self.ports_definition is not None:
-                    port_lengths = 0
-                    for direction in Direction:
-                        port_lengths += len(self.ports_definition.get(direction, []))  # type: ignore[arg-type]
-                    mapping = {0: "right", 1: "top", 2: "left", 3: "bottom"}
-                    if len(cell.ports) != port_lengths:
-                        received_ports = PortsDefinition()
-                        for port in cell.ports:
-                            mapped: Direction = Direction(mapping[port.trans.angle])
-                            if mapped not in received_ports:
-                                received_ports[mapped] = []  # type: ignore[literal-required]
-                            received_ports[mapped].append(port.name)  # type: ignore[literal-required]
-                        raise ValueError(
-                            "The `@cell` decorator defines ports, but they do not match"
-                            " the extracted ports. Declared ports: "
-                            f"{self.ports_definition}"
-                            ", Received ports: "
-                            f"{received_ports}"
-                        )
-
-                    port_names: list[str | None] = []
-                    for direction in Direction:
-                        if direction in self.ports_definition:
-                            port_names.extend(self.ports_definition[direction])  # type: ignore[literal-required]
-
-                    for port in cell.ports:
-                        if port.name not in port_names:
-                            found_errors = True
-                    if found_errors:
-                        raise ValueError(
-                            "The `@cell` decorator defines ports, but they do not"
-                            " match the extracted ports. Declared ports: "
-                            f"{port_names}"
-                            ", Received ports: "
-                            f"{[p.name for p in cell.ports]}"
-                        )
-
-                return output_type(base=cell.base)
+            params = _parse_params(
+                sig_params.defaults, sig_params.names, kcl, args, kwargs
+            )
 
             with kcl.thread_lock:
                 cell_ = wrapped_cell(**params)
@@ -727,6 +785,126 @@ def wrapped_cell(**params: Any) -> VK:
 
                 return cell_
 
+        @cached(
+            cache=cache,
+            lock=RLock(),
+            key=get_keys_function(
+                hints=hints,
+                drop_args=drop_params,
+                serialize_types=type_serializers,
+                serialize_hints=type_hints_serializer,
+            ),
+        )
+        def wrapped_cell(**params: Any) -> VK:
+
+            _params_to_original(params)
+
+            old_future_name: str | None = None
+            if set_name:
+                if basename is not None:
+                    name = get_cell_name(basename, **params)
+                else:
+                    name = get_cell_name(self.name, **params)
+                old_future_name = kcl._future_cell_name
+                kcl._future_cell_name = name
+                logger.debug(f"Constructing {kcl._future_cell_name}")
+                name_: str | None = name
+            else:
+                name_ = None
+            cell = f(**params)  # ty:ignore[missing-argument]
+            if cell is None:
+                raise TypeError(
+                    f"The cell function {self.name!r} in {str(self.file)!r}"
+                    " returned None. Did you forget to return the cell or component"
+                    " at the end of the function?"
+                )
+            if not isinstance(cell, VKCell):
+                raise TypeError(
+                    f"The cell function {self.name!r} in {str(self.file)!r}"
+                    f" returned {type(cell)=}. The `@vcell` decorator only supports"
+                    " VKCell or any SubClass such as ComponentAllAngle."
+                )
+
+            logger.debug("Constructed {}", name_ or cell.name)
+
+            if cell.locked:
+                # If the cell is locked, it comes from a cache (most likely)
+                # and should be copied first
+                cell = cell.dup(new_name=kcl._future_cell_name)
+            if set_name and name_:
+                cell.name = name_
+                kcl._future_cell_name = old_future_name
+            if set_settings:
+                _set_settings(cell, f, drop_params, params, sig_params.units, basename)
+            if check_ports:
+                _check_ports(cell)
+            if check_pins:
+                _check_pins(cell)
+            match check_unnamed_cells:
+                case CheckUnnamedCells.RAISE | CheckUnnamedCells.WARNING:
+                    unnamed_cells: set[str] = set()
+                    for inst in cell.insts:
+                        c = inst.cell
+                        if re.fullmatch(_fixed_unnamed_pattern, c.name or ""):
+                            factory_name = c.basename or c.function_name
+                            factory_string = (
+                                f"factory_name={factory_name!r}"
+                                if factory_name
+                                else "Cell without cell function"
+                            )
+                            unnamed_cells.add(f"{c.name} ({factory_string})")
+                    if unnamed_cells:
+                        msg = (
+                            f"Cell {cell.name!r} has"
+                            " unnamed cells instantiated:\n" + "\n".join(unnamed_cells)
+                        )
+                        if check_unnamed_cells == CheckUnnamedCells.RAISE:
+                            raise ValueError(msg)
+                        logger.warning(msg)
+            if add_port_layers:
+                _add_port_layers_vkcell(cell, kcl)
+            _post_process(cell, post_process)
+            cell.base.lock()
+            _check_cell(cell, kcl)
+            if self.ports_definition is not None:
+                port_lengths = 0
+                for direction in Direction:
+                    port_lengths += len(self.ports_definition.get(direction, []))
+                mapping = {0: "right", 1: "top", 2: "left", 3: "bottom"}
+                if len(cell.ports) != port_lengths:
+                    received_ports = PortsDefinition()
+                    for port in cell.ports:
+                        mapped: Direction = Direction(mapping[port.trans.angle])
+                        if mapped not in received_ports:
+                            received_ports[mapped] = []  # ty:ignore[invalid-key]
+                        received_ports[mapped].append(port.name)  # ty:ignore[invalid-key]
+                    raise ValueError(
+                        "The `@cell` decorator defines ports, but they do not match"
+                        " the extracted ports. Declared ports: "
+                        f"{self.ports_definition}"
+                        ", Received ports: "
+                        f"{received_ports}"
+                    )
+
+                port_names: list[str | None] = []
+                for direction in Direction:
+                    if direction in self.ports_definition:
+                        port_names.extend(self.ports_definition[direction])  # ty:ignore[invalid-key]
+
+                for port in cell.ports:
+                    if port.name not in port_names:
+                        found_errors = True
+                if found_errors:
+                    raise ValueError(
+                        "The `@cell` decorator defines ports, but they do not"
+                        " match the extracted ports. Declared ports: "
+                        f"{port_names}"
+                        ", Received ports: "
+                        f"{[p.name for p in cell.ports]}"
+                    )
+
+            return output_type(base=cell.base)
+
         self._f = wrapper_autocell
         self._f_orig = f
         self.cache = cache
@@ -741,11 +919,18 @@ def __len__(self) -> int:
 
     @functools.cached_property
     def file(self) -> Path:
-        if isinstance(self._f_orig, FunctionType):
-            return Path(self._f_orig.__code__.co_filename).resolve()
-        if isinstance(self._f_orig, functools.partial):
-            return Path(self._f_orig.func.__code__.co_filename).resolve()
-        return Path(self._f_orig.__code__.co_filename).resolve()
+        return _get_path(self._f_orig)
+
+
+def _get_path(f: Callable[..., Any]) -> Path:
+    if isinstance(f, FunctionType):
+        return Path(f.__code__.co_filename).resolve()
+    if isinstance(f, functools.partial):
+        return _get_path(f.func)
+    try:
+        return Path(f.__code__.co_filename).resolve()  # ty:ignore[unresolved-attribute]
+    except Exception as e:
+        raise ValueError("Failed to retrieve path of code for function {f}") from e
 
 
 class ModuleCellKWargs(TypedDict, total=False):
@@ -789,12 +974,12 @@ class KCellDecoratorKWargs(TypedDict, total=False):
     debug_names: bool | None
 
 
-class KCellDecorator(Protocol):
+class KCellDecorator[**KCellParams, K: ProtoKCell[Any, Any]](Protocol):
     """Signature of the `@cell` decorator."""
 
     def __call__(
         self, **kwargs: Unpack[KCellDecoratorKWargs]
-    ) -> Callable[[Callable[KCellParams, KC_co]], Callable[KCellParams, KC_co]]:
+    ) -> Callable[[Callable[KCellParams, K]], Callable[KCellParams, K]]:
         """__call__ implementation."""
         ...
 
@@ -802,28 +987,32 @@ def __call__(
 class ModuleDecorator(Protocol):
     """Signature of the `@module_cell` decorator."""
 
-    def __call__(
+    def __call__[**KCellParams, K: ProtoKCell[Any, Any]](
         self, /, **kwargs: Unpack[ModuleCellKWargs]
-    ) -> Callable[[Callable[KCellParams, KC_co]], Callable[KCellParams, KC_co]]:
+    ) -> Callable[[Callable[KCellParams, K]], Callable[KCellParams, K]]:
         """__call__ implementation."""
         ...
 
 
-def _module_cell(
-    cell_decorator: KCellDecorator,
+def _module_cell[**KCellParams, K: ProtoKCell[Any, Any]](
+    cell_decorator: KCellDecorator[KCellParams, K],
     /,
     **kwargs: Unpack[ModuleCellKWargs],
-) -> Callable[[Callable[KCellParams, KC_co]], Callable[KCellParams, KC_co]]:
+) -> Callable[[Callable[KCellParams, K]], Callable[KCellParams, K]]:
     """Constructs the actual decorator.
 
     Modifies the basename to the module if the module is not the main one.
     """
 
-    def decorator_cell(
-        f: Callable[KCellParams, KC_co],
-    ) -> Callable[KCellParams, KC_co]:
+    def decorator_cell[**KCP, KC: ProtoTKCell[Any]](
+        f: Callable[KCellParams, K],
+    ) -> Callable[KCellParams, K]:
         mod = f.__module__
-        basename = f.__name__ if mod == "__main" else f"{mod}_{f.__name__}"
+        basename = (
+            get_function_name(f)
+            if mod.startswith("__main")
+            else f"{mod}_{get_function_name(f)}"
+        )
         return cell_decorator(basename=basename, **kwargs)(f)
 
     return decorator_cell
@@ -837,31 +1026,58 @@ def __init__(self, kcl: KCLayout) -> None:
         self._cell = kcl.cell
 
     @overload
-    def module_cell(
+    def module_cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
-        _func: Callable[KCellParams, KC_co],
+        _func: Callable[KCellParams, KC],
         /,
-    ) -> Callable[KCellParams, KC_co]: ...
+    ) -> Callable[KCellParams, KC]: ...
 
     @overload
-    def module_cell(
+    def module_cell[**KCellParams, KC: ProtoTKCell[Any]](
         self, /, **kwargs: Unpack[ModuleCellKWargs]
-    ) -> Callable[[Callable[KCellParams, KC_co]], Callable[KCellParams, KC_co]]: ...
+    ) -> Callable[[Callable[KCellParams, KC]], Callable[KCellParams, KC]]: ...
 
-    def module_cell(
+    def module_cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
-        _func: Callable[KCellParams, KC_co] | None = None,
+        _func: Callable[KCellParams, KC] | None = None,
         /,
         **kwargs: Unpack[ModuleCellKWargs],
     ) -> (
-        Callable[KCellParams, KC_co]
-        | Callable[[Callable[KCellParams, KC_co]], Callable[KCellParams, KC_co]]
+        Callable[KCellParams, KC]
+        | Callable[[Callable[KCellParams, KC]], Callable[KCellParams, KC]]
     ):
         """Constructs the `@module_cell` decorator on KCLayout.decorators."""
 
         def mc(
             **kwargs: Unpack[ModuleCellKWargs],
-        ) -> Callable[[Callable[KCellParams, KC_co]], Callable[KCellParams, KC_co]]:
-            return _module_cell(self._cell, **kwargs)  # type: ignore[arg-type]
+        ) -> Callable[[Callable[KCellParams, KC]], Callable[KCellParams, KC]]:
+            return _module_cell(self._cell, **kwargs)  # ty:ignore[invalid-argument-type]
 
         return mc(**kwargs) if _func is None else mc(**kwargs)(_func)
+
+
+def get_keys_function(
+    hints: dict[str, type],
+    drop_args: Sequence[str],
+    serialize_types: Sequence[tuple[type | UnionType, Callable[[Any], Any]]],
+    serialize_hints: dict[type | UnionType | TypeAliasType, Callable[[Any], Any]],
+) -> Callable[..., tuple[Hashable, ...]]:
+    types_ = tuple(st[0] for st in serialize_types)
+
+    def _keys_function(*args: Any, **kwargs: Any) -> tuple[Hashable, ...]:
+        for arg in drop_args:
+            kwargs.pop(arg, None)
+
+        updates: dict[str, Any] = {}
+        for key, value in kwargs.items():
+            if key in hints and hints[key] in serialize_hints:
+                updates[key] = serialize_hints[hints[key]](value)
+            elif isinstance(value, types_):
+                for type_, serializer in serialize_types:
+                    if isinstance(value, type_):
+                        updates[key] = serializer(value)
+                        break
+        kwargs.update(updates)
+        return hashkey(*args, **kwargs)
+
+    return _keys_function
diff --git a/src/kfactory/enclosure.py b/src/kfactory/enclosure.py
index d2995d954..d81446c5b 100644
--- a/src/kfactory/enclosure.py
+++ b/src/kfactory/enclosure.py
@@ -18,6 +18,7 @@
     Any,
     NotRequired,
     TypeGuard,
+    cast,
     overload,
 )
 
@@ -34,6 +35,7 @@
 
 from . import kdb
 from .conf import config, logger
+from .exceptions import CrossSectionNamingConflictError
 
 if TYPE_CHECKING:
     from collections.abc import (
@@ -43,6 +45,7 @@
         Sequence,
     )
 
+    from .cross_section import AnyCrossSection
     from .kcell import KCell
     from .layout import KCLayout
     from .port import Port
@@ -51,6 +54,7 @@
     "KCellEnclosure",
     "LayerEnclosure",
     "extrude_path",
+    "extrude_path_cross_section",
     "extrude_path_dynamic",
     "extrude_path_dynamic_points",
     "extrude_path_points",
@@ -90,17 +94,25 @@ def path_pts_to_polygon(
     return kdb.DPolygon(pts_top + pts_bot)
 
 
-def extrude_path_points(
+def _extrude_path_band_points(
     path: Sequence[kdb.DPoint],
-    width: float,
+    lo: float,
+    hi: float,
     start_angle: float | None = None,
     end_angle: float | None = None,
 ) -> tuple[list[kdb.DPoint], list[kdb.DPoint]]:
-    """Extrude a path from a list of points and a static width.
+    """Generate the two edges of a signed band along ``path``.
+
+    The band covers the signed offsets ``[lo, hi]`` from the path center line, in the
+    same units as ``path`` (``+`` is the left-hand side of the travel direction). The
+    returned ``(top, bottom)`` edges sit at offset ``hi`` and ``lo`` respectively. The
+    symmetric case is ``lo=-width/2, hi=width/2`` (what `extrude_path_points` passes),
+    which reproduces the previous ``±width/2`` + ``R180``-mirror behavior exactly.
 
     Args:
         path: list of floating-points points
-        width: width in um
+        lo: signed offset of the bottom edge from the center line
+        hi: signed offset of the top edge from the center line
         start_angle: optionally specify a custom starting angle if `None` will
             be autocalculated from the first two elements
         end_angle: optionally specify a custom ending angle if `None`
@@ -118,9 +130,10 @@ def extrude_path_points(
     start_trans = kdb.DCplxTrans(1, start_angle, False, p_start.x, p_start.y)
     end_trans = kdb.DCplxTrans(1, end_angle, False, p_end.x, p_end.y)
 
-    ref_vector = kdb.DCplxTrans(kdb.DVector(0, width / 2))
-    vector_top = [start_trans * ref_vector]
-    vector_bot = [(start_trans * kdb.DCplxTrans.R180) * ref_vector]
+    top_vector = kdb.DCplxTrans(kdb.DVector(0, hi))
+    bot_vector = kdb.DCplxTrans(kdb.DVector(0, lo))
+    vector_top = [start_trans * top_vector]
+    vector_bot = [start_trans * bot_vector]
 
     p_old = path[0]
     p = path[1]
@@ -130,17 +143,38 @@ def extrude_path_points(
         v = p_new - p_old
         angle = np.rad2deg(np.arctan2(v.y, v.x))
         transformation = kdb.DCplxTrans(1, angle, False, p.x, p.y)
-        vector_top.append(transformation * ref_vector)
-        vector_bot.append(transformation * kdb.DCplxTrans.R180 * ref_vector)
+        vector_top.append(transformation * top_vector)
+        vector_bot.append(transformation * bot_vector)
         p_old = p
         p = p_new
 
-    vector_top.append(end_trans * ref_vector)
-    vector_bot.append(end_trans * kdb.DCplxTrans.R180 * ref_vector)
+    vector_top.append(end_trans * top_vector)
+    vector_bot.append(end_trans * bot_vector)
 
     return [v.disp.to_p() for v in vector_top], [v.disp.to_p() for v in vector_bot]
 
 
+def extrude_path_points(
+    path: Sequence[kdb.DPoint],
+    width: float,
+    start_angle: float | None = None,
+    end_angle: float | None = None,
+) -> tuple[list[kdb.DPoint], list[kdb.DPoint]]:
+    """Extrude a path from a list of points and a static width.
+
+    Args:
+        path: list of floating-points points
+        width: width in um
+        start_angle: optionally specify a custom starting angle if `None` will
+            be autocalculated from the first two elements
+        end_angle: optionally specify a custom ending angle if `None`
+            will be autocalculated from the last two elements
+    """
+    return _extrude_path_band_points(
+        path, -width / 2, width / 2, start_angle, end_angle
+    )
+
+
 def extrude_path(
     target: KCell,
     layer: kdb.LayerInfo,
@@ -203,6 +237,65 @@ def extrude_path(
     return ret_path
 
 
+def extrude_path_cross_section(
+    target: KCell,
+    path: list[kdb.DPoint],
+    cross_section: AnyCrossSection,
+    start_angle: float | None = None,
+    end_angle: float | None = None,
+) -> None:
+    """Extrude a (symmetric or asymmetric) cross section along a path.
+
+    The standard static-width extrusion that takes a cross section instead of a
+    ``(layer, width, enclosure)`` triple. Symmetric cross sections are extruded exactly
+    as `extrude_path` (centered ``width`` + enclosure sections — byte-identical output).
+    Asymmetric cross sections are extruded as one signed band ``[section_min,
+    section_max]`` per strip (the main strip plus each aux section), so the profile
+    keeps its left/right offsets; strips sharing a layer are merged.
+
+    Args:
+        target: the cell where to insert the shapes to (and get the database unit from)
+        path: list of floating-points points (in um)
+        cross_section: the cross section to extrude
+        start_angle: optionally specify a custom starting angle if `None` will
+            be autocalculated from the first two elements
+        end_angle: optionally specify a custom ending angle if `None` will be
+            autocalculated from the last two elements
+    """
+    from .cross_section import AsymmetricalCrossSection
+
+    if not isinstance(cross_section, AsymmetricalCrossSection):
+        # Symmetric: identical to the legacy width + enclosure path.
+        extrude_path(
+            target,
+            cross_section.main_layer,
+            path,
+            target.kcl.to_um(cross_section.width),
+            cross_section.enclosure,
+            start_angle,
+            end_angle,
+        )
+        return
+
+    to_um = target.kcl.to_um
+    # One signed band [section_min, section_max] per strip; strips merged per layer.
+    strips: dict[kdb.LayerInfo, list[tuple[float, float]]] = defaultdict(list)
+    strips[cross_section.layer].append(
+        (to_um(cross_section.section_min), to_um(cross_section.section_max))
+    )
+    for sec in cross_section.sections:
+        strips[sec.layer].append((to_um(sec.section_min), to_um(sec.section_max)))
+
+    for _layer, bands in strips.items():
+        reg = kdb.Region()
+        for lo, hi in bands:
+            polygon = path_pts_to_polygon(
+                *_extrude_path_band_points(path, lo, hi, start_angle, end_angle)
+            )
+            reg.insert(target.kcl.to_dbu(polygon))
+        target.shapes(target.kcl.layer(_layer)).insert(reg.merge())
+
+
 def extrude_path_dynamic_points(
     path: list[kdb.DPoint],
     widths: Callable[[float], float] | list[float],
@@ -236,14 +329,14 @@ def extrude_path_dynamic_points(
     if callable(widths):
         length = sum(((p2 - p1).abs() for p2, p1 in itertools.pairwise(path)))
         z: float = 0
-        ref_vector = kdb.DCplxTrans(kdb.DVector(0, widths(z / length) / 2))
+        ref_vector = kdb.DCplxTrans(kdb.DVector(0, widths(z / length) / 2))  # ty:ignore[call-top-callable, unsupported-operator]
         vector_top = [start_trans * ref_vector]
         vector_bot = [start_trans * kdb.DCplxTrans.R180 * ref_vector]
         p_old = path[0]
         p = path[1]
         z += (p - p_old).abs()
         for point in path[2:]:
-            ref_vector = kdb.DCplxTrans(kdb.DVector(0, widths(z / length) / 2))
+            ref_vector = kdb.DCplxTrans(kdb.DVector(0, widths(z / length) / 2))  # ty:ignore[call-top-callable, unsupported-operator]
             p_new = point
             v = p_new - p_old
             angle = np.rad2deg(np.arctan2(v.y, v.x))
@@ -253,7 +346,7 @@ def extrude_path_dynamic_points(
             z += (p_new - p).abs()
             p_old = p
             p = p_new
-        ref_vector = kdb.DCplxTrans(kdb.DVector(0, widths(z / length) / 2))
+        ref_vector = kdb.DCplxTrans(kdb.DVector(0, widths(z / length) / 2))  # ty:ignore[call-top-callable, unsupported-operator]
     else:
         ref_vector = kdb.DCplxTrans(kdb.DVector(0, widths[0] / 2))
         vector_top = [start_trans * ref_vector]
@@ -359,10 +452,7 @@ def w_min(x: float, section: Section = section) -> float:
         for layer_, layer_sec in layer_list.items():
             reg = kdb.Region()
             for section in layer_sec.sections:
-                max_widths = [
-                    w + 2 * section.d_max * target.kcl.dbu
-                    for w in widths  # type: ignore[union-attr]
-                ]
+                max_widths = [w + 2 * section.d_max * target.kcl.dbu for w in widths]  # ty:ignore[not-iterable]
                 r = kdb.Region(
                     target.kcl.to_dbu(
                         path_pts_to_polygon(
@@ -378,7 +468,7 @@ def w_min(x: float, section: Section = section) -> float:
                 if section.d_min is not None:
                     min_widths = [
                         w + 2 * section.d_min * target.kcl.dbu
-                        for w in widths  # type: ignore[union-attr]
+                        for w in widths  # ty:ignore[not-iterable]
                     ]
                     r -= kdb.Region(
                         target.kcl.to_dbu(
@@ -459,13 +549,11 @@ def add_section(self, sec: Section) -> int:
         if sec.d_min is not None:
             while i < len(self.sections) and sec.d_min > self.sections[i].d_max:
                 i += 1
-            while (
-                i < len(self.sections) and sec.d_max >= self.sections[i].d_min  # type: ignore[operator]
-            ):
+            while i < len(self.sections) and sec.d_max >= self.sections[i].d_min:  # ty:ignore[unsupported-operator]
                 sec.d_max = max(self.sections[i].d_max, sec.d_max)
                 sec.d_min = min(
-                    self.sections[i].d_min,
-                    sec.d_min,  # type: ignore[type-var]
+                    self.sections[i].d_min,  # ty:ignore[invalid-argument-type]
+                    sec.d_min,
                 )
                 self.sections.pop(i)
                 if i == len(self.sections):
@@ -509,6 +597,20 @@ class LayerEnclosure(BaseModel, arbitrary_types_allowed=True, frozen=True):
     main_layer: kdb.LayerInfo | None
     bbox_sections: dict[kdb.LayerInfo, int]
 
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, LayerEnclosure):
+            if self.main_layer is not None and other.main_layer is not None:
+                layer_info_equal = self.main_layer.is_equivalent(other.main_layer)
+            else:
+                layer_info_equal = self.main_layer == other.main_layer
+            return (
+                self.layer_sections == other.layer_sections
+                and self.name == other.name
+                and layer_info_equal
+                and self.bbox_sections == other.bbox_sections
+            )
+        return False
+
     def __init__(
         self,
         sections: Sequence[
@@ -542,10 +644,10 @@ def __init__(
             assert kcl is not None, "If sections in um are defined, kcl must be set"
             sections = list(sections)
             for section in dsections:
-                if len(section) == 2:  # noqa: PLR2004
+                if len(section) == 2:
                     sections.append((section[0], kcl.to_dbu(section[1])))
 
-                elif len(section) == 3:  # noqa: PLR2004
+                elif len(section) == 3:
                     sections.append(
                         (
                             section[0],
@@ -563,8 +665,8 @@ def __init__(
             else:
                 ls = LayerSection()
                 layer_sections[sec[0]] = ls
-            ls.add_section(Section(d_max=sec[1])) if len(sec) < 3 else ls.add_section(  # noqa: PLR2004
-                Section(d_max=sec[2], d_min=sec[1])
+            ls.add_section(Section(d_max=sec[1])) if len(sec) < 3 else ls.add_section(
+                Section(d_max=sec[2], d_min=sec[1])  # ty:ignore[index-out-of-bounds]
             )
         super().__init__(
             main_layer=main_layer,
@@ -572,7 +674,7 @@ def __init__(
             layer_sections=layer_sections,
             bbox_sections={t[0]: t[1] for t in bbox_sections},
         )
-        self._name = name
+        self._name = name  # ty:ignore[invalid-assignment]
 
     @model_serializer
     def _serialize(self) -> dict[str, Any]:
@@ -611,6 +713,28 @@ def name(self) -> str:
         """Get name of the Enclosure."""
         return self.__str__()
 
+    @property
+    def is_named(self) -> bool:
+        """Whether an explicit name was given (vs. a derived structural name)."""
+        return self._name is not None
+
+    @property
+    def unnamed_key(self) -> str:
+        """Deterministic structural key, independent of the explicit name.
+
+        This is the same hash an unnamed enclosure is named after. Exposing it on
+        named enclosures as well lets the registry alias the structural signature
+        to a named enclosure.
+        """
+        list_to_hash: list[tuple[str, ...]] = [(str(self.main_layer),)]
+        for layer, layer_section in self.layer_sections.items():
+            list_to_hash.append((str(layer), str(layer_section.sections)))
+        for layer, offset in sorted(
+            self.bbox_sections.items(), key=lambda kv: str(kv[0])
+        ):
+            list_to_hash.append((str(layer), "bbox", str(offset)))
+        return sha1(str(list_to_hash).encode("UTF-8")).hexdigest()[-8:]  # noqa: S324
+
     def minkowski_region(
         self,
         r: kdb.Region,
@@ -635,7 +759,7 @@ def minkowski_region(
             return r.minkowski_sum(shape(d))
         shape_ = shape(abs(d))
         if isinstance(shape_, list):
-            box_shape = kdb.Polygon(shape_)
+            box_shape = kdb.Polygon(cast("list[kdb.Point]", shape_))
             bbox_maxsize = max(
                 box_shape.bbox().width(),
                 box_shape.bbox().height(),
@@ -795,7 +919,7 @@ def apply_minkowski_tiled(
                     " Therefore the layer must be defined in calls"
                 )
         tp = kdb.TilingProcessor()
-        tp.frame = c.dbbox()  # type: ignore[misc, assignment]
+        tp.frame = c.dbbox()  # ty:ignore[invalid-assignment]
         tp.dbu = c.kcl.dbu
         tp.threads = n_threads or config.n_threads
         maxsize = 0
@@ -976,12 +1100,7 @@ def __str__(self) -> str:
         """
         if self._name is not None:
             return self._name
-        list_to_hash: Any = [
-            self.main_layer,
-        ]
-        for layer, layer_section in self.layer_sections.items():
-            list_to_hash.append([str(layer), str(layer_section.sections)])
-        return sha1(str(list_to_hash).encode("UTF-8")).hexdigest()[-8:]  # noqa: S324
+        return self.unnamed_key
 
     def extrude_path(
         self,
@@ -1127,8 +1246,8 @@ def get_enclosure(
             )
 
         if enclosure not in self.enclosures:
-            self.enclosures.append(enclosure)  # type: ignore[arg-type]
-        return enclosure  # type: ignore[return-value]
+            self.enclosures.append(enclosure)  # ty:ignore[invalid-argument-type]
+        return enclosure  # ty:ignore[invalid-return-type]
 
 
 class RegionOperator(kdb.TileOutputReceiver):
@@ -1311,7 +1430,7 @@ def minkowski_region(
             return r.minkowski_sum(shape(d))
         shape_ = shape(abs(d))
         if isinstance(shape_, list):
-            box_shape = kdb.Polygon(shape_)
+            box_shape = kdb.Polygon(cast("list[kdb.Point]", shape_))
             bbox_maxsize = max(
                 box_shape.bbox().width(),
                 box_shape.bbox().height(),
@@ -1451,7 +1570,7 @@ def apply_minkowski_tiled(
             carve_out_ports: Carves out a box of port_width +
         """
         tp = kdb.TilingProcessor()
-        tp.frame = c.dbbox()  # type: ignore[misc, assignment]
+        tp.frame = c.dbbox()  # ty:ignore[invalid-assignment]
         tp.dbu = c.kcl.dbu
         tp.threads = n_threads or config.n_threads
         inputs: set[str] = set()
@@ -1499,7 +1618,7 @@ def apply_minkowski_tiled(
             tuple[int, LayerSection], RegionTilesOperator
         ] = {}
 
-        logger.debug("Starting KCellEnclosure on {}", c.kcl.future_cell_name or c.name)
+        logger.debug("Starting KCellEnclosure on {}", c.kcl._future_cell_name or c.name)
 
         n_enc = len(self.enclosures.enclosures)
 
@@ -1580,7 +1699,7 @@ def apply_minkowski_tiled(
                             "{}/{}: Queuing string for {} on layer {}: '{}'",
                             i + 1,
                             n_enc,
-                            c.kcl.future_cell_name or c.name,
+                            c.kcl._future_cell_name or c.name,
                             layer,
                             queue_str,
                         )
@@ -1589,7 +1708,7 @@ def apply_minkowski_tiled(
         c.kcl.start_changes()
         logger.debug(
             "Starting enclosure {}",
-            c.kcl.future_cell_name or c.name,
+            c.kcl._future_cell_name or c.name,
             enc.name,
         )
         tp.execute(f"Minkowski {c.name}")
@@ -1603,7 +1722,7 @@ def apply_minkowski_tiled(
         else:
             for operator in layer_regiontilesoperators.values():
                 operator.insert()
-        logger.debug("Finished KCellEnclosure on {}", c.kcl.future_cell_name or c.name)
+        logger.debug("Finished KCellEnclosure on {}", c.kcl._future_cell_name or c.name)
 
 
 class LayerEnclosureModel(RootModel[dict[str, LayerEnclosure]]):
@@ -1627,13 +1746,26 @@ def __setitem__(self, __key: str, /, __val: LayerEnclosure) -> None:
         """Add a new LayerEnclosure."""
         self.root[__key] = __val
 
+    def _canonical_for_unnamed_key(self, unnamed_key: str) -> LayerEnclosure | None:
+        """Return the registered enclosure whose structural signature matches."""
+        for enc in self.root.values():
+            if enc.unnamed_key == unnamed_key:
+                return enc
+        return None
+
     def get_enclosure(
         self,
         enclosure: str | LayerEnclosure | LayerEnclosureSpec,
         kcl: KCLayout,
     ) -> LayerEnclosure:
         if isinstance(enclosure, str):
-            return self[enclosure]
+            if enclosure in self.root:
+                return self.root[enclosure]
+            # Also addressable by the structural (unnamed) key.
+            existing = self._canonical_for_unnamed_key(enclosure)
+            if existing is not None:
+                return existing
+            return self.root[enclosure]
         if isinstance(enclosure, dict):
             if "dsections" in enclosure:
                 enclosure = LayerEnclosure(
@@ -1649,11 +1781,36 @@ def get_enclosure(
                     main_layer=enclosure["main_layer"],
                     kcl=kcl,
                 )
-
-        if enclosure.name not in self.root:
+        enclosure = cast("LayerEnclosure", enclosure)
+        key = enclosure.unnamed_key
+        existing = self._canonical_for_unnamed_key(key)
+
+        if not enclosure.is_named:
+            # Unnamed request: reuse the canonical entry if the signature is known.
+            if existing is not None:
+                return existing
             self.root[enclosure.name] = enclosure
             return enclosure
-        return self.root[enclosure.name]
+
+        # Named request.
+        name = enclosure.name
+        if name in self.root:
+            # The name is already claimed: preserve the existing (lenient) name-keyed
+            # behavior and return the registered enclosure.
+            return self.root[name]
+        if existing is None:
+            self.root[name] = enclosure
+            return enclosure
+        if existing.is_named:
+            raise CrossSectionNamingConflictError(
+                f"Cannot register enclosure {name!r}: an enclosure with the same "
+                f"structural signature is already registered as {existing.name!r}. "
+                "A structure can have at most one name."
+            )
+        # Promote: the named enclosure replaces the existing unnamed one.
+        self.root.pop(existing.name, None)
+        self.root[name] = enclosure
+        return enclosure
 
 
 def _add_section(
@@ -1673,6 +1830,12 @@ def _add_section(
         layer_sections[layer] = LayerSection(sections=[section])
 
 
+def _point_list_guard(
+    shape: list[kdb.Point] | kdb.Box | kdb.Edge | kdb.Polygon,
+) -> TypeGuard[list[kdb.Point]]:
+    return isinstance(shape, list)
+
+
 LayerEnclosureModel.model_rebuild()
 LayerSection.model_rebuild()
 LayerEnclosure.model_rebuild()
diff --git a/src/kfactory/exceptions.py b/src/kfactory/exceptions.py
index 18da93e5d..08b864e39 100644
--- a/src/kfactory/exceptions.py
+++ b/src/kfactory/exceptions.py
@@ -10,7 +10,12 @@
     from .port import ProtoPort
 
 __all__ = [
+    "AsymmetricMirrorRequiredError",
     "CellNameError",
+    "CrossSectionNamingConflictError",
+    "CrossSectionSymmetryMismatchError",
+    "DuplicateCellNameError",
+    "FactoriesLockedError",
     "InvalidLayerError",
     "LockedError",
     "MergeError",
@@ -33,10 +38,24 @@ def __init__(self, kcell: AnyKCell | BaseKCell) -> None:
         )
 
 
+class FactoriesLockedError(RuntimeError):
+    """Raised when trying to add a factory to a locked Factories collection."""
+
+
 class MergeError(ValueError):
     """Raised if two layout's have conflicting cell definitions."""
 
 
+class CrossSectionNamingConflictError(ValueError):
+    """Raised when a second name is registered for an existing structural signature.
+
+    Cross sections and enclosures are canonicalized by their name-independent
+    structural signature. A given signature may have at most one *named* canonical
+    entry; attempting to register a second, differently-named entry for the same
+    signature (or to reuse a name for a different signature) raises this error.
+    """
+
+
 class PortWidthMismatchError(ValueError):
     """Error thrown when two ports don't have a matching `width`."""
 
@@ -135,9 +154,63 @@ def __init__(
             )
 
 
+class AsymmetricMirrorRequiredError(ValueError):
+    """Raised when connecting two asymmetric ports without `mirror=True`.
+
+    Two ports carrying the same `AsymmetricalCrossSection` can only be
+    connected via an M90 (mirror) transformation, since R180 would flip the
+    left/right halves of the profile. Pass `mirror=True` to `connect`.
+    """
+
+    def __init__(
+        self,
+        p1: ProtoPort[Any],
+        p2: ProtoPort[Any],
+        *args: Any,
+    ) -> None:
+        super().__init__(
+            f"Cannot connect ports {p1.name!r} and {p2.name!r} carrying the same"
+            " asymmetric cross section without `mirror=True`. Asymmetric profiles"
+            " require an M90 transformation (mirror) — R180 would flip the"
+            " left/right halves. Pass `mirror=True` to `connect`.",
+            *args,
+        )
+
+
+class CrossSectionSymmetryMismatchError(ValueError):
+    """Raised when connecting ports whose cross sections differ in symmetry.
+
+    A symmetric and an asymmetric cross section cannot be connected even with
+    `allow_width_mismatch`, since they are structurally different objects.
+    """
+
+    def __init__(
+        self,
+        p1: ProtoPort[Any],
+        p2: ProtoPort[Any],
+        *args: Any,
+    ) -> None:
+        kind1 = "symmetric" if p1.base.is_symmetric() else "asymmetric"
+        kind2 = "symmetric" if p2.base.is_symmetric() else "asymmetric"
+        super().__init__(
+            f"Cross section symmetry mismatch between ports {p1.name!r} ({kind1})"
+            f" and {p2.name!r} ({kind2}). Symmetric and asymmetric cross sections"
+            " cannot be connected.",
+            *args,
+        )
+
+
 class CellNameError(ValueError):
     """Raised if a KCell is created and the automatic assigned name is taken."""
 
 
+class DuplicateCellNameError(ValueError):
+    """Raised when writing a layout with multiple cells sharing the same name.
+
+    GDS/OASIS formats require unique cell names. This error provides details
+    about which names are duplicated and which cells are involved.
+    """
+
+
 class InvalidLayerError(ValueError):
     """Raised when a layer is not valid."""
diff --git a/src/kfactory/factories/bezier.py b/src/kfactory/factories/bezier.py
index d019f367f..c6aeb41ee 100644
--- a/src/kfactory/factories/bezier.py
+++ b/src/kfactory/factories/bezier.py
@@ -5,20 +5,29 @@
 
 import numpy as np
 import numpy.typing as npt
-from scipy.special import binom  # type:ignore[import-untyped,unused-ignore]
+from scipy.special import binom
 
 from .. import kdb
-from ..enclosure import LayerEnclosure
+from ..cross_section import (
+    AnyCrossSectionInput,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+)
+from ..enclosure import LayerEnclosure, extrude_path_cross_section
 from ..kcell import KCell
 from ..layout import CellKWargs, KCLayout
 from ..port import rename_by_direction, rename_clockwise
 from ..settings import Info
 from ..typings import KC, KC_co, MetaData, um
+from .utils import (
+    _is_additional_info_func,
+    boundary_from_shapes,
+    cross_section_from_width,
+)
 
 if TYPE_CHECKING:
     from collections.abc import Callable
 
-    from ..enclosure import LayerEnclosure
     from ..kcell import KCell
 
 __all__ = ["bend_s_bezier_factory"]
@@ -27,26 +36,35 @@
 class BezierFactory(Protocol[KC_co]):
     def __call__(
         self,
-        width: um,
+        *,
         height: um,
         length: um,
-        layer: kdb.LayerInfo,
         nb_points: int = 99,
         t_start: float = 0,
         t_stop: float = 1,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
         enclosure: LayerEnclosure | None = None,
     ) -> KC_co:
-        """Creat a bezier bend.
+        """Create a bezier bend.
+
+        Either pass a ``cross_section`` or the legacy ``width``/``layer``/``enclosure``.
 
         Args:
-            width: Width of the core. [um]
             height: height difference of left/right. [um]
             length: Length of the bend. [um]
-            layer: Layer index of the core.
             nb_points: Number of points of the backbone.
             t_start: start
             t_stop: end
-            enclosure: Slab/Exclude definition. [dbu]
+            cross_section: Cross section of the bend.
+            width: Width of the core. [um] (legacy; requires ``layer``)
+            layer: Layer index of the core. (legacy)
+            enclosure: Slab/Exclude definition. (legacy)
         """
         ...
 
@@ -108,24 +126,22 @@ def bend_s_bezier_factory(
     port_type: str = "optical",
     **cell_kwargs: Unpack[CellKWargs],
 ) -> BezierFactory[KC]:
-    """Returns a function generating bezier s-bends.
+    """Returns a function generating bezier s-bends (generic interface).
 
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
             [`KCell.info`][kfactory.settings.Info]. Can be a static dict
-            mapping info name to info value. Or can a callable which takes the straight
+            mapping info name to info value. Or can a callable which takes the bend
             functions' parameters as kwargs and returns a dict with the mapping.
-        basename: Overwrite the prefix of the resulting KCell's name. By default
-            the KCell will be named 'straight_dbu[...]'.
         cell_kwargs: Additional arguments passed as `@kcl.cell(**cell_kwargs)`.
     """
-    if callable(additional_info) and additional_info is not None:
+    _additional_info: dict[str, MetaData] = {}
+    if _is_additional_info_func(additional_info):
         _additional_info_func: Callable[
             ...,
             dict[str, MetaData],
         ] = additional_info
-        _additional_info: dict[str, MetaData] = {}
     else:
 
         def additional_info_func(
@@ -134,7 +150,7 @@ def additional_info_func(
             return {}
 
         _additional_info_func = additional_info_func
-        _additional_info = additional_info or {}
+        _additional_info = additional_info or {}  # ty:ignore[invalid-assignment]
 
     ports = cell_kwargs.get("ports")
     if ports is None:
@@ -142,6 +158,8 @@ def additional_info_func(
             cell_kwargs["ports"] = {"left": ["o1"], "right": ["o2"]}
         elif kcl.rename_function == rename_by_direction:
             cell_kwargs["ports"] = {"left": ["W0"], "right": ["E0"]}
+    cell_kwargs.setdefault("basename", "bend_s_bezier")
+    basename = cell_kwargs["basename"]
 
     if output_type is not None:
         cell = kcl.cell(output_type=output_type, **cell_kwargs)
@@ -149,29 +167,17 @@ def additional_info_func(
         cell = kcl.cell(output_type=cast("type[KC]", KCell), **cell_kwargs)
 
     @cell
-    def bend_s_bezier(
-        width: um,
+    def _bend_s_bezier(
+        cross_section: str | AnyCrossSectionInput,
         height: um,
         length: um,
-        layer: kdb.LayerInfo,
         nb_points: int = 99,
         t_start: float = 0,
         t_stop: float = 1,
-        enclosure: LayerEnclosure | None = None,
     ) -> KCell:
-        """Creat a bezier bend.
-
-        Args:
-            width: Width of the core. [um]
-            height: height difference of left/right. [um]
-            length: Length of the bend. [um]
-            layer: Layer index of the core.
-            nb_points: Number of points of the backbone.
-            t_start: start
-            t_stop: end
-            enclosure: Slab/Exclude definition. [dbu]
-        """
+        """Bezier bend [um] from a cross section."""
         c = kcl.kcell()
+        xs = kcl.get_base_cross_section(cross_section)
         _length, _height = length, height
         pts = bezier_curve(
             control_points=[
@@ -183,36 +189,32 @@ def bend_s_bezier(
             t=np.linspace(t_start, t_stop, nb_points),
         )
 
-        if enclosure is None:
-            enclosure = LayerEnclosure()
-
-        enclosure.extrude_path(
-            c, path=pts, main_layer=layer, width=width, start_angle=0, end_angle=0
-        )
+        extrude_path_cross_section(c, pts, xs, start_angle=0, end_angle=0)
 
         c.create_port(
-            width=int(width / c.kcl.dbu),
+            name="o1",
+            cross_section=xs,
             trans=kdb.Trans(2, False, 0, 0),
-            layer=c.kcl.layer(layer),
             port_type=port_type,
         )
         c.create_port(
-            width=int(width / c.kcl.dbu),
+            name="o2",
+            cross_section=xs,
             trans=kdb.Trans(0, False, c.bbox().right, kcl.to_dbu(height)),
-            layer=c.kcl.layer(layer),
             port_type=port_type,
         )
+        boundary = boundary_from_shapes(c)
+        if boundary is not None:
+            c.boundary = boundary
         _info: dict[str, MetaData] = {}
         _info.update(
             _additional_info_func(
-                width=width,
+                cross_section=xs,
                 height=height,
                 length=length,
-                layer=layer,
                 nb_points=nb_points,
                 t_start=t_start,
                 t_stop=t_stop,
-                enclosure=enclosure,
             )
         )
         _info.update(_additional_info)
@@ -222,4 +224,38 @@ def bend_s_bezier(
 
         return c
 
+    @kcl.generic_factory(name=basename)
+    def bend_s_bezier(
+        *,
+        height: um,
+        length: um,
+        nb_points: int = 99,
+        t_start: float = 0,
+        t_stop: float = 1,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> KC:
+        if cross_section is None:
+            if width is None or layer is None:
+                raise ValueError(
+                    "Provide a cross_section, or width and layer (legacy call)."
+                )
+            xs = cross_section_from_width(kcl, kcl.to_dbu(width), layer, enclosure)
+        else:
+            xs = kcl.get_icross_section(cross_section)
+        return _bend_s_bezier(
+            cross_section=xs,
+            height=height,
+            length=length,
+            nb_points=nb_points,
+            t_start=t_start,
+            t_stop=t_stop,
+        )
+
     return bend_s_bezier
diff --git a/src/kfactory/factories/circular.py b/src/kfactory/factories/circular.py
index d496a32cd..f0381b276 100644
--- a/src/kfactory/factories/circular.py
+++ b/src/kfactory/factories/circular.py
@@ -10,16 +10,25 @@
 
 from .. import kdb
 from ..conf import logger
-from ..enclosure import LayerEnclosure, extrude_path
+from ..cross_section import (
+    AnyCrossSectionInput,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+)
+from ..enclosure import LayerEnclosure, extrude_path_cross_section
 from ..kcell import KCell
 from ..layout import CellKWargs, KCLayout
 from ..settings import Info
 from ..typings import KC, KC_co, MetaData, deg, um
+from .utils import (
+    _is_additional_info_func,
+    boundary_from_shapes,
+    cross_section_from_width,
+)
 
 if TYPE_CHECKING:
     from collections.abc import Callable
 
-    from ..enclosure import LayerEnclosure
     from ..kcell import KCell
 
 __all__ = ["bend_circular_factory"]
@@ -28,22 +37,32 @@
 class BendCircularFactory(Protocol[KC_co]):
     def __call__(
         self,
-        width: um,
+        *,
         radius: um,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
         angle: deg = 90,
         angle_step: deg = 1,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
     ) -> KC_co:
         """Circular radius bend [um].
 
+        Either pass a ``cross_section`` (name, spec, or instance) or the legacy
+        ``width``/``layer``/``enclosure`` which is normalized into a cross section.
+
         Args:
-            width: Width of the core. [um]
             radius: Radius of the backbone. [um]
-            layer: Layer index of the target layer.
-            enclosure: Optional enclosure.
             angle: Angle amount of the bend.
             angle_step: Angle amount per backbone point of the bend.
+            cross_section: Cross section of the bend.
+            width: Width of the core. [um] (legacy; requires ``layer``)
+            layer: Main layer of the bend. (legacy)
+            enclosure: Optional enclosure. (legacy)
         """
         ...
 
@@ -92,23 +111,24 @@ def bend_circular_factory(
 ) -> BendCircularFactory[KC]:
     """Returns a function generating circular bends.
 
+    The returned function is the generic interface: it accepts either a
+    ``cross_section`` or the legacy ``width``/``layer``/``enclosure`` (normalized into a
+    symmetric cross section). Will snap ports by default.
+
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
             [`KCell.info`][kfactory.settings.Info]. Can be a static dict
-            mapping info name to info value. Or can a callable which takes the straight
+            mapping info name to info value. Or can a callable which takes the bend
             functions' parameters as kwargs and returns a dict with the mapping.
-        basename: Overwrite the prefix of the resulting KCell's name. By default
-            the KCell will be named 'straight_dbu[...]'.
-        snap_ports: Whether to snap ports to grid.
         cell_kwargs: Additional arguments passed as `@kcl.cell(**cell_kwargs)`.
     """
-    if callable(additional_info):
+    _additional_info: dict[str, MetaData] = {}
+    if _is_additional_info_func(additional_info):
         _additional_info_func: Callable[
             ...,
             dict[str, MetaData],
         ] = additional_info
-        _additional_info: dict[str, MetaData] = {}
     else:
 
         def additional_info_func(
@@ -117,10 +137,12 @@ def additional_info_func(
             return {}
 
         _additional_info_func = additional_info_func
-        _additional_info = additional_info or {}
+        _additional_info = additional_info or {}  # ty:ignore[invalid-assignment]
 
     if cell_kwargs.get("snap_ports") is None:
         cell_kwargs["snap_ports"] = False
+    cell_kwargs.setdefault("basename", "bend_circular")
+    basename = cell_kwargs["basename"]
 
     if output_type is not None:
         cell = kcl.cell(output_type=output_type, **cell_kwargs)
@@ -128,24 +150,13 @@ def additional_info_func(
         cell = kcl.cell(output_type=cast("type[KC]", KCell), **cell_kwargs)
 
     @cell
-    def bend_circular(
-        width: um,
+    def _bend_circular(
+        cross_section: str | AnyCrossSectionInput,
         radius: um,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
         angle: deg = 90,
         angle_step: deg = 1,
     ) -> KCell:
-        """Circular radius bend [um].
-
-        Args:
-            width: Width of the core. [um]
-            radius: Radius of the backbone. [um]
-            layer: Layer index of the target layer.
-            enclosure: Optional enclosure.
-            angle: Angle amount of the bend.
-            angle_step: Angle amount per backbone point of the bend.
-        """
+        """Circular radius bend [um] from a cross section."""
         c = kcl.kcell()
         r = radius
 
@@ -156,13 +167,8 @@ def bend_circular(
                 " lengths."
             )
             angle = -angle
-        if width < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
-            )
-            width = -width
+
+        xs = kcl.get_base_cross_section(cross_section)
 
         backbone = [
             kdb.DPoint(x, y)
@@ -177,37 +183,29 @@ def bend_circular(
             ]
         ]
 
-        center_path = extrude_path(
-            target=c,
-            layer=layer,
-            path=backbone,
-            width=width,
-            enclosure=enclosure,
-            start_angle=0,
-            end_angle=angle,
-        )
+        extrude_path_cross_section(c, backbone, xs, start_angle=0, end_angle=angle)
 
         c.create_port(
+            name="o1",
             trans=kdb.Trans(2, False, 0, 0),
-            width=int(width / c.kcl.dbu),
-            layer=c.kcl.layer(layer),
+            cross_section=xs,
             port_type=port_type,
         )
         c.create_port(
+            name="o2",
             dcplx_trans=kdb.DCplxTrans(1, angle, False, backbone[-1].to_v()),
-            width=c.kcl.to_dbu(width),
-            layer=c.kcl.layer(layer),
+            cross_section=xs,
             port_type=port_type,
         )
         c.auto_rename_ports()
-        c.boundary = center_path
+        boundary = boundary_from_shapes(c)
+        if boundary is not None:
+            c.boundary = boundary
         _info: dict[str, MetaData] = {}
         _info.update(
             _additional_info_func(
-                width=width,
+                cross_section=xs,
                 radius=radius,
-                layer=layer,
-                enclosure=enclosure,
                 angle=angle,
                 angle_step=angle_step,
             )
@@ -216,4 +214,31 @@ def bend_circular(
         c.info = Info(**_info)
         return c
 
+    @kcl.generic_factory(name=basename)
+    def bend_circular(
+        *,
+        radius: um,
+        angle: deg = 90,
+        angle_step: deg = 1,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> KC:
+        if cross_section is None:
+            if width is None or layer is None:
+                raise ValueError(
+                    "Provide a cross_section, or width and layer (legacy call)."
+                )
+            xs = cross_section_from_width(kcl, kcl.to_dbu(width), layer, enclosure)
+        else:
+            xs = kcl.get_icross_section(cross_section)
+        return _bend_circular(
+            cross_section=xs, radius=radius, angle=angle, angle_step=angle_step
+        )
+
     return bend_circular
diff --git a/src/kfactory/factories/euler.py b/src/kfactory/factories/euler.py
index a2782fe9d..a7411d2b4 100644
--- a/src/kfactory/factories/euler.py
+++ b/src/kfactory/factories/euler.py
@@ -13,16 +13,26 @@
 from typing import Any, Protocol, Unpack, cast, overload
 
 import numpy as np
-from scipy.optimize import brentq  # type:ignore[import-untyped,unused-ignore]
-from scipy.special import fresnel  # type:ignore[import-untyped,unused-ignore]
+from scipy.optimize import brentq
+from scipy.special import fresnel
 
 from .. import kdb
 from ..conf import logger
-from ..enclosure import LayerEnclosure, extrude_path
+from ..cross_section import (
+    AnyCrossSectionInput,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+)
+from ..enclosure import LayerEnclosure, extrude_path_cross_section
 from ..kcell import KCell
 from ..layout import CellKWargs, KCLayout
 from ..settings import Info
 from ..typings import KC, KC_co, MetaData, deg, um
+from .utils import (
+    _is_additional_info_func,
+    boundary_from_shapes,
+    cross_section_from_width,
+)
 
 __all__ = [
     "bend_euler_factory",
@@ -35,22 +45,31 @@
 class BendEulerFactory(Protocol[KC_co]):
     def __call__(
         self,
-        width: um,
+        *,
         radius: um,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
         angle: deg = 90,
         resolution: float = 150,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
     ) -> KC_co:
         """Create a euler bend.
 
+        Either pass a ``cross_section`` or the legacy ``width``/``layer``/``enclosure``.
+
         Args:
-            width: Width of the core. [um]
             radius: Radius off the backbone. [um]
-            layer: Layer index / LayerEnum of the core.
-            enclosure: Slab/exclude definition. [dbu]
             angle: Angle of the bend.
             resolution: Angle resolution for the backbone.
+            cross_section: Cross section of the bend.
+            width: Width of the core. [um] (legacy; requires ``layer``)
+            layer: Main layer of the bend. (legacy)
+            enclosure: Slab/exclude definition. (legacy)
         """
         ...
 
@@ -58,22 +77,31 @@ def __call__(
 class BendSEulerFactory(Protocol[KC_co]):
     def __call__(
         self,
+        *,
         offset: um,
-        width: um,
         radius: um,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
         resolution: float = 150,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
     ) -> KC_co:
         """Create a euler s-bend.
 
+        Either pass a ``cross_section`` or the legacy ``width``/``layer``/``enclosure``.
+
         Args:
             offset: Offset between left/right. [um]
-            width: Width of the core. [um]
             radius: Radius off the backbone. [um]
-            layer: Layer index / LayerEnum of the core.
-            enclosure: Slab/exclude definition. [dbu]
             resolution: Angle resolution for the backbone.
+            cross_section: Cross section of the bend.
+            width: Width of the core. [um] (legacy; requires ``layer``)
+            layer: Main layer of the bend. (legacy)
+            enclosure: Slab/exclude definition. (legacy)
         """
         ...
 
@@ -249,25 +277,23 @@ def bend_euler_factory(
 ) -> BendEulerFactory[KC]:
     """Returns a function generating euler bends.
 
+    The returned function is the generic interface (``cross_section`` or the legacy
+    ``width``/``layer``/``enclosure``). Will snap ports by default.
+
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
             [`KCell.info`][kfactory.settings.Info]. Can be a static dict
-            mapping info name to info value. Or can a callable which takes the straight
+            mapping info name to info value. Or can a callable which takes the bend
             functions' parameters as kwargs and returns a dict with the mapping.
-        basename: Overwrite the prefix of the resulting KCell's name. By default
-            the KCell will be named 'straight_dbu[...]'.
-        snap_ports: Whether to snap ports to grid. If snapping is turned of, ports
-            ports are still snapped to grid if they are within 0.0001° of multiples
-            of 90°.
         cell_kwargs: Additional arguments passed as `@kcl.cell(**cell_kwargs)`.
     """
-    if callable(additional_info) and additional_info is not None:
+    _additional_info: dict[str, MetaData] = {}
+    if _is_additional_info_func(additional_info):
         _additional_info_func: Callable[
             ...,
             dict[str, MetaData],
         ] = additional_info
-        _additional_info: dict[str, MetaData] = {}
     else:
 
         def additional_info_func(
@@ -276,9 +302,11 @@ def additional_info_func(
             return {}
 
         _additional_info_func = additional_info_func
-        _additional_info = additional_info or {}
+        _additional_info = additional_info or {}  # ty:ignore[invalid-assignment]
     if cell_kwargs.get("snap_ports") is None:
         cell_kwargs["snap_ports"] = False
+    cell_kwargs.setdefault("basename", "bend_euler")
+    basename = cell_kwargs["basename"]
 
     if output_type is not None:
         cell = kcl.cell(output_type=output_type, **cell_kwargs)
@@ -286,24 +314,13 @@ def additional_info_func(
         cell = kcl.cell(output_type=cast("type[KC]", KCell), **cell_kwargs)
 
     @cell
-    def bend_euler(
-        width: um,
+    def _bend_euler(
+        cross_section: str | AnyCrossSectionInput,
         radius: um,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
         angle: deg = 90,
         resolution: float = 150,
     ) -> KCell:
-        """Create a euler bend.
-
-        Args:
-            width: Width of the core. [um]
-            radius: Radius off the backbone. [um]
-            layer: Layer index / LayerEnum of the core.
-            enclosure: Slab/exclude definition. [dbu]
-            angle: Angle of the bend.
-            resolution: Angle resolution for the backbone.
-        """
+        """Euler bend [um] from a cross section."""
         c = kcl.kcell()
         if angle < 0:
             logger.critical(
@@ -312,64 +329,79 @@ def bend_euler(
                 " lengths."
             )
             angle = -angle
-        if width < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
-            )
-            width = -width
+
+        xs = kcl.get_base_cross_section(cross_section)
         backbone = euler_bend_points(angle, radius=radius, resolution=resolution)
 
-        center_path = extrude_path(
-            target=c,
-            layer=layer,
-            path=backbone,
-            width=width,
-            enclosure=enclosure,
-            start_angle=0,
-            end_angle=angle,
-        )
-        li = c.kcl.layer(layer)
+        extrude_path_cross_section(c, backbone, xs, start_angle=0, end_angle=angle)
+
         c.create_port(
-            layer=li,
-            width=c.kcl.to_dbu(width),
+            name="o1",
+            cross_section=xs,
             trans=kdb.Trans(2, False, c.kcl.to_dbu(backbone[0]).to_v()),
+            port_type=port_type,
         )
 
-        if abs(angle % 90) < 0.001:  # noqa: PLR2004
+        if abs(angle % 90) < 0.001:
             _ang = round(angle)
             c.create_port(
+                name="o2",
                 trans=kdb.Trans(_ang // 90, False, c.kcl.to_dbu(backbone[-1]).to_v()),
-                width=round(width / c.kcl.dbu),
-                layer=li,
+                cross_section=xs,
                 port_type=port_type,
             )
         else:
             c.create_port(
+                name="o2",
                 dcplx_trans=kdb.DCplxTrans(1, angle, False, backbone[-1].to_v()),
-                width=c.kcl.to_dbu(width),
-                layer=li,
+                cross_section=xs,
                 port_type=port_type,
             )
         _info: dict[str, MetaData] = {}
         _info.update(
             _additional_info_func(
-                width=width,
+                cross_section=xs,
                 radius=radius,
-                layer=li,
-                enclosure=enclosure,
                 angle=angle,
                 resolution=resolution,
             )
         )
         _info.update(_additional_info)
         c.info = Info(**_info)
-        c.boundary = center_path
+        boundary = boundary_from_shapes(c)
+        if boundary is not None:
+            c.boundary = boundary
 
         c.auto_rename_ports()
         return c
 
+    @kcl.generic_factory(name=basename)
+    def bend_euler(
+        *,
+        radius: um,
+        angle: deg = 90,
+        resolution: float = 150,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> KC:
+        if cross_section is None:
+            if width is None or layer is None:
+                raise ValueError(
+                    "Provide a cross_section, or width and layer (legacy call)."
+                )
+            xs = cross_section_from_width(kcl, kcl.to_dbu(width), layer, enclosure)
+        else:
+            xs = kcl.get_icross_section(cross_section)
+        return _bend_euler(
+            cross_section=xs, radius=radius, angle=angle, resolution=resolution
+        )
+
     return bend_euler
 
 
@@ -414,24 +446,22 @@ def bend_s_euler_factory(
     port_type: str = "optical",
     **cell_kwargs: Unpack[CellKWargs],
 ) -> BendSEulerFactory[KC]:
-    """Returns a function generating euler s-bends.
+    """Returns a function generating euler s-bends (generic interface).
 
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
             [`KCell.info`][kfactory.settings.Info]. Can be a static dict
-            mapping info name to info value. Or can a callable which takes the straight
+            mapping info name to info value. Or can a callable which takes the bend
             functions' parameters as kwargs and returns a dict with the mapping.
-        basename: Overwrite the prefix of the resulting KCell's name. By default
-            the KCell will be named 'straight_dbu[...]'.
         cell_kwargs: Additional arguments passed as `@kcl.cell(**cell_kwargs)`.
     """
-    if callable(additional_info):
+    _additional_info: dict[str, MetaData] = {}
+    if _is_additional_info_func(additional_info):
         _additional_info_func: Callable[
             ...,
             dict[str, MetaData],
         ] = additional_info
-        _additional_info: dict[str, MetaData] = {}
     else:
 
         def additional_info_func(
@@ -440,53 +470,30 @@ def additional_info_func(
             return {}
 
         _additional_info_func = additional_info_func
-        _additional_info = additional_info or {}
+        _additional_info = additional_info or {}  # ty:ignore[invalid-assignment]
+    cell_kwargs.setdefault("basename", "bend_s_euler")
+    basename = cell_kwargs["basename"]
     if output_type is not None:
         cell = kcl.cell(output_type=output_type, **cell_kwargs)
     else:
         cell = kcl.cell(output_type=cast("type[KC]", KCell), **cell_kwargs)
 
     @cell
-    def bend_s_euler(
+    def _bend_s_euler(
+        cross_section: str | AnyCrossSectionInput,
         offset: um,
-        width: um,
         radius: um,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
         resolution: float = 150,
     ) -> KCell:
-        """Create a euler s-bend.
-
-        Args:
-            offset: Offset between left/right. [um]
-            width: Width of the core. [um]
-            radius: Radius off the backbone. [um]
-            layer: Layer index / LayerEnum of the core.
-            enclosure: Slab/exclude definition. [dbu]
-            resolution: Angle resolution for the backbone.
-        """
+        """Euler s-bend [um] from a cross section."""
         c = kcl.kcell()
-        if width < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
-            )
-            width = -width
+        xs = kcl.get_base_cross_section(cross_section)
         backbone = euler_sbend_points(
             offset=offset,
             radius=radius,
             resolution=resolution,
         )
-        center_path = extrude_path(
-            target=c,
-            layer=layer,
-            path=backbone,
-            width=width,
-            enclosure=enclosure,
-            start_angle=0,
-            end_angle=0,
-        )
+        extrude_path_cross_section(c, backbone, xs, start_angle=0, end_angle=0)
 
         v = backbone[-1] - backbone[0]
         if v.x < 0:
@@ -495,28 +502,27 @@ def bend_s_euler(
         else:
             p1 = c.kcl.to_dbu(backbone[0])
             p2 = c.kcl.to_dbu(backbone[-1])
-        li = c.kcl.layer(layer)
         c.create_port(
+            name="o1",
             trans=kdb.Trans(2, False, p1.to_v()),
-            width=c.kcl.to_dbu(width),
+            cross_section=xs,
             port_type=port_type,
-            layer=li,
         )
         c.create_port(
+            name="o2",
             trans=kdb.Trans(0, False, p2.to_v()),
-            width=c.kcl.to_dbu(width),
+            cross_section=xs,
             port_type=port_type,
-            layer=li,
         )
-        c.boundary = center_path
+        boundary = boundary_from_shapes(c)
+        if boundary is not None:
+            c.boundary = boundary
         _info: dict[str, MetaData] = {}
         _info.update(
             _additional_info_func(
+                cross_section=xs,
                 offset=offset,
-                width=width,
                 radius=radius,
-                layer=layer,
-                enclosure=enclosure,
                 resolution=resolution,
             )
         )
@@ -526,4 +532,31 @@ def bend_s_euler(
         c.auto_rename_ports()
         return c
 
+    @kcl.generic_factory(name=basename)
+    def bend_s_euler(
+        *,
+        offset: um,
+        radius: um,
+        resolution: float = 150,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> KC:
+        if cross_section is None:
+            if width is None or layer is None:
+                raise ValueError(
+                    "Provide a cross_section, or width and layer (legacy call)."
+                )
+            xs = cross_section_from_width(kcl, kcl.to_dbu(width), layer, enclosure)
+        else:
+            xs = kcl.get_icross_section(cross_section)
+        return _bend_s_euler(
+            cross_section=xs, offset=offset, radius=radius, resolution=resolution
+        )
+
     return bend_s_euler
diff --git a/src/kfactory/factories/straight.py b/src/kfactory/factories/straight.py
index 4cf4f37c4..a1d481616 100644
--- a/src/kfactory/factories/straight.py
+++ b/src/kfactory/factories/straight.py
@@ -12,8 +12,7 @@
     │         Slab/Exclude         │
     └──────────────────────────────┘
 
-The slabs and excludes can be given in the form of an
-[Enclosure][kfactory.enclosure.LayerEnclosure].
+The slabs and excludes are part of the cross section the waveguide is built from.
 """
 
 from collections.abc import Callable
@@ -21,13 +20,21 @@
 
 from .. import kdb
 from ..conf import logger
-from ..decorators import PortsDefinition
-from ..enclosure import LayerEnclosure
+from ..cross_section import (
+    AnyCrossSectionInput,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+)
+from ..enclosure import LayerEnclosure, extrude_path_cross_section
 from ..kcell import KCell
 from ..layout import CellKWargs, KCLayout
 from ..port import rename_by_direction, rename_clockwise
 from ..settings import Info
 from ..typings import KC, KC_co, MetaData, dbu
+from .utils import (
+    _is_additional_info_func,
+    cross_section_from_width,
+)
 
 __all__ = ["straight_dbu_factory"]
 
@@ -37,34 +44,33 @@ class StraightFactory(Protocol[KC_co]):
 
     def __call__(
         self,
-        width: dbu,
+        *,
         length: dbu,
-        layer: kdb.LayerInfo,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: dbu | None = None,
+        layer: kdb.LayerInfo | None = None,
         enclosure: LayerEnclosure | None = None,
     ) -> KC_co:
-        """Waveguide defined in dbu.
-
-            ┌──────────────────────────────┐
-            │         Slab/Exclude         │
-            ├──────────────────────────────┤
-            │                              │
-            │             Core             │
-            │                              │
-            ├──────────────────────────────┤
-            │         Slab/Exclude         │
-            └──────────────────────────────┘
+        """Waveguide defined by a cross section, length in dbu.
+
+        Either pass a ``cross_section`` (name, spec, or instance) or the legacy
+        ``width``/``layer``/``enclosure`` (all dbu) which is normalized into a
+        cross section.
+
         Args:
-            width: Waveguide width. [dbu]
             length: Waveguide length. [dbu]
-            layer: Main layer of the waveguide.
-            enclosure: Definition of slab/excludes. [dbu]
+            cross_section: Cross section of the waveguide.
+            width: Waveguide width. [dbu] (legacy; requires ``layer``)
+            layer: Main layer of the waveguide. (legacy)
+            enclosure: Definition of slab/excludes. [dbu] (legacy)
         """
         ...
 
 
-_straight_default_ports = PortsDefinition(left=["o1"], right=["o2"])
-
-
 @overload
 def straight_dbu_factory(
     kcl: KCLayout,
@@ -106,37 +112,35 @@ def straight_dbu_factory(
     port_type: str = "optical",
     **cell_kwargs: Unpack[CellKWargs],
 ) -> StraightFactory[KC]:
-    """Returns a function generating straights [dbu].
-
-        ┌──────────────────────────────┐
-        │         Slab/Exclude         │
-        ├──────────────────────────────┤
-        │                              │
-        │             Core             │
-        │                              │
-        ├──────────────────────────────┤
-        │         Slab/Exclude         │
-        └──────────────────────────────┘
+    """Returns a function generating straights [dbu length].
+
+    The returned function is the generic interface: it accepts either a
+    ``cross_section`` or the legacy ``width``/``layer``/``enclosure`` (all dbu),
+    normalized into a symmetric cross section.
+
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
             [`KCell.info`][kfactory.settings.Info]. Can be a static dict
             mapping info name to info value. Or can a callable which takes the straight
             functions' parameters as kwargs and returns a dict with the mapping.
-        basename: Overwrite the prefix of the resulting KCell's name. By default
-            the KCell will be named 'straight_dbu[...]'.
         cell_kwargs: Additional arguments passed as `@kcl.cell(**cell_kwargs)`.
     """
-    if callable(additional_info):
-        _additional_info_func: Callable[..., dict[str, MetaData]] = additional_info
-        _additional_info: dict[str, MetaData] = {}
+    _additional_info: dict[str, MetaData] = {}
+    if _is_additional_info_func(additional_info):
+        _additional_info_func: Callable[
+            ...,
+            dict[str, MetaData],
+        ] = additional_info
     else:
 
-        def additional_info_func(**kwargs: Any) -> dict[str, MetaData]:
+        def additional_info_func(
+            **kwargs: Any,
+        ) -> dict[str, MetaData]:
             return {}
 
         _additional_info_func = additional_info_func
-        _additional_info = additional_info or {}
+        _additional_info = additional_info or {}  # ty:ignore[invalid-assignment]
 
     ports = cell_kwargs.get("ports")
     if ports is None:
@@ -144,6 +148,8 @@ def additional_info_func(**kwargs: Any) -> dict[str, MetaData]:
             cell_kwargs["ports"] = {"left": ["o1"], "right": ["o2"]}
         elif kcl.rename_function == rename_by_direction:
             cell_kwargs["ports"] = {"left": ["W0"], "right": ["E0"]}
+    cell_kwargs.setdefault("basename", "straight")
+    basename = cell_kwargs["basename"]
 
     if output_type is not None:
         cell = kcl.cell(output_type=output_type, **cell_kwargs)
@@ -151,29 +157,11 @@ def additional_info_func(**kwargs: Any) -> dict[str, MetaData]:
         cell = kcl.cell(output_type=cast("type[KC]", KCell), **cell_kwargs)
 
     @cell
-    def straight(
-        width: dbu,
+    def _straight(
+        cross_section: str | AnyCrossSectionInput,
         length: dbu,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
     ) -> KCell:
-        """Waveguide defined in dbu.
-
-            ┌──────────────────────────────┐
-            │         Slab/Exclude         │
-            ├──────────────────────────────┤
-            │                              │
-            │             Core             │
-            │                              │
-            ├──────────────────────────────┤
-            │         Slab/Exclude         │
-            └──────────────────────────────┘
-        Args:
-            width: Waveguide width. [dbu]
-            length: Waveguide length. [dbu]
-            layer: Main layer of the waveguide.
-            enclosure: Definition of slab/excludes. [dbu]
-        """
+        """Waveguide defined by a cross section."""
         c = kcl.kcell()
 
         if length < 0:
@@ -183,47 +171,61 @@ def straight(
                 " lengths."
             )
             length = -length
-        if width < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
-            )
-            width = -width
 
-        if width // 2 * 2 != width:
-            raise ValueError("The width (w) must be a multiple of 2 database units")
+        xs = kcl.get_base_cross_section(cross_section)
+
+        extrude_path_cross_section(
+            c, [kdb.DPoint(0.0, 0.0), kdb.DPoint(kcl.to_um(length), 0.0)], xs
+        )
 
-        li = c.kcl.layer(layer)
-        c.shapes(li).insert(kdb.Box(0, -width // 2, length, width // 2))
         c.create_port(
-            trans=kdb.Trans(2, False, 0, 0), layer=li, width=width, port_type=port_type
+            name="o1",
+            trans=kdb.Trans(2, False, 0, 0),
+            cross_section=xs,
+            port_type=port_type,
         )
         c.create_port(
+            name="o2",
             trans=kdb.Trans(0, False, length, 0),
-            layer=li,
-            width=width,
+            cross_section=xs,
             port_type=port_type,
         )
 
-        if enclosure is not None:
-            enclosure.apply_minkowski_y(c, layer)
         _info: dict[str, MetaData] = {
-            "width_um": width * c.kcl.dbu,
-            "length_um": length * c.kcl.dbu,
-            "width_dbu": width,
+            "width_um": kcl.to_um(xs.width),
+            "length_um": kcl.to_um(length),
+            "width_dbu": xs.width,
             "length_dbu": length,
         }
-        _info.update(
-            _additional_info_func(
-                width=width, length=length, layer=layer, enclosure=enclosure
-            )
-        )
+        _info.update(_additional_info_func(cross_section=xs, length=length))
         _info.update(_additional_info)
         c.info = Info(**_info)
 
-        c.boundary = c.dbbox()  # type: ignore[assignment]
+        c.boundary = kdb.DPolygon(c.dbbox())
         c.auto_rename_ports()
         return c
 
+    @kcl.generic_factory(name=basename)
+    def straight(
+        *,
+        length: dbu,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: dbu | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> KC:
+        if cross_section is None:
+            if width is None or layer is None:
+                raise ValueError(
+                    "Provide a cross_section, or width and layer (legacy call)."
+                )
+            xs = cross_section_from_width(kcl, width, layer, enclosure)
+        else:
+            xs = kcl.get_icross_section(cross_section)
+        return _straight(cross_section=xs, length=length)
+
     return straight
diff --git a/src/kfactory/factories/taper.py b/src/kfactory/factories/taper.py
index a7c969f74..ee48494c4 100644
--- a/src/kfactory/factories/taper.py
+++ b/src/kfactory/factories/taper.py
@@ -1,5 +1,24 @@
 """Taper definitions [dbu].
 
+A linear taper transitions between two cross sections (two core widths) over a
+given length::
+
+           __
+         _/  │ Slab/Exclude
+       _/  __│
+     _/  _/  │
+    │  _/    │
+    │_/      │
+    │_       │ Core
+    │ \\_     │
+    │_  \\_   │
+      \\_  \\__│
+        \\_   │
+          \\__│ Slab/Exclude
+
+The slabs and excludes are part of the cross sections the taper is built from, or
+can be given for the legacy ``(width1, width2, layer, enclosure)`` call.
+
 TODO: Non-linear tapers
 """
 
@@ -9,11 +28,18 @@
 
 from .. import kdb
 from ..conf import logger
+from ..cross_section import (
+    AnyCrossSectionInput,
+    AsymmetricalCrossSection,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+)
 from ..kcell import KCell
 from ..layout import CellKWargs, KCLayout  # noqa: TC001
 from ..port import rename_by_direction, rename_clockwise
 from ..settings import Info
 from ..typings import KC, KC_co, MetaData, dbu
+from .utils import _is_additional_info_func, cross_section_from_width
 
 if TYPE_CHECKING:
     from collections.abc import Callable
@@ -24,12 +50,25 @@
 
 
 class TaperFactory(Protocol[KC_co]):
+    __name__: str
+
     def __call__(
         self,
-        width1: dbu,
-        width2: dbu,
+        *,
         length: dbu,
-        layer: kdb.LayerInfo,
+        cross_section1: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        cross_section2: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width1: dbu | None = None,
+        width2: dbu | None = None,
+        layer: kdb.LayerInfo | None = None,
         enclosure: LayerEnclosure | None = None,
     ) -> KC_co:
         r"""Linear Taper [dbu].
@@ -47,12 +86,20 @@ def __call__(
                 \_   │
                   \__│ Slab/Exclude
 
+        Either pass two cross sections (``cross_section1``/``cross_section2``) or the
+        legacy ``width1``/``width2``/``layer``/``enclosure`` (all dbu) which is
+        normalized into a pair of symmetric cross sections.
+
         Args:
-            width1: Width of the core on the left side. [dbu]
-            width2: Width of the core on the right side. [dbu]
             length: Length of the taper. [dbu]
-            layer: Main layer of the taper.
-            enclosure: Definition of the slab/exclude.
+            cross_section1: Cross section of the left side.
+            cross_section2: Cross section of the right side.
+            width1: Width of the core on the left side. [dbu] (legacy; requires
+                ``layer``)
+            width2: Width of the core on the right side. [dbu] (legacy; requires
+                ``layer``)
+            layer: Main layer of the taper. (legacy)
+            enclosure: Definition of the slab/exclude. (legacy)
         """
         ...
 
@@ -113,22 +160,27 @@ def taper_factory(
             \_   │
               \__│ Slab/Exclude
 
+    The returned function is the generic interface: it accepts either two cross
+    sections (``cross_section1``/``cross_section2``) or the legacy
+    ``width1``/``width2``/``layer``/``enclosure`` (all dbu), normalized into a pair
+    of symmetric cross sections.
+
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
             [`KCell.info`][kfactory.settings.Info]. Can be a static dict
-            mapping info name to info value. Or can a callable which takes the straight
+            mapping info name to info value. Or can a callable which takes the taper
             functions' parameters as kwargs and returns a dict with the mapping.
-        basename: Overwrite the prefix of the resulting KCell's name. By default
-            the KCell will be named 'straight_dbu[...]'.
+        output_type: The type of the returned cell.
+        port_type: Type of the ports the taper gets.
         cell_kwargs: Additional arguments passed as `@kcl.cell(**cell_kwargs)`.
     """
-    if callable(additional_info) and additional_info is not None:
+    _additional_info: dict[str, MetaData] = {}
+    if _is_additional_info_func(additional_info):
         _additional_info_func: Callable[
             ...,
             dict[str, MetaData],
         ] = additional_info
-        _additional_info: dict[str, MetaData] = {}
     else:
 
         def additional_info_func(
@@ -137,7 +189,7 @@ def additional_info_func(
             return {}
 
         _additional_info_func = additional_info_func
-        _additional_info = additional_info or {}
+        _additional_info = additional_info or {}  # ty:ignore[invalid-assignment]
 
     ports = cell_kwargs.get("ports")
     if ports is None:
@@ -145,6 +197,8 @@ def additional_info_func(
             cell_kwargs["ports"] = {"left": ["o1"], "right": ["o2"]}
         elif kcl.rename_function == rename_by_direction:
             cell_kwargs["ports"] = {"left": ["W0"], "right": ["E0"]}
+    cell_kwargs.setdefault("basename", "taper")
+    basename = cell_kwargs["basename"]
 
     if output_type is not None:
         cell = kcl.cell(output_type=output_type, **cell_kwargs)
@@ -152,35 +206,12 @@ def additional_info_func(
         cell = kcl.cell(output_type=cast("type[KC]", KCell), **cell_kwargs)
 
     @cell
-    def taper(
-        width1: dbu,
-        width2: dbu,
+    def _taper(
+        cross_section1: str | AnyCrossSectionInput,
+        cross_section2: str | AnyCrossSectionInput,
         length: dbu,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
     ) -> KCell:
-        r"""Linear Taper [um].
-
-                   __
-                 _/  │ Slab/Exclude
-               _/  __│
-             _/  _/  │
-            │  _/    │
-            │_/      │
-            │_       │ Core
-            │ \_     │
-            │_  \_   │
-              \_  \__│
-                \_   │
-                  \__│ Slab/Exclude
-
-        Args:
-            width1: Width of the core on the left side. [dbu]
-            width2: Width of the core on the right side. [dbu]
-            length: Length of the taper. [dbu]
-            layer: Main layer of the taper.
-            enclosure: Definition of the slab/exclude.
-        """
+        """Linear taper defined by two cross sections."""
         c = kcl.kcell()
         if length < 0:
             logger.critical(
@@ -189,21 +220,35 @@ def taper(
                 " lengths."
             )
             length = -length
-        if width1 < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width1} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
-            )
-            width1 = -width1
 
-        if width2 < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width2} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
+        xs1 = kcl.get_base_cross_section(cross_section1)
+        xs2 = kcl.get_base_cross_section(cross_section2)
+        if isinstance(xs1, AsymmetricalCrossSection) or isinstance(
+            xs2, AsymmetricalCrossSection
+        ):
+            raise NotImplementedError(
+                "Tapers do not support asymmetric cross sections yet (the straight"
+                " edge of the taper is ambiguous for an off-center profile). Got"
+                f" {xs1.name!r} -> {xs2.name!r}."
             )
-            width2 = -width2
+        # The taper applies a single, constant enclosure (and core layer) along its
+        # length via minkowski; a differing enclosure between the two cross sections
+        # (slab/exclude sections, bbox sections, or main layer) cannot be represented
+        # and would be silently taken from cross_section1 only. ``LayerEnclosure``
+        # equality is structural (its name normalizes to a geometry hash) and includes
+        # the main layer, so this also catches a core-layer mismatch.
+        if xs1.enclosure != xs2.enclosure:
+            raise ValueError(
+                "Taper requires both cross sections to share the same enclosure "
+                "(slab/exclude sections and main layer); got "
+                f"{xs1.name!r} ({xs1.enclosure.name!r}) and "
+                f"{xs2.name!r} ({xs2.enclosure.name!r}). Only the core width may "
+                "differ between the two cross sections."
+            )
+        width1 = xs1.width
+        width2 = xs2.width
+        layer = xs1.main_layer
+        enclosure = xs1.enclosure
 
         li = c.kcl.layer(layer)
         taper = c.shapes(li).insert(
@@ -218,35 +263,33 @@ def taper(
         )
 
         c.create_port(
+            name="o1",
             trans=kdb.Trans(2, False, 0, 0),
-            width=width1,
-            layer=li,
+            cross_section=xs1,
             port_type=port_type,
         )
         c.create_port(
+            name="o2",
             trans=kdb.Trans(0, False, length, 0),
-            width=width2,
-            layer=li,
+            cross_section=xs2,
             port_type=port_type,
         )
 
-        if enclosure is not None:
-            enclosure.apply_minkowski_y(c, layer)
+        enclosure.apply_minkowski_y(c, layer)
+
         _info: dict[str, MetaData] = {
-            "width1_um": width1 * c.kcl.dbu,
-            "width2_um": width2 * c.kcl.dbu,
-            "length_um": length * c.kcl.dbu,
+            "width1_um": c.kcl.to_um(width1),
+            "width2_um": c.kcl.to_um(width2),
+            "length_um": c.kcl.to_um(length),
             "width1_dbu": width1,
             "width2_dbu": width2,
             "length_dbu": length,
         }
         _info.update(
             _additional_info_func(
-                width1=width1,
-                width2=width2,
+                cross_section1=xs1,
+                cross_section2=xs2,
                 length=length,
-                layer=layer,
-                enclosure=enclosure,
             )
         )
         _info.update(_additional_info)
@@ -256,4 +299,36 @@ def taper(
 
         return c
 
+    @kcl.generic_factory(name=basename)
+    def taper(
+        *,
+        length: dbu,
+        cross_section1: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        cross_section2: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width1: dbu | None = None,
+        width2: dbu | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> KC:
+        if cross_section1 is None or cross_section2 is None:
+            if width1 is None or width2 is None or layer is None:
+                raise ValueError(
+                    "Provide cross_section1 and cross_section2, or width1, width2 and"
+                    " layer (legacy call)."
+                )
+            xs1 = cross_section_from_width(kcl, width1, layer, enclosure)
+            xs2 = cross_section_from_width(kcl, width2, layer, enclosure)
+        else:
+            xs1 = kcl.get_icross_section(cross_section1)
+            xs2 = kcl.get_icross_section(cross_section2)
+        return _taper(cross_section1=xs1, cross_section2=xs2, length=length)
+
     return taper
diff --git a/src/kfactory/factories/utils.py b/src/kfactory/factories/utils.py
new file mode 100644
index 000000000..a2c838112
--- /dev/null
+++ b/src/kfactory/factories/utils.py
@@ -0,0 +1,282 @@
+"""Utility functions for cell factories."""
+
+from collections import defaultdict
+from collections.abc import Callable, Sequence
+from functools import partial
+from typing import TYPE_CHECKING, TypeGuard
+
+from .. import kdb
+from ..cross_section import CrossSection, CrossSectionSpecDict
+from ..enclosure import (
+    LayerEnclosure,
+    _extrude_path_band_points,
+    extrude_path_dynamic_points,
+    extrude_path_points,
+    path_pts_to_polygon,
+)
+from ..kcell import KCell, VKCell
+from ..typings import MetaData
+
+if TYPE_CHECKING:
+    from ..cross_section import AnyCrossSection
+    from ..layout import KCLayout
+
+__all__ = [
+    "boundary_from_shapes",
+    "cross_section_from_width",
+    "extrude_backbone",
+    "extrude_backbone_cross_section",
+    "extrude_backbone_dynamic",
+    "layer_enclosure_to_sections",
+]
+
+
+def boundary_from_shapes(c: KCell) -> kdb.DPolygon | None:
+    """Build a cell boundary from its drawn shapes.
+
+    Collects every shape on every layer, merges them, and returns the first polygon
+    (in um) of the merged region — i.e. the outline of the cell's footprint. Returns
+    `None` if the cell has no shapes.
+    """
+    region = kdb.Region()
+    for layer_index in c.kcl.layer_indexes():
+        region.insert(c.shapes(layer_index))
+    region.merge()
+    if region.is_empty():
+        return None
+    return region[0].to_dtype(c.kcl.dbu)
+
+
+def layer_enclosure_to_sections(
+    enclosure: LayerEnclosure | None,
+) -> list[tuple[kdb.LayerInfo, int] | tuple[kdb.LayerInfo, int, int]]:
+    """Flatten a `LayerEnclosure` into `CrossSectionSpec` ``sections`` (dbu)."""
+    if enclosure is None:
+        return []
+    sections: list[tuple[kdb.LayerInfo, int] | tuple[kdb.LayerInfo, int, int]] = []
+    for layer, layer_section in enclosure.layer_sections.items():
+        for section in layer_section.sections:
+            if section.d_min is None:
+                sections.append((layer, section.d_max))
+            else:
+                sections.append((layer, section.d_min, section.d_max))
+    return sections
+
+
+def cross_section_from_width(
+    kcl: "KCLayout",
+    width: int,
+    layer: kdb.LayerInfo,
+    enclosure: LayerEnclosure | None = None,
+) -> CrossSection:
+    """Build a (dbu) symmetric cross section from legacy width/layer/enclosure args."""
+    return kcl.get_icross_section(
+        CrossSectionSpecDict(
+            layer=layer,
+            width=width,
+            unit="dbu",
+            sections=layer_enclosure_to_sections(enclosure),
+        ),
+        symmetrical=True,
+    )
+
+
+def extrude_backbone(
+    c: VKCell,
+    backbone: Sequence[kdb.DPoint],
+    width: float,
+    layer: kdb.LayerInfo,
+    start_angle: float,
+    end_angle: float,
+    dbu: float,
+    enclosure: LayerEnclosure | None = None,
+) -> None:
+    """Extrude a backbone into a virtual cell.
+
+    Args:
+        c: target cell
+        backbone: backbone to extrude
+        width: width to extrude (main layer)
+        layer: main layer & reference for enclosure
+        enclosure: enclosure to apply
+        start_angle: force a certain start angle
+        end_angle: force a acertain end angle
+        dbu: database unit to use as a reference
+    """
+    center_path_l, center_path_r = extrude_path_points(
+        backbone, width=width, start_angle=start_angle, end_angle=end_angle
+    )
+    center_path_r.reverse()
+    c.shapes(c.kcl.layer(layer)).insert(kdb.DPolygon(center_path_l + center_path_r))
+
+    if enclosure:
+        for _layer, sections in enclosure.layer_sections.items():
+            _li = c.kcl.layer(_layer)
+            for section in sections.sections:
+                if section.d_min is not None:
+                    inner_l, inner_r = extrude_path_points(
+                        backbone,
+                        width=width + 2 * section.d_min * dbu,
+                        start_angle=start_angle,
+                        end_angle=end_angle,
+                    )
+                    outer_l, outer_r = extrude_path_points(
+                        backbone,
+                        width=width + 2 * section.d_max * dbu,
+                        start_angle=start_angle,
+                        end_angle=end_angle,
+                    )
+                    inner_l.reverse()
+                    outer_r.reverse()
+                    c.shapes(_li).insert(kdb.DPolygon(outer_l + inner_l))
+                    c.shapes(_li).insert(kdb.DPolygon(inner_r + outer_r))
+                else:
+                    outer_l, outer_r = extrude_path_points(
+                        backbone,
+                        width=width + 2 * section.d_max * dbu,
+                        start_angle=start_angle,
+                        end_angle=end_angle,
+                    )
+                    outer_r.reverse()
+                    c.shapes(_li).insert(kdb.DPolygon(outer_l + outer_r))
+
+
+def extrude_backbone_cross_section(
+    c: VKCell,
+    backbone: Sequence[kdb.DPoint],
+    cross_section: "AnyCrossSection",
+    start_angle: float,
+    end_angle: float,
+) -> None:
+    """Extrude a (symmetric or asymmetric) cross section along a backbone (um).
+
+    The virtual-cell counterpart of
+    [`extrude_path_cross_section`][kfactory.enclosure.extrude_path_cross_section]:
+    symmetric cross sections reproduce `extrude_backbone` exactly (byte-identical,
+    centered width + enclosure annuli); asymmetric ones are extruded as one signed
+    band ``[section_min, section_max]`` per strip (main strip + each aux section),
+    with strips sharing a layer merged.
+
+    Args:
+        c: target virtual cell
+        backbone: backbone to extrude (in um)
+        cross_section: the cross section to extrude
+        start_angle: force a certain start angle
+        end_angle: force a certain end angle
+    """
+    from ..cross_section import AsymmetricalCrossSection
+
+    if not isinstance(cross_section, AsymmetricalCrossSection):
+        extrude_backbone(
+            c,
+            backbone=list(backbone),
+            width=c.kcl.to_um(cross_section.width),
+            layer=cross_section.main_layer,
+            start_angle=start_angle,
+            end_angle=end_angle,
+            dbu=c.kcl.dbu,
+            enclosure=cross_section.enclosure,
+        )
+        return
+
+    to_um = c.kcl.to_um
+    strips: dict[kdb.LayerInfo, list[tuple[float, float]]] = defaultdict(list)
+    strips[cross_section.layer].append(
+        (to_um(cross_section.section_min), to_um(cross_section.section_max))
+    )
+    for sec in cross_section.sections:
+        strips[sec.layer].append((to_um(sec.section_min), to_um(sec.section_max)))
+
+    for layer, bands in strips.items():
+        region = kdb.Region()
+        for lo, hi in bands:
+            polygon = path_pts_to_polygon(
+                *_extrude_path_band_points(
+                    list(backbone), lo, hi, start_angle, end_angle
+                )
+            )
+            region.insert(c.kcl.to_dbu(polygon))
+        region.merge()
+        li = c.kcl.layer(layer)
+        for poly in region.each():
+            c.shapes(li).insert(poly.to_dtype(c.kcl.dbu))
+
+
+def extrude_backbone_dynamic(
+    c: VKCell,
+    backbone: list[kdb.DPoint],
+    width1: float,
+    width2: float,
+    layer: kdb.LayerInfo,
+    start_angle: float,
+    end_angle: float,
+    dbu: float,
+    enclosure: LayerEnclosure | None = None,
+) -> None:
+    """Extrude a backbone into a virtual cell.
+
+    Args:
+        c: target cell
+        backbone: backbone to extrude
+        width1: start width to extrude (main layer)
+        width2: end width to extrude (main layer)
+        layer: main layer & reference for enclosure
+        enclosure: enclosure to apply
+        start_angle: force a certain start angle
+        end_angle: force a acertain end angle
+        dbu: database unit to use as a reference
+    """
+
+    def width_f(x: float, a: float) -> float:
+        return (width1 - width2) * (1 - x) + width2 + a
+
+    center_path_l, center_path_r = extrude_path_dynamic_points(
+        backbone,
+        widths=partial(width_f, a=0),
+        start_angle=start_angle,
+        end_angle=end_angle,
+    )
+    center_path_r.reverse()
+    c.shapes(c.kcl.layer(layer)).insert(kdb.DPolygon(center_path_l + center_path_r))
+
+    if enclosure:
+        for _layer, sections in enclosure.layer_sections.items():
+            _li = c.kcl.layer(_layer)
+            for section in sections.sections:
+                if section.d_min is not None:
+                    inner_l, inner_r = extrude_path_dynamic_points(
+                        backbone,
+                        widths=partial(width_f, a=2 * section.d_min * dbu),
+                        start_angle=start_angle,
+                        end_angle=end_angle,
+                    )
+                    outer_l, outer_r = extrude_path_dynamic_points(
+                        backbone,
+                        widths=partial(width_f, a=2 * section.d_max * dbu),
+                        start_angle=start_angle,
+                        end_angle=end_angle,
+                    )
+                    inner_l.reverse()
+                    outer_r.reverse()
+                    c.shapes(_li).insert(kdb.DPolygon(outer_l + inner_l))
+                    c.shapes(_li).insert(kdb.DPolygon(inner_r + outer_r))
+                else:
+                    outer_l, outer_r = extrude_path_dynamic_points(
+                        backbone,
+                        widths=partial(width_f, a=2 * section.d_max * dbu),
+                        start_angle=start_angle,
+                        end_angle=end_angle,
+                    )
+                    outer_r.reverse()
+                    c.shapes(_li).insert(kdb.DPolygon(outer_l + outer_r))
+
+
+def _is_additional_info_func(
+    additional_info: Callable[
+        ...,
+        dict[str, MetaData],
+    ]
+    | dict[str, MetaData]
+    | None,
+) -> TypeGuard[Callable[..., dict[str, MetaData]]]:
+    return callable(additional_info)
diff --git a/src/kfactory/factories/virtual/__init__.py b/src/kfactory/factories/virtual/__init__.py
index cfdcf7e63..775849bea 100644
--- a/src/kfactory/factories/virtual/__init__.py
+++ b/src/kfactory/factories/virtual/__init__.py
@@ -4,6 +4,6 @@
 for example in all-angle routing.
 """
 
-from . import circular, euler, straight, utils
+from . import circular, euler, straight
 
-__all__ = ["circular", "euler", "straight", "utils"]
+__all__ = ["circular", "euler", "straight"]
diff --git a/src/kfactory/factories/virtual/circular.py b/src/kfactory/factories/virtual/circular.py
index 4651b6558..b8676c59b 100644
--- a/src/kfactory/factories/virtual/circular.py
+++ b/src/kfactory/factories/virtual/circular.py
@@ -7,12 +7,21 @@
 
 from ... import kdb
 from ...conf import logger
+from ...cross_section import (
+    AnyCrossSectionInput,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+)
 from ...enclosure import LayerEnclosure
 from ...kcell import VKCell
 from ...layout import KCLayout
 from ...settings import Info
-from ...typings import MetaData
-from .utils import extrude_backbone
+from ...typings import MetaData, deg, um
+from ..utils import (
+    _is_additional_info_func,
+    cross_section_from_width,
+    extrude_backbone_cross_section,
+)
 
 __all__ = ["virtual_bend_circular_factory"]
 
@@ -20,24 +29,35 @@
 class BendCircularVKCell(Protocol):
     """Factory for virtual circular bend."""
 
+    __name__: str
+
     def __call__(
         self,
-        width: float,
-        radius: float,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
-        angle: float = 90,
+        *,
+        radius: um,
+        angle: deg = 90,
         angle_step: float = 1,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
     ) -> VKCell:
         """Create a virtual circular bend.
 
+        Either pass a ``cross_section`` or the legacy ``width``/``layer``/``enclosure``.
+
         Args:
-            width: Width of the core. [um]
             radius: Radius of the backbone. [um]
-            layer: Layer index of the target layer.
-            enclosure: Optional enclosure.
             angle: Angle amount of the bend.
             angle_step: Angle amount per backbone point of the bend.
+            cross_section: Cross section of the bend.
+            width: Width of the core. [um] (legacy; requires ``layer``)
+            layer: Main layer of the bend. (legacy)
+            enclosure: Optional enclosure. (legacy)
         """
         ...
 
@@ -55,22 +75,26 @@ def virtual_bend_circular_factory(
 ) -> BendCircularVKCell:
     """Returns a function generating virtual circular bends.
 
+    The returned function is the generic interface (``cross_section`` or the legacy
+    ``width``/``layer``/``enclosure``).
+
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
             [`VKCell.info`][kfactory.settings.Info]. Can be a static dict
-            mapping info name to info value. Or can a callable which takes the straight
+            mapping info name to info value. Or can a callable which takes the bend
             functions' parameters as kwargs and returns a dict with the mapping.
         basename: Overwrite the prefix of the resulting VKCell's name. By default
             the VKCell will be named 'virtual_bend_circular[...]'.
         cell_kwargs: Additional arguments passed as `@kcl.vcell(**cell_kwargs)`.
     """
-    if callable(additional_info) and additional_info is not None:
+    _additional_info: dict[str, MetaData]
+    if _is_additional_info_func(additional_info):
         _additional_info_func: Callable[
             ...,
             dict[str, MetaData],
         ] = additional_info
-        _additional_info: dict[str, MetaData] = {}
+        _additional_info = {}
     else:
 
         def additional_info_func(
@@ -79,31 +103,18 @@ def additional_info_func(
             return {}
 
         _additional_info_func = additional_info_func
-        _additional_info = additional_info or {}
+        _additional_info = additional_info or {}  # ty:ignore[invalid-assignment]
 
     @kcl.vcell(
-        basename=basename,
-        output_type=VKCell,
-        **cell_kwargs,
+        basename=basename or "virtual_bend_circular", output_type=VKCell, **cell_kwargs
     )
     def virtual_bend_circular(
-        width: float,
-        radius: float,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
-        angle: float = 90,
+        cross_section: str | AnyCrossSectionInput,
+        radius: um,
+        angle: deg = 90,
         angle_step: float = 1,
     ) -> VKCell:
-        """Create a virtual circular bend.
-
-        Args:
-            width: Width of the core. [um]
-            radius: Radius of the backbone. [um]
-            layer: Layer index of the target layer.
-            enclosure: Optional enclosure.
-            angle: Angle amount of the bend.
-            angle_step: Angle amount per backbone point of the bend.
-        """
+        """Virtual circular bend defined by a cross section (um)."""
         c = kcl.vkcell()
         if angle < 0:
             logger.critical(
@@ -112,14 +123,8 @@ def virtual_bend_circular(
                 " lengths."
             )
             angle = -angle
-        if width < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
-            )
-            width = -width
-        dbu = c.kcl.dbu
+
+        xs = kcl.get_base_cross_section(cross_section)
         backbone = [
             kdb.DPoint(x, y)
             for x, y in [
@@ -133,23 +138,18 @@ def virtual_bend_circular(
             ]
         ]
 
-        extrude_backbone(
-            c=c,
+        extrude_backbone_cross_section(
+            c,
             backbone=backbone,
-            width=width,
-            layer=layer,
-            enclosure=enclosure,
+            cross_section=xs,
             start_angle=0,
             end_angle=angle,
-            dbu=dbu,
         )
         _info: dict[str, MetaData] = {}
         _info.update(
             _additional_info_func(
-                width=width,
+                cross_section=xs,
                 radius=radius,
-                layer=layer,
-                enclosure=enclosure,
                 angle=angle,
                 angle_step=angle_step,
             )
@@ -159,16 +159,46 @@ def virtual_bend_circular(
 
         c.create_port(
             name="o1",
-            layer=c.kcl.layer(layer),
-            width=round(width / c.kcl.dbu),
+            cross_section=xs,
             dcplx_trans=kdb.DCplxTrans(1, 180, False, backbone[0].to_v()),
         )
         c.create_port(
             name="o2",
             dcplx_trans=kdb.DCplxTrans(1, angle, False, backbone[-1].to_v()),
-            width=width,
-            layer=c.kcl.layer(layer),
+            cross_section=xs,
         )
         return c
 
-    return virtual_bend_circular
+    @kcl.generic_factory(name=basename or "virtual_bend_circular")
+    def virtual_bend_circular_generic(
+        *,
+        radius: um,
+        angle: deg = 90,
+        angle_step: float = 1,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> VKCell:
+        if cross_section is None:
+            if width is None or layer is None:
+                raise ValueError(
+                    "Provide a cross_section, or width and layer (legacy call)."
+                )
+            if width < 0:
+                logger.critical(
+                    f"Negative widths are not allowed {width}. Forcing positive width."
+                )
+                width = -width
+            xs = cross_section_from_width(kcl, kcl.to_dbu(width), layer, enclosure)
+        else:
+            xs = kcl.get_icross_section(cross_section)
+        return virtual_bend_circular(
+            cross_section=xs, radius=radius, angle=angle, angle_step=angle_step
+        )
+
+    return virtual_bend_circular_generic
diff --git a/src/kfactory/factories/virtual/euler.py b/src/kfactory/factories/virtual/euler.py
index d230c78d9..652c2f21c 100644
--- a/src/kfactory/factories/virtual/euler.py
+++ b/src/kfactory/factories/virtual/euler.py
@@ -5,36 +5,58 @@
 
 from ... import kdb
 from ...conf import logger
+from ...cross_section import (
+    AnyCrossSectionInput,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+)
 from ...enclosure import LayerEnclosure
 from ...factories.euler import euler_bend_points
 from ...kcell import VKCell
 from ...layout import KCLayout
 from ...settings import Info
-from ...typings import MetaData
-from .utils import extrude_backbone
+from ...typings import MetaData, deg, um
+from ..utils import (
+    _is_additional_info_func,
+    cross_section_from_width,
+    extrude_backbone_cross_section,
+)
+
+__all__ = ["virtual_bend_euler_factory"]
 
 
 class BendEulerVKCell(Protocol):
     """Factory for virtual euler bends."""
 
+    __name__: str
+
     def __call__(
         self,
-        width: float,
-        radius: float,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
-        angle: float = 90,
+        *,
+        radius: um,
+        angle: deg = 90,
         resolution: float = 150,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
     ) -> VKCell:
         """Create a virtual euler bend.
 
+        Either pass a ``cross_section`` or the legacy ``width``/``layer``/``enclosure``.
+
         Args:
-            width: Width of the core. [um]
             radius: Radius off the backbone. [um]
-            layer: Layer index / LayerEnum of the core.
-            enclosure: Slab/exclude definition. [dbu]
             angle: Angle of the bend.
             resolution: Angle resolution for the backbone.
+            cross_section: Cross section of the bend.
+            width: Width of the core. [um] (legacy; requires ``layer``)
+            layer: Main layer of the bend. (legacy)
+            enclosure: Slab/exclude definition. (legacy)
         """
         ...
 
@@ -52,22 +74,25 @@ def virtual_bend_euler_factory(
 ) -> BendEulerVKCell:
     """Returns a function generating virtual euler bends.
 
+    The returned function is the generic interface (``cross_section`` or the legacy
+    ``width``/``layer``/``enclosure``).
+
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
             [`VKCell.info`][kfactory.settings.Info]. Can be a static dict
-            mapping info name to info value. Or can a callable which takes the straight
+            mapping info name to info value. Or can a callable which takes the bend
             functions' parameters as kwargs and returns a dict with the mapping.
         basename: Overwrite the prefix of the resulting VKCell's name. By default
-            the VKCell will be named 'virtual_bend_euler[...]'.
+            the VKCell will be named 'bend_euler[...]'.
         cell_kwargs: Additional arguments passed as `@kcl.vcell(**cell_kwargs)`.
     """
-    if callable(additional_info) and additional_info is not None:
+    _additional_info: dict[str, MetaData] = {}
+    if _is_additional_info_func(additional_info):
         _additional_info_func: Callable[
             ...,
             dict[str, MetaData],
         ] = additional_info
-        _additional_info: dict[str, MetaData] = {}
     else:
 
         def additional_info_func(
@@ -76,31 +101,16 @@ def additional_info_func(
             return {}
 
         _additional_info_func = additional_info_func
-        _additional_info = additional_info or {}
+        _additional_info = additional_info or {}  # ty:ignore[invalid-assignment]
 
-    @kcl.vcell(
-        basename=basename,
-        output_type=VKCell,
-        **cell_kwargs,
-    )
+    @kcl.vcell(basename=basename or "bend_euler", output_type=VKCell, **cell_kwargs)
     def bend_euler(
-        width: float,
-        radius: float,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
-        angle: float = 90,
+        cross_section: str | AnyCrossSectionInput,
+        radius: um,
+        angle: deg = 90,
         resolution: float = 150,
     ) -> VKCell:
-        """Create a virtual euler bend.
-
-        Args:
-            width: Width of the core. [um]
-            radius: Radius off the backbone. [um]
-            layer: Layer index / LayerEnum of the core.
-            enclosure: Slab/exclude definition. [dbu]
-            angle: Angle of the bend.
-            resolution: Angle resolution for the backbone.
-        """
+        """Virtual euler bend defined by a cross section (um)."""
         c = kcl.vkcell()
         if angle < 0:
             logger.critical(
@@ -109,34 +119,24 @@ def bend_euler(
                 " lengths."
             )
             angle = -angle
-        if width < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
-            )
-            width = -width
-        dbu = c.kcl.dbu
+
+        xs = kcl.get_base_cross_section(cross_section)
         backbone = euler_bend_points(angle, radius=radius, resolution=resolution)
 
-        extrude_backbone(
-            c=c,
+        extrude_backbone_cross_section(
+            c,
             backbone=backbone,
-            width=width,
-            layer=layer,
-            enclosure=enclosure,
+            cross_section=xs,
             start_angle=0,
             end_angle=angle,
-            dbu=dbu,
         )
         _info: dict[str, MetaData] = {}
         _info.update(
             _additional_info_func(
-                width=width,
+                cross_section=xs,
                 radius=radius,
-                layer=layer,
-                enclosure=enclosure,
                 angle=angle,
+                resolution=resolution,
             )
         )
         _info.update(_additional_info)
@@ -144,16 +144,46 @@ def bend_euler(
 
         c.create_port(
             name="o1",
-            layer=c.kcl.layer(layer),
-            width=width,
+            cross_section=xs,
             dcplx_trans=kdb.DCplxTrans(1, 180, False, backbone[0].to_v()),
         )
         c.create_port(
             name="o2",
             dcplx_trans=kdb.DCplxTrans(1, angle, False, backbone[-1].to_v()),
-            width=width,
-            layer=c.kcl.layer(layer),
+            cross_section=xs,
         )
         return c
 
-    return bend_euler
+    @kcl.generic_factory(name=basename or "virtual_bend_euler")
+    def virtual_bend_euler(
+        *,
+        radius: um,
+        angle: deg = 90,
+        resolution: float = 150,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> VKCell:
+        if cross_section is None:
+            if width is None or layer is None:
+                raise ValueError(
+                    "Provide a cross_section, or width and layer (legacy call)."
+                )
+            if width < 0:
+                logger.critical(
+                    f"Negative widths are not allowed {width}. Forcing positive width."
+                )
+                width = -width
+            xs = cross_section_from_width(kcl, kcl.to_dbu(width), layer, enclosure)
+        else:
+            xs = kcl.get_icross_section(cross_section)
+        return bend_euler(
+            cross_section=xs, radius=radius, angle=angle, resolution=resolution
+        )
+
+    return virtual_bend_euler
diff --git a/src/kfactory/factories/virtual/straight.py b/src/kfactory/factories/virtual/straight.py
index fe566babf..2918c3e8c 100644
--- a/src/kfactory/factories/virtual/straight.py
+++ b/src/kfactory/factories/virtual/straight.py
@@ -5,22 +5,39 @@
 
 from ... import kdb
 from ...conf import logger
+from ...cross_section import (
+    AnyCrossSectionInput,
+    CrossSectionSpecDict,
+    DCrossSectionSpecDict,
+)
 from ...enclosure import LayerEnclosure
 from ...kcell import VKCell
 from ...layout import KCLayout
 from ...settings import Info
-from ...typings import MetaData
-from .utils import extrude_backbone
+from ...typings import MetaData, um
+from ..utils import (
+    _is_additional_info_func,
+    cross_section_from_width,
+    extrude_backbone_cross_section,
+)
 
 __all__ = ["virtual_straight_factory"]
 
 
 class StraightVKCell(Protocol):
+    __name__: str
+
     def __call__(
         self,
-        width: float,
-        length: float,
-        layer: kdb.LayerInfo,
+        *,
+        length: um,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
         enclosure: LayerEnclosure | None = None,
     ) -> VKCell:
         """Virtual straight waveguide defined in um.
@@ -34,11 +51,15 @@ def __call__(
             ├──────────────────────────────┤
             │         Slab/Exclude         │
             └──────────────────────────────┘
+
+        Either pass a ``cross_section`` or the legacy ``width``/``layer``/``enclosure``.
+
         Args:
-            width: Waveguide width. [um]
             length: Waveguide length. [um]
-            layer: Main layer of the waveguide.
-            enclosure: Definition of slab/excludes. [dbu]
+            cross_section: Cross section of the waveguide.
+            width: Waveguide width. [um] (legacy; requires ``layer``)
+            layer: Main layer of the waveguide. (legacy)
+            enclosure: Definition of slab/excludes. (legacy)
         """
         ...
 
@@ -56,6 +77,10 @@ def virtual_straight_factory(
 ) -> StraightVKCell:
     """Returns a function generating virtual straight waveguides.
 
+    The returned function is the generic interface: it accepts either a
+    ``cross_section`` or the legacy ``width``/``layer``/``enclosure`` (all um),
+    normalized into a symmetric cross section.
+
     Args:
         kcl: The KCLayout which will be owned
         additional_info: Add additional key/values to the
@@ -63,10 +88,10 @@ def virtual_straight_factory(
             mapping info name to info value. Or can a callable which takes the straight
             functions' parameters as kwargs and returns a dict with the mapping.
         basename: Overwrite the prefix of the resulting VKCell's name. By default
-            the VKCell will be named 'virtual_bend_euler[...]'.
+            the VKCell will be named 'virtual_straight[...]'.
         cell_kwargs: Additional arguments passed as `@kcl.vcell(**cell_kwargs)`.
     """
-    if callable(additional_info) and additional_info is not None:
+    if _is_additional_info_func(additional_info):
         _additional_info_func: Callable[
             ...,
             dict[str, MetaData],
@@ -82,30 +107,14 @@ def additional_info_func(
         _additional_info_func = additional_info_func
         _additional_info = additional_info or {}
 
-    @kcl.vcell
+    @kcl.vcell(
+        basename=basename or "virtual_straight", output_type=VKCell, **cell_kwargs
+    )
     def virtual_straight(
-        width: float,
-        length: float,
-        layer: kdb.LayerInfo,
-        enclosure: LayerEnclosure | None = None,
+        cross_section: str | AnyCrossSectionInput,
+        length: um,
     ) -> VKCell:
-        """Virtual waveguide defined in um.
-
-            ┌──────────────────────────────┐
-            │         Slab/Exclude         │
-            ├──────────────────────────────┤
-            │                              │
-            │             Core             │
-            │                              │
-            ├──────────────────────────────┤
-            │         Slab/Exclude         │
-            └──────────────────────────────┘
-        Args:
-            width: Waveguide width. [um]
-            length: Waveguide length. [um]
-            layer: Main layer of the waveguide.
-            enclosure: Definition of slab/excludes. [dbu]
-        """
+        """Virtual waveguide defined by a cross section (um)."""
         c = kcl.vkcell()
         if length < 0:
             logger.critical(
@@ -114,49 +123,60 @@ def virtual_straight(
                 " lengths."
             )
             length = -length
-        if width < 0:
-            logger.critical(
-                f"Negative widths are not allowed {width} as ports"
-                " will be inverted. Please use a positive number. Forcing positive"
-                " lengths."
-            )
-            width = -width
 
-        extrude_backbone(
+        xs = kcl.get_base_cross_section(cross_section)
+
+        extrude_backbone_cross_section(
             c,
             backbone=[kdb.DPoint(0, 0), kdb.DPoint(length, 0)],
-            width=width,
-            layer=layer,
-            enclosure=enclosure,
+            cross_section=xs,
             start_angle=0,
             end_angle=0,
-            dbu=c.kcl.dbu,
         )
 
         _info: dict[str, MetaData] = {}
-        _info.update(
-            _additional_info_func(
-                width=width,
-                length=length,
-                layer=layer,
-                enclosure=enclosure,
-            )
-        )
-        _info.update(_additional_info)
+        _info.update(_additional_info_func(cross_section=xs, length=length))
+        _info.update(_additional_info)  # ty:ignore[no-matching-overload]
         c.info = Info(**_info)
 
         c.create_port(
             name="o1",
             dcplx_trans=kdb.DCplxTrans(1, 180, False, 0, 0),
-            layer=c.kcl.layer(layer),
-            width=width,
+            cross_section=xs,
         )
         c.create_port(
             name="o2",
             dcplx_trans=kdb.DCplxTrans(1, 0, False, length, 0),
-            layer=c.kcl.layer(layer),
-            width=width,
+            cross_section=xs,
         )
         return c
 
-    return virtual_straight
+    @kcl.generic_factory(name=basename or "virtual_straight")
+    def straight(
+        *,
+        length: um,
+        cross_section: str
+        | AnyCrossSectionInput
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | None = None,
+        width: um | None = None,
+        layer: kdb.LayerInfo | None = None,
+        enclosure: LayerEnclosure | None = None,
+    ) -> VKCell:
+        if cross_section is None:
+            if width is None or layer is None:
+                raise ValueError(
+                    "Provide a cross_section, or width and layer (legacy call)."
+                )
+            if width < 0:
+                logger.critical(
+                    f"Negative widths are not allowed {width}. Forcing positive width."
+                )
+                width = -width
+            xs = cross_section_from_width(kcl, kcl.to_dbu(width), layer, enclosure)
+        else:
+            xs = kcl.get_icross_section(cross_section)
+        return virtual_straight(cross_section=xs, length=length)
+
+    return straight
diff --git a/src/kfactory/factories/virtual/utils.py b/src/kfactory/factories/virtual/utils.py
deleted file mode 100644
index 692920e12..000000000
--- a/src/kfactory/factories/virtual/utils.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""Utility functions for virtual cells."""
-
-from collections.abc import Sequence
-from functools import partial
-
-from ... import kdb
-from ...enclosure import (
-    LayerEnclosure,
-    extrude_path_dynamic_points,
-    extrude_path_points,
-)
-from ...kcell import VKCell
-
-__all__ = ["extrude_backbone", "extrude_backbone_dynamic"]
-
-
-def extrude_backbone(
-    c: VKCell,
-    backbone: Sequence[kdb.DPoint],
-    width: float,
-    layer: kdb.LayerInfo,
-    start_angle: float,
-    end_angle: float,
-    dbu: float,
-    enclosure: LayerEnclosure | None = None,
-) -> None:
-    """Extrude a backbone into a virtual cell.
-
-    Args:
-        c: target cell
-        backbone: backbone to extrude
-        width: width to extrude (main layer)
-        layer: main layer & reference for enclosure
-        enclosure: enclosure to apply
-        start_angle: force a certain start angle
-        end_angle: force a acertain end angle
-        dbu: database unit to use as a reference
-    """
-    center_path_l, center_path_r = extrude_path_points(
-        backbone, width=width, start_angle=start_angle, end_angle=end_angle
-    )
-    center_path_r.reverse()
-    c.shapes(c.kcl.layer(layer)).insert(kdb.DPolygon(center_path_l + center_path_r))
-
-    if enclosure:
-        for _layer, sections in enclosure.layer_sections.items():
-            _li = c.kcl.layer(_layer)
-            for section in sections.sections:
-                if section.d_min is not None:
-                    inner_l, inner_r = extrude_path_points(
-                        backbone,
-                        width=width + 2 * section.d_min * dbu,
-                        start_angle=start_angle,
-                        end_angle=end_angle,
-                    )
-                    outer_l, outer_r = extrude_path_points(
-                        backbone,
-                        width=width + 2 * section.d_max * dbu,
-                        start_angle=start_angle,
-                        end_angle=end_angle,
-                    )
-                    inner_l.reverse()
-                    outer_r.reverse()
-                    c.shapes(_li).insert(kdb.DPolygon(outer_l + inner_l))
-                    c.shapes(_li).insert(kdb.DPolygon(inner_r + outer_r))
-                else:
-                    outer_l, outer_r = extrude_path_points(
-                        backbone,
-                        width=width + 2 * section.d_max * dbu,
-                        start_angle=start_angle,
-                        end_angle=end_angle,
-                    )
-                    outer_r.reverse()
-                    c.shapes(_li).insert(kdb.DPolygon(outer_l + outer_r))
-
-
-def extrude_backbone_dynamic(
-    c: VKCell,
-    backbone: list[kdb.DPoint],
-    width1: float,
-    width2: float,
-    layer: kdb.LayerInfo,
-    start_angle: float,
-    end_angle: float,
-    dbu: float,
-    enclosure: LayerEnclosure | None = None,
-) -> None:
-    """Extrude a backbone into a virtual cell.
-
-    Args:
-        c: target cell
-        backbone: backbone to extrude
-        width: width to extrude (main layer)
-        layer: main layer & reference for enclosure
-        enclosure: enclosure to apply
-        start_angle: force a certain start angle
-        end_angle: force a acertain end angle
-        dbu: database unit to use as a reference
-    """
-
-    def width_f(x: float, a: float) -> float:
-        return (width1 - width2) * (1 - x) + width2 + a
-
-    center_path_l, center_path_r = extrude_path_dynamic_points(
-        backbone,
-        widths=partial(width_f, a=0),
-        start_angle=start_angle,
-        end_angle=end_angle,
-    )
-    center_path_r.reverse()
-    c.shapes(c.kcl.layer(layer)).insert(kdb.DPolygon(center_path_l + center_path_r))
-
-    if enclosure:
-        for _layer, sections in enclosure.layer_sections.items():
-            _li = c.kcl.layer(_layer)
-            for section in sections.sections:
-                if section.d_min is not None:
-                    inner_l, inner_r = extrude_path_dynamic_points(
-                        backbone,
-                        widths=partial(width_f, a=2 * section.d_min * dbu),
-                        start_angle=start_angle,
-                        end_angle=end_angle,
-                    )
-                    outer_l, outer_r = extrude_path_dynamic_points(
-                        backbone,
-                        widths=partial(width_f, a=2 * section.d_max * dbu),
-                        start_angle=start_angle,
-                        end_angle=end_angle,
-                    )
-                    inner_l.reverse()
-                    outer_r.reverse()
-                    c.shapes(_li).insert(kdb.DPolygon(outer_l + inner_l))
-                    c.shapes(_li).insert(kdb.DPolygon(inner_r + outer_r))
-                else:
-                    outer_l, outer_r = extrude_path_dynamic_points(
-                        backbone,
-                        widths=partial(width_f, a=2 * section.d_max * dbu),
-                        start_angle=start_angle,
-                        end_angle=end_angle,
-                    )
-                    outer_r.reverse()
-                    c.shapes(_li).insert(kdb.DPolygon(outer_l + outer_r))
diff --git a/src/kfactory/geometry.py b/src/kfactory/geometry.py
index 599407dac..9b4779160 100644
--- a/src/kfactory/geometry.py
+++ b/src/kfactory/geometry.py
@@ -1,12 +1,11 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Any, Generic, Self, overload
+from typing import TYPE_CHECKING, Any, Self, overload
 
 import numpy as np
 
 from . import kdb
-from .typings import TUnit
 
 if TYPE_CHECKING:
     from .layer import LayerEnum
@@ -16,10 +15,10 @@
 __all__ = ["DBUGeometricObject", "GeometricObject", "SizeInfo", "UMGeometricObject"]
 
 
-class SizeInfo(Generic[TUnit]):
-    _bf: BoxFunction[TUnit]
+class SizeInfo[T: (int, float)]:
+    _bf: BoxFunction[T]
 
-    def __init__(self, bbox: BoxFunction[TUnit]) -> None:
+    def __init__(self, bbox: BoxFunction[T]) -> None:
         """Initialize this object."""
         super().__init__()
         self._bf = bbox
@@ -30,94 +29,94 @@ def __str__(self) -> str:
             f" {self.south=}, {self.north=}"
         )
 
-    def __call__(self, layer: int | LayerEnum) -> SizeInfo[TUnit]:
-        def layer_bbox() -> BoxLike[TUnit]:
+    def __call__(self, layer: int | LayerEnum) -> SizeInfo[T]:
+        def layer_bbox() -> BoxLike[T]:
             return self._bf(layer)
 
-        return SizeInfo[TUnit](bbox=layer_bbox)  # type: ignore[arg-type]
+        return SizeInfo[T](bbox=layer_bbox)  # ty:ignore[invalid-argument-type]
 
     @property
-    def west(self) -> TUnit:
+    def west(self) -> T:
         return self._bf().left
 
     @property
-    def east(self) -> TUnit:
+    def east(self) -> T:
         return self._bf().right
 
     @property
-    def south(self) -> TUnit:
+    def south(self) -> T:
         return self._bf().bottom
 
     @property
-    def north(self) -> TUnit:
+    def north(self) -> T:
         return self._bf().top
 
     @property
-    def width(self) -> TUnit:
+    def width(self) -> T:
         return self._bf().width()
 
     @property
-    def height(self) -> TUnit:
+    def height(self) -> T:
         return self._bf().height()
 
     @property
-    def sw(self) -> tuple[TUnit, TUnit]:
+    def sw(self) -> tuple[T, T]:
         bb = self._bf()
         return (bb.left, bb.bottom)
 
     @property
-    def nw(self) -> tuple[TUnit, TUnit]:
+    def nw(self) -> tuple[T, T]:
         bb = self._bf()
         return (bb.left, bb.top)
 
     @property
-    def se(self) -> tuple[TUnit, TUnit]:
+    def se(self) -> tuple[T, T]:
         bb = self._bf()
         return (bb.right, bb.bottom)
 
     @property
-    def ne(self) -> tuple[TUnit, TUnit]:
+    def ne(self) -> tuple[T, T]:
         bb = self._bf()
         return (bb.right, bb.top)
 
     @property
-    def cw(self) -> tuple[TUnit, TUnit]:
+    def cw(self) -> tuple[T, T]:
         bb = self._bf()
         return (bb.left, bb.center().y)
 
     @property
-    def ce(self) -> tuple[TUnit, TUnit]:
+    def ce(self) -> tuple[T, T]:
         bb = self._bf()
         return (bb.right, bb.center().y)
 
     @property
-    def sc(self) -> tuple[TUnit, TUnit]:
+    def sc(self) -> tuple[T, T]:
         bb = self._bf()
         return (bb.center().x, bb.bottom)
 
     @property
-    def nc(self) -> tuple[TUnit, TUnit]:
+    def nc(self) -> tuple[T, T]:
         bb = self._bf()
         return (bb.center().x, bb.top)
 
     @property
-    def cc(self) -> tuple[TUnit, TUnit]:
+    def cc(self) -> tuple[T, T]:
         c = self._bf().center()
         return (c.x, c.y)
 
     @property
-    def center(self) -> tuple[TUnit, TUnit]:
+    def center(self) -> tuple[T, T]:
         c = self._bf().center()
         return (c.x, c.y)
 
 
-class GeometricObject(Generic[TUnit], ABC):  # noqa: PYI059
+class GeometricObject[T: (int, float)](ABC):
     @property
     @abstractmethod
     def kcl(self) -> KCLayout: ...
 
     @abstractmethod
-    def bbox(self, layer: int | None = None) -> BoxLike[TUnit]: ...
+    def bbox(self, layer: int | None = None) -> BoxLike[T]: ...
 
     @abstractmethod
     def ibbox(self, layer: int | None = None) -> kdb.Box: ...
@@ -125,12 +124,6 @@ def ibbox(self, layer: int | None = None) -> kdb.Box: ...
     @abstractmethod
     def dbbox(self, layer: int | None = None) -> kdb.DBox: ...
 
-    @overload
-    @abstractmethod
-    def _standard_trans(self: GeometricObject[int]) -> type[kdb.Trans]: ...
-    @overload
-    @abstractmethod
-    def _standard_trans(self: GeometricObject[float]) -> type[kdb.DCplxTrans]: ...
     @abstractmethod
     def _standard_trans(self) -> type[kdb.Trans | kdb.DCplxTrans]: ...
 
@@ -142,112 +135,110 @@ def transform(
     ) -> Any: ...
 
     @property
-    def x(self) -> TUnit:
+    def x(self) -> T:
         """Returns the x-coordinate of the center of the bounding box."""
         return self.bbox().center().x
 
     @x.setter
-    def x(self, __val: TUnit, /) -> None:
+    def x(self, __val: T, /) -> None:
         """Moves self so that the bbox's center x-coordinate."""
-        self.transform(self._standard_trans()(x=__val - self.bbox().center().x))
+        self.transform(self._standard_trans()(x=__val - self.bbox().center().x))  # ty:ignore[invalid-argument-type]
 
     @property
-    def y(self) -> TUnit:
+    def y(self) -> T:
         """Returns the y-coordinate of the center of the bounding box."""
         return self.bbox().center().y
 
     @y.setter
-    def y(self, __val: TUnit, /) -> None:
+    def y(self, __val: T, /) -> None:
         """Moves self so that the bbox's center y-coordinate."""
-        self.transform(self._standard_trans()(y=__val - self.bbox().center().y))
+        self.transform(self._standard_trans()(y=__val - self.bbox().center().y))  # ty:ignore[invalid-argument-type]
 
     @property
-    def xmin(self) -> TUnit:
+    def xmin(self) -> T:
         """Returns the x-coordinate of the left edge of the bounding box."""
         return self.bbox().left
 
     @xmin.setter
-    def xmin(self, __val: TUnit, /) -> None:
+    def xmin(self, __val: T, /) -> None:
         """Moves self so that the bbox's left edge x-coordinate."""
-        self.transform(self._standard_trans()(x=__val - self.bbox().left))
+        self.transform(self._standard_trans()(x=__val - self.bbox().left))  # ty:ignore[invalid-argument-type]
 
     @property
-    def ymin(self) -> TUnit:
+    def ymin(self) -> T:
         """Returns the y-coordinate of the bottom edge of the bounding box."""
         return self.bbox().bottom
 
     @ymin.setter
-    def ymin(self, __val: TUnit, /) -> None:
+    def ymin(self, __val: T, /) -> None:
         """Moves self so that the bbox's bottom edge y-coordinate."""
-        self.transform(self._standard_trans()(y=__val - self.bbox().bottom))
+        self.transform(self._standard_trans()(y=__val - self.bbox().bottom))  # ty:ignore[invalid-argument-type]
 
     @property
-    def xmax(self) -> TUnit:
+    def xmax(self) -> T:
         """Returns the x-coordinate of the right edge of the bounding box."""
         return self.bbox().right
 
     @xmax.setter
-    def xmax(self, __val: TUnit, /) -> None:
+    def xmax(self, __val: T, /) -> None:
         """Moves self so that the bbox's right edge x-coordinate."""
-        self.transform(self._standard_trans()(x=__val - self.bbox().right))
+        self.transform(self._standard_trans()(x=__val - self.bbox().right))  # ty:ignore[invalid-argument-type]
 
     @property
-    def ymax(self) -> TUnit:
+    def ymax(self) -> T:
         """Returns the y-coordinate of the top edge of the bounding box."""
         return self.bbox().top
 
     @ymax.setter
-    def ymax(self, __val: TUnit, /) -> None:
+    def ymax(self, __val: T, /) -> None:
         """Moves self so that the bbox's top edge y-coordinate."""
-        self.transform(self._standard_trans()(y=__val - self.bbox().top))
+        self.transform(self._standard_trans()(y=__val - self.bbox().top))  # ty:ignore[invalid-argument-type]
 
     @property
-    def xsize(self) -> TUnit:
+    def xsize(self) -> T:
         """Returns the width of the bounding box."""
         return self.bbox().width()
 
     @xsize.setter
-    def xsize(self, __val: TUnit, /) -> None:
+    def xsize(self, __val: T, /) -> None:
         """Sets the width of the bounding box."""
-        self.transform(self._standard_trans()(x=__val - self.bbox().width()))
+        self.transform(self._standard_trans()(x=__val - self.bbox().width()))  # ty:ignore[invalid-argument-type]
 
     @property
-    def ysize(self) -> TUnit:
+    def ysize(self) -> T:
         """Returns the height of the bounding box."""
         return self.bbox().height()
 
     @ysize.setter
-    def ysize(self, __val: TUnit, /) -> None:
+    def ysize(self, __val: T, /) -> None:
         """Sets the height of the bounding box."""
-        self.transform(self._standard_trans()(y=__val - self.bbox().height()))
+        self.transform(self._standard_trans()(y=__val - self.bbox().height()))  # ty:ignore[invalid-argument-type]
 
     @property
-    def center(self) -> tuple[TUnit, TUnit]:
+    def center(self) -> tuple[T, T]:
         """Returns the coordinate center of the bounding box."""
         center = self.bbox().center()
         return center.x, center.y
 
     @center.setter
-    def center(self, __val: tuple[TUnit, TUnit], /) -> None:
+    def center(self, __val: tuple[T, T], /) -> None:
         """Moves self so that the bbox's center coordinate."""
         self.transform(
             self._standard_trans()(
                 __val[0] - self.bbox().center().x, __val[1] - self.bbox().center().y
-            )
+            )  # ty:ignore[no-matching-overload]
         )
 
     @overload
-    def move(self, destination: tuple[TUnit, TUnit], /) -> Self: ...
+    def move(self, destination: tuple[T, T], /) -> Self: ...
 
     @overload
-    def move(
-        self, origin: tuple[TUnit, TUnit], destination: tuple[TUnit, TUnit]
-    ) -> Self: ...
+    def move(self, origin: tuple[T, T], destination: tuple[T, T]) -> Self: ...
 
     def move(
         self,
-        origin: tuple[TUnit, TUnit],
-        destination: tuple[TUnit, TUnit] | None = None,
+        origin: tuple[T, T],
+        destination: tuple[T, T] | None = None,
     ) -> Self:
         """Move self in dbu.
 
@@ -256,22 +247,22 @@ def move(
             destination: move origin so that it will land on this coordinate [dbu]
         """
         if destination is None:
-            self.transform(self._standard_trans()(*origin))
+            self.transform(self._standard_trans()(*origin))  # ty:ignore[no-matching-overload]
         else:
             self.transform(
                 self._standard_trans()(
                     destination[0] - origin[0], destination[1] - origin[1]
-                )
+                )  # ty:ignore[no-matching-overload]
             )
         return self
 
     @overload
-    def movex(self, destination: TUnit, /) -> Self: ...
+    def movex(self, destination: T, /) -> Self: ...
 
     @overload
-    def movex(self, origin: TUnit, destination: TUnit) -> Self: ...
+    def movex(self, origin: T, destination: T) -> Self: ...
 
-    def movex(self, origin: TUnit, destination: TUnit | None = None) -> Self:
+    def movex(self, origin: T, destination: T | None = None) -> Self:
         """Move self in x-direction in dbu.
 
         Args:
@@ -279,18 +270,18 @@ def movex(self, origin: TUnit, destination: TUnit | None = None) -> Self:
             destination: move origin so that it will land on this coordinate [dbu]
         """
         if destination is None:
-            self.transform(self._standard_trans()(x=origin))
+            self.transform(self._standard_trans()(x=origin))  # ty:ignore[invalid-argument-type]
         else:
-            self.transform(self._standard_trans()(x=destination - origin))
+            self.transform(self._standard_trans()(x=destination - origin))  # ty:ignore[invalid-argument-type]
         return self
 
     @overload
-    def movey(self, destination: TUnit, /) -> Self: ...
+    def movey(self, destination: T, /) -> Self: ...
 
     @overload
-    def movey(self, origin: TUnit, destination: TUnit) -> Self: ...
+    def movey(self, origin: T, destination: T) -> Self: ...
 
-    def movey(self, origin: TUnit, destination: TUnit | None = None) -> Self:
+    def movey(self, origin: T, destination: T | None = None) -> Self:
         """Move self in y-direction in dbu.
 
         Args:
@@ -298,30 +289,28 @@ def movey(self, origin: TUnit, destination: TUnit | None = None) -> Self:
             destination: move origin so that it will land on this coordinate [dbu]
         """
         if destination is None:
-            self.transform(self._standard_trans()(y=origin))
+            self.transform(self._standard_trans()(y=origin))  # ty:ignore[invalid-argument-type]
         else:
-            self.transform(self._standard_trans()(y=destination - origin))
+            self.transform(self._standard_trans()(y=destination - origin))  # ty:ignore[invalid-argument-type]
         return self
 
     @abstractmethod
-    def rotate(self, angle: TUnit, center: tuple[TUnit, TUnit] | None = None) -> Self:
+    def rotate(self, angle: T, center: tuple[T, T] | None = None) -> Self:
         """Rotate self."""
         ...
 
     @abstractmethod
-    def mirror(
-        self, p1: tuple[TUnit, TUnit] = ..., p2: tuple[TUnit, TUnit] = ...
-    ) -> Self:
+    def mirror(self, p1: tuple[T, T] = ..., p2: tuple[T, T] = ...) -> Self:
         """Mirror self at a line."""
         ...
 
     @abstractmethod
-    def mirror_x(self, x: TUnit = ...) -> Self:
+    def mirror_x(self, x: T = ...) -> Self:
         """Mirror self at an y-axis at position x."""
         ...
 
     @abstractmethod
-    def mirror_y(self, y: TUnit = ...) -> Self:
+    def mirror_y(self, y: T = ...) -> Self:
         """Mirror self at an x-axis at position y."""
         ...
 
@@ -740,8 +729,8 @@ def dmirror_y(self, y: float = 0) -> Self:
         return self
 
     @property
-    def size_info(self) -> SizeInfo[TUnit]:
-        return SizeInfo[TUnit](self.bbox)
+    def size_info(self) -> SizeInfo[T]:
+        return SizeInfo[T](self.bbox)
 
     @property
     def isize_info(self) -> SizeInfo[int]:
diff --git a/src/kfactory/grid.py b/src/kfactory/grid.py
index 2a68a9ae9..0cbc61d2f 100644
--- a/src/kfactory/grid.py
+++ b/src/kfactory/grid.py
@@ -100,9 +100,9 @@ def grid_dbu(
 
     if shape is None:
         if isinstance(kcells[0], KCell):  # noqa: SIM108
-            kcell_array = [list(kcells)]  # type:ignore[arg-type]
+            kcell_array = [list(kcells)]  # ty:ignore[invalid-assignment]
         else:
-            kcell_array = kcells  # type: ignore[assignment]
+            kcell_array = kcells  # ty:ignore[invalid-assignment]
 
         x0 = 0
         y0 = 0
@@ -553,9 +553,9 @@ def grid(
 
     if shape is None:
         if isinstance(kcells[0], DKCell):  # noqa: SIM108
-            kcell_array = [list(kcells)]  # type:ignore[arg-type]
+            kcell_array = [list(kcells)]  # ty:ignore[invalid-assignment]
         else:
-            kcell_array = kcells  # type: ignore[assignment]
+            kcell_array = kcells  # ty:ignore[invalid-assignment]
 
         x0: float = 0
         y0: float = 0
diff --git a/src/kfactory/instance.py b/src/kfactory/instance.py
index 61801faa0..2cded7657 100644
--- a/src/kfactory/instance.py
+++ b/src/kfactory/instance.py
@@ -7,7 +7,6 @@
     TYPE_CHECKING,
     Any,
     ClassVar,
-    Generic,
     Self,
     overload,
 )
@@ -16,13 +15,14 @@
 
 from .conf import PROPID, config, logger
 from .exceptions import (
+    AsymmetricMirrorRequiredError,
+    CrossSectionSymmetryMismatchError,
     PortLayerMismatchError,
     PortTypeMismatchError,
     PortWidthMismatchError,
 )
 from .geometry import DBUGeometricObject, GeometricObject, UMGeometricObject
 from .port import DPort, Port, ProtoPort
-from .typings import TUnit
 
 if TYPE_CHECKING:
     from ruamel.yaml.representer import BaseRepresenter, MappingNode
@@ -47,7 +47,24 @@
 __all__ = ["DInstance", "Instance", "ProtoInstance", "ProtoTInstance", "VInstance"]
 
 
-class ProtoInstance(GeometricObject[TUnit], Generic[TUnit]):
+def _right_dir_trans(t: kdb.Trans) -> tuple[int, int]:
+    """Return the port's "right" unit direction in world coords.
+
+    A port faces +x in its local frame; its profile extends in y, with `+y`
+    being the "right" side. Applying `t` to the unit `+y` vector gives the
+    world-frame right direction.
+    """
+    v = t.trans(kdb.Point(0, 1)) - t.trans(kdb.Point(0, 0))
+    return (v.x, v.y)
+
+
+def _right_dir_dcplx(t: kdb.DCplxTrans) -> tuple[float, float]:
+    """DCplxTrans equivalent of `_right_dir_trans`. See its docstring."""
+    v = t.trans(kdb.DPoint(0, 1)) - t.trans(kdb.DPoint(0, 0))
+    return (v.x, v.y)
+
+
+class ProtoInstance[T: (int, float)](GeometricObject[T]):
     """Base class for instances."""
 
     _kcl: KCLayout
@@ -82,13 +99,13 @@ def cell_name(self) -> str | None:
     @abstractmethod
     def __getitem__(
         self, key: int | str | tuple[int | str | None, int, int] | None
-    ) -> ProtoPort[TUnit]: ...
+    ) -> ProtoPort[T]: ...
     @property
     @abstractmethod
-    def ports(self) -> ProtoInstancePorts[TUnit, ProtoInstance[TUnit]]: ...
+    def ports(self) -> ProtoInstancePorts[T, ProtoInstance[T]]: ...
 
 
-class ProtoTInstance(ProtoInstance[TUnit], Generic[TUnit]):
+class ProtoTInstance[T: (int, float)](ProtoInstance[T]):
     _instance: kdb.Instance
 
     @property
@@ -118,7 +135,7 @@ def to_dtype(self) -> DInstance:
     def __getattr__(self, name: str) -> Any:
         """If we don't have an attribute, get it from the instance."""
         try:
-            return super().__getattr__(name)  # type: ignore[misc]
+            return super().__getattr__(name)  # ty:ignore[unresolved-attribute]
         except Exception:
             return getattr(self._instance, name)
 
@@ -147,12 +164,12 @@ def name(self, value: str | None) -> None:
 
     @property
     @abstractmethod
-    def parent_cell(self) -> ProtoTKCell[TUnit]: ...
+    def parent_cell(self) -> ProtoTKCell[T]: ...
 
     @property
     def purpose(self) -> str | None:
         """Purpose value of instance in GDS."""
-        return self._instance.property(PROPID.PURPOSE)  # type: ignore[no-any-return]
+        return self._instance.property(PROPID.PURPOSE)
 
     @purpose.setter
     def purpose(self, value: str | None) -> None:
@@ -169,7 +186,7 @@ def cell_index(self, value: int) -> None:
 
     @property
     @abstractmethod
-    def cell(self) -> ProtoTKCell[TUnit]:
+    def cell(self) -> ProtoTKCell[T]:
         """Parent KCell  of the Instance."""
         ...
 
@@ -179,13 +196,13 @@ def cell(self, value: ProtoTKCell[Any]) -> None: ...
 
     @property
     @abstractmethod
-    def ports(self) -> ProtoTInstancePorts[TUnit]:
+    def ports(self) -> ProtoTInstancePorts[T]:
         """Ports of the instance."""
         ...
 
     @property
     @abstractmethod
-    def pins(self) -> ProtoTInstancePins[TUnit]:
+    def pins(self) -> ProtoTInstancePins[T]:
         """Ports of the instance."""
         ...
 
@@ -196,7 +213,7 @@ def a(self) -> kdb.Vector:
 
     @a.setter
     def a(self, vec: kdb.Vector | kdb.DVector) -> None:
-        self._instance.a = vec  # type: ignore[assignment]
+        self._instance.a = vec  # ty:ignore[invalid-assignment]
 
     @property
     def b(self) -> kdb.Vector:
@@ -205,7 +222,7 @@ def b(self) -> kdb.Vector:
 
     @b.setter
     def b(self, vec: kdb.Vector | kdb.DVector) -> None:
-        self._instance.b = vec  # type: ignore[assignment]
+        self._instance.b = vec  # ty:ignore[invalid-assignment]
 
     @property
     def cell_inst(self) -> kdb.CellInstArray:
@@ -214,7 +231,7 @@ def cell_inst(self) -> kdb.CellInstArray:
 
     @cell_inst.setter
     def cell_inst(self, cell_inst: kdb.CellInstArray | kdb.DCellInstArray) -> None:
-        self._instance.cell_inst = cell_inst  # type: ignore[assignment]
+        self._instance.cell_inst = cell_inst  # ty:ignore[invalid-assignment]
 
     @property
     def cplx_trans(self) -> kdb.ICplxTrans:
@@ -226,7 +243,7 @@ def cplx_trans(self) -> kdb.ICplxTrans:
 
     @cplx_trans.setter
     def cplx_trans(self, trans: kdb.ICplxTrans | kdb.DCplxTrans) -> None:
-        self._instance.cplx_trans = trans  # type: ignore[assignment]
+        self._instance.cplx_trans = trans  # ty:ignore[invalid-assignment]
 
     @property
     def dcplx_trans(self) -> kdb.DCplxTrans:
@@ -262,7 +279,7 @@ def trans(self) -> kdb.Trans:
 
     @trans.setter
     def trans(self, trans: kdb.Trans | kdb.DTrans) -> None:
-        self._instance.trans = trans  # type: ignore[assignment]
+        self._instance.trans = trans  # ty:ignore[invalid-assignment]
 
     @property
     def na(self) -> int:
@@ -399,7 +416,7 @@ def connect(
                     "complex connections (non-90 degree and floating point ports) use"
                     "route_cplx instead"
                 )
-            op = Port(base=other.ports[other_port_name].base)  # type: ignore[index]
+            op = Port(base=other.ports[other_port_name].base)  # ty:ignore[invalid-argument-type]
         if isinstance(port, ProtoPort):
             p = Port(base=port.base.transformed(self.dcplx_trans.inverted()))
         else:
@@ -408,20 +425,29 @@ def connect(
         assert isinstance(p, Port)
         assert isinstance(op, Port)
 
+        if p.base.is_symmetric() != op.base.is_symmetric():
+            raise CrossSectionSymmetryMismatchError(p, op)
         if p.width != op.width and not allow_width_mismatch:
             raise PortWidthMismatchError(self, other, p, op)
         if p.layer != op.layer and not allow_layer_mismatch:
             raise PortLayerMismatchError(self.cell.kcl, self, other, p, op)
         if p.port_type != op.port_type and not allow_type_mismatch:
             raise PortTypeMismatchError(self, other, p, op)
+        # For two ports carrying the same asymmetric cross section, the
+        # profile asymmetry must align in world coordinates after the
+        # connection — i.e. both ports' world "right" direction must match.
+        # This depends on the actual resulting inst.trans (not just the
+        # `mirror` flag), so compute the would-be world trans of p and verify.
+        same_asym = (
+            p.base.asymmetric_cross_section is not None
+            and p.base.asymmetric_cross_section == op.base.asymmetric_cross_section
+        )
         if p.base.dcplx_trans or op.base.dcplx_trans:
             dconn_trans = kdb.DCplxTrans.M90 if mirror else kdb.DCplxTrans.R180
+            extra_dmirror_y: float | None = None
             match (use_mirror, use_angle):
                 case True, True:
-                    dcplx_trans = (
-                        op.dcplx_trans * dconn_trans * p.dcplx_trans.inverted()
-                    )
-                    self._instance.dcplx_trans = dcplx_trans
+                    candidate = op.dcplx_trans * dconn_trans * p.dcplx_trans.inverted()
                 case False, True:
                     dconn_trans = (
                         kdb.DCplxTrans.M90
@@ -430,42 +456,70 @@ def connect(
                     )
                     opt = op.dcplx_trans
                     opt.mirror = False
-                    dcplx_trans = opt * dconn_trans * p.dcplx_trans.inverted()
-                    self._instance.dcplx_trans = dcplx_trans
+                    candidate = opt * dconn_trans * p.dcplx_trans.inverted()
                 case False, False:
-                    self._instance.dcplx_trans = kdb.DCplxTrans(
-                        op.dcplx_trans.disp - p.dcplx_trans.disp
-                    )
+                    candidate = kdb.DCplxTrans(op.dcplx_trans.disp - p.dcplx_trans.disp)
                 case True, False:
-                    self._instance.dcplx_trans = kdb.DCplxTrans(
-                        op.dcplx_trans.disp - p.dcplx_trans.disp
-                    )
-                    self.dmirror_y(op.dcplx_trans.disp.y)
+                    candidate = kdb.DCplxTrans(op.dcplx_trans.disp - p.dcplx_trans.disp)
+                    extra_dmirror_y = op.dcplx_trans.disp.y
                 case _:
                     raise NotImplementedError("This shouldn't happen")
 
+            if same_asym:
+                final = candidate
+                if extra_dmirror_y is not None:
+                    final = kdb.DCplxTrans(1, 0, True, 0, 2 * extra_dmirror_y) * final
+                p_world = final * p.dcplx_trans
+                if _right_dir_dcplx(p_world) != _right_dir_dcplx(op.dcplx_trans):
+                    raise AsymmetricMirrorRequiredError(p, op)
+
+            self._instance.dcplx_trans = candidate
+            if extra_dmirror_y is not None:
+                self.dmirror_y(extra_dmirror_y)
+
         else:
             conn_trans = kdb.Trans.M90 if mirror else kdb.Trans.R180
+            extra_dmirror_y_t: float | None = None
             match (use_mirror, use_angle):
                 case True, True:
-                    trans = op.trans * conn_trans * p.trans.inverted()
-                    self._instance.trans = trans
+                    candidate_t = op.trans * conn_trans * p.trans.inverted()
                 case False, True:
                     conn_trans = (
                         kdb.Trans.M90 if mirror ^ self.trans.mirror else kdb.Trans.R180
                     )
                     op = op.copy()
                     op.trans.mirror = False
-                    trans = op.trans * conn_trans * p.trans.inverted()
-                    self._instance.trans = trans
+                    candidate_t = op.trans * conn_trans * p.trans.inverted()
                 case False, False:
-                    self._instance.trans = kdb.Trans(op.trans.disp - p.trans.disp)
+                    candidate_t = kdb.Trans(op.trans.disp - p.trans.disp)
                 case True, False:
-                    self._instance.trans = kdb.Trans(op.trans.disp - p.trans.disp)
-                    self.dmirror_y(op.dcplx_trans.disp.y)
+                    candidate_t = kdb.Trans(op.trans.disp - p.trans.disp)
+                    extra_dmirror_y_t = op.dcplx_trans.disp.y
                 case _:
                     raise NotImplementedError("This shouldn't happen")
 
+            if same_asym:
+                if extra_dmirror_y_t is not None:
+                    # dmirror_y converts inst trans to DCplxTrans; model that.
+                    final_dcplx = kdb.DCplxTrans(
+                        1, 0, True, 0, 2 * extra_dmirror_y_t
+                    ) * kdb.DCplxTrans(candidate_t.to_dtype(self.cell.kcl.dbu))
+                    p_world_d = final_dcplx * kdb.DCplxTrans(
+                        p.trans.to_dtype(self.cell.kcl.dbu)
+                    )
+                    if _right_dir_dcplx(p_world_d) != _right_dir_dcplx(
+                        kdb.DCplxTrans(op.trans.to_dtype(self.cell.kcl.dbu))
+                    ):
+                        raise AsymmetricMirrorRequiredError(p, op)
+                else:
+                    p_world_t = candidate_t * p.trans
+                    if _right_dir_trans(p_world_t) != _right_dir_trans(op.trans):
+                        raise AsymmetricMirrorRequiredError(p, op)
+
+            self._instance.trans = candidate_t
+            if extra_dmirror_y_t is not None:
+                self.dmirror_y(extra_dmirror_y_t)
+
         return self
 
     def __repr__(self) -> str:
@@ -901,27 +955,26 @@ def insert_into_flat(
 
         if trans is None:
             trans = kdb.DCplxTrans()
+        trans_ = trans * self.trans
 
         if isinstance(self.cell, VKCell):
             for layer, shapes in self.cell.shapes().items():
-                for shape in shapes.transform(trans * self.trans):
+                for shape in shapes.transform(trans_):
                     if isinstance(cell, ProtoTKCell) and isinstance(
                         shape, kdb.DPolygon | kdb.DSimplePolygon
                     ):
                         cell.shapes(layer).insert(shape.to_itype(cell.kcl.dbu))
                     else:
-                        cell.shapes(layer).insert(shape)
+                        cell.shapes(layer).insert(shape)  # ty:ignore[no-matching-overload]
             for inst in self.cell.insts:
                 if levels is not None:
                     if levels > 0:
-                        inst.insert_into_flat(
-                            cell, trans=trans * self.trans, levels=levels - 1
-                        )
+                        inst.insert_into_flat(cell, trans=trans_, levels=levels - 1)
                     else:
                         assert isinstance(cell, ProtoTKCell)
-                        inst.insert_into(cell, trans=trans * self.trans)
+                        inst.insert_into(cell, trans=trans_)
                 else:
-                    inst.insert_into_flat(cell, trans=trans * self.trans)
+                    inst.insert_into_flat(cell, trans=trans_)
 
         else:
             assert isinstance(self.cell, ProtoTKCell)
@@ -930,16 +983,20 @@ def insert_into_flat(
                     "Levels are not supported if the inserted Instance is a KCell."
                 )
             if isinstance(cell, ProtoTKCell):
-                for layer in cell.kcl.layer_indexes():
+                assert self.cell.kcl is cell.kcl, (
+                    "Inserting a KCell into a KCell across different KCLayouts"
+                    " is currently not supported"
+                )
+                for layer in self.cell.kcl.layer_indexes():
                     reg = kdb.Region(self.cell.kdb_cell.begin_shapes_rec(layer))
-                    reg.transform(kdb.ICplxTrans((trans * self.trans), cell.kcl.dbu))
+                    reg.transform(kdb.ICplxTrans(trans_, self.cell.kcl.dbu))
                     cell.shapes(layer).insert(reg)
             else:
-                for layer, shapes in self.cell._shapes.items():
-                    for shape in shapes.transform(trans * self.trans):
-                        cell.shapes(layer).insert(shape)
-                for vinst in self.cell.insts:
-                    vinst.insert_into_flat(cell, trans=trans * self.trans)
+                for layer in self.cell.kcl.layer_indexes():
+                    for poly in kdb.Region(self.cell.kdb_cell.begin_shapes_rec(layer)):
+                        cell.shapes(layer).insert(
+                            poly.to_dtype(self.cell.kcl.dbu).transformed(trans_)
+                        )
 
     @overload
     def connect(
@@ -1035,7 +1092,7 @@ def connect(
                     "complex connections (non-90 degree and floating point ports) use"
                     "route_cplx instead"
                 )
-            op = Port(base=other.ports[other_port_name].base)  # type: ignore[index]
+            op = Port(base=other.ports[other_port_name].base)  # ty:ignore[invalid-argument-type]
         else:
             op = Port(base=other.base)
         if isinstance(port, ProtoPort):
@@ -1046,17 +1103,23 @@ def connect(
         assert isinstance(p, Port)
         assert isinstance(op, Port)
 
+        if p.base.is_symmetric() != op.base.is_symmetric():
+            raise CrossSectionSymmetryMismatchError(p, op)
         if p.width != op.width and not allow_width_mismatch:
             raise PortWidthMismatchError(self, other, p, op)
         if p.layer != op.layer and not allow_layer_mismatch:
             raise PortLayerMismatchError(self.cell.kcl, self, other, p, op)
         if p.port_type != op.port_type and not allow_type_mismatch:
             raise PortTypeMismatchError(self, other, p, op)
+        same_asym = (
+            p.base.asymmetric_cross_section is not None
+            and p.base.asymmetric_cross_section == op.base.asymmetric_cross_section
+        )
         dconn_trans = kdb.DCplxTrans.M90 if mirror else kdb.DCplxTrans.R180
+        extra_dmirror_y: float | None = None
         match (use_mirror, use_angle):
             case True, True:
-                trans = op.dcplx_trans * dconn_trans * p.dcplx_trans.inverted()
-                self.trans = trans
+                candidate = op.dcplx_trans * dconn_trans * p.dcplx_trans.inverted()
             case False, True:
                 dconn_trans = (
                     kdb.DCplxTrans.M90
@@ -1065,15 +1128,26 @@ def connect(
                 )
                 opt = op.dcplx_trans
                 opt.mirror = False
-                dcplx_trans = opt * dconn_trans * p.dcplx_trans.inverted()
-                self.trans = dcplx_trans
+                candidate = opt * dconn_trans * p.dcplx_trans.inverted()
             case False, False:
-                self.trans = kdb.DCplxTrans(op.dcplx_trans.disp - p.dcplx_trans.disp)
+                candidate = kdb.DCplxTrans(op.dcplx_trans.disp - p.dcplx_trans.disp)
             case True, False:
-                self.trans = kdb.DCplxTrans(op.dcplx_trans.disp - p.dcplx_trans.disp)
-                self.mirror_y(op.dcplx_trans.disp.y)
+                candidate = kdb.DCplxTrans(op.dcplx_trans.disp - p.dcplx_trans.disp)
+                extra_dmirror_y = op.dcplx_trans.disp.y
             case _:
-                ...
+                raise NotImplementedError("This shouldn't happen")
+
+        if same_asym:
+            final = candidate
+            if extra_dmirror_y is not None:
+                final = kdb.DCplxTrans(1, 0, True, 0, 2 * extra_dmirror_y) * final
+            p_world = final * p.dcplx_trans
+            if _right_dir_dcplx(p_world) != _right_dir_dcplx(op.dcplx_trans):
+                raise AsymmetricMirrorRequiredError(p, op)
+
+        self.trans = candidate
+        if extra_dmirror_y is not None:
+            self.mirror_y(extra_dmirror_y)
 
         return self
 
diff --git a/src/kfactory/instance_group.py b/src/kfactory/instance_group.py
index b983e9d33..bdadf1fc0 100644
--- a/src/kfactory/instance_group.py
+++ b/src/kfactory/instance_group.py
@@ -2,7 +2,7 @@
 
 from abc import ABC, abstractmethod
 from functools import cached_property
-from typing import TYPE_CHECKING, Any, Generic, NoReturn, overload
+from typing import TYPE_CHECKING, Any, NoReturn, overload
 
 from . import kdb
 from .conf import config
@@ -15,7 +15,6 @@
 from .instance import DInstance, Instance, ProtoTInstance, VInstance
 from .port import BasePort, DPort, Port, ProtoPort
 from .ports import DCreatePort, DPorts, ICreatePort, Ports, ProtoPorts
-from .typings import TInstance_co, TTInstance_co, TUnit
 
 if TYPE_CHECKING:
     from collections.abc import Iterator, Sequence
@@ -31,13 +30,15 @@
 ]
 
 
-class ProtoInstanceGroup(GeometricObject[TUnit], Generic[TUnit, TInstance_co], ABC):  # noqa: PYI059
-    insts: list[TInstance_co]
+class ProtoInstanceGroup[T: (int, float), TI: ProtoTInstance[Any] | VInstance[Any]](
+    GeometricObject[T], ABC
+):
+    insts: list[TI]
     _base_ports: list[BasePort]
 
     def __init__(
         self,
-        insts: Sequence[TInstance_co] | None = None,
+        insts: Sequence[TI] | None = None,
         ports: Sequence[ProtoPort[Any]] | None = None,
     ) -> None:
         """Initialize the InstanceGroup."""
@@ -58,7 +59,7 @@ def __str__(self) -> str:
 
     @cached_property
     @abstractmethod
-    def ports(self) -> ProtoPorts[TUnit]:
+    def ports(self) -> ProtoPorts[T]:
         """Ports of the instance."""
         ...
 
@@ -103,7 +104,7 @@ def dbbox(self, layer: int | None = None) -> kdb.DBox:
             bb += _bb
         return bb
 
-    def __iter__(self) -> Iterator[TInstance_co]:
+    def __iter__(self) -> Iterator[TI]:
         return iter(self.insts)
 
     @overload
@@ -256,10 +257,9 @@ def connect(
                     raise NotImplementedError("This shouldn't happen")
 
 
-class ProtoTInstanceGroup(
-    ProtoInstanceGroup[TUnit, TTInstance_co],
-    GeometricObject[TUnit],
-    Generic[TUnit, TTInstance_co],
+class ProtoTInstanceGroup[T: (int, float), TI: ProtoTInstance[Any]](
+    ProtoInstanceGroup[T, TI],
+    GeometricObject[T],
 ):
     def to_itype(self) -> InstanceGroup:
         return InstanceGroup(insts=[inst.to_itype() for inst in self.insts])
diff --git a/src/kfactory/instance_pins.py b/src/kfactory/instance_pins.py
index b2de50246..ac41e03f2 100644
--- a/src/kfactory/instance_pins.py
+++ b/src/kfactory/instance_pins.py
@@ -1,28 +1,27 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Any, Generic, Literal, cast
+from typing import TYPE_CHECKING, Any, Literal, cast
 
 from . import kdb
 from .conf import config
-from .instance import DInstance, Instance, ProtoTInstance, VInstance
+from .instance import DInstance, Instance, ProtoInstance, ProtoTInstance, VInstance
 from .pin import BasePin, DPin, Pin, ProtoPin, filter_type_reg
 from .pins import DPins, Pins, ProtoPins
-from .typings import TInstance_co, TUnit
 from .utilities import pprint_pins
 
 if TYPE_CHECKING:
     from collections.abc import Callable, Iterable, Iterator, Sequence
 
 
-class HasCellPins(ABC, Generic[TUnit]):
+class HasCellPins[T: (int, float)](ABC):
     @property
     @abstractmethod
-    def cell_pins(self) -> ProtoPins[TUnit]: ...
+    def cell_pins(self) -> ProtoPins[T]: ...
 
 
-class ProtoInstancePins(HasCellPins[TUnit], ABC, Generic[TUnit, TInstance_co]):
-    instance: TInstance_co
+class ProtoInstancePins[T: (int, float), TI: ProtoInstance[Any]](HasCellPins[T], ABC):
+    instance: TI
 
     @abstractmethod
     def __len__(self) -> int: ...
@@ -31,10 +30,10 @@ def __len__(self) -> int: ...
     def __contains__(self, pin: str | ProtoPin[Any]) -> bool: ...
 
     @abstractmethod
-    def __getitem__(self, key: int | str | None) -> ProtoPin[TUnit]: ...
+    def __getitem__(self, key: int | str) -> ProtoPin[T]: ...
 
     @abstractmethod
-    def __iter__(self) -> Iterator[ProtoPin[TUnit]]: ...
+    def __iter__(self) -> Iterator[ProtoPin[T]]: ...
 
     def __repr__(self) -> str:
         return f"{self.__class__.__name__}(n={len(self)})"
@@ -43,9 +42,7 @@ def __str__(self) -> str:
         return f"{self.__class__.__name__}(pins={list(self)})"
 
 
-class ProtoTInstancePins(
-    ProtoInstancePins[TUnit, ProtoTInstance[TUnit]], ABC, Generic[TUnit]
-):
+class ProtoTInstancePins[T: (int, float)](ProtoInstancePins[T, ProtoTInstance[T]], ABC):
     """Pins of an Instance.
 
     These act as virtual pins as the centers needs to change if the
@@ -59,7 +56,7 @@ class ProtoTInstancePins(
             This provides a way to dynamically calculate the pins.
     """
 
-    instance: ProtoTInstance[TUnit]
+    instance: ProtoTInstance[T]
 
     def __len__(self) -> int:
         """Return Pin count."""
@@ -74,7 +71,7 @@ def __contains__(self, pin: str | ProtoPin[Any]) -> bool:
         return any(_pin.name == pin for _pin in self.instance.pins)
 
     @property
-    def pins(self) -> ProtoTInstancePins[TUnit]:
+    def pins(self) -> ProtoTInstancePins[T]:
         return self.instance.pins
 
     @property
@@ -85,7 +82,7 @@ def filter(
         self,
         pin_type: str | None = None,
         regex: str | None = None,
-    ) -> Sequence[ProtoPin[TUnit]]:
+    ) -> Sequence[ProtoPin[T]]:
         """Filter pins by name.
 
         Args:
@@ -94,12 +91,10 @@ def filter(
         Returns:
             Filtered list of pins.
         """
-        pins: Iterable[ProtoPin[TUnit]] = list(self)
+        pins: Iterable[ProtoPin[T]] = list(self)
         return list(filter_type_reg(pins, pin_type=pin_type, regex=regex))
 
-    def __getitem__(
-        self, key: int | str | tuple[int | str | None, int, int] | None
-    ) -> ProtoPin[TUnit]:
+    def __getitem__(self, key: int | str | tuple[int | str, int, int]) -> ProtoPin[T]:
         """Returns pin from instance.
 
         The key can either be an integer, in which case the nth pin is
@@ -123,7 +118,7 @@ def __getitem__(
         """
         if not self.instance.is_regular_array():
             try:
-                p = self.cell_pins[cast("int | str | None", key)]
+                p = self.cell_pins[cast("int | str", key)]
                 if not self.instance.is_complex():
                     return p.copy(self.instance.trans)
                 return p.copy(self.instance.dcplx_trans)
@@ -158,9 +153,9 @@ def __getitem__(
 
     @property
     @abstractmethod
-    def cell_pins(self) -> ProtoPins[TUnit]: ...
+    def cell_pins(self) -> ProtoPins[T]: ...
 
-    def each_pin(self) -> Iterator[ProtoPin[TUnit]]:
+    def each_pin(self) -> Iterator[ProtoPin[T]]:
         """Create a copy of the pins to iterate through."""
         if not self.instance.is_regular_array():
             if not self.instance.is_complex():
@@ -189,9 +184,9 @@ def each_pin(self) -> Iterator[ProtoPin[TUnit]]:
             )
 
     @abstractmethod
-    def __iter__(self) -> Iterator[ProtoPin[TUnit]]: ...
+    def __iter__(self) -> Iterator[ProtoPin[T]]: ...
 
-    def each_by_array_coord(self) -> Iterator[tuple[int, int, ProtoPin[TUnit]]]:
+    def each_by_array_coord(self) -> Iterator[tuple[int, int, ProtoPin[T]]]:
         if not self.instance.is_regular_array():
             if not self.instance.is_complex():
                 yield from ((0, 0, p.copy(self.instance.trans)) for p in self.cell_pins)
@@ -305,9 +300,7 @@ def filter(
         pins: Iterable[Pin] = list(self)
         return list(filter_type_reg(pins, pin_type=pin_type, regex=regex))
 
-    def __getitem__(
-        self, key: int | str | tuple[int | str | None, int, int] | None
-    ) -> Pin:
+    def __getitem__(self, key: int | str | tuple[int | str, int, int]) -> Pin:
         return Pin(base=super().__getitem__(key).base)
 
     def __iter__(self) -> Iterator[Pin]:
@@ -343,9 +336,7 @@ def filter(
         pins: Iterable[DPin] = list(self)
         return list(filter_type_reg(pins, pin_type=pin_type, regex=regex))
 
-    def __getitem__(
-        self, key: int | str | tuple[int | str | None, int, int] | None
-    ) -> DPin:
+    def __getitem__(self, key: int | str | tuple[int | str, int, int]) -> DPin:
         return DPin(base=super().__getitem__(key).base)
 
     def __iter__(self) -> Iterator[DPin]:
@@ -384,7 +375,7 @@ def __len__(self) -> int:
         """Return Pin count."""
         return len(self.cell_pins)
 
-    def __getitem__(self, key: int | str | None) -> DPin:
+    def __getitem__(self, key: int | str) -> DPin:
         """Get a pin by name."""
         p = self.cell_pins[key]
         return p.copy(self.instance.trans)
diff --git a/src/kfactory/instance_ports.py b/src/kfactory/instance_ports.py
index 844602c1e..d9066cc02 100644
--- a/src/kfactory/instance_ports.py
+++ b/src/kfactory/instance_ports.py
@@ -1,11 +1,11 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Any, Generic, cast
+from typing import TYPE_CHECKING, Any, cast
 
 from . import kdb
 from .conf import config
-from .instance import DInstance, Instance, ProtoTInstance, VInstance
+from .instance import DInstance, Instance, ProtoInstance, ProtoTInstance, VInstance
 from .port import (
     BasePort,
     DPort,
@@ -18,7 +18,6 @@
     filter_regex,
 )
 from .ports import DPorts, Ports, ProtoPorts
-from .typings import TInstance_co, TUnit
 from .utilities import pprint_ports
 
 if TYPE_CHECKING:
@@ -35,14 +34,14 @@
 ]
 
 
-class HasCellPorts(ABC, Generic[TUnit]):
+class HasCellPorts[T: (int, float)](ABC):
     @property
     @abstractmethod
-    def cell_ports(self) -> ProtoPorts[TUnit]: ...
+    def cell_ports(self) -> ProtoPorts[T]: ...
 
 
-class ProtoInstancePorts(HasCellPorts[TUnit], ABC, Generic[TUnit, TInstance_co]):
-    instance: TInstance_co
+class ProtoInstancePorts[T: (int, float), TI: ProtoInstance[Any]](HasCellPorts[T], ABC):
+    instance: TI
 
     @abstractmethod
     def __len__(self) -> int: ...
@@ -51,10 +50,10 @@ def __len__(self) -> int: ...
     def __contains__(self, port: str | ProtoPort[Any]) -> bool: ...
 
     @abstractmethod
-    def __getitem__(self, key: int | str | None) -> ProtoPort[TUnit]: ...
+    def __getitem__(self, key: int | str | None) -> ProtoPort[T]: ...
 
     @abstractmethod
-    def __iter__(self) -> Iterator[ProtoPort[TUnit]]: ...
+    def __iter__(self) -> Iterator[ProtoPort[T]]: ...
 
     def __repr__(self) -> str:
         return f"{self.__class__.__name__}(n={len(self)})"
@@ -63,8 +62,8 @@ def __str__(self) -> str:
         return f"{self.__class__.__name__}(ports={list(self)})"
 
 
-class ProtoTInstancePorts(
-    ProtoInstancePorts[TUnit, ProtoTInstance[TUnit]], ABC, Generic[TUnit]
+class ProtoTInstancePorts[T: (int, float)](
+    ProtoInstancePorts[T, ProtoTInstance[T]], ABC
 ):
     """Ports of an Instance.
 
@@ -79,7 +78,7 @@ class ProtoTInstancePorts(
             This provides a way to dynamically calculate the ports.
     """
 
-    instance: ProtoTInstance[TUnit]
+    instance: ProtoTInstance[T]
 
     def __len__(self) -> int:
         """Return Port count."""
@@ -94,7 +93,7 @@ def __contains__(self, port: str | ProtoPort[Any]) -> bool:
         return any(_port.name == port for _port in self.instance.ports)
 
     @property
-    def ports(self) -> ProtoTInstancePorts[TUnit]:
+    def ports(self) -> ProtoTInstancePorts[T]:
         return self.instance.ports
 
     @property
@@ -108,7 +107,7 @@ def filter(
         layer: LayerEnum | int | None = None,
         port_type: str | None = None,
         regex: str | None = None,
-    ) -> Sequence[ProtoPort[TUnit]]:
+    ) -> Sequence[ProtoPort[T]]:
         """Filter ports by name.
 
         Args:
@@ -118,7 +117,7 @@ def filter(
             port_type: Filter by port type.
             regex: Filter by regex of the name.
         """
-        ports: Iterable[ProtoPort[TUnit]] = list(self.ports)
+        ports: Iterable[ProtoPort[T]] = list(self.ports)
         if regex:
             ports = filter_regex(ports, regex)
         if layer is not None:
@@ -133,7 +132,7 @@ def filter(
 
     def __getitem__(
         self, key: int | str | tuple[int | str | None, int, int] | None
-    ) -> ProtoPort[TUnit]:
+    ) -> ProtoPort[T]:
         """Returns port from instance.
 
         The key can either be an integer, in which case the nth port is
@@ -185,9 +184,9 @@ def __getitem__(
 
     @property
     @abstractmethod
-    def cell_ports(self) -> ProtoPorts[TUnit]: ...
+    def cell_ports(self) -> ProtoPorts[T]: ...
 
-    def each_port(self) -> Iterator[ProtoPort[TUnit]]:
+    def each_port(self) -> Iterator[ProtoPort[T]]:
         """Create a copy of the ports to iterate through."""
         if not self.instance.is_regular_array():
             if not self.instance.is_complex():
@@ -216,9 +215,9 @@ def each_port(self) -> Iterator[ProtoPort[TUnit]]:
             )
 
     @abstractmethod
-    def __iter__(self) -> Iterator[ProtoPort[TUnit]]: ...
+    def __iter__(self) -> Iterator[ProtoPort[T]]: ...
 
-    def each_by_array_coord(self) -> Iterator[tuple[int, int, ProtoPort[TUnit]]]:
+    def each_by_array_coord(self) -> Iterator[tuple[int, int, ProtoPort[T]]]:
         if not self.instance.is_regular_array():
             if not self.instance.is_complex():
                 yield from (
diff --git a/src/kfactory/instances.py b/src/kfactory/instances.py
index 38a07ac00..861e657fc 100644
--- a/src/kfactory/instances.py
+++ b/src/kfactory/instances.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Any, Generic
+from typing import TYPE_CHECKING, Any
 
 import klayout.db as kdb
 
@@ -13,12 +13,12 @@
     ProtoTInstance,
     VInstance,
 )
-from .typings import TInstance_co, TUnit
 
 if TYPE_CHECKING:
     from collections.abc import Iterator
 
     from .kcell import TKCell
+    from .typings import TInstance_co
 
 __all__ = [
     "DInstances",
@@ -29,9 +29,9 @@
 ]
 
 
-class ProtoInstances(ABC, Generic[TUnit, TInstance_co]):
+class ProtoInstances[T: (int, float), I: ProtoInstance[Any]](ABC):
     @abstractmethod
-    def __iter__(self) -> Iterator[ProtoInstance[TUnit]]: ...
+    def __iter__(self) -> Iterator[ProtoInstance[T]]: ...
 
     @abstractmethod
     def __len__(self) -> int: ...
@@ -40,7 +40,7 @@ def __len__(self) -> int: ...
     def __delitem__(self, item: TInstance_co | int) -> None: ...
 
     @abstractmethod
-    def __getitem__(self, key: str | int) -> ProtoInstance[TUnit]: ...
+    def __getitem__(self, key: str | int) -> ProtoInstance[T]: ...
 
     @abstractmethod
     def __contains__(self, key: str | int | TInstance_co) -> bool: ...
@@ -60,7 +60,7 @@ def __eq__(self, other: object) -> bool:
         return list(self) == list(other)
 
 
-class ProtoTInstances(ProtoInstances[TUnit, ProtoTInstance[TUnit]], ABC):
+class ProtoTInstances[T: (int, float)](ProtoInstances[T, ProtoTInstance[T]], ABC):
     _tkcell: TKCell
 
     def __init__(self, cell: TKCell) -> None:
@@ -68,7 +68,7 @@ def __init__(self, cell: TKCell) -> None:
         self._tkcell = cell
 
     @abstractmethod
-    def __iter__(self) -> Iterator[ProtoTInstance[TUnit]]: ...
+    def __iter__(self) -> Iterator[ProtoTInstance[T]]: ...
 
     def __len__(self) -> int:
         """Length of the instances."""
@@ -107,7 +107,7 @@ def __contains__(self, key: str | int | ProtoTInstance[Any]) -> bool:
             return False
 
     @abstractmethod
-    def __getitem__(self, key: str | int) -> ProtoTInstance[TUnit]: ...
+    def __getitem__(self, key: str | int) -> ProtoTInstance[T]: ...
 
     def clear(self) -> None:
         for inst in self._insts:
diff --git a/src/kfactory/kcell.py b/src/kfactory/kcell.py
index ad3482877..795919abd 100644
--- a/src/kfactory/kcell.py
+++ b/src/kfactory/kcell.py
@@ -3,7 +3,7 @@
 Defines the [KCell][kfactory.kcell.KCell] providing klayout Cells with Ports
 and other convenience functions.
 
-[Instance][kfactory.kcell.Instance] are the kfactory instances used to also acquire
+[Instance][kfactory.instance.Instance] are the kfactory instances used to also acquire
 ports and other inf from instances.
 
 """
@@ -19,7 +19,6 @@
 import socket
 import subprocess
 from abc import ABC, abstractmethod
-from collections import defaultdict
 from collections.abc import (
     Callable,
     ItemsView,
@@ -35,15 +34,14 @@
     TYPE_CHECKING,
     Any,
     ClassVar,
-    Generic,
     Literal,
     Self,
-    TypeAlias,
+    cast,
     overload,
 )
 
 import ruamel.yaml
-from klayout import __version__ as _klayout_version  # type: ignore[attr-defined]
+from klayout import __version__ as _klayout_version
 from pydantic import (
     BaseModel,
     Field,
@@ -53,14 +51,25 @@
 from semver import Version
 
 from . import kdb, rdb
-from .conf import DEFAULT_TRANS, PROPID, CheckInstances, ShowFunction, config, logger
+from .checks import (
+    _collect_inst_ports,
+    dangling_ports_check,
+    instance_overlap_check,
+    port_mismatch_check,
+    shape_instance_overlap_check,
+)
+from .conf import DEFAULT_TRANS, CheckInstances, ShowFunction, config, logger
 from .cross_section import (
+    AsymmetricalCrossSection,
+    AsymmetricCrossSection,
     CrossSection,
+    DAsymmetricCrossSection,
     DCrossSection,
     SymmetricalCrossSection,
+    TAsymmetricCrossSection,
     TCrossSection,
 )
-from .exceptions import LockedError, MergeError
+from .exceptions import DuplicateCellNameError, LockedError, MergeError
 from .geometry import DBUGeometricObject, GeometricObject, UMGeometricObject
 from .instance import DInstance, Instance, ProtoInstance, ProtoTInstance, VInstance
 from .instances import (
@@ -72,7 +81,6 @@
 )
 from .layer import LayerEnum
 from .merge import MergeDiff
-from .netlist import Net, Netlist, NetlistPort, PortArrayRef, PortRef
 from .pin import BasePin, DPin, Pin, ProtoPin
 from .pins import DPins, Pins, ProtoPins
 from .port import (
@@ -81,9 +89,7 @@
     Port,
     PortCheck,
     ProtoPort,
-    create_port_error,
     port_check,
-    port_polygon,
 )
 from .ports import DCreatePort, DPorts, ICreatePort, Ports, ProtoPorts
 from .serialization import (
@@ -94,10 +100,15 @@
 )
 from .settings import Info, KCellSettings, KCellSettingsUnits
 from .shapes import VShapes
-from .typings import KC_co, MetaData, TBaseCell_co, TUnit
+from .typings import (
+    DShapeLike,
+    JSONSerializable,
+    KC_co,
+    MarkerConfig,
+    MetaData,
+    TBaseCell_co,
+)
 from .utilities import (
-    check_cell_ports,
-    check_inst_ports,
     get_build_path,
     instance_port_name,
     load_layout_options,
@@ -107,6 +118,7 @@
 if TYPE_CHECKING:
     from types import ModuleType
 
+    from kfnetlist import Net, Netlist
     from ruamel.yaml.representer import BaseRepresenter, MappingNode
 
     from .layout import KCLayout
@@ -133,6 +145,51 @@
 ]
 
 
+def _deduplicate_cell_names(layout: kdb.Layout, cell_indices: set[int]) -> None:
+    """Auto-rename cells with duplicate names so the layout can be written.
+
+    GDS/OASIS require unique cell names. When duplicates are found among
+    `cell_indices`, the first cell keeps its name and subsequent ones get
+    a ``$1``, ``$2``, … suffix (matching KLayout's own convention).
+    A warning is logged for each renamed cell.
+    """
+    from collections import defaultdict
+
+    name_to_indices: dict[str, list[int]] = defaultdict(list)
+    for ci in cell_indices:
+        c = layout.cell(ci)
+        if c is not None and not c._destroyed():
+            name_to_indices[c.name].append(ci)
+
+    duplicates = {
+        name: indices for name, indices in name_to_indices.items() if len(indices) > 1
+    }
+    if not duplicates:
+        return
+
+    for name, indices in duplicates.items():
+        # Keep the first cell, rename the rest
+        for ci in indices[1:]:
+            c = layout.cell(ci)
+            if c is None or c._destroyed():
+                continue
+            unique = layout.unique_cell_name(name)
+            was_locked = c.is_locked()
+            if was_locked:
+                c.locked = False
+            c.name = unique
+            if was_locked:
+                c.locked = True
+            logger.warning(
+                "Renamed duplicate cell {old!r} (cell_index={ci}) to {new!r}"
+                " before writing. Set `kf.config.debug_names = True` to catch"
+                " name conflicts earlier.",
+                old=name,
+                ci=ci,
+                new=unique,
+            )
+
+
 class BaseKCell(BaseModel, ABC, arbitrary_types_allowed=True):
     """KLayout cell and change its class to KCell.
 
@@ -194,8 +251,8 @@ def name(self) -> str | None: ...
     def name(self, value: str) -> None: ...
 
 
-class ProtoKCell(GeometricObject[TUnit], Generic[TUnit, TBaseCell_co], ABC):  # noqa: PYI059
-    _base: TBaseCell_co
+class ProtoKCell[T: (int, float), TB: BaseKCell[Any]](GeometricObject[T], ABC):
+    _base: TB
 
     @property
     def locked(self) -> bool:
@@ -250,7 +307,7 @@ def settings(self, value: KCellSettings) -> None:
     def settings_units(self) -> KCellSettingsUnits:
         """Dictionary containing the units of the settings.
 
-        Set by the [@cell][kfactory.kcell.KCLayout.cell] decorator.
+        Set by the [@cell][kfactory.layout.KCLayout.cell] decorator.
         """
         return self._base.settings_units
 
@@ -284,14 +341,14 @@ def base(self) -> TBaseCell_co:
 
     @property
     @abstractmethod
-    def insts(self) -> ProtoInstances[TUnit, ProtoInstance[TUnit]]: ...
+    def insts(self) -> ProtoInstances[T, ProtoInstance[T]]: ...
 
     @abstractmethod
     def shapes(self, layer: int | kdb.LayerInfo) -> kdb.Shapes | VShapes: ...
 
     @property
     @abstractmethod
-    def ports(self) -> ProtoPorts[TUnit]: ...
+    def ports(self) -> ProtoPorts[T]: ...
 
     @ports.setter
     @abstractmethod
@@ -299,7 +356,7 @@ def ports(self, new_ports: Iterable[ProtoPort[Any]]) -> None: ...
 
     @property
     @abstractmethod
-    def pins(self) -> ProtoPins[TUnit]: ...
+    def pins(self) -> ProtoPins[T]: ...
 
     @pins.setter
     @abstractmethod
@@ -311,7 +368,7 @@ def add_port(
         port: ProtoPort[Any],
         name: str | None = None,
         keep_mirror: bool = False,
-    ) -> ProtoPort[TUnit]:
+    ) -> ProtoPort[T]:
         """Add an existing port. E.g. from an instance to propagate the port.
 
         Args:
@@ -433,7 +490,7 @@ class TKCell(BaseKCell):
     def __getattr__(self, name: str) -> Any:
         """If KCell doesn't have an attribute, look in the KLayout Cell."""
         try:
-            return super().__getattr__(name)  # type: ignore[misc]
+            return super().__getattr__(name)  # ty:ignore[unresolved-attribute]
         except Exception:
             return getattr(self.kdb_cell, name)
 
@@ -470,125 +527,59 @@ def name(self, value: str) -> None:
             and not self.kcl.layout.cell(value).is_library_cell()
             and not self.is_library_cell()
         ):
-            stack = inspect.stack()
-            module = inspect.getmodule(stack[3].frame)
             tkcells = [
                 self.kcl.tkcells[cell.cell_index()]
                 for cell in self.kcl.layout.cells(value)
                 if not cell.is_library_cell()
             ]
 
+            conflicting = "\n".join(
+                f"  - {tkcell.name!r} (cell_index={tkcell.kdb_cell.cell_index()},"
+                f" function_name={tkcell.function_name!r},"
+                f" basename={tkcell.basename!r})"
+                for tkcell in tkcells
+            )
+
+            stack = inspect.stack()
+            module = inspect.getmodule(stack[3].frame)
+
             if module is not None and module.__name__ == "kfactory.layout":
-                frame_info = stack[5]
-                logger.opt(depth=2).error(
-                    "Name conflict in "
-                    f"{frame_info.frame.f_locals['f'].__code__.co_filename}::"
-                    f"{frame_info.frame.f_locals['f'].__name__} at line "
-                    f"{frame_info.frame.f_locals['f'].__code__.co_firstlineno}\n"
-                    f"Renaming {self.name} (cell_index={self.kdb_cell.cell_index()}) to"
-                    f" {value} would cause it to be named the same as:\n"
-                    + "\n".join(
-                        f" - {tkcell.name} (cell_index={tkcell.kdb_cell.cell_index()}),"
-                        f" function_name={tkcell.function_name},"
-                        f" basename={tkcell.basename}"
-                        for tkcell in tkcells
-                    )
-                )
-                if config.debug_names:
-                    raise ValueError(
-                        "Name conflict in "
-                        f"{frame_info.frame.f_locals['f'].__code__.co_filename}::"
-                        f"{frame_info.frame.f_locals['f'].__name__} at line "
-                        f"{frame_info.frame.f_locals['f'].__code__.co_firstlineno}\n"
-                        f"Renaming {self.name} (cell_index={self.kdb_cell.cell_index()}"
-                        f") to {value} would cause it to be named the same as:\n"
-                        + "\n".join(
-                            f" - {tkcell.name} "
-                            f"(cell_index={tkcell.kdb_cell.cell_index()}),"
-                            f" function_name={tkcell.function_name},"
-                            f" basename={tkcell.basename}"
-                            for tkcell in tkcells
-                        )
+                fi = stack[5]
+                f_obj = fi.frame.f_locals.get("f")
+                if f_obj is not None:
+                    location = (
+                        f"{f_obj.__code__.co_filename}::{f_obj.__name__}"
+                        f" at line {f_obj.__code__.co_firstlineno}"
                     )
+                else:
+                    location = f"{fi.filename}::{fi.function} at line {fi.lineno}"
+                log_depth = 2
             else:
-                frame_info = stack[3]
+                fi = stack[3]
                 if module is not None:
-                    module_name = module.__name__
-                    if module_name == "__main__":
-                        module_name = frame_info.filename
-                    function_name = (
-                        "::" + frame_info.function
-                        if frame_info.function != ""
-                        else ""
-                    )
-                    logger.opt(depth=3).error(
-                        "Name conflict in "
-                        f"{module_name}{function_name} at line "
-                        f"{frame_info.lineno}\n"
-                        f"Renaming {self.name} (cell_index="
-                        f"{self.kdb_cell.cell_index()}) to"
-                        f" {value} would cause it to be named the same as:\n"
-                        + "\n".join(
-                            f" - {tkcell.name} "
-                            f"(cell_index={tkcell.kdb_cell.cell_index()}),"
-                            f" function_name={tkcell.function_name},"
-                            f" basename={tkcell.basename}"
-                            for tkcell in tkcells
-                        )
-                    )
-                    if config.debug_names:
-                        raise ValueError(
-                            "Name conflict in "
-                            f"{module_name}{function_name} at line "
-                            f"{frame_info.lineno}\n"
-                            f"Renaming {self.name} (cell_index="
-                            f"{self.kdb_cell.cell_index()}) to"
-                            f" {value} would cause it to be named the same as:\n"
-                            + "\n".join(
-                                f" - {tkcell.name} "
-                                f"(cell_index={tkcell.kdb_cell.cell_index()}),"
-                                f" function_name={tkcell.function_name},"
-                                f" basename={tkcell.basename}"
-                                for tkcell in tkcells
-                            )
-                        )
+                    mod_name = module.__name__
+                    if mod_name == "__main__":
+                        mod_name = fi.filename
                 else:
-                    function_name = (
-                        "::" + frame_info.function
-                        if frame_info.function != ""
-                        else ""
-                    )
-                    logger.opt(depth=3).error(
-                        "Name conflict in "
-                        f"{frame_info.filename}"
-                        f"{function_name} at line {frame_info.lineno}\n"
-                        f"Renaming {self.name} (cell_index="
-                        f"{self.kdb_cell.cell_index()}) to"
-                        f" {value} would cause it to be named the same as:\n"
-                        + "\n".join(
-                            f" - {tkcell.name} "
-                            f"(cell_index={tkcell.kdb_cell.cell_index()}),"
-                            f" function_name={tkcell.function_name},"
-                            f" basename={tkcell.basename}"
-                            for tkcell in tkcells
-                        )
-                    )
-                    if config.debug_names:
-                        raise ValueError(
-                            "Name conflict in "
-                            f"{frame_info.filename}"
-                            f"{function_name} at line {frame_info.lineno}\n"
-                            f"Renaming {self.name} (cell_index="
-                            f"{self.kdb_cell.cell_index()}) to"
-                            f" {value} would cause it to be named the same as:\n"
-                            + "\n".join(
-                                f" - {tkcell.name} "
-                                f"(cell_index={tkcell.kdb_cell.cell_index()}),"
-                                f" function_name={tkcell.function_name},"
-                                f" basename={tkcell.basename}"
-                                for tkcell in tkcells
-                            )
-                        )
+                    mod_name = fi.filename
+                func_suffix = f"::{fi.function}" if fi.function != "" else ""
+                location = f"{mod_name}{func_suffix} at line {fi.lineno}"
+                log_depth = 3
+
+            msg = (
+                f"Cell name conflict in {location}\n"
+                f"Renaming {self.name!r}"
+                f" (cell_index={self.kdb_cell.cell_index()}) to {value!r}"
+                f" would create a duplicate — the following cell(s) already"
+                f" have that name:\n{conflicting}\n"
+                f"This will make the layout unwritable (GDS/OASIS require"
+                f" unique cell names).\n"
+                f"Set `kf.config.debug_names = True` to turn this warning"
+                f" into an error and catch the conflict at its source."
+            )
+            logger.opt(depth=log_depth).error(msg)
+            if config.debug_names:
+                raise DuplicateCellNameError(msg)
 
         self.kdb_cell.name = value
 
@@ -615,7 +606,9 @@ def name(self, value: str) -> None:
         self._name = value
 
 
-class ProtoTKCell(ProtoKCell[TUnit, TKCell], Generic[TUnit], ABC):  # noqa: PYI059
+class ProtoTKCell[T: (int, float)](ProtoKCell[T, TKCell], ABC):
+    _base: TKCell
+
     def __init__(
         self,
         *,
@@ -678,7 +671,7 @@ def schematic(self, value: TSchematic[Any] | None) -> None:
         self._base.schematic = value
 
     @abstractmethod
-    def __getitem__(self, key: int | str | None) -> ProtoPort[TUnit]:
+    def __getitem__(self, key: int | str | None) -> ProtoPort[T]:
         """Returns port from instance."""
         ...
 
@@ -696,10 +689,9 @@ def virtual(self) -> bool:
             return self.library_cell.virtual
         return self._base.virtual
 
-    @property
     @property
     @abstractmethod
-    def pins(self) -> ProtoPins[TUnit]: ...
+    def pins(self) -> ProtoPins[T]: ...
 
     @pins.setter
     @abstractmethod
@@ -739,7 +731,7 @@ def ghost_cell(self, value: bool) -> None:
     def __getattr__(self, name: str) -> Any:
         """If KCell doesn't have an attribute, look in the KLayout Cell."""
         try:
-            return super().__getattr__(name)  # type: ignore[misc]
+            return ProtoKCell.__getattribute__(self, name)
         except Exception:
             return getattr(self._base, name)
 
@@ -747,12 +739,20 @@ def cell_index(self) -> int:
         """Gets the cell index."""
         return self._base.kdb_cell.cell_index()
 
+    def called_cells(self) -> list[int]:
+        """Cell indices for every cell transitively instantiated inside this cell."""
+        return self._base.kdb_cell.called_cells()
+
+    def is_library_cell(self) -> bool:
+        """True if this cell is imported from a klayout library."""
+        return self._base.kdb_cell.is_library_cell()
+
     def shapes(self, layer: int | kdb.LayerInfo) -> kdb.Shapes:
         return self._base.kdb_cell.shapes(layer)
 
     @property
     @abstractmethod
-    def insts(self) -> ProtoTInstances[TUnit]: ...
+    def insts(self) -> ProtoTInstances[T]: ...
 
     def __copy__(self) -> Self:
         """Enables use of `copy.copy` and `copy.deep_copy`."""
@@ -791,7 +791,7 @@ def dup(self, new_name: str | None = None) -> Self:
         c.ports = self.ports.copy()
 
         if self.pins:
-            port_mapping = {id(p): i for i, p in enumerate(c.ports)}
+            port_mapping = {id(p): i for i, p in enumerate(self._base.ports)}
             c._base.pins = [
                 BasePin(
                     name=p.name,
@@ -810,6 +810,11 @@ def dup(self, new_name: str | None = None) -> Self:
 
         return c
 
+    def get_original_kcell(self) -> KCell:
+        if self.is_library_cell():
+            return self.library_cell.get_original_kcell()
+        return KCell(base=self.base)
+
     @property
     def kdb_cell(self) -> kdb.Cell:
         return self._base.kdb_cell
@@ -842,6 +847,7 @@ def show(
         use_libraries: bool = True,
         library_save_options: kdb.SaveLayoutOptions | None = None,
         technology: str | None = None,
+        markers: list[tuple[DShapeLike, MarkerConfig]] | None = None,
     ) -> None:
         """Stream the gds to klive.
 
@@ -867,6 +873,7 @@ def show(
             save_options=save_options,
             use_libraries=use_libraries,
             library_save_options=library_save_options,
+            markers=markers,
             **kwargs,
         )
 
@@ -912,11 +919,11 @@ def add_port(
     def create_pin(
         self,
         *,
+        name: str,
         ports: Iterable[ProtoPort[Any]],
-        name: str | None = None,
         pin_type: str = "DC",
-        info: dict[str, int | float | str] | None = None,
-    ) -> ProtoPin[TUnit]: ...
+        info: dict[str, MetaData] | None = None,
+    ) -> ProtoPin[T]: ...
 
     @overload
     @abstractmethod
@@ -993,12 +1000,21 @@ def _get_ci(
                 kcell.base.virtual = cell.virtual
                 if cell.kcl.dbu != self.kcl.dbu:
                     for port, lib_port in zip(kcell.ports, cell.ports, strict=False):
-                        port.cross_section = CrossSection(
-                            kcl=kcell.kcl,
-                            base=cell.kcl.get_symmetrical_cross_section(
-                                lib_port.cross_section.base.to_dtype(cell.kcl)
-                            ),
-                        )
+                        if lib_port.is_symmetric():
+                            port.cross_section = CrossSection(
+                                kcl=kcell.kcl,
+                                base=cell.kcl.get_symmetrical_cross_section(
+                                    lib_port.cross_section.base.to_dtype(cell.kcl)
+                                ),
+                            )
+                        else:
+                            port.asymmetric_cross_section = (
+                                cell.kcl.get_asymmetrical_cross_section(
+                                    lib_port.asymmetric_cross_section.base.to_dtype(
+                                        cell.kcl
+                                    )
+                                )
+                            )
             return ci
         return lib_ci
 
@@ -1114,15 +1130,15 @@ def _kdb_copy(self) -> kdb.Cell:
     def layout(self) -> kdb.Layout:
         return self._base.kdb_cell.layout()
 
-    def library(self) -> kdb.Library:
-        return self._base.kdb_cell.library()  # type: ignore[return-value]
+    def library(self) -> kdb.LibraryBase:
+        return self._base.kdb_cell.library()
 
     @property
     @abstractmethod
-    def library_cell(self) -> ProtoTKCell[TUnit]: ...
+    def library_cell(self) -> ProtoTKCell[T]: ...
 
     @abstractmethod
-    def __lshift__(self, cell: AnyTKCell) -> ProtoTInstance[TUnit]: ...
+    def __lshift__(self, cell: AnyTKCell) -> ProtoTInstance[T]: ...
 
     def auto_rename_ports(self, rename_func: Callable[..., None] | None = None) -> None:
         """Rename the ports with the schema angle -> "NSWE" and sort by x and y.
@@ -1224,7 +1240,7 @@ def draw_ports(self) -> None:
                         ]
                     )
                 )
-                if w > 20:  # noqa: PLR2004
+                if w > 20:
                     poly -= kdb.Region(
                         kdb.Polygon(
                             [
@@ -1255,7 +1271,7 @@ def write(
     ) -> None:
         """Write a KCell to a GDS.
 
-        See [KCLayout.write][kfactory.kcell.KCLayout.write] for more info.
+        See [KCLayout.write][kfactory.layout.KCLayout.write] for more info.
         """
         if save_options is None:
             save_options = save_layout_options()
@@ -1286,6 +1302,9 @@ def write(
             case _:
                 ...
 
+        relevant_cells = {self.cell_index(), *self.called_cells()}
+        _deduplicate_cell_names(self.layout(), relevant_cells)
+
         filename = str(filename)
         if autoformat_from_file_extension:
             save_options.set_format_from_filename(filename)
@@ -1299,7 +1318,7 @@ def write_bytes(
     ) -> bytes:
         """Write a KCell to a binary format as oasis.
 
-        See [KCLayout.write][kfactory.kcell.KCLayout.write] for more info.
+        See [KCLayout.write][kfactory.layout.KCLayout.write] for more info.
         """
         if save_options is None:
             save_options = save_layout_options()
@@ -1330,6 +1349,9 @@ def write_bytes(
             case _:
                 ...
 
+        relevant_cells = {self.cell_index(), *self.called_cells()}
+        _deduplicate_cell_names(self.layout(), relevant_cells)
+
         save_options.format = save_options.format or "OASIS"
         save_options.clear_cells()
         save_options.select_cell(self.cell_index())
@@ -1414,14 +1436,14 @@ def read(
                     yaml = ruamel.yaml.YAML(typ=["rt", "string"])
                     err_msg += (
                         "\nLayout Meta Diff:\n```\n"
-                        + yaml.dumps(dict(diff.layout_meta_diff))
+                        + yaml.dumps(dict(diff.layout_meta_diff))  # ty:ignore[unresolved-attribute]
                         + "\n```"
                     )
                 if diff.cells_meta_diff:
                     yaml = ruamel.yaml.YAML(typ=["rt", "string"])
                     err_msg += (
                         "\nLayout Meta Diff:\n```\n"
-                        + yaml.dumps(dict(diff.cells_meta_diff))
+                        + yaml.dumps(dict(diff.cells_meta_diff))  # ty:ignore[unresolved-attribute]
                         + "\n```"
                     )
 
@@ -1538,15 +1560,20 @@ def transform(
         transform_ports: bool = True,
     ) -> Instance | None:
         """Transforms the instance or cell with the transformation given."""
-        if trans:
+        if trans is not None:
             return Instance(
                 self.kcl,
                 self._base.kdb_cell.transform(
-                    inst_or_trans,  # type: ignore[arg-type]
-                    trans,  # type: ignore[arg-type]
+                    cast("kdb.Instance", inst_or_trans),
+                    trans,
                 ),
             )
-        self._base.kdb_cell.transform(inst_or_trans)  # type:ignore[arg-type]
+        self._base.kdb_cell.transform(
+            cast(
+                "kdb.Trans | kdb.DTrans | kdb.ICplxTrans | kdb.DCplxTrans",
+                inst_or_trans,
+            )
+        )
         if transform_ports:
             if isinstance(inst_or_trans, kdb.DTrans):
                 inst_or_trans = kdb.DCplxTrans(inst_or_trans)
@@ -1558,7 +1585,7 @@ def transform(
                     port.trans = inst_or_trans * port.trans
             else:
                 for port in self.ports:
-                    port.dcplx_trans = inst_or_trans * port.dcplx_trans  # type: ignore[operator]
+                    port.dcplx_trans = inst_or_trans * port.dcplx_trans  # ty:ignore[unsupported-operator]
         return None
 
     def set_meta_data(self) -> None:
@@ -1569,27 +1596,26 @@ def set_meta_data(self) -> None:
         self.clear_meta_info()
         if not self.is_library_cell():
             for i, port in enumerate(self.ports):
+                xs_name = port.base.any_cross_section.name
                 if port.base.trans is not None:
                     meta_info: dict[str, MetaData] = {
                         "name": port.name,
-                        "cross_section": port.cross_section.name,
+                        "cross_section": xs_name,
                         "trans": port.base.trans,
                         "port_type": port.port_type,
                         "info": port.info.model_dump(),
                     }
-
                     self.add_meta_info(
                         kdb.LayoutMetaInfo(f"kfactory:ports:{i}", meta_info, None, True)
                     )
                 else:
                     meta_info = {
                         "name": port.name,
-                        "cross_section": port.cross_section.name,
+                        "cross_section": xs_name,
                         "dcplx_trans": port.dcplx_trans,
                         "port_type": port.port_type,
                         "info": port.info.model_dump(),
                     }
-
                     self.add_meta_info(
                         kdb.LayoutMetaInfo(f"kfactory:ports:{i}", meta_info, None, True)
                     )
@@ -1645,7 +1671,7 @@ def get_meta_data(
             meta_format = config.meta_format
         port_dict: dict[str, Any] = {}
         pin_dict: dict[str, Any] = {}
-        ports: dict[str, BasePort] = {}
+        ports: dict[str, Port] = {}
         settings: dict[str, MetaData] = {}
         settings_units: dict[str, str] = {}
         from .layout import kcls
@@ -1681,14 +1707,15 @@ def get_meta_data(
                 if not self.is_library_cell():
                     for index in sorted(port_dict.keys()):
                         v = port_dict[index]
+                        xs = self.kcl.get_base_cross_section(
+                            v["cross_section"], symmetrical=None
+                        )
                         trans_: kdb.Trans | None = v.get("trans")
                         if trans_ is not None:
                             ports[index] = self.create_port(
                                 name=v.get("name"),
                                 trans=trans_,
-                                cross_section=self.kcl.get_symmetrical_cross_section(
-                                    v["cross_section"]
-                                ),
+                                cross_section=xs,
                                 port_type=v["port_type"],
                                 info=v["info"],
                             )
@@ -1696,9 +1723,7 @@ def get_meta_data(
                             ports[index] = self.create_port(
                                 name=v.get("name"),
                                 dcplx_trans=v["dcplx_trans"],
-                                cross_section=self.kcl.get_symmetrical_cross_section(
-                                    v["cross_section"]
-                                ),
+                                cross_section=xs,
                                 port_type=v["port_type"],
                                 info=v["info"],
                             )
@@ -1706,7 +1731,10 @@ def get_meta_data(
                         v = pin_dict[index]
                         self.create_pin(
                             name=v.get("name"),
-                            ports=[ports[port_index] for port_index in v["ports"]],  # type: ignore[misc]
+                            ports=[
+                                Port(base=ports[str(port_index)].base)
+                                for port_index in v["ports"]
+                            ],
                             pin_type=v["pin_type"],
                             info=v["info"],
                         )
@@ -1716,11 +1744,18 @@ def get_meta_data(
                         v = port_dict[index]
                         trans_ = v.get("trans")
                         lib_kcl = kcls[lib_name]
-                        cs = self.kcl.get_symmetrical_cross_section(
-                            lib_kcl.get_symmetrical_cross_section(
-                                v["cross_section"]
-                            ).to_dtype(lib_kcl)
+                        lib_xs = lib_kcl.get_base_cross_section(
+                            v["cross_section"], symmetrical=None
                         )
+                        cs: SymmetricalCrossSection | AsymmetricalCrossSection
+                        if isinstance(lib_xs, SymmetricalCrossSection):
+                            cs = self.kcl.get_symmetrical_cross_section(
+                                lib_xs.to_dtype(lib_kcl)
+                            )
+                        else:
+                            cs = self.kcl.get_asymmetrical_cross_section(
+                                lib_xs.to_dtype(lib_kcl)
+                            )
 
                         if trans_ is not None:
                             ports[index] = self.create_port(
@@ -1742,7 +1777,10 @@ def get_meta_data(
                         v = pin_dict[index]
                         self.create_pin(
                             name=v.get("name"),
-                            ports=[ports[str(port_index)] for port_index in v["ports"]],  # type: ignore[misc]
+                            ports=[
+                                Port(base=ports[str(port_index)].base)
+                                for port_index in v["ports"]
+                            ],
                             pin_type=v["pin_type"],
                             info=v["info"],
                         )
@@ -1886,20 +1924,6 @@ def dbbox(self, layer: int | None = None) -> kdb.DBox:
             return self._base.kdb_cell.dbbox()
         return self._base.kdb_cell.dbbox(layer)
 
-    def l2n(self, port_types: Iterable[str] = ("optical",)) -> kdb.LayoutToNetlist:
-        """Generate a LayoutToNetlist object from the port types.
-
-        Args:
-            port_types: The port types to consider for the netlist extraction.
-        Returns:
-            LayoutToNetlist extracted from instance and cell port positions.
-        """
-        logger.warning(
-            "l2n is deprecated and will be removed in 2.0. Please use `l2n_ports`"
-            " instead."
-        )
-        return self.l2n_ports(port_types=port_types)
-
     def l2n_ports(
         self,
         port_types: Iterable[str] = ("optical",),
@@ -1953,11 +1977,9 @@ def l2n_elec(
             | tuple[kdb.LayerInfo, kdb.LayerInfo, kdb.LayerInfo],
         ]
         | None = None,
-        port_mapping: dict[str, dict[str | None, str]] | None = None,
+        port_mapping: dict[str, dict[str, str]] | None = None,
     ) -> kdb.LayoutToNetlist:
-        """Generate a LayoutToNetlist object from the port types.
-
-        Uses electrical connectivity for extraction.
+        """Generate a LayoutToNetlist object from electrical connectivity.
 
         Args:
             mark_port_types: The port types to consider for the netlist extraction.
@@ -1965,74 +1987,20 @@ def l2n_elec(
                 layers (just consider this layer as metal), two layers (two metals
                 which touch each other), or three layers (two metals with a via)
             port_mapping: Remap ports of cells to others. This allows to define
-                equivalent ports in the lvs. E.g. `{"cell_A": {"o3": "o1", "o2"}}`
-                will remap "o2" and "o3" to "o1". Making the three ports the same
-                one for LVS.
-        Returns:
-            LayoutToNetlist extracted from electrical connectivity.
+                equivalent ports in the lvs.
         """
-        connectivity = connectivity or self.kcl.connectivity
-        ly_elec = self.kcl.layout.dup()
-
-        port_mapping = port_mapping or {}
-        c_elec: kdb.Cell = ly_elec.cell(self.name)
-
-        for ci in [c_elec.cell_index(), *c_elec.called_cells()]:
-            c_ = self.kcl[ci]
-            c = ly_elec.cell(c_.name)
-            assert c_.name == c.name
-            c.locked = False
-            mapping = port_mapping.get(
-                c_.name,
-                port_mapping.get(c_.factory_name, {}) if c_.has_factory_name() else {},
-            )
-            for port in c_.ports:
-                port_name = mapping.get(port.name, port.name)
-                if (
-                    port_name == port.name
-                    and port.port_type in mark_port_types
-                    and port.name is not None
-                ):
-                    c.shapes(port.layer_info).insert(
-                        kdb.Text(string=port.name, trans=port.trans)
-                    )
+        from kfnetlist.extract import l2n_elec as _kfnetlist_l2n_elec
 
-        l2n: kdb.LayoutToNetlist = kdb.LayoutToNetlist(
-            kdb.RecursiveShapeIterator(
-                ly_elec,
-                ly_elec.cell(self.name),
-                [],
-            )
+        return _kfnetlist_l2n_elec(
+            self,
+            mark_port_types=mark_port_types,
+            connectivity=connectivity,
+            port_mapping=port_mapping,
         )
 
-        connectivity = connectivity or self.kcl.connectivity
-
-        layers: dict[int, kdb.Region] = {}
-
-        layer_infos = {
-            ly_elec.get_info(ly_elec.layer(info))
-            for layer_set in connectivity
-            for info in layer_set
-        }
-        for info in layer_infos:
-            l_ = l2n.make_layer(ly_elec.layer(info), info.name)
-            layers[ly_elec.layer(info)] = l_
-            l2n.connect(l_)
-        for conn in connectivity:
-            old_layer = layers[ly_elec.layer(conn[0])]
-
-            for layer in conn[1:]:
-                li = layers[ly_elec.layer(layer)]
-                l2n.connect(old_layer, li)
-                old_layer = li
-        l2n.extract_netlist()
-        l2n.check_extraction_errors()
-
-        return l2n
-
     def netlist(
         self,
-        port_types: Iterable[str] = ("optical",),
+        port_types: Sequence[str] = ("optical",),
         mark_port_types: Iterable[str] = ("electrical", "RF", "DC"),
         connectivity: Sequence[
             tuple[kdb.LayerInfo, kdb.LayerInfo]
@@ -2045,80 +2013,33 @@ def netlist(
         exclude_purposes: list[str] | None = None,
         allow_width_mismatch: bool = False,
     ) -> dict[str, Netlist]:
-        if equivalent_ports is None:
-            equivalent_ports = {}
-            for ci in [self.cell_index(), *self.called_cells()]:
-                c_ = self.kcl[ci]
-                eqps: list[list[str]] | None = c_.lvs_equivalent_ports or None
-                if c_.has_factory_name():
-                    if c_.is_library_cell():
-                        if c_.virtual:
-                            eqps = (
-                                _get_orig_cell(c_)
-                                .kcl.virtual_factories[c_.factory_name]
-                                .lvs_equivalent_ports
-                            )
-                        else:
-                            eqps = (
-                                _get_orig_cell(c_)
-                                .kcl.factories[c_.factory_name]
-                                .lvs_equivalent_ports
-                            )
-                    elif c_.virtual:
-                        eqps = c_.kcl.virtual_factories[
-                            c_.factory_name
-                        ].lvs_equivalent_ports
-                    else:
-                        eqps = c_.kcl.factories[c_.factory_name].lvs_equivalent_ports
-                if eqps is not None:
-                    equivalent_ports[c_.name] = eqps
-        port_mapping: dict[str, dict[str | None, str]] = defaultdict(dict)
-        for cell_name, list_of_port_lists in equivalent_ports.items():
-            for port_list in list_of_port_lists:
-                if port_list:
-                    p1 = port_list[0]
-                    for port in port_list:
-                        port_mapping[cell_name][port] = p1
-        l2n_elec = self.l2n_elec(
+        from kfnetlist.extract import extract as _kfnetlist_extract
+
+        return _kfnetlist_extract(
+            self,
+            wrap_kdb_instance=lambda i: Instance(kcl=self.kcl, instance=i),
+            port_types=port_types,
             mark_port_types=mark_port_types,
             connectivity=connectivity,
-            port_mapping=port_mapping,
-        )
-        l2n_opt = self.l2n_ports(
-            port_types=port_types,
-            exclude_purposes=exclude_purposes,
+            equivalent_ports=equivalent_ports,
             ignore_unnamed=ignore_unnamed,
+            exclude_purposes=exclude_purposes,
             allow_width_mismatch=allow_width_mismatch,
         )
 
-        netlists: dict[str, Netlist] = {}
-
-        for cell_name, eqps in equivalent_ports.items():
-            for eqp_list in eqps:
-                if eqp_list:
-                    p1 = eqp_list[0]
-                    for p in eqp_list:
-                        port_mapping[cell_name][p] = p1
-
-        for ci in [self.cell_index(), *self.called_cells()]:
-            c_ = self.kcl[ci]
-            name = c_.name
+    def get_optical_nets(
+        self,
+        port_types: Sequence[str] = ("optical",),
+        allow_width_mismatch: bool = False,
+    ) -> list[Net]:
+        """Extract geometric port-adjacency nets for the given port types."""
+        from kfnetlist.extract import get_optical_nets as _kfnetlist_get_optical_nets
 
-            nl = _get_netlist(
-                c=c_,
-                l2n_opt=l2n_opt,
-                l2n_elec=l2n_elec,
-                ignore_unnamed=ignore_unnamed,
-                exclude_purposes=exclude_purposes,
-            )
-            if equivalent_ports.get(c_.name) is not None:
-                nl = nl.lvs_equivalent(
-                    cell_name=c_.name,
-                    equivalent_ports=equivalent_ports,
-                    port_mapping=port_mapping,
-                )
-            netlists[name] = nl
-        return netlists
+        return _kfnetlist_get_optical_nets(
+            self,
+            port_types=port_types,
+            allow_width_mismatch=allow_width_mismatch,
+        )
 
     def circuit(
         self,
@@ -2128,7 +2049,9 @@ def circuit(
         exclude_purposes: list[str] | None = None,
         allow_width_mismatch: bool = False,
     ) -> None:
-        """Create the circuit of the KCell in the given netlist."""
+        """Create the (optical type) circuit of the KCell in the given netlist.
+
+        This is NOT recommended though."""
         netlist = l2n.netlist()
 
         def port_filter(num_port: tuple[int, ProtoPort[Any]]) -> bool:
@@ -2141,9 +2064,9 @@ def port_filter(num_port: tuple[int, ProtoPort[Any]]) -> bool:
 
         inst_ports: dict[
             str,
-            dict[str, list[tuple[int, int, Instance, Port, kdb.SubCircuit]]],
+            dict[str, list[tuple[int, int, Instance, ProtoPort[Any], kdb.SubCircuit]]],
         ] = {}
-        cell_ports: dict[str, dict[str, list[tuple[int, Port]]]] = {}
+        cell_ports: dict[str, dict[str, list[tuple[int, ProtoPort[Any]]]]] = {}
 
         # sort the cell's ports by position and layer
 
@@ -2251,35 +2174,29 @@ def port_filter(num_port: tuple[int, ProtoPort[Any]]) -> bool:
                     assert pin is not None
                     assert net is not None
                     subc.connect_pin(pin, net)
-                else:
+                elif len(ports) >= 2:
                     # connect instance ports to each other
-                    name = "-".join(
-                        [
-                            (inst.name or str(i)) + "_" + (port.name or str(j))
-                            for i, j, inst, port, _ in ports
-                        ]
-                    )
-
-                    net = circ.create_net(name)
-                    assert len(ports) <= 2, (  # noqa: PLR2004
-                        "Optical connection with more than two ports are not supported "
-                        f"{[_port[3] for _port in ports]}"
-                    )
-                    if len(ports) == 2:  # noqa: PLR2004
-                        if allow_width_mismatch:
-                            port_check(
-                                ports[0][3],
-                                ports[1][3],
-                                PortCheck.layer
-                                + PortCheck.port_type
-                                + PortCheck.opposite,
-                            )
-                        else:
-                            port_check(ports[0][3], ports[1][3], PortCheck.all_opposite)
-                        for _, j, _, port, subc in ports:
-                            subc.connect_pin(
-                                subc.circuit_ref().pin_by_name(port.name or str(j)), net
+                    check = PortCheck.position + PortCheck.opposite + PortCheck.layer
+                    for n, (i, j, inst, port, subc) in enumerate(ports):
+                        net_ports = [(i, j, inst, port, subc)]
+                        for i_, j_, inst_, port_, subc_ in ports[n:]:
+                            if port.base.check_connection(port_.base) & check == check:
+                                net_ports.append((i_, j_, inst_, port_, subc_))
+                        if len(net_ports) >= 2:
+                            net_name = "-".join(
+                                [
+                                    (inst.name or str(i)) + "_" + (port.name or str(j))
+                                    for i, j, inst, port, _ in ports
+                                ]
                             )
+                            net = circ.create_net(net_name)
+                            for _, j_, _, port_, subc_ in net_ports:
+                                subc_.connect_pin(
+                                    subc_.circuit_ref().pin_by_name(
+                                        port_.name or str(j_)
+                                    ),
+                                    net,
+                                )
 
         del_subcs: list[kdb.SubCircuit] = []
         if ignore_unnamed:
@@ -2333,439 +2250,78 @@ def connectivity_check(
         add_cell_ports: bool = False,
         check_layer_connectivity: bool = True,
     ) -> rdb.ReportDatabase:
-        """Create a ReportDatabase for port problems.
+        """Create a ReportDatabase aggregating all standalone connectivity checks.
 
-        Problems are overlapping ports that aren't aligned, more than two ports
-        overlapping, width mismatch, port_type mismatch.
+        This is the all-in-one pass/fail entry point. It runs
+        [`port_mismatch_check`][kfactory.checks.port_mismatch_check],
+        [`dangling_ports_check`][kfactory.checks.dangling_ports_check], and (when
+        `check_layer_connectivity=True`)
+        [`instance_overlap_check`][kfactory.checks.instance_overlap_check] +
+        [`shape_instance_overlap_check`][kfactory.checks.shape_instance_overlap_check]
+        into a single report. Call those functions directly to run a narrower
+        check.
 
         Args:
-            port_types: Filter for certain port typers
-            layers: Only create the report for certain layers
-            db: Use an existing ReportDatabase instead of creating a new one
-            recursive: Create the report not only for this cell, but all child cells as
-                well.
-            add_cell_ports: Also add a category "CellPorts" which contains all the cells
-                selected ports.
-            check_layer_connectivity: Check whether the layer overlaps with instances.
+            port_types: Filter for certain port types.
+            layers: Only create the report for certain layers.
+            db: Use an existing ReportDatabase instead of creating a new one.
+            recursive: Create the report not only for this cell, but all child
+                cells as well.
+            add_cell_ports: Also add a category "CellPorts" which contains all
+                the cell's selected ports.
+            check_layer_connectivity: Run the shape/instance overlap checks.
         """
-        if layers is None:
-            layers = []
-        if port_types is None:
-            port_types = []
+        port_types = port_types or []
+        layers = layers or []
         db_: rdb.ReportDatabase = db or rdb.ReportDatabase(
             f"Connectivity Check {self.name}"
         )
-        assert isinstance(db_, rdb.ReportDatabase)
         if recursive:
             cc = self.called_cells()
             for c in self.kcl.each_cell_bottom_up():
                 if c in cc:
                     self.kcl[c].connectivity_check(
                         port_types=port_types,
+                        layers=layers,
                         db=db_,
                         recursive=False,
                         add_cell_ports=add_cell_ports,
-                        layers=layers,
+                        check_layer_connectivity=check_layer_connectivity,
                     )
-        db_cell = db_.create_cell(self.name)
-        cell_ports: dict[int, dict[tuple[float, float], list[ProtoPort[Any]]]] = {}
-        layer_cats: dict[int, rdb.RdbCategory] = {}
-
-        def layer_cat(layer: int) -> rdb.RdbCategory:
-            if layer not in layer_cats:
-                if isinstance(layer, LayerEnum):
-                    ln = str(layer.name)
-                else:
-                    li = self.kcl.get_info(layer)
-                    ln = str(li).replace("/", "_")
-                layer_cats[layer] = db_.category_by_path(ln) or db_.create_category(ln)
-            return layer_cats[layer]
 
-        for port in Ports(kcl=self.kcl, bases=self.ports.bases):
-            if (not port_types or port.port_type in port_types) and (
-                not layers or port.layer in layers
-            ):
-                if add_cell_ports:
-                    c_cat = db_.category_by_path(
-                        f"{layer_cat(port.layer).path()}.CellPorts"
-                    ) or db_.create_category(layer_cat(port.layer), "CellPorts")
-                    it = db_.create_item(db_cell, c_cat)
-                    if port.name:
-                        it.add_value(f"Port name: {port.name}")
-                    if port.base.trans:
-                        it.add_value(
-                            self.kcl.to_um(
-                                port_polygon(port.width).transformed(port.trans)
-                            )
-                        )
-                    else:
-                        it.add_value(
-                            self.kcl.to_um(port_polygon(port.width)).transformed(
-                                port.dcplx_trans
-                            )
-                        )
-                xy = (port.x, port.y)
-                if port.layer not in cell_ports:
-                    cell_ports[port.layer] = {xy: [port]}
-                elif xy not in cell_ports[port.layer]:
-                    cell_ports[port.layer][xy] = [port]
-                else:
-                    cell_ports[port.layer][xy].append(port)
-                rec_it = kdb.RecursiveShapeIterator(
-                    self.kcl.layout,
-                    self._base.kdb_cell,
-                    port.layer,
-                    kdb.Box(2, port.width).transformed(port.trans),
+        port_mismatch_check(
+            self,
+            port_types=port_types,
+            layers=layers,
+            db=db_,
+            recursive=False,
+            add_cell_ports=add_cell_ports,
+        )
+        dangling_ports_check(
+            self,
+            port_types=port_types,
+            layers=layers,
+            db=db_,
+            recursive=False,
+        )
+        if check_layer_connectivity:
+            # Preserve original behaviour: only scan layers that carry at least
+            # one (filtered) instance port. This avoids surfacing overlap items
+            # on layers the user didn't ask about via port_types/layers.
+            gated_layers = list(_collect_inst_ports(self, port_types, layers).keys())
+            if gated_layers:
+                instance_overlap_check(
+                    self,
+                    layers=gated_layers,
+                    db=db_,
+                    recursive=False,
+                )
+                shape_instance_overlap_check(
+                    self,
+                    layers=gated_layers,
+                    db=db_,
+                    recursive=False,
                 )
-                edges = kdb.Region(rec_it).merge().edges().merge()
-                port_edge = kdb.Edge(0, port.width // 2, 0, -port.width // 2)
-                if port.base.trans:
-                    port_edge = port_edge.transformed(port.trans)
-                else:
-                    port_edge = port_edge.transformed(
-                        kdb.ICplxTrans(port.dcplx_trans, self.kcl.dbu)
-                    )
-                p_edges = kdb.Edges([port_edge])
-                phys_overlap = p_edges & edges
-                if not phys_overlap.is_empty() and phys_overlap[0] != port_edge:
-                    p_cat = db_.category_by_path(
-                        layer_cat(port.layer).path() + ".PartialPhysicalShape"
-                    ) or db_.create_category(
-                        layer_cat(port.layer), "PartialPhysicalShape"
-                    )
-                    it = db_.create_item(db_cell, p_cat)
-                    it.add_value(
-                        "Insufficient overlap, partial overlap with polygon of"
-                        f" {(phys_overlap[0].p1 - phys_overlap[0].p2).abs()}/"
-                        f"{port.width}"
-                    )
-                    it.add_value(
-                        self.kcl.to_um(port_polygon(port.width).transformed(port.trans))
-                        if port.base.trans
-                        else self.kcl.to_um(port_polygon(port.width)).transformed(
-                            port.dcplx_trans
-                        )
-                    )
-                elif phys_overlap.is_empty():
-                    p_cat = db_.category_by_path(
-                        layer_cat(port.layer).path() + ".MissingPhysicalShape"
-                    ) or db_.create_category(
-                        layer_cat(port.layer), "MissingPhysicalShape"
-                    )
-                    it = db_.create_item(db_cell, p_cat)
-                    it.add_value(
-                        f"Found no overlapping Edge with Port {port.name or str(port)}"
-                    )
-                    it.add_value(
-                        self.kcl.to_um(port_polygon(port.width).transformed(port.trans))
-                        if port.base.trans
-                        else self.kcl.to_um(port_polygon(port.width)).transformed(
-                            port.dcplx_trans
-                        )
-                    )
-
-        inst_ports: dict[
-            LayerEnum | int,
-            dict[tuple[int, int], list[tuple[Port, KCell, str]]],
-        ] = {}
-        for inst in self.insts:
-            inst_name = inst.name
-            inst_cell = inst.cell.to_itype()
-            for port in Ports(kcl=self.kcl, bases=[p.base for p in inst.ports]):
-                if (not port_types or port.port_type in port_types) and (
-                    not layers or port.layer in layers
-                ):
-                    xy = (port.x, port.y)
-                    entry = (port, inst_cell, inst_name)
-                    if port.layer not in inst_ports:
-                        inst_ports[port.layer] = {xy: [entry]}
-                    elif xy not in inst_ports[port.layer]:
-                        inst_ports[port.layer][xy] = [entry]
-                    else:
-                        inst_ports[port.layer][xy].append(entry)
-
-        for layer, port_coord_mapping in inst_ports.items():
-            lc = layer_cat(layer)
-            for coord, ports in port_coord_mapping.items():
-                match len(ports):
-                    case 1:
-                        if layer in cell_ports and coord in cell_ports[layer]:
-                            ccp = check_cell_ports(
-                                cell_ports[layer][coord][0], ports[0][0]
-                            )
-                            if ccp & 1:
-                                subc = db_.category_by_path(
-                                    lc.path() + ".WidthMismatch"
-                                ) or db_.create_category(lc, "WidthMismatch")
-                                create_port_error(
-                                    ports[0][0],
-                                    cell_ports[layer][coord][0],
-                                    ports[0][1],
-                                    self,
-                                    db_,
-                                    db_cell,
-                                    subc,
-                                    self.kcl.dbu,
-                                    inst_name1=ports[0][2],
-                                )
-
-                            if ccp & 2:
-                                subc = db_.category_by_path(
-                                    lc.path() + ".AngleMismatch"
-                                ) or db_.create_category(lc, "AngleMismatch")
-                                create_port_error(
-                                    ports[0][0],
-                                    cell_ports[layer][coord][0],
-                                    ports[0][1],
-                                    self,
-                                    db_,
-                                    db_cell,
-                                    subc,
-                                    self.kcl.dbu,
-                                    inst_name1=ports[0][2],
-                                )
-                            if ccp & 4:
-                                subc = db_.category_by_path(
-                                    lc.path() + ".TypeMismatch"
-                                ) or db_.create_category(lc, "TypeMismatch")
-                                create_port_error(
-                                    ports[0][0],
-                                    cell_ports[layer][coord][0],
-                                    ports[0][1],
-                                    self,
-                                    db_,
-                                    db_cell,
-                                    subc,
-                                    self.kcl.dbu,
-                                    inst_name1=ports[0][2],
-                                )
-                        else:
-                            subc = db_.category_by_path(
-                                lc.path() + ".OrphanPort"
-                            ) or db_.create_category(lc, "OrphanPort")
-                            it = db_.create_item(db_cell, subc)
-                            port_name = ports[0][0].name or str(ports[0][0])
-                            cell_name = ports[0][1].name
-                            inst_name = ports[0][2]
-                            if inst_name:
-                                it.add_value(
-                                    f"Port Name: {inst_name}.{port_name}"
-                                    f" (cell: {cell_name})"
-                                )
-                            else:
-                                it.add_value(f"Port Name: {cell_name}.{port_name}")
-                            if ports[0][0]._base.trans:
-                                it.add_value(
-                                    self.kcl.to_um(
-                                        port_polygon(ports[0][0].width).transformed(
-                                            ports[0][0]._base.trans
-                                        )
-                                    )
-                                )
-                            else:
-                                it.add_value(
-                                    self.kcl.to_um(
-                                        port_polygon(port.width)
-                                    ).transformed(port.dcplx_trans)
-                                )
-
-                    case 2:
-                        cip = check_inst_ports(ports[0][0], ports[1][0])
-                        if cip & 1:
-                            subc = db_.category_by_path(
-                                lc.path() + ".WidthMismatch"
-                            ) or db_.create_category(lc, "WidthMismatch")
-                            create_port_error(
-                                ports[0][0],
-                                ports[1][0],
-                                ports[0][1],
-                                ports[1][1],
-                                db_,
-                                db_cell,
-                                subc,
-                                self.kcl.dbu,
-                                inst_name1=ports[0][2],
-                                inst_name2=ports[1][2],
-                            )
-
-                        if cip & 2:
-                            subc = db_.category_by_path(
-                                lc.path() + ".AngleMismatch"
-                            ) or db_.create_category(lc, "AngleMismatch")
-                            create_port_error(
-                                ports[0][0],
-                                ports[1][0],
-                                ports[0][1],
-                                ports[1][1],
-                                db_,
-                                db_cell,
-                                subc,
-                                self.kcl.dbu,
-                                inst_name1=ports[0][2],
-                                inst_name2=ports[1][2],
-                            )
-                        if cip & 4:
-                            subc = db_.category_by_path(
-                                lc.path() + ".TypeMismatch"
-                            ) or db_.create_category(lc, "TypeMismatch")
-                            create_port_error(
-                                ports[0][0],
-                                ports[1][0],
-                                ports[0][1],
-                                ports[1][1],
-                                db_,
-                                db_cell,
-                                subc,
-                                self.kcl.dbu,
-                                inst_name1=ports[0][2],
-                                inst_name2=ports[1][2],
-                            )
-                        if layer in cell_ports and coord in cell_ports[layer]:
-                            subc = db_.category_by_path(
-                                lc.path() + ".portoverlap"
-                            ) or db_.create_category(lc, "portoverlap")
-                            it = db_.create_item(db_cell, subc)
-                            text = "Port Names: "
-                            values: list[rdb.RdbItemValue] = []
-                            cell_port = cell_ports[layer][coord][0]
-                            text += (
-                                f"{self.name}."
-                                f"{cell_port.name or cell_port.trans.to_s()}/"
-                            )
-                            if cell_port.base.trans:
-                                values.append(
-                                    rdb.RdbItemValue(
-                                        self.kcl.to_um(
-                                            port_polygon(cell_port.width).transformed(
-                                                cell_port.base.trans
-                                            )
-                                        )
-                                    )
-                                )
-                            else:
-                                values.append(
-                                    rdb.RdbItemValue(
-                                        self.kcl.to_um(
-                                            port_polygon(cell_port.width)
-                                        ).transformed(cell_port.dcplx_trans)
-                                    )
-                                )
-                            for _port in ports:
-                                _label = (
-                                    f"{_port[2]}." if _port[2] else f"{_port[1].name}."
-                                )
-                                text += (
-                                    f"{_label}{_port[0].name or _port[0].trans.to_s()}/"
-                                )
-
-                                values.append(
-                                    rdb.RdbItemValue(
-                                        self.kcl.to_um(
-                                            port_polygon(_port[0].width).transformed(
-                                                _port[0].trans
-                                            )
-                                        )
-                                    )
-                                )
-                            it.add_value(text[:-1])
-                            for value in values:
-                                it.add_value(value)
-
-                    case x if x > 2:  # noqa: PLR2004
-                        subc = db_.category_by_path(
-                            lc.path() + ".portoverlap"
-                        ) or db_.create_category(lc, "portoverlap")
-                        it = db_.create_item(db_cell, subc)
-                        text = "Port Names: "
-                        values = []
-                        for _port in ports:
-                            _label = f"{_port[2]}." if _port[2] else f"{_port[1].name}."
-                            text += f"{_label}{_port[0].name or _port[0].trans.to_s()}/"
-
-                            values.append(
-                                rdb.RdbItemValue(
-                                    self.kcl.to_um(
-                                        port_polygon(_port[0].width).transformed(
-                                            _port[0].trans
-                                        )
-                                    )
-                                )
-                            )
-                        it.add_value(text[:-1])
-                        for value in values:
-                            it.add_value(value)
-                    case _:
-                        raise ValueError(f"Unexpected number of ports: {len(ports)}")
-            if check_layer_connectivity:
-                error_region_shapes = kdb.Region()
-                error_region_instances = kdb.Region()
-                reg = kdb.Region(self.shapes(layer))
-                inst_regions: dict[int, kdb.Region] = {}
-                inst_region = kdb.Region()
-                for i, inst in enumerate(self.insts):
-                    inst_region_ = kdb.Region(inst.ibbox(layer))
-                    inst_shapes: kdb.Region | None = None
-                    if not (inst_region & inst_region_).is_empty():
-                        if inst_shapes is None:
-                            inst_shapes = kdb.Region()
-                            shape_it = self.begin_shapes_rec_overlapping(
-                                layer, inst.bbox(layer)
-                            )
-                            shape_it.select_cells([inst.cell.cell_index()])
-                            shape_it.min_depth = 1
-                            shape_it.shape_flags = kdb.Shapes.SRegions
-                            for _it in shape_it.each():
-                                if _it.path()[0].inst() == inst.instance:
-                                    inst_shapes.insert(
-                                        _it.shape().polygon.transformed(_it.trans())
-                                    )
-
-                        for j, _reg in inst_regions.items():
-                            if _reg & inst_region_:
-                                reg_ = kdb.Region()
-                                shape_it = self.begin_shapes_rec_touching(
-                                    layer, (_reg & inst_region_).bbox()
-                                )
-                                shape_it.select_cells([self.insts[j].cell.cell_index()])
-                                shape_it.min_depth = 1
-                                shape_it.shape_flags = kdb.Shapes.SRegions
-                                for _it in shape_it.each():
-                                    if _it.path()[0].inst() == self.insts[j].instance:
-                                        reg_.insert(
-                                            _it.shape().polygon.transformed(_it.trans())
-                                        )
-
-                                error_region_instances.insert(reg_ & inst_shapes)
-
-                    if not (inst_region_ & reg).is_empty():
-                        rec_it = self.begin_shapes_rec_touching(
-                            layer, (inst_region_ & reg).bbox()
-                        )
-                        rec_it.min_depth = 1
-                        error_region_shapes += kdb.Region(rec_it) & reg
-                    inst_region += inst_region_
-                    inst_regions[i] = inst_region_
-                if not error_region_shapes.is_empty():
-                    sc = db_.category_by_path(
-                        layer_cat(layer).path() + ".ShapeInstanceshapeOverlap"
-                    ) or db_.create_category(
-                        layer_cat(layer), "ShapeInstanceshapeOverlap"
-                    )
-                    for poly in error_region_shapes.merge().each():
-                        it = db_.create_item(db_cell, sc)
-                        it.add_value("Shapes overlapping with shapes of instances")
-                        it.add_value(self.kcl.to_um(poly.downcast()))
-                if not error_region_instances.is_empty():
-                    sc = db_.category_by_path(
-                        layer_cat(layer).path() + ".InstanceshapeOverlap"
-                    ) or db_.create_category(layer_cat(layer), "InstanceshapeOverlap")
-                    for poly in error_region_instances.merge().each():
-                        it = db_.create_item(db_cell, sc)
-                        it.add_value(
-                            "Instance shapes overlapping with shapes of other instances"
-                        )
-                        it.add_value(self.kcl.to_um(poly.downcast()))
-
         return db_
 
     def insert_vinsts(self, recursive: bool = True) -> None:
@@ -2795,14 +2351,46 @@ def get_cross_section(
         cross_section: str
         | dict[str, Any]
         | Callable[..., CrossSection | DCrossSection]
-        | SymmetricalCrossSection,
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection,
         **cross_section_kwargs: Any,
-    ) -> TCrossSection[TUnit]: ...
+    ) -> TCrossSection[T] | TAsymmetricCrossSection[T]: ...
 
     @property
     def lvs_equivalent_ports(self) -> list[list[str]] | None:
         return self._base.lvs_equivalent_ports
 
+    def __reduce__(
+        self,
+    ) -> tuple[Callable[..., ProtoTKCell[Any]], tuple[str, str, dict[str, Any]]]:
+        if self.has_factory_name():
+            return (
+                _reconstruct,
+                (self.kcl.name, self.factory_name, self.settings.model_dump()),
+            )
+        raise NotImplementedError
+
+
+def _reconstruct(
+    kcl_name: str, factory_name: str, settings: dict[str, Any]
+) -> ProtoTKCell[Any]:
+    from .layout import kcls
+
+    return kcls[kcl_name].factories[factory_name](**settings)
+
+
+def _check_pin_ports_in_cell(
+    cell: ProtoTKCell[Any], ports: Iterable[ProtoPort[Any]], *, pin_name: str
+) -> None:
+    cell_ports = cell.base.ports
+    for port in ports:
+        if port.base not in cell_ports:
+            raise ValueError(
+                f"Cannot create pin {pin_name!r}: port {port!r} is not a port"
+                f" of cell {cell.name!r}. Add it via cell.create_port/add_port"
+                " first."
+            )
+
 
 class DKCell(ProtoTKCell[float], UMGeometricObject, DCreatePort):
     """Cell with floating point units."""
@@ -2846,7 +2434,7 @@ def __init__(
             kcl: KCLayout the cell should be attached to.
             kdb_cell: If not `None`, a KCell will be created from and existing
                 KLayout Cell
-            ports: Attach an existing [Ports][kfactory.kcell.Ports] object to the KCell,
+            ports: Attach an existing [Ports][kfactory.ports.Ports] object to the KCell,
                 if `None` create an empty one.
             info: Info object to attach to the KCell.
             settings: KCellSettings object to attach to the KCell.
@@ -2917,12 +2505,14 @@ def add_port(
     def create_pin(
         self,
         *,
+        name: str,
         ports: Iterable[ProtoPort[Any]],
-        name: str | None = None,
         pin_type: str = "DC",
-        info: dict[str, int | float | str] | None = None,
+        info: dict[str, MetaData] | None = None,
     ) -> DPin:
         """Create a pin in the cell."""
+        ports = list(ports)
+        _check_pin_ports_in_cell(self, ports, pin_name=name)
         return self.pins.create_pin(
             name=name, ports=ports, pin_type=pin_type, info=info
         )
@@ -2962,28 +2552,21 @@ def get_cross_section(
         cross_section: str
         | dict[str, Any]
         | Callable[..., CrossSection | DCrossSection]
-        | SymmetricalCrossSection,
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection,
         **cross_section_kwargs: Any,
-    ) -> DCrossSection:
-        if isinstance(cross_section, str):
-            return DCrossSection(
-                kcl=self.kcl, base=self.kcl.cross_sections[cross_section]
-            )
-        if isinstance(cross_section, SymmetricalCrossSection):
-            return DCrossSection(kcl=self.kcl, base=cross_section)
+    ) -> DCrossSection | DAsymmetricCrossSection:
         if callable(cross_section):
-            any_cross_section = cross_section(**cross_section_kwargs)
-            return DCrossSection(kcl=self.kcl, base=any_cross_section._base)
-        if isinstance(cross_section, dict):
+            return self.kcl.get_dcross_section(
+                cross_section(**cross_section_kwargs)  # ty:ignore[call-top-callable]
+            )
+        if isinstance(cross_section, dict) and "settings" in cross_section:
             return DCrossSection(
                 kcl=self.kcl,
                 name=cross_section.get("name"),
                 **cross_section["settings"],
             )
-        raise ValueError(
-            "Cannot create a cross section from "
-            f"{type(cross_section)=} and {cross_section_kwargs=}"
-        )
+        return self.kcl.get_dcross_section(cross_section)
 
     @property
     def library_cell(self) -> DKCell:
@@ -3039,7 +2622,7 @@ def __init__(
             kcl: KCLayout the cell should be attached to.
             kdb_cell: If not `None`, a KCell will be created from and existing
                 KLayout Cell
-            ports: Attach an existing [Ports][kfactory.kcell.Ports] object to the KCell,
+            ports: Attach an existing [Ports][kfactory.ports.Ports] object to the KCell,
                 if `None` create an empty one.
             info: Info object to attach to the KCell.
             settings: KCellSettings object to attach to the KCell.
@@ -3121,12 +2704,14 @@ def add_port(
     def create_pin(
         self,
         *,
+        name: str,
         ports: Iterable[ProtoPort[Any]],
-        name: str | None = None,
         pin_type: str = "DC",
-        info: dict[str, int | float | str] | None = None,
+        info: dict[str, MetaData] | None = None,
     ) -> Pin:
         """Create a pin in the cell."""
+        ports = list(ports)
+        _check_pin_ports_in_cell(self, ports, pin_name=name)
         return self.pins.create_pin(
             name=name, ports=ports, pin_type=pin_type, info=info
         )
@@ -3245,19 +2830,19 @@ def from_yaml(
                 margin = t.get("margin", DEFAULT_TRANS["margin"])
                 margin_x = margin.get(
                     "x",
-                    DEFAULT_TRANS["margin"]["x"],  # type: ignore[index]
+                    DEFAULT_TRANS["margin"]["x"],  # ty:ignore[not-subscriptable, invalid-argument-type]
                 )
                 margin_y = margin.get(
                     "y",
-                    DEFAULT_TRANS["margin"]["y"],  # type: ignore[index]
+                    DEFAULT_TRANS["margin"]["y"],  # ty:ignore[not-subscriptable, invalid-argument-type]
                 )
                 margin_x0 = margin.get(
                     "x0",
-                    DEFAULT_TRANS["margin"]["x0"],  # type: ignore[index]
+                    DEFAULT_TRANS["margin"]["x0"],  # ty:ignore[not-subscriptable, invalid-argument-type]
                 )
                 margin_y0 = margin.get(
                     "y0",
-                    DEFAULT_TRANS["margin"]["y0"],  # type: ignore[index]
+                    DEFAULT_TRANS["margin"]["y0"],  # ty:ignore[not-subscriptable, invalid-argument-type]
                 )
                 ref_yml = t.get("ref", DEFAULT_TRANS["ref"])
                 if isinstance(ref_yml, str):
@@ -3433,28 +3018,28 @@ def get_cross_section(
         cross_section: str
         | dict[str, Any]
         | Callable[..., CrossSection | DCrossSection]
-        | SymmetricalCrossSection,
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection,
         **cross_section_kwargs: Any,
-    ) -> CrossSection:
-        if isinstance(cross_section, str):
-            return CrossSection(
-                kcl=self.kcl, base=self.kcl.cross_sections[cross_section]
-            )
-        if isinstance(cross_section, SymmetricalCrossSection):
-            return CrossSection(kcl=self.kcl, base=cross_section)
+    ) -> CrossSection | AsymmetricCrossSection:
         if callable(cross_section):
-            any_cross_section = cross_section(**cross_section_kwargs)
-            return CrossSection(kcl=self.kcl, base=any_cross_section._base)
-        if isinstance(cross_section, dict):
+            return self.kcl.get_icross_section(
+                cross_section(**cross_section_kwargs)  # ty:ignore[call-top-callable]
+            )
+        if isinstance(cross_section, dict) and "settings" in cross_section:
             return CrossSection(
                 kcl=self.kcl,
                 name=cross_section.get("name"),
                 **cross_section["settings"],
             )
-        raise ValueError(
-            "Cannot create a cross section from "
-            f"{type(cross_section)=} and {cross_section_kwargs=}"
-        )
+        return self.kcl.get_icross_section(cross_section)
+
+    def __getattr__(self, name: str) -> Any:
+        """If KCell doesn't have an attribute, look in the KLayout Cell."""
+        try:
+            return ProtoTKCell.__getattr__(self, name)
+        except Exception:
+            return getattr(self._base, name)
 
 
 class VKCell(ProtoKCell[float, TVCell], UMGeometricObject, DCreatePort):
@@ -3551,6 +3136,27 @@ def __getitem__(self, key: int | str | None) -> DPort:
         """Returns port from instance."""
         return self.ports[key]
 
+    def get_cross_section(
+        self,
+        cross_section: str
+        | dict[str, Any]
+        | Callable[..., CrossSection | DCrossSection]
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection,
+        **cross_section_kwargs: Any,
+    ) -> DCrossSection | DAsymmetricCrossSection:
+        if callable(cross_section):
+            return self.kcl.get_dcross_section(
+                cross_section(**cross_section_kwargs)  # ty:ignore[call-top-callable]
+            )
+        if isinstance(cross_section, dict) and "settings" in cross_section:
+            return DCrossSection(
+                kcl=self.kcl,
+                name=cross_section.get("name"),
+                **cross_section["settings"],
+            )
+        return self.kcl.get_dcross_section(cross_section)
+
     @property
     def insts(self) -> VInstances:
         return self._base.vinsts
@@ -3653,7 +3259,7 @@ def add_port(
         name: str | None = None,
         keep_mirror: bool = False,
     ) -> DPort:
-        """Proxy for [Ports.create_port][kfactory.kcell.Ports.create_port]."""
+        """Proxy for [Ports.create_port][kfactory.ports.Ports.create_port]."""
         if self.locked:
             raise LockedError(self)
         return self.ports.add_port(
@@ -3709,7 +3315,8 @@ def flatten(self) -> None:
         if self.locked:
             raise LockedError(self)
         for inst in self.insts:
-            inst.insert_into_flat(self, inst.trans)
+            inst.insert_into_flat(self)
+        self._base.vinsts = VInstances()
 
     def draw_ports(self) -> None:
         """Draw all the ports on their respective layer."""
@@ -3721,7 +3328,7 @@ def draw_ports(self) -> None:
             if w in polys:
                 poly = polys[w]
             else:
-                if w < 2:  # noqa: PLR2004
+                if w < 2:
                     poly = kdb.DPolygon(
                         [
                             kdb.DPoint(0, -w / 2),
@@ -3756,7 +3363,7 @@ def write(
     ) -> None:
         """Write a KCell to a GDS.
 
-        See [KCLayout.write][kfactory.kcell.KCLayout.write] for more info.
+        See [KCLayout.write][kfactory.layout.KCLayout.write] for more info.
         """
         if save_options is None:
             save_options = save_layout_options()
@@ -3856,6 +3463,7 @@ def show(
     library_save_options: kdb.SaveLayoutOptions | None = None,
     set_technology: bool = True,
     file_format: Literal["oas", "gds"] = "oas",
+    markers: list[tuple[DShapeLike, MarkerConfig]] | None = None,
 ) -> None:
     """Show GDS in klayout.
 
@@ -3869,6 +3477,7 @@ def show(
         use_libraries: Save other KCLayouts as libraries on write.
         library_save_options: Specific saving options for Cells which are in a library
             and not the main KCLayout.
+        markers: lay.Marker list
     """
     from .layout import KCLayout, kcls
 
@@ -3956,7 +3565,7 @@ def show(
     if not file.is_file():
         raise ValueError(f"{file} is not a File")
     logger.debug("klive file: {}", file)
-    data_dict = {
+    data_dict: JSONSerializable = {
         "gds": _klive_path(file),
         "keep_position": keep_position,
         "libraries": kcl_paths,
@@ -3995,6 +3604,14 @@ def show(
     if set_technology and technology is not None:
         data_dict["technology"] = technology
 
+    if markers:
+        json_markers: list[tuple[str, str, MarkerConfig]] = []
+        for marker_shape, marker_config in markers:
+            json_markers.append(
+                (marker_shape.__class__.__name__, marker_shape.to_s(), marker_config)
+            )
+        data_dict["markers"] = json_markers  # ty:ignore[invalid-assignment]
+
     data = json.dumps(data_dict)
     try:
         conn = socket.create_connection(("127.0.0.1", 8082), timeout=0.5)
@@ -4183,157 +3800,5 @@ def get_cells(
     return cells
 
 
-AnyKCell: TypeAlias = ProtoKCell[Any, Any]
-AnyTKCell: TypeAlias = ProtoTKCell[Any]
-
-
-def _get_netlist(
-    c: ProtoTKCell[Any],
-    l2n_opt: kdb.LayoutToNetlist,
-    l2n_elec: kdb.LayoutToNetlist,
-    ignore_unnamed: bool = False,
-    exclude_purposes: list[str] | None = None,
-) -> Netlist:
-    opt_circ = l2n_opt.netlist().circuit_by_name(c.name)
-    elec_circ = l2n_elec.netlist().circuit_by_name(c.name)
-    nl = Netlist(nets=[])
-    exclude_purposes = exclude_purposes or []
-    keep_name = not ignore_unnamed
-
-    for inst in c.insts:
-        if (keep_name or inst.is_named()) and (inst.purpose not in exclude_purposes):
-            if inst.cell.has_factory_name():
-                nl.create_inst(
-                    name=inst.name,
-                    kcl=inst.cell.library().name()
-                    if inst.cell.is_library_cell()
-                    else inst.cell.kcl.name,
-                    component=inst.cell.factory_name,
-                    settings={
-                        k: serialize_setting(v)
-                        for k, v in inst.cell.settings.model_dump().items()
-                    },
-                )
-            else:
-                nl.create_inst(
-                    name=inst.name,
-                    kcl=inst.cell.library().name()
-                    if inst.cell.is_library_cell()
-                    else inst.cell.kcl.name,
-                    component=inst.cell.name,
-                    settings={
-                        k: serialize_setting(v)
-                        for k, v in inst.cell.settings.model_dump().items()
-                    },
-                )
-
-    for net in opt_circ.each_net():
-        net_refs: list[PortRef | NetlistPort] = []
-        for pinref in net.each_pin():
-            p = nl.create_port(pinref.pin().name())
-            net_refs.append(p)
-        for subc_pin in net.each_subcircuit_pin():
-            subc = subc_pin.subcircuit()
-            circ_ref = subc.circuit_ref()
-            circ = subc.circuit()
-            pin = subc_pin.pin()
-            recit = kdb.RecursiveInstanceIterator(
-                c.kcl.layout, c.kcl.layout.cell(circ.name)
-            )
-            recit.max_depth = 0
-            recit.targets = [circ_ref.cell_index]
-            for it in recit.each():
-                inst_el = it.current_inst_element()
-                if inst_el.specific_cplx_trans() == kdb.ICplxTrans(
-                    trans=subc.trans, dbu=c.kcl.dbu
-                ):
-                    if inst_el.ia() < 0:
-                        net_refs.append(PortRef(instance=subc.name, port=pin.name()))
-                    else:
-                        net_refs.append(
-                            PortArrayRef(
-                                instance=subc.name,
-                                port=pin.name(),
-                                ia=inst_el.ia(),
-                                ib=inst_el.ib(),
-                            )
-                        )
-                    break
-        if len(net_refs) > 1:
-            nl.nets.append(Net(net_refs))
-    if elec_circ:
-        instances_per_transformation: dict[
-            kdb.DCplxTrans, list[ProtoTInstance[Any]]
-        ] = defaultdict(list)
-        for inst in c.insts:
-            instances_per_transformation[inst.dcplx_trans].append(inst)
-        for net in elec_circ.each_net():
-            net_refs = []
-            for pinref in net.each_pin():
-                p = nl.create_port(pinref.pin().name())
-                net_refs.append(p)
-            for subc_pin in net.each_subcircuit_pin():
-                subc = subc_pin.subcircuit()
-                circ_ref = subc.circuit_ref()
-                circ = subc.circuit()
-                pin = subc_pin.pin()
-                recit = kdb.RecursiveInstanceIterator(
-                    c.kcl.layout,
-                    c.kcl.layout.cell(circ.name),
-                    box=kdb.Box(2).transformed(
-                        kdb.ICplxTrans(trans=subc.trans, dbu=c.kcl.dbu)
-                    ),
-                )
-                recit.max_depth = 0
-                recit.targets = [
-                    c.kcl[
-                        l2n_elec.internal_layout().cell(circ_ref.cell_index).name
-                    ].cell_index()
-                ]
-                recit.overlapping = True
-                for it in recit.each():
-                    inst_el = it.current_inst_element()
-                    if (
-                        inst_el.specific_cplx_trans()
-                        == kdb.ICplxTrans(trans=subc.trans, dbu=c.kcl.dbu)
-                        and pin.name() != ""
-                    ):
-                        inst = Instance(kcl=c.kcl, instance=inst_el.inst())
-                        purpose = inst.property(PROPID.PURPOSE)
-                        name = inst.property(PROPID.NAME)
-                        if (name is None and ignore_unnamed) or (
-                            purpose in exclude_purposes
-                        ):
-                            continue
-                        if inst_el.ia() < 0:
-                            net_refs.append(
-                                PortRef(instance=inst.name, port=pin.name())
-                            )
-                        else:
-                            net_refs.append(
-                                PortArrayRef(
-                                    instance=subc.name,
-                                    port=pin.name(),
-                                    ia=inst_el.ia(),
-                                    ib=inst_el.ib(),
-                                )
-                            )
-                        break
-            if len(net_refs) > 1:
-                nl.create_net(*net_refs)
-    nl.sort()
-    return nl
-
-
-@overload
-def _get_orig_cell(c: KCell) -> KCell: ...
-
-
-@overload
-def _get_orig_cell(c: DKCell) -> DKCell: ...
-
-
-def _get_orig_cell(c: KCell | DKCell) -> KCell | DKCell:
-    if c.is_library_cell():
-        return _get_orig_cell(c.library_cell)
-    return c
+type AnyKCell = ProtoKCell[Any, Any]
+type AnyTKCell = ProtoTKCell[Any]
diff --git a/src/kfactory/layer.py b/src/kfactory/layer.py
index 3ce4d19ed..1c2451ca9 100644
--- a/src/kfactory/layer.py
+++ b/src/kfactory/layer.py
@@ -3,7 +3,7 @@
 from typing import TYPE_CHECKING, Any, Self
 
 import klayout.db as kdb
-from aenum import Enum, constant  # type: ignore[import-untyped,unused-ignore]
+from aenum import Enum, constant
 from pydantic import BaseModel, ConfigDict, Field, model_validator
 
 from .exceptions import InvalidLayerError
@@ -65,10 +65,10 @@ def _validate_layers(self) -> Self:
         return self
 
     def __getitem__(self, value: str) -> kdb.LayerInfo:
-        return getattr(self, value)  # type: ignore[no-any-return]
+        return getattr(self, value)
 
 
-class LayerEnum(int, Enum):  # type: ignore[misc]
+class LayerEnum(int, Enum):  # ty:ignore[unsupported-base]
     """Class for having the layers stored and a mapping int <-> layer,datatype.
 
     This Enum can also be treated as a tuple, i.e. it implements `__getitem__`
@@ -103,10 +103,10 @@ def __new__(
         """
         value = cls.layout.layer(layer, datatype)
         obj: int = int.__new__(cls, value)
-        obj._value_ = value  # type: ignore[attr-defined]
-        obj.layer = layer  # type: ignore[attr-defined]
-        obj.datatype = datatype  # type: ignore[attr-defined]
-        return obj  # type: ignore[return-value]
+        obj._value_ = value  # ty:ignore[unresolved-attribute]
+        obj.layer = layer  # ty:ignore[unresolved-attribute]
+        obj.datatype = datatype  # ty:ignore[unresolved-attribute]
+        return obj  # ty:ignore[invalid-return-type]
 
     def __getitem__(self, key: int) -> int:
         """Retrieve layer number[0] / datatype[1] of a layer."""
@@ -272,6 +272,6 @@ def layerenum_from_dict(
     for li in layers.model_dump().values():
         members[li.name] = li.layer, li.datatype
     return LayerEnum(
-        name,  # type: ignore[arg-type]
-        members,  # type: ignore[arg-type]
-    )
+        name,  # ty:ignore[invalid-argument-type]
+        members,  # ty:ignore[invalid-argument-type]
+    )  # ty:ignore[invalid-return-type]
diff --git a/src/kfactory/layout.py b/src/kfactory/layout.py
index c707e58bb..73b55cc8c 100644
--- a/src/kfactory/layout.py
+++ b/src/kfactory/layout.py
@@ -1,10 +1,12 @@
 from __future__ import annotations
 
+import contextlib
 import functools
 import inspect
 from collections import defaultdict
 from collections.abc import (
     Callable,
+    Hashable,
     Iterable,
     Iterator,
     Mapping,
@@ -18,7 +20,6 @@
     TYPE_CHECKING,
     Any,
     Concatenate,
-    Generic,
     Literal,
     TypedDict,
     cast,
@@ -32,28 +33,42 @@
     BaseModel,
     ConfigDict,
     Field,
-    model_validator,
+    PrivateAttr,
+    field_validator,
 )
 
 from . import __version__, kdb
-from .conf import CheckInstances, config, logger
+from .conf import CheckInstances, CheckUnnamedCells, config, logger
 from .cross_section import (
+    AsymmetricalCrossSection,
+    AsymmetricCrossSection,
     CrossSection,
+    CrossSectionLayer,
     CrossSectionModel,
-    CrossSectionSpec,
+    CrossSectionSpecDict,
+    DAsymmetricalCrossSection,
+    DAsymmetricCrossSection,
     DCrossSection,
-    DCrossSectionSpec,
+    DCrossSectionLayer,
+    DCrossSectionSpecDict,
     DSymmetricalCrossSection,
     SymmetricalCrossSection,
+    TAsymmetricCrossSection,
+    TCrossSection,
+)
+from .decorators import (
+    Decorators,
+    PortsDefinition,
+    WrappedKCellFunc,
+    WrappedVKCellFunc,
 )
-from .decorators import Decorators, PortsDefinition, WrappedKCellFunc, WrappedVKCellFunc
 from .enclosure import (
     KCellEnclosure,
     LayerEnclosure,
     LayerEnclosureModel,
     LayerEnclosureSpec,
 )
-from .exceptions import MergeError
+from .exceptions import FactoriesLockedError, MergeError
 from .kcell import (
     AnyTKCell,
     BaseKCell,
@@ -72,24 +87,20 @@
 from .pin import BasePin
 from .port import BasePort, ProtoPort, rename_clockwise_multi
 from .routing.generic import ManhattanRoute
+from .serialization import get_function_name
 from .settings import Info, KCellSettings
-from .typings import (
-    KC,
-    KCIN,
-    VK,
-    F,
-    KC_contra,
-    KCellParams,
-    MetaData,
-    P,
-    T,
-    TUnit,
-)
 from .utilities import load_layout_options, save_layout_options
 
 if TYPE_CHECKING:
     from .ports import DPorts, Ports
     from .schematic import TSchematic
+    from .typings import (
+        KCIN,
+        VK,
+        KCellParams,
+        MetaData,
+        T,
+    )
 
 kcl: KCLayout
 kcls: dict[str, KCLayout] = {}
@@ -108,19 +119,37 @@ def get_default_kcl() -> KCLayout:
     return kcl
 
 
-class Factories(Mapping[str, F], Generic[F]):
+class Factories[F: WrappedKCellFunc[Any, Any] | WrappedVKCellFunc[Any, Any]](
+    Mapping[str, F]
+):
     _all: list[F]
     _by_name: dict[str, int]
     _by_tag: defaultdict[str, list[int]]
     _by_function: dict[Callable[..., Any], int]
+    _locked: bool
 
     def __init__(self) -> None:
         self._all = []
         self._by_name = {}
         self._by_tag = defaultdict(list)
         self._by_function = {}
+        self._locked = False
+
+    @property
+    def locked(self) -> bool:
+        """Whether this collection rejects new factories via `add`."""
+        return self._locked
+
+    def lock(self) -> None:
+        """Prevent further additions through `add`. This is irreversible."""
+        self._locked = True
 
     def add(self, factory: F) -> None:
+        if self._locked:
+            raise FactoriesLockedError(
+                f"Cannot add factory {factory.name!r}: this Factories collection is "
+                "locked."
+            )
         idx = len(self._all)
         self._all.append(factory)
         for tag in factory.tags:
@@ -179,16 +208,20 @@ def __getitem__(self, key: str) -> F:
             ) from e
 
     @overload
-    def get(self, key: str, /) -> F | None: ...
+    def get(self, key: object, /) -> F | None: ...
 
     @overload
-    def get(self, key: str, /, default: T) -> F | T: ...
+    def get(self, key: object, /, default: T) -> F | T: ...
 
-    def get(self, key: str, /, default: T | None = None) -> F | T | None:
+    def get(self, key: object, /, default: T | None = None) -> F | T | None:
         if key in self._by_name:
-            return self.get_by_name(key)
+            return self.get_by_name(cast("str", key))
         return default
 
+    def get_by_path(self, path: str | Path) -> list[F]:
+        p = Path(path).expanduser().resolve()
+        return [factory for factory in self._all if p == factory.file]
+
     def as_dict(self) -> dict[str, F]:
         return {name: self._all[i] for name, i in self._by_name.items()}
 
@@ -241,8 +274,10 @@ class KCLayout(
 
     factories: Factories[WrappedKCellFunc[Any, ProtoTKCell[Any]]]
     virtual_factories: Factories[WrappedVKCellFunc[Any, VKCell]]
+    generic_factories: dict[
+        str, Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]
+    ] = Field(default_factory=dict)
     tkcells: dict[int, TKCell] = Field(default_factory=dict)
-    layers: type[LayerEnum]
     infos: LayerInfos
     layer_stack: LayerStack
     netlist_layer_mapping: dict[LayerEnum | int, LayerEnum | int] = Field(
@@ -257,7 +292,7 @@ class KCLayout(
 
     info: Info = Field(default_factory=Info)
     settings: KCellSettings = Field(frozen=True)
-    future_cell_name: str | None
+    _future_cell_name: str | None = PrivateAttr(default=None)
 
     decorators: Decorators
     default_cell_output_type: type[KCell | DKCell] = KCell
@@ -273,8 +308,7 @@ class KCLayout(
         Callable[
             Concatenate[
                 ProtoTKCell[Any],
-                Sequence[ProtoPort[Any]],
-                Sequence[ProtoPort[Any]],
+                Sequence[Sequence[ProtoPort[Any]]],
                 ...,
             ],
             list[ManhattanRoute],
@@ -347,7 +381,6 @@ def __init__(
             cross_sections=CrossSectionModel(kcl=self),
             enclosure=KCellEnclosure([]),
             infos=infos_,
-            layers=LayerEnum,
             factories=Factories[WrappedKCellFunc[Any, ProtoTKCell[Any]]](),
             virtual_factories=Factories[WrappedVKCellFunc[Any, VKCell]](),
             sparameters_path=sparameters_path,
@@ -358,10 +391,9 @@ def __init__(
             layout=layout,
             rename_function=port_rename_function,
             info=Info(**info) if info else Info(),
-            future_cell_name=None,
             settings=KCellSettings(
                 version=__version__,
-                klayout_version=kdb.__version__,  # type: ignore[attr-defined]
+                klayout_version=kdb.__version__,  # ty:ignore[unresolved-attribute]
                 meta_format="v3",
             ),
             decorators=Decorators(self),
@@ -371,6 +403,10 @@ def __init__(
         )
 
         self.library.register(self.name)
+        # Materialize `layers` so LayerEnum.__init__ registers each layer's name
+        # on `self.layout`; otherwise `find_layer(layer, datatype)` called
+        # before any `kcl.layers` access would see an unnamed layer.
+        _ = self.layers
 
         enclosure = KCellEnclosure(
             enclosures=[enc.model_copy() for enc in enclosure.enclosures.enclosures]
@@ -383,14 +419,19 @@ def __init__(
 
         kcls[self.name] = self
 
-    @model_validator(mode="before")
+    @field_validator("infos", mode="before")
     @classmethod
-    def _validate_layers(cls, data: dict[str, Any]) -> dict[str, Any]:
-        data["layers"] = layerenum_from_dict(
-            layers=data["infos"], layout=data["library"].layout()
-        )
-        data["library"].register(data["name"])
-        return data
+    def _validate_infos(cls, value: Any) -> LayerInfos:
+        if value is None:
+            return LayerInfos()
+        if isinstance(value, type) and issubclass(value, LayerInfos):
+            return value()
+        return value
+
+    @cached_property
+    def layers(self) -> type[LayerEnum]:
+        """LayerEnum derived from `infos`. Cached; invalidated when `infos` is set."""
+        return layerenum_from_dict(layers=self.infos, layout=self.library.layout())
 
     @functools.cached_property
     def dkcells(self) -> DKCells:
@@ -407,6 +448,22 @@ def dbu(self) -> float:
         """Get the database unit."""
         return self.layout.dbu
 
+    @property
+    def factories_locked(self) -> bool:
+        """Whether both the real and virtual factory collections are locked."""
+        return self.factories.locked and self.virtual_factories.locked
+
+    def lock_factories(self) -> None:
+        """Prevent further factories (real and virtual) from being registered.
+
+        This is irreversible: once locked, a `KCLayout` will reject any new
+        factory registrations (e.g. via `@kcl.cell` / `@kcl.vcell` or direct
+        `factories.add` calls). Use this to seal a PDK after registering all
+        of its pcell functions.
+        """
+        self.factories.lock()
+        self.virtual_factories.lock()
+
     def create_layer_enclosure(
         self,
         sections: Sequence[
@@ -502,7 +559,7 @@ def find_layer(
         )
         info = self.layout.get_info(self.layout.layer(*args, **kwargs))
         try:
-            return self.layers[info.name]  # type:ignore[no-any-return, index]
+            return self.layers[info.name]
         except KeyError as e:
             if allow_undefined_layers:
                 return self.layout.layer(info)
@@ -609,14 +666,14 @@ def to_dbu(
         return kdb.CplxTrans(self.layout.dbu).inverted() * other
 
     @overload
-    def schematic_cell(
+    def schematic_cell[**KCellParams](
         self,
-        _func: Callable[KCellParams, TSchematic[TUnit]],
+        _func: Callable[KCellParams, TSchematic[Any]],
         /,
     ) -> Callable[KCellParams, KCell]: ...
 
     @overload
-    def schematic_cell(
+    def schematic_cell[**KCellParams](
         self,
         /,
         *,
@@ -627,7 +684,7 @@ def schematic_cell(
         check_instances: CheckInstances | None = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
@@ -655,11 +712,11 @@ def schematic_cell(
         ]
         | None = None,
     ) -> Callable[
-        [Callable[KCellParams, TSchematic[TUnit]]], Callable[KCellParams, KCell]
+        [Callable[KCellParams, TSchematic[Any]]], Callable[KCellParams, KCell]
     ]: ...
 
     @overload
-    def schematic_cell(
+    def schematic_cell[**KCellParams](
         self,
         /,
         *,
@@ -670,7 +727,7 @@ def schematic_cell(
         check_instances: CheckInstances | None = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
@@ -699,11 +756,11 @@ def schematic_cell(
         ]
         | None = None,
     ) -> Callable[
-        [Callable[KCellParams, TSchematic[TUnit]]], Callable[KCellParams, KCell]
+        [Callable[KCellParams, TSchematic[Any]]], Callable[KCellParams, KCell]
     ]: ...
 
     @overload
-    def schematic_cell(
+    def schematic_cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -715,14 +772,14 @@ def schematic_cell(
         check_instances: CheckInstances | None = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
         overwrite_existing: bool | None = ...,
         layout_cache: bool | None = ...,
         info: dict[str, MetaData] | None = ...,
-        post_process: Iterable[Callable[[KCell], None]],
+        post_process: Iterable[Callable[[KC], None]],
         debug_names: bool | None = ...,
         tags: list[str] | None = ...,
         factories: Mapping[
@@ -744,11 +801,11 @@ def schematic_cell(
         ]
         | None = None,
     ) -> Callable[
-        [Callable[KCellParams, TSchematic[TUnit]]], Callable[KCellParams, KC]
+        [Callable[KCellParams, TSchematic[Any]]], Callable[KCellParams, KC]
     ]: ...
 
     @overload
-    def schematic_cell(
+    def schematic_cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -760,7 +817,7 @@ def schematic_cell(
         check_instances: CheckInstances | None = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
@@ -788,12 +845,12 @@ def schematic_cell(
         ]
         | None = None,
     ) -> Callable[
-        [Callable[KCellParams, TSchematic[TUnit]]], Callable[KCellParams, KC]
+        [Callable[KCellParams, TSchematic[Any]]], Callable[KCellParams, KC]
     ]: ...
 
-    def schematic_cell(
+    def schematic_cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
-        _func: Callable[KCellParams, TSchematic[TUnit]] | None = None,
+        _func: Callable[KCellParams, TSchematic[Any]] | None = None,
         /,
         *,
         output_type: type[KC] | None = None,
@@ -804,14 +861,14 @@ def schematic_cell(
         check_instances: CheckInstances | None = None,
         snap_ports: bool = True,
         add_port_layers: bool = True,
-        cache: Cache[int, Any] | dict[int, Any] | None = None,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = None,
         basename: str | None = None,
         drop_params: Sequence[str] = ("self", "cls"),
         register_factory: bool = True,
         overwrite_existing: bool | None = None,
         layout_cache: bool | None = None,
         info: dict[str, MetaData] | None = None,
-        post_process: Iterable[Callable[[KCell], None]] | None = None,
+        post_process: Iterable[Callable[[KC], None]] | None = None,
         debug_names: bool | None = None,
         tags: list[str] | None = None,
         factories: Mapping[
@@ -824,8 +881,7 @@ def schematic_cell(
             Callable[
                 Concatenate[
                     ProtoTKCell[Any],
-                    Sequence[ProtoPort[Any]],
-                    Sequence[ProtoPort[Any]],
+                    Sequence[Sequence[ProtoPort[Any]]],
                     ...,
                 ],
                 Any,
@@ -847,7 +903,7 @@ def schematic_cell(
             if output_type is None:
 
                 def wrap_f(
-                    f: Callable[KCellParams, TSchematic[TUnit]],
+                    f: Callable[KCellParams, TSchematic[Any]],
                 ) -> Callable[KCellParams, KCell]:
                     @self.cell(
                         output_type=KCell,
@@ -865,7 +921,9 @@ def wrap_f(
                         overwrite_existing=overwrite_existing,
                         layout_cache=layout_cache,
                         info=info,
-                        post_process=post_process or [],
+                        post_process=cast(
+                            "Iterable[Callable[[KCell], None]]", post_process or []
+                        ),
                         debug_names=debug_names,
                         tags=tags,
                         schematic_function=f,
@@ -876,7 +934,7 @@ def kcell_func(
                     ) -> KCell:
                         schematic = f(*args, **kwargs)
                         if set_name:
-                            schematic.name = self.future_cell_name
+                            schematic.name = self._future_cell_name
                         c_ = schematic.create_cell(
                             KCell,
                             factories=factories,
@@ -890,8 +948,10 @@ def kcell_func(
 
                 return wrap_f
 
+            post_process = cast("Iterable[Callable[[KC], None]]", post_process or [])
+
             def custom_wrap_f(
-                f: Callable[KCellParams, TSchematic[TUnit]],
+                f: Callable[KCellParams, TSchematic[Any]],
             ) -> Callable[KCellParams, KC]:
                 @self.cell(
                     output_type=output_type,
@@ -909,7 +969,7 @@ def custom_wrap_f(
                     overwrite_existing=overwrite_existing,
                     layout_cache=layout_cache,
                     info=info,
-                    post_process=post_process or [],
+                    post_process=post_process,
                     debug_names=debug_names,
                     tags=tags,
                     schematic_function=f,
@@ -920,7 +980,7 @@ def custom_kcell_func(
                 ) -> KCell:
                     schematic = f(*args, **kwargs)
                     if set_name:
-                        schematic.name = self.future_cell_name
+                        schematic.name = self._future_cell_name
                     c_ = schematic.create_cell(
                         KCell,
                         factories=factories,
@@ -935,7 +995,7 @@ def custom_kcell_func(
             return custom_wrap_f
 
         def simple_wrap_f(
-            f: Callable[KCellParams, TSchematic[TUnit]],
+            f: Callable[KCellParams, TSchematic[Any]],
         ) -> Callable[KCellParams, KCell]:
             @self.cell(output_type=KCell, schematic_function=f)
             @functools.wraps(f)
@@ -944,7 +1004,7 @@ def kcell_func(
             ) -> KCell:
                 schematic = f(*args, **kwargs)
                 if set_name:
-                    schematic.name = self.future_cell_name
+                    schematic.name = self._future_cell_name
                 c_ = schematic.create_cell(
                     KCell,
                     factories=factories,
@@ -959,14 +1019,14 @@ def kcell_func(
         return simple_wrap_f(_func)
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         _func: Callable[KCellParams, KC],
         /,
     ) -> Callable[KCellParams, KC]: ...
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -975,9 +1035,10 @@ def cell(
         check_ports: bool = ...,
         check_pins: bool = ...,
         check_instances: CheckInstances | None = ...,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
@@ -992,7 +1053,7 @@ def cell(
     ) -> Callable[[Callable[KCellParams, KC]], Callable[KCellParams, KC]]: ...
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -1001,9 +1062,10 @@ def cell(
         check_ports: bool = ...,
         check_pins: bool = ...,
         check_instances: CheckInstances | None = ...,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
@@ -1018,7 +1080,7 @@ def cell(
     ) -> Callable[[Callable[KCellParams, KC]], Callable[KCellParams, KC]]: ...
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -1027,16 +1089,17 @@ def cell(
         check_ports: bool = ...,
         check_pins: bool = ...,
         check_instances: CheckInstances | None = ...,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
         overwrite_existing: bool | None = ...,
         layout_cache: bool | None = ...,
         info: dict[str, MetaData] | None = ...,
-        post_process: Iterable[Callable[[KC_contra], None]],
+        post_process: Iterable[Callable[[KC], None]],
         debug_names: bool | None = ...,
         tags: list[str] | None = ...,
         lvs_equivalent_ports: list[list[str]] | None = None,
@@ -1045,7 +1108,7 @@ def cell(
     ) -> Callable[[Callable[KCellParams, KC]], Callable[KCellParams, KC]]: ...
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -1054,16 +1117,17 @@ def cell(
         check_ports: bool = ...,
         check_pins: bool = ...,
         check_instances: CheckInstances | None = ...,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
         overwrite_existing: bool | None = ...,
         layout_cache: bool | None = ...,
         info: dict[str, MetaData] | None = ...,
-        post_process: Iterable[Callable[[KC_contra], None]],
+        post_process: Iterable[Callable[[KC], None]],
         debug_names: bool | None = ...,
         tags: list[str] | None = ...,
         lvs_equivalent_ports: list[list[str]] | None = None,
@@ -1072,7 +1136,7 @@ def cell(
     ) -> Callable[[Callable[KCellParams, KC]], Callable[KCellParams, KC]]: ...
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -1082,16 +1146,17 @@ def cell(
         check_ports: bool = ...,
         check_pins: bool = ...,
         check_instances: CheckInstances | None = ...,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
         overwrite_existing: bool | None = ...,
         layout_cache: bool | None = ...,
         info: dict[str, MetaData] | None = ...,
-        post_process: Iterable[Callable[[KC_contra], None]],
+        post_process: Iterable[Callable[[KC], None]],
         debug_names: bool | None = ...,
         tags: list[str] | None = ...,
         lvs_equivalent_ports: list[list[str]] | None = None,
@@ -1102,7 +1167,7 @@ def cell(
     ]: ...
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -1112,16 +1177,17 @@ def cell(
         check_ports: bool = ...,
         check_pins: bool = ...,
         check_instances: CheckInstances | None = ...,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
         overwrite_existing: bool | None = ...,
         layout_cache: bool | None = ...,
         info: dict[str, MetaData] | None = ...,
-        post_process: Iterable[Callable[[KC_contra], None]],
+        post_process: Iterable[Callable[[KC], None]],
         debug_names: bool | None = ...,
         tags: list[str] | None = ...,
         lvs_equivalent_ports: list[list[str]] | None = None,
@@ -1132,7 +1198,7 @@ def cell(
     ]: ...
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -1142,9 +1208,10 @@ def cell(
         check_ports: bool = ...,
         check_pins: bool = ...,
         check_instances: CheckInstances | None = ...,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
@@ -1161,7 +1228,7 @@ def cell(
     ]: ...
 
     @overload
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         /,
         *,
@@ -1171,9 +1238,10 @@ def cell(
         check_ports: bool = ...,
         check_pins: bool = ...,
         check_instances: CheckInstances | None = ...,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         snap_ports: bool = ...,
         add_port_layers: bool = ...,
-        cache: Cache[int, Any] | dict[int, Any] | None = ...,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = ...,
         basename: str | None = ...,
         drop_params: list[str] = ...,
         register_factory: bool = ...,
@@ -1189,7 +1257,7 @@ def cell(
         [Callable[KCellParams, ProtoTKCell[Any]]], Callable[KCellParams, KC]
     ]: ...
 
-    def cell(
+    def cell[**KCellParams, KC: ProtoTKCell[Any]](
         self,
         _func: Callable[KCellParams, ProtoTKCell[Any]] | None = None,
         /,
@@ -1200,16 +1268,17 @@ def cell(
         check_ports: bool = True,
         check_pins: bool = True,
         check_instances: CheckInstances | None = None,
+        check_unnamed_cells: CheckUnnamedCells | None = None,
         snap_ports: bool = True,
         add_port_layers: bool = True,
-        cache: Cache[int, Any] | dict[int, Any] | None = None,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = None,
         basename: str | None = None,
         drop_params: Sequence[str] = ("self", "cls"),
         register_factory: bool = True,
         overwrite_existing: bool | None = None,
         layout_cache: bool | None = None,
         info: dict[str, MetaData] | None = None,
-        post_process: Iterable[Callable[[KC_contra], None]] | None = None,
+        post_process: Iterable[Callable[[KC], None]] | None = None,
         debug_names: bool | None = None,
         tags: list[str] | None = None,
         lvs_equivalent_ports: list[list[str]] | None = None,
@@ -1241,6 +1310,9 @@ def cell(
                 Depending on the setting, an error is raised, the cell is flattened,
                 a VInstance is created instead of a regular instance, or they are
                 ignored.
+            check_unnamed_cells: Check for unnamed child cells (matching
+                ``Unnamed_\\d+``). ``"error"`` raises, ``"warning"`` logs a warning,
+                ``"ignore"`` skips the check.
             snap_ports: Snap the centers of the ports onto the grid
                 (only x/y, not angle).
             add_port_layers: Add special layers of `KCLayout.netlist_layer_mapping`
@@ -1272,6 +1344,8 @@ def cell(
         """
         if check_instances is None:
             check_instances = config.check_instances
+        if check_unnamed_cells is None:
+            check_unnamed_cells = config.check_unnamed_cells
         if overwrite_existing is None:
             overwrite_existing = config.cell_overwrite_existing
         if layout_cache is None:
@@ -1279,19 +1353,18 @@ def cell(
         if debug_names is None:
             debug_names = config.debug_names
         if post_process is None:
-            post_process = ()
+            post_process = []
 
         def decorator_autocell(
             f: Callable[KCellParams, KCIN],
         ) -> Callable[KCellParams, KC]:
             sig = inspect.signature(f)
-            output_cell_type_: type[KC | ProtoTKCell[Any]]
             if output_type is not None:
-                output_cell_type_ = output_type
+                output_cell_type_: type[KC | ProtoTKCell[Any]] = output_type
             elif sig.return_annotation is not inspect.Signature.empty:
                 # Use get_type_hints to resolve string annotations
                 try:
-                    type_hints = get_type_hints(f, globalns=f.__globals__)
+                    type_hints = get_type_hints(f, globalns=f.__globals__)  # ty:ignore[unresolved-attribute]
                     output_cell_type_ = type_hints.get("return", sig.return_annotation)
 
                 except Exception:
@@ -1307,10 +1380,12 @@ def decorator_autocell(
 
             output_cell_type__ = cast("type[KC]", output_cell_type_)
 
-            cache_: Cache[int, KC] | dict[int, KC] = cache or Cache(
+            cache_: Cache[Hashable, Any] | dict[Hashable, Any] = cache or Cache(
                 maxsize=float("inf")
             )
-            wrapper_autocell: WrappedKCellFunc[KCellParams, KC] = WrappedKCellFunc(
+            wrapper_autocell: WrappedKCellFunc[KCellParams, KC] = WrappedKCellFunc[
+                KCellParams, KC
+            ](
                 kcl=self,
                 f=f,
                 sig=sig,
@@ -1321,6 +1396,7 @@ def decorator_autocell(
                 check_ports=check_ports,
                 check_pins=check_pins,
                 check_instances=check_instances,
+                check_unnamed_cells=check_unnamed_cells,
                 snap_ports=snap_ports,
                 add_port_layers=add_port_layers,
                 basename=basename,
@@ -1328,7 +1404,7 @@ def decorator_autocell(
                 overwrite_existing=overwrite_existing,
                 layout_cache=layout_cache,
                 info=info,
-                post_process=post_process,  # type: ignore[arg-type]
+                post_process=post_process,  # ty:ignore[invalid-argument-type]
                 debug_names=debug_names,
                 tags=tags,
                 lvs_equivalent_ports=lvs_equivalent_ports,
@@ -1340,7 +1416,7 @@ def decorator_autocell(
                 with self.thread_lock:
                     if wrapper_autocell.name is None:
                         raise ValueError(f"Function {f} has no name.")
-                    self.factories.add(wrapper_autocell)  # type: ignore[arg-type]
+                    self.factories.add(wrapper_autocell)
 
             @functools.wraps(f)
             def func(*args: KCellParams.args, **kwargs: KCellParams.kwargs) -> KC:
@@ -1351,41 +1427,42 @@ def func(*args: KCellParams.args, **kwargs: KCellParams.kwargs) -> KC:
         return decorator_autocell if _func is None else decorator_autocell(_func)
 
     @overload
-    def vcell(
+    def vcell[**KCellParams, VK: VKCell](
         self,
         _func: Callable[KCellParams, VK],
         /,
     ) -> Callable[KCellParams, VK]: ...
 
     @overload
-    def vcell(
+    def vcell[**KCellParams, VK: VKCell](
         self,
         /,
         *,
         set_settings: bool = True,
         set_name: bool = True,
         add_port_layers: bool = True,
-        cache: Cache[int, Any] | dict[int, Any] | None = None,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = None,
         basename: str | None = None,
         drop_params: Sequence[str] = ("self", "cls"),
         register_factory: bool = True,
         info: dict[str, MetaData] | None = None,
         check_ports: bool = True,
         check_pins: bool = True,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         tags: list[str] | None = None,
         lvs_equivalent_ports: list[list[str]] | None = None,
         ports: PortsDefinition | None = None,
     ) -> Callable[[Callable[KCellParams, VK]], Callable[KCellParams, VK]]: ...
 
     @overload
-    def vcell(
+    def vcell[**KCellParams, VK: VKCell](
         self,
         /,
         *,
         set_settings: bool = True,
         set_name: bool = True,
         add_port_layers: bool = True,
-        cache: Cache[int, Any] | dict[int, Any] | None = None,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = None,
         basename: str | None = None,
         drop_params: Sequence[str] = ("self", "cls"),
         register_factory: bool = True,
@@ -1393,13 +1470,14 @@ def vcell(
         info: dict[str, MetaData] | None = None,
         check_ports: bool = True,
         check_pins: bool = True,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         tags: list[str] | None = None,
         lvs_equivalent_ports: list[list[str]] | None = None,
         ports: PortsDefinition | None = None,
     ) -> Callable[[Callable[KCellParams, VK]], Callable[KCellParams, VK]]: ...
 
     @overload
-    def vcell(
+    def vcell[**KCellParams, VK: VKCell](
         self,
         /,
         *,
@@ -1407,20 +1485,21 @@ def vcell(
         set_settings: bool = True,
         set_name: bool = True,
         add_port_layers: bool = True,
-        cache: Cache[int, Any] | dict[int, Any] | None = None,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = None,
         basename: str | None = None,
         drop_params: Sequence[str] = ("self", "cls"),
         register_factory: bool = True,
         info: dict[str, MetaData] | None = None,
         check_ports: bool = True,
         check_pins: bool = True,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         tags: list[str] | None = None,
         lvs_equivalent_ports: list[list[str]] | None = None,
         ports: PortsDefinition | None = None,
     ) -> Callable[[Callable[KCellParams, VKCell]], Callable[KCellParams, VK]]: ...
 
     @overload
-    def vcell(
+    def vcell[**KCellParams, VK: VKCell](
         self,
         /,
         *,
@@ -1428,7 +1507,7 @@ def vcell(
         set_settings: bool = True,
         set_name: bool = True,
         add_port_layers: bool = True,
-        cache: Cache[int, Any] | dict[int, Any] | None = None,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = None,
         basename: str | None = None,
         drop_params: Sequence[str] = ("self", "cls"),
         register_factory: bool = True,
@@ -1436,12 +1515,13 @@ def vcell(
         info: dict[str, MetaData] | None = None,
         check_ports: bool = True,
         check_pins: bool = True,
+        check_unnamed_cells: CheckUnnamedCells = ...,
         tags: list[str] | None = None,
         lvs_equivalent_ports: list[list[str]] | None = None,
         ports: PortsDefinition | None = None,
     ) -> Callable[[Callable[KCellParams, VKCell]], Callable[KCellParams, VK]]: ...
 
-    def vcell(
+    def vcell[**KCellParams, VK: VKCell](
         self,
         _func: Callable[KCellParams, VKCell] | None = None,
         /,
@@ -1450,7 +1530,7 @@ def vcell(
         set_settings: bool = True,
         set_name: bool = True,
         add_port_layers: bool = True,
-        cache: Cache[int, Any] | dict[int, Any] | None = None,
+        cache: Cache[Hashable, Any] | dict[Hashable, Any] | None = None,
         basename: str | None = None,
         drop_params: Sequence[str] = ("self", "cls"),
         register_factory: bool = True,
@@ -1458,6 +1538,7 @@ def vcell(
         info: dict[str, MetaData] | None = None,
         check_ports: bool = True,
         check_pins: bool = True,
+        check_unnamed_cells: CheckUnnamedCells = CheckUnnamedCells.WARNING,
         tags: list[str] | None = None,
         lvs_equivalent_ports: list[list[str]] | None = None,
         ports: PortsDefinition | None = None,
@@ -1478,6 +1559,9 @@ def vcell(
                 string created from the args/kwargs
             check_ports: Check uniqueness of port names.
             check_pins: Check uniqueness of pin names.
+            check_unnamed_cells: Check for unnamed child cells (matching
+                ``Unnamed_\\d+``). ``"error"`` raises, ``"warning"`` logs a warning,
+                ``"ignore"`` skips the check.
             add_port_layers: Add special layers of `KCLayout.netlist_layer_mapping`
                 to the ports if the port layer is in the mapping.
             cache: Provide a user defined cache instead of an internal one. This
@@ -1494,6 +1578,8 @@ def vcell(
             A wrapped vcell function which caches responses and modifies the VKCell
             according to settings.
         """
+        if check_unnamed_cells is None:
+            check_unnamed_cells = config.check_unnamed_cells
         if post_process is None:
             post_process = ()
 
@@ -1507,7 +1593,7 @@ def decorator_autocell(
             elif sig.return_annotation is not inspect.Signature.empty:
                 # Use get_type_hints to resolve string annotations
                 try:
-                    type_hints = get_type_hints(f, globalns=f.__globals__)
+                    type_hints = get_type_hints(f, globalns=f.__globals__)  # ty:ignore[unresolved-attribute]
                     output_cell_type_ = type_hints.get("return", sig.return_annotation)
 
                 except Exception:
@@ -1523,7 +1609,7 @@ def decorator_autocell(
 
             output_cell_type__ = cast("type[VK]", output_cell_type_)
             # previously was a KCellCache, but dict should do for most case
-            cache_: Cache[int, VK] | dict[int, VK] = cache or Cache(
+            cache_: Cache[Hashable, VK] | dict[Hashable, VK] = cache or Cache(
                 maxsize=float("inf")
             )
 
@@ -1542,6 +1628,7 @@ def decorator_autocell(
                 info=info,
                 check_ports=check_ports,
                 check_pins=check_pins,
+                check_unnamed_cells=check_unnamed_cells,
                 tags=tags,
                 lvs_equivalent_ports=lvs_equivalent_ports,
                 ports=ports,
@@ -1550,7 +1637,7 @@ def decorator_autocell(
             if register_factory:
                 if wrapper_autocell.name is None:
                     raise ValueError(f"Function {f} has no name.")
-                self.virtual_factories.add(wrapper_autocell)  # type: ignore[arg-type]
+                self.virtual_factories.add(wrapper_autocell)  # ty:ignore[invalid-argument-type]
 
             @functools.wraps(f)
             def func(*args: KCellParams.args, **kwargs: KCellParams.kwargs) -> VK:
@@ -1578,9 +1665,12 @@ def set_layers_from_infos(self, name: str, layers: LayerInfos) -> type[LayerEnum
 
     def __getattr__(self, name: str) -> Any:
         """If KCLayout doesn't have an attribute, look in the KLayout Cell."""
-        if name != "_name" and name not in self.__class__.model_fields:
+        if (
+            name not in self.__class__.model_fields
+            and name not in self.__class__.__private_attributes__
+        ):
             return self.layout.__getattribute__(name)
-        return None
+        return super().__getattr__(name)  # ty:ignore[unresolved-attribute]
 
     def __setattr__(self, name: str, value: Any) -> None:
         """Use a custom setter to automatically set attributes.
@@ -1588,8 +1678,19 @@ def __setattr__(self, name: str, value: Any) -> None:
         If the attribute is not in this object, set it on the
         Layout object.
         """
-        if name in self.__class__.model_fields:
+        if (
+            name in self.__class__.model_fields
+            or name in self.__class__.__private_attributes__
+        ):
             super().__setattr__(name, value)
+            if name == "infos":
+                # Drop the cached `layers` and rebuild it eagerly so the new
+                # LayerEnum members register their names with `self.layout`.
+                # The AttributeError only fires on the construction path where
+                # `layers` hasn't been materialized yet.
+                with contextlib.suppress(AttributeError):
+                    del self.layers
+                _ = self.layers  # make sure the layers are computed
         elif hasattr(self.layout, name):
             self.layout.__setattr__(name, value)
 
@@ -1610,9 +1711,11 @@ def clear(self, keep_layers: bool = True) -> None:
         self.tkcells = {}
 
         if keep_layers:
-            self.layers = self.layerenum_from_dict(layers=self.infos)
+            with contextlib.suppress(AttributeError):
+                del self.layers
+            _ = self.layers  # make sure the layers are computed
         else:
-            self.layers = self.layerenum_from_dict(layers=LayerInfos())
+            self.infos = LayerInfos()
 
     def dup(self, init_cells: bool = True) -> KCLayout:
         """Create a duplication of the `~KCLayout` object.
@@ -1757,10 +1860,10 @@ def __getitem__(self, obj: str | int) -> KCell:
         """
         return self.get_cell(obj)
 
-    def get_cell(
+    def get_cell[KC: ProtoTKCell[Any]](
         self,
         obj: str | int,
-        cell_type: type[KC] = KCell,  # type: ignore[assignment]
+        cell_type: type[KC] = KCell,  # ty:ignore[invalid-parameter-default]
         error_search_limit: int | None = 10,
     ) -> KC:
         """Retrieve a cell by name(str) or index(int).
@@ -1893,14 +1996,14 @@ def read(
                         yaml = ruamel.yaml.YAML(typ=["rt", "string"])
                         err_msg += (
                             "\nLayout Meta Diff:\n```\n"
-                            + yaml.dumps(dict(diff.layout_meta_diff))
+                            + yaml.dumps(dict(diff.layout_meta_diff))  # ty:ignore[unresolved-attribute]
                             + "\n```"
                         )
                     if diff.cells_meta_diff:
                         yaml = ruamel.yaml.YAML(typ=["rt", "string"])
                         err_msg += (
                             "\nLayout Meta Diff:\n```\n"
-                            + yaml.dumps(dict(diff.cells_meta_diff))
+                            + yaml.dumps(dict(diff.cells_meta_diff))  # ty:ignore[unresolved-attribute]
                             + "\n```"
                         )
 
@@ -1965,6 +2068,7 @@ def get_meta_data(self) -> tuple[dict[str, Any], dict[str, Any]]:
         settings: dict[str, Any] = {}
         info: dict[str, Any] = {}
         cross_sections: list[dict[str, Any]] = []
+        asym_cross_sections: list[dict[str, Any]] = []
         for meta in self.layout.each_meta_info():
             if meta.name.startswith("kfactory:info"):
                 info[meta.name.removeprefix("kfactory:info:")] = meta.value
@@ -1976,6 +2080,15 @@ def get_meta_data(self) -> tuple[dict[str, Any], dict[str, Any]]:
                         **meta.value,
                     )
                 )
+            elif meta.name.startswith("kfactory:asymmetrical_cross_section:"):
+                asym_cross_sections.append(
+                    {
+                        "name": meta.name.removeprefix(
+                            "kfactory:asymmetrical_cross_section:"
+                        ),
+                        **meta.value,
+                    }
+                )
             elif meta.name.startswith("kfactory:cross_section:"):
                 cross_sections.append(
                     {
@@ -1990,6 +2103,23 @@ def get_meta_data(self) -> tuple[dict[str, Any], dict[str, Any]]:
                     width=cs["width"],
                     enclosure=self.get_enclosure(cs["layer_enclosure"]),
                     name=cs["name"],
+                    radius=cs.get("radius"),
+                    radius_min=cs.get("radius_min"),
+                )
+            )
+        for acs in asym_cross_sections:
+            self.get_asymmetrical_cross_section(
+                AsymmetricalCrossSection(
+                    layer=acs["layer"],
+                    section_min=acs["section_min"],
+                    section_max=acs["section_max"],
+                    sections=tuple(
+                        CrossSectionLayer(**s) for s in acs.get("sections", ())
+                    ),
+                    name=acs["name"],
+                    radius=acs.get("radius"),
+                    radius_min=acs.get("radius_min"),
+                    bbox_sections=acs.get("bbox_sections", {}),
                 )
             )
 
@@ -1997,10 +2127,11 @@ def get_meta_data(self) -> tuple[dict[str, Any], dict[str, Any]]:
 
     def set_meta_data(self) -> None:
         """Set the info/settings of the KCLayout."""
-        for name, setting in self.settings.model_dump().items():
-            self.add_meta_info(
-                kdb.LayoutMetaInfo(f"kfactory:settings:{name}", setting, None, True)
-            )
+        if config.write_kfactory_settings:
+            for name, setting in self.settings.model_dump().items():
+                self.add_meta_info(
+                    kdb.LayoutMetaInfo(f"kfactory:settings:{name}", setting, None, True)
+                )
         for name, info in self.info.model_dump().items():
             self.add_meta_info(
                 kdb.LayoutMetaInfo(f"kfactory:info:{name}", info, None, True)
@@ -2014,18 +2145,49 @@ def set_meta_data(self) -> None:
                     True,
                 )
             )
-        for cross_section in self.cross_sections.cross_sections.values():
-            self.add_meta_info(
-                kdb.LayoutMetaInfo(
-                    f"kfactory:cross_section:{cross_section.name}",
-                    {
-                        "width": cross_section.width,
-                        "layer_enclosure": cross_section.enclosure.name,
-                    },
-                    None,
-                    True,
+        for xs in set(self.cross_sections.cross_sections.values()):
+            if isinstance(xs, AsymmetricalCrossSection):
+                self.add_meta_info(
+                    kdb.LayoutMetaInfo(
+                        f"kfactory:asymmetrical_cross_section:{xs.name}",
+                        {
+                            "layer": xs.layer,
+                            "section_min": xs.section_min,
+                            "section_max": xs.section_max,
+                            "sections": [
+                                {
+                                    "layer": s.layer,
+                                    "section_min": s.section_min,
+                                    "section_max": s.section_max,
+                                }
+                                for s in xs.sections
+                            ],
+                            "radius": xs.radius,
+                            "radius_min": xs.radius_min,
+                            "bbox_sections": xs.bbox_sections,
+                        },
+                        None,
+                        True,
+                    )
+                )
+            else:
+                self.add_meta_info(
+                    kdb.LayoutMetaInfo(
+                        f"kfactory:cross_section:{xs.name}",
+                        {
+                            "width": xs.width,
+                            "layer_enclosure": xs.enclosure.name,
+                            **({"radius": xs.radius} if xs.radius is not None else {}),
+                            **(
+                                {"radius_min": xs.radius_min}
+                                if xs.radius_min is not None
+                                else {}
+                            ),
+                        },
+                        None,
+                        True,
+                    )
                 )
-            )
 
     def write(
         self,
@@ -2072,11 +2234,57 @@ def write(
                     if kcell.is_library_cell() and not kcell.destroyed():
                         kcell.convert_to_static(recursive=True)
 
+        self._deduplicate_cell_names()
+
         if autoformat_from_file_extension:
             options.set_format_from_filename(filename)
 
         return self.layout.write(filename, options)
 
+    def _deduplicate_cell_names(self) -> None:
+        """Auto-rename cells with duplicate names so the layout can be written.
+
+        GDS/OASIS require unique cell names. The first cell keeps its name;
+        subsequent duplicates get ``$1``, ``$2``, … suffixes. A warning is
+        logged for each rename.
+        """
+        from collections import defaultdict
+
+        name_to_cells: dict[str, list[kdb.Cell]] = defaultdict(list)
+        for c in self.layout.each_cell():
+            if not c._destroyed():
+                name_to_cells[c.name].append(c)
+
+        duplicates = {
+            name: cells for name, cells in name_to_cells.items() if len(cells) > 1
+        }
+        if not duplicates:
+            return
+
+        for name, cells in duplicates.items():
+            for c in cells[1:]:
+                if c._destroyed():
+                    continue
+                unique = self.layout.unique_cell_name(name)
+                tkcell = self.tkcells.get(c.cell_index())
+                fn = tkcell.function_name if tkcell else None
+                was_locked = c.is_locked()
+                if was_locked:
+                    c.locked = False
+                c.name = unique
+                if was_locked:
+                    c.locked = True
+                logger.warning(
+                    "Renamed duplicate cell {old!r} (cell_index={ci},"
+                    " function_name={fn!r}) to {new!r} before writing."
+                    " Set `kf.config.debug_names = True` to catch name"
+                    " conflicts earlier.",
+                    old=name,
+                    ci=c.cell_index(),
+                    fn=fn,
+                    new=unique,
+                )
+
     def top_kcells(self) -> list[KCell]:
         """Return the top KCells."""
         return [self[tc.cell_index()] for tc in self.top_cells()]
@@ -2103,41 +2311,197 @@ def get_symmetrical_cross_section(
         self,
         cross_section: str
         | SymmetricalCrossSection
-        | CrossSectionSpec
-        | DCrossSectionSpec
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | DSymmetricalCrossSection,
     ) -> SymmetricalCrossSection:
         """Get a cross section by name or specification."""
         return self.cross_sections.get_cross_section(cross_section)
 
+    def get_asymmetrical_cross_section(
+        self,
+        cross_section: str
+        | AsymmetricalCrossSection
+        | DAsymmetricalCrossSection
+        | TAsymmetricCrossSection[Any],
+    ) -> AsymmetricalCrossSection:
+        """Get an asymmetrical cross section by name or instance."""
+        if isinstance(cross_section, TAsymmetricCrossSection):
+            cross_section = cross_section.base
+        return self.cross_sections.get_asymmetrical_cross_section(cross_section)
+
+    @overload
+    def get_base_cross_section(
+        self,
+        cross_section: str
+        | SymmetricalCrossSection
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
+        | DSymmetricalCrossSection
+        | TCrossSection[Any],
+        symmetrical: Literal[True],
+    ) -> SymmetricalCrossSection: ...
+
+    @overload
+    def get_base_cross_section(
+        self,
+        cross_section: str
+        | AsymmetricalCrossSection
+        | DAsymmetricalCrossSection
+        | TAsymmetricCrossSection[Any],
+        symmetrical: Literal[False],
+    ) -> AsymmetricalCrossSection: ...
+
+    @overload
+    def get_base_cross_section(
+        self,
+        cross_section: Any,
+        symmetrical: None = None,
+    ) -> SymmetricalCrossSection | AsymmetricalCrossSection: ...
+
+    def get_base_cross_section(
+        self,
+        cross_section: Any,
+        symmetrical: bool | None = None,
+    ) -> SymmetricalCrossSection | AsymmetricalCrossSection:
+        """Get a cross section by name or instance.
+
+        Args:
+            cross_section: name, spec, or instance.
+            symmetrical: kind filter. `None` (default) returns either kind,
+                dispatching by input type (string names are looked up directly).
+                `True` returns a symmetric cross section and raises if the resolved
+                one is asymmetric. `False` returns an asymmetric cross section and
+                raises if the resolved one is symmetric.
+        """
+        if symmetrical is True:
+            return self.get_symmetrical_cross_section(cross_section)
+        if symmetrical is False:
+            return self.get_asymmetrical_cross_section(cross_section)
+        if isinstance(cross_section, str):
+            if cross_section in self.cross_sections.cross_sections:
+                return self.cross_sections.cross_sections[cross_section]
+            raise KeyError(
+                f"No cross section named {cross_section!r} (symmetric or asymmetric)."
+            )
+        if isinstance(
+            cross_section,
+            (
+                AsymmetricalCrossSection,
+                DAsymmetricalCrossSection,
+                TAsymmetricCrossSection,
+            ),
+        ):
+            return self.get_asymmetrical_cross_section(cross_section)
+        # spec dicts (CrossSectionSpec / DCrossSectionSpec) are always symmetric
+        return self.get_symmetrical_cross_section(cross_section)
+
+    @overload
     def get_icross_section(
         self,
         cross_section: str
         | SymmetricalCrossSection
-        | CrossSectionSpec
-        | DCrossSectionSpec
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | DCrossSection
         | DSymmetricalCrossSection
         | CrossSection,
-    ) -> CrossSection:
-        """Get a cross section by name or specification."""
-        return CrossSection(
-            kcl=self, base=self.cross_sections.get_cross_section(cross_section)
-        )
+        symmetrical: Literal[True],
+    ) -> CrossSection: ...
+    @overload
+    def get_icross_section(
+        self,
+        cross_section: str
+        | AsymmetricalCrossSection
+        | DAsymmetricalCrossSection
+        | TAsymmetricCrossSection[Any],
+        symmetrical: Literal[False],
+    ) -> AsymmetricCrossSection: ...
+    @overload
+    def get_icross_section(
+        self, cross_section: Any, symmetrical: None = None
+    ) -> CrossSection | AsymmetricCrossSection: ...
+    def get_icross_section(
+        self, cross_section: Any, symmetrical: bool | None = None
+    ) -> CrossSection | AsymmetricCrossSection:
+        """Get a dbu cross section wrapper (symmetric or asymmetric, see kwarg)."""
+        if symmetrical is True:
+            return CrossSection(
+                kcl=self, base=self.get_symmetrical_cross_section(cross_section)
+            )
+        if symmetrical is False:
+            return AsymmetricCrossSection(
+                kcl=self, base=self.get_asymmetrical_cross_section(cross_section)
+            )
+        xs = self.get_base_cross_section(cross_section)
+        if isinstance(xs, AsymmetricalCrossSection):
+            return AsymmetricCrossSection(kcl=self, base=xs)
+        return CrossSection(kcl=self, base=xs)
 
+    @overload
     def get_dcross_section(
         self,
         cross_section: str
         | SymmetricalCrossSection
-        | CrossSectionSpec
-        | DCrossSectionSpec
+        | CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | DSymmetricalCrossSection
         | CrossSection
         | DCrossSection,
-    ) -> DCrossSection:
-        """Get a cross section by name or specification."""
-        return DCrossSection(
-            kcl=self, base=self.cross_sections.get_cross_section(cross_section)
+        symmetrical: Literal[True],
+    ) -> DCrossSection: ...
+    @overload
+    def get_dcross_section(
+        self,
+        cross_section: str
+        | AsymmetricalCrossSection
+        | DAsymmetricalCrossSection
+        | TAsymmetricCrossSection[Any],
+        symmetrical: Literal[False],
+    ) -> DAsymmetricCrossSection: ...
+    @overload
+    def get_dcross_section(
+        self, cross_section: Any, symmetrical: None = None
+    ) -> DCrossSection | DAsymmetricCrossSection: ...
+    def get_dcross_section(
+        self, cross_section: Any, symmetrical: bool | None = None
+    ) -> DCrossSection | DAsymmetricCrossSection:
+        """Get a um cross section wrapper (symmetric or asymmetric, see kwarg)."""
+        if symmetrical is True:
+            return DCrossSection(
+                kcl=self, base=self.get_symmetrical_cross_section(cross_section)
+            )
+        if symmetrical is False:
+            return DAsymmetricCrossSection(
+                kcl=self, base=self.get_asymmetrical_cross_section(cross_section)
+            )
+        xs = self.get_base_cross_section(cross_section)
+        if isinstance(xs, AsymmetricalCrossSection):
+            return DAsymmetricCrossSection(kcl=self, base=xs)
+        return DCrossSection(kcl=self, base=xs)
+
+    def get_iasymmetric_cross_section(
+        self,
+        cross_section: str
+        | AsymmetricalCrossSection
+        | DAsymmetricalCrossSection
+        | TAsymmetricCrossSection[Any],
+    ) -> AsymmetricCrossSection:
+        """Get a dbu-flavored asymmetric cross section wrapper."""
+        return AsymmetricCrossSection(
+            kcl=self, base=self.get_asymmetrical_cross_section(cross_section)
+        )
+
+    def get_dasymmetric_cross_section(
+        self,
+        cross_section: str
+        | AsymmetricalCrossSection
+        | DAsymmetricalCrossSection
+        | TAsymmetricCrossSection[Any],
+    ) -> DAsymmetricCrossSection:
+        """Get a um-flavored asymmetric cross section wrapper."""
+        return DAsymmetricCrossSection(
+            kcl=self, base=self.get_asymmetrical_cross_section(cross_section)
         )
 
     def __repr__(self) -> str:
@@ -2152,28 +2516,85 @@ def routing_strategy(
         f: Callable[
             Concatenate[
                 ProtoTKCell[Any],
-                Sequence[ProtoPort[Any]],
-                Sequence[ProtoPort[Any]],
-                P,
+                Sequence[Sequence[ProtoPort[Any]]],
+                ...,
             ],
             list[ManhattanRoute],
         ],
     ) -> Callable[
         Concatenate[
             ProtoTKCell[Any],
-            Sequence[ProtoPort[Any]],
-            Sequence[ProtoPort[Any]],
-            P,
+            Sequence[Sequence[ProtoPort[Any]]],
+            ...,
         ],
         list[ManhattanRoute],
     ]:
-        self.routing_strategies[f.__name__] = f
+        self.routing_strategies[get_function_name(f)] = f
         return f
 
+    @overload
+    def generic_factory[F: Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]](
+        self, f: F, *, name: str | None = None
+    ) -> F: ...
+    @overload
+    def generic_factory[F: Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]](
+        self, *, name: str | None = None
+    ) -> Callable[[F], F]: ...
+    def generic_factory[F: Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]](
+        self,
+        f: F | None = None,
+        *,
+        name: str | None = None,
+    ) -> F | Callable[[F], F]:
+        """Register an arbitrary cell-producing function as a generic factory.
+
+        Generic factories are stored in `KCLayout.generic_factories`, separate
+        from the `factories` / `virtual_factories` registries. They are expected
+        to delegate to one of the real (cached) factories, so they need no cache
+        of their own. On every call the returned cell's `kcl` is checked against
+        this layout.
+
+        Can be used bare (`@kcl.generic_factory`), with a custom name
+        (`@kcl.generic_factory(name="...")`), or as a direct call
+        (`kcl.generic_factory(func, name="...")`).
+
+        Args:
+            f: A callable returning a `(D)KCell` or `VKCell`.
+            name: Name to register under. Defaults to the function's name.
+
+        Returns:
+            The wrapped function (guardrail-checked) registered under `name`.
+        """
+
+        def register(func: F) -> F:
+            factory_name = name or get_function_name(func)
+
+            @functools.wraps(func)
+            def wrapper(*args: Any, **kwargs: Any) -> ProtoTKCell[Any] | VKCell:
+                c = func(*args, **kwargs)
+                if c.kcl is not self:
+                    raise ValueError(
+                        f"generic_factory {factory_name!r} returned a cell from"
+                        f" KCLayout {c.kcl.name!r}, expected {self.name!r}."
+                    )
+                return c
+
+            registered = cast(
+                "Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]", wrapper
+            )
+            self.generic_factories[factory_name] = registered
+            return cast("F", registered)
+
+        return register if f is None else register(f)
+
 
 ManhattanRoute.model_rebuild()
 KCLayout.model_rebuild()
 SymmetricalCrossSection.model_rebuild()
+AsymmetricalCrossSection.model_rebuild()
+DAsymmetricalCrossSection.model_rebuild()
+CrossSectionLayer.model_rebuild()
+DCrossSectionLayer.model_rebuild()
 CrossSectionModel.model_rebuild()
 TKCell.model_rebuild()
 TVCell.model_rebuild()
@@ -2193,7 +2614,7 @@ def routing_strategy(
 """Default kcl @vcell decorator."""
 
 
-class CellKWargs(TypedDict, total=False):
+class CellKWargs[KC: ProtoTKCell[Any]](TypedDict, total=False):
     set_settings: bool
     set_name: bool
     check_ports: bool
@@ -2201,14 +2622,14 @@ class CellKWargs(TypedDict, total=False):
     check_instances: CheckInstances
     snap_ports: bool
     add_port_layers: bool
-    cache: Cache[int, Any] | dict[int, Any]
+    cache: Cache[Hashable, Any] | dict[Hashable, Any]
     basename: str
     drop_params: list[str]
     register_factory: bool
     overwrite_existing: bool
     layout_cache: bool
     info: dict[str, MetaData]
-    post_process: Iterable[Callable[[KC_contra], None]]
+    post_process: Iterable[Callable[[KC], None]]
     debug_names: bool
     tags: list[str]
     lvs_equivalent_ports: list[list[str]]
diff --git a/src/kfactory/merge.py b/src/kfactory/merge.py
index 4f0f6adaa..902188198 100644
--- a/src/kfactory/merge.py
+++ b/src/kfactory/merge.py
@@ -48,14 +48,14 @@ def __post_init__(self) -> None:
         self.diff_a = kdb.Layout()
         self.diff_b = kdb.Layout()
         self.kdiff = kdb.LayoutDiff()
-        self.kdiff.on_begin_cell = self.on_begin_cell  # type: ignore[assignment]
-        self.kdiff.on_begin_layer = self.on_begin_layer  # type: ignore[assignment]
-        self.kdiff.on_end_layer = self.on_end_layer  # type: ignore[assignment]
-        self.kdiff.on_instance_in_a_only = self.on_instance_in_a_only  # type: ignore[assignment]
-        self.kdiff.on_instance_in_b_only = self.on_instance_in_b_only  # type: ignore[assignment]
-        self.kdiff.on_polygon_in_a_only = self.on_polygon_in_a_only  # type: ignore[assignment]
-        self.kdiff.on_polygon_in_b_only = self.on_polygon_in_b_only  # type: ignore[assignment]
-        self.kdiff.on_cell_meta_info_differs = self.on_cell_meta_info_differs  # type: ignore[assignment]
+        self.kdiff.on_begin_cell = self.on_begin_cell  # ty:ignore[invalid-assignment]
+        self.kdiff.on_begin_layer = self.on_begin_layer  # ty:ignore[invalid-assignment]
+        self.kdiff.on_end_layer = self.on_end_layer  # ty:ignore[invalid-assignment]
+        self.kdiff.on_instance_in_a_only = self.on_instance_in_a_only  # ty:ignore[invalid-assignment]
+        self.kdiff.on_instance_in_b_only = self.on_instance_in_b_only  # ty:ignore[invalid-assignment]
+        self.kdiff.on_polygon_in_a_only = self.on_polygon_in_a_only  # ty:ignore[invalid-assignment]
+        self.kdiff.on_polygon_in_b_only = self.on_polygon_in_b_only  # ty:ignore[invalid-assignment]
+        self.kdiff.on_cell_meta_info_differs = self.on_cell_meta_info_differs  # ty:ignore[invalid-assignment]
 
     def on_dbu_differs(self, dbu_a: float, dbu_b: float) -> None:
         """Called when the DBU differs between the two layouts."""
diff --git a/src/kfactory/netlist.py b/src/kfactory/netlist.py
deleted file mode 100644
index 136ab347f..000000000
--- a/src/kfactory/netlist.py
+++ /dev/null
@@ -1,425 +0,0 @@
-"""This is still experimental.
-
-Caution is advised when using this, as the API might suddenly change.
-In order to fix bugs etc.
-"""
-
-from __future__ import annotations
-
-import re
-from collections import defaultdict
-from typing import TYPE_CHECKING, Any, Self
-
-from pydantic import BaseModel, Field, RootModel, model_validator
-
-from .typings import JSONSerializable  # noqa: TC001
-
-if TYPE_CHECKING:
-    from collections.abc import Mapping
-
-    from .cross_section import CrossSection, DCrossSection
-    from .kcell import KCell, ProtoTKCell
-    from .port import BasePort
-    from .schematic import TSchematic
-
-__all__ = ["Netlist", "NetlistInstance", "NetlistPort", "PortArrayRef", "PortRef"]
-
-
-class PortRef(BaseModel, extra="forbid"):
-    """Reference to a port in a Netlist or Schema Instance."""
-
-    instance: str
-    port: str
-
-    @property
-    def name(self) -> str:
-        return self.port
-
-    @model_validator(mode="before")
-    @classmethod
-    def _validate_portref(cls, data: dict[str, Any]) -> dict[str, Any]:
-        if isinstance(data, str):
-            data = tuple(data.rsplit(",", 1))
-        if isinstance(data, tuple):
-            return {"instance": data[0], "port": data[1]}
-        return data
-
-    def __lt__(self, other: PortRef | PortArrayRef | NetlistPort) -> bool:
-        if isinstance(other, NetlistPort):
-            return False
-        if isinstance(other, PortArrayRef):
-            return True
-        return (self.instance, self.port) < (other.instance, other.port)
-
-    def __hash__(self) -> int:
-        return hash((self.instance, self.port))
-
-    def __eq__(self, other: object) -> bool:
-        return (
-            isinstance(other, PortRef)
-            and len(self.__class__.model_fields) == len(other.__class__.model_fields)
-            and self.instance == other.instance
-            and self.port == other.port
-        )
-
-    def __str__(self) -> str:
-        return self.as_python_str()
-
-    def as_python_str(self, inst_name: str | None = None) -> str:
-        return f"{inst_name or self.instance}[{self.port!r}]"
-
-    def is_placeable(self, placed_instances: set[str]) -> bool:
-        return self.instance in placed_instances
-
-    def place(
-        self,
-        cell: KCell,
-        schematic: TSchematic[Any],
-        name: str,
-        cross_sections: Mapping[str, CrossSection | DCrossSection],
-    ) -> BasePort:
-        if schematic.instances[self.instance].virtual:
-            return cell.add_port(
-                port=cell.vinsts[self.instance].ports[self.port], name=name
-            ).base
-        return cell.add_port(
-            port=cell.insts[self.instance].ports[self.port], name=name
-        ).base
-
-
-class PortArrayRef(PortRef, extra="forbid"):
-    """Reference to a port which is in an array instance."""
-
-    ia: int
-    ib: int
-
-    @model_validator(mode="before")
-    @classmethod
-    def _validate_array_portref(cls, data: dict[str, Any]) -> dict[str, Any]:
-        if isinstance(data, str):
-            data = tuple(data.rsplit(",", 1))
-        if isinstance(data, tuple):
-            match = re.match(r"(.*?)<(\d+)\.(\d+)>$", data[0])
-            if match:
-                return {
-                    "instance": match.group(1),
-                    "ia": int(match.group(2)),
-                    "ib": int(match.group(3)),
-                    "port": data[1],
-                }
-        return data
-
-    def __lt__(self, other: PortRef | NetlistPort | PortArrayRef) -> bool:
-        if isinstance(other, NetlistPort | PortRef):
-            return False
-        return (self.instance, self.port, self.ia, self.ib) < (
-            other.instance,
-            other.port,
-            other.ia,
-            other.ib,
-        )
-
-    def __hash__(self) -> int:
-        return hash((self.instance, self.port, self.ia, self.ib))
-
-    def __eq__(self, other: object) -> bool:
-        return (
-            isinstance(other, PortArrayRef)
-            and len(self.model_fields) == len(other.model_fields)
-            and self.instance == other.instance
-            and self.port == other.port
-            and self.ia == other.ia
-            and self.ib == other.ib
-        )
-
-    def __str__(self) -> str:
-        return self.as_python_str()
-
-    def as_python_str(self, inst_name: str | None = None) -> str:
-        return f"{inst_name or self.instance}[{self.port!r}, {self.ia}, {self.ib}]"
-
-    def place(
-        self,
-        cell: ProtoTKCell[Any],
-        schematic: TSchematic[Any],
-        name: str,
-        cross_sections: Mapping[str, CrossSection | DCrossSection],
-    ) -> BasePort:
-        if schematic.instances[self.instance].virtual:
-            return cell.add_port(
-                port=cell.vinsts[self.instance].ports[self.port, self.ia, self.ib],
-                name=name,
-            ).base
-        return cell.add_port(
-            port=cell.insts[self.instance].ports[self.port, self.ia, self.ib], name=name
-        ).base
-
-
-class NetlistPort(BaseModel):
-    """Cell level port in a netlsit."""
-
-    name: str
-
-    def __lt__(self, other: NetlistPort | PortRef) -> bool:
-        if isinstance(other, NetlistPort):
-            return self.name < other.name
-        return True
-
-    def __hash__(self) -> int:
-        return hash(self.name)
-
-
-class Net(RootModel[list[PortArrayRef | PortRef | NetlistPort]]):
-    """Net for a Netlist.
-
-    A net is a sequence of port references or netlist ports.
-    """
-
-    root: list[PortArrayRef | PortRef | NetlistPort]
-
-    def sort(self) -> Self:
-        def _port_sort(port: PortRef | NetlistPort) -> tuple[Any, ...]:
-            if isinstance(port, PortRef):
-                return (port.instance, port.port)
-            return (port.name,)
-
-        self.root.sort(key=_port_sort)
-        return self
-
-    def __lt__(self, other: Net) -> bool:
-        if len(self.root) == 0:
-            return False
-        if len(other.root) == 0:
-            return True
-        s0 = self.root[0]
-        o0 = other.root[0]
-        return s0 < o0
-
-    @model_validator(mode="after")
-    def _sort_data(self) -> Self:
-        self.root.sort()
-        return self
-
-    def __hash__(self) -> int:
-        return hash(tuple(self.root))
-
-
-class NetlistArray(BaseModel):
-    na: int
-    nb: int
-
-
-class NetlistInstance(BaseModel):
-    """Instance reference.
-
-    Attributes:
-        kcl: The original KCLayout (PDK) the instance was instantiated from.
-        component: The `@cell` decorated function the component was instantiated from.
-        settings: Settings used to call the component.
-        array: Whether the instance was a AREF.
-    """
-
-    kcl: str
-    component: str
-    settings: dict[str, JSONSerializable] = Field(default={})
-    array: NetlistArray | None = Field(default=None)
-    name: str = Field(exclude=True)
-
-
-class Netlist(BaseModel, extra="forbid"):
-    """This is still experimental.
-
-    Caution is advised when using this, as the API might suddenly change.
-    In order to fix bugs etc.
-
-
-    Attributes:
-        instances: Dictionary with a mapping between instances and their settings.
-        nets: Nets of the netlist. This is an abstraction and can in the Schema either
-            be a route or a connection.
-        ports: Ports/Pins of the netlist. Upstream exposed ports/pins. These can either
-            be references to a subcircuit (instance) port or a new one.
-    """
-
-    instances: dict[str, NetlistInstance] = Field(default_factory=dict)
-    nets: list[Net] = Field(default_factory=list)
-    ports: list[NetlistPort] = Field(default_factory=list)
-
-    def sort(self) -> Self:
-        if self.instances:
-            self.instances = dict(sorted(self.instances.items()))
-        for net in self.nets:
-            net.sort()
-        self.nets.sort()
-        if self.ports:
-            self.ports.sort()
-        return self
-
-    @model_validator(mode="before")
-    @classmethod
-    def _validate_model(cls, data: dict[str, Any]) -> dict[str, Any]:
-        if not isinstance(data, dict):
-            return data
-        instances = data.get("instances")
-        if instances:
-            for name, instance in instances.items():
-                if isinstance(instance, dict):
-                    instance["name"] = name
-        return data
-
-    def create_port(self, name: str) -> NetlistPort:
-        p = NetlistPort(name=name)
-        self.ports.append(p)
-        return p
-
-    def create_inst(
-        self, name: str, kcl: str, component: str, settings: dict[str, JSONSerializable]
-    ) -> None:
-        self.instances[name] = NetlistInstance(
-            kcl=kcl, component=component, settings=settings, name=name
-        )
-
-    def create_net(self, *ports: PortRef | NetlistPort) -> None:
-        net_ports: list[PortRef | NetlistPort] = []
-        for port in ports:
-            if isinstance(port, PortRef):
-                if port.instance not in self.instances:
-                    raise ValueError("Unknown instance ", port.instance)
-                inst = self.instances[port.instance]
-                if isinstance(port, PortArrayRef):
-                    if port.ia == 1 and port.ib == 1:
-                        net_ports.append(
-                            PortRef(instance=port.instance, port=port.port)
-                        )
-                        continue
-                    if not inst.array:
-                        raise ValueError(
-                            f"Instance {port.instance} is not an array instance. "
-                            f"But an array portref was requested {port=}"
-                        )
-                    if port.ia > inst.array.na:
-                        raise ValueError(
-                            f"Instance {port.instance} has only {inst.array.na}"
-                            " elements in `na` direction"
-                        )
-                    if port.ib > inst.array.nb:
-                        raise ValueError(
-                            f"Instance {port.instance} has only {inst.array.nb}"
-                            " elements in `na` direction"
-                        )
-                net_ports.append(port)
-            else:
-                net_ports.append(NetlistPort(name=port.name))
-
-        self.nets.append(Net(net_ports))
-
-    def lvs_equivalent(
-        self,
-        cell_name: str,
-        equivalent_ports: dict[str, list[list[str]]],
-        port_mapping: dict[str, dict[str | None, str]] | None = None,
-    ) -> Netlist:
-        """Get an equivalent netlist.
-
-        This is is useful for when there are components such as pads which have
-        more than one port which electrically are equivalent (same metal plane).
-
-        Args:
-            cell_name: Name of the netlist. This is usually `c.name` or similar.
-                Used to retrieve equivalent ports for self.
-            equivalent_ports: Dict containing cellname mapping vs lists of equivalent
-                port names.
-            port_mapping: Passed as a dict of
-                `{c_name: {port_name: equivalent_name, ...}, ...}`.
-                If not given is constructed in function.
-
-        Returns:
-            New netlist with equivalent ports mapped to their equivalent (usually first
-            port name in the list of equivalents).
-        """
-        if port_mapping is None:
-            port_mapping = defaultdict(dict)
-            for cell_name_, list_of_port_lists in equivalent_ports.items():
-                for port_list in list_of_port_lists:
-                    if port_list:
-                        p1 = port_list[0]
-                        for port in port_list:
-                            port_mapping[cell_name_][port] = p1
-        ports_per_inst: dict[str, list[PortRef]] = defaultdict(list)
-        net_for_port: dict[PortRef, Net] = {}
-
-        matched_insts: list[str] = [
-            inst.name
-            for inst in self.instances.values()
-            if inst.component in equivalent_ports
-        ]
-        changed_nets_dict: dict[PortRef, set[Net]] = defaultdict(set)
-        all_changed_nets: set[Net] = set()
-        nl = self.model_copy(deep=True)
-        for net in nl.nets:
-            for netport in net.root:
-                if isinstance(netport, PortRef):
-                    ports_per_inst[netport.instance].append(netport)
-                    net_for_port[netport] = net
-                    if netport.instance in matched_insts:
-                        port_name = port_mapping[
-                            nl.instances[netport.instance].component
-                        ].get(netport.port)
-                        if port_name is not None:
-                            netport.port = port_name
-                            changed_nets_dict[netport].add(net)
-                            all_changed_nets.add(net)
-
-        targets = {net: NetMergeTarget() for net in all_changed_nets}
-
-        for changed_nets in changed_nets_dict.values():
-            if len(changed_nets) > 1:
-                changed_nets_ = list(changed_nets)
-                t = targets[changed_nets_[0]].find_target()
-                for net in changed_nets_:
-                    target = targets[net].find_target()
-                    target.set_target(t)
-
-        nets_per_target: dict[NetMergeTarget, set[Net]] = defaultdict(set)
-        for net in all_changed_nets:
-            nets_per_target[targets[net].find_target()].add(net)
-
-        del_nets: set[Net] = set()
-        new_nets: list[Net] = []
-        ports = {port.name: port for port in nl.ports}
-        for nets in nets_per_target.values():
-            new_net = Net(root=[])
-            refs: set[PortRef | NetlistPort] = set()
-            for net in nets:
-                for portorref in net.root:
-                    if isinstance(portorref, PortRef):
-                        refs.add(portorref)
-                    else:
-                        refs.add(ports[port_mapping[cell_name][portorref.name]])
-                del_nets.add(net)
-            new_net.root.extend(list(refs))
-            new_nets.append(new_net)
-
-        nl.ports = list(set(ports.values()))
-        nl.nets = list(set(nl.nets) - del_nets) + new_nets
-
-        nl.sort()
-
-        return nl
-
-
-class NetMergeTarget:
-    target: NetMergeTarget | None
-
-    def __init__(self) -> None:
-        self.target = None
-
-    def set_target(self, target: NetMergeTarget) -> None:
-        if target is not self:
-            self.target = target
-
-    def find_target(self) -> NetMergeTarget:
-        if self.target is None:
-            return self
-
-        return self.target.find_target()
diff --git a/src/kfactory/packing.py b/src/kfactory/packing.py
index 9450b6d9b..fa793602a 100644
--- a/src/kfactory/packing.py
+++ b/src/kfactory/packing.py
@@ -2,7 +2,7 @@
 
 from collections.abc import Sequence
 
-import rpack  # type: ignore[import-untyped,unused-ignore]
+import rpack
 
 from . import kdb
 from .instance import Instance
diff --git a/src/kfactory/pin.py b/src/kfactory/pin.py
index 9a5c64354..43194afce 100644
--- a/src/kfactory/pin.py
+++ b/src/kfactory/pin.py
@@ -2,7 +2,7 @@
 
 import re
 from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Any, Generic
+from typing import TYPE_CHECKING, Any
 
 from pydantic import BaseModel
 from typing_extensions import TypedDict
@@ -10,18 +10,18 @@
 from . import kdb
 from .port import BasePort, DPort, Port, ProtoPort
 from .settings import Info
-from .typings import TPin, TPort_co, TUnit
 
 if TYPE_CHECKING:
     from collections.abc import Iterable
 
     from .layout import KCLayout
+    from .typings import TPin
 
 __all__ = ["DPin", "Pin", "ProtoPin"]
 
 
 class BasePinDict(TypedDict):
-    name: str | None
+    name: str
     kcl: KCLayout
     ports: list[BasePort]
     info: Info
@@ -29,7 +29,7 @@ class BasePinDict(TypedDict):
 
 
 class BasePin(BaseModel, arbitrary_types_allowed=True):
-    name: str | None
+    name: str
     kcl: KCLayout
     ports: list[BasePort]
     info: Info = Info()
@@ -51,7 +51,7 @@ def transformed(
         )
 
 
-class ProtoPin(ABC, Generic[TUnit]):
+class ProtoPin[T: (int, float)](ABC):
     """Base class for kf.Pin, kf.DPin."""
 
     yaml_tag: str = "!Pin"
@@ -71,7 +71,7 @@ def name(self) -> str | None:
         return self._base.name
 
     @name.setter
-    def name(self, value: str | None) -> None:
+    def name(self, value: str) -> None:
         self._base.name = value
 
     @property
@@ -106,7 +106,8 @@ def info(self, value: Info) -> None:
     def ports(self) -> list[Any]: ...  # because mypy... should be list[ProtoPort[Any]]
 
     @ports.setter
-    def ports(self, value: Iterable[TPort_co]) -> None: ...
+    @abstractmethod
+    def ports(self, value: Iterable[ProtoPort[T]]) -> None: ...
 
     def to_itype(self) -> Pin:
         """Convert the pin to a dbu pin."""
@@ -124,7 +125,7 @@ def __repr__(self) -> str:
         )
 
     @abstractmethod
-    def __getitem__(self, key: int | str | None) -> ProtoPort[TUnit]:
+    def __getitem__(self, key: int | str | None) -> ProtoPort[T]:
         """Get a port in the pin by index or name."""
         ...
 
@@ -133,7 +134,7 @@ def copy(
         self,
         trans: kdb.Trans | kdb.DCplxTrans = kdb.Trans.R0,
         post_trans: kdb.Trans | kdb.DCplxTrans = kdb.Trans.R0,
-    ) -> ProtoPin[TUnit]:
+    ) -> ProtoPin[T]:
         """Copy the port with a transformation."""
         ...
 
diff --git a/src/kfactory/pins.py b/src/kfactory/pins.py
index 1975e3116..38e5eac07 100644
--- a/src/kfactory/pins.py
+++ b/src/kfactory/pins.py
@@ -6,7 +6,6 @@
 from .conf import config
 from .pin import BasePin, DPin, Pin, ProtoPin, filter_type_reg
 from .settings import Info
-from .typings import TUnit
 from .utilities import pprint_pins
 
 if TYPE_CHECKING:
@@ -14,11 +13,12 @@
 
     from .layout import KCLayout
     from .port import ProtoPort
+    from .typings import MetaData
 
 __all__ = ["DPins", "Pins", "ProtoPins"]
 
 
-class ProtoPins(Protocol[TUnit]):
+class ProtoPins[T: (int, float)](Protocol):
     _kcl: KCLayout
     _bases: list[BasePin]
 
@@ -58,12 +58,12 @@ def to_dtype(self) -> DPins:
         return DPins(kcl=self.kcl, bases=self._bases)
 
     @abstractmethod
-    def __iter__(self) -> Iterator[ProtoPin[TUnit]]:
+    def __iter__(self) -> Iterator[ProtoPin[T]]:
         """Iterator over the Pins."""
         ...
 
     @abstractmethod
-    def __getitem__(self, key: int | str | None) -> ProtoPin[TUnit]:
+    def __getitem__(self, key: int | str) -> ProtoPin[T]:
         """Get a pin by index or name."""
         ...
 
@@ -79,16 +79,16 @@ def __contains__(self, pin: str | ProtoPin[Any] | BasePin) -> bool:
     def create_pin(
         self,
         *,
+        name: str,
         ports: Iterable[ProtoPort[Any]],
-        name: str | None = None,
         pin_type: str = "DC",
-        info: dict[str, int | float | str] | None = None,
-    ) -> ProtoPin[TUnit]:
+        info: dict[str, MetaData] | None = None,
+    ) -> ProtoPin[T]:
         """Add a pin."""
         ...
 
     @abstractmethod
-    def get_all_named(self) -> Mapping[str, ProtoPin[TUnit]]:
+    def get_all_named(self) -> Mapping[str, ProtoPin[T]]:
         """Get all pins in a dictionary with names as keys.
 
         This filters out Pins with `None` as name.
@@ -99,7 +99,7 @@ def filter(
         self,
         pin_type: str | None = None,
         regex: str | None = None,
-    ) -> Sequence[ProtoPin[TUnit]]:
+    ) -> Sequence[ProtoPin[T]]:
         """Filter pins by name.
 
         Args:
@@ -108,7 +108,7 @@ def filter(
         Returns:
             Filtered list of pins.
         """
-        pins: Iterable[ProtoPin[TUnit]] = list(self)
+        pins: Iterable[ProtoPin[T]] = list(self)
         return list(filter_type_reg(pins, pin_type=pin_type, regex=regex))
 
 
@@ -134,10 +134,10 @@ def __getitem__(self, key: int | str | None) -> Pin:
     def create_pin(
         self,
         *,
+        name: str,
         ports: Iterable[ProtoPort[Any]],
-        name: str | None = None,
         pin_type: str = "DC",
-        info: dict[str, int | float | str] | None = None,
+        info: dict[str, MetaData] | None = None,
     ) -> Pin:
         """Add a pin to Pins."""
         if info is None:
@@ -180,7 +180,7 @@ def __iter__(self) -> Iterator[DPin]:
         """Iterator, that allows for loops etc to directly access the object."""
         yield from (DPin(base=b) for b in self._bases)
 
-    def __getitem__(self, key: int | str | None) -> DPin:
+    def __getitem__(self, key: int | str) -> DPin:
         """Get a specific pin by name."""
         if isinstance(key, int):
             return DPin(base=self._bases[key])
@@ -195,10 +195,10 @@ def __getitem__(self, key: int | str | None) -> DPin:
     def create_pin(
         self,
         *,
+        name: str,
         ports: Iterable[ProtoPort[Any]],
-        name: str | None = None,
         pin_type: str = "DC",
-        info: dict[str, int | float | str] | None = None,
+        info: dict[str, MetaData] | None = None,
     ) -> DPin:
         """Add a pin to Pins."""
         if info is None:
@@ -210,10 +210,12 @@ def create_pin(
             )
         port_bases = []
         for port in ports:
-            port_base = port.base
             if port.kcl != self.kcl:
-                port_base.kcl = self.kcl
-            port_bases.append(port_base)
+                raise ValueError(
+                    "Cannot add a pin which belongs to a different layout or cell to a"
+                    f" cell. {port=}, {self.kcl!r}"
+                )
+            port_bases.append(port.base)
 
         base_ = BasePin(
             name=name, kcl=self.kcl, ports=port_bases, pin_type=pin_type, info=info_
diff --git a/src/kfactory/placer.py b/src/kfactory/placer.py
index 20ef47c5f..711b18f25 100644
--- a/src/kfactory/placer.py
+++ b/src/kfactory/placer.py
@@ -5,7 +5,7 @@
 from collections.abc import Sequence
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Any, Self, TypeVar
+from typing import Any, Self
 
 from .port import Port
 from .ports import Ports
@@ -14,18 +14,18 @@
 from ruamel.yaml.constructor import SafeConstructor
 
 from .enclosure import LayerEnclosure
-from .kcell import KCell, AnyTKCell
+from .kcell import KCell
 from .layout import KCLayout
 from .layout import kcl as stdkcl
 
 __all__ = ["cells_from_yaml", "cells_to_yaml"]
 
-PathLike = TypeVar("PathLike", str, Path, None)
+type PathLike = str | Path | None
 
 
 def cells_to_yaml(
     output: PathLike,
-    cells: Sequence[AnyTKCell] | AnyTKCell | Sequence[TKCell] | TKCell,
+    cells: Sequence[ProtoTKCell[Any]] | ProtoTKCell[Any] | Sequence[TKCell] | TKCell,
 ) -> None:
     """Convert cell(s) to a yaml representations.
 
@@ -121,7 +121,7 @@ def exploded_yaml(
 ) -> Any:
     """Expanded yaml.
 
-    Expand cross-references. Same syntax as :py:func:~`cells_from_yaml`
+    Expand cross-references. Same syntax as `cells_from_yaml`
     """
     yaml = YAML(pure=True)
 
diff --git a/src/kfactory/port.py b/src/kfactory/port.py
index 2b12af7ac..b3bbe8128 100644
--- a/src/kfactory/port.py
+++ b/src/kfactory/port.py
@@ -8,10 +8,8 @@
 import re
 from abc import ABC, abstractmethod
 from enum import IntEnum, IntFlag, auto
-from typing import TYPE_CHECKING, Any, Generic, Literal, Self, overload
+from typing import TYPE_CHECKING, Any, Literal, Self, overload
 
-import klayout.db as kdb
-from klayout import rdb
 from pydantic import (
     BaseModel,
     model_serializer,
@@ -19,16 +17,21 @@
 )
 from typing_extensions import TypedDict
 
+from . import kdb, rdb
 from .conf import ANGLE_180, config
 from .cross_section import (
+    AnyCrossSectionInput,
+    AsymmetricalCrossSection,
+    AsymmetricCrossSection,
     CrossSection,
-    CrossSectionSpec,
+    CrossSectionSpecDict,
+    DAsymmetricCrossSection,
     DCrossSection,
     SymmetricalCrossSection,
+    TAsymmetricCrossSection,
     TCrossSection,
 )
 from .settings import Info
-from .typings import Angle, TPort, TUnit
 from .utilities import pprint_ports
 
 if TYPE_CHECKING:
@@ -37,6 +40,7 @@
     from .kcell import AnyTKCell, KCell
     from .layer import LayerEnum
     from .layout import KCLayout
+    from .typings import Angle, TPort
 
 
 def create_port_error(
@@ -70,12 +74,8 @@ def create_port_error(
         label1 = f"{inst_name1}.{p1.name}" if inst_name1 else f"{c1.name}.{p1.name}"
         label2 = f"{inst_name2}.{p2.name}" if inst_name2 else f"{c2.name}.{p2.name}"
         it.add_value(f"Port Names: {label1}/{label2}")
-    it.add_value(
-        port_polygon(p1.cross_section.width).transformed(p1.trans).to_dtype(dbu)
-    )
-    it.add_value(
-        port_polygon(p2.cross_section.width).transformed(p2.trans).to_dtype(dbu)
-    )
+    it.add_value(port_polygon(p1.iwidth).transformed(p1.trans).to_dtype(dbu))
+    it.add_value(port_polygon(p2.iwidth).transformed(p2.trans).to_dtype(dbu))
 
 
 class PortCheck(IntFlag):
@@ -85,40 +85,49 @@ class PortCheck(IntFlag):
     """
 
     opposite = auto()
+    same = auto()
     width = auto()
     layer = auto()
+    cross_section = auto()
     port_type = auto()
-    all_opposite = opposite + width + port_type + layer  # type: ignore[operator]
-    all_overlap = width + port_type + layer  # type: ignore[operator]
+    position = auto()
+    all_opposite = opposite + width + port_type + layer  # ty:ignore[unsupported-operator]
+    all_overlap = width + port_type + layer  # ty:ignore[unsupported-operator]
 
 
 def port_check(
-    p1: Port, p2: Port, checks: PortCheck | int = PortCheck.all_opposite
+    p1: ProtoPort[Any],
+    p2: ProtoPort[Any],
+    checks: PortCheck | int = PortCheck.all_opposite,
 ) -> None:
     """Check if two ports are equal."""
-    if checks & PortCheck.opposite:
-        assert (
-            p1.trans == p2.trans * kdb.Trans.R180
-            or p1.trans == p2.trans * kdb.Trans.M90
-        ), f"Transformations of ports not matching for opposite check{p1=} {p2=}"
-    if (checks & PortCheck.opposite) == 0:
-        assert p1.trans == p2.trans or p1.trans == p2.trans * kdb.Trans.M0, (
+    if checks & PortCheck.opposite and not (
+        p1.trans == p2.trans * kdb.Trans.R180 or p1.trans == p2.trans * kdb.Trans.M90
+    ):
+        raise ValueError(
+            f"Transformations of ports not matching for opposite check{p1=} {p2=}"
+        )
+    if (checks & PortCheck.opposite) == 0 and not (
+        p1.trans == p2.trans or p1.trans == p2.trans * kdb.Trans.M0
+    ):
+        raise ValueError(
             f"Transformations of ports not matching for overlapping check {p1=} {p2=}"
         )
-    if checks & PortCheck.width:
-        assert p1.width == p2.width, f"Width mismatch for {p1=} {p2=}"
-    if checks & PortCheck.layer:
-        assert p1.layer == p2.layer, f"Layer mismatch for {p1=} {p2=}"
-    if checks & PortCheck.port_type:
-        assert p1.port_type == p2.port_type, f"Port type mismatch for {p1=} {p2=}"
+    if checks & PortCheck.width and not (p1.iwidth == p2.iwidth):
+        raise ValueError(f"Width mismatch for {p1=} {p2=}")
+    if checks & PortCheck.layer and not p1.layer_info.is_equivalent(p2.layer_info):
+        raise ValueError(f"Layer mismatch for {p1=} {p2=}")
+    if checks & PortCheck.port_type and not p1.port_type == p2.port_type:
+        raise ValueError(f"Port type mismatch for {p1=} {p2=}")
 
 
 class BasePortDict(TypedDict):
     """TypedDict for the BasePort."""
 
-    name: str | None
+    name: str
     kcl: KCLayout
-    cross_section: SymmetricalCrossSection
+    cross_section: SymmetricalCrossSection | None
+    asymmetric_cross_section: AsymmetricalCrossSection | None
     trans: kdb.Trans | None
     dcplx_trans: kdb.DCplxTrans | None
     info: Info
@@ -128,12 +137,15 @@ class BasePortDict(TypedDict):
 class BasePort(BaseModel, arbitrary_types_allowed=True):
     """Class representing the base port.
 
-    This does not have any knowledge of units.
+    This does not have any knowledge of units. Exactly one of
+    `cross_section` (symmetric) or `asymmetric_cross_section` (asymmetric)
+    must be set, mirroring the `trans` / `dcplx_trans` pattern.
     """
 
-    name: str | None
+    name: str
     kcl: KCLayout
-    cross_section: SymmetricalCrossSection
+    cross_section: SymmetricalCrossSection | None = None
+    asymmetric_cross_section: AsymmetricalCrossSection | None = None
     trans: kdb.Trans | None = None
     dcplx_trans: kdb.DCplxTrans | None = None
     info: Info = Info()
@@ -141,19 +153,40 @@ class BasePort(BaseModel, arbitrary_types_allowed=True):
 
     @model_validator(mode="after")
     def check_exclusivity(self) -> Self:
-        """Check if the port has a valid transformation."""
+        """Check that exactly one trans and exactly one cross_section is set."""
         if self.trans is None and self.dcplx_trans is None:
             raise ValueError("Both trans and dcplx_trans cannot be None.")
         if self.trans is not None and self.dcplx_trans is not None:
             raise ValueError("Only one of trans or dcplx_trans can be set.")
+        if self.cross_section is None and self.asymmetric_cross_section is None:
+            raise ValueError(
+                "Exactly one of cross_section or asymmetric_cross_section must be set."
+            )
+        if self.cross_section is not None and self.asymmetric_cross_section is not None:
+            raise ValueError(
+                "Only one of cross_section or asymmetric_cross_section can be set."
+            )
         return self
 
+    def is_symmetric(self) -> bool:
+        """Whether the port carries a symmetric cross section."""
+        return self.cross_section is not None
+
+    @property
+    def any_cross_section(self) -> SymmetricalCrossSection | AsymmetricalCrossSection:
+        """The cross section regardless of kind (symmetric or asymmetric)."""
+        if self.cross_section is not None:
+            return self.cross_section
+        assert self.asymmetric_cross_section is not None
+        return self.asymmetric_cross_section
+
     def __copy__(self) -> BasePort:
         """Copy the BasePort."""
         return BasePort(
             name=self.name,
             kcl=self.kcl,
             cross_section=self.cross_section,
+            asymmetric_cross_section=self.asymmetric_cross_section,
             trans=self.trans.dup() if self.trans else None,
             dcplx_trans=self.dcplx_trans.dup() if self.dcplx_trans else None,
             info=self.info.model_copy(),
@@ -180,7 +213,7 @@ def transformed(
         if isinstance(post_trans, kdb.Trans):
             post_trans = kdb.DCplxTrans(post_trans.to_dtype(self.kcl.dbu))
         dcplx_trans = self.dcplx_trans or kdb.DCplxTrans(
-            t=self.trans.to_dtype(self.kcl.dbu)  # type: ignore[union-attr]
+            t=self.trans.to_dtype(self.kcl.dbu)  # ty:ignore[unresolved-attribute]
         )
 
         base.trans = None
@@ -192,7 +225,7 @@ def transform(
         trans: kdb.Trans | kdb.DCplxTrans = kdb.Trans.R0,
         post_trans: kdb.Trans | kdb.DCplxTrans = kdb.Trans.R0,
     ) -> Self:
-        """Get a transformed copy of the BasePort."""
+        """Transform self."""
         base = self
         if (
             base.trans is not None
@@ -207,7 +240,7 @@ def transform(
         if isinstance(post_trans, kdb.Trans):
             post_trans = kdb.DCplxTrans(post_trans.to_dtype(self.kcl.dbu))
         dcplx_trans = self.dcplx_trans or kdb.DCplxTrans(
-            t=self.trans.to_dtype(self.kcl.dbu)  # type: ignore[union-attr]
+            t=self.trans.to_dtype(self.kcl.dbu)  # ty:ignore[unresolved-attribute]
         )
 
         base.trans = None
@@ -223,6 +256,7 @@ def ser_model(self) -> BasePortDict:
             name=self.name,
             kcl=self.kcl,
             cross_section=self.cross_section,
+            asymmetric_cross_section=self.asymmetric_cross_section,
             trans=trans,
             dcplx_trans=dcplx_trans,
             info=self.info.model_copy(),
@@ -265,14 +299,59 @@ def __eq__(self, other: object) -> bool:
                 )
                 and self.name == other.name
                 and self.kcl == other.kcl
-                and self.cross_section == other.cross_section
+                and self.any_cross_section == other.any_cross_section
                 and self.port_type == other.port_type
                 and self.info == other.info
             )
         )
 
+    def check_connection(
+        self,
+        other: BasePort,
+        tolerance: float = 0.1,
+        angle_tolerance: float = 0.01,
+        snapped: bool = False,
+    ) -> int:
+        tol_um = self.kcl.dbu * tolerance
+        check: int = 0
+        if snapped or (self.trans is not None and other.trans is not None):
+            t1 = self.get_trans()
+            t2 = other.get_trans()
+            if t1.disp == t2.disp:
+                check += PortCheck.position
+            orientation = (t1.angle - t2.angle) % 4
+            if orientation == 2:
+                check += PortCheck.opposite
+            elif orientation == 0:
+                check += PortCheck.same
+        else:
+            dt1 = self.get_dcplx_trans()
+            dt2 = other.get_dcplx_trans()
+            if (dt1.disp - dt2.disp).length() < tol_um:
+                check += PortCheck.position
+            angle_diff = (dt1.angle - dt2.angle) % 360
+            if abs(angle_diff - 180) < angle_tolerance:
+                check += PortCheck.opposite
+            elif abs(angle_diff) < angle_tolerance:
+                check += PortCheck.same
+        self_xs = self.any_cross_section
+        other_xs = other.any_cross_section
+        if self_xs == other_xs:
+            check += PortCheck.cross_section
+            check += PortCheck.layer
+            check += PortCheck.width
+        else:
+            if self_xs.main_layer.is_equivalent(other_xs.main_layer):
+                check += PortCheck.layer
+            if self_xs.width == other_xs.width:
+                check += PortCheck.width
+        if self.port_type == other.port_type:
+            check += PortCheck.port_type
 
-class ProtoPort(Generic[TUnit], ABC):  # noqa: PYI059
+        return check
+
+
+class ProtoPort[T: (int, float)](ABC):
     """Base class for kf.Port, kf.DPort."""
 
     yaml_tag: str = "!Port"
@@ -281,21 +360,21 @@ class ProtoPort(Generic[TUnit], ABC):  # noqa: PYI059
     @abstractmethod
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
-        width: TUnit | None = None,
+        width: T | None = None,
         layer: int | None = None,
         layer_info: kdb.LayerInfo | None = None,
         port_type: str = "optical",
         trans: kdb.Trans | str | None = None,
         dcplx_trans: kdb.DCplxTrans | str | None = None,
-        angle: TUnit | None = None,
-        center: tuple[TUnit, TUnit] | None = None,
+        angle: T | None = None,
+        center: tuple[T, T] | None = None,
         mirror_x: bool = False,
         port: Port | None = None,
         kcl: KCLayout | None = None,
         info: dict[str, int | float | str] = ...,
-        cross_section: TCrossSection[TUnit] | None = None,
+        cross_section: TCrossSection[T] | None = None,
         base: BasePort | None = None,
     ) -> None:
         """Initialise a ProtoPort."""
@@ -317,8 +396,8 @@ def kcl(self, value: KCLayout) -> None:
 
     @property
     @abstractmethod
-    def cross_section(self) -> TCrossSection[TUnit]:
-        """Get the cross section of the port."""
+    def cross_section(self) -> TCrossSection[T]:
+        """Get the symmetric cross section of the port. Raises if asymmetric."""
         ...
 
     @cross_section.setter
@@ -328,12 +407,29 @@ def cross_section(
     ) -> None: ...
 
     @property
-    def name(self) -> str | None:
+    @abstractmethod
+    def asymmetric_cross_section(self) -> TAsymmetricCrossSection[T]:
+        """Get the asymmetric cross section of the port. Raises if symmetric."""
+        ...
+
+    @asymmetric_cross_section.setter
+    @abstractmethod
+    def asymmetric_cross_section(
+        self,
+        value: AsymmetricalCrossSection | TAsymmetricCrossSection[Any],
+    ) -> None: ...
+
+    def is_symmetric(self) -> bool:
+        """Whether the port carries a symmetric cross section."""
+        return self._base.is_symmetric()
+
+    @property
+    def name(self) -> str:
         """Name of the port."""
         return self._base.name
 
     @name.setter
-    def name(self, value: str | None) -> None:
+    def name(self, value: str) -> None:
         self._base.name = value
 
     @property
@@ -365,7 +461,7 @@ def layer(self) -> LayerEnum | int:
         index.
         """
         return self.kcl.find_layer(
-            self.cross_section.layer, allow_undefined_layers=True
+            self._base.any_cross_section.main_layer, allow_undefined_layers=True
         )
 
     @property
@@ -374,7 +470,7 @@ def layer_info(self) -> kdb.LayerInfo:
 
         This corresponds to the port's cross section's main layer.
         """
-        return self.cross_section.layer
+        return self._base.any_cross_section.main_layer
 
     def __eq__(self, other: object) -> bool:
         """Support for `port1 == port2` comparisons."""
@@ -482,43 +578,43 @@ def copy(
         self,
         trans: kdb.Trans | kdb.DCplxTrans = kdb.Trans.R0,
         post_trans: kdb.Trans | kdb.DCplxTrans = kdb.Trans.R0,
-    ) -> ProtoPort[TUnit]:
+    ) -> ProtoPort[T]:
         """Copy the port with a transformation."""
         ...
 
     @property
-    def center(self) -> tuple[TUnit, TUnit]:
+    def center(self) -> tuple[T, T]:
         """Returns port center."""
         return (self.x, self.y)
 
     @center.setter
-    def center(self, value: tuple[TUnit, TUnit]) -> None:
+    def center(self, value: tuple[T, T]) -> None:
         self.x = value[0]
         self.y = value[1]
 
     @property
     @abstractmethod
-    def x(self) -> TUnit:
+    def x(self) -> T:
         """X coordinate of the port."""
         ...
 
     @x.setter
     @abstractmethod
-    def x(self, value: TUnit) -> None: ...
+    def x(self, value: T) -> None: ...
 
     @property
     @abstractmethod
-    def y(self) -> TUnit:
+    def y(self) -> T:
         """Y coordinate of the port."""
         ...
 
     @y.setter
     @abstractmethod
-    def y(self, value: TUnit) -> None: ...
+    def y(self, value: T) -> None: ...
 
     @property
     @abstractmethod
-    def width(self) -> TUnit:
+    def width(self) -> T:
         """Width of the port."""
         ...
 
@@ -557,7 +653,7 @@ def iy(self, value: int) -> None:
     @property
     def iwidth(self) -> int:
         """Width of the port in dbu."""
-        return self._base.cross_section.width
+        return self._base.any_cross_section.width
 
     @property
     def dx(self) -> float:
@@ -616,7 +712,7 @@ def icenter(self, pos: tuple[int, int]) -> None:
     @property
     def dwidth(self) -> float:
         """Width of the port in um."""
-        return self.kcl.to_um(self._base.cross_section.width)
+        return self.kcl.to_um(self._base.any_cross_section.width)
 
     def print(self, print_type: Literal["dbu", "um"] | None = None) -> None:
         """Print the port pretty."""
@@ -668,7 +764,7 @@ class Port(ProtoPort[int]):
     def __init__(
         self,
         *,
-        name: str | None = None,
+        name: str,
         width: int,
         layer: LayerEnum | int,
         trans: kdb.Trans | str,
@@ -681,7 +777,7 @@ def __init__(
     def __init__(
         self,
         *,
-        name: str | None = None,
+        name: str,
         width: int,
         layer: LayerEnum | int,
         dcplx_trans: kdb.DCplxTrans | str,
@@ -693,7 +789,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: int,
         layer: LayerEnum | int,
@@ -708,7 +804,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: int,
         layer_info: kdb.LayerInfo,
@@ -721,7 +817,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: int,
         layer_info: kdb.LayerInfo,
@@ -734,7 +830,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: int,
         layer_info: kdb.LayerInfo,
@@ -749,9 +845,9 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
-        cross_section: CrossSection | SymmetricalCrossSection,
+        cross_section: AnyCrossSectionInput,
         port_type: str = "optical",
         angle: int,
         center: tuple[int, int],
@@ -763,9 +859,9 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
-        cross_section: CrossSection | SymmetricalCrossSection,
+        cross_section: AnyCrossSectionInput,
         trans: kdb.Trans | str,
         kcl: KCLayout | None = None,
         info: dict[str, int | float | str] = ...,
@@ -775,9 +871,9 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
-        cross_section: CrossSection | SymmetricalCrossSection,
+        cross_section: AnyCrossSectionInput,
         dcplx_trans: kdb.DCplxTrans | str,
         kcl: KCLayout | None = None,
         info: dict[str, int | float | str] = ...,
@@ -806,7 +902,7 @@ def __init__(
         port: ProtoPort[Any] | None = None,
         kcl: KCLayout | None = None,
         info: dict[str, int | float | str] | None = None,
-        cross_section: CrossSection | SymmetricalCrossSection | None = None,
+        cross_section: AnyCrossSectionInput | None = None,
         base: BasePort | None = None,
     ) -> None:
         """Create a port from dbu or um based units."""
@@ -818,10 +914,18 @@ def __init__(
         if port is not None:
             self._base = port.base.__copy__()
             return
+
+        if name is None:
+            raise ValueError(
+                "Port must have a name. Only when passing another port or port base"
+                " name can be None."
+            )
         info_ = Info(**info)
         from .layout import get_default_kcl
 
         kcl_ = kcl or get_default_kcl()
+        sym_xs: SymmetricalCrossSection | None = None
+        asym_xs: AsymmetricalCrossSection | None = None
         if cross_section is None:
             if layer_info is None:
                 if layer is None:
@@ -832,19 +936,24 @@ def __init__(
                     "any width and layer, or a cross_section must be given if the"
                     " 'port is None'"
                 )
-            cross_section_ = kcl_.get_symmetrical_cross_section(
-                CrossSectionSpec(layer=layer_info, width=width)
+            sym_xs = kcl_.get_symmetrical_cross_section(
+                CrossSectionSpecDict(layer=layer_info, width=width)
             )
         elif isinstance(cross_section, SymmetricalCrossSection):
-            cross_section_ = cross_section
+            sym_xs = cross_section
+        elif isinstance(cross_section, AsymmetricalCrossSection):
+            asym_xs = cross_section
+        elif isinstance(cross_section, TAsymmetricCrossSection):
+            asym_xs = cross_section.base
         else:
-            cross_section_ = cross_section.base
+            sym_xs = cross_section.base
         if trans is not None:
             trans_ = kdb.Trans.from_s(trans) if isinstance(trans, str) else trans.dup()
             self._base = BasePort(
                 name=name,
                 kcl=kcl_,
-                cross_section=cross_section_,
+                cross_section=sym_xs,
+                asymmetric_cross_section=asym_xs,
                 trans=trans_,
                 info=info_,
                 port_type=port_type,
@@ -857,7 +966,8 @@ def __init__(
             self._base = BasePort(
                 name=name,
                 kcl=kcl_,
-                cross_section=cross_section_,
+                cross_section=sym_xs,
+                asymmetric_cross_section=asym_xs,
                 trans=kdb.Trans.R0,
                 info=info_,
                 port_type=port_type,
@@ -869,7 +979,8 @@ def __init__(
             self._base = BasePort(
                 name=name,
                 kcl=kcl_,
-                cross_section=cross_section_,
+                cross_section=sym_xs,
+                asymmetric_cross_section=asym_xs,
                 trans=trans_,
                 info=info_,
                 port_type=port_type,
@@ -942,7 +1053,16 @@ def width(self) -> int:
 
     @property
     def cross_section(self) -> CrossSection:
-        """Get the cross section of the port."""
+        """Get the symmetric cross section of the port.
+
+        Raises:
+            TypeError: if the port carries an asymmetric cross section.
+        """
+        if self._base.cross_section is None:
+            raise TypeError(
+                f"Port {self.name!r} carries an asymmetric cross section."
+                " Use `asymmetric_cross_section` instead."
+            )
         return CrossSection(kcl=self._base.kcl, base=self._base.cross_section)
 
     @cross_section.setter
@@ -951,8 +1071,36 @@ def cross_section(
     ) -> None:
         if isinstance(value, SymmetricalCrossSection):
             self._base.cross_section = value
-            return
-        self._base.cross_section = value.base
+        else:
+            self._base.cross_section = value.base
+        self._base.asymmetric_cross_section = None
+
+    @property
+    def asymmetric_cross_section(self) -> AsymmetricCrossSection:
+        """Get the asymmetric cross section of the port.
+
+        Raises:
+            TypeError: if the port carries a symmetric cross section.
+        """
+        if self._base.asymmetric_cross_section is None:
+            raise TypeError(
+                f"Port {self.name!r} carries a symmetric cross section."
+                " Use `cross_section` instead."
+            )
+        return AsymmetricCrossSection(
+            kcl=self._base.kcl, base=self._base.asymmetric_cross_section
+        )
+
+    @asymmetric_cross_section.setter
+    def asymmetric_cross_section(
+        self,
+        value: AsymmetricalCrossSection | TAsymmetricCrossSection[Any],
+    ) -> None:
+        if isinstance(value, AsymmetricalCrossSection):
+            self._base.asymmetric_cross_section = value
+        else:
+            self._base.asymmetric_cross_section = value.base
+        self._base.cross_section = None
 
 
 class DPort(ProtoPort[float]):
@@ -982,7 +1130,7 @@ class DPort(ProtoPort[float]):
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: float,
         layer: LayerEnum | int,
@@ -995,7 +1143,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: float,
         layer: LayerEnum | int,
@@ -1008,7 +1156,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: float,
         layer: LayerEnum | int,
@@ -1023,7 +1171,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: float,
         layer_info: kdb.LayerInfo,
@@ -1036,7 +1184,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: float,
         layer_info: kdb.LayerInfo,
@@ -1049,7 +1197,7 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
         width: float,
         layer_info: kdb.LayerInfo,
@@ -1064,9 +1212,9 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
-        cross_section: DCrossSection | SymmetricalCrossSection,
+        cross_section: AnyCrossSectionInput,
         port_type: str = "optical",
         orientation: float,
         center: tuple[float, float],
@@ -1078,9 +1226,9 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
-        cross_section: DCrossSection | SymmetricalCrossSection,
+        cross_section: AnyCrossSectionInput,
         trans: kdb.Trans | str,
         kcl: KCLayout | None = None,
         info: dict[str, int | float | str] = ...,
@@ -1090,9 +1238,9 @@ def __init__(
     @overload
     def __init__(
         self,
-        name: str | None = None,
+        name: str,
         *,
-        cross_section: DCrossSection | SymmetricalCrossSection,
+        cross_section: AnyCrossSectionInput,
         dcplx_trans: kdb.DCplxTrans | str,
         kcl: KCLayout | None = None,
         info: dict[str, int | float | str] = ...,
@@ -1121,7 +1269,7 @@ def __init__(
         port: ProtoPort[Any] | None = None,
         kcl: KCLayout | None = None,
         info: dict[str, int | float | str] | None = None,
-        cross_section: DCrossSection | SymmetricalCrossSection | None = None,
+        cross_section: AnyCrossSectionInput | None = None,
         base: BasePort | None = None,
     ) -> None:
         """Create a port from dbu or um based units."""
@@ -1133,11 +1281,19 @@ def __init__(
         if port is not None:
             self._base = port.base.__copy__()
             return
+
+        if name is None:
+            raise ValueError(
+                "DPort must have a name. Only when passing another port or port base"
+                " name can be None."
+            )
         info_ = Info(**info)
 
         from .layout import get_default_kcl
 
         kcl_ = kcl or get_default_kcl()
+        sym_xs: SymmetricalCrossSection | None = None
+        asym_xs: AsymmetricalCrossSection | None = None
         if cross_section is None:
             if layer_info is None:
                 if layer is None:
@@ -1153,19 +1309,24 @@ def __init__(
                     f"width needs to be even to snap to grid. Got {width}."
                     "Ports must have a grid width of multiples of 2."
                 )
-            cross_section_ = kcl_.get_symmetrical_cross_section(
-                CrossSectionSpec(layer=layer_info, width=kcl_.to_dbu(width))
+            sym_xs = kcl_.get_symmetrical_cross_section(
+                CrossSectionSpecDict(layer=layer_info, width=kcl_.to_dbu(width))
             )
         elif isinstance(cross_section, SymmetricalCrossSection):
-            cross_section_ = cross_section
+            sym_xs = cross_section
+        elif isinstance(cross_section, AsymmetricalCrossSection):
+            asym_xs = cross_section
+        elif isinstance(cross_section, TAsymmetricCrossSection):
+            asym_xs = cross_section.base
         else:
-            cross_section_ = cross_section.base
+            sym_xs = cross_section.base
         if trans is not None:
             trans_ = kdb.Trans.from_s(trans) if isinstance(trans, str) else trans.dup()
             self._base = BasePort(
                 name=name,
                 kcl=kcl_,
-                cross_section=cross_section_,
+                cross_section=sym_xs,
+                asymmetric_cross_section=asym_xs,
                 trans=trans_,
                 info=info_,
                 port_type=port_type,
@@ -1178,7 +1339,8 @@ def __init__(
             self._base = BasePort(
                 name=name,
                 kcl=kcl_,
-                cross_section=cross_section_,
+                cross_section=sym_xs,
+                asymmetric_cross_section=asym_xs,
                 trans=kdb.Trans.R0,
                 info=info_,
                 port_type=port_type,
@@ -1190,7 +1352,8 @@ def __init__(
             self._base = BasePort(
                 name=name,
                 kcl=kcl_,
-                cross_section=cross_section_,
+                cross_section=sym_xs,
+                asymmetric_cross_section=asym_xs,
                 dcplx_trans=dcplx_trans_,
                 info=info_,
                 port_type=port_type,
@@ -1267,7 +1430,16 @@ def width(self) -> float:
 
     @property
     def cross_section(self) -> DCrossSection:
-        """Get the cross section of the port."""
+        """Get the symmetric cross section of the port.
+
+        Raises:
+            TypeError: if the port carries an asymmetric cross section.
+        """
+        if self._base.cross_section is None:
+            raise TypeError(
+                f"Port {self.name!r} carries an asymmetric cross section."
+                " Use `asymmetric_cross_section` instead."
+            )
         return DCrossSection(kcl=self._base.kcl, base=self._base.cross_section)
 
     @cross_section.setter
@@ -1276,8 +1448,36 @@ def cross_section(
     ) -> None:
         if isinstance(value, SymmetricalCrossSection):
             self._base.cross_section = value
-            return
-        self._base.cross_section = value.base
+        else:
+            self._base.cross_section = value.base
+        self._base.asymmetric_cross_section = None
+
+    @property
+    def asymmetric_cross_section(self) -> DAsymmetricCrossSection:
+        """Get the asymmetric cross section of the port.
+
+        Raises:
+            TypeError: if the port carries a symmetric cross section.
+        """
+        if self._base.asymmetric_cross_section is None:
+            raise TypeError(
+                f"Port {self.name!r} carries a symmetric cross section."
+                " Use `cross_section` instead."
+            )
+        return DAsymmetricCrossSection(
+            kcl=self._base.kcl, base=self._base.asymmetric_cross_section
+        )
+
+    @asymmetric_cross_section.setter
+    def asymmetric_cross_section(
+        self,
+        value: AsymmetricalCrossSection | TAsymmetricCrossSection[Any],
+    ) -> None:
+        if isinstance(value, AsymmetricalCrossSection):
+            self._base.asymmetric_cross_section = value
+        else:
+            self._base.asymmetric_cross_section = value.base
+        self._base.cross_section = None
 
 
 class DIRECTION(IntEnum):
@@ -1309,6 +1509,7 @@ def autorename(
 def rename_clockwise(
     ports: Iterable[ProtoPort[Any]],
     layer: LayerEnum | int | None = None,
+    layer_info: kdb.LayerInfo | None = None,
     port_type: str | None = None,
     regex: str | None = None,
     prefix: str = "o",
@@ -1334,7 +1535,7 @@ def rename_clockwise(
             o8  o7
     ```
     """
-    ports_ = filter_layer_pt_reg(ports, layer, port_type, regex)
+    ports_ = filter_layer_pt_reg(ports, layer, layer_info, port_type, regex)
 
     def sort_key(port: ProtoPort[Any]) -> tuple[int, int, int]:
         match port.trans.angle:
@@ -1415,6 +1616,7 @@ def rename_clockwise_multi(
 def rename_by_direction(
     ports: Iterable[ProtoPort[Any]],
     layer: LayerEnum | int | None = None,
+    layer_info: kdb.LayerInfo | None = None,
     port_type: str | None = None,
     regex: str | None = None,
     dir_names: tuple[str, str, str, str] = ("E", "N", "W", "S"),
@@ -1441,7 +1643,7 @@ def rename_by_direction(
     ```
     """
     for angle in DIRECTION:
-        ports_ = filter_layer_pt_reg(ports, layer, port_type, regex)
+        ports_ = filter_layer_pt_reg(ports, layer, layer_info, port_type, regex)
         dir_2 = -1 if angle < ANGLE_180 else 1
         if angle % 2:
 
@@ -1457,9 +1659,10 @@ def key_sort(port: ProtoPort[Any], dir_2: int = dir_2) -> tuple[int, int]:
             p.name = f"{prefix}{dir_names[angle]}{i}"
 
 
-def filter_layer_pt_reg(
+def filter_layer_pt_reg[TPort: ProtoPort[Any]](
     ports: Iterable[TPort],
     layer: LayerEnum | int | None = None,
+    layer_info: kdb.LayerInfo | None = None,
     port_type: str | None = None,
     regex: str | None = None,
 ) -> Iterable[TPort]:
@@ -1467,6 +1670,8 @@ def filter_layer_pt_reg(
     ports_ = ports
     if layer is not None:
         ports_ = filter_layer(ports_, layer)
+    if layer_info is not None:
+        ports_ = filter_layer_info(ports_, layer_info)
     if port_type is not None:
         ports_ = filter_port_type(ports_, port_type)
     if regex is not None:
@@ -1475,8 +1680,10 @@ def filter_layer_pt_reg(
     return ports_
 
 
-def filter_direction(ports: Iterable[TPort], direction: int) -> filter[TPort]:
-    """Filter iterable/sequence of ports by direction :py:class:~`DIRECTION`."""
+def filter_direction[TPort: ProtoPort[Any]](
+    ports: Iterable[TPort], direction: int
+) -> filter[TPort]:
+    """Filter iterable/sequence of ports by direction `DIRECTION`."""
 
     def f_func(p: TPort) -> bool:
         return p.trans.angle == direction
@@ -1484,8 +1691,10 @@ def f_func(p: TPort) -> bool:
     return filter(f_func, ports)
 
 
-def filter_orientation(ports: Iterable[TPort], orientation: float) -> filter[TPort]:
-    """Filter iterable/sequence of ports by direction :py:class:~`DIRECTION`."""
+def filter_orientation[TPort: ProtoPort[Any]](
+    ports: Iterable[TPort], orientation: float
+) -> filter[TPort]:
+    """Filter iterable/sequence of ports by direction `DIRECTION`."""
 
     def f_func(p: TPort) -> bool:
         return p.dcplx_trans.angle == orientation
@@ -1493,7 +1702,9 @@ def f_func(p: TPort) -> bool:
     return filter(f_func, ports)
 
 
-def filter_port_type(ports: Iterable[TPort], port_type: str) -> filter[TPort]:
+def filter_port_type[TPort: ProtoPort[Any]](
+    ports: Iterable[TPort], port_type: str
+) -> filter[TPort]:
     """Filter iterable/sequence of ports by port_type."""
 
     def pt_filter(p: TPort) -> bool:
@@ -1502,7 +1713,9 @@ def pt_filter(p: TPort) -> bool:
     return filter(pt_filter, ports)
 
 
-def filter_layer(ports: Iterable[TPort], layer: int | LayerEnum) -> filter[TPort]:
+def filter_layer[TPort: ProtoPort[Any]](
+    ports: Iterable[TPort], layer: LayerEnum | int
+) -> filter[TPort]:
     """Filter iterable/sequence of ports by layer index / LayerEnum."""
 
     def layer_filter(p: TPort) -> bool:
@@ -1511,14 +1724,25 @@ def layer_filter(p: TPort) -> bool:
     return filter(layer_filter, ports)
 
 
-def filter_regex(ports: Iterable[TPort], regex: str) -> filter[TPort]:
+def filter_layer_info[TPort: ProtoPort[Any]](
+    ports: Iterable[TPort], layer_info: kdb.LayerInfo
+) -> filter[TPort]:
+    """Filter iterable/sequence of ports by kdb.LayerInfo."""
+
+    def layer_filter(p: TPort) -> bool:
+        return p.layer_info.is_equivalent(layer_info)
+
+    return filter(layer_filter, ports)
+
+
+def filter_regex[TPort: ProtoPort[Any]](
+    ports: Iterable[TPort], regex: str
+) -> filter[TPort]:
     """Filter iterable/sequence of ports by port name."""
     pattern = re.compile(regex)
 
     def regex_filter(p: TPort) -> bool:
-        if p.name is not None:
-            return bool(pattern.match(p.name))
-        return False
+        return bool(pattern.match(p.name))
 
     return filter(regex_filter, ports)
 
diff --git a/src/kfactory/ports.py b/src/kfactory/ports.py
index 578ab65c1..91be2c800 100644
--- a/src/kfactory/ports.py
+++ b/src/kfactory/ports.py
@@ -9,11 +9,16 @@
 from . import kdb
 from .conf import config
 from .cross_section import (
+    AsymmetricalCrossSection,
+    AsymmetricCrossSection,
     CrossSection,
-    CrossSectionSpec,
+    CrossSectionSpecDict,
+    DAsymmetricalCrossSection,
+    DAsymmetricCrossSection,
     DCrossSection,
-    DCrossSectionSpec,
+    DCrossSectionSpecDict,
     SymmetricalCrossSection,
+    TAsymmetricCrossSection,
 )
 from .port import (
     BasePort,
@@ -22,26 +27,28 @@
     ProtoPort,
     filter_direction,
     filter_layer,
+    filter_layer_info,
     filter_orientation,
     filter_port_type,
     filter_regex,
 )
-from .typings import Angle, MetaData, TPort, TUnit
 from .utilities import pprint_ports
 
 if TYPE_CHECKING:
     from .layer import LayerEnum
     from .layout import KCLayout
+    from .typings import Angle, MetaData, TPort
 
 
 __all__ = ["DPorts", "Ports", "ProtoPorts"]
 
 
-def _filter_ports(
+def _filter_ports[TPort: ProtoPort[Any]](
     ports: Iterable[TPort],
     angle: Angle | None = None,
     orientation: float | None = None,
     layer: LayerEnum | int | None = None,
+    layer_info: kdb.LayerInfo | None = None,
     port_type: str | None = None,
     regex: str | None = None,
 ) -> list[TPort]:
@@ -49,6 +56,8 @@ def _filter_ports(
         ports = filter_regex(ports, regex)
     if layer is not None:
         ports = filter_layer(ports, layer)
+    if layer_info is not None:
+        ports = filter_layer_info(ports, layer_info)
     if port_type:
         ports = filter_port_type(ports, port_type)
     if angle is not None:
@@ -58,7 +67,7 @@ def _filter_ports(
     return list(ports)
 
 
-class ProtoPorts(Protocol[TUnit]):
+class ProtoPorts[T: (int, float)](Protocol):
     """Base class for kf.Ports, kf.DPorts."""
 
     _kcl: KCLayout
@@ -129,7 +138,7 @@ def kcl(self, value: KCLayout) -> None:
     @abstractmethod
     def copy(
         self,
-        rename_function: Callable[[Sequence[ProtoPort[TUnit]]], None] | None = None,
+        rename_function: Callable[[Sequence[ProtoPort[T]]], None] | None = None,
     ) -> Self:
         """Get a copy of each port."""
         ...
@@ -143,7 +152,7 @@ def to_dtype(self) -> DPorts:
         return DPorts(kcl=self.kcl, bases=self._bases)
 
     @abstractmethod
-    def __iter__(self) -> Iterator[ProtoPort[TUnit]]:
+    def __iter__(self) -> Iterator[ProtoPort[T]]:
         """Iterator over the Ports."""
         ...
 
@@ -154,12 +163,12 @@ def add_port(
         port: ProtoPort[Any],
         name: str | None = None,
         keep_mirror: bool = False,
-    ) -> ProtoPort[TUnit]:
+    ) -> ProtoPort[T]:
         """Add a port."""
         ...
 
     @abstractmethod
-    def get_all_named(self) -> Mapping[str, ProtoPort[TUnit]]:
+    def get_all_named(self) -> Mapping[str, ProtoPort[T]]:
         """Get all ports in a dictionary with names as keys.
 
         This filters out Ports with `None` as name.
@@ -180,7 +189,7 @@ def add_ports(
 
     @overload
     @abstractmethod
-    def __getitem__(self, key: int | str | None) -> ProtoPort[TUnit]:
+    def __getitem__(self, key: int | str | None) -> ProtoPort[T]:
         """Get a port by index or name."""
         ...
 
@@ -196,9 +205,10 @@ def filter(
         angle: Angle | None = None,
         orientation: float | None = None,
         layer: LayerEnum | int | None = None,
+        layer_info: kdb.LayerInfo | None = None,
         port_type: str | None = None,
         regex: str | None = None,
-    ) -> Sequence[ProtoPort[TUnit]]:
+    ) -> Sequence[ProtoPort[T]]:
         """Filter ports.
 
         Args:
@@ -259,13 +269,16 @@ def kcl(self) -> KCLayout: ...
     def create_port(
         self,
         *,
+        name: str,
         trans: kdb.Trans,
-        cross_section: CrossSectionSpec
-        | DCrossSectionSpec
+        cross_section: CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | CrossSection
         | DCrossSection
-        | SymmetricalCrossSection,
-        name: str | None = None,
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection
+        | AsymmetricCrossSection
+        | DAsymmetricCrossSection,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> Port: ...
@@ -274,10 +287,10 @@ def create_port(
     def create_port(
         self,
         *,
+        name: str,
         trans: kdb.Trans,
         width: int,
         layer: int,
-        name: str | None = None,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> Port: ...
@@ -286,10 +299,10 @@ def create_port(
     def create_port(
         self,
         *,
+        name: str,
         dcplx_trans: kdb.DCplxTrans,
         width: int,
         layer: LayerEnum | int,
-        name: str | None = None,
         port_type: str = "optical",
     ) -> Port: ...
 
@@ -297,11 +310,11 @@ def create_port(
     def create_port(
         self,
         *,
+        name: str,
         width: int,
         layer: LayerEnum | int,
         center: tuple[int, int],
         angle: Angle,
-        name: str | None = None,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> Port: ...
@@ -310,10 +323,10 @@ def create_port(
     def create_port(
         self,
         *,
+        name: str,
         trans: kdb.Trans,
         width: int,
         layer_info: kdb.LayerInfo,
-        name: str | None = None,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> Port: ...
@@ -322,11 +335,11 @@ def create_port(
     def create_port(
         self,
         *,
+        name: str,
         width: int,
         layer_info: kdb.LayerInfo,
         center: tuple[int, int],
         angle: Angle,
-        name: str | None = None,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> Port: ...
@@ -335,14 +348,17 @@ def create_port(
     def create_port(
         self,
         *,
+        name: str,
         layer_info: kdb.LayerInfo,
         trans: kdb.Trans,
-        cross_section: CrossSectionSpec
-        | DCrossSectionSpec
+        cross_section: CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | CrossSection
         | DCrossSection
-        | SymmetricalCrossSection,
-        name: str | None = None,
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection
+        | AsymmetricCrossSection
+        | DAsymmetricCrossSection,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> Port: ...
@@ -350,13 +366,16 @@ def create_port(
     def create_port(
         self,
         *,
+        name: str,
         dcplx_trans: kdb.DCplxTrans,
-        cross_section: CrossSectionSpec
-        | DCrossSectionSpec
+        cross_section: CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | CrossSection
         | DCrossSection
-        | SymmetricalCrossSection,
-        name: str | None = None,
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection
+        | AsymmetricCrossSection
+        | DAsymmetricCrossSection,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> Port: ...
@@ -364,7 +383,7 @@ def create_port(
     def create_port(
         self,
         *,
-        name: str | None = None,
+        name: str,
         width: int | None = None,
         layer: LayerEnum | int | None = None,
         layer_info: kdb.LayerInfo | None = None,
@@ -374,16 +393,25 @@ def create_port(
         center: tuple[int, int] | None = None,
         angle: Angle | None = None,
         mirror_x: bool = False,
-        cross_section: CrossSectionSpec
-        | DCrossSectionSpec
+        cross_section: CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | CrossSection
         | DCrossSection
         | SymmetricalCrossSection
+        | AsymmetricalCrossSection
+        | DAsymmetricalCrossSection
+        | AsymmetricCrossSection
+        | DAsymmetricCrossSection
         | None = None,
         info: dict[str, MetaData] | None = None,
     ) -> Port:
         """Create a port."""
-
+        xs: (
+            CrossSection
+            | AsymmetricCrossSection
+            | SymmetricalCrossSection
+            | AsymmetricalCrossSection
+        )
         if cross_section is None:
             if width is None:
                 raise ValueError(
@@ -399,7 +427,7 @@ def create_port(
             assert layer_info is not None
             try:
                 xs = self.kcl.get_icross_section(
-                    CrossSectionSpec(layer=layer_info, width=width, unit="dbu")
+                    CrossSectionSpecDict(layer=layer_info, width=width, unit="dbu")
                 )
             except ValidationError as e:
                 raise ValueError(
@@ -407,6 +435,15 @@ def create_port(
                     "and greater than 0"
                     f". 1 DBU is {self.kcl.dbu} um."
                 ) from e
+        elif isinstance(
+            cross_section,
+            (
+                AsymmetricalCrossSection,
+                DAsymmetricalCrossSection,
+                TAsymmetricCrossSection,
+            ),
+        ):
+            xs = self.kcl.get_iasymmetric_cross_section(cross_section)
         else:
             xs = self.kcl.get_icross_section(cross_section)
         if trans is not None:
@@ -469,7 +506,7 @@ def create_port(
         trans: kdb.Trans,
         width: float,
         layer: int,
-        name: str | None = None,
+        name: str,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> DPort: ...
@@ -481,7 +518,7 @@ def create_port(
         dcplx_trans: kdb.DCplxTrans,
         width: float,
         layer: LayerEnum | int,
-        name: str | None = None,
+        name: str,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> DPort: ...
@@ -494,7 +531,7 @@ def create_port(
         layer: LayerEnum | int,
         center: tuple[float, float],
         orientation: float,
-        name: str | None = None,
+        name: str,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> DPort: ...
@@ -506,7 +543,7 @@ def create_port(
         trans: kdb.Trans,
         width: float,
         layer_info: kdb.LayerInfo,
-        name: str | None = None,
+        name: str,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> DPort: ...
@@ -518,7 +555,7 @@ def create_port(
         dcplx_trans: kdb.DCplxTrans,
         width: float,
         layer_info: kdb.LayerInfo,
-        name: str | None = None,
+        name: str,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> DPort: ...
@@ -531,7 +568,7 @@ def create_port(
         layer_info: kdb.LayerInfo,
         center: tuple[float, float],
         orientation: float,
-        name: str | None = None,
+        name: str,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> DPort: ...
@@ -541,12 +578,15 @@ def create_port(
         self,
         *,
         trans: kdb.Trans,
-        cross_section: CrossSectionSpec
-        | DCrossSectionSpec
+        cross_section: CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | CrossSection
         | DCrossSection
-        | SymmetricalCrossSection,
-        name: str | None = None,
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection
+        | AsymmetricCrossSection
+        | DAsymmetricCrossSection,
+        name: str,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> DPort: ...
@@ -555,12 +595,15 @@ def create_port(
         self,
         *,
         dcplx_trans: kdb.DCplxTrans,
-        cross_section: CrossSectionSpec
-        | DCrossSectionSpec
+        cross_section: CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | CrossSection
         | DCrossSection
-        | SymmetricalCrossSection,
-        name: str | None = None,
+        | SymmetricalCrossSection
+        | AsymmetricalCrossSection
+        | AsymmetricCrossSection
+        | DAsymmetricCrossSection,
+        name: str,
         port_type: str = "optical",
         info: dict[str, MetaData] | None = None,
     ) -> DPort: ...
@@ -568,7 +611,7 @@ def create_port(
     def create_port(
         self,
         *,
-        name: str | None = None,
+        name: str,
         width: float | None = None,
         layer: LayerEnum | int | None = None,
         layer_info: kdb.LayerInfo | None = None,
@@ -578,15 +621,25 @@ def create_port(
         center: tuple[float, float] | None = None,
         orientation: float | None = None,
         mirror_x: bool = False,
-        cross_section: CrossSectionSpec
-        | DCrossSectionSpec
+        cross_section: CrossSectionSpecDict
+        | DCrossSectionSpecDict
         | CrossSection
         | DCrossSection
         | SymmetricalCrossSection
+        | AsymmetricalCrossSection
+        | DAsymmetricalCrossSection
+        | AsymmetricCrossSection
+        | DAsymmetricCrossSection
         | None = None,
         info: dict[str, MetaData] | None = None,
     ) -> DPort:
         """Create a port."""
+        xs: (
+            DCrossSection
+            | DAsymmetricCrossSection
+            | SymmetricalCrossSection
+            | AsymmetricalCrossSection
+        )
         if cross_section is None:
             if width is None:
                 raise ValueError(
@@ -602,7 +655,7 @@ def create_port(
             assert layer_info is not None
             try:
                 xs = self.kcl.get_dcross_section(
-                    DCrossSectionSpec(layer=layer_info, width=width, unit="um")
+                    DCrossSectionSpecDict(layer=layer_info, width=width, unit="um")
                 )
             except ValidationError as e:
                 raise ValueError(
@@ -611,6 +664,15 @@ def create_port(
                     f". 1 DBU is {self.kcl.dbu} um. Port width must be a "
                     f"multiple of {2 * self.kcl.dbu} um."
                 ) from e
+        elif isinstance(
+            cross_section,
+            (
+                AsymmetricalCrossSection,
+                DAsymmetricalCrossSection,
+                TAsymmetricCrossSection,
+            ),
+        ):
+            xs = self.kcl.get_dasymmetric_cross_section(cross_section)
         else:
             xs = self.kcl.get_dcross_section(cross_section)
         if trans is not None:
@@ -685,7 +747,7 @@ def add_port(
             port: The port to add
             name: Overwrite the name of the port
             keep_mirror: Keep the mirror flag from the original port if `True`,
-                else set [Port.trans.mirror][kfactory.kcell.Port.trans] (or the complex
+                else set [Port.trans.mirror][kfactory.Port.trans] (or the complex
                 equivalent) to `False`.
         """
         if port.kcl == self.kcl:
@@ -707,9 +769,16 @@ def add_port(
             base.trans = kdb.Trans.R0
             base.dcplx_trans = None
             base.kcl = self.kcl
-            base.cross_section = self.kcl.get_symmetrical_cross_section(
-                port.cross_section.base.to_dtype(port.kcl)
-            )
+            if port.is_symmetric():
+                base.cross_section = self.kcl.get_symmetrical_cross_section(
+                    port.cross_section.base.to_dtype(port.kcl)
+                )
+                base.asymmetric_cross_section = None
+            else:
+                base.asymmetric_cross_section = self.kcl.get_asymmetrical_cross_section(
+                    port.asymmetric_cross_section.base.to_dtype(port.kcl)
+                )
+                base.cross_section = None
             if name is not None:
                 base.name = name
             port_ = Port(base=base)
@@ -756,6 +825,7 @@ def filter(
         angle: Angle | None = None,
         orientation: float | None = None,
         layer: LayerEnum | int | None = None,
+        layer_info: kdb.LayerInfo | None = None,
         port_type: str | None = None,
         regex: str | None = None,
     ) -> list[Port]:
@@ -773,6 +843,7 @@ def filter(
             angle,
             orientation,
             layer,
+            layer_info,
             port_type,
             regex,
         )
@@ -808,7 +879,7 @@ def add_port(
             port: The port to add
             name: Overwrite the name of the port
             keep_mirror: Keep the mirror flag from the original port if `True`,
-                else set [Port.trans.mirror][kfactory.kcell.Port.trans] (or the complex
+                else set [Port.trans.mirror][kfactory.Port.trans] (or the complex
                 equivalent) to `False`.
         """
         if port.kcl == self.kcl:
@@ -830,9 +901,16 @@ def add_port(
             base.trans = kdb.Trans.R0
             base.dcplx_trans = None
             base.kcl = self.kcl
-            base.cross_section = self.kcl.get_symmetrical_cross_section(
-                port.cross_section.base.to_dtype(port.kcl)
-            )
+            if port.is_symmetric():
+                base.cross_section = self.kcl.get_symmetrical_cross_section(
+                    port.cross_section.base.to_dtype(port.kcl)
+                )
+                base.asymmetric_cross_section = None
+            else:
+                base.asymmetric_cross_section = self.kcl.get_asymmetrical_cross_section(
+                    port.asymmetric_cross_section.base.to_dtype(port.kcl)
+                )
+                base.cross_section = None
             port_ = DPort(base=base)
             port_.dcplx_trans = dcplx_trans
             self._bases.append(port_.base)
@@ -877,6 +955,7 @@ def filter(
         angle: Angle | None = None,
         orientation: float | None = None,
         layer: LayerEnum | int | None = None,
+        layer_info: kdb.LayerInfo | None = None,
         port_type: str | None = None,
         regex: str | None = None,
     ) -> list[DPort]:
@@ -889,11 +968,14 @@ def filter(
             port_type: Filter by port type.
             regex: Filter by regex of the name.
         """
+        if layer is None and layer_info is not None:
+            layer = self.kcl.layout.layer(layer_info)
         return _filter_ports(
             (DPort(base=b) for b in self._bases),
             angle,
             orientation,
             layer,
+            layer_info,
             port_type,
             regex,
         )
diff --git a/src/kfactory/protocols.py b/src/kfactory/protocols.py
index f0a10f156..758e629ee 100644
--- a/src/kfactory/protocols.py
+++ b/src/kfactory/protocols.py
@@ -2,8 +2,6 @@
 
 from typing import TYPE_CHECKING, Protocol, overload, runtime_checkable
 
-from .typings import TUnit
-
 if TYPE_CHECKING:
     from .layer import LayerEnum
 
@@ -11,39 +9,39 @@
 
 
 @runtime_checkable
-class PointLike(Protocol[TUnit]):
+class PointLike[T: (int, float)](Protocol):
     """Protocol for a point.
 
     Mirrors some functionality of  kdb.DPoint, kdb.Point,
     but provides generic types for the units.
     """
 
-    x: TUnit
-    y: TUnit
+    x: T
+    y: T
 
 
 @runtime_checkable
-class BoxLike(Protocol[TUnit]):
+class BoxLike[T: (int, float)](Protocol):
     """Protocol for a box.
 
     Mirrors some functionality of kdb.DBox, kdb.Box,
     but provides generic types for the units.
     """
 
-    left: TUnit
-    bottom: TUnit
-    right: TUnit
-    top: TUnit
+    left: T
+    bottom: T
+    right: T
+    top: T
 
-    def center(self) -> PointLike[TUnit]:
+    def center(self) -> PointLike[T]:
         """Get the center of the box."""
         ...
 
-    def width(self) -> TUnit:
+    def width(self) -> T:
         """Get the width of the box."""
         ...
 
-    def height(self) -> TUnit:
+    def height(self) -> T:
         """Get the height of the box."""
         ...
 
@@ -53,17 +51,17 @@ def empty(self) -> bool:
 
 
 @runtime_checkable
-class BoxFunction(Protocol[TUnit]):
+class BoxFunction[T: (int, float)](Protocol):
     """Protocol for a box function.
 
     Represents bbox/ibbox/dbbox functions.
     """
 
     @overload
-    def __call__(self) -> BoxLike[TUnit]: ...
+    def __call__(self) -> BoxLike[T]: ...
     @overload
-    def __call__(self, layer: LayerEnum | int) -> BoxLike[TUnit]: ...
+    def __call__(self, layer: LayerEnum | int) -> BoxLike[T]: ...
 
-    def __call__(self, layer: LayerEnum | int | None = None) -> BoxLike[TUnit]:
+    def __call__(self, layer: LayerEnum | int | None = None) -> BoxLike[T]:
         """Call the box function."""
         ...
diff --git a/src/kfactory/routing/__init__.py b/src/kfactory/routing/__init__.py
index 113903831..15042af91 100644
--- a/src/kfactory/routing/__init__.py
+++ b/src/kfactory/routing/__init__.py
@@ -1,5 +1,15 @@
 """Module for creating automatic optical and electrical routing."""
 
 from . import aa, electrical, generic, manhattan, optical
+from .optical import LoopPosition, LoopSide, PathLengthConfig
 
-__all__ = ["aa", "electrical", "generic", "manhattan", "optical"]
+__all__ = [
+    "LoopPosition",
+    "LoopSide",
+    "PathLengthConfig",
+    "aa",
+    "electrical",
+    "generic",
+    "manhattan",
+    "optical",
+]
diff --git a/src/kfactory/routing/aa/optical.py b/src/kfactory/routing/aa/optical.py
index bd51cb46e..b78a799b4 100644
--- a/src/kfactory/routing/aa/optical.py
+++ b/src/kfactory/routing/aa/optical.py
@@ -32,11 +32,11 @@ def _angle(v: kdb.DVector) -> float:
 
 
 class VirtualStraightFactory(Protocol):
-    def __call__(self, width: float, length: float) -> VKCell: ...
+    def __call__(self, *, width: float, length: float) -> VKCell: ...
 
 
 class VirtualBendFactory(Protocol):
-    def __call__(self, width: float, angle: float) -> VKCell: ...
+    def __call__(self, *, width: float, angle: float) -> VKCell: ...
 
 
 def route(
@@ -545,7 +545,7 @@ def _get_effective_radius(
 
     if xp is None:
         return float("inf")
-    return (xp - port1.dcplx_trans.disp.to_p()).abs()  # type: ignore[no-any-return]
+    return (xp - port1.dcplx_trans.disp.to_p()).abs()
 
 
 def _get_effective_radius_debug(
@@ -557,7 +557,7 @@ def _get_effective_radius_debug(
 
     if xp is None:
         return float("inf")
-    return (xp - port1.dcplx_trans.disp.to_p()).abs()  # type: ignore[no-any-return]
+    return (xp - port1.dcplx_trans.disp.to_p()).abs()
 
 
 def backbone2bundle(
diff --git a/src/kfactory/routing/electrical.py b/src/kfactory/routing/electrical.py
index 0bcb118ea..3b527cd4c 100644
--- a/src/kfactory/routing/electrical.py
+++ b/src/kfactory/routing/electrical.py
@@ -1,7 +1,8 @@
 """Utilities for automatically routing electrical connections."""
 
-from collections.abc import Callable, Sequence
-from typing import Any, Literal, Protocol, cast, overload
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, overload
 
 import numpy as np
 
@@ -17,12 +18,10 @@
 from ..enclosure import LayerEnclosure
 from ..kcell import DKCell, KCell, ProtoTKCell
 from ..port import DPort, Port
-from ..typings import dbu, um
 from .generic import ManhattanRoute
 from .generic import route_bundle as route_bundle_generic
 from .length_functions import get_length_from_backbone
 from .manhattan import (
-    ManhattanRoutePathFunction,
     _is_manhattan,
     route_manhattan,
     route_smart,
@@ -30,148 +29,29 @@
 from .optical import vec_angle
 from .steps import Step, Straight
 
+if TYPE_CHECKING:
+    from collections.abc import Callable, Sequence
+
+    from ..schematic import Constraint
+    from ..typings import dbu, um
+    from .utils import RouteDebug
+
 __all__ = [
     "place_dual_rails",
     "place_single_wire",
-    "route_L",
     "route_bundle",
     "route_bundle_dual_rails",
     "route_bundle_rf",
     "route_dual_rails",
-    "route_elec",
 ]
 
 
-def route_elec(
-    c: KCell,
-    p1: Port,
-    p2: Port,
-    start_straight: int | None = None,
-    end_straight: int | None = None,
-    route_path_function: ManhattanRoutePathFunction = route_manhattan,
-    width: int | None = None,
-    layer: int | None = None,
-    minimum_straight: int | None = None,
-) -> None:
-    """Connect two ports with a wire.
-
-    A wire is a path object on a usually metal layer.
-
-
-    Args:
-        c: KCell to place the wire in.
-        p1: Beginning
-        p2: End
-        start_straight: Minimum length of straight at start port.
-        end_straight: Minimum length of straight at end port.
-        route_path_function: Function to calculate the path. Signature:
-            `route_path_function(p1, p2, bend90_radius, start_straight,
-            end_straight)`
-        width: Overwrite the width of the wire. Calculated by the width of the start
-            port if `None`.
-        layer: Layer to place the wire on. Calculated from the start port if `None`.
-        minimum_straight: require a minimum straight
-    """
-    logger.opt(depth=2).warning(
-        "`kfactory.routing.electrical.route_elec` is deprecated, please use "
-        "`route_bundle` instead. `route_elec` will be removed in kfactory 3"
-    )
-    c_ = c.to_itype()
-    p1_ = p1.to_itype()
-    p2_ = p2.to_itype()
-    if width is None:
-        width = p1_.width
-    if layer is None:
-        layer = p1.layer
-    if start_straight is None:
-        start_straight = round(width / 2)
-    if end_straight is None:
-        end_straight = round(width / 2)
-
-    if minimum_straight is not None:
-        start_straight = min(minimum_straight // 2, start_straight)
-        end_straight = min(minimum_straight // 2, end_straight)
-
-        pts = route_path_function(
-            p1_.copy(),
-            p2_.copy(),
-            bend90_radius=minimum_straight,
-            start_steps=[Straight(dist=start_straight)],
-            end_steps=[Straight(dist=end_straight)],
-        )
-    else:
-        pts = route_path_function(
-            p1_.copy(),
-            p2_.copy(),
-            bend90_radius=0,
-            start_steps=[Straight(dist=start_straight)],
-            end_steps=[Straight(dist=end_straight)],
-        )
-
-    path = kdb.Path(pts, width)
-    c_.shapes(layer).insert(path.polygon())
-
-
-def route_L(  # noqa: N802
-    c: KCell,
-    input_ports: Sequence[Port],
-    output_orientation: int = 1,
-    wire_spacing: int = 10000,
-) -> list[Port]:
-    """Route ports towards a bundle in an L shape.
-
-    This function takes a list of input ports and assume they are oriented in the west.
-    The output will be a list of ports that have the same y coordinates.
-    The function will produce a L-shape routing to connect input ports to output ports
-    without any crossings.
-    """
-    logger.opt(depth=2).warning(
-        "`kfactory.routing.electrical.route_L` is deprecated, please use `route_bundle`"
-        " instead. `route_L` will be removed in kfactory 3"
-    )
-    input_ports_ = [p.to_itype() for p in input_ports]
-    c_ = c.to_itype()
-    input_ports_.sort(key=lambda p: p.y)
-
-    y_max = input_ports_[-1].y
-    y_min = input_ports_[0].y
-    x_max = max(p.x for p in input_ports_)
-
-    output_ports: list[Port] = []
-    if output_orientation == 1:
-        for i, p in enumerate(input_ports_[::-1]):
-            temp_port = p.copy()
-            temp_port.trans = kdb.Trans(
-                3, False, x_max - wire_spacing * (i + 1), y_max + wire_spacing
-            )
-
-            route_elec(c_, p, temp_port)
-            temp_port.trans.angle = 1
-            output_ports.append(temp_port)
-    elif output_orientation == ANGLE_270:
-        for i, p in enumerate(input_ports_):
-            temp_port = p.copy()
-            temp_port.trans = kdb.Trans(
-                1, False, x_max - wire_spacing * (i + 1), y_min - wire_spacing
-            )
-            route_elec(c_, p, temp_port)
-            temp_port.trans.angle = 3
-            output_ports.append(temp_port)
-    else:
-        raise ValueError(
-            "Invalid L-shape routing. Please change output_orientaion to 1 or 3."
-        )
-    return output_ports
-
-
 @overload
 def route_bundle(
     c: KCell,
     start_ports: Sequence[Port],
     end_ports: Sequence[Port],
     separation: dbu,
-    start_straights: dbu | list[dbu] = 0,
-    end_straights: dbu | list[dbu] = 0,
     place_layer: kdb.LayerInfo | None = None,
     route_width: dbu | list[dbu] | None = None,
     bboxes: Sequence[kdb.Box] | None = None,
@@ -186,6 +66,8 @@ def route_bundle(
     start_angles: int | list[int] | None = None,
     end_angles: int | list[int] | None = None,
     purpose: str | None = "routing",
+    constraints: Sequence[Constraint] | None = None,
+    route_debug: RouteDebug | None = None,
 ) -> list[ManhattanRoute]: ...
 
 
@@ -195,8 +77,6 @@ def route_bundle(
     start_ports: Sequence[DPort],
     end_ports: Sequence[DPort],
     separation: um,
-    start_straights: um | list[um] = 0,
-    end_straights: um | list[um] = 0,
     place_layer: kdb.LayerInfo | None = None,
     route_width: um | list[um] | None = None,
     bboxes: Sequence[kdb.DBox] | None = None,
@@ -211,6 +91,8 @@ def route_bundle(
     start_angles: float | list[float] | None = None,
     end_angles: float | list[float] | None = None,
     purpose: str | None = "routing",
+    constraints: Sequence[Constraint] | None = None,
+    route_debug: RouteDebug | None = None,
 ) -> list[ManhattanRoute]: ...
 
 
@@ -219,8 +101,6 @@ def route_bundle(
     start_ports: Sequence[Port] | Sequence[DPort],
     end_ports: Sequence[Port] | Sequence[DPort],
     separation: dbu | um,
-    start_straights: dbu | list[dbu] | um | list[um] = 0,
-    end_straights: dbu | list[dbu] | um | list[um] = 0,
     place_layer: kdb.LayerInfo | None = None,
     route_width: dbu | um | list[dbu] | list[um] | None = None,
     bboxes: Sequence[kdb.Box] | Sequence[kdb.DBox] | None = None,
@@ -245,6 +125,8 @@ def route_bundle(
     start_angles: list[int] | float | list[float] | None = None,
     end_angles: list[int] | float | list[float] | None = None,
     purpose: str | None = "routing",
+    constraints: Sequence[Constraint] | None = None,
+    route_debug: RouteDebug | None = None,
 ) -> list[ManhattanRoute]:
     r"""Connect multiple input ports to output ports.
 
@@ -303,8 +185,6 @@ def route_bundle(
         separation: Minimum space between wires. [dbu]
         starts: Minimal straight segment after `start_ports`.
         ends: Minimal straight segment before `end_ports`.
-        start_straights: Deprecated, use starts instead.
-        end_straights: Deprecated, use ends instead.
         place_layer: Override automatic detection of layers with specific layer.
         route_width: Width of the route. If None, the width of the ports is used.
         bboxes: List of boxes to consider. Currently only boxes overlapping ports will
@@ -341,12 +221,15 @@ def route_bundle(
     if bboxes is None:
         bboxes = []
 
+    start_ports_ = [p.base.model_copy() for p in start_ports]
+    end_ports_ = [p.base.model_copy() for p in end_ports]
+
     if isinstance(c, KCell):
         try:
             return route_bundle_generic(
                 c=c,
-                start_ports=[p.base for p in start_ports],
-                end_ports=[p.base for p in end_ports],
+                start_ports=start_ports_,
+                end_ports=end_ports_,
                 starts=cast("dbu | list[dbu] | list[Step] | list[list[Step]]", starts),
                 ends=cast("dbu | list[dbu] | list[Step] | list[list[Step]]", ends),
                 routing_function=route_smart,
@@ -362,13 +245,14 @@ def route_bundle(
                 placer_kwargs={
                     "route_width": route_width,
                 },
-                sort_ports=sort_ports,
                 on_collision=on_collision,
                 on_placer_error=on_placer_error,
                 collision_check_layers=collision_check_layers,
                 start_angles=cast("int | list[int] | None", start_angles),
                 end_angles=cast("int | list[int]", end_angles),
                 route_width=cast("int", route_width),
+                constraints=constraints,
+                route_debug=route_debug,
             )
         except ValueError as e:
             if str(e).startswith("Found non-manhattan waypoints."):
@@ -392,15 +276,16 @@ def route_bundle(
                     )
                 if on_placer_error == "show_error":
                     c_: KCell | DKCell = c.dup()
-                    c_.name = c.kcl.future_cell_name or c.name
+                    c_.name = c.kcl._future_cell_name or c.name
                     db = rdb.ReportDatabase("Routing Waypoint Errors")
                     err_cat = db.create_category("Waypoint Error")
                     wp_cat = db.create_category("Waypoints")
                     cell = db.create_cell(c_.name)
                     wp_len = len(waypoints)
 
-                    width = cast("int | None", route_width) or cast(
-                        "int", start_ports[0].width
+                    width = (
+                        cast("int | None", route_width)
+                        or Port(base=start_ports_[0]).width
                     )
 
                     for i, wp in enumerate(waypoints):
@@ -446,13 +331,13 @@ def route_bundle(
         starts = c.kcl.to_dbu(starts)
     elif isinstance(starts, list):
         if isinstance(starts[0], int | float):
-            starts = [c.kcl.to_dbu(start) for start in starts]  # type: ignore[arg-type]
+            starts = [c.kcl.to_dbu(cast("int|float", start)) for start in starts]
         starts = cast("int | list[int] | list[Step] | list[list[Step]]", starts)
     if isinstance(ends, int | float):
         ends = c.kcl.to_dbu(ends)
     elif isinstance(ends, list):
         if isinstance(ends[0], int | float):
-            ends = [c.kcl.to_dbu(end) for end in ends]  # type: ignore[arg-type]
+            ends = [c.kcl.to_dbu(cast("int|float", end)) for end in ends]
         ends = cast("int | list[int] | list[Step] | list[list[Step]]", ends)
     if waypoints is not None:
         if isinstance(waypoints, list):
@@ -464,8 +349,8 @@ def route_bundle(
     try:
         return route_bundle_generic(
             c=c.kcl[c.cell_index()],
-            start_ports=[p.base for p in start_ports],
-            end_ports=[p.base for p in end_ports],
+            start_ports=start_ports_,
+            end_ports=end_ports_,
             starts=starts,
             ends=ends,
             routing_function=route_smart,
@@ -484,13 +369,14 @@ def route_bundle(
                 "route_width": route_width,
                 "layer_info": place_layer,
             },
-            sort_ports=sort_ports,
             on_collision=on_collision,
             on_placer_error=on_placer_error,
             collision_check_layers=collision_check_layers,
             start_angles=start_angles,
             end_angles=end_angles,
             route_width=route_width,
+            constraints=constraints,
+            route_debug=route_debug,
         )
     except ValueError as e:
         if str(e).startswith("Found non-manhattan waypoints."):
@@ -514,15 +400,16 @@ def route_bundle(
                 )
             if on_placer_error == "show_error":
                 c_ = c.dup()
-                c_.name = c.kcl.future_cell_name or c.name
+                c_.name = c.kcl._future_cell_name or c.name
                 db = rdb.ReportDatabase("Routing Waypoint Errors")
                 err_cat = db.create_category("Waypoint Error")
                 wp_cat = db.create_category("Waypoints")
                 cell = db.create_cell(c_.name)
                 wp_len = len(waypoints)
 
-                width_d = cast("float | None", route_width) or cast(
-                    "float", start_ports[0].width
+                width_d = (
+                    cast("float | None", route_width)
+                    or DPort(base=start_ports_[0]).width
                 )
 
                 for i, wp_d in enumerate(waypoints):
@@ -547,8 +434,6 @@ def route_bundle_dual_rails(
     start_ports: list[Port],
     end_ports: list[Port],
     separation: dbu,
-    start_straights: dbu | list[dbu] | None = None,
-    end_straights: dbu | list[dbu] | None = None,
     place_layer: kdb.LayerInfo | None = None,
     width_rails: dbu | None = None,
     separation_rails: dbu | None = None,
@@ -563,6 +448,8 @@ def route_bundle_dual_rails(
     ends: dbu | list[dbu] | list[Step] | list[list[Step]] | None = None,
     start_angles: int | list[int] | None = None,
     end_angles: int | list[int] | None = None,
+    constraints: Sequence[Constraint] | None = None,
+    route_debug: RouteDebug | None = None,
 ) -> list[ManhattanRoute]:
     r"""Connect multiple input ports to output ports.
 
@@ -621,8 +508,6 @@ def route_bundle_dual_rails(
         separation: Minimum space between wires. [dbu]
         starts: Minimal straight segment after `start_ports`.
         ends: Minimal straight segment before `end_ports`.
-        start_straights: Deprecated, use starts instead.
-        end_straights: Deprecated, use ends instead.
         place_layer: Override automatic detection of layers with specific layer.
         width_rails: Total width of the rails.
         separation_rails: Separation between the two rails.
@@ -658,12 +543,6 @@ def route_bundle_dual_rails(
         starts = []
     if bboxes is None:
         bboxes = []
-    if start_straights is not None:
-        logger.warning("start_straights is deprecated. Use `starts` instead.")
-        starts = start_straights
-    if end_straights is not None:
-        logger.warning("end_straights is deprecated. Use `starts` instead.")
-        ends = end_straights
     try:
         return route_bundle_generic(
             c=c,
@@ -686,12 +565,13 @@ def route_bundle_dual_rails(
                 "route_width": width_rails,
                 "layer_info": place_layer,
             },
-            sort_ports=sort_ports,
             on_collision=on_collision,
             on_placer_error=on_placer_error,
             collision_check_layers=collision_check_layers,
             start_angles=start_angles,
             end_angles=end_angles,
+            constraints=constraints,
+            route_debug=route_debug,
         )
     except ValueError as e:
         if str(e).startswith("Found non-manhattan waypoints."):
@@ -715,7 +595,7 @@ def route_bundle_dual_rails(
                 )
             if on_placer_error == "show_error":
                 c_: KCell | DKCell = c.dup()
-                c_.name = c.kcl.future_cell_name or c.name
+                c_.name = c.kcl._future_cell_name or c.name
                 db = rdb.ReportDatabase("Routing Waypoint Errors")
                 err_cat = db.create_category("Waypoint Error")
                 wp_cat = db.create_category("Waypoints")
@@ -989,13 +869,14 @@ def place_rf_rails(
         SymmetricalCrossSection(
             width=p1.width,
             enclosure=enclosure or LayerEnclosure(sections=[], main_layer=layer_info),
-        )
+        ),
+        symmetrical=True,
     )
     route_start_port = p1.copy()
-    route_start_port.name = None
+    route_start_port.name = "route_start"
     route_start_port.trans.angle = (route_start_port.angle + 2) % 4
     route_end_port = p2.copy()
-    route_end_port.name = None
+    route_end_port.name = "route_end"
     route_end_port.trans.angle = (route_end_port.angle + 2) % 4
 
     old_pt = pts[0]
@@ -1125,7 +1006,7 @@ def place_rf_rails(
         )
         route.instances.append(wg)
         route.start_port = Port(base=wg_p1.base.transformed())
-        route.start_port.name = None
+        route.start_port.name = "route_start"
         route.length_straights += int(length)
         return route
     for i in range(1, len(pts) - 1):
@@ -1160,7 +1041,7 @@ def place_rf_rails(
         if d_angle == 1:
             bend90 = c << bend_factory(cross_section=cross_section, radius=inner_radius)
             b90c = b90c_inner
-        elif d_angle == 3:  # noqa: PLR2004
+        elif d_angle == 3:
             bend90 = c << bend_factory(cross_section=cross_section, radius=outer_radius)
             b90c = b90c_outer
         else:
@@ -1219,12 +1100,12 @@ def place_rf_rails(
         )
         route.instances.append(wg)
         route.end_port = wg.ports[wg_p2.name].copy()
-        route.end_port.name = None
+        route.end_port.name = "route_end"
         route.length_straights += int(length)
 
     else:
         route.end_port = old_bend_port.copy()
-        route.end_port.name = None
+        route.end_port.name = "route_end"
     return route
 
 
@@ -1260,6 +1141,8 @@ def route_bundle_rf(
     end_angles: list[int] | float | list[float] | None = None,
     purpose: str | None = "routing",
     minimum_radius: int = 0,
+    constraints: Sequence[Constraint] | None = None,
+    route_debug: RouteDebug | None = None,
     *,
     layer: kdb.LayerInfo,
     enclosure: LayerEnclosure | None = None,
@@ -1401,7 +1284,6 @@ def route_bundle_rf(
         starts=cast("dbu | list[dbu] | list[Step] | list[list[Step]]", starts),
         ends=cast("dbu | list[dbu] | list[Step] | list[list[Step]]", ends),
         route_width=None,
-        sort_ports=sort_ports,
         on_collision=on_collision,
         on_placer_error=on_placer_error,
         collision_check_layers=collision_check_layers,
@@ -1432,4 +1314,6 @@ def route_bundle_rf(
         },
         start_angles=cast("list[int] | int", start_angles),
         end_angles=cast("list[int] | int", end_angles),
+        constraints=constraints,
+        route_debug=route_debug,
     )
diff --git a/src/kfactory/routing/generic.py b/src/kfactory/routing/generic.py
index 69f253210..58c93eb50 100644
--- a/src/kfactory/routing/generic.py
+++ b/src/kfactory/routing/generic.py
@@ -3,7 +3,7 @@
 from __future__ import annotations
 
 from collections import defaultdict
-from typing import TYPE_CHECKING, Any, Literal, Protocol, cast
+from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeGuard, cast
 
 import klayout.db as kdb
 from klayout import rdb
@@ -25,6 +25,8 @@
     from collections.abc import Sequence
 
     from ..kcell import KCell
+    from ..schematic import Constraint
+    from .utils import RouteDebug
 
 __all__ = [
     "ManhattanRoute",
@@ -55,22 +57,6 @@ def __call__(
         ...
 
 
-class RouterPostProcessFunction(Protocol):
-    """A function that can be used to post process functions."""
-
-    def __call__(
-        self,
-        *,
-        c: KCell,
-        routers: Sequence[ManhattanRouter],
-        start_ports: Sequence[BasePort],
-        end_ports: Sequence[BasePort],
-        **kwargs: Any,
-    ) -> None:
-        """Implementation of post process function."""
-        ...
-
-
 class ManhattanRoute(BaseModel, arbitrary_types_allowed=True):
     """Optical route containing a connection between two ports.
 
@@ -155,12 +141,12 @@ def check_collisions(
     if collision_edges or not inter_route_collisions.is_empty():
         if collision_check_layers is None:
             collision_check_layers = list(
-                {p.cross_section.main_layer for p in start_ports}
+                {p.any_cross_section.main_layer for p in start_ports}
             )
         dbu = c.kcl.dbu
         db = rdb.ReportDatabase("Routing Errors")
         cat = db.create_category("Manhattan Routing Collisions")
-        c.name = c.kcl.future_cell_name or c.name
+        c.name = c.kcl._future_cell_name or c.name
         cell = db.create_cell(c.name)
         for name, edges in collision_edges.items():
             item = db.create_item(cell, cat)
@@ -256,11 +242,11 @@ def layer_cat(layer_info: kdb.LayerInfo) -> rdb.RdbCategory:
                 case "show_error":
                     c.show(lyrdb=db)
                     raise RuntimeError(
-                        f"Routing collision in {c.kcl.future_cell_name or c.name}"
+                        f"Routing collision in {c.kcl._future_cell_name or c.name}"
                     )
                 case "error":
                     raise RuntimeError(
-                        f"Routing collision in {c.kcl.future_cell_name or c.name}"
+                        f"Routing collision in {c.kcl._future_cell_name or c.name}"
                     )
 
 
@@ -307,7 +293,6 @@ def route_bundle(
     start_ports: list[BasePort],
     end_ports: list[BasePort],
     route_width: dbu | list[dbu] | None = None,
-    sort_ports: bool = False,
     on_collision: Literal["error", "show_error"] | None = "show_error",
     on_placer_error: Literal["error", "show_error"] | None = "show_error",
     collision_check_layers: Sequence[kdb.LayerInfo] | None = None,
@@ -315,12 +300,13 @@ def route_bundle(
     routing_kwargs: dict[str, Any] | None = None,
     placer_function: PlacerFunction,
     placer_kwargs: dict[str, Any] | None = None,
-    router_post_process_function: RouterPostProcessFunction | None = None,
-    router_post_process_kwargs: Any = None,
+    constraints: Sequence[Constraint] | None = None,
     starts: dbu | list[dbu] | list[Step] | list[list[Step]] | None = None,
     ends: dbu | list[dbu] | list[Step] | list[list[Step]] | None = None,
     start_angles: int | list[int] | None = None,
     end_angles: int | list[int] | None = None,
+    route_debug: RouteDebug | None = None,
+    route_name: str | None = None,
 ) -> list[ManhattanRoute]:
     r"""Route a bundle from starting ports to end_ports.
 
@@ -399,10 +385,9 @@ def route_bundle(
             )
             ```
         placer_kwargs: Additional kwargs passed to the placer_function.
-        router_post_process_function: Function used to modify the routers returned by
-            the routing function. This is particularly useful for operations such as
-            path length matching.
-        router_post_process_kwargs: Kwargs for router_post_process_function.
+        constraints: Routing constraints to enforce after routing but before placement.
+            Each constraint's `enforce` method is called with the routers and routing
+            kwargs (e.g. separation, bend90_radius).
         starts: List of steps to use on each starting port or all of them.
         ends: List of steps to use on each end port or all of them.
         start_angles: Overwrite the port orientation of all start_ports together
@@ -422,12 +407,12 @@ def route_bundle(
         ends = []
     if starts is None:
         starts = []
-    if router_post_process_kwargs is None:
-        router_post_process_kwargs = {}
     if placer_kwargs is None:
         placer_kwargs = {}
     if routing_kwargs is None:
         routing_kwargs = {"bbox_routing": "minimal"}
+    if route_debug is not None:
+        routing_kwargs["route_debug"] = route_debug
     if not start_ports:
         return []
     if not (len(start_ports) == len(end_ports)):
@@ -436,18 +421,26 @@ def route_bundle(
             " the same size as the end ports and be the same length."
         )
     length = len(start_ports)
-    if starts == []:
-        starts = [starts] * length  # type: ignore[assignment]
+    if starts is None or starts == []:
+        starts = [[]] * length
     elif isinstance(starts, int):
-        starts = [[Straight(dist=starts)] for _ in range(length)]  # type: ignore[assignment]
-    elif isinstance(starts[0], Step):
-        starts = [starts for _ in range(len(start_ports))]  # type: ignore[assignment]
-    if ends == []:
-        ends = [ends] * length  # type: ignore[assignment]
+        starts = [[Straight(dist=starts)] for _ in range(length)]
+    elif isinstance(starts, list):
+        if _is_steps_list(starts):
+            starts = [starts for _ in range(len(start_ports))]
+        else:
+            starts = cast("list[int]", starts)
+            starts = [[Straight(dist=s) for s in starts]] * len(start_ports)
+    if ends is None or ends == []:
+        ends = [[]] * length
     elif isinstance(ends, int):
-        ends = [[Straight(dist=ends)] for _ in range(length)]  # type: ignore[assignment]
-    elif isinstance(ends[0], Step):
-        ends = [ends for _ in range(len(start_ports))]  # type: ignore[assignment]
+        ends = [[Straight(dist=ends)] for _ in range(length)]
+    elif isinstance(ends, list):
+        if _is_steps_list(ends):
+            ends = [ends for _ in range(len(end_ports))]
+        else:
+            ends = cast("list[int]", ends)
+            ends = [[Straight(dist=e) for e in ends]] * len(end_ports)
 
     if start_angles is not None:
         if isinstance(start_angles, int):
@@ -480,7 +473,7 @@ def route_bundle(
                 )
             end_ports = [
                 p.transformed(post_trans=kdb.Trans(a - p.get_trans().angle))
-                for a, p in zip(end_angles, start_ports, strict=False)
+                for a, p in zip(end_angles, end_ports, strict=False)
             ]
 
     if route_width:
@@ -489,14 +482,14 @@ def route_bundle(
         else:
             widths = route_width
     else:
-        widths = [p.cross_section.width for p in start_ports]
+        widths = [p.any_cross_section.width for p in start_ports]
 
     routers = routing_function(
         start_ports=start_ports,
         end_ports=end_ports,
         widths=widths,
-        starts=cast("list[list[Step]]", starts),
-        ends=cast("list[list[Step]]", ends),
+        starts=starts,
+        ends=ends,
         **routing_kwargs,
     )
 
@@ -515,14 +508,13 @@ def route_bundle(
         start_ports.append(sp)
         end_ports.append(ep)
 
-    if router_post_process_function is not None:
-        router_post_process_function(
-            c=c,
-            start_ports=start_ports,
-            end_ports=end_ports,
-            routers=routers,
-            **router_post_process_kwargs,
-        )
+    if constraints:
+        for constraint in constraints:
+            constraint.enforce(
+                c=c,
+                routers=routers,
+                route_name=route_name,
+            )
     placer_errors: list[Exception] = []
     error_routes: list[tuple[BasePort, BasePort, list[kdb.Point], int]] = []
     for router, ps, pe in zip(routers, start_ports, end_ports, strict=False):
@@ -540,7 +532,7 @@ def route_bundle(
             error_routes.append((ps, pe, router.start.pts, router.width))
     if placer_errors and on_placer_error == "show_error":
         db = rdb.ReportDatabase("Route Placing Errors")
-        c.name = c.kcl.future_cell_name or c.name
+        c.name = c.kcl._future_cell_name or c.name
         cell = db.create_cell(c.name)
         for error, (ps, pe, pts, width) in zip(
             placer_errors, error_routes, strict=False
@@ -552,14 +544,14 @@ def route_bundle(
                 f" points (dbu): {pts}"
             )
             it.add_value(f"Exception: {error}")
-            path = kdb.Path(pts, width or ps.cross_section.width)
+            path = kdb.Path(pts, width or ps.any_cross_section.width)
             it.add_value(c.kcl.to_um(path.polygon()))
         c.show(lyrdb=db)
     if placer_errors and on_placer_error is not None:
         for error in placer_errors:
             logger.error(error)
         if c.name.startswith("Unnamed_"):
-            c.name = c.kcl.future_cell_name or c.name
+            c.name = c.kcl._future_cell_name or c.name
         raise PlacerError(
             "Failed to place routes for bundle routing from "
             f"{[p.name for p in start_ports]} to {[p.name for p in end_ports]}"
@@ -574,4 +566,13 @@ def route_bundle(
         routers=routers,
         routes=routes,
     )
+    if constraints:
+        for constraint in constraints:
+            constraint._routes[route_name] = routes
     return routes
+
+
+def _is_steps_list(
+    step_list: list[Step] | list[int] | list[list[Step]],
+) -> TypeGuard[list[Step]]:
+    return isinstance(step_list[0], Step)
diff --git a/src/kfactory/routing/length_functions.py b/src/kfactory/routing/length_functions.py
index 19ec8971d..14408cdee 100644
--- a/src/kfactory/routing/length_functions.py
+++ b/src/kfactory/routing/length_functions.py
@@ -67,7 +67,7 @@ def get_length_(route: ManhattanRoute) -> float:
 def get_length_from_info(
     route: ManhattanRoute, attribute_name: str = "length"
 ) -> int | float:
-    return sum(inst.cell.info[attribute_name] for inst in route.instances)  # type: ignore[no-any-return]
+    return sum(inst.cell.info[attribute_name] for inst in route.instances)
 
 
 def get_length_from_backbone(route: ManhattanRoute) -> int:
diff --git a/src/kfactory/routing/manhattan.py b/src/kfactory/routing/manhattan.py
index 95429dd62..ea95b8478 100644
--- a/src/kfactory/routing/manhattan.py
+++ b/src/kfactory/routing/manhattan.py
@@ -33,8 +33,8 @@
 if TYPE_CHECKING:
     from collections.abc import Iterable, Sequence
 
-    from ..kcell import DKCell, KCell
     from ..layout import KCLayout
+    from .utils import RouteDebug
 
 __all__ = [
     "ManhattanRoutePathFunction",
@@ -89,6 +89,7 @@ def __call__(
         starts: Sequence[Sequence[Step]],
         ends: Sequence[Sequence[Step]],
         widths: Sequence[int] | None = None,
+        route_debug: RouteDebug | None = None,
         **kwargs: Any,
     ) -> list[ManhattanRouter]: ...
 
@@ -185,7 +186,7 @@ def route_manhattan_180(
                 "`case (x, y, 0) if x > 0 and abs(y) == bend180_radius`"
                 " not supported yet"
             )
-        case (x, 0, 2):
+        case (_, 0, 2):
             if start_straight > 0:
                 t1 *= kdb.Trans(0, False, start_straight, 0)
             if end_straight > 0:
@@ -293,7 +294,7 @@ def tv(self) -> kdb.Vector:
 
     @property
     def ta(self) -> Literal[0, 1, 2, 3]:
-        return (self.other.t.angle - self.t.angle) % 4  # type: ignore[return-value]
+        return (self.other.t.angle - self.t.angle) % 4  # ty:ignore[invalid-return-type]
 
     def right(self) -> None:
         self.pts.append(
@@ -338,6 +339,7 @@ class ManhattanRouter:
     """Class to store state of a routing between two ports or transformations."""
 
     bend90_radius: int
+    separation: int
     start_transformation: kdb.Trans
     end_transformation: kdb.Trans
     start: ManhattanRouterSide = field(init=False)
@@ -593,7 +595,6 @@ def route_manhattan(
     bend90_radius: int,
     start_steps: Sequence[Step] | None = None,
     end_steps: Sequence[Step] | None = None,
-    max_tries: int = 20,
     invert: bool = False,
 ) -> list[kdb.Point]:
     """Calculate manhattan route using um based points.
@@ -632,6 +633,7 @@ def route_manhattan(
 
     router = ManhattanRouter(
         bend90_radius=bend90_radius,
+        separation=0,
         start_transformation=t1,
         end_transformation=t2,
         start_steps=start_steps_,
@@ -653,32 +655,20 @@ class PathMatchDict(TypedDict):
 
 def path_length_match_manhattan_route(
     *,
-    c: KCell | DKCell,
     routers: Sequence[ManhattanRouter],
-    start_ports: Sequence[BasePort],
-    end_ports: Sequence[BasePort],
     bend90_radius: int | None = None,
     separation: int | None = None,
     path_length: int | None = None,
-    **kwargs: Any,
 ) -> None:
     """Simple path length matching router postprocess.
 
     Args:
-        c: KCell where the routes are placed into.
         routers: List of the manhattan routers to be modified.
-        start_ports: The start ports of the routes.
-        end_ports: The end ports of the routes.
         bend90_radius: Radius of a bend in the routes.
         separation: Separation between the routes.
         path_length: Match to a certain path length instead of the maximum
             of all routers.
-        kwargs: Compatibility with type checkers. Throws an error if defined.
     """
-    if kwargs:
-        raise ValueError(
-            f"Additional kwargs aren't supported in route_dual_rails {kwargs=}"
-        )
     if bend90_radius is None:
         raise ValueError(
             "bend90_radius must be passed to the function, please pass it"
@@ -703,10 +693,9 @@ def path_length_match_manhattan_route(
         2: [],
         3: [],
     }
-    modify_pts: tuple[kdb.Point, kdb.Point]
 
     for router in routers:
-        modify_pts = tuple(router.start.pts[-2:])  # type: ignore[assignment]
+        modify_pts: tuple[kdb.Point, kdb.Point] = tuple(router.start.pts[-2:])  # ty:ignore[invalid-assignment]
         v = modify_pts[1] - modify_pts[0]
         match (v.x, v.y):
             case (x, 0) if x > 0:
@@ -767,7 +756,6 @@ def path_length_match_manhattan_route(
                     router_group = [(router, settings)]
                 else:
                     router_group.append((router, settings))
-                old_router = router
                 old_settings = settings
                 increasing = increasing_
             if not increasing:
@@ -860,6 +848,7 @@ def route_smart(
     waypoints: Sequence[kdb.Point] | kdb.Trans | None = None,
     bbox_routing: Literal["minimal", "full"] = "minimal",
     allow_sbend: bool = False,
+    route_debug: RouteDebug | None = None,
     **kwargs: Any,
 ) -> list[ManhattanRouter]:
     """Route around start or end bboxes (obstacles on the way not implemented yet).
@@ -925,9 +914,24 @@ def route_smart(
 
     start_ts = [p.get_trans() if isinstance(p, BasePort) else p for p in start_ports]
     end_ts = [p.get_trans() if isinstance(p, BasePort) else p for p in end_ports]
+    start_port_names: dict[kdb.Trans, str] = {}
+    for i, p in enumerate(start_ports):
+        if isinstance(p, BasePort):
+            t = p.get_trans()
+            start_port_names[t] = p.name or f"{i}_{t.disp}"
+        else:
+            start_port_names[p] = f"{i}_{p.disp}"
+    end_port_names: dict[kdb.Trans, str] = {}
+    for i, p in enumerate(end_ports):
+        if isinstance(p, BasePort):
+            t = p.get_trans()
+            end_port_names[t] = p.name or f"{i}_{t.disp}"
+        else:
+            end_port_names[p] = f"{i}_{p.disp}"
     if widths is None:
         widths = [
-            p.cross_section.width if isinstance(p, BasePort) else 0 for p in start_ports
+            p.any_cross_section.width if isinstance(p, BasePort) else 0
+            for p in start_ports
         ]
     box_region = kdb.Region()
     if bboxes:
@@ -964,6 +968,9 @@ def route_smart(
                 sort_ports=True,
                 bbox_routing=bbox_routing,
                 allow_sbends=allow_sbend,
+                route_debug=route_debug,
+                start_port_names=start_port_names,
+                end_port_names=end_port_names,
             )
         default_start_bundle: list[kdb.Trans] = []
         start_bundles: dict[kdb.Box, list[kdb.Trans]] = defaultdict(list)
@@ -972,6 +979,7 @@ def route_smart(
             mh_routers.append(
                 ManhattanRouter(
                     bend90_radius=bend90_radius,
+                    separation=separation,
                     start_transformation=s_t,
                     end_transformation=e_t,
                     start_steps=s,
@@ -1229,6 +1237,7 @@ def route_smart(
             all_routers.append(
                 ManhattanRouter(
                     bend90_radius=bend90_radius,
+                    separation=separation,
                     start_transformation=start_t,
                     end_transformation=end_t,
                     start_steps=ss,
@@ -1253,6 +1262,9 @@ def route_smart(
                 bend90_radius=bend90_radius,
                 sort_ports=False,
                 allow_sbends=allow_sbend,
+                route_debug=route_debug,
+                start_port_names=start_port_names,
+                end_port_names=end_port_names,
             )
 
         all_routers = []
@@ -1262,6 +1274,7 @@ def route_smart(
             all_routers.append(
                 ManhattanRouter(
                     bend90_radius=bend90_radius,
+                    separation=separation,
                     start_transformation=ts,
                     end_transformation=te,
                     start_steps=ss,
@@ -1271,43 +1284,62 @@ def route_smart(
                 )
             )
 
-    router_bboxes: list[kdb.Box] = [
-        kdb.Box(router.start.t.disp.to_p(), router.end.t.disp.to_p()).enlarged(
-            router.width // 2
+    # ── Retry loop for bundle-overlap correction ─────────────────────
+    # After running the bundling + routing logic, check whether any two
+    # bundles' actual routed paths overlap.  If they do, the initial
+    # bundling (which only inspects the straight-line bbox between start
+    # and end ports) missed that their routes share territory.  Force them
+    # to merge by extending the affected routers' bboxes with the overlap
+    # region, reset the routers, and retry.
+    _saved_router_state: list[
+        tuple[kdb.Trans, list[kdb.Point], kdb.Trans, list[kdb.Point], bool]
+    ] = [
+        (
+            r.start.t.dup(),
+            list(r.start.pts),
+            r.end.t.dup(),
+            list(r.end.pts),
+            r.finished,
         )
-        for router in all_routers
+        for r in all_routers
     ]
-    complete_bbox = router_bboxes[0].dup()
-    bundled_bboxes: list[kdb.Box] = []
-    bundled_routers: list[list[ManhattanRouter]] = [[all_routers[0]]]
-    bundle = bundled_routers[0]
-    bundle_bbox = complete_bbox.dup()
-
-    for router, bbox in zip(all_routers[1:], router_bboxes[1:], strict=False):
-        dbrbox = bbox.enlarged(separation + router.width // 2)
-        overlap_box = dbrbox & bundle_bbox
-
-        if overlap_box.empty():
-            overlap_complete = dbrbox & complete_bbox
-            if overlap_complete.empty():
-                bundled_bboxes.append(bundle_bbox)
-                bundle_bbox = bbox.dup()
-                bundle_region = kdb.Region(bundle_bbox)
-                if not (bundle_region & box_region).is_empty():
-                    bundle_bbox += box_region.interacting(bundle_region).bbox()
-                bundle = [router]
-                bundled_routers.append(bundle)
-            else:
-                for i in range(len(bundled_bboxes)):
-                    bundled_bbox = bundled_bboxes[i]
-                    if not (dbrbox & bundled_bbox).empty():
-                        bb = bundled_bboxes[i]
-                        bundled_routers[i].append(router)
-                        bundled_bboxes[i] = bb + bbox
-                        bundle_bbox = bundled_bboxes[i]
-                        bundle = bundled_routers[i]
-                        break
-                else:
+    _router_extra_bbox: list[kdb.Box | None] = [None] * len(all_routers)
+    _max_overlap_retries = 5
+    for _retry_attempt in range(_max_overlap_retries):
+        if _retry_attempt > 0:
+            for _r, (_st, _spts, _et, _epts, _fin) in zip(
+                all_routers, _saved_router_state, strict=False
+            ):
+                _r.start.t = _st
+                _r.start.pts = list(_spts)
+                _r.end.t = _et
+                _r.end.pts = list(_epts)
+                _r.finished = _fin
+        router_bboxes: list[kdb.Box] = [
+            kdb.Box(router.start.t.disp.to_p(), router.end.t.disp.to_p()).enlarged(
+                router.width // 2
+            )
+            for router in all_routers
+        ]
+        # Inflate router_bboxes with any forced-merge bboxes accumulated
+        # from a previous retry due to detected bundle-path overlaps.
+        for _i in range(len(router_bboxes)):
+            _extra = _router_extra_bbox[_i]
+            if _extra is not None:
+                router_bboxes[_i] = router_bboxes[_i] + _extra
+        complete_bbox = router_bboxes[0].dup()
+        bundled_bboxes: list[kdb.Box] = []
+        bundled_routers: list[list[ManhattanRouter]] = [[all_routers[0]]]
+        bundle = bundled_routers[0]
+        bundle_bbox = complete_bbox.dup()
+
+        for router, bbox in zip(all_routers[1:], router_bboxes[1:], strict=False):
+            dbrbox = bbox.enlarged(separation + router.width // 2)
+            overlap_box = dbrbox & bundle_bbox
+
+            if overlap_box.empty():
+                overlap_complete = dbrbox & complete_bbox
+                if overlap_complete.empty():
                     bundled_bboxes.append(bundle_bbox)
                     bundle_bbox = bbox.dup()
                     bundle_region = kdb.Region(bundle_bbox)
@@ -1315,207 +1347,261 @@ def route_smart(
                         bundle_bbox += box_region.interacting(bundle_region).bbox()
                     bundle = [router]
                     bundled_routers.append(bundle)
-                    continue
-        else:
-            bundle.append(router)
-            bundle_bbox += bbox
-        complete_bbox += bbox
-    bundled_bboxes.append(bundle_bbox)
-
-    merge_bboxes: list[tuple[int, int]] = []
-    for i in range(len(bundled_bboxes)):
-        for j in range(i):
-            if not (bundled_bboxes[j] & bundled_bboxes[i]).empty():
-                merge_bboxes.append((i, j))
-                break
-    for i, j in reversed(merge_bboxes):
-        bundled_bboxes[j] = bundled_bboxes[i] + bundled_bboxes[j]
-        bundled_routers[j] = bundled_routers[i] + bundled_routers[j]
-    for i, _ in reversed(merge_bboxes):
-        del bundled_bboxes[i]
-        del bundled_routers[i]
-    for router_bundle in bundled_routers:
-        sorted_routers = _sort_routers(router_bundle)
-
-        # simple (maybe error-prone) way to determine the ideal routing angle
-        angle = router_bundle[0].end.t.angle
-
-        r = router_bundle[0]
-        end_angle = r.end.t.angle
-        re = router_bundle[-1]
-        start_bbox = kdb.Box(r.start.pts[0], re.start.t * _p)
-        end_bbox = kdb.Box(r.end.pts[0], re.end.t * _p)
-        start_bbox += re.start.t * kdb.Point(-1, 0)
-        end_bbox += re.end.t * kdb.Point(-1, 0)
-        for r in router_bundle:
-            start_bbox += kdb.Box(r.start.pts[0], r.start.t.disp.to_p()) + kdb.Box(
-                0, -r.width // 2, 0, r.width // 2
-            ).transformed(r.start.t)
-            end_bbox += kdb.Box(r.end.pts[0], r.end.t.disp.to_p()) + kdb.Box(
-                0, -r.width // 2, 0, r.width // 2
-            ).transformed(r.end.t)
-            if r.end.t.angle != end_angle:
-                raise ValueError(
-                    "All ports at the target (end) must have the same angle. "
-                    f"{r.start.t=}/{r.end.t=}"
+                else:
+                    for i in range(len(bundled_bboxes)):
+                        bundled_bbox = bundled_bboxes[i]
+                        if not (dbrbox & bundled_bbox).empty():
+                            bb = bundled_bboxes[i]
+                            bundled_routers[i].append(router)
+                            bundled_bboxes[i] = bb + bbox
+                            bundle_bbox = bundled_bboxes[i]
+                            bundle = bundled_routers[i]
+                            break
+                    else:
+                        bundled_bboxes.append(bundle_bbox)
+                        bundle_bbox = bbox.dup()
+                        bundle_region = kdb.Region(bundle_bbox)
+                        if not (bundle_region & box_region).is_empty():
+                            bundle_bbox += box_region.interacting(bundle_region).bbox()
+                        bundle = [router]
+                        bundled_routers.append(bundle)
+                        continue
+            else:
+                bundle.append(router)
+                bundle_bbox += bbox
+            complete_bbox += bbox
+        bundled_bboxes.append(bundle_bbox)
+
+        merge_bboxes: list[tuple[int, int]] = []
+        for i in range(len(bundled_bboxes)):
+            for j in range(i):
+                if not (bundled_bboxes[j] & bundled_bboxes[i]).empty():
+                    merge_bboxes.append((i, j))
+                    break
+        for i, j in reversed(merge_bboxes):
+            bundled_bboxes[j] = bundled_bboxes[i] + bundled_bboxes[j]
+            bundled_routers[j] = bundled_routers[i] + bundled_routers[j]
+        for i, _ in reversed(merge_bboxes):
+            del bundled_bboxes[i]
+            del bundled_routers[i]
+        for router_bundle in bundled_routers:
+            sorted_routers = _sort_routers(router_bundle)
+
+            # simple (maybe error-prone) way to determine the ideal routing angle
+            # this would need to be expanded in order to allow for automatic single
+            # waypoint router (without transformation or similar)
+            angle = router_bundle[0].end.t.angle
+
+            r = router_bundle[0]
+            end_angle = r.end.t.angle
+            re = router_bundle[-1]
+            start_bbox = kdb.Box(r.start.pts[0], re.start.t * _p)
+            end_bbox = kdb.Box(r.end.pts[0], re.end.t * _p)
+            start_bbox += re.start.t * kdb.Point(-1, 0)
+            end_bbox += re.end.t * kdb.Point(-1, 0)
+
+            _route_p(
+                sorted_routers=sorted_routers,
+                start_bbox=start_bbox,
+                separation=separation,
+            )
+
+            for r in router_bundle:
+                start_bbox += kdb.Box(r.start.pts[0], r.start.t.disp.to_p()) + kdb.Box(
+                    0, -r.width // 2, 0, r.width // 2
+                ).transformed(r.start.t)
+                end_bbox += kdb.Box(r.end.pts[0], r.end.t.disp.to_p()) + kdb.Box(
+                    0, -r.width // 2, 0, r.width // 2
+                ).transformed(r.end.t)
+                if r.end.t.angle != end_angle:
+                    raise ValueError(
+                        "All ports at the target (end) must have the same angle. "
+                        f"{r.start.t=}/{r.end.t=}"
+                    )
+            if bbox_routing == "minimal":
+                route_to_bbox(
+                    (router.start for router in sorted_routers),
+                    start_bbox,
+                    bbox_routing="full",
+                    separation=separation,
+                )
+                route_to_bbox(
+                    (router.end for router in sorted_routers),
+                    end_bbox,
+                    bbox_routing="full",
+                    separation=separation,
+                )
+
+            if box_region:
+                start_bbox = (
+                    box_region.interacting(kdb.Region(start_bbox)).bbox() + start_bbox
+                )
+                end_bbox = (
+                    box_region.interacting(kdb.Region(end_bbox)).bbox() + end_bbox
                 )
-        if bbox_routing == "minimal":
             route_to_bbox(
                 (router.start for router in sorted_routers),
                 start_bbox,
-                bbox_routing="full",
+                bbox_routing=bbox_routing,
                 separation=separation,
             )
             route_to_bbox(
                 (router.end for router in sorted_routers),
                 end_bbox,
-                bbox_routing="full",
+                bbox_routing=bbox_routing,
                 separation=separation,
             )
+            bb_start2end = kdb.Trans(-angle, False, 0, 0) * start_bbox
+            bb_end2start = kdb.Trans(-angle, False, 0, 0) * end_bbox
 
-        if box_region:
-            start_bbox = (
-                box_region.interacting(kdb.Region(start_bbox)).bbox() + start_bbox
-            )
-            end_bbox = box_region.interacting(kdb.Region(end_bbox)).bbox() + end_bbox
-        route_to_bbox(
-            (router.start for router in sorted_routers),
-            start_bbox,
-            bbox_routing=bbox_routing,
-            separation=separation,
-        )
-        route_to_bbox(
-            (router.end for router in sorted_routers),
-            end_bbox,
-            bbox_routing=bbox_routing,
-            separation=separation,
-        )
-        bb_start2end = kdb.Trans(-angle, False, 0, 0) * start_bbox
-        bb_end2start = kdb.Trans(-angle, False, 0, 0) * end_bbox
+            if bb_start2end.left - bb_end2start.right > bend90_radius + sum(widths):
+                target_angle = (angle - 2) % 4
+            else:
+                target_angle = angle
+                avg = kdb.Vector()
+                end_routers = [r.end for r in sorted_routers]
+                for rs in end_routers:
+                    avg += rs.tv
+                route_to_bbox(
+                    end_routers,
+                    end_bbox,
+                    separation=separation,
+                    bbox_routing=bbox_routing,
+                )
+                _route_to_side(
+                    end_routers,
+                    clockwise=avg.y > 0,
+                    bbox=end_bbox,
+                    separation=separation,
+                    bbox_routing=bbox_routing,
+                )
+                _route_to_side(
+                    end_routers,
+                    clockwise=avg.y > 0,
+                    bbox=end_bbox,
+                    separation=separation,
+                    bbox_routing=bbox_routing,
+                )
+            router_groups: list[tuple[int, list[ManhattanRouter]]] = []
+            group_angle: int | None = None
+            current_group: list[ManhattanRouter] = []
+            for router in sorted_routers:
+                ang = router.start.t.angle
+                if ang != group_angle:
+                    if group_angle is not None:
+                        router_groups.append(
+                            ((group_angle - target_angle) % 4, current_group)
+                        )
+                    group_angle = ang
+                    current_group = []
+                current_group.append(router)
+            if group_angle is not None:
+                router_groups.append(((group_angle - target_angle) % 4, current_group))
+
+            total_bbox = start_bbox
+
+            if len(router_groups) > 1:
+                i = 0
+                rg_angles = [rg[0] for rg in router_groups]
+                traverses0 = False
+                a = rg_angles[0]
+
+                for _a in rg_angles[1:]:
+                    if _a == 0:
+                        continue
+                    if _a <= a:
+                        traverses0 = True
+                    a = _a
+                angle = rg_angles[0]
+
+                # Find out whether we are passing the angle where no side routing is
+                # necessary and if we do, we need to start routing clockwise until we
+                # pass 0. Otherwise test on which side of the bounding box we land
+
+                # Routing clock-wise (the order of the routers, the actual routings are
+                # anti-clockwise and vice-versa)
+
+                if traverses0 or rg_angles[-1] in {0, 3}:
+                    routers_clockwise: list[ManhattanRouter] = router_groups[0][
+                        1
+                    ].copy()
+                    for i in range(1, len(router_groups)):
+                        new_angle, new_routers = router_groups[i]
+                        a = angle
+                        if routers_clockwise:
+                            if traverses0:
+                                while a not in {new_angle, 0}:
+                                    a = (a + 1) % 4
+                                    total_bbox += _route_to_side(
+                                        routers=[
+                                            router.start for router in routers_clockwise
+                                        ],
+                                        clockwise=True,
+                                        bbox=start_bbox,
+                                        separation=separation,
+                                        allow_sbends=a == 0 and allow_sbend,
+                                    )
+                            else:
+                                while a != new_angle:
+                                    a = (a + 1) % 4
+                                    total_bbox += _route_to_side(
+                                        routers=[
+                                            router.start for router in routers_clockwise
+                                        ],
+                                        clockwise=True,
+                                        bbox=start_bbox,
+                                        separation=separation,
+                                        allow_sbends=a == 0 and allow_sbend,
+                                    )
+                        if new_angle <= angle:
+                            if new_angle != 0:
+                                i -= 1  # noqa: PLW2901
+                            break
+                        routers_clockwise.extend(new_routers)
+                        angle = new_angle
+                    else:
+                        a = angle
+                        while a != 0:
+                            a = (a + 1) % 4
+                            total_bbox += _route_to_side(
+                                routers=[router.start for router in routers_clockwise],
+                                clockwise=True,
+                                bbox=start_bbox,
+                                separation=separation,
+                            )
 
-        if bb_start2end.left - bb_end2start.right > bend90_radius + sum(widths):
-            target_angle = (angle - 2) % 4
-        else:
-            target_angle = angle
-            avg = kdb.Vector()
-            end_routers = [r.end for r in sorted_routers]
-            for rs in end_routers:
-                avg += rs.tv
-            route_to_bbox(
-                end_routers, end_bbox, separation=separation, bbox_routing=bbox_routing
-            )
-            _route_to_side(
-                end_routers,
-                clockwise=avg.y > 0,
-                bbox=end_bbox,
-                separation=separation,
-                bbox_routing=bbox_routing,
-            )
-            _route_to_side(
-                end_routers,
-                clockwise=avg.y > 0,
-                bbox=end_bbox,
-                separation=separation,
-                bbox_routing=bbox_routing,
-            )
-        router_groups: list[tuple[int, list[ManhattanRouter]]] = []
-        group_angle: int | None = None
-        current_group: list[ManhattanRouter] = []
-        for router in sorted_routers:
-            ang = router.start.t.angle
-            if ang != group_angle:
-                if group_angle is not None:
-                    router_groups.append(
-                        ((group_angle - target_angle) % 4, current_group)
-                    )
-                group_angle = ang
-                current_group = []
-            current_group.append(router)
-        if group_angle is not None:
-            router_groups.append(((group_angle - target_angle) % 4, current_group))
-
-        total_bbox = start_bbox
-
-        if len(router_groups) > 1:
-            i = 0
-            rg_angles = [rg[0] for rg in router_groups]
-            traverses0 = False
-            a = rg_angles[0]
-
-            for _a in rg_angles[1:]:
-                if _a == 0:
-                    continue
-                if _a <= a:
-                    traverses0 = True
-                a = _a
-            angle = rg_angles[0]
-
-            # Find out whether we are passing the angle where no side routing is
-            # necessary and if we do, we need to start routing clockwise until we
-            # pass 0. Otherwise test on which side of the bounding box we land
-
-            # Routing clock-wise (the order of the routers, the actual routings are
-            # anti-clockwise and vice-versa)
-
-            if traverses0 or rg_angles[-1] in {0, 3}:
-                routers_clockwise: list[ManhattanRouter]
-                routers_clockwise = router_groups[0][1].copy()
-                for i in range(1, len(router_groups)):
-                    new_angle, new_routers = router_groups[i]
-                    a = angle
-                    if routers_clockwise:
-                        if traverses0:
+                # Route the rest of the groups anti-clockwise
+                if i < len(router_groups) - 1:
+                    angle = rg_angles[-1]
+                    routers_anticlockwise: list[ManhattanRouter] = router_groups[-1][
+                        1
+                    ].copy()
+                    n = i
+                    for i in reversed(range(n, len(router_groups) - 1)):
+                        new_angle, new_routers = router_groups[i]
+                        a = angle
+                        if routers_anticlockwise:
                             while a not in {new_angle, 0}:
-                                a = (a + 1) % 4
+                                a = (a - 1) % 4
                                 total_bbox += _route_to_side(
                                     routers=[
-                                        router.start for router in routers_clockwise
+                                        router.start for router in routers_anticlockwise
                                     ],
-                                    clockwise=True,
-                                    bbox=start_bbox,
-                                    separation=separation,
-                                    allow_sbends=a == 0 and allow_sbend,
-                                )
-                        else:
-                            while a != new_angle:
-                                a = (a + 1) % 4
-                                total_bbox += _route_to_side(
-                                    routers=[
-                                        router.start for router in routers_clockwise
-                                    ],
-                                    clockwise=True,
+                                    clockwise=False,
                                     bbox=start_bbox,
                                     separation=separation,
                                     allow_sbends=a == 0 and allow_sbend,
                                 )
-                    if new_angle <= angle:
-                        if new_angle != 0:
-                            i -= 1  # noqa: PLW2901
-                        break
-                    routers_clockwise.extend(new_routers)
-                    angle = new_angle
-                else:
-                    a = angle
-                    while a != 0:
-                        a = (a + 1) % 4
-                        total_bbox += _route_to_side(
-                            routers=[router.start for router in routers_clockwise],
-                            clockwise=True,
-                            bbox=start_bbox,
-                            separation=separation,
-                        )
-
-            # Route the rest of the groups anti-clockwise
-            if i < len(router_groups) - 1:
-                angle = rg_angles[-1]
-                routers_anticlockwise: list[ManhattanRouter]
-                routers_anticlockwise = router_groups[-1][1].copy()
-                n = i
-                for i in reversed(range(n, len(router_groups) - 1)):
-                    new_angle, new_routers = router_groups[i]
-                    a = angle
-                    if routers_anticlockwise:
-                        while a not in {new_angle, 0}:
+                        if new_angle == 0:
+                            routers_anticlockwise.extend(new_routers)
+                            break
+                        if new_angle >= angle:
+                            break
+                        routers_anticlockwise.extend(new_routers)
+                        angle = new_angle
+                    else:
+                        a = angle
+                        while a != 0:
                             a = (a - 1) % 4
                             total_bbox += _route_to_side(
                                 routers=[
@@ -1526,72 +1612,139 @@ def route_smart(
                                 separation=separation,
                                 allow_sbends=a == 0 and allow_sbend,
                             )
-                    if new_angle == 0:
-                        routers_anticlockwise.extend(new_routers)
-                        break
-                    if new_angle >= angle:
-                        break
-                    routers_anticlockwise.extend(new_routers)
-                    angle = new_angle
-                else:
-                    a = angle
-                    while a != 0:
-                        a = (a - 1) % 4
-                        total_bbox += _route_to_side(
-                            routers=[router.start for router in routers_anticlockwise],
-                            clockwise=False,
-                            bbox=start_bbox,
+                route_to_bbox(
+                    [router.start for router in sorted_routers],
+                    total_bbox,
+                    bbox_routing=bbox_routing,
+                    separation=separation,
+                )
+                route_loosely(
+                    sorted_routers,
+                    separation=separation,
+                    start_bbox=total_bbox,
+                    end_bbox=end_bbox,
+                    bbox_routing=bbox_routing,
+                    allow_sbend=allow_sbend,
+                )
+            else:
+                routers = router_groups[0][1]
+                r = routers[0]
+                match (target_angle - r.start.t.angle) % 4:
+                    case 2:
+                        total_bbox = _route_to_side(
+                            [r.start for r in routers],
+                            clockwise=routers[0].start.tv.y > 0,
+                            bbox=total_bbox,
                             separation=separation,
-                            allow_sbends=a == 0 and allow_sbend,
                         )
-            route_to_bbox(
-                [router.start for router in sorted_routers],
-                total_bbox,
-                bbox_routing=bbox_routing,
-                separation=separation,
-            )
-            route_loosely(
-                sorted_routers,
-                separation=separation,
-                start_bbox=total_bbox,
-                end_bbox=end_bbox,
-                bbox_routing=bbox_routing,
-                allow_sbend=allow_sbend,
-            )
-        else:
-            routers = router_groups[0][1]
-            r = routers[0]
-            match (target_angle - r.start.t.angle) % 4:
-                case 2:
-                    total_bbox = _route_to_side(
-                        [r.start for r in routers],
-                        clockwise=routers[0].start.tv.y > 0,
-                        bbox=total_bbox,
-                        separation=separation,
+                        total_bbox = _route_to_side(
+                            [r.start for r in routers],
+                            clockwise=routers[0].start.tv.y > 0,
+                            bbox=total_bbox,
+                            separation=separation,
+                            allow_sbends=allow_sbend,
+                        )
+                    case _:
+                        ...
+                route_to_bbox(
+                    [router.start for router in router_bundle],
+                    total_bbox,
+                    bbox_routing=bbox_routing,
+                    separation=separation,
+                )
+                route_loosely(
+                    routers,
+                    separation=separation,
+                    start_bbox=total_bbox,
+                    end_bbox=end_bbox,
+                    bbox_routing=bbox_routing,
+                    allow_sbend=allow_sbend,
+                )
+
+        # Check whether any two bundles' routed paths overlap.  If so,
+        # extend the affected routers' router_bbox via _router_extra_bbox
+        # so the next attempt will bundle them together.
+        _bundle_regions: list[kdb.Region] = []
+        for _bundle in bundled_routers:
+            _region = kdb.Region()
+            for _router in _bundle:
+                _pts = list(_router.start.pts) + list(reversed(_router.end.pts))
+                if len(_pts) >= 2:
+                    _path = kdb.Path(_pts, _router.width)
+                    _region.insert(_path.polygon())
+            _bundle_regions.append(_region)
+        _found_overlap = False
+        for _bi in range(len(_bundle_regions)):
+            for _bj in range(_bi + 1, len(_bundle_regions)):
+                _inter = _bundle_regions[_bi] & _bundle_regions[_bj]
+                if not _inter.is_empty():
+                    _overlap_bbox = _inter.bbox()
+                    for _router in bundled_routers[_bi] + bundled_routers[_bj]:
+                        _idx = all_routers.index(_router)
+                        _existing = _router_extra_bbox[_idx]
+                        if _existing is None:
+                            _router_extra_bbox[_idx] = _overlap_bbox.dup()
+                        else:
+                            _router_extra_bbox[_idx] = _existing + _overlap_bbox
+                    _found_overlap = True
+        if not _found_overlap:
+            break
+
+    if route_debug is not None:
+        for router in all_routers:
+            p_end_t = router.end.t.disp.to_p()
+
+            pt1 = router.start.pts[0]
+            for i in range(1, len(router.start.pts)):
+                pt2 = router.start.pts[i]
+                e = kdb.Edge(pt1, pt2)
+                if e.contains(p_end_t):
+                    if e.p1 == p_end_t:
+                        start_pts = router.start.pts[: i + 1]
+                        end_pts = router.start.pts[i:]
+                    elif e.p2 == p_end_t:
+                        start_pts = router.start.pts[: i + 2]
+                        end_pts = router.start.pts[i + 1 :]
+                        if e.p2 == router.start.pts[-1]:
+                            end_pts.append(router.start.pts[-1])
+                    else:
+                        start_pts = [*router.start.pts[:i], p_end_t]
+                        end_pts = [p_end_t, *router.start.pts[i:]]
+
+                    fan_in_name = (
+                        start_port_names.get(router.start_transformation, "")
+                        if start_port_names
+                        else ""
                     )
-                    total_bbox = _route_to_side(
-                        [r.start for r in routers],
-                        clockwise=routers[0].start.tv.y > 0,
-                        bbox=total_bbox,
-                        separation=separation,
-                        allow_sbends=allow_sbend,
+                    route_debug.fan_in_region.insert(
+                        kdb.PathWithProperties(
+                            kdb.Path(start_pts, router.width),
+                            {
+                                0: kdb.Text(
+                                    f"fan_in - {fan_in_name}",
+                                    router.start_transformation,
+                                ).to_s()
+                            },
+                        )
                     )
-                case _:
-                    ...
-            route_to_bbox(
-                [router.start for router in router_bundle],
-                total_bbox,
-                bbox_routing=bbox_routing,
-                separation=separation,
-            )
-            route_loosely(
-                routers,
-                separation=separation,
-                start_bbox=total_bbox,
-                end_bbox=end_bbox,
-                bbox_routing=bbox_routing,
-                allow_sbend=allow_sbend,
-            )
+                    fan_out_name = (
+                        end_port_names.get(router.end_transformation, "")
+                        if end_port_names
+                        else ""
+                    )
+                    route_debug.fan_out_region.insert(
+                        kdb.PathWithProperties(
+                            kdb.Path(end_pts, router.width),
+                            {
+                                0: kdb.Text(
+                                    f"fan_out - {fan_out_name}",
+                                    router.end_transformation,
+                                ).to_s()
+                            },
+                        )
+                    )
+                    break
+                pt1 = pt2
 
     return all_routers
 
@@ -1645,6 +1798,138 @@ def route_to_bbox(
             )
 
 
+def _route_group(
+    router_groups: list[list[ManhattanRouter]],
+    separation: int,
+    bbox: kdb.Box,
+    reverse: bool = False,
+) -> None:
+    for router_group in router_groups:
+        delta = 0
+        routers = reversed(router_group) if reverse else iter(router_group)
+        for router in routers:
+            if not router.finished:
+                router.start.straight(delta)
+                delta += router.width + separation
+                router.auto_route(bbox=bbox)
+
+
+def _route_p(
+    sorted_routers: Sequence[ManhattanRouter],
+    start_bbox: kdb.Box,
+    separation: int,
+) -> None:
+    _route_p_side(
+        sorted_routers=sorted_routers,
+        start_bbox=start_bbox,
+        separation=separation,
+        reverse_order=False,
+    )
+    _route_p_side(
+        sorted_routers=sorted_routers,
+        start_bbox=start_bbox,
+        separation=separation,
+        reverse_order=True,
+    )
+
+
+def _route_p_side(
+    sorted_routers: Sequence[ManhattanRouter],
+    start_bbox: kdb.Box,
+    separation: int,
+    reverse_order: bool,
+) -> None:
+
+    box = kdb.Box()
+    extend: int = 0
+
+    if reverse_order:
+        _sorted_routers = list(reversed(sorted_routers))
+    else:
+        _sorted_routers = list(sorted_routers)
+
+    for i, r in enumerate(_sorted_routers):
+        if r.start.ta == 0:
+            v = r.start.tv
+            br = r.bend90_radius
+            p1 = r.start.t.disp.to_p()
+            if not box.empty():
+                match r.start.t.angle:
+                    case 0 | 2:
+                        p = kdb.Point(box.left, p1.y)
+                    case _:
+                        p = kdb.Point(p1.x, box.bottom)
+                contains_p = box.contains(p)
+            else:
+                contains_p = box.contains(p1)
+            ws = r.width // 2 + separation
+            if reverse_order:
+                comparison = v.y >= 0
+                d_p = kdb.Point(br, -(br + ws))
+            else:
+                comparison = v.y < 0
+                d_p = kdb.Point(br, (br + ws))
+
+            if contains_p or ((abs(v.y) < 2 * br) and comparison):
+                route_to_bbox([r.start], start_bbox, separation, bbox_routing="full")
+
+                br = 2 * r.bend90_radius
+
+                box += kdb.Box(p1, r.start.t * d_p).enlarged(r.width // 2 + separation)
+                extend += 1
+            else:
+                _box = start_bbox.dup()
+                for j in range(i - 1, i - extend - 1, -1):
+                    r_ = _sorted_routers[j]
+                    route_to_bbox([r_.start], _box, separation, bbox_routing="full")
+                    v = r_.start.tv
+
+                    _box += r_.start.t * kdb.Point(r_.width + separation, 0)
+
+                    if v.y < 0:
+                        r_.start.left()
+                        r_.start.right()
+                    else:
+                        r_.start.right()
+                        r_.start.left()
+
+                box = kdb.Box()
+                extend = 0
+        else:
+            _box = start_bbox.dup()
+            for j in range(i - 1, i - extend - 1, -1):
+                r_ = _sorted_routers[j]
+                route_to_bbox([r_.start], _box, separation, bbox_routing="full")
+                v = r_.start.tv
+
+                _box += r_.start.t * kdb.Point(r_.width + separation, 0)
+
+                if v.y < 0:
+                    r_.start.left()
+                    r_.start.right()
+                else:
+                    r_.start.right()
+                    r_.start.left()
+
+            box = kdb.Box()
+            extend = 0
+    _box = start_bbox.dup()
+    j_range: range | reversed[int] = range(i, i - extend, -1)
+    for j in j_range:
+        r_ = _sorted_routers[j]
+        route_to_bbox([r_.start], _box, separation, bbox_routing="full")
+        v = r_.start.tv
+
+        _box += r_.start.t * kdb.Point(r_.width + separation, 0)
+
+        if v.y < 0:
+            r_.start.left()
+            r_.start.right()
+        else:
+            r_.start.right()
+            r_.start.left()
+
+
 def route_loosely(
     routers: Sequence[ManhattanRouter],
     separation: int,
@@ -1807,21 +2092,8 @@ def route_loosely(
         elif s == -1 and group:
             reverse_groups.append(group)
 
-        for router_group in forward_groups:
-            delta = 0
-            for router in reversed(router_group):
-                if not router.finished:
-                    router.start.straight(delta)
-                    delta += router.width + separation
-                    router.auto_route(bbox=start_bbox)
-
-        for router_group in reverse_groups:
-            delta = 0
-            for router in router_group:
-                if not router.finished:
-                    router.start.straight(delta)
-                    delta += router.width + separation
-                    router.auto_route(bbox=start_bbox)
+        _route_group(forward_groups, separation, start_bbox, reverse=True)
+        _route_group(reverse_groups, separation, start_bbox, reverse=False)
 
 
 def vec_dir(vec: kdb.Vector) -> int:
@@ -2194,6 +2466,9 @@ def _route_waypoints(
     bbox_routing: Literal["minimal", "full"] = "minimal",
     sort_ports: bool = False,
     allow_sbends: bool = False,
+    route_debug: RouteDebug | None = None,
+    start_port_names: dict[kdb.Trans, str] | None = None,
+    end_port_names: dict[kdb.Trans, str] | None = None,
 ) -> list[ManhattanRouter]:
     if isinstance(waypoints, kdb.Trans):
         length_widths = len(widths)
@@ -2275,6 +2550,7 @@ def _route_waypoints(
         for sr, er in zip(start_manhattan_routers, end_manhattan_routers, strict=False):
             router = ManhattanRouter(
                 bend90_radius=bend90_radius,
+                separation=separation,
                 start_transformation=sr.start_transformation,
                 end_transformation=er.start_transformation,
                 start_points=sr.start.pts[:-1] + list(reversed(er.start.pts[:-1])),
@@ -2284,6 +2560,37 @@ def _route_waypoints(
             router.start.t = router.end_transformation * kdb.Trans.R180
             router.finished = True
             all_routers.append(router)
+            if route_debug is not None:
+                fan_in_name = (
+                    start_port_names.get(sr.start_transformation, "")
+                    if start_port_names
+                    else ""
+                )
+                route_debug.fan_in_region.insert(
+                    kdb.PathWithProperties(
+                        kdb.Path(sr.start.pts, sr.width),
+                        {
+                            0: kdb.Text(
+                                f"fan_in - {fan_in_name}", sr.start_transformation
+                            ).to_s()
+                        },
+                    )
+                )
+                fan_out_name = (
+                    end_port_names.get(er.start_transformation)
+                    if end_port_names
+                    else ""
+                )
+                route_debug.fan_out_region.insert(
+                    kdb.PathWithProperties(
+                        kdb.Path(list(reversed(er.start.pts)), er.width),
+                        {
+                            0: kdb.Text(
+                                f"fan_out - {fan_out_name}", er.start_transformation
+                            ).to_s()
+                        },
+                    )
+                )
         return all_routers
     if len(waypoints) < MIN_WAYPOINTS_FOR_ROUTING:
         raise ValueError(
@@ -2402,11 +2709,59 @@ def _route_waypoints(
             key=lambda pair: pair[0][0],
         )
     ]
+    if route_debug is not None:
+        for sr, _bb, er in zip(
+            start_manhattan_routers,
+            bundle_points,
+            end_manhattan_routers,
+            strict=False,
+        ):
+            fan_in_name = (
+                start_port_names.get(sr.start_transformation, "")
+                if start_port_names
+                else ""
+            )
+            route_debug.fan_in_region.insert(
+                kdb.PathWithProperties(
+                    kdb.Path(sr.start.pts, sr.width),
+                    {
+                        0: kdb.Text(
+                            f"fan_in - {fan_in_name}", sr.start_transformation
+                        ).to_s()
+                    },
+                )
+            )
+            wp_props: dict[int, str] = {
+                i: kdb.Text(
+                    f"Waypoint {i}: {wp.x},{wp.y}",
+                    kdb.Trans(0, False, wp.x, wp.y),
+                ).to_s()
+                for i, wp in enumerate(waypoints)
+            }
+            route_debug.waypoints_region.insert(
+                kdb.PathWithProperties(kdb.Path(_bb, sr.width), wp_props)
+            )
+            fan_out_name = (
+                end_port_names.get(er.start_transformation, "")
+                if end_port_names
+                else ""
+            )
+            route_debug.fan_out_region.insert(
+                kdb.PathWithProperties(
+                    kdb.Path(list(reversed(er.start.pts)), er.width),
+                    {
+                        0: kdb.Text(
+                            f"fan_out - {fan_out_name}", er.start_transformation
+                        ).to_s()
+                    },
+                )
+            )
     for sr, _bb, er in zip(
         start_manhattan_routers, bundle_points, end_manhattan_routers, strict=False
     ):
         router = ManhattanRouter(
             bend90_radius=bend90_radius,
+            separation=separation,
             start_transformation=sr.start_transformation,
             end_transformation=er.start_transformation,
             start_points=sr.start.pts[:-1]
@@ -2446,8 +2801,8 @@ def clean_points(
     del_points: list[int] = []
 
     for i, p_n in enumerate(points[2:], 2):
-        v2 = p_n - p  # type: ignore[operator]
-        v1 = p - p_p  # type: ignore[operator]
+        v2 = p_n - p  # ty:ignore[unsupported-operator]
+        v1 = p - p_p  # ty:ignore[unsupported-operator]
 
         if (
             (np.sign(v1.x) == np.sign(v2.x)) and (np.sign(v1.y) == np.sign(v2.y))
@@ -2455,7 +2810,7 @@ def clean_points(
             del_points.append(i - 1)
         else:
             p_p = p
-            p = p_n  # type: ignore[assignment]
+            p = p_n
     for i in reversed(del_points):
         del points[i]
 
diff --git a/src/kfactory/routing/optical.py b/src/kfactory/routing/optical.py
index a519414ee..3608175c6 100644
--- a/src/kfactory/routing/optical.py
+++ b/src/kfactory/routing/optical.py
@@ -4,7 +4,6 @@
 
 from collections.abc import Sequence
 from enum import IntEnum
-from functools import partial
 from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast, overload
 
 from .. import kdb, rdb
@@ -23,7 +22,6 @@
     route_bundle as route_bundle_generic,
 )
 from .manhattan import (
-    ManhattanRoutePathFunction,
     ManhattanRouter,
     _is_manhattan,
     route_manhattan,
@@ -40,15 +38,15 @@
         StraightFactoryDBU,
         StraightFactoryUM,
     )
-    from ..port import BasePort, DPort, Port
+    from ..port import DPort, Port
+    from ..schematic import Constraint
     from ..typings import dbu, um
+    from .utils import RouteDebug
 
 __all__ = [
     "get_radius",
-    "place90",
     "place_manhattan",
     "place_manhattan_with_sbends",
-    "route",
     "route_bundle",
     "route_loopback",
     "vec_angle",
@@ -67,26 +65,30 @@ class LoopPosition(IntEnum):
     end = 1
 
 
-class PathLengthConfig(TypedDict, total=False):
+class PathLengthConfig[T: (int, float)](TypedDict, total=False):
     loops: int
     loop_side: int
     element: int
     loop_position: int
+    total_length: int
 
 
 def path_length_match(
-    c: ProtoTKCell[Any],
     routers: Sequence[ManhattanRouter],
-    start_ports: Sequence[BasePort],
-    end_ports: Sequence[BasePort],
-    separation: dbu,
     element: int = -1,
     loops: int = 1,
     loop_side: LoopSide = LoopSide.left,
     loop_position: LoopPosition = LoopPosition.start,
-    **kwargs: Any,
+    path_length: int | None = None,
 ) -> None:
-    path_length = max(router.path_length for router in routers)
+    if path_length is None:
+        path_length = max(router.path_length for router in routers)
+    elif path_length < max(router.path_length for router in routers):
+        path_length_ = max(router.path_length for router in routers)
+        logger.warning(
+            f"Requesting path length matching to {path_length!r}[dbu], but the minimal"
+            f" possible path length is {path_length_!r}. Increasing to minimum."
+        )
     if path_length % 2:
         logger.warning(
             "path length matching target length "
@@ -101,7 +103,7 @@ def path_length_match(
     match loop_side:
         case LoopSide.center:
             loops += 1
-    br = max(routers[0].bend90_radius, routers[0].width + separation)
+    br = max(routers[0].bend90_radius, routers[0].width + routers[0].separation)
 
     for router in routers:
         length = router.path_length
@@ -234,8 +236,6 @@ def route_bundle(
     straight_factory: StraightFactoryDBU,
     bend90_cell: KCell,
     taper_cell: KCell | None = None,
-    start_straights: dbu | list[dbu] | None = None,
-    end_straights: dbu | list[dbu] | None = None,
     min_straight_taper: dbu = 0,
     place_port_type: str = "optical",
     place_allow_small_routes: bool = False,
@@ -256,7 +256,9 @@ def route_bundle(
     end_angles: int | list[int] | None = None,
     purpose: str | None = "routing",
     sbend_factory: SBendFactoryDBU | None = None,
-    path_length_matching_config: PathLengthConfig | None = None,
+    constraints: Sequence[Constraint] | None = None,
+    route_debug: RouteDebug | None = None,
+    route_name: str | None = None,
 ) -> list[ManhattanRoute]: ...
 
 
@@ -269,8 +271,6 @@ def route_bundle(
     straight_factory: StraightFactoryUM,
     bend90_cell: DKCell,
     taper_cell: DKCell | None = None,
-    start_straights: um | list[um] | None = None,
-    end_straights: um | list[um] | None = None,
     min_straight_taper: um = 0,
     place_port_type: str = "optical",
     place_allow_small_routes: bool = False,
@@ -291,7 +291,9 @@ def route_bundle(
     end_angles: float | list[float] | None = None,
     purpose: str | None = "routing",
     sbend_factory: SBendFactoryUM | None = None,
-    path_length_matching_config: PathLengthConfig | None = None,
+    constraints: Sequence[Constraint] | None = None,
+    route_debug: RouteDebug | None = None,
+    route_name: str | None = None,
 ) -> list[ManhattanRoute]: ...
 
 
@@ -303,8 +305,6 @@ def route_bundle(
     straight_factory: StraightFactoryDBU | StraightFactoryUM,
     bend90_cell: KCell | DKCell,
     taper_cell: KCell | DKCell | None = None,
-    start_straights: dbu | list[dbu] | um | list[um] | None = None,
-    end_straights: dbu | list[dbu] | um | list[um] | None = None,
     min_straight_taper: dbu | float = 0,
     place_port_type: str = "optical",
     place_allow_small_routes: bool = False,
@@ -335,7 +335,9 @@ def route_bundle(
     end_angles: list[int] | float | list[float] | None = None,
     purpose: str | None = "routing",
     sbend_factory: SBendFactoryDBU | SBendFactoryUM | None = None,
-    path_length_matching_config: PathLengthConfig | None = None,
+    constraints: Sequence[Constraint] | None = None,
+    route_debug: RouteDebug | None = None,
+    route_name: str | None = None,
 ) -> list[ManhattanRoute]:
     r"""Route a bundle from starting ports to end_ports.
 
@@ -389,10 +391,6 @@ def route_bundle(
         straight_factory: Factory function for straight cells. in DBU.
         bend90_cell: 90° bend cell.
         taper_cell: Taper cell.
-        start_straights: DEPRECATED[Use starts instead]
-            `p1`.
-        end_straights: DEPRECATED[Use ends instead]
-            `p2`.
         starts: Minimal straight segment after `start_ports`.
         ends: Minimal straight segment before `end_ports`.
         min_straight_taper: Minimum straight [dbu] before attempting to place tapers.
@@ -442,12 +440,6 @@ def route_bundle(
         starts = []
     if bboxes is None:
         bboxes = []
-    if start_straights is not None:
-        logger.warning("start_straights is deprecated. Use `starts` instead.")
-        starts = start_straights
-    if end_straights is not None:
-        logger.warning("end_straights is deprecated. Use `ends` instead.")
-        ends = end_straights
     bend90_radius = get_radius(bend90_cell.ports.filter(port_type=place_port_type))
     start_ports_ = [p.base.model_copy() for p in start_ports]
     end_ports_ = [p.base.model_copy() for p in end_ports]
@@ -459,7 +451,7 @@ def route_bundle(
             "taper_cell": taper_cell,
             "port_type": place_port_type,
             "min_straight_taper": min_straight_taper,
-            "allow_small_routes": False,
+            "allow_small_routes": place_allow_small_routes,
             "allow_width_mismatch": allow_width_mismatch,
             "allow_layer_mismatch": allow_layer_mismatch,
             "allow_type_mismatch": allow_type_mismatch,
@@ -468,14 +460,14 @@ def route_bundle(
         }
     else:
         # Not a type error
-        placer = place_manhattan_with_sbends  # type: ignore[assignment]
+        placer = place_manhattan_with_sbends
         placer_kwargs = {
             "straight_factory": straight_factory,
             "bend90_cell": bend90_cell,
             "taper_cell": taper_cell,
             "port_type": place_port_type,
             "min_straight_taper": min_straight_taper,
-            "allow_small_routes": False,
+            "allow_small_routes": place_allow_small_routes,
             "allow_width_mismatch": allow_width_mismatch,
             "allow_layer_mismatch": allow_layer_mismatch,
             "allow_type_mismatch": allow_type_mismatch,
@@ -484,12 +476,6 @@ def route_bundle(
             "sbend_factory": sbend_factory,
         }
     if isinstance(c, KCell):
-        if path_length_matching_config is not None:
-            post_process_f = partial(
-                path_length_match, separation=cast("dbu", separation)
-            )
-        else:
-            post_process_f = None
         try:
             return route_bundle_generic(
                 c=c,
@@ -498,7 +484,6 @@ def route_bundle(
                 starts=cast("dbu | list[dbu] | list[Step] | list[list[Step]]", starts),
                 ends=cast("dbu | list[dbu] | list[Step] | list[list[Step]]", ends),
                 route_width=cast("int", route_width),
-                sort_ports=sort_ports,
                 on_collision=on_collision,
                 on_placer_error=on_placer_error,
                 collision_check_layers=collision_check_layers,
@@ -516,8 +501,9 @@ def route_bundle(
                 placer_kwargs=placer_kwargs,
                 start_angles=cast("list[int] | int", start_angles),
                 end_angles=cast("list[int] | int", end_angles),
-                router_post_process_function=post_process_f,
-                router_post_process_kwargs=path_length_matching_config,
+                constraints=constraints,
+                route_debug=route_debug,
+                route_name=route_name,
             )
         except ValueError as e:
             if str(e).startswith("Found non-manhattan waypoints."):
@@ -541,7 +527,7 @@ def route_bundle(
                     )
                 if on_placer_error == "show_error":
                     c_: KCell | DKCell = c.dup()
-                    c_.name = c.kcl.future_cell_name or c.name
+                    c_.name = c.kcl._future_cell_name or c.name
                     db = rdb.ReportDatabase("Routing Waypoint Errors")
                     err_cat = db.create_category("Waypoint Error")
                     wp_cat = db.create_category("Waypoints")
@@ -594,13 +580,13 @@ def route_bundle(
         starts = c.kcl.to_dbu(starts)
     elif isinstance(starts, list):
         if isinstance(starts[0], int | float):
-            starts = [c.kcl.to_dbu(start) for start in starts]  # type: ignore[arg-type]
+            starts = [c.kcl.to_dbu(cast("int|float", start)) for start in starts]
         starts = cast("int | list[int] | list[Step] | list[list[Step]]", starts)
     if isinstance(ends, int | float):
         ends = c.kcl.to_dbu(ends)
     elif isinstance(ends, list):
         if isinstance(ends[0], int | float):
-            ends = [c.kcl.to_dbu(end) for end in ends]  # type: ignore[arg-type]
+            ends = [c.kcl.to_dbu(cast("int|float", end)) for end in ends]
         ends = cast("int | list[int] | list[Step] | list[list[Step]]", ends)
 
     def _straight_factory(width: int, length: int) -> KCell:
@@ -623,10 +609,6 @@ def _straight_factory(width: int, length: int) -> KCell:
             ]
         else:
             waypoints = cast("kdb.DCplxTrans", waypoints).s_trans().to_itype(c.kcl.dbu)
-    if path_length_matching_config is not None:
-        post_process_f = partial(path_length_match, separation=c.kcl.to_dbu(separation))
-    else:
-        post_process_f = None
     if sbend_factory is None:
         placer = place_manhattan
         placer_kwargs = {
@@ -635,7 +617,7 @@ def _straight_factory(width: int, length: int) -> KCell:
             "taper_cell": taper_cell,
             "port_type": place_port_type,
             "min_straight_taper": min_straight_taper,
-            "allow_small_routes": False,
+            "allow_small_routes": place_allow_small_routes,
             "allow_width_mismatch": allow_width_mismatch,
             "allow_layer_mismatch": allow_layer_mismatch,
             "allow_type_mismatch": allow_type_mismatch,
@@ -656,14 +638,14 @@ def _sbend_factory(
             )
 
         # Not a type error
-        placer = place_manhattan_with_sbends  # type: ignore[assignment]
+        placer = place_manhattan_with_sbends
         placer_kwargs = {
             "straight_factory": _straight_factory,
             "bend90_cell": bend90_cell,
             "taper_cell": taper_cell,
             "port_type": place_port_type,
             "min_straight_taper": min_straight_taper,
-            "allow_small_routes": False,
+            "allow_small_routes": place_allow_small_routes,
             "allow_width_mismatch": allow_width_mismatch,
             "allow_layer_mismatch": allow_layer_mismatch,
             "allow_type_mismatch": allow_type_mismatch,
@@ -679,7 +661,6 @@ def _sbend_factory(
             starts=starts,
             ends=ends,
             route_width=route_width,
-            sort_ports=sort_ports,
             on_collision=on_collision,
             on_placer_error=on_placer_error,
             collision_check_layers=collision_check_layers,
@@ -695,10 +676,11 @@ def _sbend_factory(
             },
             placer_function=placer,
             placer_kwargs=placer_kwargs,
-            router_post_process_function=post_process_f,
-            router_post_process_kwargs=path_length_matching_config,
+            constraints=constraints,
             start_angles=start_angles,
             end_angles=end_angles,
+            route_debug=route_debug,
+            route_name=route_name,
         )
     except ValueError as e:
         if str(e).startswith("Found non-manhattan waypoints."):
@@ -722,16 +704,14 @@ def _sbend_factory(
                 )
             if on_placer_error == "show_error":
                 c_ = c.dup()
-                c_.name = c.kcl.future_cell_name or c.name
+                c_.name = c.kcl._future_cell_name or c.name
                 db = rdb.ReportDatabase("Routing Waypoint Errors")
                 err_cat = db.create_category("Waypoint Error")
                 wp_cat = db.create_category("Waypoints")
                 cell = db.create_cell(c_.name)
                 wp_len = len(waypoints)
 
-                width_d = cast("float | None", route_width) or cast(
-                    "float", start_ports[0].width
-                )
+                width_d = cast("float | None", route_width) or start_ports[0].width
 
                 for i, wp_d in enumerate(waypoints):
                     it = db.create_item(cell=cell, category=wp_cat)
@@ -761,7 +741,6 @@ def _place_straight(
     route_width: int | None,
     *,
     port_type: str,
-    allow_small_routes: bool,
     allow_width_mismatch: bool,
     allow_layer_mismatch: bool,
     allow_type_mismatch: bool,
@@ -769,7 +748,7 @@ def _place_straight(
     length = int((p1.trans.disp.to_p() - p2.trans.disp.to_p()).length())
     wg = c << straight_factory(width=w, length=length)
     wg.purpose = purpose
-    wg_p1, wg_p2 = (v for v in wg.ports if v.port_type == port_type)
+    wg_p1, _ = (v for v in wg.ports if v.port_type == port_type)
     wg.connect(
         wg_p1,
         p1,
@@ -791,10 +770,7 @@ def _place_sbend(
     route: ManhattanRoute,
     p1: Port,
     p2: Port,
-    route_width: int | None,
     *,
-    port_type: str,
-    allow_small_routes: bool,
     allow_width_mismatch: bool,
     allow_layer_mismatch: bool,
     allow_type_mismatch: bool,
@@ -854,7 +830,6 @@ def _place_tapered_straight(
     straight_factory: StraightFactoryDBU,
     taper_cell: KCell,
     purpose: str | None,
-    w: int,
     route: ManhattanRoute,
     p1: Port,
     p2: Port,
@@ -862,7 +837,6 @@ def _place_tapered_straight(
     taper_ports: tuple[Port, Port],
     *,
     port_type: str,
-    allow_small_routes: bool,
     allow_width_mismatch: bool,
     allow_layer_mismatch: bool,
     allow_type_mismatch: bool,
@@ -894,7 +868,7 @@ def _place_tapered_straight(
     if l_ != 0:
         p1_ = t1.ports[taperp2.name]
         p2_ = t2.ports[taperp2.name]
-        _, p2_ = _place_straight(
+        _place_straight(
             c=c,
             straight_factory=straight_factory,
             purpose=purpose,
@@ -904,13 +878,10 @@ def _place_tapered_straight(
             route_width=route_width,
             route=route,
             port_type=port_type,
-            allow_small_routes=allow_small_routes,
             allow_width_mismatch=allow_width_mismatch,
             allow_layer_mismatch=allow_layer_mismatch,
             allow_type_mismatch=allow_type_mismatch,
         )
-    else:
-        p2_ = t1.ports[taperp2.name]
 
     return t1.ports[taperp1.name], t2.ports[taperp1.name]
 
@@ -918,18 +889,14 @@ def _place_tapered_straight(
 def _place_tapered_sbend_or_straight(
     c: KCell,
     sbend_factory: SBendFactoryDBU,
-    straight_factory: StraightFactoryDBU,
     taper_cell: KCell,
     purpose: str | None,
-    w: int,
     route: ManhattanRoute,
     p1: Port,
     p2: Port,
     route_width: int | None,
     taper_ports: tuple[Port, Port],
     *,
-    port_type: str,
-    allow_small_routes: bool,
     allow_width_mismatch: bool,
     allow_layer_mismatch: bool,
     allow_type_mismatch: bool,
@@ -961,23 +928,18 @@ def _place_tapered_sbend_or_straight(
     if l_ != 0:
         p1_ = t1.ports[taperp2.name]
         p2_ = t2.ports[taperp2.name]
-        _, p2_ = _place_sbend(
+        _place_sbend(
             c=c,
             sbend_factory=sbend_factory,
             purpose=purpose,
             w=taperp2.width,
             p1=p1_,
             p2=p2_,
-            route_width=route_width,
             route=route,
-            port_type=port_type,
-            allow_small_routes=allow_small_routes,
             allow_width_mismatch=allow_width_mismatch,
             allow_layer_mismatch=allow_layer_mismatch,
             allow_type_mismatch=allow_type_mismatch,
         )
-    else:
-        p2_ = t1.ports[taperp2.name]
 
     return t1.ports[taperp1.name], t2.ports[taperp1.name]
 
@@ -1036,9 +998,9 @@ def place_manhattan(
             " 90 degrees). Forcing port to be manhattan."
         )
         route_end_port.trans = route_end_port.trans
-    route_start_port.name = None
+    route_start_port.name = "route_start"
     route_start_port.trans.angle = (route_start_port.angle + 2) % 4
-    route_end_port.name = None
+    route_end_port.name = "route_end"
     route_end_port.trans.angle = (route_end_port.angle + 2) % 4
 
     old_pt = pts[0]
@@ -1143,7 +1105,6 @@ def place_manhattan(
                 p2=route.end_port.copy_polar(),
                 route_width=w,
                 port_type=port_type,
-                allow_small_routes=allow_small_routes,
                 allow_width_mismatch=allow_width_mismatch,
                 allow_layer_mismatch=allow_layer_mismatch,
                 allow_type_mismatch=allow_type_mismatch,
@@ -1153,21 +1114,19 @@ def place_manhattan(
                 c=c,
                 straight_factory=straight_factory,
                 purpose=purpose,
-                w=w,
                 taper_ports=(taperp1, taperp2),
                 route=route,
                 p1=route.start_port.copy_polar(),
                 p2=route.end_port.copy_polar(),
                 route_width=w,
                 port_type=port_type,
-                allow_small_routes=allow_small_routes,
                 allow_width_mismatch=allow_width_mismatch,
                 allow_layer_mismatch=allow_layer_mismatch,
                 allow_type_mismatch=allow_type_mismatch,
                 taper_cell=taper_cell,
             )
-        p1_.name = None
-        p2_.name = None
+        p1_.name = "route_start"
+        p2_.name = "route_end"
         route.start_port = p1
         route.end_port = p2
         return route
@@ -1220,7 +1179,7 @@ def place_manhattan(
                 < (taperp1.trans.disp - taperp2.trans.disp).length() * 2
                 + min_straight_taper
             ):
-                p1_, p2_ = _place_straight(
+                p1_, _ = _place_straight(
                     c=c,
                     straight_factory=straight_factory,
                     purpose=purpose,
@@ -1230,25 +1189,22 @@ def place_manhattan(
                     p2=new_bend_port,
                     route_width=route_width,
                     port_type=port_type,
-                    allow_small_routes=allow_small_routes,
                     allow_layer_mismatch=allow_layer_mismatch,
                     allow_type_mismatch=allow_type_mismatch,
                     allow_width_mismatch=allow_width_mismatch,
                 )
             else:
-                p1_, p2_ = _place_tapered_straight(
+                p1_, _ = _place_tapered_straight(
                     c=c,
                     straight_factory=straight_factory,
                     taper_cell=taper_cell,
                     purpose=purpose,
-                    w=w,
                     route=route,
                     p1=old_bend_port,
                     p2=new_bend_port,
                     route_width=route_width,
                     taper_ports=(taperp1, taperp2),
                     port_type=port_type,
-                    allow_small_routes=allow_small_routes,
                     allow_width_mismatch=allow_width_mismatch,
                     allow_layer_mismatch=allow_layer_mismatch,
                     allow_type_mismatch=allow_type_mismatch,
@@ -1266,7 +1222,7 @@ def place_manhattan(
             < (taperp1.trans.disp - taperp2.trans.disp).length() * 2
             + min_straight_taper
         ):
-            p1_, p2_ = _place_straight(
+            _, p2_ = _place_straight(
                 c=c,
                 straight_factory=straight_factory,
                 purpose=purpose,
@@ -1276,25 +1232,22 @@ def place_manhattan(
                 p2=p2,
                 route_width=route_width,
                 port_type=port_type,
-                allow_small_routes=allow_small_routes,
                 allow_width_mismatch=allow_width_mismatch,
                 allow_layer_mismatch=allow_layer_mismatch,
                 allow_type_mismatch=allow_type_mismatch,
             )
         else:
-            p1_, p2_ = _place_tapered_straight(
+            _, p2_ = _place_tapered_straight(
                 c=c,
                 straight_factory=straight_factory,
                 taper_cell=taper_cell,
                 purpose=purpose,
-                w=w,
                 route=route,
                 p1=old_bend_port,
                 p2=p2,
                 route_width=route_width,
                 taper_ports=(taperp1, taperp2),
                 port_type=port_type,
-                allow_small_routes=allow_small_routes,
                 allow_width_mismatch=allow_width_mismatch,
                 allow_layer_mismatch=allow_layer_mismatch,
                 allow_type_mismatch=allow_type_mismatch,
@@ -1302,8 +1255,8 @@ def place_manhattan(
         route.end_port = p2_.copy()
     else:
         route.end_port = old_bend_port.copy()
-    route.start_port.name = None
-    route.end_port.name = None
+    route.start_port.name = "route_start"
+    route.end_port.name = "route_end"
     return route
 
 
@@ -1324,7 +1277,7 @@ def place_manhattan_with_sbends(
     allow_type_mismatch: bool | None = None,
     purpose: str | None = "routing",
     *,
-    sbend_factory: SBendFactoryDBU,
+    sbend_factory: SBendFactoryDBU | None = None,
     **kwargs: Any,
 ) -> ManhattanRoute:
     # configure and set up route and placers
@@ -1341,19 +1294,24 @@ def place_manhattan_with_sbends(
         allow_type_mismatch = config.allow_type_mismatch
     if straight_factory is None:
         raise ValueError(
-            "place_manhattan needs to have a straight_factory set. Please pass a "
-            "straight_factory which takes kwargs 'width: int' and 'length: int'."
+            "place_manhattan_with_sbends needs to have a straight_factory set. Please "
+            "pass a straight_factory which takes kwargs 'width: int' and 'length: int'."
         )
     if bend90_cell is None:
         raise ValueError(
-            "place_manhattan needs to be passed a fixed bend90 cell with two optical"
-            " ports which are 90° apart from each other with port_type 'port_type'."
+            "place_manhattan_with_sbends needs to be passed a fixed bend90 cell with "
+            "two optical ports which are 90° apart from each other with port_type "
+            "'port_type'."
+        )
+    if sbend_factory is None:
+        raise ValueError(
+            "place_manhattan_with_sbends needs to be passed a sbend_function."
         )
     route_start_port = p1.copy()
-    route_start_port.name = None
+    route_start_port.name = "route_start"
     route_start_port.trans.angle = (route_start_port.angle + 2) % 4
     route_end_port = p2.copy()
-    route_end_port.name = None
+    route_end_port.name = "route_end"
     route_end_port.trans.angle = (route_end_port.angle + 2) % 4
 
     old_pt = pts[0]
@@ -1449,12 +1407,9 @@ def place_manhattan_with_sbends(
                 route=route,
                 p1=old_bend_port,
                 p2=old_bend_port.copy_polar(d=sbend_vec.x, d_orth=sbend_vec.y, angle=2),
-                route_width=route_width,
-                allow_small_routes=allow_small_routes,
                 allow_width_mismatch=allow_width_mismatch,
                 allow_layer_mismatch=allow_layer_mismatch,
                 allow_type_mismatch=allow_type_mismatch,
-                port_type=port_type,
             )
         else:
             length = int(vec.length())
@@ -1464,7 +1419,7 @@ def place_manhattan_with_sbends(
                 < (taperp1.trans.disp - taperp2.trans.disp).length() * 2
                 + min_straight_taper
             ):
-                p1_, p2_ = _place_straight(
+                _place_straight(
                     c=c,
                     straight_factory=straight_factory,
                     purpose=purpose,
@@ -1474,31 +1429,28 @@ def place_manhattan_with_sbends(
                     p2=route.end_port.copy_polar(),
                     route_width=w,
                     port_type=port_type,
-                    allow_small_routes=allow_small_routes,
                     allow_width_mismatch=allow_width_mismatch,
                     allow_layer_mismatch=allow_layer_mismatch,
                     allow_type_mismatch=allow_type_mismatch,
                 )
             else:
-                p1_, p2_ = _place_tapered_straight(
+                _place_tapered_straight(
                     c=c,
                     straight_factory=straight_factory,
                     purpose=purpose,
-                    w=w,
                     taper_ports=(taperp1, taperp2),
                     route=route,
                     p1=route.start_port.copy_polar(),
                     p2=route.end_port.copy_polar(),
                     route_width=w,
                     port_type=port_type,
-                    allow_small_routes=allow_small_routes,
                     allow_width_mismatch=allow_width_mismatch,
                     allow_layer_mismatch=allow_layer_mismatch,
                     allow_type_mismatch=allow_type_mismatch,
                     taper_cell=taper_cell,
                 )
-        p1.name = None
-        p2.name = None
+        p1.name = "route_start"
+        p2.name = "route_end"
         route.start_port = p1
         route.end_port = p2
         return route
@@ -1523,12 +1475,9 @@ def place_manhattan_with_sbends(
                 route=route,
                 p1=old_bend_port,
                 p2=bend_port,
-                route_width=route_width,
-                allow_small_routes=allow_small_routes,
                 allow_width_mismatch=allow_width_mismatch,
                 allow_layer_mismatch=allow_layer_mismatch,
                 allow_type_mismatch=allow_type_mismatch,
-                port_type=port_type,
             )
             old_pt = pt
             old_bend_port = p2_
@@ -1548,7 +1497,7 @@ def place_manhattan_with_sbends(
                     < (taperp1.trans.disp - taperp2.trans.disp).length() * 2
                     + min_straight_taper
                 ):
-                    p1_, p2_ = _place_straight(
+                    _, p2_ = _place_straight(
                         c=c,
                         straight_factory=straight_factory,
                         purpose=purpose,
@@ -1558,25 +1507,22 @@ def place_manhattan_with_sbends(
                         p2=new_bend_port,
                         route_width=route_width,
                         port_type=port_type,
-                        allow_small_routes=allow_small_routes,
                         allow_layer_mismatch=allow_layer_mismatch,
                         allow_type_mismatch=allow_type_mismatch,
                         allow_width_mismatch=allow_width_mismatch,
                     )
                 else:
-                    p1_, p2_ = _place_tapered_straight(
+                    _, p2_ = _place_tapered_straight(
                         c=c,
                         straight_factory=straight_factory,
                         taper_cell=taper_cell,
                         purpose=purpose,
-                        w=w,
                         route=route,
                         p1=old_bend_port,
                         p2=new_bend_port,
                         route_width=route_width,
                         taper_ports=(taperp1, taperp2),
                         port_type=port_type,
-                        allow_small_routes=allow_small_routes,
                         allow_width_mismatch=allow_width_mismatch,
                         allow_layer_mismatch=allow_layer_mismatch,
                         allow_type_mismatch=allow_type_mismatch,
@@ -1635,7 +1581,6 @@ def place_manhattan_with_sbends(
                     p2=new_bend_port,
                     route_width=route_width,
                     port_type=port_type,
-                    allow_small_routes=allow_small_routes,
                     allow_layer_mismatch=allow_layer_mismatch,
                     allow_type_mismatch=allow_type_mismatch,
                     allow_width_mismatch=allow_width_mismatch,
@@ -1646,14 +1591,12 @@ def place_manhattan_with_sbends(
                     straight_factory=straight_factory,
                     taper_cell=taper_cell,
                     purpose=purpose,
-                    w=w,
                     route=route,
                     p1=old_bend_port,
                     p2=new_bend_port,
                     route_width=route_width,
                     taper_ports=(taperp1, taperp2),
                     port_type=port_type,
-                    allow_small_routes=allow_small_routes,
                     allow_width_mismatch=allow_width_mismatch,
                     allow_layer_mismatch=allow_layer_mismatch,
                     allow_type_mismatch=allow_type_mismatch,
@@ -1675,15 +1618,10 @@ def place_manhattan_with_sbends(
             route=route,
             p1=old_bend_port,
             p2=bend_port,
-            route_width=route_width,
-            allow_small_routes=allow_small_routes,
             allow_width_mismatch=allow_width_mismatch,
             allow_layer_mismatch=allow_layer_mismatch,
             allow_type_mismatch=allow_type_mismatch,
-            port_type=port_type,
         )
-        old_pt = pt
-        old_bend_port = bend_port
         route.end_port = bend_port
     else:
         length = int((old_bend_port.trans.disp - p2.trans.disp).length())
@@ -1694,7 +1632,7 @@ def place_manhattan_with_sbends(
                 < (taperp1.trans.disp - taperp2.trans.disp).length() * 2
                 + min_straight_taper
             ):
-                p1_, p2_ = _place_straight(
+                _, p2_ = _place_straight(
                     c=c,
                     straight_factory=straight_factory,
                     purpose=purpose,
@@ -1704,25 +1642,22 @@ def place_manhattan_with_sbends(
                     p2=p2,
                     route_width=route_width,
                     port_type=port_type,
-                    allow_small_routes=allow_small_routes,
                     allow_width_mismatch=allow_width_mismatch,
                     allow_layer_mismatch=allow_layer_mismatch,
                     allow_type_mismatch=allow_type_mismatch,
                 )
             else:
-                p1_, p2_ = _place_tapered_straight(
+                _, p2_ = _place_tapered_straight(
                     c=c,
                     straight_factory=straight_factory,
                     taper_cell=taper_cell,
                     purpose=purpose,
-                    w=w,
                     route=route,
                     p1=old_bend_port,
                     p2=p2,
                     route_width=route_width,
                     taper_ports=(taperp1, taperp2),
                     port_type=port_type,
-                    allow_small_routes=allow_small_routes,
                     allow_width_mismatch=allow_width_mismatch,
                     allow_layer_mismatch=allow_layer_mismatch,
                     allow_type_mismatch=allow_type_mismatch,
@@ -1730,59 +1665,11 @@ def place_manhattan_with_sbends(
             route.end_port = p2_.copy()
         else:
             route.end_port = old_bend_port.copy()
-    route.start_port.name = None
-    route.end_port.name = None
+    route.start_port.name = "route_start"
+    route.end_port.name = "route_end"
     return route
 
 
-def place90(
-    c: ProtoTKCell[Any],
-    p1: Port,
-    p2: Port,
-    pts: Sequence[kdb.Point],
-    route_width: dbu | None = None,
-    straight_factory: StraightFactoryDBU | None = None,
-    bend90_cell: ProtoTKCell[Any] | None = None,
-    taper_cell: ProtoTKCell[Any] | None = None,
-    port_type: str = "optical",
-    min_straight_taper: dbu = 0,
-    allow_small_routes: bool = False,
-    allow_width_mismatch: bool | None = None,
-    allow_layer_mismatch: bool | None = None,
-    allow_type_mismatch: bool | None = None,
-    purpose: str | None = "routing",
-    sbend_factory: SBendFactoryDBU | None = None,
-    **kwargs: Any,
-) -> ManhattanRoute:
-    """Deprecated, use place_manhattan instead.
-
-    Will be removed with kfactory 2.0.
-    """
-    logger.warning(
-        "place90 is deprecated, please use kfactory.routing.optical.place_manhattan"
-        " instead. place90 will be removed in kfactory 2.0."
-    )
-
-    return place_manhattan(
-        c=c,
-        p1=p1,
-        p2=p2,
-        pts=pts,
-        route_width=route_width,
-        straight_factory=straight_factory,
-        bend90_cell=bend90_cell,
-        taper_cell=taper_cell,
-        port_type=port_type,
-        min_straight_taper=min_straight_taper,
-        allow_small_routes=allow_small_routes,
-        allow_width_mismatch=allow_width_mismatch,
-        allow_layer_mismatch=allow_layer_mismatch,
-        allow_type_mismatch=allow_type_mismatch,
-        purpose=purpose,
-        **kwargs,
-    )
-
-
 def route_loopback(
     port1: Port | kdb.Trans,
     port2: Port | kdb.Trans,
@@ -1829,6 +1716,10 @@ def route_loopback(
     t1 = port1 if isinstance(port1, kdb.Trans) else port1.trans
     t2 = port2 if isinstance(port2, kdb.Trans) else port2.trans
 
+    (t1, port1_), (t2, _) = sorted(
+        [(t1, port1), (t2, port2)], key=lambda t: -(t1.inverted() * t[0]).disp.y
+    )
+
     if (t1.angle != t2.angle) and (
         (t1.disp.x == t2.disp.x) or (t1.disp.y == t2.disp.y)
     ):
@@ -1878,7 +1769,7 @@ def route_loopback(
         t1 *= kdb.Trans(2, False, start_straight + bend90_radius, 2 * bend90_radius)
         t2 *= kdb.Trans(2, False, end_straight + bend90_radius, -2 * bend90_radius)
 
-    return (
+    pts = (
         pts_start
         + route_manhattan(
             t1,
@@ -1889,355 +1780,9 @@ def route_loopback(
         + pts_end
     )
 
-
-def route(
-    c: KCell,
-    p1: Port,
-    p2: Port,
-    straight_factory: StraightFactoryDBU,
-    bend90_cell: KCell,
-    bend180_cell: KCell | None = None,
-    taper_cell: KCell | None = None,
-    start_straight: dbu = 0,
-    end_straight: dbu = 0,
-    route_path_function: ManhattanRoutePathFunction = route_manhattan,
-    port_type: str = "optical",
-    allow_small_routes: bool = False,
-    route_kwargs: dict[str, Any] | None = None,
-    route_width: dbu | None = None,
-    min_straight_taper: dbu = 0,
-    allow_width_mismatch: bool | None = None,
-    allow_layer_mismatch: bool | None = None,
-    allow_type_mismatch: bool | None = None,
-    purpose: str | None = "routing",
-) -> ManhattanRoute:
-    """Places a route between two ports.
-
-    Args:
-        c: Cell to place the route in.
-        p1: Start port.
-        p2: End port.
-        straight_factory: Factory function for straight cells. in DBU.
-        bend90_cell: 90° bend cell.
-        bend180_cell: 180° bend cell.
-        taper_cell: Taper cell.
-        start_straight: Minimal straight segment after `p1`.
-        end_straight: Minimal straight segment before `p2`.
-        route_path_function: Function to calculate the route path. If bend180_cell is
-            not None, this function must also take the kwargs `bend180_radius` as
-            specified in
-            [ManhattanRoutePathFunction180][kfactory.routing.manhattan.ManhattanRoutePathFunction180]
-        port_type: Port type to use for the bend90_cell.
-        allow_small_routes: Don't throw an error if two corners cannot be safely placed
-            due to small space and place them anyway.
-        route_kwargs: Additional keyword arguments for the route_path_function.
-        route_width: Width of the route. If None, the width of the ports is used.
-        min_straight_taper: Minimum straight [dbu] before attempting to place tapers.
-        allow_width_mismatch: If True, the width of the ports is ignored
-            (config default: False).
-        allow_layer_mismatch: If True, the layer of the ports is ignored
-            (config default: False).
-        allow_type_mismatch: If True, the type of the ports is ignored
-            (config default: False).
-        purpose: Set the property "purpose" (at id kf.kcell.PROPID.PURPOSE) to the
-            value. Not set if None.
-
-    Raises:
-        ValueError: If the route cannot be placed due to small space.
-        AttributeError: If the bend90_cell or taper_cell do not have the correct.
-
-    Returns:
-        ManhattanRoute: The route object with the placed components.
-    """
-    logger.opt(depth=2).warning(
-        "`kfactory.routing.optical.route` is deprecated, please use `route_bundle`"
-        " instead. `route` will be removed in kfactory 3"
-    )
-    if route_kwargs is None:
-        route_kwargs = {}
-    if allow_width_mismatch is None:
-        allow_width_mismatch = config.allow_width_mismatch
-    if allow_layer_mismatch is None:
-        allow_layer_mismatch = config.allow_layer_mismatch
-    if allow_type_mismatch is None:
-        allow_type_mismatch = config.allow_type_mismatch
-    if p1.width != p2.width and not allow_width_mismatch:
-        raise ValueError(
-            f"The ports have different widths {p1.width=} {p2.width=}. If this is"
-            "intentional, add `allow_width_mismatch=True` to override this."
-        )
-
-    p1 = p1.copy()
-    p1.trans.mirror = False
-    p2 = p2.copy()
-    p2.trans.mirror = False
-
-    # determine bend90_radius
-    bend90_ports = [p for p in bend90_cell.ports if p.port_type == port_type]
-
-    if len(bend90_ports) != NUM_PORTS_FOR_ROUTING:
-        raise ValueError(
-            f"{bend90_cell.name} should have 2 ports but has {len(bend90_ports)} ports"
-        )
-
-    if abs((bend90_ports[0].trans.angle - bend90_ports[1].trans.angle) % 4) != 1:
-        raise ValueError(
-            f"{bend90_cell.name} bend ports should be 90° apart from each other. "
-            f"{bend90_ports[0]=} {bend90_ports[1]=}"
-        )
-    if (bend90_ports[1].trans.angle - bend90_ports[0].trans.angle) % 4 == ANGLE_270:
-        b90p1 = bend90_ports[1]
-        b90p2 = bend90_ports[0]
-    else:
-        b90p1 = bend90_ports[0]
-        b90p2 = bend90_ports[1]
-
-    b90c = kdb.Trans(
-        b90p1.trans.rot,
-        b90p1.trans.is_mirror(),
-        b90p1.trans.disp.x if b90p1.trans.angle % 2 else b90p2.trans.disp.x,
-        b90p2.trans.disp.y if b90p1.trans.angle % 2 else b90p1.trans.disp.y,
-    )
-
-    start_port: Port = p1.copy()
-    end_port: Port = p2.copy()
-    b90r = int(
-        max(
-            (b90p1.trans.disp - b90c.disp).length(),
-            (b90p2.trans.disp - b90c.disp).length(),
-        )
-    )
-
-    if bend180_cell is not None:
-        # Bend 180 is available
-        bend180_ports = list(bend180_cell.ports.filter(port_type=port_type))
-        if len(bend180_ports) != NUM_PORTS_FOR_ROUTING:
-            raise AttributeError(
-                f"{bend180_cell.name} should have 2 ports but has {len(bend180_ports)}"
-                " ports"
-            )
-        if abs((bend180_ports[0].trans.angle - bend180_ports[1].trans.angle) % 4) != 0:
-            raise AttributeError(
-                f"{bend180_cell.name} bend ports for bend180 should be 0° apart from"
-                f" each other, {bend180_ports[0]=} {bend180_ports[1]=}"
-            )
-        d = 1 if bend180_ports[0].trans.angle in [0, 3] else -1
-        b180p1, b180p2 = sorted(
-            bend180_ports,
-            key=lambda port: (d * port.trans.disp.x, d * port.trans.disp.y),
-        )
-
-        b180r = int((b180p2.trans.disp - b180p1.trans.disp).length())
-        start_port = p1.copy()
-        end_port = p2.copy()
-        pts = route_path_function(  # type: ignore[call-arg]
-            port1=start_port,
-            port2=end_port,
-            bend90_radius=b90r,
-            bend180_radius=b180r,
-            start_straight=start_straight,
-            end_straight=end_straight,
-        )
-
-        if len(pts) > 2:  # noqa: PLR2004
-            if (vec := pts[1] - pts[0]).length() == b180r:
-                match (p1.trans.angle - vec_angle(vec)) % 4:
-                    case 1:
-                        bend180 = c << bend180_cell
-                        bend180.purpose = purpose
-                        bend180.connect(
-                            b180p1.name,
-                            p1,
-                            allow_width_mismatch=allow_width_mismatch,
-                            allow_layer_mismatch=allow_layer_mismatch,
-                            allow_type_mismatch=allow_type_mismatch,
-                        )
-                        start_port = bend180.ports[b180p2.name]
-                        pts = pts[1:]
-                    case 3:
-                        bend180 = c << bend180_cell
-                        bend180.purpose = purpose
-                        bend180.connect(
-                            b180p2.name,
-                            p1,
-                            allow_width_mismatch=allow_width_mismatch,
-                            allow_layer_mismatch=allow_layer_mismatch,
-                            allow_type_mismatch=allow_type_mismatch,
-                        )
-                        start_port = bend180.ports[b180p1.name]
-                        pts = pts[1:]
-            if (vec := pts[-1] - pts[-2]).length() == b180r:
-                match (vec_angle(vec) - p2.trans.angle) % 4:
-                    case 1:
-                        bend180 = c << bend180_cell
-                        bend180.purpose = purpose
-                        bend180.connect(
-                            b180p1.name,
-                            p2,
-                            allow_width_mismatch=allow_width_mismatch,
-                            allow_layer_mismatch=allow_layer_mismatch,
-                            allow_type_mismatch=allow_type_mismatch,
-                        )
-                        end_port = bend180.ports[b180p2.name]
-                        pts = pts[:-1]
-                    case 3:
-                        bend180 = c << bend180_cell
-                        bend180.purpose = purpose
-                        bend180.connect(
-                            b180p2.name,
-                            p2,
-                            allow_width_mismatch=allow_width_mismatch,
-                            allow_layer_mismatch=allow_layer_mismatch,
-                            allow_type_mismatch=allow_type_mismatch,
-                        )
-                        end_port = bend180.ports[b180p1.name]
-                        pts = pts[:-1]
-
-            if len(pts) > 3:  # noqa: PLR2004
-                pt1, pt2, pt3 = pts[:3]
-                j = 0
-                for i in range(3, len(pts) - 2):
-                    pt4 = pts[i]
-                    vecp = pt2 - pt1
-                    vec = pt3 - pt2
-                    vecn = pt4 - pt3
-
-                    ang1 = vec_angle(vecp)
-                    ang2 = vec_angle(vec)
-                    ang3 = vec_angle(vecn)
-
-                    if vecp == vec and ang2 - ang1 == 0:
-                        bend180 = c << bend180_cell
-                        bend180.purpose = purpose
-                        if start_port.name == b180p2.name:
-                            bend180.connect(
-                                b180p1.name,
-                                start_port,
-                                allow_width_mismatch=allow_width_mismatch,
-                                allow_layer_mismatch=allow_layer_mismatch,
-                                allow_type_mismatch=allow_type_mismatch,
-                            )
-                            start_port = bend180.ports[b180p2.name]
-                        else:
-                            bend180.connect(
-                                b180p2.name,
-                                start_port,
-                                allow_width_mismatch=allow_width_mismatch,
-                                allow_layer_mismatch=allow_layer_mismatch,
-                                allow_type_mismatch=allow_type_mismatch,
-                            )
-                            start_port = bend180.ports[b180p1.name]
-                        j = i - 1
-                    elif (
-                        vec.length() == b180r
-                        and (ang2 - ang1) % 4 == 1
-                        and (ang3 - ang2) % 4 == 1
-                    ):
-                        bend180 = c << bend180_cell
-                        bend180.purpose = purpose
-                        bend180.transform(
-                            kdb.Trans((ang1 + 2) % 4, False, pt2.x, pt2.y)
-                            * b180p1.trans.inverted()
-                        )
-                        place_manhattan(
-                            c=c,
-                            p1=start_port.copy(),
-                            p2=bend180.ports[b180p1.name],
-                            pts=pts[j : i - 2],
-                            straight_factory=straight_factory,
-                            bend90_cell=bend90_cell,
-                            taper_cell=taper_cell,
-                            port_type=port_type,
-                            allow_small_routes=allow_small_routes,
-                            allow_width_mismatch=allow_width_mismatch,
-                            allow_layer_mismatch=allow_layer_mismatch,
-                            allow_type_mismatch=allow_type_mismatch,
-                            route_width=route_width,
-                        )
-                        j = i - 1
-                        start_port = bend180.ports[b180p2.name]
-                    elif (
-                        vec.length() == b180r
-                        and (ang2 - ang1) % 4 == ANGLE_270
-                        and (ang3 - ang2) % 4 == ANGLE_270
-                    ):
-                        bend180 = c << bend180_cell
-                        bend180.purpose = purpose
-                        bend180.transform(
-                            kdb.Trans((ang1 + 2) % 4, False, pt2.x, pt2.y)
-                            * b180p2.trans.inverted()
-                        )
-                        place_manhattan(
-                            c=c,
-                            p1=start_port.copy(),
-                            p2=bend180.ports[b180p2.name],
-                            pts=pts[j : i - 2],
-                            straight_factory=straight_factory,
-                            bend90_cell=bend90_cell,
-                            taper_cell=taper_cell,
-                            port_type=port_type,
-                            allow_small_routes=allow_small_routes,
-                            allow_width_mismatch=allow_width_mismatch,
-                            route_width=route_width,
-                        )
-                        j = i - 1
-                        start_port = bend180.ports[b180p1.name]
-
-                    pt1 = pt2
-                    pt2 = pt3
-                    pt3 = pt4
-
-        route = place_manhattan(
-            c=c,
-            p1=start_port.copy(),
-            p2=end_port.copy(),
-            pts=pts,
-            straight_factory=straight_factory,
-            bend90_cell=bend90_cell,
-            taper_cell=taper_cell,
-            min_straight_taper=min_straight_taper,
-            port_type=port_type,
-            allow_small_routes=allow_small_routes,
-            allow_width_mismatch=allow_width_mismatch,
-            route_width=route_width,
-        )
-
-    else:
-        start_port = p1.copy()
-        end_port = p2.copy()
-        if not route_kwargs:
-            pts = route_path_function(
-                start_port,
-                end_port,
-                bend90_radius=b90r,
-                start_steps=[Straight(dist=start_straight)],
-                end_steps=[Straight(dist=end_straight)],
-            )
-        else:
-            pts = route_path_function(
-                start_port,
-                end_port,
-                bend90_radius=b90r,
-                start_steps=[Straight(dist=start_straight)],
-                end_steps=[Straight(dist=end_straight)],
-                **route_kwargs,
-            )
-
-        route = place_manhattan(
-            c=c,
-            p1=p1.copy(),
-            p2=p2.copy(),
-            pts=pts,
-            straight_factory=straight_factory,
-            bend90_cell=bend90_cell,
-            taper_cell=taper_cell,
-            allow_small_routes=allow_small_routes,
-            min_straight_taper=min_straight_taper,
-            port_type=port_type,
-            allow_width_mismatch=allow_width_mismatch,
-            route_width=route_width,
-        )
-    return route
+    if port1_ == port1:
+        return pts
+    return list(reversed(pts))
 
 
 def vec_angle(v: kdb.Vector) -> int:
diff --git a/src/kfactory/routing/utils.py b/src/kfactory/routing/utils.py
new file mode 100644
index 000000000..f42079c95
--- /dev/null
+++ b/src/kfactory/routing/utils.py
@@ -0,0 +1,84 @@
+import contextlib
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+from .. import kdb
+from ..typings import DShapeLike, MarkerConfig
+
+default_fanin_color = 0x19B058
+default_waypoints_color = 0xB07419
+default_fanout_color = 0x1921B0
+
+
+class RouteDebug(BaseModel, arbitrary_types_allowed=True):
+    fan_in_region: kdb.Region = Field(default_factory=kdb.Region)
+    fan_in_marker_config: MarkerConfig = Field(
+        default=MarkerConfig(
+            color=default_fanin_color,
+            dismissable=True,
+            dither_pattern=0,
+            halo=-1,
+            frame_color=default_fanin_color,
+            line_style=0,
+            line_width=1,
+            vertex_size=1,
+        )
+    )
+    fan_out_region: kdb.Region = Field(default_factory=kdb.Region)
+    fan_out_marker_config: MarkerConfig = Field(
+        default=MarkerConfig(
+            color=default_fanout_color,
+            dismissable=True,
+            dither_pattern=0,
+            halo=-1,
+            frame_color=default_fanout_color,
+            line_style=0,
+            line_width=1,
+            vertex_size=1,
+        )
+    )
+    waypoints_region: kdb.Region = Field(default_factory=kdb.Region)
+    waypoints_marker_config: MarkerConfig = Field(
+        default=MarkerConfig(
+            color=default_fanin_color,
+            dismissable=True,
+            dither_pattern=0,
+            halo=-1,
+            frame_color=default_waypoints_color,
+            line_style=0,
+            line_width=1,
+            vertex_size=1,
+        )
+    )
+
+    def model_post_init(self, context: Any) -> None:
+        self.fan_in_region.merged_semantics = False
+        self.fan_out_region.merged_semantics = False
+        self.waypoints_region.merged_semantics = False
+
+    def to_dict(self) -> dict[str, str]:
+        return {name: value.to_s() for name, value in iter(self)}
+
+    def to_markers(self, dbu: float) -> list[tuple[DShapeLike, MarkerConfig]]:
+        marker_list = []
+        for poly in self.fan_in_region.each():
+            marker_list.append((poly.to_dtype(dbu), self.fan_in_marker_config))
+            for prop in poly.properties().values():
+                with contextlib.suppress(Exception):
+                    text = kdb.Text.from_s(prop).to_dtype(dbu)
+                    marker_list.append((text, self.fan_in_marker_config))
+        for poly in self.fan_out_region.each():
+            marker_list.append((poly.to_dtype(dbu), self.fan_out_marker_config))
+            for prop in poly.properties().values():
+                with contextlib.suppress(Exception):
+                    text = kdb.Text.from_s(prop).to_dtype(dbu)
+                    marker_list.append((text, self.fan_out_marker_config))
+        for poly in self.waypoints_region.each():
+            marker_list.append((poly.to_dtype(dbu), self.waypoints_marker_config))
+            for prop in poly.properties().values():
+                with contextlib.suppress(Exception):
+                    text = kdb.Text.from_s(prop).to_dtype(dbu)
+                    marker_list.append((text, self.waypoints_marker_config))
+
+        return marker_list
diff --git a/src/kfactory/schematic.py b/src/kfactory/schematic.py
index 8f930b54b..894b96a81 100644
--- a/src/kfactory/schematic.py
+++ b/src/kfactory/schematic.py
@@ -18,8 +18,10 @@
 import keyword
 import re
 import subprocess
+from abc import ABC, abstractmethod
 from collections import defaultdict
 from functools import cached_property
+from numbers import Real
 from operator import attrgetter
 from pathlib import Path
 from typing import (
@@ -27,7 +29,6 @@
     Annotated,
     Any,
     Concatenate,
-    Generic,
     Literal,
     Self,
     TypedDict,
@@ -37,12 +38,18 @@
 )
 
 from cachetools import Cache, cached
+from kfnetlist import (
+    Net,
+    Netlist,
+    NetlistPort,
+    PortArrayRef,
+    PortRef,
+)
 from pydantic import (
     AfterValidator,
     BaseModel,
     Field,
     PrivateAttr,
-    RootModel,
     field_serializer,
     field_validator,
     model_validator,
@@ -50,25 +57,38 @@
 from ruamel.yaml import YAML
 
 from . import kdb
-from .conf import PROPID, logger
+from .conf import logger
 from .decorators import WrappedKCellFunc, WrappedVKCellFunc
-from .instance import DInstance, Instance, VInstance
+from .instance import Instance, VInstance
 from .kcell import DKCell, KCell, ProtoTKCell, VKCell
 from .layout import KCLayout, get_default_kcl, kcls
-from .netlist import Net, Netlist, NetlistInstance, NetlistPort, PortArrayRef, PortRef
+from .port import BasePort, ProtoPort
 from .port import DPort as DKCellPort
 from .port import Port as KCellPort
-from .port import ProtoPort
+from .routing.generic import ManhattanRoute  # noqa: TC001
+from .routing.manhattan import ManhattanRouter  # noqa: TC001
 from .settings import Info
-from .typings import KC, JSONSerializable, TUnit, dbu, um
+from .typings import KC, JSONSerializable, dbu, um
 
 if TYPE_CHECKING:
     from collections.abc import Callable, Mapping, Sequence
 
-    from .cross_section import CrossSection, DCrossSection
-    from .kcell import DKCell, KCell, ProtoTKCell, VKCell
+    from klayout import rdb
+
+    from .cross_section import (
+        AsymmetricCrossSection,
+        CrossSection,
+        DAsymmetricCrossSection,
+        DCrossSection,
+    )
 
-__all__ = ["DSchematic", "Schematic", "get_schematic", "read_schematic"]
+__all__ = [
+    "Constraint",
+    "DSchematic",
+    "PathLengthMatch",
+    "Schematic",
+    "read_schematic",
+]
 
 
 yaml = YAML(typ="safe")
@@ -94,7 +114,7 @@ def _valid_varname(name: str) -> str:
     return name if name.isidentifier() and not keyword.iskeyword(name) else f"_{name}"
 
 
-def _gez(value: TUnit) -> TUnit:
+def _gez[T: Real](value: T) -> T:
     """Validate that a unit-like value is >= 0.
 
     Raises:
@@ -200,7 +220,7 @@ def _is_portdict(d: PortAnchorDict | FixedAnchorDict) -> TypeGuard[PortAnchorDic
 }
 
 
-class Placement(MirrorPlacement, Generic[TUnit], extra="forbid"):
+class Placement[T: (int, float)](MirrorPlacement, extra="forbid"):
     """Absolute placement and orientation for an instance.
 
     Coordinates may be absolute (`x`, `y`) with
@@ -220,11 +240,11 @@ class Placement(MirrorPlacement, Generic[TUnit], extra="forbid"):
         mirror: Whether the instance is to be mirrored or not.
     """
 
-    x: TUnit | PortRef | PortArrayRef | AnchorRefX = cast("TUnit", 0)
-    dx: TUnit = cast("TUnit", 0)
-    y: TUnit | PortRef | PortArrayRef | AnchorRefY = cast("TUnit", 0)
-    dy: TUnit = cast("TUnit", 0)
-    orientation: float = 0
+    x: int | T | PortArrayRef | PortRef | AnchorRefX = 0
+    dx: int | T = 0
+    y: int | T | PortArrayRef | PortRef | AnchorRefY = 0
+    dy: int | T = 0
+    orientation: float | PortRef = 0
     anchor: FixedAnchor | PortAnchor | None = None
 
     @model_validator(mode="before")
@@ -252,10 +272,12 @@ def is_placeable(self, placed_instances: set[str], placed_ports: set[str]) -> bo
             placeable = placeable and self.y.name in placed_ports
         elif isinstance(self.y, AnchorRefY):
             placeable = placeable and self.y.instance in placed_instances
+        if isinstance(self.orientation, PortRef):
+            placeable = placeable and self.orientation.instance in placed_instances
         return placeable
 
 
-class RegularArray(BaseModel, Generic[TUnit], extra="forbid"):
+class RegularArray[T: (int, float)](BaseModel, extra="forbid"):
     """Rectangular array with uniform row/column pitch.
 
     Attributes:
@@ -266,15 +288,15 @@ class RegularArray(BaseModel, Generic[TUnit], extra="forbid"):
     """
 
     columns: int = Field(gt=0, default=1)
-    column_pitch: TUnit
+    column_pitch: T
     rows: int = Field(gt=0, default=1)
-    row_pitch: TUnit
+    row_pitch: T
 
     def __repr__(self) -> str:
         return f"RegularArray(columns={self.columns}, columns_pitch=)"
 
 
-class Array(BaseModel, Generic[TUnit], extra="forbid"):
+class Array[T: (int, float)](BaseModel, extra="forbid"):
     """General 2D array parameterization using two pitch vectors.
 
     Attributes:
@@ -288,11 +310,11 @@ class Array(BaseModel, Generic[TUnit], extra="forbid"):
 
     na: int = Field(gt=1, default=1)
     nb: int = Field(gt=0, default=1)
-    pitch_a: tuple[Annotated[TUnit, AfterValidator(_gez)], TUnit]
-    pitch_b: tuple[TUnit, Annotated[TUnit, AfterValidator(_gez)]]
+    pitch_a: tuple[Annotated[T, AfterValidator(_gez)], T]
+    pitch_b: tuple[T, Annotated[T, AfterValidator(_gez)]]
 
 
-class Ports(BaseModel, Generic[TUnit]):
+class Ports[T: (int, float)]:
     """Indexer for an instance's ports to produce `PortRef`/`PortArrayRef`.
 
     Example:
@@ -300,7 +322,10 @@ class Ports(BaseModel, Generic[TUnit]):
         `inst.ports["out", 1, 0]` -> `PortArrayRef` (requires instance array)
     """
 
-    instance: SchematicInstance[TUnit]
+    __slots__ = ("instance",)
+
+    def __init__(self, instance: SchematicInstance[T]) -> None:
+        self.instance = instance
 
     def __getitem__(self, key: str | tuple[str, int, int]) -> PortRef | PortArrayRef:
         """Return a port reference for a (standard or array) port."""
@@ -316,8 +341,25 @@ def __getitem__(self, key: str | tuple[str, int, int]) -> PortRef | PortArrayRef
         return PortRef(instance=self.instance.name, port=key)
 
 
-class SchematicInstance(
-    BaseModel, Generic[TUnit], extra="forbid", arbitrary_types_allowed=True
+class Pins[T: (int, float)]:
+    """Indexer for an instance's pins to produce `PinRef`.
+
+    Example:
+        `inst.pins["dc"]` -> `PinRef`
+    """
+
+    __slots__ = ("instance",)
+
+    def __init__(self, instance: SchematicInstance[T]) -> None:
+        self.instance = instance
+
+    def __getitem__(self, key: str) -> PinRef:
+        """Return a pin reference."""
+        return PinRef(instance=self.instance.name, pin=key)
+
+
+class SchematicInstance[T: (int, float)](
+    BaseModel, extra="forbid", arbitrary_types_allowed=True
 ):
     """Instance record within a schematic.
 
@@ -343,10 +385,10 @@ class SchematicInstance(
     name: str = Field(exclude=True, frozen=True)
     component: str
     settings: dict[str, JSONSerializable] = Field(default_factory=dict)
-    array: RegularArray[TUnit] | Array[TUnit] | None = None
+    array: RegularArray[T] | Array[T] | None = None
     kcl: KCLayout = Field(default_factory=get_default_kcl)
     virtual: bool = False
-    _schematic: TSchematic[TUnit] = PrivateAttr()
+    _schematic: TSchematic[T] = PrivateAttr()
 
     @field_validator("kcl", mode="before")
     @classmethod
@@ -360,32 +402,40 @@ def _serialize_kcl(self, kcl: KCLayout) -> str:
         return kcl.name
 
     @property
-    def parent_schematic(self) -> TSchematic[TUnit]:
+    def parent_schematic(self) -> TSchematic[T]:
         if self._schematic is None:
             raise RuntimeError("Schematic instance has no parent set.")
         return self._schematic
 
     @property
-    def placement(self) -> MirrorPlacement | Placement[TUnit] | None:
+    def placement(self) -> Placement[T] | MirrorPlacement | None:
         return self.parent_schematic.placements.get(self.name)
 
+    def get_placement(self) -> Placement[T] | MirrorPlacement:
+        placement = self.placement
+        if placement is None:
+            raise ValueError(
+                f"SchematicInstance {self.name!r} does not have a placement"
+            )
+        return placement
+
     def place(
         self,
-        x: TUnit | PortRef | AnchorRefX = 0,
-        y: TUnit | PortRef | AnchorRefY = 0,
-        dx: TUnit = 0,
-        dy: TUnit = 0,
-        orientation: float = 0,
+        x: T | PortRef | AnchorRefX = 0,
+        y: T | PortRef | AnchorRefY = 0,
+        dx: T = 0,
+        dy: T = 0,
+        orientation: float | PortRef = 0,
         mirror: bool = False,
         anchor: FixedAnchorDict | PortAnchorDict | None = None,
-    ) -> Placement[TUnit]:
+    ) -> Placement[T]:
         """Declare placement/orientation/mirroring for this instance.
 
         Returns:
             The created `Placement` (also stored under `self.placement` which references
             `self.parent_schematic.placements`).
         """
-        placement = Placement[TUnit](
+        placement = Placement[T](
             x=x,
             y=y,
             dx=dx,
@@ -415,8 +465,8 @@ def __getitem__(self, value: str | tuple[str, int, int]) -> PortRef:
     def connect(
         self,
         port: str | tuple[str, int, int],
-        other: Port[TUnit] | PortRef,
-    ) -> Connection[TUnit]:
+        other: Port[T] | PortRef,
+    ) -> Connection[T]:
         """Connect one of my ports to `other` and register it on the schematic."""
         if isinstance(port, str):
             pref = PortRef(instance=self.name, port=port)
@@ -424,8 +474,8 @@ def connect(
             pref = PortArrayRef(
                 instance=self.name, port=port[0], ia=port[1], ib=port[2]
             )
-        conn = Connection[TUnit]((other, pref))
-        self.parent_schematic.connections.append(conn)
+        conn = Connection[T](net=(other, pref))
+        self.parent_schematic.nets.append(conn)
         return conn
 
     @property
@@ -445,9 +495,13 @@ def mirror(self, value: bool) -> None:
             self.placement.mirror = value
 
     @cached_property
-    def ports(self) -> Ports[TUnit]:
+    def ports(self) -> Ports[T]:
         return Ports(instance=self)
 
+    @cached_property
+    def pins(self) -> Pins[T]:
+        return Pins(instance=self)
+
     @property
     def xmin(self) -> AnchorRefX:
         return AnchorRefX(instance=self.name, x="left")
@@ -472,38 +526,29 @@ def center(self) -> tuple[AnchorRefX, AnchorRefY]:
         )
 
 
-class Route(BaseModel, Generic[TUnit], extra="forbid"):
+class Route[T: (int, float)](BaseModel, extra="forbid"):
     """Bundle of `Link`s routed using a named strategy.
 
     Attributes:
         name: Route identifier (key in `(D)Schematic.routes`).
-        links: Pairs of start/end ports to be routed.
         routing_strategy: Name of routing function registered on the
             `Schematic.create_cell` or registered in `KCLayout` if none are given.
         settings: Keyword arguments forwarded to the routing strategy.
     """
 
     name: str = Field(exclude=True)
-    links: list[Link[TUnit]]
     routing_strategy: str = "route_bundle"
     settings: dict[str, JSONSerializable]
 
     @model_validator(mode="before")
     @classmethod
-    def _parse_links(cls, data: dict[str, Any]) -> dict[str, Any]:
-        links = cast("dict[str, str]| None", data.get("links"))
-
-        if isinstance(links, dict):
-            data["links"] = [
-                (tuple(str(k).rsplit(",", 1)), tuple(str(v).rsplit(",", 1)))
-                for k, v in links.items()
-            ]
+    def _parse_nets(cls, data: dict[str, Any]) -> dict[str, Any]:
         if "settings" not in data:
             data["settings"] = {}
         return data
 
 
-class Port(BaseModel, Generic[TUnit], extra="forbid"):
+class Port[T: (int, float)](BaseModel, extra="forbid"):
     """A schematic-level, placeable port.
 
     This port is on the Schematic's cell's level, i.e. equivalent of `(D)KCell.ports`
@@ -523,42 +568,70 @@ class Port(BaseModel, Generic[TUnit], extra="forbid"):
 
     Methods:
         is_placeable: True if all references resolve to already placed instances.
-        place: Materialize the port on a `KCell` using provided cross-sections.
     """
 
     name: str = Field(exclude=True)
-    x: TUnit | PortRef | AnchorRefX
-    y: TUnit | PortRef | AnchorRefY
-    dx: TUnit = cast("TUnit", 0)
-    dy: TUnit = cast("TUnit", 0)
+    x: T | PortRef | AnchorRefX
+    y: T | PortRef | AnchorRefY
+    dx: T = 0
+    dy: T = 0
     cross_section: str
     orientation: Literal[0, 90, 180, 270] | PortRef
 
-    def __lt__(self, other: Port[Any] | PortRef) -> bool:
-        if isinstance(other, Port):
-            return self._as_tuple() < other._as_tuple()
-        return True
+    def __lt__(self, other: Port[T] | PortRef) -> bool:
+        if not isinstance(other, Port):
+            return True
+        if self.name != other.name:
+            return self.name < other.name
+        x = self.x
+        ox = other.x
+        if _is_real(x):
+            if _is_real(ox):
+                return x < ox
+            if x != ox:
+                return False
+        elif _is_port_ref(x):
+            if _is_real(ox):
+                return False
+            if _is_port_ref(ox):
+                return x < ox
+            return True
+        else:
+            if _is_real(ox) or _is_port_ref(ox):
+                return False
+            return x < ox  # ty:ignore[unsupported-operator]
+        y = self.y
+        oy = other.y
+        if _is_real(y):
+            if _is_real(oy):
+                return y < oy
+            if y != oy:
+                return False
+        elif _is_port_ref(y):
+            if _is_real(oy):
+                return False
+            if _is_port_ref(oy):
+                return y < oy
+            return True
+        else:
+            if _is_real(oy) or _is_port_ref(oy):
+                return False
+            return y < oy  # ty:ignore[unsupported-operator]
+
+        if self.dx != other.dx:
+            return self.dx < other.dx
+        if self.dy != other.dy:
+            return self.dy < other.dy
+        if self.cross_section != other.cross_section:
+            return self.cross_section < other.cross_section
+        if isinstance(self.orientation, int | float):
+            if isinstance(other, int | float):
+                return self.orientation < other.orientation
+            return False
+        if isinstance(other.orientation, PortRef):
+            return self.orientation < other.orientation
 
-    def _as_tuple(
-        self,
-    ) -> tuple[
-        str,
-        TUnit | PortRef | AnchorRefX,
-        TUnit | PortRef | AnchorRefY,
-        TUnit,
-        TUnit,
-        Literal[0, 90, 180, 270] | PortRef,
-        str,
-    ]:
-        return (
-            self.name,
-            self.x,
-            self.y,
-            self.dx,
-            self.dy,
-            self.orientation,
-            self.cross_section,
-        )
+        return True
 
     def is_placeable(self, placed_instances: set[str]) -> bool:
         placeable = True
@@ -570,79 +643,6 @@ def is_placeable(self, placed_instances: set[str]) -> bool:
             placeable = placeable and self.orientation.instance in placed_instances
         return placeable
 
-    def place(
-        self,
-        cell: KCell,
-        schematic: TSchematic[TUnit],
-        name: str,
-        cross_sections: Mapping[str, CrossSection | DCrossSection],
-    ) -> KCellPort:
-        if isinstance(self.x, PortRef):
-            if isinstance(self.x, PortArrayRef):
-                x: float = (
-                    cell.insts[self.x.instance]
-                    .ports[self.x.port, self.x.ia, self.x.ib]
-                    .x
-                )
-            else:
-                x = cell.insts[self.x.instance].ports[self.x.port].x
-        elif isinstance(self.x, AnchorRefX):
-            match self.x.x:
-                case "left":
-                    x = cell.insts[self.x.instance].xmin
-                case "center":
-                    x = cell.insts[self.x.instance].bbox().center().x
-                case "right":
-                    x = cell.insts[self.x.instance].xmax
-        else:
-            x = self.x
-        x += self.dx
-        if isinstance(self.y, PortRef):
-            if isinstance(self.y, PortArrayRef):
-                y: float = (
-                    cell.insts[self.y.instance]
-                    .ports[self.y.port, self.y.ia, self.y.ib]
-                    .y
-                )
-            else:
-                y = cell.insts[self.y.instance].ports[self.y.port].y
-        elif isinstance(self.y, AnchorRefY):
-            match self.y.y:
-                case "bottom":
-                    y = cell.insts[self.y.instance].ymin
-                case "center":
-                    y = cell.insts[self.y.instance].bbox().center().y
-                case "top":
-                    y = cell.insts[self.y.instance].ymax
-        else:
-            y = self.y
-        y += self.dy
-        if isinstance(self.orientation, PortRef):
-            orientation = (
-                cell.insts[self.orientation.instance]
-                .ports[self.orientation.port]
-                .orientation
-            )
-        else:
-            orientation = self.orientation
-
-        if schematic.unit == "dbu":
-            return cell.create_port(
-                dcplx_trans=kdb.DCplxTrans(
-                    rot=orientation,
-                    x=cell.kcl.to_um(cast("int", x)),
-                    y=cell.kcl.to_um(cast("int", y)),
-                ),
-                cross_section=cross_sections[self.cross_section],
-                name=self.name,
-            )
-
-        return cell.create_port(
-            dcplx_trans=kdb.DCplxTrans(rot=orientation, x=x, y=y),
-            cross_section=cross_sections[self.cross_section],
-            name=self.name,
-        )
-
     def __str__(self) -> str:
         return self.as_python_str()
 
@@ -659,32 +659,93 @@ def as_python_str(self, schematic_name: str = "schematic") -> str:
         return f"{schematic_name}.ports[{self.name!r}]"
 
 
-class Link(
-    RootModel[
-        tuple[
-            PortArrayRef | PortRef | Port[TUnit], PortArrayRef | PortRef | Port[TUnit]
-        ]
-    ],
-    Generic[TUnit],
-):
+class PinRef(BaseModel, extra="forbid"):
+    """Reference to a pin on a schematic instance.
+
+    Produced by ``inst.pins["name"]`` and used as the ``pin`` argument of
+    `TSchematic.add_pin` to expose an instance pin as a top-level
+    schematic pin.
+    """
+
+    instance: str
+    pin: str
+
+    @property
+    def name(self) -> str:
+        return self.pin
+
+    def __lt__(self, other: PinRef) -> bool:
+        return (self.instance, self.pin) < (other.instance, other.pin)
+
+    def __hash__(self) -> int:
+        return hash((self.instance, self.pin))
+
+    def __eq__(self, other: object) -> bool:
+        return (
+            isinstance(other, PinRef)
+            and self.instance == other.instance
+            and self.pin == other.pin
+        )
+
+    def as_python_str(self, inst_name: str | None = None) -> str:
+        return f"{inst_name or self.instance}.pins[{self.pin!r}]"
+
+
+class Pin(BaseModel, extra="forbid"):
+    """A schematic-level pin grouping one or more schematic-level ports.
+
+    The pin will be materialized on the resulting cell at ``create_cell``
+    time as a `kfactory.Pin`. The ``ports`` list references entries
+    in `TSchematic.ports` by name.
+
+    Attributes:
+        name: Pin name (key in `TSchematic.pins`).
+        ports: Names of schematic-level ports that belong to this pin.
+        pin_type: Pin type (default ``"DC"``).
+        info: Free-form info attached to the pin.
+    """
+
+    name: str = Field(exclude=True)
+    ports: list[str]
+    pin_type: str = "DC"
+    info: dict[str, JSONSerializable] = Field(default_factory=dict)
+
+
+class SchematicNet[T: (int, float)](BaseModel):
     """Undirected association between two ports (refs or schematic ports).
 
     The pair is stored in sorted order to ensure stable equality and hashing.
     """
 
-    root: tuple[
-        PortArrayRef | PortRef | Port[TUnit], PortArrayRef | PortRef | Port[TUnit]
-    ]
+    net: tuple[PortRef | Port[T], PortRef | Port[T]]
+
+
+class RouteNet[T: (int, float)](SchematicNet[T]):
+    """Undirected association between two ports (refs or schematic ports).
+
+    The pair is stored in sorted order to ensure stable equality and hashing.
+    """
+
+    route: str
+    net: tuple[PortRef, ...]
+
+
+class VirtualConnection[T: (int, float)](SchematicNet[T]):
+    type: Literal["virtual"] = "virtual"
+    net: tuple[PortRef | Port[T], ...]
 
     @model_validator(mode="after")
     def _sort_data(self) -> Self:
-        self.root = tuple(sorted(self.root))  # type: ignore[assignment]
+        self.net = tuple(sorted(self.net))
+        if isinstance(self.net[1], Port):
+            raise TypeError(
+                "Two cell ports cannot be connected together. This would cause an "
+                "invalid netlist."
+            )
         return self
 
 
-class Connection(
-    RootModel[tuple[Port[TUnit] | PortArrayRef | PortRef, PortArrayRef | PortRef]]
-):
+class Connection[T: (int, float)](SchematicNet[T]):
     """Hard connection between two ports.
 
     Enforced as {PortRef | PortArrayRef | Port} x {PortRef | PortArrayRef}.
@@ -694,22 +755,40 @@ class Connection(
         TypeError: If connection attempts to join two `Port` objects.
     """
 
-    root: tuple[PortArrayRef | PortRef | Port[TUnit], PortArrayRef | PortRef]
+    net: tuple[PortArrayRef | PortRef | Port[T], PortArrayRef | PortRef] = Field(
+        frozen=True
+    )
 
-    @model_validator(mode="after")
-    def _sort_data(self) -> Self:
-        self.root = tuple(sorted(self.root))  # type: ignore[assignment]
-        if isinstance(self.root[1], Port):
+    @field_validator("net", mode="before")
+    @classmethod
+    def _sort_and_validate_net(
+        cls,
+        v: tuple[PortArrayRef | PortRef | Port[T], ...],
+    ) -> tuple[PortArrayRef | PortRef | Port[T], ...]:
+        # Allow list/iterable input; normalize to a 2-tuple
+        if not isinstance(v, tuple):
+            v = tuple(v)
+        if len(v) != 2:
+            raise TypeError("net must contain exactly two endpoints")
+
+        a, b = v
+
+        # Sort without mutating the model instance (important for frozen fields/models)
+        net_sorted = tuple(sorted((a, b)))
+
+        # After sorting, ensure the RHS isn't a cell Port (your original invariant)
+        if isinstance(net_sorted[1], Port):
             raise TypeError(
                 "Two cell ports cannot be connected together. This would cause an "
                 "invalid netlist."
             )
-        return self
+
+        return net_sorted
 
     @classmethod
     def from_list(
         cls, data: list[Any] | tuple[Any, ...] | dict[str, Any]
-    ) -> Connection[TUnit]:
+    ) -> Connection[T]:
         """Parse a Connection from a compact list/tuple/dict representation.
 
         Used for parsing legacy gdsfactory like connections.
@@ -736,17 +815,183 @@ def from_list(
             else:
                 p2 = {"instance": data[1][0], "port": data[1][1]}
 
-            return Connection.model_validate((p1, p2))
-        return Connection(**data)
+            return Connection[T].model_validate({"net": (p1, p2)})
+        return Connection[T](**data)
 
 
-class TSchematic(BaseModel, Generic[TUnit], extra="forbid"):
-    """Schematic of a cell / component.
+class Constraint(BaseModel, ABC, arbitrary_types_allowed=True):
+    """Base class for schematic constraints.
 
-    Parameters:
-        unit: Base coordinate unit ("dbu" or "um"). Fixed by subclass.
-        kcl: `KCLayout` context (excluded from model serialization). Needed for
-            referencing and creation of the cell.
+    Constraints operate in two phases:
+
+    1. **enforce** — called by the routing function during the post-process phase,
+       before instances are placed. Receives the `ManhattanRouter` objects and routing
+       context kwargs (e.g. ``separation``, ``bend90_radius``).
+
+    2. **check** — called by `TSchematic.create_cell` after routing is complete.
+       Receives the materialized cell, the schematic, the resolved `Instance` objects
+       for `instance_names`, and the `ManhattanRoute` results for `route_names`.
+
+    Attributes:
+        route_names: Names of the routes this constraint applies to.
+        instance_names: Names of the instances this constraint applies to.
+        on_failure: Behaviour when `check` returns False. ``"error"`` raises a
+            `ValueError`. ``"show_error"`` additionally opens the cell in KLayout
+            with an lyrdb marker database. ``None`` silently ignores failures.
+    """
+
+    route_names: list[str]
+    instance_names: list[str] = Field(default=[])
+    on_failure: Literal["error", "show_error"] | None = "error"
+    _routes: dict[str | None, list[ManhattanRoute]] = PrivateAttr(default={})
+    _routers: dict[str | None, list[ManhattanRouter]] = PrivateAttr(default={})
+
+    @abstractmethod
+    def enforce(
+        self,
+        c: KCell,
+        routers: Sequence[ManhattanRouter],
+        route_name: str | None,
+    ) -> None:
+        """Enforce the constraint on the routers before placement.
+
+        Called by the routing function. ``**kwargs`` contains routing context
+        such as ``separation`` and ``bend90_radius``.
+        """
+        ...
+
+    @abstractmethod
+    def check(
+        self,
+        c: KCell,
+        schematic: TSchematic[Any],
+        instances: dict[str, Instance],
+        routes: dict[str, list[ManhattanRoute]],
+    ) -> bool:
+        """Return True if the constraint is satisfied after routing."""
+        ...
+
+    def lyrdb_markers(
+        self,
+        c: KCell,
+        schematic: TSchematic[Any],
+        instances: dict[str, Instance],
+        routes: dict[str, list[ManhattanRoute]],
+    ) -> rdb.ReportDatabase:
+        """Build an lyrdb marker database for failing routes.
+
+        The default implementation highlights route backbones. Override for
+        constraint-specific visualisation.
+        """
+        from klayout import rdb as _rdb
+
+        db = _rdb.ReportDatabase(f"{self.__class__.__name__} Constraint Failure")
+        cat = db.create_category("Failing Routes")
+        cell = db.create_cell(c.name)
+        for name in self.route_names:
+            for route in routes.get(name, []):
+                if len(route.backbone) >= 2:
+                    item = db.create_item(cell, cat)
+                    item.add_value(
+                        kdb.DPath(
+                            [kdb.DPoint(p) * c.kcl.dbu for p in route.backbone],
+                            route.start_port.dwidth,
+                        ).polygon()
+                    )
+        return db
+
+
+class PathLengthMatch(Constraint):
+    """Constraint that equalises optical path lengths across a set of routes.
+
+    Uses the loop-based path-length matching already built into the optical
+    router.  The `enforce` method injects meander loops into the shorter
+    routers so that all routes reach the same length before instances are
+    placed.  The `check` method verifies the final `ManhattanRoute` lengths
+    are within `tolerance` of each other.
+
+    Attributes:
+        loops: Number of meander loops to use per route.
+        loop_side: Which side of the route to place the loop on.
+        loop_position: Where along the route to place the loop.
+        element: Index of the straight segment to insert the loop into.
+        tolerance: Maximum allowed length difference after enforcement.
+    """
+
+    loops: int = 1
+    loop_side: int = -1  # LoopSide.left
+    loop_position: int = -1  # LoopPosition.start
+    element: int = -1
+    tolerance: int = 0
+    length: int | None = None
+    all: bool = False
+
+    def enforce(
+        self,
+        c: KCell,
+        routers: Sequence[ManhattanRouter],
+        route_name: str | None,
+    ) -> None:
+        from .routing.optical import LoopPosition, LoopSide, path_length_match
+
+        if self.all or (len(self.route_names) - len(self._routers)) == 1:
+            path_length_match(
+                routers=routers,
+                element=self.element,
+                loops=self.loops,
+                loop_side=LoopSide(self.loop_side),
+                loop_position=LoopPosition(self.loop_position),
+                path_length=self.length,
+            )
+
+        self._routers[route_name] = list(routers)
+
+    def check(
+        self,
+        c: KCell,
+        schematic: TSchematic[Any],
+        instances: dict[str, Instance],
+        routes: dict[str, list[ManhattanRoute]],
+    ) -> bool:
+        all_routes = [r for name in self.route_names for r in routes.get(name, [])]
+        if not all_routes:
+            return True
+        lengths = [r.length for r in all_routes]
+        return (max(lengths) - min(lengths)) <= self.tolerance
+
+    def lyrdb_markers(
+        self,
+        c: KCell,
+        schematic: TSchematic[Any],
+        instances: dict[str, Instance],
+        routes: dict[str, list[ManhattanRoute]],
+    ) -> rdb.ReportDatabase:
+        from klayout import rdb as _rdb
+
+        all_routes = [r for name in self.route_names for r in routes.get(name, [])]
+        lengths = [r.length for r in all_routes] if all_routes else []
+        target = max(lengths) if lengths else 0
+
+        db = _rdb.ReportDatabase("PathLengthMatch Constraint Failure")
+        cat = db.create_category("Length Mismatch")
+        cell = db.create_cell(c.name)
+        for name in self.route_names:
+            for route in routes.get(name, []):
+                delta = target - route.length
+                if delta > self.tolerance and len(route.backbone) >= 2:
+                    item = db.create_item(cell, cat)
+                    item.add_value(
+                        kdb.DPath(
+                            [kdb.DPoint(p) * c.kcl.dbu for p in route.backbone],
+                            route.start_port.dwidth,
+                        ).polygon()
+                    )
+                    item.add_value(f"length={route.length}, delta={delta}")
+        return db
+
+
+class TSchematic[T: (int, float)](BaseModel, extra="forbid"):
+    """Schematic of a cell / component.
 
     Attributes:
         name: Optional schematic name.
@@ -755,18 +1000,26 @@ class TSchematic(BaseModel, Generic[TUnit], extra="forbid"):
         connections: List of `Connection`s.
         routes: Mapping of route name -> `Route`.
         ports: Mapping of port name -> `Port`/`PortRef`/`PortArrayRef`.
+        pins: Mapping of pin name -> `Pin`/`PinRef`. Pins are top-level
+            groupings of schematic ports (purely structural — they do not
+            participate in nets/connections).
         info: dict which will be mapped to `KCell.info` (or any other derivate like
             Component).
+        unit: Base coordinate unit ("dbu" or "um"). Fixed by subclass.
+        kcl: `KCLayout` context (excluded from model serialization). Needed for
+            referencing and creation of the cell.
     """
 
     name: str | None = None
-    instances: dict[str, SchematicInstance[TUnit]] = Field(default_factory=dict)
-    placements: dict[str, MirrorPlacement | Placement[TUnit]] = Field(
-        default_factory=dict
+    instances: dict[str, SchematicInstance[T]] = Field(default_factory=dict)
+    placements: dict[str, Placement[T] | MirrorPlacement] = Field(default_factory=dict)
+    nets: list[RouteNet[T] | Connection[T] | VirtualConnection[T]] = Field(
+        default_factory=list
     )
-    connections: list[Connection[TUnit]] = Field(default_factory=list)
-    routes: dict[str, Route[TUnit]] = Field(default_factory=dict)
-    ports: dict[str, Port[TUnit] | PortRef | PortArrayRef] = Field(default_factory=dict)
+    routes: dict[str, Route[T]] = Field(default_factory=dict)
+    ports: dict[str, Port[T] | PortArrayRef | PortRef] = Field(default_factory=dict)
+    pins: dict[str, Pin | PinRef] = Field(default_factory=dict)
+    constraints: list[Constraint] = Field(default_factory=list)
     kcl: KCLayout = Field(exclude=True, default_factory=get_default_kcl)
     unit: Literal["dbu", "um"]
     info: dict[str, JSONSerializable] = Field(default_factory=dict)
@@ -776,11 +1029,11 @@ def create_inst(
         name: str,
         component: str,
         settings: dict[str, JSONSerializable] | None = None,
-        array: RegularArray[TUnit] | Array[TUnit] | None = None,
-        placement: Placement[TUnit] | None = None,
+        array: RegularArray[T] | Array[T] | None = None,
+        placement: Placement[T] | None = None,
         kcl: KCLayout | None = None,
         virtual: bool = False,
-    ) -> SchematicInstance[TUnit]:
+    ) -> SchematicInstance[T]:
         """Create a schema instance.
 
         This would be an SREF or AREF in the resulting GDS cell.
@@ -800,7 +1053,7 @@ def create_inst(
         Returns:
             Schematic instance representing the args.
         """
-        inst = SchematicInstance[TUnit].model_validate(
+        inst = SchematicInstance[T].model_validate(
             {
                 "name": name,
                 "component": component,
@@ -845,12 +1098,12 @@ def create_port(
         self,
         name: str,
         cross_section: str,
-        x: PortRef | PortArrayRef | TUnit,
-        y: PortRef | PortArrayRef | TUnit,
-        dx: TUnit = 0,
-        dy: TUnit = 0,
+        x: PortRef | PortArrayRef | T,
+        y: PortRef | PortArrayRef | T,
+        dx: T = 0,
+        dy: T = 0,
         orientation: Literal[0, 90, 180, 270] = 0,
-    ) -> Port[TUnit]:
+    ) -> Port[T]:
         """Create a schematic-level, placeable port.
 
         Returns:
@@ -868,9 +1121,171 @@ def create_port(
         self.ports[p.name] = p
         return p
 
+    def place_port(
+        self,
+        name: str,
+        cell: KCell,
+        cross_sections: Mapping[
+            str,
+            CrossSection
+            | DCrossSection
+            | AsymmetricCrossSection
+            | DAsymmetricCrossSection,
+        ],
+    ) -> BasePort:
+        """Materialize a schematic-level port on `cell`.
+
+        Looks up `self.ports[name]` and dispatches by port type:
+
+        - `PortArrayRef` / `PortRef`: forwards an instance port through
+          `cell.add_port` (using `cell.vinsts` for virtual instances).
+        - `Port[T]`: resolves `(x, y, orientation)` (which may themselves be
+          references), then creates the port via `cell.create_port`.
+        """
+        port = self.ports[name]
+        if isinstance(port, PortArrayRef):
+            if self.instances[port.instance].virtual:
+                return cell.add_port(
+                    port=cell.vinsts[port.instance].ports[port.port, port.ia, port.ib],
+                    name=name,
+                ).base
+            return cell.add_port(
+                port=cell.insts[port.instance].ports[port.port, port.ia, port.ib],
+                name=name,
+            ).base
+        if isinstance(port, PortRef):
+            if self.instances[port.instance].virtual:
+                return cell.add_port(
+                    port=cell.vinsts[port.instance].ports[port.port], name=name
+                ).base
+            return cell.add_port(
+                port=cell.insts[port.instance].ports[port.port], name=name
+            ).base
+
+        if isinstance(port.x, PortRef):
+            if isinstance(port.x, PortArrayRef):
+                x: float = (
+                    cell.insts[port.x.instance]
+                    .ports[port.x.port, port.x.ia, port.x.ib]
+                    .x
+                )
+            else:
+                x = cell.insts[port.x.instance].ports[port.x.port].x
+        elif isinstance(port.x, AnchorRefX):
+            match port.x.x:
+                case "left":
+                    x = cell.insts[port.x.instance].xmin
+                case "center":
+                    x = cell.insts[port.x.instance].bbox().center().x
+                case "right":
+                    x = cell.insts[port.x.instance].xmax
+        else:
+            x = cast("int | T", port.x)
+        x += port.dx
+        if isinstance(port.y, PortRef):
+            if isinstance(port.y, PortArrayRef):
+                y: float = (
+                    cell.insts[port.y.instance]
+                    .ports[port.y.port, port.y.ia, port.y.ib]
+                    .y
+                )
+            else:
+                y = cell.insts[port.y.instance].ports[port.y.port].y
+        elif isinstance(port.y, AnchorRefY):
+            match port.y.y:
+                case "bottom":
+                    y = cell.insts[port.y.instance].ymin
+                case "center":
+                    y = cell.insts[port.y.instance].bbox().center().y
+                case "top":
+                    y = cell.insts[port.y.instance].ymax
+        else:
+            y = cast("int | T", port.y)
+        y += port.dy
+        if isinstance(port.orientation, PortRef):
+            orientation = (
+                cell.insts[port.orientation.instance]
+                .ports[port.orientation.port]
+                .orientation
+            )
+        else:
+            orientation = port.orientation
+
+        if self.unit == "dbu":
+            return cell.create_port(
+                dcplx_trans=kdb.DCplxTrans(
+                    rot=orientation,
+                    x=cell.kcl.to_um(cast("int", x)),
+                    y=cell.kcl.to_um(cast("int", y)),
+                ),
+                cross_section=cross_sections[port.cross_section],
+                name=name,
+            ).base
+
+        return cell.create_port(
+            dcplx_trans=kdb.DCplxTrans(rot=orientation, x=x, y=y),
+            cross_section=cross_sections[port.cross_section],
+            name=name,
+        ).base
+
+    def create_pin(
+        self,
+        name: str,
+        ports: list[str],
+        pin_type: str = "DC",
+        info: dict[str, JSONSerializable] | None = None,
+    ) -> Pin:
+        """Create a schematic-level pin grouping existing schematic ports.
+
+        Args:
+            name: Pin name. Must be unique within the schematic.
+            ports: Names of schematic ports (entries of ``self.ports``) that
+                belong to this pin. Each name must already be registered as a
+                schematic port.
+            pin_type: Pin type (default ``"DC"``).
+            info: Optional info dict attached to the pin.
+
+        Returns:
+            The created `Pin`, also stored in `self.pins`.
+
+        Raises:
+            ValueError: If a pin with ``name`` already exists, ``ports`` is
+                empty, or any port name is not present in ``self.ports``.
+        """
+        if name in self.pins:
+            raise ValueError(f"Pin with name {name!r} already exists")
+        if not ports:
+            raise ValueError(
+                f"At least one port must be provided to create pin named {name!r}"
+            )
+        missing = [p for p in ports if p not in self.ports]
+        if missing:
+            raise ValueError(
+                f"Cannot create pin {name!r}: port(s) {missing!r} are not "
+                "registered as schematic ports."
+            )
+        pin = Pin(name=name, ports=list(ports), pin_type=pin_type, info=info or {})
+        self.pins[name] = pin
+        return pin
+
+    def add_pin(self, name: str | None = None, *, pin: PinRef) -> None:
+        """Expose an existing instance pin as a schematic top-level pin.
+
+        Args:
+            name: Name for the schematic pin; defaults to the underlying pin name.
+            pin: Pin reference to expose.
+
+        Raises:
+            ValueError: If a schematic pin with ``name`` already exists.
+        """
+        name = name or pin.pin
+        if name in self.pins:
+            raise ValueError(f"Pin with name {name!r} already exists")
+        self.pins[name] = pin
+
     def create_connection(
-        self, port1: PortRef | Port[TUnit], port2: PortRef
-    ) -> Connection[TUnit]:
+        self, port1: PortRef | Port[T], port2: PortRef
+    ) -> Connection[T]:
         """Create and register a connection between two instance ports.
 
         Args:
@@ -884,7 +1299,7 @@ def create_connection(
             The created `Connection`.
         """
 
-        conn = Connection[TUnit]((port1, port2))
+        conn = Connection[T](net=(port1, port2))
         if isinstance(port1, PortRef):
             if port1.instance not in self.instances:
                 raise ValueError(
@@ -896,7 +1311,7 @@ def create_connection(
             raise ValueError(
                 f"Cannot create connection to unknown instance {port2.instance}"
             )
-        self.connections.append(conn)
+        self.nets.append(conn)
         return conn
 
     def netlist(
@@ -916,34 +1331,15 @@ def netlist(
         netlist is sorted for stable output.
         """
 
-        nets: list[Net] = []
-        if self.routes is not None:
-            nets.extend(
-                [
-                    Net(
-                        [
-                            NetlistPort(name=port.name)
-                            if isinstance(port, Port)
-                            else port
-                            for port in link.root
-                        ]
-                    )
-                    for route in self.routes.values()
-                    for link in route.links
-                ]
-            )
-        if self.connections:
-            nets.extend(
+        nets = [
+            Net(
                 [
-                    Net(
-                        [
-                            NetlistPort(name=p.name) if isinstance(p, Port) else p
-                            for p in connection.root
-                        ]
-                    )
-                    for connection in self.connections
+                    NetlistPort(name=p.name) if isinstance(p, Port) else p
+                    for p in net.net
                 ]
             )
+            for net in self.nets
+        ]
 
         if self.ports:
             nets.extend(
@@ -954,16 +1350,15 @@ def netlist(
                 ]
             )
         if add_defaults:
-            kcl_factories: dict[
-                str, Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]
-            ]
             if external_factories is None:
                 all_factories: dict[
                     str,
                     dict[str, Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]],
                 ] = defaultdict(dict)
                 for kcl_ in kcls.values():
-                    kcl_factories = {f.name: f._f for f in kcl_.factories._all}
+                    kcl_factories: dict[
+                        str, Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]
+                    ] = {f.name: f._f for f in kcl_.factories._all}
                     kcl_factories.update(
                         {vf.name: vf._f for vf in kcl_.virtual_factories._all}
                     )
@@ -972,9 +1367,13 @@ def netlist(
                 all_factories = external_factories.copy()
             if factories is not None:
                 all_factories[self.kcl.name] = factories
-            nl = Netlist(
-                instances={
-                    inst.name: NetlistInstance(
+            nl = Netlist()
+            for name in self.ports:
+                nl.create_port(name)
+            if self.instances:
+                for inst in self.instances.values():
+                    na, nb = _array_dims(inst.array)
+                    nl.create_inst(
                         name=inst.name,
                         kcl=inst.kcl.name,
                         component=inst.component,
@@ -984,37 +1383,34 @@ def netlist(
                                 all_factories[inst.kcl.name][inst.component]
                             ),
                         ),
+                        na=na,
+                        nb=nb,
                     )
-                    for inst in self.instances.values()
-                }
-                if self.instances
-                else {},
-                nets=nets,
-                ports=[NetlistPort(name=name) for name in self.ports],
-            )
+            for net in nets:
+                nl.add_net(net)
         else:
-            nl = Netlist(
-                instances={
-                    inst.name: NetlistInstance(
+            nl = Netlist()
+            for name in self.ports:
+                nl.create_port(name)
+            if self.instances:
+                for inst in self.instances.values():
+                    na, nb = _array_dims(inst.array)
+                    nl.create_inst(
                         name=inst.name,
                         kcl=inst.kcl.name,
                         component=inst.component,
                         settings=inst.settings,
+                        na=na,
+                        nb=nb,
                     )
-                    for inst in self.instances.values()
-                }
-                if self.instances
-                else {},
-                nets=nets,
-                ports=[NetlistPort(name=name) for name in self.ports],
-            )
+            for net in nets:
+                nl.add_net(net)
         nl.sort()
         return nl
 
     @model_validator(mode="before")
     @classmethod
     def _validate_schematic(cls, data: dict[str, Any]) -> dict[str, Any]:
-        data.pop("nets", None)
         data.pop("warnings", None)
         if not isinstance(data, dict):
             return data
@@ -1025,53 +1421,56 @@ def _validate_schematic(cls, data: dict[str, Any]) -> dict[str, Any]:
             for name, instance in instances.items():
                 instance["name"] = name
                 instance.pop("info", None)
+
+        nets: list[dict[str, str]] = data.get("nets", [])
+        built_nets: list[RouteNet[T] | Connection[T] | VirtualConnection[T]] = []
+        data["nets"] = built_nets
+        if nets:
+            built_nets.extend(
+                [
+                    VirtualConnection[T](
+                        net=(
+                            _get_instance_and_port(net["p1"]),
+                            _get_instance_and_port(net["p2"]),
+                        )
+                    )
+                    for net in nets
+                ]
+            )
+
+        connections = data.pop("connections", None)
+        if connections is not None and isinstance(connections, dict):
+            built_nets.extend(
+                [
+                    Connection[T](
+                        net=(
+                            _get_instance_and_port(conn_ref1),
+                            _get_instance_and_port(conn_ref2),
+                        )
+                    )
+                    for conn_ref1, conn_ref2 in connections.items()
+                ]
+            )
         routes = data.get("routes")
         if routes:
             for name, route in routes.items():
                 route["name"] = name
-        connections = data.get("connections")
-        if connections is not None and isinstance(connections, dict):
-            built_connections: list[Connection[TUnit]] = []
-            connections_: list[tuple[tuple[str, str], tuple[str, str]]] = [
-                (k.rsplit(",", 1), v.rsplit(",", 1)) for k, v in connections.items()
-            ]
-            for connection_ in connections_:
-                connection_0: (
-                    tuple[str, str] | tuple[tuple[str, tuple[int, ...]], str]
-                ) = connection_[0]
-                match = re.match(r"(.*?)(<\d+\.\d+>)$", connection_[0][0])
-                if match:
-                    connection_0 = (
-                        (
-                            match.group(1),
-                            tuple(
-                                int(j) for j in match.group(2).strip("<>").split(".")
-                            ),
-                        ),
-                        connection_[0][1],
-                    )
-                connection_1: (
-                    tuple[str, str] | tuple[tuple[str, tuple[int, ...]], str]
-                ) = connection_[1]
-                match = re.match(r"(.*?)(<\d+\.\d+>)$", connection_[1][0])
-                if match:
-                    connection_1 = (
-                        (
-                            match.group(1),
-                            tuple(
-                                int(j) for j in match.group(2).strip("<>").split(".")
+                built_nets.extend(
+                    [
+                        RouteNet[T](
+                            route=name,
+                            net=(
+                                _get_instance_and_port(link1),
+                                _get_instance_and_port(link2),
                             ),
-                        ),
-                        connection_[1][1],
-                    )
-                built_connections.append(
-                    Connection.from_list((connection_0, connection_1))
+                        )
+                        for link1, link2 in route["links"].items()
+                    ]
                 )
-            data["connections"] = built_connections
+                route.pop("links")
         placements = data.get("placements")
         if placements:
             for placement in placements.values():
-                anchor: FixedAnchorDict | None = None
                 if "port" in placement:
                     port = placement.pop("port")
                     if port in [
@@ -1089,7 +1488,7 @@ def _validate_schematic(cls, data: dict[str, Any]) -> dict[str, Any]:
                         placement["anchor"] = _anchor_mapping[port]
                     else:
                         placement["anchor"] = {"port": port}
-                anchor = placement.get("anchor", {})
+                anchor: FixedAnchorDict = placement.get("anchor", {})
                 if "xmin" in placement:
                     anchor["x"] = "left"
                     placement["x"] = placement.pop("xmin")
@@ -1127,8 +1526,28 @@ def _validate_schematic(cls, data: dict[str, Any]) -> dict[str, Any]:
 
                 if isinstance(placement.get("y"), str):
                     raise NotImplementedError
+        ports = data.get("ports")
+        if ports:
+            for name, port_val in ports.items():
+                if isinstance(port_val, str):
+                    ports[name] = _get_instance_and_port(port_val)
+        pins = data.get("pins")
+        if pins:
+            for name, pin in pins.items():
+                if isinstance(pin, str):
+                    inst, pname = pin.rsplit(",", 1)
+                    pins[name] = {"instance": inst, "pin": pname}
+                elif isinstance(pin, dict) and "ports" in pin:
+                    pin["name"] = name
         return data
 
+    @field_serializer("placements")
+    @classmethod
+    def _serialize_placements(
+        cls, v: dict[str, Placement[Any] | MirrorPlacement]
+    ) -> dict[str, dict[str, Any]]:
+        return {k: p.model_dump(warnings=False) for k, p in v.items()}
+
     @model_validator(mode="after")
     def assign_backrefs(self) -> Self:
         for inst in self.instances.values():
@@ -1142,14 +1561,20 @@ def create_cell(
             str, Callable[..., KCell] | Callable[..., DKCell] | Callable[..., VKCell]
         ]
         | None = None,
-        cross_sections: Mapping[str, CrossSection | DCrossSection] | None = None,
+        cross_sections: Mapping[
+            str,
+            CrossSection
+            | DCrossSection
+            | AsymmetricCrossSection
+            | DAsymmetricCrossSection,
+        ]
+        | None = None,
         routing_strategies: dict[
             str,
             Callable[
                 Concatenate[
                     ProtoTKCell[Any],
-                    Sequence[ProtoPort[Any]],
-                    Sequence[ProtoPort[Any]],
+                    Sequence[Sequence[ProtoPort[Any]]],
                     ...,
                 ],
                 Any,
@@ -1157,6 +1582,7 @@ def create_cell(
         ]
         | None = None,
         place_unknown: bool = False,
+        ignore_errors: bool = False,
     ) -> KC:
         """Materialize the schematic into a `KCell`/`DKCell`/`Component`.
 
@@ -1193,22 +1619,25 @@ def create_cell(
         # must be isolated from other islands either through no connection at all or
         # routes
 
+        connections = self.connections
+
         islands, instance_connections = _get_island_connections(
-            self.instances, self.connections
+            instances=self.instances, connections=connections
         )
 
         placed_insts: set[str] = set()
         placed_ports: set[str] = set()
 
         for name, port in self.ports.items():
+            if isinstance(port, PortRef | PortArrayRef):
+                continue
             if port.is_placeable(placed_instances=placed_insts):
-                p = port.place(
+                p = self.place_port(
+                    name,
                     cell=c,
-                    schematic=self,
-                    name=name,
                     cross_sections=cross_sections,
                 )
-                placed_ports.add(p.name)  # type: ignore[arg-type]
+                placed_ports.add(p.name)
 
         instances: dict[str, Instance | VInstance] = {}
         placed_islands: list[set[str]] = []
@@ -1227,10 +1656,10 @@ def create_cell(
                     schematic_island=island,
                     instances=instances,
                     connections=instance_connections,
-                    schematic_instances=self.instances,
+                    schematic_instances=self.instances,  # ty:ignore[invalid-argument-type]
                     placed_insts=placed_insts,
                     placed_ports=placed_ports,
-                    schematic=self,
+                    schematic=self,  # ty:ignore[invalid-argument-type]
                     cross_sections=cross_sections,
                     factories=factories,
                     place_unknown=place_unknown,
@@ -1238,55 +1667,87 @@ def create_cell(
                 placed_islands.append(island)
                 placed_insts |= island
 
+        nets_per_route = self.routes_nets()
+
         # routes
+        route_results: dict[str, list[Any]] = {}
         for route in self.routes.values():
-            start_ports: list[ProtoPort[Any]] = []
-            end_ports: list[ProtoPort[Any]] = []
-            for link in route.links:
-                l1, l2 = link.root[0], link.root[1]
-                if isinstance(l1, Port):
-                    p1: KCellPort | DKCellPort = c.ports[l1.name]
-                elif isinstance(l1, PortArrayRef):
-                    if self.instances[l1.instance].virtual:
-                        p1 = c.vinsts[l1.instance].ports[l1.port, l1.ia, l1.ib]
-                    else:
-                        p1 = c.insts[l1.instance].ports[l1.port, l1.ia, l1.ib]
-                elif self.instances[l1.instance].virtual:
-                    p1 = c.vinsts[l1.instance].ports[l1.port]
-                else:
-                    p1 = c.insts[l1.instance].ports[l1.port]
-                start_ports.append(p1)
-                if isinstance(l2, Port):
-                    p2: KCellPort | DKCellPort = c.ports[l2.name]
-                elif isinstance(l2, PortArrayRef):
-                    if self.instances[l2.instance].virtual:
-                        p2 = c.vinsts[l2.instance].ports[l2.port, l2.ia, l2.ib]
+            resolved_ports: list[tuple[ProtoPort[Any], ...]] = []
+            for net in nets_per_route[route.name]:
+                resolved_port_list: list[KCellPort | DKCellPort] = []
+                for port_ref in net.net:
+                    if isinstance(port_ref, Port):
+                        p: KCellPort | DKCellPort = c.ports[port_ref.name]
                     else:
-                        p2 = c.insts[l2.instance].ports[l2.port, l2.ia, l2.ib]
-                elif self.instances[l2.instance].virtual:
-                    p2 = c.vinsts[l2.instance].ports[l2.port]
-                else:
-                    p2 = c.insts[l2.instance].ports[l2.port]
-                end_ports.append(p2)
+                        inst = self.instances[port_ref.instance]
+                        target = (
+                            c.vinsts[port_ref.instance]
+                            if inst.virtual
+                            else c.insts[port_ref.instance]
+                        )
+                        if isinstance(port_ref, PortArrayRef):
+                            p = target.ports[port_ref.port, port_ref.ia, port_ref.ib]
+                        else:
+                            p = target.ports[port_ref.port]
+                    resolved_port_list.append(p)
+                resolved_ports.append(tuple(resolved_port_list))
             route_c = output_type(base=c.base)
+            relevant_constraints = [
+                ct for ct in self.constraints if route.name in ct.route_names
+            ]
+            extra_kwargs: dict[str, Any] = (
+                {"constraints": relevant_constraints} if relevant_constraints else {}
+            )
             if isinstance(route_c, KCell):
-                routing_strategies[route.routing_strategy](
-                    output_type(base=c.base), start_ports, end_ports, **route.settings
+                result = routing_strategies[route.routing_strategy](
+                    output_type(base=c.base),
+                    resolved_ports,
+                    **route.settings,
+                    **extra_kwargs,
                 )
             else:
-                routing_strategies[route.routing_strategy](
+                result = routing_strategies[route.routing_strategy](
                     output_type(base=c.base),
-                    [DKCellPort(base=sp.base) for sp in start_ports],
-                    [DKCellPort(base=ep.base) for ep in end_ports],
+                    [
+                        tuple(DKCellPort(base=p.base) for p in net_ports)
+                        for net_ports in resolved_ports
+                    ],
                     **route.settings,
+                    **extra_kwargs,
+                )
+            route_results[route.name] = result or []
+
+        # check constraints
+        if self.constraints:
+            failed: list[Constraint] = []
+            for ct in self.constraints:
+                resolved_instances = {
+                    n: c.insts[n] for n in ct.instance_names if n in c.insts
+                }
+                resolved_routes = {n: route_results.get(n, []) for n in ct.route_names}
+                if not ct.check(c, self, resolved_instances, resolved_routes):
+                    if ct.on_failure == "show_error":
+                        c_ = c.dup()
+                        c_.name = c.kcl._future_cell_name or c.name
+                        c_.show(
+                            lyrdb=ct.lyrdb_markers(
+                                c_, self, resolved_instances, resolved_routes
+                            )
+                        )
+                    if ct.on_failure in ("error", "show_error"):
+                        failed.append(ct)
+            if failed:
+                raise ValueError(
+                    f"Constraints not satisfied in schematic {self.name!r}:\n"
+                    + "\n".join(repr(ct) for ct in failed)
                 )
 
         # verify connections
-        port_connection_transformation_errors: list[Connection[TUnit]] = []
-        connection_transformation_errors: list[Connection[TUnit]] = []
-        for conn in self.connections:
-            c1 = conn.root[0]
-            c2 = conn.root[1]
+        port_connection_transformation_errors: list[Connection[T]] = []
+        connection_transformation_errors: list[Connection[T]] = []
+        for conn in connections:
+            c1 = conn.net[0]
+            c2 = conn.net[1]
             if isinstance(c1, Port):
                 p1 = c.ports[c1.name]
                 p2 = c.insts[c2.instance].ports[c2.port]
@@ -1315,36 +1776,134 @@ def create_cell(
                 if (t1 != t2 * kdb.DCplxTrans.R180) and (t1 != t2 * kdb.DCplxTrans.M90):
                     connection_transformation_errors.append(conn)
 
-        if connection_transformation_errors or port_connection_transformation_errors:
+        if not ignore_errors and (
+            connection_transformation_errors or port_connection_transformation_errors
+        ):
             raise ValueError(
                 f"Not all connections in schema {self.name}"
                 " could be satisfied. Missing or wrong connections:\n"
                 + "\n".join(
-                    f"{conn.root[0]} - {conn.root[1]}"
+                    f"{conn.net[0]} - {conn.net[1]}"
                     for conn in connection_transformation_errors
                     + port_connection_transformation_errors
                 )
             )
+
+        # materialize pins on the resulting cell. ports must already be created.
+        if self.pins:
+            # map (instance, original port name) -> schematic-port key name
+            # only PortRef-style schematic ports map to instance ports; cell
+            # port names equal the schematic-port keys (see PortRef.place).
+            ref_to_schematic_port: dict[tuple[str, str], str] = {}
+            for sname, sport in self.ports.items():
+                if isinstance(sport, PortRef) and not isinstance(sport, PortArrayRef):
+                    ref_to_schematic_port[(sport.instance, sport.port)] = sname
+
+        for pin_name, pin_def in self.pins.items():
+            if isinstance(pin_def, PinRef):
+                inst = self.instances.get(pin_def.instance)
+                if inst is None:
+                    raise ValueError(
+                        f"Pin {pin_name!r} references unknown instance "
+                        f"{pin_def.instance!r}."
+                    )
+                if inst.virtual:
+                    inst_pins = c.vinsts[pin_def.instance].cell.pins
+                else:
+                    inst_pins = c.insts[pin_def.instance].cell.pins
+                if pin_def.pin not in [p.name for p in inst_pins]:
+                    raise ValueError(
+                        f"Pin {pin_name!r} references unknown pin "
+                        f"{pin_def.pin!r} on instance {pin_def.instance!r}."
+                    )
+                inst_pin = inst_pins[pin_def.pin]
+                cell_port_names: list[str] = []
+                missing: list[str] = []
+                for p in inst_pin.ports:
+                    if p.name is None:
+                        missing.append("")
+                        continue
+                    key = (pin_def.instance, p.name)
+                    if key not in ref_to_schematic_port:
+                        missing.append(p.name)
+                    else:
+                        cell_port_names.append(ref_to_schematic_port[key])
+                if missing:
+                    raise ValueError(
+                        f"Cannot materialize pin {pin_name!r} from "
+                        f"{pin_def.instance!r}.{pin_def.pin!r}: underlying "
+                        f"port(s) {missing!r} are not exposed as top-level"
+                        " schematic ports (use schematic.add_port for each)."
+                    )
+                c.create_pin(
+                    name=pin_name,
+                    ports=[c.ports[n] for n in cell_port_names],
+                    pin_type=inst_pin.pin_type,
+                    info=inst_pin.info.model_dump(),
+                )
+            else:
+                missing = [p for p in pin_def.ports if p not in c.ports]
+                if missing:
+                    raise ValueError(
+                        f"Cannot materialize pin {pin_name!r}: port(s) "
+                        f"{missing!r} are not registered on the cell."
+                    )
+                c.create_pin(
+                    name=pin_name,
+                    ports=[c.ports[p] for p in pin_def.ports],
+                    pin_type=pin_def.pin_type,
+                    info=dict(pin_def.info),
+                )
+
         c.schematic = self
         if self.name:
             c.name = self.name
 
         return output_type(base=c.base)
 
+    def add_constraint(self, constraint: Constraint) -> Constraint:
+        """Register a constraint on this schematic.
+
+        Validates that all ``route_names`` and ``instance_names`` referenced by
+        the constraint already exist in the schematic.
+
+        Args:
+            constraint: The constraint to add.
+
+        Returns:
+            The added constraint (for chaining).
+
+        Raises:
+            ValueError: If any referenced route or instance name is unknown.
+        """
+        missing_routes = [n for n in constraint.route_names if n not in self.routes]
+        if missing_routes:
+            raise ValueError(
+                f"Constraint references unknown route(s): {missing_routes}"
+            )
+        missing_insts = [
+            n for n in constraint.instance_names if n not in self.instances
+        ]
+        if missing_insts:
+            raise ValueError(
+                f"Constraint references unknown instance(s): {missing_insts}"
+            )
+        self.constraints.append(constraint)
+        return constraint
+
     def add_route(
         self,
         name: str,
-        start_ports: list[PortRef | Port[TUnit]],
-        end_ports: list[PortRef | Port[TUnit]],
+        nets: Sequence[Sequence[PortRef]],
         routing_strategy: str,
-        **settings: JSONSerializable,
-    ) -> Route[TUnit]:
+        settings: dict[str, JSONSerializable],
+    ) -> Route[T]:
         """Create a multi-link route bundle.
 
         Args:
             name: Route identifier (must be unique).
-            start_ports: Start ports for each link.
-            end_ports: End ports for each link.
+            nets: List of net tuples. Each tuple contains 2 or more ports
+                that belong to the same net.
             routing_strategy: Name of the routing strategy function.
             **settings: Extra keyword args forwarded to the strategy.
 
@@ -1352,30 +1911,62 @@ def add_route(
             The created `Route`.
 
         Raises:
-            ValueError: If `name` already exists.
+            ValueError: If `name` already exists or a net has fewer than 2 ports.
         """
 
         if name in self.routes:
             raise ValueError(f"Route with name {name!r} already exists")
-        route = Route[TUnit](
+        route = Route[T](
             name=name,
             routing_strategy=routing_strategy,
-            links=[
-                Link((sp, ep)) for sp, ep in zip(start_ports, end_ports, strict=True)
-            ],
             settings=settings,
         )
         self.routes[name] = route
+        for net in nets:
+            if len(net) < 2:
+                raise ValueError(
+                    f"Each route net must have at least 2 ports, got {len(net)} in "
+                    f"route {name!r}"
+                )
+            self.nets.append(RouteNet[T](route=name, net=tuple(net)))
         return route
 
+    @overload
+    def connect(
+        self,
+        port1: PortRef | Port[T],
+        port2: PortRef,
+        *,
+        virtual: Literal[False] = False,
+    ) -> Connection[T]: ...
+
+    @overload
+    def connect(
+        self,
+        port1: PortRef | Port[T],
+        port2: PortRef | Port[T],
+        *ports: PortRef | Port[T],
+        virtual: Literal[True] = True,
+    ) -> VirtualConnection[T]: ...
     def connect(
-        self, port1: PortRef | Port[TUnit], port2: PortRef
-    ) -> Connection[TUnit]:
-        conn = Connection[TUnit]((port1, port2))
-        self.connections.append(conn)
+        self,
+        port1: PortRef | Port[T],
+        port2: PortRef | Port[T],
+        *ports: PortRef | Port[T],
+        virtual: bool = False,
+    ) -> Connection[T] | VirtualConnection[T]:
+        conn: Connection[T] | VirtualConnection[T]
+        if virtual:
+            conn = VirtualConnection[T](net=(port1, port2, *ports))
+        else:
+            assert isinstance(port2, PortRef), (
+                "non-virtual connect requires port2 to be a PortRef"
+            )
+            conn = Connection[T](net=(port1, port2))
+        self.nets.append(conn)
         return conn
 
-    def __getitem__(self, key: str) -> Port[TUnit] | PortRef:
+    def __getitem__(self, key: str) -> Port[T] | PortRef:
         return self.ports[key]
 
     def code_str(
@@ -1512,6 +2103,32 @@ def _kcls(name: str) -> str:
                         f"name={name!r},"
                         f"port={port.as_python_str(names[port.instance])})\n"
                     )
+        if self.pins:
+            schematic_cell += f"\n{_ind()}# Schematic pins\n"
+            for name, pin in sorted(
+                self.pins.items(),
+                key=lambda named_pin: (
+                    named_pin[1].__class__.__name__,
+                    named_pin[0],
+                ),
+            ):
+                if isinstance(pin, Pin):
+                    schematic_cell += f"{_ind()}schematic.create_pin(\n"
+                    indent += 2
+                    schematic_cell += f"{_ind()}name={name!r},\n"
+                    schematic_cell += f"{_ind()}ports={pin.ports!r},\n"
+                    if pin.pin_type != "DC":
+                        schematic_cell += f"{_ind()}pin_type={pin.pin_type!r},\n"
+                    if pin.info:
+                        schematic_cell += f"{_ind()}info={pin.info!r},\n"
+                    indent -= 2
+                    schematic_cell += f"{_ind()})\n"
+                else:
+                    schematic_cell += (
+                        f"{_ind()}schematic.add_pin("
+                        f"name={name!r},"
+                        f"pin={pin.as_python_str(names[pin.instance])})\n"
+                    )
         if self.placements:
             schematic_cell += f"\n{_ind()}# Schematic instance placements\n"
 
@@ -1545,12 +2162,12 @@ def _kcls(name: str) -> str:
                     schematic_cell += f"{_ind()})\n"
                 else:
                     schematic_cell += f"{_ind()}{inst_name}.mirror = True\n"
-
-        if self.connections:
+        connections = self.connections
+        if connections:
             schematic_cell += f"\n{_ind()}# Schematic connections\n"
 
-            for connection in self.connections:
-                ref1, ref2 = connection.root
+            for connection in connections:
+                ref1, ref2 = connection.net
                 if isinstance(ref1, PortRef):
                     schematic_cell += f"{_ind()}{names[ref1.instance]}.connect(\n"
                     indent += 2
@@ -1579,6 +2196,7 @@ def _kcls(name: str) -> str:
                     indent -= 2
                     schematic_cell += f"{_ind()})\n"
         if self.routes:
+            nets_per_route = self.routes_nets()
             schematic_cell += f"\n{_ind()}# Schematic routes\n"
             for route in self.routes.values():
                 schematic_cell += f"{_ind()}schematic.add_route(\n"
@@ -1587,8 +2205,8 @@ def _kcls(name: str) -> str:
                 schematic_cell += f"{_ind()}name={route.name!r},\n"
                 start_ports: list[str] = []
                 end_ports: list[str] = []
-                for link in route.links:
-                    p1, p2 = link.root
+                for net in nets_per_route[route.name]:
+                    p1, p2 = net.net
                     if isinstance(p1, Port):
                         start_ports.append(p1.as_python_str())
                     else:
@@ -1653,11 +2271,10 @@ def get_port_orientation_function(
             component: str, /, **settings: Any
         ) -> dict[str | None, float]:
             factory = factories[component]
-            is_schematic_inst = False
             if isinstance(factory, WrappedKCellFunc):
                 is_schematic_inst = factory.schematic_driven()
                 if is_schematic_inst:
-                    _schematic = factory._f_schematic(**settings)  # type: ignore[misc]
+                    _schematic = factory._f_schematic(**settings)  # ty:ignore[call-non-callable]
                     port_positions = _schematic.get_port_positions(factories=factories)
                     port_directions: dict[str | None, float] = {}
                     for port_name in port_positions["right"]:
@@ -1685,11 +2302,10 @@ def get_port_orientation_function(
                         get_port_orientation_f=get_port_orientation_function,
                     )
                     factory = factories[inst.component]
-                    is_schematic_inst = False
                     if isinstance(factory, WrappedKCellFunc):
                         is_schematic_inst = factory.schematic_driven()
                         if is_schematic_inst:
-                            _schematic = factory._f_schematic(**inst.settings)  # type: ignore[misc]
+                            _schematic = factory._f_schematic(**inst.settings)  # ty:ignore[call-non-callable]
                             inst_ports = _schematic.get_port_positions(
                                 factories=factories
                             )
@@ -1718,7 +2334,6 @@ def get_port_orientation_function(
                                 for inst_port in inst_ports["bottom"]:
                                     if inst_port == port:
                                         port_orientation = 270
-                                        found = True
                                         break
                             if port_orientation is None:
                                 raise ValueError(
@@ -1729,9 +2344,9 @@ def get_port_orientation_function(
                             cell = factory(**inst.settings)
                             port_orientation = cell.ports[port.name].dcplx_trans.angle
                     if inst.mirror:
-                        orientation = (orientation - port_orientation) % 360  # type: ignore[operator]
+                        orientation = (orientation - port_orientation) % 360  # ty:ignore[unsupported-operator]
                     else:
-                        orientation = (orientation + port_orientation) % 360  # type: ignore[operator]
+                        orientation = (orientation + port_orientation) % 360  # ty:ignore[unsupported-operator]
 
                     match orientation:
                         case 0:
@@ -1762,11 +2377,10 @@ def get_port_orientation_function(
                     get_port_orientation_f=get_port_orientation_function,
                 )
                 factory = factories[inst.component]
-                is_schematic_inst = False
                 if isinstance(factory, WrappedKCellFunc):
                     is_schematic_inst = factory.schematic_driven()
                     if is_schematic_inst:
-                        _schematic = factory._f_schematic(**inst.settings)  # type: ignore[misc]
+                        _schematic = factory._f_schematic(**inst.settings)  # ty:ignore[call-non-callable]
                         inst_ports = _schematic.get_port_positions(factories=factories)
                         found = False
 
@@ -1793,7 +2407,6 @@ def get_port_orientation_function(
                             for inst_port in inst_ports["bottom"]:
                                 if inst_port == port.port:
                                     port_orientation = 270
-                                    found = True
                                     break
                         if port_orientation is None:
                             raise ValueError(
@@ -1802,11 +2415,11 @@ def get_port_orientation_function(
                             )
                     else:
                         cell = factory(**inst.settings)
-                        port_orientation = cell.ports[port.name].dcplx_trans.angle
+                        port_orientation = cell.ports[port.port].dcplx_trans.angle
                 if inst.mirror:
-                    orientation = (orientation - port_orientation) % 360  # type: ignore[operator]
+                    orientation = (orientation - port_orientation) % 360  # ty:ignore[unsupported-operator]
                 else:
-                    orientation = (orientation + port_orientation) % 360  # type: ignore[operator]
+                    orientation = (orientation + port_orientation) % 360  # ty:ignore[unsupported-operator]
 
                 match orientation:
                     case 0:
@@ -1818,16 +2431,27 @@ def get_port_orientation_function(
                     case 270:
                         sorted_ports["bottom"].append(port_name)
         for side in ("right", "top", "left", "bottom"):
-            sorted_ports[side].sort()  # type: ignore[literal-required]
+            sorted_ports[side].sort()
         return sorted_ports
 
+    def routes_nets(self) -> dict[str, list[RouteNet[T]]]:
+        nets: dict[str, list[RouteNet[T]]] = defaultdict(list)
+        for net in self.nets:
+            if isinstance(net, RouteNet):
+                nets[net.route].append(net)
+        return nets
+
+    @property
+    def connections(self) -> list[Connection[T]]:
+        return [net for net in self.nets if isinstance(net, Connection)]
+
 
-def _get_instance_orientation(
+def _get_instance_orientation[T: (int, float)](
     instance: str,
-    schematic: TSchematic[Any],
+    schematic: TSchematic[T],
     visited_instances: set[str],
     get_port_orientation_f: Callable[..., dict[str | None, float]],
-    instance_connections: defaultdict[str, list[Connection[TUnit]]] | None = None,
+    instance_connections: defaultdict[str, list[Connection[T]]] | None = None,
     instance_orientations: dict[str, float] | None = None,
 ) -> float | None:
     s_inst = schematic.instances[instance]
@@ -1837,10 +2461,12 @@ def _get_instance_orientation(
     if isinstance(placement, Placement):
         if isinstance(placement.orientation, PortRef):
             return _get_instance_orientation(
-                placement.orientation,
+                placement.orientation.instance,
                 schematic,
                 instance_connections=instance_connections,
                 instance_orientations=instance_orientations,
+                visited_instances=visited_instances,
+                get_port_orientation_f=get_port_orientation_f,
             )
         return placement.orientation
     if instance_connections is None:
@@ -1854,20 +2480,20 @@ def _get_instance_orientation(
     s_inst_sign = -1 if s_inst.mirror else 1
 
     for connection in instance_connections[instance]:
-        if isinstance(connection.root[0], Port):
-            if isinstance(connection.root[0].orientation, PortRef):
+        if isinstance(connection.net[0], Port):
+            if isinstance(connection.net[0].orientation, PortRef):
                 continue
             return (
                 s_inst_sign
                 * (
-                    connection.root[0].orientation
+                    connection.net[0].orientation
                     - get_port_orientation_f(s_inst.component, **s_inst.settings)[
-                        connection.root[1].port
+                        connection.net[1].port
                     ]
                 )
             ) % 360
-        if connection.root[0].instance == instance:
-            conn_inst = connection.root[1].instance
+        if connection.net[0].instance == instance:
+            conn_inst = connection.net[1].instance
             conn_orientation = _get_possible_orienation(conn_inst, schematic)
             if conn_orientation is not None:
                 s_conn_inst = schematic.instances[conn_inst]
@@ -1878,17 +2504,17 @@ def _get_instance_orientation(
                         conn_orientation
                         + get_port_orientation_f(
                             s_conn_inst.component, **s_conn_inst.settings
-                        )[connection.root[1].port]
+                        )[connection.net[1].port]
                     )
                     + 180
                     - s_inst_sign
                     * get_port_orientation_f(s_inst.component, **s_inst.settings)[
-                        connection.root[0].port
+                        connection.net[0].port
                     ]
                 ) % 360
-            potential_instances.add(connection.root[1].instance)
+            potential_instances.add(connection.net[1].instance)
         else:
-            conn_inst = connection.root[0].instance
+            conn_inst = connection.net[0].instance
             conn_orientation = _get_possible_orienation(conn_inst, schematic)
             if conn_orientation is not None:
                 s_conn_inst = schematic.instances[conn_inst]
@@ -1899,16 +2525,16 @@ def _get_instance_orientation(
                         conn_orientation
                         + get_port_orientation_f(
                             s_conn_inst.component, **s_conn_inst.settings
-                        )[connection.root[0].port]
+                        )[connection.net[0].port]
                     )
                     + 180
                     - s_inst_sign
                     * get_port_orientation_f(s_inst.component, **s_inst.settings)[
-                        connection.root[1].port
+                        connection.net[1].port
                     ]
                 ) % 360
 
-            potential_instances.add(connection.root[0].instance)
+            potential_instances.add(connection.net[0].instance)
 
     visited_instances_ = visited_instances | potential_instances
 
@@ -1917,16 +2543,16 @@ def _get_instance_orientation(
             continue
         orientation = _get_instance_orientation(
             inst,
-            schematic,
+            schematic,  # ty:ignore[invalid-argument-type]
             visited_instances_,
             get_port_orientation_f=get_port_orientation_f,
-            instance_connections=instance_connections,
+            instance_connections=instance_connections,  # ty:ignore[invalid-argument-type]
         )
         if orientation is not None:
             for connection in instance_connections[instance]:
-                if isinstance(connection.root[0], Port):
+                if isinstance(connection.net[0], Port):
                     continue
-                if connection.root[0].instance == inst:
+                if connection.net[0].instance == inst:
                     s_conn_inst = schematic.instances[inst]
                     s_conn_sign = -1 if s_conn_inst.mirror else 1
                     return (
@@ -1935,15 +2561,15 @@ def _get_instance_orientation(
                             orientation
                             + get_port_orientation_f(
                                 s_conn_inst.component, **s_conn_inst.settings
-                            )[connection.root[0].port]
+                            )[connection.net[0].port]
                         )
                         + 180
                         - s_inst_sign
                         * get_port_orientation_f(s_inst.component, **s_inst.settings)[
-                            connection.root[1].port
+                            connection.net[1].port
                         ]
                     ) % 360
-                if connection.root[1].instance == inst:
+                if connection.net[1].instance == inst:
                     s_conn_inst = schematic.instances[inst]
                     s_conn_sign = -1 if s_conn_inst.mirror else 1
                     return (
@@ -1952,12 +2578,12 @@ def _get_instance_orientation(
                             orientation
                             + get_port_orientation_f(
                                 s_conn_inst.component, **s_conn_inst.settings
-                            )[connection.root[1].port]
+                            )[connection.net[1].port]
                         )
                         + 180
                         - s_inst_sign
                         * get_port_orientation_f(s_inst.component, **s_inst.settings)[
-                            connection.root[0].port
+                            connection.net[0].port
                         ]
                     ) % 360
 
@@ -2034,17 +2660,15 @@ def __init__(self, **data: Any) -> None:
         super().__init__(**data)
 
 
-def _create_kinst(
+def _create_kinst[T: (int, float)](
     c: KCell,
-    schematic_inst: SchematicInstance[TUnit],
+    schematic_inst: SchematicInstance[T],
     factories: Mapping[
         str, Callable[..., KCell] | Callable[..., DKCell] | Callable[..., VKCell]
     ]
     | None,
 ) -> Instance | VInstance:
-    kinst: Instance | DInstance
     if factories:
-        is_factory = schematic_inst.component in factories
         cell_: ProtoTKCell[Any] | VKCell = factories[schematic_inst.component](
             **schematic_inst.settings
         )
@@ -2191,16 +2815,23 @@ def _dvec(x: float, y: float, c: KCell, unit: Literal["dbu", "um"]) -> kdb.DVect
     return kdb.DVector(x, y)
 
 
-def _place_island(
+def _is_int_schematic(s: TSchematic[Any]) -> TypeGuard[Schematic[int]]:
+    return s.unit == "dbu"
+
+
+def _place_island[T: (int, float)](
     c: KCell,
     schematic_island: set[str],
     instances: dict[str, Instance | VInstance],
-    connections: dict[str, list[Connection[TUnit]]],
-    schematic_instances: dict[str, SchematicInstance[TUnit]],
+    connections: dict[str, list[Connection[T]]],
+    schematic_instances: dict[str, SchematicInstance[T]],
     placed_insts: set[str],
     placed_ports: set[str],
-    schematic: TSchematic[TUnit],
-    cross_sections: Mapping[str, CrossSection | DCrossSection],
+    schematic: TSchematic[T],
+    cross_sections: Mapping[
+        str,
+        CrossSection | DCrossSection | AsymmetricCrossSection | DAsymmetricCrossSection,
+    ],
     factories: Mapping[
         str, Callable[..., KCell] | Callable[..., DKCell] | Callable[..., VKCell]
     ]
@@ -2213,13 +2844,13 @@ def _place_island(
         schema_inst = schematic_instances[inst]
         kinst = _create_kinst(c, schema_inst, factories=factories)
         instances[inst] = kinst
-        if schema_inst.placement and isinstance(schema_inst.placement, Placement):
+        p = schema_inst.placement
+        if isinstance(p, Placement):
+            p = cast("Placement[int]", p)
             logger.debug("Placing {}", schema_inst.name)
-            p = schema_inst.placement
-            assert p is not None
 
             if p.is_placeable(placed_insts, placed_ports):
-                if schematic.unit == "dbu":
+                if _is_int_schematic(schematic):
                     if isinstance(p.x, PortRef):
                         x: float = KCellPort(
                             base=instances[p.x.instance].ports[p.x.port].base
@@ -2250,11 +2881,19 @@ def _place_island(
                                 y = bb.center().y
                     else:
                         y = p.y
+                    if isinstance(p.orientation, PortRef):
+                        rot: float = (
+                            instances[p.orientation.instance]
+                            .ports[p.orientation.port]
+                            .orientation
+                        )
+                    else:
+                        rot = p.orientation
                     if p.anchor is None:
                         kinst.transform(
                             kdb.ICplxTrans(
                                 mag=1,
-                                rot=p.orientation,
+                                rot=rot,
                                 x=x + p.dx,
                                 y=y + p.dy,
                             )
@@ -2263,7 +2902,7 @@ def _place_island(
                         kinst.transform(
                             kdb.ICplxTrans(
                                 mag=1,
-                                rot=p.orientation,
+                                rot=rot,
                                 x=x + p.dx,
                                 y=y + p.dy,
                             )
@@ -2288,7 +2927,7 @@ def _place_island(
                         kinst.transform(
                             kdb.ICplxTrans(
                                 mag=1,
-                                rot=p.orientation,
+                                rot=rot,
                                 x=x + p.dx,
                                 y=y + p.dy,
                             )
@@ -2325,11 +2964,19 @@ def _place_island(
                                 y = bb.center().y
                     else:
                         y = p.y
+                    if isinstance(p.orientation, PortRef):
+                        drot: float = (
+                            instances[p.orientation.instance]
+                            .ports[p.orientation.port]
+                            .orientation
+                        )
+                    else:
+                        drot = p.orientation
                     if p.anchor is None:
                         kinst.transform(
                             kdb.DCplxTrans(
                                 mag=1,
-                                rot=p.orientation,
+                                rot=drot,
                                 x=x + p.dx,
                                 y=y + p.dy,
                             )
@@ -2338,7 +2985,7 @@ def _place_island(
                         kinst.transform(
                             kdb.DCplxTrans(
                                 mag=1,
-                                rot=p.orientation,
+                                rot=drot,
                                 x=x + p.dx,
                                 y=y + p.dy,
                             )
@@ -2369,7 +3016,7 @@ def _place_island(
                         kinst.transform(
                             kdb.DCplxTrans(
                                 mag=1,
-                                rot=p.orientation,
+                                rot=drot,
                                 x=x + p.dx,
                                 y=y + p.dy,
                             )
@@ -2434,15 +3081,18 @@ def _place_island(
     return placed_insts
 
 
-def _get_and_place_insts_and_ports(
+def _get_and_place_insts_and_ports[T: (int, float)](
     c: KCell,
     placed_insts: set[str],
     placed_ports: set[str],
-    connections: dict[str, list[Connection[TUnit]]],
-    schematic: TSchematic[TUnit],
+    connections: dict[str, list[Connection[T]]],
+    schematic: TSchematic[T],
     instances: dict[str, Instance | VInstance],
     placed_island_insts: set[str],
-    cross_sections: Mapping[str, CrossSection | DCrossSection],
+    cross_sections: Mapping[
+        str,
+        CrossSection | DCrossSection | AsymmetricCrossSection | DAsymmetricCrossSection,
+    ],
 ) -> bool:
     placeable_insts, placeable_ports = _get_placeable(
         placed_insts=placed_insts,
@@ -2458,8 +3108,10 @@ def _get_and_place_insts_and_ports(
         placed_instances=placed_insts,
     )
     for port in placeable_ports:
-        schematic.ports[port].place(
-            cell=c, schematic=schematic, name=port, cross_sections=cross_sections
+        schematic.place_port(
+            port,
+            cell=c,
+            cross_sections=cross_sections,
         )
         placed_ports.add(port)
     placed_insts |= placeable_insts
@@ -2468,34 +3120,34 @@ def _get_and_place_insts_and_ports(
     return bool(placeable_insts) or bool(placeable_ports)
 
 
-def _connect_instances(
+def _connect_instances[T: (int, float)](
     instances: dict[str, Instance | VInstance],
     place_insts: set[str],
-    connections: dict[str, list[Connection[TUnit]]],
+    connections: dict[str, list[Connection[T]]],
     placed_instances: set[str],
 ) -> None:
     for inst_name in place_insts:
         inst = instances[inst_name]
         for conn in connections[inst_name]:
-            if isinstance(conn.root[0], Port):
+            if isinstance(conn.net[0], Port):
                 continue
             if (
-                conn.root[0].instance == inst_name
-                and conn.root[1].instance in placed_instances
+                conn.net[0].instance == inst_name
+                and conn.net[1].instance in placed_instances
             ):
                 inst.connect(
-                    conn.root[0].port,
-                    instances[conn.root[1].instance],
-                    conn.root[1].port,
+                    conn.net[0].port,
+                    instances[conn.net[1].instance],
+                    conn.net[1].port,
                     use_angle=True,
                     use_mirror=False,
                 )
                 break
-            if conn.root[0].instance in placed_instances:
+            if conn.net[0].instance in placed_instances:
                 inst.connect(
-                    conn.root[1].port,
-                    instances[conn.root[0].instance],
-                    conn.root[0].port,
+                    conn.net[1].port,
+                    instances[conn.net[0].instance],
+                    conn.net[0].port,
                     use_angle=True,
                     use_mirror=False,
                 )
@@ -2504,17 +3156,17 @@ def _connect_instances(
             raise ValueError("Could not connect all instances")
 
 
-def _get_placeable(
+def _get_placeable[T: (int, float)](
     placed_insts: set[str],
-    connections: dict[str, list[Connection[TUnit]]],
+    connections: dict[str, list[Connection[T]]],
     placed_ports: set[str],
-    schematic: TSchematic[TUnit],
+    schematic: TSchematic[T],
 ) -> tuple[set[str], set[str]]:
     placeable_insts: set[str] = set()
     placeable_ports: set[str] = set()
     for inst in placed_insts:
         for connection in connections[inst]:
-            ref1, ref2 = connection.root
+            ref1, ref2 = connection.net
             if isinstance(ref1, Port):
                 if ref1 in placed_ports:
                     placeable_insts.add(ref2.instance)
@@ -2537,51 +3189,6 @@ def _get_placeable(
     return placeable_insts - placed_insts, placeable_ports
 
 
-@overload
-def get_schematic(
-    c: KCell,
-    exclude_port_types: Sequence[str] | None = ("placement", "pad", "bump"),
-) -> TSchematic[int]: ...
-
-
-@overload
-def get_schematic(
-    c: DKCell,
-    exclude_port_types: Sequence[str] | None = ("placement", "pad", "bump"),
-) -> TSchematic[float]: ...
-
-
-def get_schematic(
-    c: KCell | DKCell,
-    exclude_port_types: Sequence[str] | None = ("placement", "pad", "bump"),
-) -> TSchematic[int] | TSchematic[float]:
-    """NOT FUNCTIONAL YET.
-
-    Create a minimal `TSchematic` from an existing cell.
-
-    Currently extracts named instances only. Port extraction is not yet implemented.
-
-    Args:
-        c: Source cell.
-        exclude_port_types: Port types to ignore (reserved for future use).
-
-    Returns:
-        A `Schematic` if `c` is `KCell`, otherwise a `DSchematic`.
-    """
-
-    if isinstance(c, KCell):
-        schematic: TSchematic[int] | TSchematic[float] = Schematic(name=c.name)
-    else:
-        schematic = DSchematic(name=c.name)
-
-    for inst in c.insts:
-        name = inst.property(PROPID.NAME)
-        if name is not None:
-            schematic.create_inst(name, inst.cell.factory_name or inst.cell.name)
-
-    return schematic
-
-
 @overload
 def read_schematic(file: Path | str, unit: Literal["dbu"] = "dbu") -> Schematic: ...
 
@@ -2630,14 +3237,14 @@ def _get_full_settings(
     return params
 
 
-def _get_island_connections(
-    instances: dict[str, SchematicInstance[TUnit]],
-    connections: list[Connection[TUnit]],
-) -> tuple[dict[str, set[str]], defaultdict[str, list[Connection[TUnit]]]]:
+def _get_island_connections[T: (int, float)](
+    instances: dict[str, SchematicInstance[T]],
+    connections: list[Connection[T]],
+) -> tuple[dict[str, set[str]], defaultdict[str, list[Connection[T]]]]:
     islands: dict[str, set[str]] = {}
-    instance_connections: defaultdict[str, list[Connection[TUnit]]] = defaultdict(list)
+    instance_connections: defaultdict[str, list[Connection[T]]] = defaultdict(list)
     for connection in connections:
-        pr1, pr2 = connection.root
+        pr1, pr2 = connection.net
         if isinstance(pr1, Port):
             continue
         instance_connections[pr1.instance].append(connection)
@@ -2667,3 +3274,32 @@ def _get_island_connections(
             islands[inst_name] = {inst_name}
 
     return islands, instance_connections
+
+
+def _is_real(v: Any) -> TypeGuard[Real]:
+    return isinstance(v, Real)
+
+
+def _is_port_ref(v: Any) -> TypeGuard[PortRef]:
+    return isinstance(v, PortRef)
+
+
+def _array_dims(array: RegularArray[Any] | Array[Any] | None) -> tuple[int, int]:
+    if array is None:
+        return 0, 0
+    if isinstance(array, RegularArray):
+        return array.columns, array.rows
+    return array.na, array.nb
+
+
+def _get_instance_and_port(s: str) -> PortRef:
+    instance, port = s.rsplit(",", 1)
+    match = re.match(r"(.*?)<(\d+)\.(\d+)>$", instance)
+    if match:
+        return PortArrayRef(
+            instance=match.group(1),
+            port=port,
+            ia=int(match.group(2)),
+            ib=int(match.group(3)),
+        )
+    return PortRef(instance=instance, port=port)
diff --git a/src/kfactory/serialization.py b/src/kfactory/serialization.py
index 5c644807e..6aecb1cbf 100644
--- a/src/kfactory/serialization.py
+++ b/src/kfactory/serialization.py
@@ -6,27 +6,38 @@
 from collections.abc import Callable, Hashable
 from hashlib import sha3_512
 from types import FunctionType
-from typing import TYPE_CHECKING, Any, overload
+from typing import TYPE_CHECKING, Any, TypeGuard, overload
 
-import klayout.db as kdb
 import numpy as np
-import toolz  # type: ignore[import-untyped,unused-ignore]
+import toolz
 
+from . import kdb, lay
 from .conf import config
 from .exceptions import CellNameError
-from .typings import JSONSerializable, MetaData, SerializableShape
 
 if TYPE_CHECKING:
+    from .cross_section import CrossSectionSpec
     from .kcell import AnyKCell
+    from .layout import KCLayout
+    from .typings import (
+        DShapeLike,
+        IShapeLike,
+        JSONSerializable,
+        MetaData,
+        SerializableShape,
+    )
 
 
 class DecoratorList(UserList[Any]):
     """Hashable decorator for a list."""
 
-    def __hash__(self) -> int:  # type: ignore[override]
+    def __hash__(self) -> int:
         """Hash the list."""
         return hash(tuple(self.data))
 
+    def __reduce__(self) -> tuple[type[DecoratorList], tuple[list[Any]]]:
+        return (DecoratorList, (self.data,))
+
 
 class DecoratorDict(UserDict[Hashable, Any]):
     """Hashable decorator for a dictionary."""
@@ -35,6 +46,9 @@ def __hash__(self) -> int:
         """Hash the dictionary."""
         return hash(tuple(sorted(self.data.items())))
 
+    def __reduce__(self) -> tuple[type[DecoratorDict], tuple[dict[Hashable, Any]]]:
+        return (DecoratorDict, (self.data,))
+
 
 def clean_dict(d: dict[str, Any]) -> dict[str, Any]:
     """Cleans dictionary recursively."""
@@ -85,10 +99,10 @@ def clean_value(
     if isinstance(value, kdb.LayerInfo):
         return f"{value.name or str(value.layer) + '_' + str(value.datatype)}"
     if isinstance(value, list | tuple):
-        return "_".join(clean_value(v) for v in value)
+        return "_".join(clean_value(v) for v in value)  # ty:ignore[invalid-argument-type]
     if isinstance(value, dict):
         try:
-            return dict2name(**value)
+            return dict2name(**value)  # ty:ignore[invalid-argument-type]
         except TypeError as e:
             raise CellNameError(
                 "Dictionaries passed to functions as args/kwargs"
@@ -96,7 +110,7 @@ def clean_value(
                 " for Cell/Component names or similar."
             ) from e
     if hasattr(value, "name"):
-        return clean_name(value.name)  # type: ignore[arg-type]
+        return clean_name(value.name)  # ty:ignore[invalid-argument-type]
     if callable(value):
         if isinstance(value, FunctionType) and value.__name__ == "":
             msg = "Unable to serialize lambda function. Use a named function instead."
@@ -110,7 +124,7 @@ def clean_value(
             while hasattr(func, "func"):
                 func = func.func
             v = {
-                "function": func.__name__,
+                "function": get_function_name(func),  # ty:ignore[invalid-argument-type]
                 "module": func.__module__,
                 "settings": args_as_kwargs,
             }
@@ -205,10 +219,14 @@ def dict2name(prefix: str | None = None, **kwargs: dict[str, Any]) -> str:
 
 def convert_metadata_type(value: Any) -> MetaData:
     """Recursively clean up a MetaData for KCellSettings."""
-    if isinstance(value, int | float | bool | str | SerializableShape):
-        return value
     if value is None:
         return None
+    if isinstance(value, float):
+        if value.is_integer():
+            return int(value)
+        return value
+    if serializible_value_or_shape_guard(value):
+        return value
     if isinstance(value, tuple):
         return tuple(convert_metadata_type(tv) for tv in value)
     if isinstance(value, list):
@@ -218,11 +236,13 @@ def convert_metadata_type(value: Any) -> MetaData:
     return clean_value(value)
 
 
-def check_metadata_type(value: MetaData) -> MetaData:
+def check_metadata_type(value: Any) -> MetaData:
     """Recursively check an info value whether it can be stored."""
     if value is None:
         return None
-    if isinstance(value, str | int | float | bool | SerializableShape):
+    if isinstance(value, float) and value.is_integer():
+        value = int(value)
+    if serializible_value_or_shape_guard(value):
         return value
     if isinstance(value, tuple):
         return tuple(check_metadata_type(tv) for tv in value)
@@ -231,31 +251,37 @@ def check_metadata_type(value: MetaData) -> MetaData:
     if isinstance(value, dict):
         return {k: check_metadata_type(v) for k, v in value.items()}
     msg = (
-        "Values of the info dict only support int, float, string, tuple or list."
-        f"{value=}, {type(value)=}"
+        "MetaData values of the info dict only support int, float, string"
+        f", tuple or list. {value=}, {type(value)=}"
     )
     raise ValueError(msg)
 
 
 def serialize_setting(setting: MetaData) -> JSONSerializable:
     """Serialize a setting."""
+    if setting is None:
+        return None
     if isinstance(setting, dict):
-        return {name: serialize_setting(_setting) for name, _setting in setting.items()}
+        return {
+            str(name): serialize_setting(_setting)  # ty:ignore[invalid-argument-type]
+            for name, _setting in setting.items()
+        }
     if isinstance(setting, list):
-        return [serialize_setting(s) for s in setting]
+        return [serialize_setting(s) for s in setting]  # ty:ignore[invalid-argument-type]
     if isinstance(setting, tuple):
-        return tuple(serialize_setting(s) for s in setting)
-    if isinstance(setting, SerializableShape):
+        return tuple(serialize_setting(s) for s in setting)  # ty:ignore[invalid-argument-type]
+    if serializible_shape_guard(setting):
         return f"!#{setting.__class__.__name__} {setting!s}"
-    return setting
+    return setting  # ty:ignore[invalid-return-type]
 
 
 def deserialize_setting(setting: JSONSerializable) -> MetaData:
     """Deserialize a setting."""
     if isinstance(setting, dict):
         return {
-            name: deserialize_setting(_setting) for name, _setting in setting.items()
-        }
+            name: deserialize_setting(_setting)  # ty:ignore[invalid-argument-type]
+            for name, _setting in setting.items()
+        }  # ty:ignore[invalid-return-type]
     if isinstance(setting, list):
         return [deserialize_setting(s) for s in setting]
     if isinstance(setting, tuple):
@@ -264,9 +290,9 @@ def deserialize_setting(setting: JSONSerializable) -> MetaData:
         cls_name, value = setting.removeprefix("!#").split(" ", 1)
         match cls_name:
             case "LayerInfo":
-                return getattr(kdb, cls_name).from_string(value)  # type: ignore[no-any-return]
+                return getattr(kdb, cls_name).from_string(value)
             case _:
-                return getattr(kdb, cls_name).from_s(value)  # type: ignore[no-any-return]
+                return getattr(kdb, cls_name).from_s(value)
     return setting
 
 
@@ -285,3 +311,129 @@ def get_cell_name(
         name = f"{name[: (max_cellname_length - 9)]}_{name_hash}"
 
     return name
+
+
+def serializible_value_or_shape_guard(
+    value: Any,
+) -> TypeGuard[int | float | bool | str | SerializableShape]:
+    return isinstance(
+        value,
+        int
+        | float
+        | bool
+        | str
+        | kdb.Box
+        | kdb.DBox
+        | kdb.Edge
+        | kdb.DEdge
+        | kdb.EdgePair
+        | kdb.DEdgePair
+        | kdb.EdgePairs
+        | kdb.Edges
+        | lay.LayerProperties
+        | kdb.Matrix2d
+        | kdb.Matrix3d
+        | kdb.Path
+        | kdb.DPath
+        | kdb.Point
+        | kdb.DPoint
+        | kdb.Polygon
+        | kdb.DPolygon
+        | kdb.SimplePolygon
+        | kdb.DSimplePolygon
+        | kdb.Region
+        | kdb.Text
+        | kdb.DText
+        | kdb.Texts
+        | kdb.Trans
+        | kdb.DTrans
+        | kdb.CplxTrans
+        | kdb.ICplxTrans
+        | kdb.DCplxTrans
+        | kdb.VCplxTrans
+        | kdb.Vector
+        | kdb.DVector
+        | kdb.LayerInfo,
+    )
+
+
+def serializible_shape_guard(
+    value: Any,
+) -> TypeGuard[SerializableShape]:
+    return isinstance(
+        value,
+        kdb.Box
+        | kdb.DBox
+        | kdb.Edge
+        | kdb.DEdge
+        | kdb.EdgePair
+        | kdb.DEdgePair
+        | kdb.EdgePairs
+        | kdb.Edges
+        | lay.LayerProperties
+        | kdb.Matrix2d
+        | kdb.Matrix3d
+        | kdb.Path
+        | kdb.DPath
+        | kdb.Point
+        | kdb.DPoint
+        | kdb.Polygon
+        | kdb.DPolygon
+        | kdb.SimplePolygon
+        | kdb.DSimplePolygon
+        | kdb.Region
+        | kdb.Text
+        | kdb.DText
+        | kdb.Texts
+        | kdb.Trans
+        | kdb.DTrans
+        | kdb.CplxTrans
+        | kdb.ICplxTrans
+        | kdb.DCplxTrans
+        | kdb.VCplxTrans
+        | kdb.Vector
+        | kdb.DVector
+        | kdb.LayerInfo,
+    )
+
+
+def ishape_guard(value: Any) -> TypeGuard[IShapeLike]:
+    return isinstance(
+        value,
+        kdb.Polygon
+        | kdb.Edge
+        | kdb.Path
+        | kdb.Box
+        | kdb.Text
+        | kdb.SimplePolygon
+        | kdb.Region,
+    )
+
+
+def dshape_guard(value: Any) -> TypeGuard[DShapeLike]:
+    return isinstance(
+        value,
+        kdb.DPolygon
+        | kdb.DEdge
+        | kdb.DPath
+        | kdb.DBox
+        | kdb.DText
+        | kdb.DSimplePolygon,
+    )
+
+
+def get_function_name(f: Callable[..., Any]) -> str:
+    if hasattr(f, "__name__"):
+        return str(f.__name__)
+    if hasattr(f, "func") and callable(f.func):
+        return get_function_name(f.func)
+    raise ValueError(f"Function {f} has no name.")
+
+
+def kcl_cross_section_serializer(
+    kcl: KCLayout,
+) -> Callable[[CrossSectionSpec], str]:
+    def serialize_cross_section_spec(cross_section_spec: CrossSectionSpec) -> str:
+        return kcl.get_icross_section(cross_section_spec).name
+
+    return serialize_cross_section_spec
diff --git a/src/kfactory/session_cache.py b/src/kfactory/session_cache.py
index 7585a38b3..5faf66ed3 100644
--- a/src/kfactory/session_cache.py
+++ b/src/kfactory/session_cache.py
@@ -1,28 +1,95 @@
 from __future__ import annotations
 
+import copyreg
 import functools
 import hashlib
+import importlib
 import json
 import operator
 import pickle
+import types
 from collections import defaultdict
-from hashlib import sha256
 from shutil import rmtree
 from typing import TYPE_CHECKING
 
+from kfactory.cross_section import AsymmetricalCrossSection, SymmetricalCrossSection
+from kfactory.kcell import ProtoKCell, VKCell
+
+from . import kdb
 from .conf import logger
 from .layout import KCLayout, kcls
+from .typings import DShapeLike, IShapeLike
 from .utilities import get_session_directory, save_layout_options
 
 if TYPE_CHECKING:
+    from collections.abc import Callable, Hashable
+    from io import BufferedReader, BufferedWriter
     from pathlib import Path
     from typing import Any
 
     from .kcell import KCell, ProtoTKCell
 
 
+def _reconstruct_function(module_name: str, qualname: str) -> Any:
+    """Reconstruct a function from its module and qualified name."""
+    module = importlib.import_module(module_name)
+    obj: Any = module
+    for attr in qualname.split("."):
+        obj = getattr(obj, attr)
+    return obj
+
+
+def _reconstruct_partial(
+    func: Any, args: tuple[Any, ...], keywords: dict[str, Any]
+) -> functools.partial[Any]:
+    """Reconstruct a functools.partial from its components."""
+    return functools.partial(func, *args, **keywords)
+
+
+class FunctionPickler(pickle.Pickler):
+    """Custom pickler that can serialize non-lambda functions by reference."""
+
+    def reducer_override(self, obj: Any) -> Any:
+        if isinstance(obj, types.FunctionType):
+            if obj is _reconstruct_function or obj is _reconstruct_partial:
+                return NotImplemented
+            if obj.__name__ == "":
+                raise pickle.PicklingError(
+                    "Cannot pickle lambda functions. Use a named function instead."
+                )
+            if "" in obj.__qualname__:
+                raise pickle.PicklingError(
+                    f"Cannot pickle nested function {obj.__qualname__!r}. "
+                    "Use a module-level function instead."
+                )
+            return _reconstruct_function, (obj.__module__, obj.__qualname__)
+        if isinstance(obj, functools.partial):
+            return _reconstruct_partial, (obj.func, obj.args, obj.keywords)
+        return NotImplemented
+
+
+class FunctionUnpickler(pickle.Unpickler):
+    """Custom unpickler paired with FunctionPickler."""
+
+
+def _dump(obj: Any, f: BufferedWriter) -> None:
+    """Pickle an object using FunctionPickler."""
+    FunctionPickler(f).dump(obj)
+
+
+def _load(f: BufferedReader) -> Any:
+    """Unpickle an object using FunctionUnpickler."""
+    return FunctionUnpickler(f).load()
+
+
+def _factory_key(name: str, file_path: str) -> str:
+    """Create a unique key for a factory based on name and file path."""
+    return f"{name}@{file_path}"
+
+
 def save_session(
-    c: ProtoTKCell[Any] | None = None, session_dir: Path | None = None
+    c: ProtoTKCell[Any] | None = None,
+    session_dir: Path | None = None,
 ) -> None:
     kcls_dir = get_session_directory(session_dir)
     if kcls_dir.exists():
@@ -40,49 +107,66 @@ def save_session(
         kcl_dir = kcls_dir / kcl.name
         kcl_dir.mkdir(parents=True)
 
-        cis = set(kcl.each_cell_bottom_up())
+        cis = kcl.each_cell_bottom_up()
         factory_dependency: defaultdict[str, set[str]] = defaultdict(set)
-        factory_cells: defaultdict[str, list[tuple[int, str]]] = defaultdict(list)
+        factory_cells: defaultdict[str, list[tuple[Hashable, Any]]] = defaultdict(list)
         take_cell_indexes: set[int] = set()
         for ci in cis:
             if ci in skip_cells:
                 continue
             kc = kcl[ci]
             if kc.is_library_cell():
+                logger.debug(f"Adding {kc.name!r} to session cache")
                 take_cell_indexes.add(ci)
                 kcl_dependencies[kcl.name].add(kc.library().name())
                 continue
-            if kc.factory_name is None:
-                skip_cells |= set(ci)
+            if not kc.has_factory_name():
+                skip_cells.add(ci)
+                skip_cells |= set(kc.caller_cells())
+                logger.warning(
+                    f"Skipping to save cell {kc.name!r} (cell_index {ci})"
+                    " as it does not have a creator "
+                    "function. This will affect all parent cells as well: "
+                    f"{[kcl[ci_].name for ci_ in kc.caller_cells()]!r}"
+                )
             else:
                 fd = factory_dependency[kc.factory_name]
                 for pi in kc.caller_cells():
                     pc = kcl[pi]
                     if pc.factory_name is not None:
                         fd.add(pc.factory_name)
+                logger.debug(f"Adding {kc.name!r} to session cache")
                 take_cell_indexes.add(ci)
-
         for factory in kcl.factories._all:
             assert factory.name is not None
             for hk, cell in factory.cache.items():
                 if cell.cell_index() in take_cell_indexes:
                     factory_cells[factory.name].append((hk, cell.name))
-
-        for ci in cis - skip_cells:
+        for ci in take_cell_indexes - skip_cells:
             save_options.add_this_cell(ci)
         kcl.end_changes()
         kcl.write(kcl_dir / "cells.gds.gz", options=save_options)
         factory_infos = {
-            k: [
+            _factory_key(k, _file_path(kcl.factories[k].file)): [
                 v,
                 factory_cells[k],
-                _file_path_hash(kcl.factories[k].file),
+                _file_path(kcl.factories[k].file),
                 _file_hash(kcl.factories[k].file),
             ]
             for k, v in factory_dependency.items()
         }
         with (kcl_dir / "factories.pkl").open("wb") as f:
-            pickle.dump(factory_infos, f)
+            _dump(factory_infos, f)
+        _xs = set(kcl.cross_sections.cross_sections.values())
+        with (kcl_dir / "cross_sections.pkl").open("wb") as f:
+            pickle.dump(
+                {x.name: x for x in _xs if isinstance(x, SymmetricalCrossSection)}, f
+            )
+        with (kcl_dir / "asymmetrical_cross_sections.pkl").open("wb") as f:
+            pickle.dump(
+                {x.name: x for x in _xs if isinstance(x, AsymmetricalCrossSection)}, f
+            )
+
     with (kcls_dir / "../kcl_dependencies.json").resolve().open("wt") as f:
         json.dump({k: list(v) for k, v in kcl_dependencies.items()}, f)
 
@@ -125,6 +209,7 @@ def load_session(
             if not (
                 {kcls_dir / p_ for p_ in kcl_dependencies.get(p.name, [])} - loaded_kcls
             ):
+                logger.debug(f"Loading KCLayout {p.stem!r}")
                 load_kcl(kcl_path=p)
                 loaded_kcls.add(p)
                 changed = True
@@ -140,62 +225,105 @@ def load_kcl(kcl_path: Path) -> None:
         raise ValueError(f"Unknown KCL {kcl_name}")
     kcl = kcls[kcl_name]
     loaded_kcl = KCLayout("SESSION_LOAD")
+    xs_path = kcl_path / "cross_sections.pkl"
+    if xs_path.is_file():
+        with xs_path.open("rb") as f:
+            for xs in pickle.load(f).values():  # noqa: S301
+                kcl.cross_sections.get_cross_section(xs)
+    axs_path = kcl_path / "asymmetrical_cross_sections.pkl"
+    if axs_path.is_file():
+        with axs_path.open("rb") as f:
+            for axs in pickle.load(f).values():  # noqa: S301
+                kcl.cross_sections.get_asymmetrical_cross_section(axs)
+
     loaded_kcl.read(kcl_path / "cells.gds.gz")
     invalid_factories: set[str] = set()
     with (kcl_path / "factories.pkl").open("rb") as f:
-        factory_infos = pickle.load(f)  # noqa: S301
-    for factory in kcl.factories._all:
-        ph = _file_path_hash(factory.file)
+        factory_infos = _load(f)
+    for factory in sorted(kcl.factories._all, key=operator.attrgetter("name")):
+        logger.debug(f"Loading factory {factory.name!r}")
+        p = _file_path(factory.file)
         fh = _file_hash(factory.file)
-        factory_info = factory_infos.get(factory.name)
+        factory_key = _factory_key(factory.name, p)
+        factory_info = factory_infos.get(factory_key)
         assert factory.name is not None
+        logger.debug(f"{factory_info=}")
         if factory_info is not None:
-            factory_dependencies, _, ph_loaded, fh_loaded = factory_info
-            if ph_loaded != ph or fh_loaded != fh:
+            factory_dependencies, _, p_loaded, fh_loaded = factory_info
+            logger.debug(
+                "Checking factory path compatibility of definition "
+                f"{p!r} vs loaded {p_loaded!r} ({p == p_loaded}) and file hashes "
+                f"defintio {fh!r} vs loaded {fh_loaded!r} ({fh == fh_loaded})"
+            )
+            if p_loaded != p or fh_loaded != fh:
                 invalid_factories |= factory_dependencies
-                invalid_factories.add(factory.name)
+                invalid_factories.add(factory_key)
     cells_to_add: defaultdict[int, list[tuple[int, KCell, str]]] = defaultdict(list)
-    for factory_name in set(kcl.factories._by_name.keys()) - invalid_factories:
-        if factory_info := factory_infos.get(factory_name):
+    logger.debug(f"{sorted(invalid_factories)=}")
+    for factory in kcl.factories._all:
+        if factory.name is None:
+            continue
+        p = _file_path(factory.file)
+        factory_key = _factory_key(factory.name, p)
+        if factory_key in invalid_factories:
+            continue
+        logger.debug(f"Filling {factory.name!r}")
+        if factory_info := factory_infos.get(factory_key):
             cache_ = factory_info[1]
+            logger.debug(cache_)
             for hk, cn in cache_:
                 kc = loaded_kcl[cn]
+                logger.debug(f"Adding {cn!r} to cache of {factory.name!r}")
                 cells_to_add[kc.kdb_cell.hierarchy_levels()].append(
-                    (hk, kc, factory_name)
+                    (hk, kc, factory.name)
                 )
     for _, factory_cell_list in sorted(
         cells_to_add.items(), key=operator.itemgetter(0)
     ):
         for hk, kc, factory_name in factory_cell_list:
             factory = kcl.factories[factory_name]
-            kc_ = kcl.kcell(name=kc.name)
-            for inst in kc.insts:
-                if inst.cell.is_library_cell():
-                    lib_c = inst.cell.library().layout().cell(inst.cell.name)
-                    if lib_c is not None:
+            # Check if cell already exists in the layout
+            existing_kdb_cell = kcl.layout.cell(kc.name)
+            if existing_kdb_cell is not None:
+                # Cell already exists, use it and add to cache
+                existing_cell_index = existing_kdb_cell.cell_index()
+                kc_ = kcl[existing_cell_index]
+                logger.debug(
+                    f"Cell {kc.name!r} already exists (index {existing_cell_index}), "
+                    "reusing and adding to cache"
+                )
+                tkc_ = kc_._base
+                factory.cache[hk] = factory.output_type(base=tkc_)
+            else:
+                # Create new cell
+                kc_ = kcl.kcell(name=kc.name)
+                for inst in kc.insts:
+                    if inst.cell.is_library_cell():
+                        lib_c = inst.cell.library().layout().cell(inst.cell.name)
+                        if lib_c is not None:
+                            inst_ = kc_.icreate_inst(
+                                kcls[inst.cell.library().name()][lib_c.cell_index()],
+                                na=inst.na,
+                                nb=inst.nb,
+                                a=inst.a,
+                                b=inst.b,
+                            )
+
+                    else:
                         inst_ = kc_.icreate_inst(
-                            kcls[inst.cell.library().name()][lib_c.cell_index()],
+                            kc_.kcl[inst.cell.name],
                             na=inst.na,
                             nb=inst.nb,
                             a=inst.a,
                             b=inst.b,
                         )
+                    inst_.cplx_trans = inst.cplx_trans
+                kc_.copy_shapes(kc.kdb_cell)
+                kc_.copy_meta_info(kc.kdb_cell)
+                kc_.get_meta_data()
 
-                else:
-                    inst_ = kc_.icreate_inst(
-                        kc_.kcl[inst.cell.name],
-                        na=inst.na,
-                        nb=inst.nb,
-                        a=inst.a,
-                        b=inst.b,
-                    )
-                inst_.cplx_trans = inst.cplx_trans
-            kc_.copy_shapes(kc.kdb_cell)
-            kc_.copy_meta_info(kc.kdb_cell)
-            kc_.get_meta_data()
-
-            tkc_ = kc_._base
-            factory.cache[hk] = factory.output_type(base=tkc_)
+                tkc_ = kc_._base
+                factory.cache[hk] = factory.output_type(base=tkc_)
     loaded_kcl.delete()
 
 
@@ -218,5 +346,90 @@ def _file_hash(path: Path) -> str:
 
 
 @functools.cache
-def _file_path_hash(path: Path) -> str:
-    return sha256(str(path).encode()).hexdigest()
+def _file_path(path: Path) -> str:
+    return str(path)
+
+
+def _reduce_region(
+    obj: kdb.Region,
+) -> tuple[Callable[..., Any], tuple[tuple[str, ...]]]:
+    return (_read_region, (tuple([p.to_s() for p in obj.each()]),))
+
+
+def _read_region(polygons: tuple[str]) -> kdb.Region:
+    return kdb.Region([kdb.PolygonWithProperties.from_s(p) for p in polygons])
+
+
+def _reduce_klayout_shapes(
+    obj: IShapeLike | DShapeLike,
+) -> tuple[Callable[..., Any], tuple[str, ...] | tuple[tuple[str, ...]]]:
+    if isinstance(obj, kdb.Region):
+        return _reduce_region(obj)
+    return (obj.__class__.from_s, (obj.to_s(),))
+
+
+def _reduce_layer_info(obj: kdb.LayerInfo) -> tuple[Callable[..., Any], tuple[str]]:
+    return (kdb.LayerInfo.from_string, (obj.to_s(),))
+
+
+def _get_cell(kcl_name: str, virtual: bool, factory_name: str, settings: Any) -> Any:
+    if virtual:
+        return kcls[kcl_name].virtual_factories[factory_name](**settings)
+    return kcls[kcl_name].factories[factory_name](**settings)
+
+
+def _reduce_protocells(
+    obj: ProtoTKCell[Any] | VKCell,
+) -> tuple[Callable[..., Any], tuple[str, bool, str, Any]]:
+    if obj.has_factory_name():
+        if isinstance(obj, ProtoKCell):
+            return (
+                _get_cell,
+                (
+                    obj.kcl.name,
+                    False,
+                    obj.kcl.factories[obj.factory_name].name,
+                    obj.settings.model_dump(),
+                ),
+            )
+        return (
+            _get_cell,
+            (
+                obj.kcl.name,
+                True,
+                obj.factory_name.name,
+                obj.settings.model_dump(),
+            ),
+        )
+    raise NotImplementedError
+
+
+def _get_symmetrical_cross_section(
+    model_dump: dict[str, Any],
+) -> SymmetricalCrossSection:
+    return SymmetricalCrossSection.model_validate(model_dump)
+
+
+def _reduce_symmetrical_cross_section(
+    obj: SymmetricalCrossSection,
+) -> tuple[Callable[..., SymmetricalCrossSection], tuple[dict[str, Any]]]:
+    return (_get_symmetrical_cross_section, (obj.model_dump(),))
+
+
+def _get_asymmetrical_cross_section(
+    model_dump: dict[str, Any],
+) -> AsymmetricalCrossSection:
+    return AsymmetricalCrossSection.model_validate(model_dump)
+
+
+def _reduce_asymmetrical_cross_section(
+    obj: AsymmetricalCrossSection,
+) -> tuple[Callable[..., AsymmetricalCrossSection], tuple[dict[str, Any]]]:
+    return (_get_asymmetrical_cross_section, (obj.model_dump(),))
+
+
+for cls in IShapeLike.__value__.__args__:
+    copyreg.pickle(cls, _reduce_klayout_shapes)
+copyreg.pickle(kdb.LayerInfo, _reduce_layer_info)
+copyreg.pickle(SymmetricalCrossSection, _reduce_symmetrical_cross_section)
+copyreg.pickle(AsymmetricalCrossSection, _reduce_asymmetrical_cross_section)
diff --git a/src/kfactory/settings.py b/src/kfactory/settings.py
index acc31fd90..5252d236b 100644
--- a/src/kfactory/settings.py
+++ b/src/kfactory/settings.py
@@ -17,7 +17,7 @@ class SettingMixin:
 
     def __getattr__(self, key: str) -> Any:
         """Get the value of a setting."""
-        return super().__getattr__(key)  # type: ignore[misc]
+        return super().__getattr__(key)  # ty:ignore[unresolved-attribute]
 
     def __getitem__(self, key: str) -> Any:
         """Get the value of a setting."""
@@ -72,8 +72,15 @@ def restrict_types(cls, data: dict[str, str]) -> dict[str, str]:
         return data
 
 
-class Info(SettingMixin, BaseModel, extra="allow", validate_assignment=True):
-    """Info for a KCell."""
+class Info(SettingMixin, BaseModel, extra="allow"):
+    """Info for a KCell.
+
+    `validate_assignment` is intentionally off: combined with a
+    `model_validator(mode="before")` it would re-run validation against the
+    entire extras dict on every per-field write, which historically silently
+    coerced previously-stored values via `clean_value()` (see #944). Per-write
+    validation is handled in `__setattr__` and only inspects the new value.
+    """
 
     def __init__(self, **kwargs: Any) -> None:
         """Initialize the settings."""
@@ -82,23 +89,34 @@ def __init__(self, **kwargs: Any) -> None:
     @model_validator(mode="before")
     @classmethod
     def restrict_types(cls, data: dict[str, MetaData]) -> dict[str, MetaData]:
-        """Restrict the types of the settings."""
+        """Restrict the types of the settings (runs at construction only)."""
         for name, value in data.items():
-            try:
-                data[name] = check_metadata_type(value)
-            except ValueError as e:
-                raise ValueError(
-                    "Values of the info dict only support int, float, string, "
-                    "tuple, list, dict or None."
-                    f"{name}: {value}, {type(value)}"
-                ) from e
-
+            data[name] = cls._check_value(name, value)
         return data
 
+    @staticmethod
+    def _check_value(name: str, value: MetaData) -> MetaData:
+        try:
+            return check_metadata_type(value)
+        except ValueError as e:
+            raise ValueError(
+                "Values of the info dict only support int, float, string, "
+                "tuple, list, dict or None."
+                f"{name}: {value}, {type(value)}"
+            ) from e
+
+    def __setattr__(self, name: str, value: Any) -> None:
+        """Validate the assigned value, then store it."""
+        if name.startswith("_"):
+            super().__setattr__(name, value)
+            return
+        super().__setattr__(name, self._check_value(name, value))
+
     def update(self, data: dict[str, MetaData]) -> None:
         """Update the settings."""
-        for key, value in data.items():
-            setattr(self, key, value)
+        validated = {k: self._check_value(k, v) for k, v in data.items()}
+        for key, value in validated.items():
+            super().__setattr__(key, value)
 
     def __setitem__(self, key: str, value: MetaData) -> None:
         """Set the value of a setting."""
diff --git a/src/kfactory/shapes.py b/src/kfactory/shapes.py
index ecef3bca6..65a0e1cc9 100644
--- a/src/kfactory/shapes.py
+++ b/src/kfactory/shapes.py
@@ -1,14 +1,15 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
 
 from . import kdb
-from .typings import DShapeLike, IShapeLike, ShapeLike
+from .serialization import dshape_guard, ishape_guard
 
 if TYPE_CHECKING:
     from collections.abc import Iterator, Sequence
 
     from .kcell import VKCell
+    from .typings import DShapeLike, ShapeLike
 
 
 __all__ = ["VShapes"]
@@ -72,9 +73,9 @@ def transform(
             trans = trans.to_itrans(self.cell.kcl.dbu)
 
         for shape in self._shapes:
-            if isinstance(shape, DShapeLike):
+            if dshape_guard(shape):
                 new_shapes.append(shape.transformed(trans))
-            elif isinstance(shape, IShapeLike):
+            elif ishape_guard(shape):
                 if isinstance(shape, kdb.Region):
                     new_shapes.extend(
                         poly.to_dtype(self.cell.kcl.dbu).transformed(trans)
@@ -85,7 +86,7 @@ def transform(
                         shape.to_dtype(self.cell.kcl.dbu).transformed(trans)
                     )
             else:
-                new_shapes.append(shape.dpolygon.transform(trans))
+                new_shapes.append(cast("kdb.Shape", shape).dpolygon.transform(trans))
 
         return VShapes(cell=self.cell, _shapes=new_shapes)
 
diff --git a/src/kfactory/technology/layer_map.py b/src/kfactory/technology/layer_map.py
index b12c7ea48..7f15a2be6 100644
--- a/src/kfactory/technology/layer_map.py
+++ b/src/kfactory/technology/layer_map.py
@@ -151,8 +151,8 @@ def kl2lp(kl: lay.LayerPropertiesNodeRef) -> LayerPropertiesModel:
         layer=(kl.source_layer, kl.source_datatype),
         frame_color=Color(hex(kl.frame_color)) if kl.frame_color else None,
         fill_color=Color(hex(kl.fill_color)) if kl.fill_color else None,
-        dither_pattern=index2dither[kl.dither_pattern],  # type: ignore[arg-type]
-        line_style=index2line.get(kl.line_style, "solid"),  # type: ignore[arg-type]
+        dither_pattern=index2dither[kl.dither_pattern],  # ty:ignore[invalid-argument-type]
+        line_style=index2line.get(kl.line_style, "solid"),  # ty:ignore[invalid-argument-type]
         visible=kl.visible,
         width=kl.width,
         xfill=kl.xfill,
diff --git a/src/kfactory/typings.py b/src/kfactory/typings.py
index 662ea68e6..d59998d4b 100644
--- a/src/kfactory/typings.py
+++ b/src/kfactory/typings.py
@@ -1,19 +1,18 @@
 from __future__ import annotations
 
+from collections.abc import Mapping, Sequence
 from typing import (
     TYPE_CHECKING,
     Annotated,
     Any,
     NotRequired,
     ParamSpec,
-    TypeAlias,
     TypedDict,
     TypeVar,
 )
 
 import klayout.db as kdb
 from klayout import lay
-from typing_extensions import TypeAliasType
 
 if TYPE_CHECKING:
     from collections.abc import Callable, Iterable
@@ -39,12 +38,8 @@
 K_contra = TypeVar("K_contra", bound="ProtoKCell[Any, Any]", contravariant=True)
 KC_contra = TypeVar("KC_contra", bound="ProtoTKCell[Any]", contravariant=True)
 VK_contra = TypeVar("VK_contra", bound="VKCell", contravariant=True)
-TUnit = TypeVar("TUnit", int, float)
-TUnit_co = TypeVar("TUnit_co", bound=int | float, covariant=True)
-TUnit_contra = TypeVar("TUnit_contra", bound=int | float, contravariant=True)
-TPort = TypeVar("TPort", bound="ProtoPort[Any]")
-TPort_co = TypeVar("TPort_co", bound="ProtoPort[Any]", covariant=True)
-TPort_contra = TypeVar("TPort_contra", bound="ProtoPort[Any]", contravariant=True)
+type TUnit = int | float
+type TPort = ProtoPort[int | float]
 TPin = TypeVar("TPin", bound="ProtoPin[Any]")
 TInstance_co = TypeVar("TInstance_co", bound="ProtoInstance[Any]", covariant=True)
 TTInstance_co = TypeVar("TTInstance_co", bound="ProtoTInstance[Any]", covariant=True)
@@ -63,9 +58,14 @@
 P = ParamSpec("P")
 
 
-JSONSerializable = TypeAliasType(
-    "JSONSerializable",
-    "int | float| bool | str | list[JSONSerializable] | tuple[JSONSerializable, ...] | dict[str, JSONSerializable] | None",  # noqa: E501
+type JSONSerializable = (
+    int
+    | float
+    | bool
+    | str
+    | Sequence["JSONSerializable"]
+    | Mapping[str, "JSONSerializable"]
+    | None
 )
 
 
@@ -81,7 +81,7 @@ class KCellSpecDict(TypedDict, total=True):
     "AnyTrans", bound=kdb.Trans | kdb.DTrans | kdb.ICplxTrans | kdb.DCplxTrans
 )
 
-SerializableShape: TypeAlias = (
+type SerializableShape = (
     kdb.Box
     | kdb.DBox
     | kdb.Edge
@@ -115,7 +115,7 @@ class KCellSpecDict(TypedDict, total=True):
     | kdb.DVector
     | kdb.LayerInfo
 )
-IShapeLike: TypeAlias = (
+type IShapeLike = (
     kdb.Polygon
     | kdb.Edge
     | kdb.Path
@@ -124,20 +124,19 @@ class KCellSpecDict(TypedDict, total=True):
     | kdb.SimplePolygon
     | kdb.Region
 )
-DShapeLike: TypeAlias = (
+type DShapeLike = (
     kdb.DPolygon | kdb.DEdge | kdb.DPath | kdb.DBox | kdb.DText | kdb.DSimplePolygon
 )
-ShapeLike: TypeAlias = IShapeLike | DShapeLike | kdb.Shape
+type ShapeLike = IShapeLike | DShapeLike | kdb.Shape
 
-MetaData: TypeAlias = (
+type MetaData = (
     int
     | float
     | bool
     | str
     | SerializableShape
-    | list["MetaData"]
-    | tuple["MetaData", ...]
-    | dict[str, "MetaData"]
+    | Sequence["MetaData"]
+    | Mapping[str, "MetaData"]
     | None
 )
 
@@ -153,14 +152,14 @@ class KCellSpecDict(TypedDict, total=True):
 layer = Annotated["int | LayerEnum", "layer"]
 """Integer or enum index of a Layer."""
 layer_info = Annotated[kdb.LayerInfo, "layer info"]
-Unit: TypeAlias = int | float
+type Unit = int | float
 """Database unit or micrometer."""
-Angle: TypeAlias = int
+type Angle = int
 """Integer in the range of `[0,1,2,3]` which are increments in 90°."""
-KCellSpec: TypeAlias = (
+type KCellSpec = (
     "int | str | KCellSpecDict | ProtoTKCell[Any] | Callable[..., ProtoTKCell[Any]]"
 )
-AnyCellSpec: TypeAlias = "int | str | KCellSpecDict | ProtoTKCell[Any] | VKCell | Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]"  # noqa: E501
+type AnyCellSpec = "int | str | KCellSpecDict | ProtoTKCell[Any] | VKCell | Callable[..., ProtoTKCell[Any]] | Callable[..., VKCell]"  # noqa: E501
 
 
 class CellKwargs(TypedDict, total=False):
@@ -184,3 +183,28 @@ class CellKwargs(TypedDict, total=False):
     lvs_equivalent_ports: list[list[str]]
     ports: PortsDefinition
     schematic_function: Callable[..., TSchematic[Any]]
+
+
+class MarkerConfig(TypedDict, total=False):
+    """Configure marker to send through the show function.
+
+    Attrs:
+        color: 0xffffff type of color as int [default: None (let klayout decide)]
+        dismissable: bool whether dismissable [default: True]
+        dither_pattern: dither pattern to use [default: None (let klayout decide)]
+        frame_color: 0xffffff type of color as int [default: None (let klayout decide)]
+        halo: halo value [default: -1 (klayout default is applied)]
+        frame_color: 0xffffff type of color as int [default: None (let klayout decide)]
+        line_style: line style to show [default: None (let klayout decide)]
+        line_style: line width to show [default: 1]
+        vertex_size: size of vertices [default: 1]
+    """
+
+    color: int | None
+    dismissable: bool
+    dither_pattern: int | None
+    halo: int
+    frame_color: int | None
+    line_style: int | None
+    line_width: int
+    vertex_size: int
diff --git a/src/kfactory/utilities.py b/src/kfactory/utilities.py
index 8d1256dca..dd21a9eec 100644
--- a/src/kfactory/utilities.py
+++ b/src/kfactory/utilities.py
@@ -16,6 +16,7 @@
     from .kcell import ProtoTKCell
     from .pin import DPin, Pin
     from .port import Port, ProtoPort
+    from .typings import DShapeLike, MarkerConfig
 
 
 def load_layout_options(**attributes: Any) -> kdb.LoadLayoutOptions:
@@ -50,6 +51,7 @@ def save_layout_options(**attributes: Any) -> kdb.SaveLayoutOptions:
     save.gds2_write_file_properties = config.write_file_properties
     save.gds2_write_timestamps = config.write_timestamps
     save.gds2_max_cellname_length = config.max_cellname_length
+    save.gds2_multi_xy_records = config.multi_xy_records
 
     for k, v in attributes.items():
         setattr(save, k, v)
@@ -116,7 +118,7 @@ def check_cell_ports(p1: ProtoPort[Any], p2: ProtoPort[Any]) -> int:
     return check_int
 
 
-def instance_port_name(inst: Instance, port: Port) -> str:
+def instance_port_name(inst: Instance, port: ProtoPort[Any]) -> str:
     """Create a name for an instance port.
 
     Args:
@@ -143,6 +145,7 @@ def pprint_ports(
     table.add_column("Name")
     table.add_column("Width")
     table.add_column("Layer")
+    table.add_column("Type")
     table.add_column("X")
     table.add_column("Y")
     table.add_column("Angle")
@@ -157,6 +160,7 @@ def pprint_ports(
                         str(port.name) + " [dbu]",
                         f"{port.width:_}",
                         port.kcl.get_info(port.layer).to_s(),
+                        port.port_type,
                         f"{port.x:_}",
                         f"{port.y:_}",
                         str(port.angle),
@@ -167,13 +171,14 @@ def pprint_ports(
                     t = port.dcplx_trans
                     dx = t.disp.x
                     dy = t.disp.y
-                    dwidth = port.kcl.to_um(port.cross_section.width)
+                    dwidth = port.dwidth
                     angle = t.angle
                     mirror = t.mirror
                     table.add_row(
                         str(port.name) + " [um]",
                         f"{dwidth:_}",
                         port.kcl.get_info(port.layer).to_s(),
+                        port.port_type,
                         f"{dx:_}",
                         f"{dy:_}",
                         str(angle),
@@ -186,13 +191,14 @@ def pprint_ports(
                 t = dport.dcplx_trans
                 dx = t.disp.x
                 dy = t.disp.y
-                dwidth = dport.cross_section.width
+                dwidth = dport.dwidth
                 angle = t.angle
                 mirror = t.mirror
                 table.add_row(
                     str(dport.name) + " [um]",
                     f"{dwidth:_}",
                     dport.kcl.get_info(dport.layer).to_s(),
+                    dport.port_type,
                     f"{dx:_}",
                     f"{dy:_}",
                     str(angle),
@@ -206,6 +212,7 @@ def pprint_ports(
                     str(iport.name) + " [dbu]",
                     f"{iport.width:_}",
                     iport.kcl.get_info(iport.layer).to_s(),
+                    iport.port_type,
                     f"{iport.x:_}",
                     f"{iport.y:_}",
                     str(iport.angle),
@@ -252,7 +259,24 @@ def as_png_data(
     c: ProtoTKCell[Any],
     layer_properties: str | Path | None = None,
     resolution: tuple[int, int] = (800, 600),
+    synchronous: bool = True,
+    markers: list[tuple[DShapeLike, MarkerConfig]] | None = None,
 ) -> bytes:
+    """Render a cell to PNG bytes via a headless ``lay.LayoutView``.
+
+    Args:
+        c: cell to render.
+        layer_properties: optional ``.lyp`` to apply.
+        resolution: ``(width, height)`` in pixels.
+        synchronous: ``True`` to render synchronously (default), ``False`` to
+            return whatever the view currently has.
+        markers: optional list of ``(shape, config)`` pairs. Each shape becomes
+            a ``lay.Marker`` overlay on the view; ``config`` is the same
+            ``MarkerConfig`` dict that `kfactory.show` accepts (``color``,
+            ``line_width``, ``halo``, …). When markers are supplied, the view
+            zooms to the union of all marker bounding boxes expanded by 10 %
+            instead of fitting the full cell.
+    """
     layout_view = lay.LayoutView()
     layout_view.show_layout(c.kcl.layout.dup(), False)
     if layer_properties is not None:
@@ -265,7 +289,60 @@ def as_png_data(
     layout_view.max_hier()
     layout_view.resize(*resolution)
     layout_view.add_missing_layers()
-    layout_view.zoom_fit()
+
+    # Keep marker references alive — klayout drops markers whose Python
+    # handle has been garbage-collected before the screenshot is taken.
+    marker_refs: list[lay.Marker] = []
+    if markers:
+        bbox = kdb.DBox()
+        for shape, cfg in markers:
+            m = lay.Marker(layout_view)
+            if isinstance(shape, kdb.DPolygon | kdb.DSimplePolygon):
+                m.set_polygon(
+                    shape if isinstance(shape, kdb.DPolygon) else kdb.DPolygon(shape)
+                )
+            elif isinstance(shape, kdb.DBox):
+                m.set_box(shape)
+            elif isinstance(shape, kdb.DEdge):
+                m.set_edge(shape)
+            elif isinstance(shape, kdb.DPath):
+                m.set_path(shape)
+            elif isinstance(shape, kdb.DText):
+                m.set_text(shape)
+            else:
+                continue
+            if (color := cfg.get("color")) is not None:
+                m.color = color
+            if (frame_color := cfg.get("frame_color")) is not None:
+                m.frame_color = frame_color
+            if (line_width := cfg.get("line_width")) is not None:
+                m.line_width = line_width
+            if (line_style := cfg.get("line_style")) is not None:
+                m.line_style = line_style
+            if (halo := cfg.get("halo")) is not None:
+                m.halo = halo
+            if (vertex_size := cfg.get("vertex_size")) is not None:
+                m.vertex_size = vertex_size
+            if (dither_pattern := cfg.get("dither_pattern")) is not None:
+                m.dither_pattern = dither_pattern
+            if (dismissable := cfg.get("dismissable")) is not None:
+                m.dismissable = dismissable
+            bbox += shape.bbox()
+            marker_refs.append(m)
+
+        if not bbox.empty():
+            pad_x = bbox.width() * 0.1 or 1.0
+            pad_y = bbox.height() * 0.1 or 1.0
+            layout_view.zoom_box(bbox.enlarged(pad_x, pad_y))
+        else:
+            layout_view.zoom_fit()
+    else:
+        layout_view.zoom_fit()
+
+    if synchronous:
+        return layout_view.get_pixels_with_options(
+            width=resolution[0], height=resolution[1]
+        ).to_png_data()
     return layout_view.get_screenshot_pixels().to_png_data()
 
 
diff --git a/src/kfactory/utils/difftest.py b/src/kfactory/utils/difftest.py
index 01c116416..e4189c7de 100644
--- a/src/kfactory/utils/difftest.py
+++ b/src/kfactory/utils/difftest.py
@@ -4,10 +4,11 @@
 import filecmp
 import pathlib
 import shutil
+from typing import Any
 
 import kfactory as kf
 from kfactory import DKCell, KCLayout, kdb
-from kfactory.kcell import TKCell
+from kfactory.kcell import ProtoTKCell
 
 
 class GeometryDifferenceError(Exception):
@@ -18,8 +19,8 @@ class GeometryDifferenceError(Exception):
 
 
 def xor(
-    old: KCLayout,
-    new: KCLayout,
+    old: ProtoTKCell[Any],
+    new: ProtoTKCell[Any],
     test_name: str = "",
     ignore_sliver_differences: bool = False,
     ignore_cell_name_differences: bool = False,
@@ -93,16 +94,16 @@ def text_diff_b(bnota: kdb.Text, prop_id: int) -> None:
         print("Text only in new")
         get_texts(ld.layer_index_b(), b_texts).insert(bnota)
 
-    ld.on_cell_in_a_only = lambda anotb: cell_diff_a(anotb)  # type: ignore[assignment]
-    ld.on_cell_in_b_only = lambda anotb: cell_diff_b(anotb)  # type: ignore[assignment]
-    ld.on_text_in_a_only = lambda anotb, prop_id: text_diff_a(anotb, prop_id)  # type: ignore[assignment]
-    ld.on_text_in_b_only = lambda anotb, prop_id: text_diff_b(anotb, prop_id)  # type: ignore[assignment]
+    ld.on_cell_in_a_only = cell_diff_a  # ty:ignore[invalid-assignment]
+    ld.on_cell_in_b_only = cell_diff_b  # ty:ignore[invalid-assignment]
+    ld.on_text_in_a_only = text_diff_a  # ty:ignore[invalid-assignment]
+    ld.on_text_in_b_only = text_diff_b  # ty:ignore[invalid-assignment]
 
-    ld.on_polygon_in_a_only = lambda anotb, prop_id: polygon_diff_a(anotb, prop_id)  # type: ignore[assignment]
-    ld.on_polygon_in_b_only = lambda anotb, prop_id: polygon_diff_b(anotb, prop_id)  # type: ignore[assignment]
+    ld.on_polygon_in_a_only = polygon_diff_a  # ty:ignore[invalid-assignment]
+    ld.on_polygon_in_b_only = polygon_diff_b  # ty:ignore[invalid-assignment]
 
     if ignore_cell_name_differences:
-        ld.on_cell_name_differs = lambda anotb: print(f"cell name differs {anotb.name}")  # type: ignore[assignment]
+        ld.on_cell_name_differs = lambda anotb: print(f"cell name differs {anotb.name}")  # ty:ignore[invalid-assignment]
         equal = ld.compare(
             old.kdb_cell,
             new.kdb_cell,
@@ -284,16 +285,16 @@ def text_diff_b(bnota: kdb.Text, prop_id: int) -> None:
         print("Text only in new")
         get_texts(ld.layer_index_b(), b_texts).insert(bnota)
 
-    ld.on_cell_in_a_only = cell_diff_a  # type: ignore[assignment]
-    ld.on_cell_in_b_only = cell_diff_b  # type: ignore[assignment]
-    ld.on_text_in_a_only = text_diff_a  # type: ignore[assignment]
-    ld.on_text_in_b_only = text_diff_b  # type: ignore[assignment]
+    ld.on_cell_in_a_only = cell_diff_a  # ty:ignore[invalid-assignment]
+    ld.on_cell_in_b_only = cell_diff_b  # ty:ignore[invalid-assignment]
+    ld.on_text_in_a_only = text_diff_a  # ty:ignore[invalid-assignment]
+    ld.on_text_in_b_only = text_diff_b  # ty:ignore[invalid-assignment]
 
-    ld.on_polygon_in_a_only = polygon_diff_a  # type: ignore[assignment]
-    ld.on_polygon_in_b_only = polygon_diff_b  # type: ignore[assignment]
+    ld.on_polygon_in_a_only = polygon_diff_a  # type: ignore[assignment]  # ty:ignore[invalid-assignment]
+    ld.on_polygon_in_b_only = polygon_diff_b  # type: ignore[assignment]  # ty:ignore[invalid-assignment]
 
     if ignore_cell_name_differences:
-        ld.on_cell_name_differs = lambda anotb: print(f"cell name differs {anotb.name}")  # type: ignore[assignment]
+        ld.on_cell_name_differs = lambda anotb: print(f"cell name differs {anotb.name}")  # ty:ignore[invalid-assignment]
         equal = ld.compare(
             old.kdb_cell,
             new.kdb_cell,
@@ -400,7 +401,7 @@ def text_diff_b(bnota: kdb.Text, prop_id: int) -> None:
 
 
 def difftest(
-    component: TKCell,
+    component: ProtoTKCell[Any],
     dirpath: pathlib.Path,
     dirpath_run: pathlib.Path,
     test_name: str | None = None,
@@ -496,6 +497,6 @@ def read_top_cell(arg0: pathlib.Path) -> kf.DKCell:
     kcell = kcl.dkcells[kcl.top_cell().name]
 
     if hasattr(kcl, "cross_sections"):
-        for cross_section in kcl.cross_sections.cross_sections.values():
-            kf.kcl.get_symmetrical_cross_section(cross_section)
+        for cross_section in set(kcl.cross_sections.cross_sections.values()):
+            kf.kcl.get_base_cross_section(cross_section, symmetrical=None)
     return kcell
diff --git a/src/kfactory/utils/fill.py b/src/kfactory/utils/fill.py
index 57befb3e9..636f3127a 100644
--- a/src/kfactory/utils/fill.py
+++ b/src/kfactory/utils/fill.py
@@ -165,7 +165,7 @@ def fill_tiled(
     dbb = c.dbbox()
     for r, ext in fill_regions:
         dbb += r.bbox().to_dtype(c.kcl.dbu).enlarged(ext)
-    tp.frame = dbb  # type: ignore[assignment, misc]
+    tp.frame = dbb  # ty:ignore[invalid-assignment]
     tp.dbu = c.kcl.dbu
     tp.threads = n_threads
 
@@ -292,7 +292,7 @@ def fill_tiled(
         c.kcl.start_changes()
         try:
             logger.debug(
-                "Filling {} with {}", c.kcl.future_cell_name or c.name, fill_cell.name
+                "Filling {} with {}", c.kcl._future_cell_name or c.name, fill_cell.name
             )
             logger.debug("Fill string: '{}'", queue_str)
             tp.execute(f"Fill {c.name}")
@@ -337,7 +337,7 @@ def add_coverage(
     dbb = c.dbbox()
     for r, ext in coverage_regions:
         dbb += r.bbox().to_dtype(c.kcl.dbu).enlarged(ext)
-    tp.frame = dbb  # type: ignore[assignment, misc]
+    tp.frame = dbb  # ty:ignore[invalid-assignment]
     tp.dbu = c.kcl.dbu
     tp.threads = n_threads
 
@@ -466,7 +466,7 @@ def add_coverage(
         try:
             logger.debug(
                 "Adding coverage on '{}' with '{}'",
-                c.kcl.future_cell_name or c.name,
+                c.kcl._future_cell_name or c.name,
                 coverage_cell.name,
             )
             logger.debug("Coverage string: '{}'", queue_str)
diff --git a/src/kfactory/utils/violations.py b/src/kfactory/utils/violations.py
index 74678df79..1a062440b 100644
--- a/src/kfactory/utils/violations.py
+++ b/src/kfactory/utils/violations.py
@@ -1,6 +1,6 @@
 """Utilities to fix DRC violations.
 
-:py:func:~`fix_spacing_tiled` uses :py:func:~`kdb.Region.space_check` to detect
+`fix_spacing_tiled` uses `kdb.Region.space_check` to detect
 minimum space violations and then applies a fix.
 """
 
@@ -98,7 +98,7 @@ def fix_spacing_tiled(
         tile_size = (tile_dim, tile_dim)
     li = c.kcl.layer(layer)
     tp = kdb.TilingProcessor()
-    tp.frame = c.kcl.to_um(c.bbox(li))  # type: ignore[misc, assignment]
+    tp.frame = c.kcl.to_um(c.bbox(li))  # ty:ignore[invalid-assignment]
     tp.dbu = c.kcl.dbu
     tp.tile_size(*tile_size)  # tile size in um
     tp.tile_border(min_space * overlap * tp.dbu, min_space * overlap * tp.dbu)
@@ -174,7 +174,7 @@ def fix_spacing_sizing_tiled(
         tile_dim = min(25 * c.kcl.to_um(min_space), 250)
         tile_size = (tile_dim, tile_dim)
     li = c.kcl.layer(layer)
-    tp.frame = c.kcl.to_um(c.bbox(li))  # type: ignore[misc, assignment]
+    tp.frame = c.kcl.to_um(c.bbox(li))  # ty:ignore[invalid-assignment]
     tp.dbu = c.kcl.dbu
     tp.tile_size(*tile_size)  # tile size in um
     tp.tile_border(min_space * overlap * tp.dbu, min_space * overlap * tp.dbu)
@@ -228,7 +228,7 @@ def fix_spacing_minkowski_tiled(
     """
     c = KCell(base=c.base)
     tp = kdb.TilingProcessor()
-    tp.frame = c.dbbox()  # type: ignore[misc, assignment]
+    tp.frame = c.dbbox()  # ty:ignore[invalid-assignment]
     tp.dbu = c.kcl.dbu
     tp.threads = n_threads or config.n_threads
 
@@ -304,7 +304,7 @@ def fix_width_minkowski_tiled(
     """
     c = KCell(base=c.base)
     tp = kdb.TilingProcessor()
-    tp.frame = c.dbbox()  # type: ignore[misc, assignment]
+    tp.frame = c.dbbox()  # ty:ignore[invalid-assignment]
     tp.dbu = c.kcl.dbu
     tp.threads = n_threads or config.n_threads
 
@@ -384,7 +384,7 @@ def fix_width_and_spacing_minkowski_tiled(
     """
     c = KCell(base=c.base)
     tp = kdb.TilingProcessor()
-    tp.frame = c.dbbox()  # type: ignore[misc, assignment]
+    tp.frame = c.dbbox()  # ty:ignore[invalid-assignment]
     tp.dbu = c.kcl.dbu
     tp.threads = n_threads or config.n_threads
 
@@ -462,7 +462,7 @@ def put(
             ix: x-axis index of tile.
             iy: y_axis index of tile.
             tile: The bounding box of the tile.
-            region: The target object of the :py:class:~`klayout.db.TilingProcessor`
+            region: The target object of the `klayout.db.TilingProcessor`
             dbu: dbu used by the processor.
             clip: Whether the target was clipped to the tile or not.
         """
diff --git a/tests/conftest.py b/tests/conftest.py
index 5d55fe154..eeea4cff9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,6 +8,7 @@
 
 import pytest
 from pytest_regressions.file_regression import FileRegressionFixture
+from ruamel.yaml import YAML
 
 import kfactory as kf
 import kfactory.cells
@@ -60,12 +61,8 @@ def layers() -> Layers:
 
 
 @pytest.fixture
-def kcl() -> kf.KCLayout:
-    with counter_lock:
-        global counter  # noqa: PLW0603
-        name = str(counter)
-        counter += 1
-        return kf.KCLayout(name=name, infos=Layers)
+def kcl(request: pytest.FixtureRequest) -> kf.KCLayout:
+    return kf.KCLayout(name=kf.kcell.clean_name(request.node.name), infos=Layers)
 
 
 @pytest.fixture
@@ -75,6 +72,20 @@ def wg_enc(kcl: kf.KCLayout, layers: Layers) -> kf.LayerEnclosure:
     )
 
 
+@pytest.fixture
+def sym_enc() -> Callable[..., kf.LayerEnclosure]:
+    """Build a fixed-structure enclosure, optionally named."""
+
+    def _make(name: str | None = None) -> kf.LayerEnclosure:
+        return kf.LayerEnclosure(
+            sections=[(kf.kdb.LayerInfo(2, 0, "S"), 500)],
+            main_layer=kf.kdb.LayerInfo(1, 0, "WG"),
+            name=name,
+        )
+
+    return _make
+
+
 @pytest.fixture
 def straight_factory_dbu(
     layers: Layers, wg_enc: kf.LayerEnclosure, kcl: kf.KCLayout
@@ -237,16 +248,19 @@ def unlink_merge_read_oas() -> Iterator[None]:
 
 
 @pytest.fixture
-def gds_regression(
+def oas_regression(
     file_regression: FileRegressionFixture,
 ) -> Callable[[kf.ProtoTKCell[Any]], None]:
     saveopts = kf.save_layout_options()
-    saveopts.format = "GDS2"
+    saveopts.format = "OASIS"
 
     raises: Literal["error", "warning"] = (
         "error" if platform.system() == "Linux" else "warning"
     )
 
+    write_settings = kf.config.write_kfactory_settings
+    kf.config.write_kfactory_settings = False
+
     def _check(
         c: kf.ProtoTKCell[Any],
         tolerance: int = 0,
@@ -256,9 +270,30 @@ def _check(
         file_regression.check(
             c.write_bytes(saveopts, convert_external_cells=True),
             binary=True,
-            extension=".gds",
+            extension=".oas",
             check_fn=partial(_layout_xor, tolerance=tolerance, raises=raises),
         )
+        kf.config.write_kfactory_settings = write_settings
+
+    return _check
+
+
+@pytest.fixture
+def yaml_regression(
+    file_regression: FileRegressionFixture,
+) -> Callable[[kf.schematic.TSchematic[Any]], None]:
+    yaml = YAML(typ=["rt", "safe", "string"])
+
+    def _check(
+        schematic: kf.schematic.TSchematic[Any],
+    ) -> None:
+        dumped = yaml.dump_to_string(  # ty:ignore[unresolved-attribute]
+            schematic.model_dump(exclude_defaults=True, warnings=False)
+        )
+        file_regression.check(
+            dumped,
+            extension=".yml",
+        )
 
     return _check
 
@@ -275,7 +310,11 @@ def _layout_xor(
     ly_b = kf.kdb.Layout()
     ly_b.read(str(path_b))
 
-    flags = kf.kdb.LayoutDiff.Verbose | kf.kdb.LayoutDiff.WithMetaInfo
+    flags = (
+        kf.kdb.LayoutDiff.Verbose
+        | kf.kdb.LayoutDiff.WithMetaInfo
+        | kf.kdb.LayoutDiff.NoLayerNames
+    )
 
     if not diff.compare(ly_a, ly_b, flags=flags, tolerance=tolerance):
         match raises:
diff --git a/tests/custom/show.py b/tests/custom/show.py
index 419843b37..a40bd4054 100644
--- a/tests/custom/show.py
+++ b/tests/custom/show.py
@@ -18,6 +18,8 @@ def show(
     use_libraries: bool = True,
     library_save_options: kfactory.kdb.SaveLayoutOptions | None = None,
     technology: str | None = None,
+    markers: list[tuple[kfactory.typings.DShapeLike, kfactory.typings.MarkerConfig]]
+    | None = None,
 ) -> None:
     import kfactory as kf
 
diff --git a/tests/gdsfactory-yaml-pics b/tests/gdsfactory-yaml-pics
index 73c120c8f..b91432276 160000
--- a/tests/gdsfactory-yaml-pics
+++ b/tests/gdsfactory-yaml-pics
@@ -1 +1 @@
-Subproject commit 73c120c8f68edd1c0b42f792aab48fd39d03dbd7
+Subproject commit b914322766228464670d83392eb7586489b1d3f2
diff --git a/tests/session/session_func.py b/tests/session/session_func.py
new file mode 100644
index 000000000..53b47247b
--- /dev/null
+++ b/tests/session/session_func.py
@@ -0,0 +1,20 @@
+from collections.abc import Callable
+
+import kfactory as kf
+
+kcl_func = kf.KCLayout("SESSION_KCL_FUNC")
+
+cell_created = False
+
+
+def make_box(size: int) -> kf.kdb.Box:
+    return kf.kdb.Box(size)
+
+
+@kcl_func.cell
+def cell_with_func_arg(func: Callable[..., kf.kdb.Box], size: int = 500) -> kf.KCell:
+    global cell_created  # noqa: PLW0603
+    cell_created = True
+    c = kcl_func.kcell()
+    c.shapes(c.kcl.layer(1, 0)).insert(func(size))
+    return c
diff --git a/tests/test_cell.py b/tests/test_cell.py
index a2524d0ff..768736533 100644
--- a/tests/test_cell.py
+++ b/tests/test_cell.py
@@ -8,47 +8,47 @@
 import pytest
 
 import kfactory as kf
-from kfactory.cross_section import CrossSection, CrossSectionSpec
+from kfactory.cross_section import CrossSection, CrossSectionSpecDict
 from kfactory.exceptions import LockedError
 from tests.conftest import Layers
 
 
 def test_enclosure_name(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     wg = straight_factory_dbu(width=1000, length=10000)
-    assert wg.name == "straight_W1000_L10000_LWG_EWGSTD"
-    gds_regression(wg)
+    assert wg.name == "straight_CSf7fe636c_1000_L10000"
+    oas_regression(wg)
 
 
 def test_circular_snapping(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     b = kf.factories.circular.bend_circular_factory(kcl=kcl)(
         width=1, radius=10, layer=layers.WG, angle=90
     )
     assert b.ports["o2"].dcplx_trans.disp == kcl.to_um(b.ports["o2"].trans.disp)
-    gds_regression(b)
+    oas_regression(b)
 
 
 def test_euler_snapping(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     b = kf.factories.euler.bend_euler_factory(kcl=kcl)(
         width=1, radius=10, layer=layers.WG, angle=90
     )
     assert b.ports["o2"].dcplx_trans.disp == kcl.to_um(b.ports["o2"].trans.disp)
-    gds_regression(b)
+    oas_regression(b)
 
 
 def test_unnamed_cell(
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     @kcl.cell
     def unnamed_cell(name: str = "a") -> kf.KCell:
@@ -73,7 +73,7 @@ def wrong_dict_cell(a: dict[Any, Any]) -> kf.KCell:
 
 def test_nested_dict_list(
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     @kcl.cell
     def nested_list_dict(
@@ -88,17 +88,18 @@ def nested_list_dict(
     c = nested_list_dict(dl)
     assert dl == c.settings["arg1"]
     assert dl is not c.settings["arg1"]
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_no_snap(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
 
     c.create_port(
+        name="o1",
         width=c.kcl.to_dbu(1),
         dcplx_trans=kf.kdb.DCplxTrans(1, 90, False, 0.0005, 0),
         layer=c.kcl.find_layer(layers.WG),
@@ -107,12 +108,12 @@ def test_no_snap(
     p = c.ports[0]
 
     assert p.dcplx_trans.disp != c.kcl.to_um(p.trans.disp)
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_namecollision(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     bc = kf.factories.circular.bend_circular_factory(kcl=kcl)
@@ -120,24 +121,24 @@ def test_namecollision(
     b2 = bc(width=1, radius=10.5000005, layer=layers.WG)
 
     assert b1.name != b2.name
-    gds_regression(b1)
+    oas_regression(b1)
 
 
 def test_nested_dic(
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     @kcl.cell
     def recursive_dict_cell(d: dict[str, dict[str, str] | str]) -> kf.KCell:
         return kcl.kcell()
 
     c = recursive_dict_cell({"test": {"test2": "test3"}, "test4": "test5"})
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_ports_cell(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -149,12 +150,12 @@ def test_ports_cell(
     )
     assert c["o1"]
     assert "o1" in c.ports
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_ports_instance(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -170,11 +171,11 @@ def test_ports_instance(
     assert "o1" in c.ports
     assert ref["o1"]
     assert "o1" in ref.ports
-    gds_regression(c2)
+    oas_regression(c2)
 
 
 def test_getter(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
     straight_factory: Callable[..., kf.KCell],
 ) -> None:
@@ -186,7 +187,7 @@ def test_getter(
 
 def test_array(
     straight: kf.KCell,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -197,12 +198,12 @@ def test_array(
         for a in range(3):
             wg_array["o1", a, b]
             wg_array["o1", a, b]
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_array_indexerror(
     straight: kf.KCell,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -215,12 +216,12 @@ def test_array_indexerror(
         wg_array["o1", 3, 5]
         wg_array["o1", 3, 5]
     kf.config.logfilter.regex = regex
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_invalid_array(
     straight: kf.KCell,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -233,7 +234,7 @@ def test_invalid_array(
                 wg["o1", a, b]
                 wg["o1", a, b]
     kf.config.logfilter.regex = regex
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_cell_decorator_error(
@@ -258,7 +259,7 @@ def wrong_cell() -> kf.KCell:
 
 def test_info(
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     @kcl.cell(info={"test": 42})
     def test_info_cell(test: int) -> kf.KCell:
@@ -266,13 +267,13 @@ def test_info_cell(test: int) -> kf.KCell:
 
     c = test_info_cell(42)
     assert c.info["test"] == 42
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_flatten(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     c = kcl.kcell()
     _ = c << kf.factories.straight.straight_dbu_factory(kcl)(
@@ -281,13 +282,13 @@ def test_flatten(
     assert len(c.insts) == 1, "c.insts should have 1 inst after adding a cell"
     c.flatten()
     assert len(c.insts) == 0, "c.insts should have 0 insts after flatten()"
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_size_info(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     c = kcl.kcell()
     ref = c << kf.factories.straight.straight_dbu_factory(kcl)(
@@ -295,11 +296,11 @@ def test_size_info(
     )
     assert ref.size_info.ne[0] == 10000
     assert ref.dsize_info.ne[0] == 10
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_overwrite(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     kcl = kf.KCLayout("CELL_OVERWRITE")
 
@@ -309,7 +310,7 @@ def test_overwrite_cell() -> kf.KCell:
 
     c1 = test_overwrite_cell()
 
-    @kcl.cell(overwrite_existing=True)  # type: ignore[no-redef]
+    @kcl.cell(overwrite_existing=True)
     def test_overwrite_cell() -> kf.KCell:
         return kcl.kcell()
 
@@ -317,7 +318,7 @@ def test_overwrite_cell() -> kf.KCell:
 
     assert c2 is not c1
     assert c1.destroyed()
-    gds_regression(c2)
+    oas_regression(c2)
 
 
 def test_layout_cache() -> None:
@@ -377,7 +378,7 @@ def test_multi_ports() -> kf.KCell:
 def test_ports_in_cells(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     kcell = kcl.kcell(name="test")
     dkcell = kcell.to_dtype()
@@ -389,7 +390,7 @@ def test_ports_in_cells(
         cross_section=CrossSection(
             kcl,
             base=kcl.get_symmetrical_cross_section(
-                CrossSectionSpec(layer=layers.WG, width=2000)
+                CrossSectionSpecDict(layer=layers.WG, width=2000)
             ),
         ),
     )
@@ -397,12 +398,12 @@ def test_ports_in_cells(
 
     assert new_port in kcell.ports
     assert new_port in dkcell.ports
-    gds_regression(dkcell)
+    oas_regression(dkcell)
 
 
 def test_kcell_attributes(
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     c = kcl.kcell("test_kcell_attributes")
     c.shapes(1).insert(kf.kdb.Box(0, 0, 10, 10))
@@ -507,7 +508,7 @@ def test_kcell_attributes(
     assert c.size_info.cc == (5, 5)
     assert c.size_info.center == (5, 5)
 
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_lock(
@@ -532,7 +533,7 @@ def test_lock(
         # create_port
         with pytest.raises(LockedError):
             straight.create_port(
-                trans=kf.kdb.Trans.R0, width=1000, layer_info=layers.WG
+                name="o1", trans=kf.kdb.Trans.R0, width=1000, layer_info=layers.WG
             )
         # name setter
         with pytest.raises(LockedError):
@@ -552,7 +553,7 @@ def test_cell_in_threads(
     kcl: kf.KCLayout,
     layers: Layers,
     wg_enc: kf.LayerEnclosure,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     taper_factory = kf.factories.taper.taper_factory(kcl)
 
@@ -582,18 +583,18 @@ def taper() -> kf.KCell:
         == 1
     )
 
-    gds_regression(t)
+    oas_regression(t)
 
 
 def test_to_dtype(
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     kcell = kcl.kcell()
     kcell.shapes(0).insert(kf.kdb.Box(0, 0, 1000, 1000))
     dkcell = kcell.to_dtype()
     assert dkcell.bbox() == kf.kdb.DBox(0, 0, 1, 1)
-    gds_regression(dkcell)
+    oas_regression(dkcell)
 
 
 def test_to_itype(kcl: kf.KCLayout) -> None:
@@ -604,24 +605,24 @@ def test_to_itype(kcl: kf.KCLayout) -> None:
 
 
 def test_cell_default_fallback(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     kcl = kf.KCLayout("cell_default_fallback", default_cell_output_type=kf.DKCell)
 
     @kcl.cell
-    def my_cell():  # type: ignore[no-untyped-def]  # noqa: ANN202
+    def my_cell():  # noqa: ANN202
         return kcl.kcell()
 
     assert isinstance(my_cell(), kf.DKCell)
     kcl.default_cell_output_type = kf.KCell
 
-    def my_cell():  # type: ignore[no-untyped-def,no-redef]  # noqa: ANN202
+    def my_cell():  # noqa: ANN202
         return kcl.kcell()
 
     kf.layout.kcls.pop("cell_default_fallback")
 
     assert isinstance(my_cell(), kf.KCell)
-    gds_regression(my_cell())
+    oas_regression(my_cell())
 
 
 def test_transform(
@@ -677,10 +678,10 @@ def test1() -> kf.KCell:
 def test_return_none(
     kcl: kf.KCLayout,
 ) -> None:
-    def test_no_return() -> kf.KCell:  # type: ignore[return]
+    def test_no_return() -> kf.KCell:  # ty:ignore[invalid-return-type]
         kcl.kcell()
 
-    def test_no_return_vk() -> kf.VKCell:  # type: ignore[return]
+    def test_no_return_vk() -> kf.VKCell:  # ty:ignore[invalid-return-type]
         kcl.vkcell()
 
     with pytest.raises(TypeError):
@@ -693,6 +694,81 @@ def test_no_return_vk() -> kf.VKCell:  # type: ignore[return]
         kcl.vcell(functools.partial(test_no_return_vk))()
 
 
+def test_check_unnamed_cell_raise(
+    kcl: kf.KCLayout,
+) -> None:
+    """check_unnamed_cells='error' should raise on unnamed child cells."""
+    from kfactory.conf import CheckUnnamedCells
+
+    @kcl.cell(check_unnamed_cells=CheckUnnamedCells.RAISE)
+    def parent_with_unnamed() -> kf.KCell:
+        c = kcl.kcell()
+        child = kcl.kcell()  # creates an Unnamed_N cell
+        c << child
+        return c
+
+    regex = kf.config.logfilter.regex
+    kf.config.logfilter.regex = (
+        r"^An error has been caught in function 'wrapper_autocell'"
+    )
+    with pytest.raises(ValueError, match="unnamed cells instantiated"):
+        parent_with_unnamed()
+    kf.config.logfilter.regex = regex
+
+
+def test_check_unnamed_cell_warning(
+    kcl: kf.KCLayout,
+) -> None:
+    """check_unnamed_cells='warning' should log but not raise."""
+    from kfactory.conf import CheckUnnamedCells
+
+    @kcl.cell(check_unnamed_cells=CheckUnnamedCells.WARNING)
+    def parent_with_unnamed_warn() -> kf.KCell:
+        c = kcl.kcell()
+        child = kcl.kcell()  # creates an Unnamed_N cell
+        c << child
+        return c
+
+    # Should not raise
+    cell = parent_with_unnamed_warn()
+    assert cell is not None
+
+
+def test_check_unnamed_cell_ignore(
+    kcl: kf.KCLayout,
+) -> None:
+    """check_unnamed_cells='ignore' should silently allow unnamed child cells."""
+    from kfactory.conf import CheckUnnamedCells
+
+    @kcl.cell(check_unnamed_cells=CheckUnnamedCells.IGNORE)
+    def parent_with_unnamed_ignore() -> kf.KCell:
+        c = kcl.kcell()
+        child = kcl.kcell()  # creates an Unnamed_N cell
+        c << child
+        return c
+
+    cell = parent_with_unnamed_ignore()
+    assert cell is not None
+
+
+def test_check_unnamed_cell_no_unnamed(
+    kcl: kf.KCLayout,
+) -> None:
+    """check_unnamed_cells='error' should pass when all children are named."""
+    from kfactory.conf import CheckUnnamedCells
+
+    named_child = kcl.kcell("properly_named_child")
+
+    @kcl.cell(check_unnamed_cells=CheckUnnamedCells.RAISE)
+    def parent_all_named() -> kf.KCell:
+        c = kcl.kcell()
+        c << named_child
+        return c
+
+    cell = parent_all_named()
+    assert cell is not None
+
+
 def test_return_wrong_type(
     kcl: kf.KCLayout,
 ) -> None:
@@ -703,6 +779,6 @@ def test_kc() -> kf.KCell:
         return kcl.kcell()
 
     with pytest.raises(TypeError):
-        kcl.cell()(test_vk)()  # type: ignore[type-var]
+        kcl.cell()(test_vk)()
     with pytest.raises(TypeError):
-        kcl.vcell(test_kc)()  # type: ignore[type-var]
+        kcl.vcell(test_kc)()
diff --git a/tests/test_cells.py b/tests/test_cells.py
index 6e87a0d2f..0faa5d5d2 100644
--- a/tests/test_cells.py
+++ b/tests/test_cells.py
@@ -92,11 +92,11 @@ def virtual_cell_params(wg_enc: kf.LayerEnclosure, layers: Layers) -> dict[str,
 def test_cells(
     cell_name: str,
     cell_params: dict[str, Any],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     """Ensure cells have the same geometry as their golden references."""
     cell = kf.kcl.factories[cell_name](**cell_params.get(cell_name, {}))
-    gds_regression(cell)
+    oas_regression(cell)
 
 
 @pytest.mark.parametrize(
@@ -105,7 +105,7 @@ def test_cells(
 def test_virtual_cells(
     cell_name: str,
     virtual_cell_params: dict[str, Any],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     """Ensure cells have the same geometry as their golden references."""
     cell = kf.KCell()
@@ -119,7 +119,7 @@ def test_virtual_cells(
     c = kf.kcl.kcell()
     c << cell
 
-    gds_regression(c)
+    oas_regression(c)
     c.delete()
 
 
@@ -127,7 +127,7 @@ def test_additional_info(
     kcl: kf.KCLayout,
     layers: Layers,
     wg_enc: kf.LayerEnclosure,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     test_bend_euler = partial(
         kf.factories.euler.bend_euler_factory(
@@ -142,8 +142,8 @@ def test_additional_info(
     bend = test_bend_euler(width=1)
 
     assert bend.locked is True
-    assert bend.info.creation_time == "2023-02-12Z23:00:00"  # type: ignore[attr-defined, unused-ignore]
+    assert bend.info.creation_time == "2023-02-12Z23:00:00"
 
-    gds_regression(bend)
+    oas_regression(bend)
 
     bend.delete()
diff --git a/tests/test_config.py b/tests/test_config.py
index 45cde40e6..72457abc4 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -18,6 +18,8 @@ def show(
         use_libraries: bool = True,
         library_save_options: kf.kdb.SaveLayoutOptions = _layout_options,
         technology: str | None = None,
+        markers: list[tuple[kf.typings.DShapeLike, kf.typings.MarkerConfig]]
+        | None = None,
     ) -> None:
         nonlocal showed
         showed = True
@@ -35,6 +37,6 @@ def test_custom_show_string() -> None:
     kcl = kf.KCLayout("TEST_CUSTOM_SHOW_STRING")
     c = kcl.kcell("CustomShowString")
     _show = kf.config.show_function
-    kf.config.show_function = "tests.custom.show.show"  # type: ignore[assignment]
+    kf.config.show_function = "tests.custom.show.show"  # ty:ignore[invalid-assignment]
     c.show()
     kf.config.show_function = _show
diff --git a/tests/test_connectivity.py b/tests/test_connectivity.py
index 2e7ab813c..f0b835402 100644
--- a/tests/test_connectivity.py
+++ b/tests/test_connectivity.py
@@ -8,8 +8,8 @@ def test_connectivity_cell_ports() -> None:
         "3_0": {
             "WidthMismatch": 1,
             "TypeMismatch": 1,
-            "OrphanPort": 7,
-            "InstanceshapeOverlap": 16,
+            "DanglingPort": 7,
+            "InstanceOverlap": 16,
             "CellPorts": 31,
         }
     }
@@ -38,8 +38,8 @@ def test_connectivity() -> None:
         "3_0": {
             "WidthMismatch": 1,
             "TypeMismatch": 1,
-            "OrphanPort": 7,
-            "InstanceshapeOverlap": 16,
+            "DanglingPort": 7,
+            "InstanceOverlap": 16,
         }
     }
 
@@ -63,8 +63,8 @@ def test_connectivity() -> None:
 def test_connectivity_no_rec() -> None:
     num_items_per_category = {
         "3_0": {
-            "OrphanPort": 4,
-            "InstanceshapeOverlap": 16,
+            "DanglingPort": 4,
+            "InstanceOverlap": 16,
         }
     }
 
@@ -85,3 +85,73 @@ def test_connectivity_no_rec() -> None:
             assert sub_category.num_items() == sub_categories[sub_category.name()]
 
     kf.layout.kcls.pop(kcl.name)
+
+
+def _load_chiplets() -> kf.KCell:
+    gds_ref = pathlib.Path(__file__).parent / "test_data"
+    kcl = kf.KCLayout("TEST_CONNECTIVITY")
+    kcl.read(gds_ref / "nxn_chiplets.gds")
+    return kcl["nxn_chiplets"]
+
+
+def test_dangling_ports_check_standalone() -> None:
+    cell = _load_chiplets()
+    rdb = kf.checks.dangling_ports_check(cell, port_types=["optical", "electrical"])
+
+    assert rdb.num_items() == 7
+    cat = rdb.category_by_path("3_0.DanglingPort")
+    assert cat is not None
+    assert cat.num_items() == 7
+
+    kf.layout.kcls.pop(cell.kcl.name)
+
+
+def test_instance_overlap_check_standalone() -> None:
+    cell = _load_chiplets()
+    rdb = kf.checks.instance_overlap_check(cell)
+
+    cat = rdb.category_by_path("3_0.InstanceOverlap")
+    assert cat is not None
+    assert cat.num_items() == 16
+
+    kf.layout.kcls.pop(cell.kcl.name)
+
+
+def test_shape_instance_overlap_check_standalone() -> None:
+    cell = _load_chiplets()
+    rdb = kf.checks.shape_instance_overlap_check(cell)
+
+    cat = rdb.category_by_path("3_0.CellShapeInstanceOverlap")
+    # No top-level shapes overlap with instances in this fixture.
+    assert cat is None or cat.num_items() == 0
+
+    kf.layout.kcls.pop(cell.kcl.name)
+
+
+def test_port_mismatch_check_standalone() -> None:
+    cell = _load_chiplets()
+    rdb = kf.checks.port_mismatch_check(cell, port_types=["optical", "electrical"])
+
+    width = rdb.category_by_path("3_0.WidthMismatch")
+    typ = rdb.category_by_path("3_0.TypeMismatch")
+    assert width is not None
+    assert width.num_items() == 1
+    assert typ is not None
+    assert typ.num_items() == 1
+    # The mismatch check must not emit DanglingPort items.
+    assert rdb.category_by_path("3_0.DanglingPort") is None
+
+    kf.layout.kcls.pop(cell.kcl.name)
+
+
+def test_port_mismatch_check_toggle_width() -> None:
+    cell = _load_chiplets()
+    rdb = kf.checks.port_mismatch_check(
+        cell, port_types=["optical", "electrical"], check_width=False
+    )
+    assert rdb.category_by_path("3_0.WidthMismatch") is None
+    typ = rdb.category_by_path("3_0.TypeMismatch")
+    assert typ is not None
+    assert typ.num_items() == 1
+
+    kf.layout.kcls.pop(cell.kcl.name)
diff --git a/tests/test_cross_section.py b/tests/test_cross_section.py
index c7dc0d9a8..4f4f0feb7 100644
--- a/tests/test_cross_section.py
+++ b/tests/test_cross_section.py
@@ -1,9 +1,21 @@
+import pickle
+import tempfile
+from collections.abc import Callable
+from pathlib import Path
+
+import pytest
+
 import kfactory as kf
+from kfactory.exceptions import (
+    AsymmetricMirrorRequiredError,
+    CrossSectionNamingConflictError,
+    CrossSectionSymmetryMismatchError,
+)
 
 
 def test_icross_section_creation(kcl: kf.KCLayout) -> None:
     xs = kcl.get_icross_section(
-        kf.cross_section.CrossSectionSpec(
+        kf.cross_section.CrossSectionSpecDict(
             name="WG_350",
             sections=[(kf.kdb.LayerInfo(2, 0), 500)],
             layer=kf.kdb.LayerInfo(1, 0),
@@ -23,7 +35,7 @@ def test_port_cross_section(kcl: kf.KCLayout, layers: kf.LayerInfos) -> None:
     )
 
     xs = kcl.get_icross_section(
-        kf.cross_section.CrossSectionSpec(
+        kf.cross_section.CrossSectionSpecDict(
             name="WG_350",
             sections=[(kf.kdb.LayerInfo(2, 0), 500)],
             layer=kf.kdb.LayerInfo(1, 0),
@@ -31,8 +43,9 @@ def test_port_cross_section(kcl: kf.KCLayout, layers: kf.LayerInfos) -> None:
         )
     )
 
-    p1 = c.create_port(cross_section=xs, trans=kf.kdb.Trans.R0)
+    p1 = c.create_port(name="o1", cross_section=xs, trans=kf.kdb.Trans.R0)
     p2 = c.create_port(
+        name="o2",
         cross_section=kf.SymmetricalCrossSection(
             width=1000,
             enclosure=enc,
@@ -43,3 +56,918 @@ def test_port_cross_section(kcl: kf.KCLayout, layers: kf.LayerInfos) -> None:
 
     assert p1.cross_section == c.kcl.get_icross_section(xs)
     assert p2.cross_section == c.kcl.get_icross_section(xs)
+
+
+def _make_asym(name: str = "asym_test") -> kf.AsymmetricalCrossSection:
+    return kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-250,
+        section_max=250,
+        sections=(
+            kf.CrossSectionLayer(
+                layer=kf.kdb.LayerInfo(2, 0, "SLAB"),
+                section_min=-100,
+                section_max=900,
+            ),
+        ),
+        name=name,
+    )
+
+
+def test_asymmetrical_cross_section_construction() -> None:
+    acs = _make_asym()
+    assert acs.main_layer.layer == 1
+    assert acs.width == 500
+    assert len(acs.sections) == 1
+    # main strip [-250, 250]; aux strip [-100, 900]
+    assert acs.get_xmin() == -250
+    assert acs.get_xmax() == 900
+
+
+def test_asymmetrical_cross_section_invalid_bounds() -> None:
+    with pytest.raises(ValueError, match="section_min"):
+        kf.AsymmetricalCrossSection(
+            layer=kf.kdb.LayerInfo(1, 0), section_min=0, section_max=0
+        )
+    with pytest.raises(ValueError, match="section_min"):
+        kf.CrossSectionLayer(
+            layer=kf.kdb.LayerInfo(1, 0), section_min=10, section_max=5
+        )
+
+
+def test_cross_section_name_conflict_across_kinds() -> None:
+    """Cross section names must be unique across symmetric and asymmetric kinds."""
+    kcl = kf.KCLayout("CONFLICT")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    enc = kcl.get_enclosure(
+        kf.LayerEnclosure(
+            sections=[(kf.kdb.LayerInfo(2, 0, "S"), 500)], main_layer=layer
+        )
+    )
+    kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="shared")
+    )
+    with pytest.raises(ValueError, match="different structural signature"):
+        kcl.get_asymmetrical_cross_section(
+            kf.AsymmetricalCrossSection(
+                layer=layer, section_min=-250, section_max=250, name="shared"
+            )
+        )
+
+    kcl2 = kf.KCLayout("CONFLICT2")
+    kcl2.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="shared"
+        )
+    )
+    with pytest.raises(ValueError, match="different structural signature"):
+        kcl2.get_symmetrical_cross_section(
+            kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="shared")
+        )
+
+
+def test_asymmetrical_cross_section_registration() -> None:
+    kcl = kf.KCLayout("ASYM_REG")
+    acs = _make_asym()
+    stored = kcl.get_asymmetrical_cross_section(acs)
+    assert stored == acs
+    # second registration with same name + same content is a no-op
+    again = kcl.get_asymmetrical_cross_section(acs)
+    assert again is stored
+    # different content under the same name → error
+    different = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-300,
+        section_max=300,
+        name="asym_test",
+    )
+    with pytest.raises(ValueError, match="different structural signature"):
+        kcl.get_asymmetrical_cross_section(different)
+
+
+def test_asymmetrical_cross_section_dtype_roundtrip() -> None:
+    kcl = kf.KCLayout("ASYM_DTYPE")
+    acs = _make_asym()
+    dacs = acs.to_dtype(kcl)
+    assert dacs.width == kcl.to_um(acs.width)
+    acs2 = dacs.to_itype(kcl)
+    assert acs2 == acs
+
+
+def test_asymmetrical_cross_section_equality_normalizes_section_order() -> None:
+    layer1 = kf.kdb.LayerInfo(2, 0, "SLAB")
+    layer2 = kf.kdb.LayerInfo(3, 0, "OTHER")
+    a = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-250,
+        section_max=250,
+        sections=(
+            kf.CrossSectionLayer(layer=layer1, section_min=-40, section_max=60),
+            kf.CrossSectionLayer(layer=layer2, section_min=-80, section_max=120),
+        ),
+        name="ord",
+    )
+    b = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-250,
+        section_max=250,
+        sections=(
+            kf.CrossSectionLayer(layer=layer2, section_min=-80, section_max=120),
+            kf.CrossSectionLayer(layer=layer1, section_min=-40, section_max=60),
+        ),
+        name="ord",
+    )
+    assert a == b
+    assert hash(a) == hash(b)
+
+
+def test_asymmetrical_cross_section_sections_dedup() -> None:
+    """Identical sections collapse into one."""
+    layer = kf.kdb.LayerInfo(2, 0, "SLAB")
+    acs = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-250,
+        section_max=250,
+        sections=(
+            kf.CrossSectionLayer(layer=layer, section_min=-100, section_max=500),
+            kf.CrossSectionLayer(layer=layer, section_min=-100, section_max=500),
+        ),
+        name="dedup",
+    )
+    assert len(acs.sections) == 1
+    assert acs.sections[0].section_min == -100
+    assert acs.sections[0].section_max == 500
+
+
+def test_asymmetrical_cross_section_sections_merge_overlapping() -> None:
+    """Overlapping sections on the same layer are merged into their union."""
+    layer = kf.kdb.LayerInfo(2, 0, "SLAB")
+    acs = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-250,
+        section_max=250,
+        sections=(
+            kf.CrossSectionLayer(layer=layer, section_min=-100, section_max=500),
+            kf.CrossSectionLayer(layer=layer, section_min=300, section_max=900),
+        ),
+        name="overlap",
+    )
+    assert len(acs.sections) == 1
+    assert acs.sections[0].section_min == -100
+    assert acs.sections[0].section_max == 900
+
+
+def test_asymmetrical_cross_section_sections_merge_touching() -> None:
+    """Touching sections (max == next.min) on the same layer merge."""
+    layer = kf.kdb.LayerInfo(2, 0, "SLAB")
+    acs = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-250,
+        section_max=250,
+        sections=(
+            kf.CrossSectionLayer(layer=layer, section_min=-100, section_max=300),
+            kf.CrossSectionLayer(layer=layer, section_min=300, section_max=700),
+        ),
+        name="touch",
+    )
+    assert len(acs.sections) == 1
+    assert acs.sections[0].section_min == -100
+    assert acs.sections[0].section_max == 700
+
+
+def test_asymmetrical_cross_section_sections_gap_kept_separate() -> None:
+    """Non-touching sections on the same layer stay separate, sorted."""
+    layer = kf.kdb.LayerInfo(2, 0, "SLAB")
+    acs = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-250,
+        section_max=250,
+        sections=(
+            kf.CrossSectionLayer(layer=layer, section_min=600, section_max=700),
+            kf.CrossSectionLayer(layer=layer, section_min=-100, section_max=200),
+        ),
+        name="gap",
+    )
+    assert len(acs.sections) == 2
+    assert (acs.sections[0].section_min, acs.sections[0].section_max) == (-100, 200)
+    assert (acs.sections[1].section_min, acs.sections[1].section_max) == (600, 700)
+
+
+def test_asymmetrical_cross_section_sections_different_layers_independent() -> None:
+    """Overlapping sections on different layers are not merged."""
+    l1 = kf.kdb.LayerInfo(2, 0, "A")
+    l2 = kf.kdb.LayerInfo(3, 0, "B")
+    acs = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(1, 0, "WG"),
+        section_min=-250,
+        section_max=250,
+        sections=(
+            kf.CrossSectionLayer(layer=l1, section_min=-100, section_max=500),
+            kf.CrossSectionLayer(layer=l2, section_min=-200, section_max=400),
+        ),
+        name="multi",
+    )
+    assert len(acs.sections) == 2
+
+
+def test_asymmetrical_cross_section_total_ordering() -> None:
+    """AsymmetricalCrossSection and CrossSectionLayer are sortable."""
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    a = kf.AsymmetricalCrossSection(
+        layer=layer, section_min=-100, section_max=100, name="a"
+    )
+    b = kf.AsymmetricalCrossSection(
+        layer=layer, section_min=-100, section_max=200, name="b"
+    )
+    c = kf.AsymmetricalCrossSection(
+        layer=kf.kdb.LayerInfo(2, 0, "X"), section_min=-100, section_max=100, name="c"
+    )
+    assert a < b  # same layer + min, larger max → greater
+    assert b < c  # WG < X (name)
+    assert sorted([c, b, a]) == [a, b, c]
+    assert b > a
+
+    # CrossSectionLayer sortable too
+    s1 = kf.CrossSectionLayer(layer=layer, section_min=0, section_max=10)
+    s2 = kf.CrossSectionLayer(layer=layer, section_min=0, section_max=20)
+    assert s1 < s2
+    assert sorted([s2, s1]) == [s1, s2]
+
+
+def test_asymmetrical_cross_section_pickle() -> None:
+    acs = _make_asym()
+    restored = pickle.loads(pickle.dumps(acs))  # noqa: S301
+    assert isinstance(restored, kf.AsymmetricalCrossSection)
+    assert restored == acs
+
+
+def test_asymmetrical_cross_section_gds_roundtrip() -> None:
+    kcl_w = kf.KCLayout("ASYM_GDS_W")
+    acs = _make_asym()
+    kcl_w.get_asymmetrical_cross_section(acs)
+    c = kcl_w.kcell("top")
+    c.shapes(kcl_w.layer(kf.kdb.LayerInfo(1, 0, "WG"))).insert(
+        kf.kdb.Box(0, 0, 100, 100)
+    )
+
+    with tempfile.TemporaryDirectory() as d:
+        path = Path(d) / "asym.gds"
+        kcl_w.write(path)
+
+        kcl_r = kf.KCLayout("ASYM_GDS_R")
+        kcl_r.read(path)
+
+    assert "asym_test" in kcl_r.cross_sections.cross_sections
+    restored = kcl_r.get_asymmetrical_cross_section("asym_test")
+    assert restored == acs
+
+
+def test_symmetrical_cross_section_radius_gds_roundtrip() -> None:
+    """``radius``/``radius_min`` survive a GDS metadata round-trip.
+
+    Regression: symmetric serialization used to drop them (asymmetric kept them).
+    They are non-identifying for ``__eq__``, so assert on the values directly.
+    """
+    kcl_w = kf.KCLayout("SYM_RADIUS_GDS_W")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    enc = kcl_w.get_enclosure(
+        kf.LayerEnclosure(
+            sections=[(kf.kdb.LayerInfo(2, 0, "S"), 500)], main_layer=layer
+        )
+    )
+    kcl_w.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(
+            width=1000, enclosure=enc, name="wg_r", radius=10000, radius_min=5000
+        )
+    )
+    c = kcl_w.kcell("sym_radius_top")
+    c.shapes(kcl_w.layer(layer)).insert(kf.kdb.Box(0, 0, 100, 100))
+
+    with tempfile.TemporaryDirectory() as d:
+        path = Path(d) / "sym_radius.gds"
+        kcl_w.write(path)
+
+        kcl_r = kf.KCLayout("SYM_RADIUS_GDS_R")
+        kcl_r.read(path)
+
+    restored = kcl_r.cross_sections.cross_sections["wg_r"]
+    assert isinstance(restored, kf.SymmetricalCrossSection)
+    assert restored.radius == 10000
+    assert restored.radius_min == 5000
+
+
+def test_connect_symmetric_vs_asymmetric_raises() -> None:
+    kcl = kf.KCLayout("ASYM_CONN")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+
+    parent = kcl.kcell("parent_conn")
+    child_a = kcl.kcell("child_a_conn")
+    child_a.create_port(name="o1", width=500, layer_info=layer, trans=kf.kdb.Trans.R0)
+    child_b = kcl.kcell("child_b_conn")
+    child_b.create_port(name="o1", width=500, layer_info=layer, trans=kf.kdb.Trans.R0)
+
+    acs = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="asym_conn"
+        )
+    )
+    # mutate one cell port's cross_section to be asymmetric to simulate the
+    # step-2 plumbing
+    child_b.ports["o1"].asymmetric_cross_section = acs
+
+    ia = parent << child_a
+    ib = parent << child_b
+
+    with pytest.raises(CrossSectionSymmetryMismatchError):
+        ia.connect("o1", ib, "o1")
+    # not bypassable by allow_width_mismatch
+    with pytest.raises(CrossSectionSymmetryMismatchError):
+        ia.connect("o1", ib, "o1", allow_width_mismatch=True)
+
+
+def test_create_port_with_asymmetric_cross_section() -> None:
+    kcl = kf.KCLayout("CREATE_PORT_ASYM")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    acs = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="cp_asym"
+        )
+    )
+    c = kcl.kcell("cp_top")
+    c.create_port(name="o1", trans=kf.kdb.Trans.R0, cross_section=acs)
+
+    p = c.ports["o1"]
+    assert not p.is_symmetric()
+    assert p.asymmetric_cross_section == acs
+    assert p.base.cross_section is None
+    assert p.base.asymmetric_cross_section is acs
+    with pytest.raises(TypeError, match="asymmetric"):
+        _ = p.cross_section
+
+
+def test_port_accessor_setters_route_correctly() -> None:
+    kcl = kf.KCLayout("PORT_SETTER")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    sym = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(
+            width=500,
+            enclosure=kcl.get_enclosure(
+                kf.LayerEnclosure(
+                    sections=[(kf.kdb.LayerInfo(2, 0, "S"), 500)], main_layer=layer
+                )
+            ),
+            name="setter_sym",
+        )
+    )
+    asym = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="setter_asym"
+        )
+    )
+    c = kcl.kcell("setter_top")
+    p = c.create_port(name="o1", trans=kf.kdb.Trans.R0, cross_section=sym)
+    assert p.is_symmetric()
+    # Switch to asymmetric via setter
+    p.asymmetric_cross_section = asym
+    assert not p.is_symmetric()
+    assert p.base.cross_section is None
+    # Switch back
+    p.cross_section = sym
+    assert p.is_symmetric()
+    assert p.base.asymmetric_cross_section is None
+
+
+def test_port_gds_roundtrip_preserves_asymmetric_kind() -> None:
+    kcl_w = kf.KCLayout("PORT_GDS_W")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    acs = kcl_w.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="rt_asym"
+        )
+    )
+    c = kcl_w.kcell("rt_top")
+    c.create_port(name="o1", trans=kf.kdb.Trans.R0, cross_section=acs)
+    c.shapes(kcl_w.layer(layer)).insert(kf.kdb.Box(0, 0, 100, 100))
+
+    with tempfile.TemporaryDirectory() as d:
+        path = Path(d) / "port_rt.gds"
+        kcl_w.write(path)
+        kcl_r = kf.KCLayout("PORT_GDS_R")
+        kcl_r.read(path)
+
+    c_r = kcl_r["rt_top"]
+    p_r = c_r.ports["o1"]
+    assert not p_r.is_symmetric()
+    assert p_r.asymmetric_cross_section.base == acs
+
+
+def test_asymmetric_wrappers_share_base_and_compare_across_units() -> None:
+    kcl = kf.KCLayout("ASYM_WRAP_EQ")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    # main strip [-200, 300] (offset=50, width=500); aux strip [-100, 900]
+    acs = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer,
+            section_min=-200,
+            section_max=300,
+            sections=(
+                kf.CrossSectionLayer(
+                    layer=kf.kdb.LayerInfo(2, 0, "S"),
+                    section_min=-100,
+                    section_max=900,
+                ),
+            ),
+            name="aw",
+        )
+    )
+    ixs = kf.AsymmetricCrossSection(kcl=kcl, base=acs)
+    dxs = ixs.to_dtype()
+
+    # cross-unit equality via shared base
+    assert ixs.base is dxs.base
+    assert ixs == dxs
+    assert dxs == ixs
+    # wrapper compares equal to bare base too
+    assert ixs == acs
+    assert acs == ixs
+    assert acs == dxs
+
+    # units differ on the public surface
+    assert ixs.width == 500
+    assert dxs.width == kcl.to_um(500)
+    assert ixs.section_min == -200
+    assert dxs.section_min == kcl.to_um(-200)
+    assert ixs.section_max == 300
+    assert dxs.section_max == kcl.to_um(300)
+    assert ixs.get_xmin_xmax() == (-200, 900)
+    assert dxs.get_xmin_xmax() == (kcl.to_um(-200), kcl.to_um(900))
+
+
+def test_symmetric_wrapper_get_xmin_xmax(kcl: kf.KCLayout) -> None:
+    """A symmetric profile's full extent is mirrored about the center line.
+
+    Regression: the wrappers used to return ``(xmax, xmax)`` instead of
+    ``(-xmax, xmax)`` (the symmetric ``get_xmin`` was missing entirely).
+    """
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    enc = kcl.get_enclosure(
+        kf.LayerEnclosure(
+            sections=[(kf.kdb.LayerInfo(2, 0, "S"), 500)], main_layer=layer
+        )
+    )
+    base = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="wg_xminmax")
+    )
+    xmax = base.get_xmax()
+    assert xmax > 0
+    assert base.get_xmin() == -xmax
+
+    ixs = kf.CrossSection(kcl=kcl, base=base)
+    dxs = kf.DCrossSection(kcl=kcl, base=base)
+    assert ixs.get_xmin_xmax() == (-xmax, xmax)
+    assert dxs.get_xmin_xmax() == (kcl.to_um(-xmax), kcl.to_um(xmax))
+
+
+def test_asymmetric_wrapper_construct_from_scratch() -> None:
+    kcl = kf.KCLayout("ASYM_WRAP_FROM_SCRATCH")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    ixs = kf.AsymmetricCrossSection(
+        kcl,
+        section_min=-200,
+        section_max=300,
+        layer=layer,
+        sections=(
+            kf.CrossSectionLayer(
+                layer=kf.kdb.LayerInfo(2, 0, "S"),
+                section_min=-100,
+                section_max=900,
+            ),
+        ),
+        name="acs1",
+    )
+    # builds and registers a base under the hood
+    assert "acs1" in kcl.cross_sections.cross_sections
+    # constructing a second wrapper with the same args returns equal wrapper
+    ixs2 = kf.AsymmetricCrossSection(kcl=kcl, base=ixs.base)
+    assert ixs == ixs2
+
+
+def test_get_cross_section_kind_switch() -> None:
+    kcl = kf.KCLayout("UNIFIED_GET")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    enc = kcl.get_enclosure(
+        kf.LayerEnclosure(
+            sections=[(kf.kdb.LayerInfo(2, 0, "S"), 500)], main_layer=layer
+        )
+    )
+    scs = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="wg1000")
+    )
+    acs = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="aw500"
+        )
+    )
+
+    assert kcl.get_base_cross_section(scs) is scs
+    assert kcl.get_base_cross_section("wg1000") is scs
+    assert kcl.get_base_cross_section(scs, symmetrical=True) is scs
+    assert kcl.get_base_cross_section(acs, symmetrical=False) is acs
+    assert kcl.get_base_cross_section("aw500", symmetrical=False) is acs
+    assert kcl.get_base_cross_section(scs, symmetrical=None) is scs
+    assert kcl.get_base_cross_section(acs, symmetrical=None) is acs
+    assert kcl.get_base_cross_section("wg1000", symmetrical=None) is scs
+    assert kcl.get_base_cross_section("aw500", symmetrical=None) is acs
+    # any with a wrapper resolves to its base
+    ixs = kcl.get_iasymmetric_cross_section(acs)
+    assert kcl.get_base_cross_section(ixs, symmetrical=None) is acs
+    # unknown name under "any" raises
+    with pytest.raises(KeyError):
+        kcl.get_base_cross_section("does_not_exist", symmetrical=None)
+
+
+def test_metadata_uses_separate_prefix_for_asymmetric() -> None:
+    kcl_w = kf.KCLayout("META_SEP_W")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    enc = kcl_w.get_enclosure(
+        kf.LayerEnclosure(
+            sections=[(kf.kdb.LayerInfo(2, 0, "S"), 500)], main_layer=layer
+        )
+    )
+    kcl_w.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="wg1000")
+    )
+    kcl_w.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="aw500"
+        )
+    )
+    c = kcl_w.kcell("meta_top")
+    c.shapes(kcl_w.layer(layer)).insert(kf.kdb.Box(0, 0, 100, 100))
+
+    with tempfile.TemporaryDirectory() as d:
+        path = Path(d) / "meta.gds"
+        kcl_w.write(path)
+        kcl_w.set_meta_data()
+        names = {meta.name for meta in kcl_w.layout.each_meta_info()}
+        assert "kfactory:cross_section:wg1000" in names
+        assert "kfactory:asymmetrical_cross_section:aw500" in names
+        assert "kfactory:cross_section:aw500" not in names
+
+        kcl_r = kf.KCLayout("META_SEP_R")
+        kcl_r.read(path)
+
+    assert "wg1000" in kcl_r.cross_sections.cross_sections
+    assert "aw500" in kcl_r.cross_sections.cross_sections
+
+
+def test_cell_serializes_asymmetric_cross_section_to_name(kcl: kf.KCLayout) -> None:
+    """Cross sections passed as cell args key the cache by ``.name``.
+
+    Regression: the ``@cell`` serializer only listed the symmetric union, so an
+    asymmetric cross section did not serialize to its name.
+    """
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    enc = kcl.get_enclosure(
+        kf.LayerEnclosure(
+            sections=[(kf.kdb.LayerInfo(2, 0, "S"), 500)], main_layer=layer
+        )
+    )
+    sym = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="symxs")
+    )
+    asym = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="asymxs"
+        )
+    )
+
+    @kcl.cell
+    def xs_cell(cross_section: kf.cross_section.AnyCrossSection) -> kf.KCell:
+        c = kcl.kcell()
+        c.shapes(kcl.layer(layer)).insert(kf.kdb.Box(0, 0, 100, 100))
+        return c
+
+    assert "symxs" in xs_cell(cross_section=sym).name
+    assert "asymxs" in xs_cell(cross_section=asym).name
+
+
+def test_cross_section_spec_serializer_parity(kcl: kf.KCLayout) -> None:
+    """``kcl_cross_section_serializer`` collapses every spec form to one key.
+
+    A cross section referenced as an object, a ``CrossSectionSpec`` dict, or a
+    registered name must serialize to the same string so the ``@cell`` cache
+    treats them as a single key.
+    """
+    from kfactory.serialization import kcl_cross_section_serializer
+
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    xs = kcl.get_icross_section(
+        kf.cross_section.CrossSectionSpecDict(layer=layer, width=1000, name="wg")
+    )
+
+    serialize = kcl_cross_section_serializer(kcl=kcl)
+    from_obj = serialize(xs)
+    from_dict = serialize(
+        kf.cross_section.CrossSectionSpecDict(layer=layer, width=1000)
+    )
+    from_str = serialize("wg")
+
+    assert from_obj == from_dict == from_str == "wg"
+
+
+def test_cell_cross_section_spec_cache_key_parity(kcl: kf.KCLayout) -> None:
+    """Cell args typed ``CrossSectionSpec`` key the cache by the resolved name.
+
+    Passing the same cross section as an object, a spec dict, or its name must
+    all hit the same cache entry (return the identical cell instance).
+    """
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    xs = kcl.get_icross_section(
+        kf.cross_section.CrossSectionSpecDict(layer=layer, width=1000, name="wg")
+    )
+
+    @kcl.cell
+    def spec_cell(
+        cross_section: kf.cross_section.CrossSectionSpec, length: int
+    ) -> kf.KCell:
+        c = kcl.kcell()
+        c.shapes(kcl.layer(layer)).insert(kf.kdb.Box(0, 0, length, 100))
+        return c
+
+    from_obj = spec_cell(cross_section=xs, length=10)
+    from_dict = spec_cell(
+        cross_section=kf.cross_section.CrossSectionSpecDict(layer=layer, width=1000),
+        length=10,
+    )
+    from_str = spec_cell(cross_section="wg", length=10)
+
+    assert from_obj is from_dict is from_str
+    assert "wg" in from_obj.name
+
+
+def test_connect_asym_to_asym_requires_mirror_when_misaligned() -> None:
+    """Two R0-facing asymmetric ports cannot connect without a mirror.
+
+    Both ports define their profile in the same port-local frame; an R180
+    connection between two R0 ports would flip the left/right halves in
+    world coordinates. `mirror=True` produces an M90 connection that keeps
+    the profiles aligned.
+    """
+    kcl = kf.KCLayout("ASYM_CONN_R0")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+
+    parent = kcl.kcell("parent_conn_r0")
+    a = kcl.kcell("child_a_r0")
+    a.create_port(name="o1", width=500, layer_info=layer, trans=kf.kdb.Trans.R0)
+    b = kcl.kcell("child_b_r0")
+    b.create_port(name="o1", width=500, layer_info=layer, trans=kf.kdb.Trans.R0)
+    acs = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="asym_r0"
+        )
+    )
+    a.ports["o1"].asymmetric_cross_section = acs
+    b.ports["o1"].asymmetric_cross_section = acs
+
+    ia = parent << a
+    ib = parent << b
+    with pytest.raises(AsymmetricMirrorRequiredError):
+        ia.connect("o1", ib, "o1")
+    # mirror=True succeeds and produces M90 trans
+    ia.connect("o1", ib, "o1", mirror=True)
+    assert ia.trans.mirror
+
+
+def _make_asym_wg(kcl: kf.KCLayout, name: str) -> kf.KCell:
+    """Asymmetric waveguide cell with the user's convention: o1=R180, o2=M0."""
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    acs = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="aw500"
+        )
+    )
+    c = kcl.kcell(name)
+    c.create_port(
+        name="o1", width=500, layer_info=layer, trans=kf.kdb.Trans(2, False, 0, 0)
+    )
+    c.create_port(
+        name="o2", width=500, layer_info=layer, trans=kf.kdb.Trans(0, True, 1000, 0)
+    )
+    c.ports["o1"].asymmetric_cross_section = acs
+    c.ports["o2"].asymmetric_cross_section = acs
+    return c
+
+
+def test_asym_waveguide_chain_o2_to_o1_with_mirror_true() -> None:
+    """o2 (M0) → o1 (R180) chain succeeds with `mirror=True`, result is unmirrored."""
+    kcl = kf.KCLayout("ASYM_WG_CHAIN")
+    wg = _make_asym_wg(kcl, "wg")
+    parent = kcl.kcell("parent_chain")
+    a = parent << wg
+    b = parent << wg
+
+    # default connect fails — profiles would misalign
+    with pytest.raises(AsymmetricMirrorRequiredError):
+        b.connect("o1", a, "o2")
+
+    # mirror=True succeeds and produces a "no-mirror" instance chain
+    b.connect("o1", a, "o2", mirror=True)
+    assert not a.trans.mirror
+    assert not b.trans.mirror
+
+
+def test_asym_waveguide_o1_to_o1_requires_one_mirror() -> None:
+    """o1 (R180) ↔ o1 (R180) succeeds with `mirror=True` and result is mirrored."""
+    kcl = kf.KCLayout("ASYM_WG_O1O1")
+    wg = _make_asym_wg(kcl, "wg_o1o1")
+    parent = kcl.kcell("parent_o1o1")
+    a = parent << wg
+    b = parent << wg
+
+    with pytest.raises(AsymmetricMirrorRequiredError):
+        b.connect("o1", a, "o1")
+
+    b.connect("o1", a, "o1", mirror=True)
+    # one instance ends up mirrored
+    assert a.trans.mirror ^ b.trans.mirror
+
+
+def test_asym_connect_check_ignores_input_mirror_flag_when_use_mirror_false() -> None:
+    """When use_mirror=False, the geometric check still rejects misaligned results.
+
+    `mirror=True` with a pre-mirrored instance under `use_mirror=False` doesn't
+    actually result in M90 — the effective conn_trans is R180. The geometric
+    right-direction check catches this, while a naive `mirror`-flag check
+    would have wrongly accepted it.
+    """
+    kcl = kf.KCLayout("ASYM_USE_MIRROR")
+    layer = kf.kdb.LayerInfo(1, 0, "WG")
+    acs = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layer, section_min=-250, section_max=250, name="asym_um"
+        )
+    )
+    parent = kcl.kcell("parent_um")
+    a = kcl.kcell("a_um")
+    a.create_port(name="o1", width=500, layer_info=layer, trans=kf.kdb.Trans.R0)
+    a.ports["o1"].asymmetric_cross_section = acs
+    b = kcl.kcell("b_um")
+    b.create_port(name="o1", width=500, layer_info=layer, trans=kf.kdb.Trans.R0)
+    b.ports["o1"].asymmetric_cross_section = acs
+
+    ia = parent << a
+    ib = parent << b
+    # Pre-mirror ia. Under use_mirror=False the effective M90 vs R180 depends
+    # on (mirror XOR existing_mirror). With existing mirror=True and mirror=True,
+    # effective is R180 — geometrically wrong for asymmetric.
+    ia.trans = kf.kdb.Trans(0, True, 0, 0)
+    with pytest.raises(AsymmetricMirrorRequiredError):
+        ia.connect("o1", ib, "o1", mirror=True, use_mirror=False)
+    # With existing mirror=True and mirror=False, effective is M90 — passes.
+    ia.connect("o1", ib, "o1", mirror=False, use_mirror=False)
+
+
+# --- Named/unnamed canonicalization ---------------------------------------
+
+
+def test_symmetric_unnamed_resolves_to_named(
+    kcl: kf.KCLayout, sym_enc: Callable[..., kf.LayerEnclosure]
+) -> None:
+    enc = kcl.get_enclosure(sym_enc("wgenc"))
+    named = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="A")
+    )
+    assert named.is_named
+    unnamed = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc)
+    )
+    assert unnamed is named
+    assert unnamed.name == "A"
+
+
+def test_symmetric_named_promotes_unnamed(
+    kcl: kf.KCLayout, sym_enc: Callable[..., kf.LayerEnclosure]
+) -> None:
+    enc = kcl.get_enclosure(sym_enc("wgenc"))
+    unnamed = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc)
+    )
+    assert not unnamed.is_named
+    auto_name = unnamed.name
+    named = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="B")
+    )
+    assert named.name == "B"
+    again = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc)
+    )
+    assert again is named
+    # The canonical (auto) name now aliases the promoted named entry.
+    assert kcl.cross_sections.cross_sections[auto_name] is named
+
+
+def test_symmetric_naming_conflict(
+    kcl: kf.KCLayout, sym_enc: Callable[..., kf.LayerEnclosure]
+) -> None:
+    enc = kcl.get_enclosure(sym_enc("wgenc"))
+    a1 = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="A")
+    )
+    # Idempotent re-registration of the same named cross section.
+    a2 = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="A")
+    )
+    assert a1 is a2
+    # A second, different name for the same signature raises.
+    with pytest.raises(CrossSectionNamingConflictError):
+        kcl.get_symmetrical_cross_section(
+            kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="C")
+        )
+
+
+def test_enclosure_unnamed_resolves_to_named(
+    kcl: kf.KCLayout, sym_enc: Callable[..., kf.LayerEnclosure]
+) -> None:
+    named = kcl.get_enclosure(sym_enc("encA"))
+    unnamed = kcl.get_enclosure(sym_enc())
+    assert unnamed is named
+    # Addressable by the structural (unnamed) key string too.
+    assert kcl.get_enclosure(named.unnamed_key) is named
+
+
+def test_enclosure_named_promotes_unnamed(
+    kcl: kf.KCLayout, sym_enc: Callable[..., kf.LayerEnclosure]
+) -> None:
+    unnamed = kcl.get_enclosure(sym_enc())
+    assert not unnamed.is_named
+    auto_key = unnamed.name
+    named = kcl.get_enclosure(sym_enc("encB"))
+    assert named.is_named
+    assert named.name == "encB"
+    again = kcl.get_enclosure(sym_enc())
+    assert again is named
+    assert auto_key not in kcl.layer_enclosures.root
+
+
+def test_enclosure_naming_conflict(
+    kcl: kf.KCLayout, sym_enc: Callable[..., kf.LayerEnclosure]
+) -> None:
+    kcl.get_enclosure(sym_enc("encA"))
+    with pytest.raises(CrossSectionNamingConflictError):
+        kcl.get_enclosure(sym_enc("encC"))
+
+
+def test_cross_section_enclosure_resolution_unifies(
+    kcl: kf.KCLayout, sym_enc: Callable[..., kf.LayerEnclosure]
+) -> None:
+    # With the enclosure canonicalized (named), a cross section built on the named
+    # enclosure and one built on a fresh, structurally-identical unnamed enclosure
+    # resolve to the same canonical cross section (the unnamed enclosure
+    # canonicalizes to the named one first).
+    enc_named = kcl.get_enclosure(sym_enc("encN"))
+    xs_named_enc = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc_named)
+    )
+    xs_via_unnamed = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=sym_enc())
+    )
+    assert xs_via_unnamed is xs_named_enc
+
+
+def test_radius_excluded_from_name_but_conflicts_on_registration(
+    kcl: kf.KCLayout, sym_enc: Callable[..., kf.LayerEnclosure]
+) -> None:
+    # radius is excluded from the structural name/identity ...
+    a = kf.SymmetricalCrossSection(width=1000, enclosure=sym_enc(), radius=10_000)
+    b = kf.SymmetricalCrossSection(width=1000, enclosure=sym_enc(), radius=5_000)
+    assert a == b  # geometry-equal; radius is metadata
+    assert a.auto_name() == b.auto_name()
+
+    enc = kcl.get_enclosure(sym_enc("wgenc"))
+    first = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, radius=10_000)
+    )
+    # Same radius → idempotent.
+    assert (
+        kcl.get_symmetrical_cross_section(
+            kf.SymmetricalCrossSection(width=1000, enclosure=enc, radius=10_000)
+        )
+        is first
+    )
+    # ... but re-registering the same profile with a *different* radius conflicts
+    # (override the radius at route time instead).
+    with pytest.raises(CrossSectionNamingConflictError):
+        kcl.get_symmetrical_cross_section(
+            kf.SymmetricalCrossSection(width=1000, enclosure=enc, radius=5_000)
+        )
+
+
+def test_asymmetric_unnamed_resolves_to_named(kcl: kf.KCLayout) -> None:
+    named = kcl.get_asymmetrical_cross_section(_make_asym("named_asym"))
+    assert named.is_named
+    # Same structure, no explicit name (auto-named) → resolves to the named one.
+    unnamed = _make_asym(name="")
+    assert not unnamed.is_named
+    resolved = kcl.get_asymmetrical_cross_section(unnamed)
+    assert resolved is named
diff --git a/tests/test_data b/tests/test_data
new file mode 160000
index 000000000..44bd31988
--- /dev/null
+++ b/tests/test_data
@@ -0,0 +1 @@
+Subproject commit 44bd319886612cb6f7cb361f11006f9bbdff2571
diff --git a/tests/test_data/generated/test_additional_info.gds b/tests/test_data/generated/test_additional_info.gds
deleted file mode 100644
index 17f93f9b7..000000000
Binary files a/tests/test_data/generated/test_additional_info.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_array.gds b/tests/test_data/generated/test_array.gds
deleted file mode 100644
index 2ae9bb32f..000000000
Binary files a/tests/test_data/generated/test_array.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_array_indexerror.gds b/tests/test_data/generated/test_array_indexerror.gds
deleted file mode 100644
index 2ae9bb32f..000000000
Binary files a/tests/test_data/generated/test_array_indexerror.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_autorename.gds b/tests/test_data/generated/test_autorename.gds
deleted file mode 100644
index 376cf3f4a..000000000
Binary files a/tests/test_data/generated/test_autorename.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_cell_default_fallback.gds b/tests/test_data/generated/test_cell_default_fallback.gds
deleted file mode 100644
index 4d26f4f29..000000000
Binary files a/tests/test_data/generated/test_cell_default_fallback.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_cell_in_threads.gds b/tests/test_data/generated/test_cell_in_threads.gds
deleted file mode 100644
index 72a882152..000000000
Binary files a/tests/test_data/generated/test_cell_in_threads.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_circular_snapping.gds b/tests/test_data/generated/test_circular_snapping.gds
deleted file mode 100644
index db9612a82..000000000
Binary files a/tests/test_data/generated/test_circular_snapping.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_create.gds b/tests/test_data/generated/test_create.gds
deleted file mode 100644
index 1f30b51fe..000000000
Binary files a/tests/test_data/generated/test_create.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_custom_router.gds b/tests/test_data/generated/test_custom_router.gds
deleted file mode 100644
index a0f1c0bd9..000000000
Binary files a/tests/test_data/generated/test_custom_router.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_dspiral.gds b/tests/test_data/generated/test_dspiral.gds
deleted file mode 100644
index 4ab7b5d4e..000000000
Binary files a/tests/test_data/generated/test_dspiral.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_enclosure_name.gds b/tests/test_data/generated/test_enclosure_name.gds
deleted file mode 100644
index d5ee815ca..000000000
Binary files a/tests/test_data/generated/test_enclosure_name.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_euler_snapping.gds b/tests/test_data/generated/test_euler_snapping.gds
deleted file mode 100644
index 3b5cc9a4c..000000000
Binary files a/tests/test_data/generated/test_euler_snapping.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_filter_layer_pt_reg.gds b/tests/test_data/generated/test_filter_layer_pt_reg.gds
deleted file mode 100644
index a977a1f3d..000000000
Binary files a/tests/test_data/generated/test_filter_layer_pt_reg.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_filter_regex.gds b/tests/test_data/generated/test_filter_regex.gds
deleted file mode 100644
index 4188be2df..000000000
Binary files a/tests/test_data/generated/test_filter_regex.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_flatten.gds b/tests/test_data/generated/test_flatten.gds
deleted file mode 100644
index fa07b766c..000000000
Binary files a/tests/test_data/generated/test_flatten.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_flexgrid_2d_shape_rotation.gds b/tests/test_data/generated/test_flexgrid_2d_shape_rotation.gds
deleted file mode 100644
index ba5ab32d6..000000000
Binary files a/tests/test_data/generated/test_flexgrid_2d_shape_rotation.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_flexgrid_dbu_1d.gds b/tests/test_data/generated/test_flexgrid_dbu_1d.gds
deleted file mode 100644
index 63d7b25b0..000000000
Binary files a/tests/test_data/generated/test_flexgrid_dbu_1d.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_flexgrid_dbu_1d_shape.gds b/tests/test_data/generated/test_flexgrid_dbu_1d_shape.gds
deleted file mode 100644
index 4f08b6244..000000000
Binary files a/tests/test_data/generated/test_flexgrid_dbu_1d_shape.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_flexgrid_dbu_2d.gds b/tests/test_data/generated/test_flexgrid_dbu_2d.gds
deleted file mode 100644
index 66006c8ab..000000000
Binary files a/tests/test_data/generated/test_flexgrid_dbu_2d.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_flexgrid_dbu_2d_rotation.gds b/tests/test_data/generated/test_flexgrid_dbu_2d_rotation.gds
deleted file mode 100644
index 4788438e9..000000000
Binary files a/tests/test_data/generated/test_flexgrid_dbu_2d_rotation.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_flexgrid_dbu_2d_shape.gds b/tests/test_data/generated/test_flexgrid_dbu_2d_shape.gds
deleted file mode 100644
index 90bb60383..000000000
Binary files a/tests/test_data/generated/test_flexgrid_dbu_2d_shape.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_flexgrid_dbu_2d_shape_rotation.gds b/tests/test_data/generated/test_flexgrid_dbu_2d_shape_rotation.gds
deleted file mode 100644
index 7aec5c79f..000000000
Binary files a/tests/test_data/generated/test_flexgrid_dbu_2d_shape_rotation.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_1d.gds b/tests/test_data/generated/test_grid_1d.gds
deleted file mode 100644
index 87c276708..000000000
Binary files a/tests/test_data/generated/test_grid_1d.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_1d_shape.gds b/tests/test_data/generated/test_grid_1d_shape.gds
deleted file mode 100644
index 251b7b84a..000000000
Binary files a/tests/test_data/generated/test_grid_1d_shape.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_2d.gds b/tests/test_data/generated/test_grid_2d.gds
deleted file mode 100644
index 50fc8deff..000000000
Binary files a/tests/test_data/generated/test_grid_2d.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_2d_rotation.gds b/tests/test_data/generated/test_grid_2d_rotation.gds
deleted file mode 100644
index 4d6f9a170..000000000
Binary files a/tests/test_data/generated/test_grid_2d_rotation.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_2d_shape.gds b/tests/test_data/generated/test_grid_2d_shape.gds
deleted file mode 100644
index f1f7cd1da..000000000
Binary files a/tests/test_data/generated/test_grid_2d_shape.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_2d_shape_rotation.gds b/tests/test_data/generated/test_grid_2d_shape_rotation.gds
deleted file mode 100644
index 4d5330f54..000000000
Binary files a/tests/test_data/generated/test_grid_2d_shape_rotation.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_2d_uneven.gds b/tests/test_data/generated/test_grid_2d_uneven.gds
deleted file mode 100644
index ba4636f9a..000000000
Binary files a/tests/test_data/generated/test_grid_2d_uneven.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_dbu_1d.gds b/tests/test_data/generated/test_grid_dbu_1d.gds
deleted file mode 100644
index 1b13a0658..000000000
Binary files a/tests/test_data/generated/test_grid_dbu_1d.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_dbu_1d_shape.gds b/tests/test_data/generated/test_grid_dbu_1d_shape.gds
deleted file mode 100644
index c057c4ee9..000000000
Binary files a/tests/test_data/generated/test_grid_dbu_1d_shape.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_dbu_2d.gds b/tests/test_data/generated/test_grid_dbu_2d.gds
deleted file mode 100644
index 30ae277b8..000000000
Binary files a/tests/test_data/generated/test_grid_dbu_2d.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_dbu_2d_rotation.gds b/tests/test_data/generated/test_grid_dbu_2d_rotation.gds
deleted file mode 100644
index ab18fe946..000000000
Binary files a/tests/test_data/generated/test_grid_dbu_2d_rotation.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_dbu_2d_shape.gds b/tests/test_data/generated/test_grid_dbu_2d_shape.gds
deleted file mode 100644
index 7f46093ea..000000000
Binary files a/tests/test_data/generated/test_grid_dbu_2d_shape.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_dbu_2d_shape_rotation.gds b/tests/test_data/generated/test_grid_dbu_2d_shape_rotation.gds
deleted file mode 100644
index 59f7db6e3..000000000
Binary files a/tests/test_data/generated/test_grid_dbu_2d_shape_rotation.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_grid_dbu_2d_uneven.gds b/tests/test_data/generated/test_grid_dbu_2d_uneven.gds
deleted file mode 100644
index 613f27b18..000000000
Binary files a/tests/test_data/generated/test_grid_dbu_2d_uneven.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_info.gds b/tests/test_data/generated/test_info.gds
deleted file mode 100644
index 74bf64ef8..000000000
Binary files a/tests/test_data/generated/test_info.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_invalid_array.gds b/tests/test_data/generated/test_invalid_array.gds
deleted file mode 100644
index 1b435ece5..000000000
Binary files a/tests/test_data/generated/test_invalid_array.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_kcell_attributes.gds b/tests/test_data/generated/test_kcell_attributes.gds
deleted file mode 100644
index 162fcf64f..000000000
Binary files a/tests/test_data/generated/test_kcell_attributes.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_l2n.gds b/tests/test_data/generated/test_l2n.gds
deleted file mode 100644
index 3ffbb53c2..000000000
Binary files a/tests/test_data/generated/test_l2n.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_multi_pdk.gds b/tests/test_data/generated/test_multi_pdk.gds
deleted file mode 100644
index cc17dedeb..000000000
Binary files a/tests/test_data/generated/test_multi_pdk.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_multi_pdk_convert.gds b/tests/test_data/generated/test_multi_pdk_convert.gds
deleted file mode 100644
index 450ce05ce..000000000
Binary files a/tests/test_data/generated/test_multi_pdk_convert.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_multi_pdk_read_write.gds b/tests/test_data/generated/test_multi_pdk_read_write.gds
deleted file mode 100644
index 25a4d6229..000000000
Binary files a/tests/test_data/generated/test_multi_pdk_read_write.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_namecollision.gds b/tests/test_data/generated/test_namecollision.gds
deleted file mode 100644
index c6f182176..000000000
Binary files a/tests/test_data/generated/test_namecollision.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_nested_dic.gds b/tests/test_data/generated/test_nested_dic.gds
deleted file mode 100644
index baf332d17..000000000
Binary files a/tests/test_data/generated/test_nested_dic.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_nested_dict_list.gds b/tests/test_data/generated/test_nested_dict_list.gds
deleted file mode 100644
index adf856a1e..000000000
Binary files a/tests/test_data/generated/test_nested_dict_list.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_netlist.gds b/tests/test_data/generated/test_netlist.gds
deleted file mode 100644
index 8afb749fb..000000000
Binary files a/tests/test_data/generated/test_netlist.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_netlist_equivalent.gds b/tests/test_data/generated/test_netlist_equivalent.gds
deleted file mode 100644
index 209e67c32..000000000
Binary files a/tests/test_data/generated/test_netlist_equivalent.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_no_snap.gds b/tests/test_data/generated/test_no_snap.gds
deleted file mode 100644
index e68cd6476..000000000
Binary files a/tests/test_data/generated/test_no_snap.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_overwrite.gds b/tests/test_data/generated/test_overwrite.gds
deleted file mode 100644
index 5784b5da4..000000000
Binary files a/tests/test_data/generated/test_overwrite.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_pack_instances.gds b/tests/test_data/generated/test_pack_instances.gds
deleted file mode 100644
index 1c1bea4e4..000000000
Binary files a/tests/test_data/generated/test_pack_instances.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_pack_kcells.gds b/tests/test_data/generated/test_pack_kcells.gds
deleted file mode 100644
index 1c1bea4e4..000000000
Binary files a/tests/test_data/generated/test_pack_kcells.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_pins.gds b/tests/test_data/generated/test_pins.gds
deleted file mode 100644
index 6ca54a7dc..000000000
Binary files a/tests/test_data/generated/test_pins.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_ports_cell.gds b/tests/test_data/generated/test_ports_cell.gds
deleted file mode 100644
index ca091a646..000000000
Binary files a/tests/test_data/generated/test_ports_cell.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_ports_in_cells.gds b/tests/test_data/generated/test_ports_in_cells.gds
deleted file mode 100644
index 1dc15eafb..000000000
Binary files a/tests/test_data/generated/test_ports_in_cells.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_ports_instance.gds b/tests/test_data/generated/test_ports_instance.gds
deleted file mode 100644
index 1311bd715..000000000
Binary files a/tests/test_data/generated/test_ports_instance.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_rename_clockwise.gds b/tests/test_data/generated/test_rename_clockwise.gds
deleted file mode 100644
index 5282189c0..000000000
Binary files a/tests/test_data/generated/test_rename_clockwise.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_rename_clockwise_multi.gds b/tests/test_data/generated/test_rename_clockwise_multi.gds
deleted file mode 100644
index 4ad713692..000000000
Binary files a/tests/test_data/generated/test_rename_clockwise_multi.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_rename_default_None_.gds b/tests/test_data/generated/test_rename_default_None_.gds
deleted file mode 100644
index ec4904d67..000000000
Binary files a/tests/test_data/generated/test_rename_default_None_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_rename_default_rename_clockwise_multi_.gds b/tests/test_data/generated/test_rename_default_rename_clockwise_multi_.gds
deleted file mode 100644
index 7fe42c153..000000000
Binary files a/tests/test_data/generated/test_rename_default_rename_clockwise_multi_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_rename_orientation.gds b/tests/test_data/generated/test_rename_orientation.gds
deleted file mode 100644
index 895d8bcfb..000000000
Binary files a/tests/test_data/generated/test_rename_orientation.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_rename_setter.gds b/tests/test_data/generated/test_rename_setter.gds
deleted file mode 100644
index 5bebc2643..000000000
Binary files a/tests/test_data/generated/test_rename_setter.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_0_0_2_.gds b/tests/test_data/generated/test_route_bend90_0_0_2_.gds
deleted file mode 100644
index 7d9736af5..000000000
Binary files a/tests/test_data/generated/test_route_bend90_0_0_2_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_10000_10000_3_.gds b/tests/test_data/generated/test_route_bend90_10000_10000_3_.gds
deleted file mode 100644
index c1887e585..000000000
Binary files a/tests/test_data/generated/test_route_bend90_10000_10000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_150532_12112_3_.gds b/tests/test_data/generated/test_route_bend90_150532_12112_3_.gds
deleted file mode 100644
index 1f3e265e2..000000000
Binary files a/tests/test_data/generated/test_route_bend90_150532_12112_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_20000_20000_2_.gds b/tests/test_data/generated/test_route_bend90_20000_20000_2_.gds
deleted file mode 100644
index e22df822a..000000000
Binary files a/tests/test_data/generated/test_route_bend90_20000_20000_2_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_30000_5000_3_.gds b/tests/test_data/generated/test_route_bend90_30000_5000_3_.gds
deleted file mode 100644
index 458e024ec..000000000
Binary files a/tests/test_data/generated/test_route_bend90_30000_5000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_5000_10000_3_.gds b/tests/test_data/generated/test_route_bend90_5000_10000_3_.gds
deleted file mode 100644
index bacdf98fd..000000000
Binary files a/tests/test_data/generated/test_route_bend90_5000_10000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_500_30000_3_.gds b/tests/test_data/generated/test_route_bend90_500_30000_3_.gds
deleted file mode 100644
index 2445f357d..000000000
Binary files a/tests/test_data/generated/test_route_bend90_500_30000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_500_500_3_.gds b/tests/test_data/generated/test_route_bend90_500_500_3_.gds
deleted file mode 100644
index f28a1742b..000000000
Binary files a/tests/test_data/generated/test_route_bend90_500_500_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90__10000_30000_3_.gds b/tests/test_data/generated/test_route_bend90__10000_30000_3_.gds
deleted file mode 100644
index 79f63ab44..000000000
Binary files a/tests/test_data/generated/test_route_bend90__10000_30000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90__500_30000_3_.gds b/tests/test_data/generated/test_route_bend90__500_30000_3_.gds
deleted file mode 100644
index 18c93cc20..000000000
Binary files a/tests/test_data/generated/test_route_bend90__500_30000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_euler_10000_10000_3_.gds b/tests/test_data/generated/test_route_bend90_euler_10000_10000_3_.gds
deleted file mode 100644
index a67c5ef7e..000000000
Binary files a/tests/test_data/generated/test_route_bend90_euler_10000_10000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_euler_20000_20000_3_.gds b/tests/test_data/generated/test_route_bend90_euler_20000_20000_3_.gds
deleted file mode 100644
index 3b48098cf..000000000
Binary files a/tests/test_data/generated/test_route_bend90_euler_20000_20000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_euler_40000_40000_2_.gds b/tests/test_data/generated/test_route_bend90_euler_40000_40000_2_.gds
deleted file mode 100644
index fa6ac5330..000000000
Binary files a/tests/test_data/generated/test_route_bend90_euler_40000_40000_2_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_0_0_2_.gds b/tests/test_data/generated/test_route_bend90_invert_0_0_2_.gds
deleted file mode 100644
index 7d9736af5..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_0_0_2_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_10000_10000_3_.gds b/tests/test_data/generated/test_route_bend90_invert_10000_10000_3_.gds
deleted file mode 100644
index c1887e585..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_10000_10000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_15212_19921_3_.gds b/tests/test_data/generated/test_route_bend90_invert_15212_19921_3_.gds
deleted file mode 100644
index 8f47c09d2..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_15212_19921_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_20000_20000_2_.gds b/tests/test_data/generated/test_route_bend90_invert_20000_20000_2_.gds
deleted file mode 100644
index e22df822a..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_20000_20000_2_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_30000_5000_3_.gds b/tests/test_data/generated/test_route_bend90_invert_30000_5000_3_.gds
deleted file mode 100644
index 458e024ec..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_30000_5000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_500000_50000_2_.gds b/tests/test_data/generated/test_route_bend90_invert_500000_50000_2_.gds
deleted file mode 100644
index a5b76d3eb..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_500000_50000_2_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_5000_10000_3_.gds b/tests/test_data/generated/test_route_bend90_invert_5000_10000_3_.gds
deleted file mode 100644
index 86bbe2481..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_5000_10000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_500_30000_3_.gds b/tests/test_data/generated/test_route_bend90_invert_500_30000_3_.gds
deleted file mode 100644
index 2445f357d..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_500_30000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert_500_500_3_.gds b/tests/test_data/generated/test_route_bend90_invert_500_500_3_.gds
deleted file mode 100644
index f28a1742b..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert_500_500_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert__10000_30000_3_.gds b/tests/test_data/generated/test_route_bend90_invert__10000_30000_3_.gds
deleted file mode 100644
index 79f63ab44..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert__10000_30000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bend90_invert__500_30000_3_.gds b/tests/test_data/generated/test_route_bend90_invert__500_30000_3_.gds
deleted file mode 100644
index 18c93cc20..000000000
Binary files a/tests/test_data/generated/test_route_bend90_invert__500_30000_3_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bundle.gds b/tests/test_data/generated/test_route_bundle.gds
deleted file mode 100644
index 5baeb9cac..000000000
Binary files a/tests/test_data/generated/test_route_bundle.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_bundle_route_width.gds b/tests/test_data/generated/test_route_bundle_route_width.gds
deleted file mode 100644
index 88213b552..000000000
Binary files a/tests/test_data/generated/test_route_bundle_route_width.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_generic_reorient.gds b/tests/test_data/generated/test_route_generic_reorient.gds
deleted file mode 100644
index 47041e8f0..000000000
Binary files a/tests/test_data/generated/test_route_generic_reorient.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_length.gds b/tests/test_data/generated/test_route_length.gds
deleted file mode 100644
index c81819c88..000000000
Binary files a/tests/test_data/generated/test_route_length.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_length_match_1_1_1_.gds b/tests/test_data/generated/test_route_length_match_1_1_1_.gds
deleted file mode 100644
index db5587b28..000000000
Binary files a/tests/test_data/generated/test_route_length_match_1_1_1_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_length_match_2_2_0_.gds b/tests/test_data/generated/test_route_length_match_2_2_0_.gds
deleted file mode 100644
index 706bbf09c..000000000
Binary files a/tests/test_data/generated/test_route_length_match_2_2_0_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_length_match__1_4_0_.gds b/tests/test_data/generated/test_route_length_match__1_4_0_.gds
deleted file mode 100644
index 392b7999c..000000000
Binary files a/tests/test_data/generated/test_route_length_match__1_4_0_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_length_match__2_1__1_.gds b/tests/test_data/generated/test_route_length_match__2_1__1_.gds
deleted file mode 100644
index dff0d7109..000000000
Binary files a/tests/test_data/generated/test_route_length_match__2_1__1_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_length_straight.gds b/tests/test_data/generated/test_route_length_straight.gds
deleted file mode 100644
index 30231499e..000000000
Binary files a/tests/test_data/generated/test_route_length_straight.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_smart_waypoints_pts.gds b/tests/test_data/generated/test_route_smart_waypoints_pts.gds
deleted file mode 100644
index 0caee822e..000000000
Binary files a/tests/test_data/generated/test_route_smart_waypoints_pts.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_smart_waypoints_pts_sort.gds b/tests/test_data/generated/test_route_smart_waypoints_pts_sort.gds
deleted file mode 100644
index 735dae208..000000000
Binary files a/tests/test_data/generated/test_route_smart_waypoints_pts_sort.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_smart_waypoints_trans.gds b/tests/test_data/generated/test_route_smart_waypoints_trans.gds
deleted file mode 100644
index b5bd5e71f..000000000
Binary files a/tests/test_data/generated/test_route_smart_waypoints_trans.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_smart_waypoints_trans_sort.gds b/tests/test_data/generated/test_route_smart_waypoints_trans_sort.gds
deleted file mode 100644
index 694ec4889..000000000
Binary files a/tests/test_data/generated/test_route_smart_waypoints_trans_sort.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_straight_0_.gds b/tests/test_data/generated/test_route_straight_0_.gds
deleted file mode 100644
index 7d9736af5..000000000
Binary files a/tests/test_data/generated/test_route_straight_0_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_route_straight_5000_.gds b/tests/test_data/generated/test_route_straight_5000_.gds
deleted file mode 100644
index 1fb5cb071..000000000
Binary files a/tests/test_data/generated/test_route_straight_5000_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_sbend_routing.gds b/tests/test_data/generated/test_sbend_routing.gds
deleted file mode 100644
index b38ddadf1..000000000
Binary files a/tests/test_data/generated/test_sbend_routing.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_schematic_anchor.gds b/tests/test_data/generated/test_schematic_anchor.gds
deleted file mode 100644
index fee64e764..000000000
Binary files a/tests/test_data/generated/test_schematic_anchor.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_schematic_create.gds b/tests/test_data/generated/test_schematic_create.gds
deleted file mode 100644
index 1e67a4f23..000000000
Binary files a/tests/test_data/generated/test_schematic_create.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_schematic_create_cell.gds b/tests/test_data/generated/test_schematic_create_cell.gds
deleted file mode 100644
index 9b212d7f9..000000000
Binary files a/tests/test_data/generated/test_schematic_create_cell.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_schematic_kcl_mix_netlist.gds b/tests/test_data/generated/test_schematic_kcl_mix_netlist.gds
deleted file mode 100644
index 5a078624d..000000000
Binary files a/tests/test_data/generated/test_schematic_kcl_mix_netlist.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_schematic_mirror_connection.gds b/tests/test_data/generated/test_schematic_mirror_connection.gds
deleted file mode 100644
index 9607ee851..000000000
Binary files a/tests/test_data/generated/test_schematic_mirror_connection.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_schematic_route.gds b/tests/test_data/generated/test_schematic_route.gds
deleted file mode 100644
index 3ef3479f7..000000000
Binary files a/tests/test_data/generated/test_schematic_route.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_size_info.gds b/tests/test_data/generated/test_size_info.gds
deleted file mode 100644
index 80ff50639..000000000
Binary files a/tests/test_data/generated/test_size_info.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_False_False_.gds
deleted file mode 100644
index 55e63c2ff..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_False_True_.gds
deleted file mode 100644
index cf564d631..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_True_False_.gds
deleted file mode 100644
index 1433eb0a9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_True_True_.gds
deleted file mode 100644
index d2468dd15..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_False_False_.gds
deleted file mode 100644
index 12a6ddffe..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_False_True_.gds
deleted file mode 100644
index e5e80ede8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_True_False_.gds
deleted file mode 100644
index 7cc3bf261..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_True_True_.gds
deleted file mode 100644
index f06b141b2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_False_False_.gds
deleted file mode 100644
index b5d6af3f6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_False_True_.gds
deleted file mode 100644
index 14556a9c8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_True_False_.gds
deleted file mode 100644
index a1c64d124..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_True_True_.gds
deleted file mode 100644
index 84be62b42..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_False_False_.gds
deleted file mode 100644
index 8e4a3fd62..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_False_True_.gds
deleted file mode 100644
index 37522c467..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_True_False_.gds
deleted file mode 100644
index 90cbcc4bf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_True_True_.gds
deleted file mode 100644
index 9508b6af2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_False_False_.gds
deleted file mode 100644
index ff4c23cc7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_False_True_.gds
deleted file mode 100644
index 13d560606..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_True_False_.gds
deleted file mode 100644
index a30566ec9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_True_True_.gds
deleted file mode 100644
index 9f2f9ffa4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_False_False_.gds
deleted file mode 100644
index e6c45da5b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_False_True_.gds
deleted file mode 100644
index afd540ada..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_True_False_.gds
deleted file mode 100644
index de8e9487a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_True_True_.gds
deleted file mode 100644
index 06a08589f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_False_False_.gds
deleted file mode 100644
index 0686671c6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_False_True_.gds
deleted file mode 100644
index f131eaad6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_True_False_.gds
deleted file mode 100644
index 94840c848..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_True_True_.gds
deleted file mode 100644
index 26efe1895..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_False_False_.gds
deleted file mode 100644
index fbbf1bb4d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_False_True_.gds
deleted file mode 100644
index b10956868..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_True_False_.gds
deleted file mode 100644
index fa0bc10ab..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_True_True_.gds
deleted file mode 100644
index 08881b5ad..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_0_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_False_False_.gds
deleted file mode 100644
index 3cb2270b8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_False_True_.gds
deleted file mode 100644
index 4a269a151..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_True_False_.gds
deleted file mode 100644
index 88f4784c8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_True_True_.gds
deleted file mode 100644
index f60c06f66..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_False_False_.gds
deleted file mode 100644
index 9317bf951..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_False_True_.gds
deleted file mode 100644
index cb8c57d9a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_True_False_.gds
deleted file mode 100644
index ef99e23f9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_True_True_.gds
deleted file mode 100644
index 17481c48f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_False_False_.gds
deleted file mode 100644
index ca067c9d2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_False_True_.gds
deleted file mode 100644
index 8e639d57d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_True_False_.gds
deleted file mode 100644
index 8607c4bdf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_True_True_.gds
deleted file mode 100644
index 0f92b8010..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_False_False_.gds
deleted file mode 100644
index d1e47621b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_False_True_.gds
deleted file mode 100644
index d2a395195..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_True_False_.gds
deleted file mode 100644
index 50cc3f424..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_True_True_.gds
deleted file mode 100644
index dc01fdd1b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_False_False_.gds
deleted file mode 100644
index 4258a8c02..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_False_True_.gds
deleted file mode 100644
index 1c7bca893..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_True_False_.gds
deleted file mode 100644
index 99320c1eb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_True_True_.gds
deleted file mode 100644
index e0a973dc7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_False_False_.gds
deleted file mode 100644
index 072721faf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_False_True_.gds
deleted file mode 100644
index 55d1b7814..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_True_False_.gds
deleted file mode 100644
index 165ae8b44..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_True_True_.gds
deleted file mode 100644
index 03006ce88..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_False_False_.gds
deleted file mode 100644
index 34dc28983..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_False_True_.gds
deleted file mode 100644
index eb8b99640..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_True_False_.gds
deleted file mode 100644
index a543ee78d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_True_True_.gds
deleted file mode 100644
index e6efe828a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_False_False_.gds
deleted file mode 100644
index 18eaac677..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_False_True_.gds
deleted file mode 100644
index 38ede0203..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_True_False_.gds
deleted file mode 100644
index d1290c735..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_True_True_.gds
deleted file mode 100644
index 508100ab2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_False_False_.gds
deleted file mode 100644
index da5dba377..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_False_True_.gds
deleted file mode 100644
index ee99873e4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_True_False_.gds
deleted file mode 100644
index ebc130422..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_True_True_.gds
deleted file mode 100644
index 3d45d4a3d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_False_False_.gds
deleted file mode 100644
index 8cf4db2fe..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_False_True_.gds
deleted file mode 100644
index 2b85b9f50..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_True_False_.gds
deleted file mode 100644
index 6d4946e1d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_True_True_.gds
deleted file mode 100644
index ecf0df40f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_False_False_.gds
deleted file mode 100644
index ef1b8a62e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_False_True_.gds
deleted file mode 100644
index 695befdaf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_True_False_.gds
deleted file mode 100644
index fc5dec7cf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_True_True_.gds
deleted file mode 100644
index e06ae4efd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_False_False_.gds
deleted file mode 100644
index d1a499bf3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_False_True_.gds
deleted file mode 100644
index 25b47f824..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_True_False_.gds
deleted file mode 100644
index 2b360252e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_True_True_.gds
deleted file mode 100644
index 8276c6c3b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_False_False_.gds
deleted file mode 100644
index a60cc896f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_False_True_.gds
deleted file mode 100644
index 2fdbf8f9a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_True_False_.gds
deleted file mode 100644
index f5a929749..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_True_True_.gds
deleted file mode 100644
index ef39fe31d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_False_False_.gds
deleted file mode 100644
index 755c2513c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_False_True_.gds
deleted file mode 100644
index 9b79617d5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_True_False_.gds
deleted file mode 100644
index 9d6199897..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_True_True_.gds
deleted file mode 100644
index 2b070e922..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_False_False_.gds
deleted file mode 100644
index dd520e6a1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_False_True_.gds
deleted file mode 100644
index c41941eaa..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_True_False_.gds
deleted file mode 100644
index b976104e1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_True_True_.gds
deleted file mode 100644
index 0bf1fa1af..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_False_False_.gds
deleted file mode 100644
index c031d1cac..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_False_True_.gds
deleted file mode 100644
index 5bea0b8c9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_True_False_.gds
deleted file mode 100644
index fee8d66dd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_True_True_.gds
deleted file mode 100644
index 5dc9339ac..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False_2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_False_False_.gds
deleted file mode 100644
index cf2bfcb81..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_False_True_.gds
deleted file mode 100644
index c01ceefbd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_True_False_.gds
deleted file mode 100644
index 6ddb8bac8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_True_True_.gds
deleted file mode 100644
index e358900dd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_False_False_.gds
deleted file mode 100644
index 5d806b5f7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_False_True_.gds
deleted file mode 100644
index 3a474d387..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_True_False_.gds
deleted file mode 100644
index 9d42fcc6a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_True_True_.gds
deleted file mode 100644
index 6e28fd94f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_False_False_.gds
deleted file mode 100644
index 248e04aca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_False_True_.gds
deleted file mode 100644
index b67d42640..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_True_False_.gds
deleted file mode 100644
index f5e84172e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_True_True_.gds
deleted file mode 100644
index 30c87f495..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_False_False_.gds
deleted file mode 100644
index bf87866e4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_False_True_.gds
deleted file mode 100644
index ae9ccd808..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_True_False_.gds
deleted file mode 100644
index fd54c8640..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_True_True_.gds
deleted file mode 100644
index 3fc8d9c1a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_False_False_.gds
deleted file mode 100644
index 74d939d68..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_False_True_.gds
deleted file mode 100644
index 924377a3d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_True_False_.gds
deleted file mode 100644
index ebbe76592..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_True_True_.gds
deleted file mode 100644
index e0bdf9efa..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_False_False_.gds
deleted file mode 100644
index 6f701b330..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_False_True_.gds
deleted file mode 100644
index 7b78dc2a3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_True_False_.gds
deleted file mode 100644
index d9d298771..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_True_True_.gds
deleted file mode 100644
index 9f887e3f7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_False_False_.gds
deleted file mode 100644
index 5916e7803..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_False_True_.gds
deleted file mode 100644
index 62d8f2eac..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_True_False_.gds
deleted file mode 100644
index f417708f1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_True_True_.gds
deleted file mode 100644
index ad3a07440..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_False_False_.gds
deleted file mode 100644
index 83ba92092..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_False_True_.gds
deleted file mode 100644
index 4f74602f8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_True_False_.gds
deleted file mode 100644
index 0ca648dfb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_True_True_.gds
deleted file mode 100644
index f72d79456..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_False_False_.gds
deleted file mode 100644
index 6697eaf09..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_False_True_.gds
deleted file mode 100644
index eb96d83fa..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_True_False_.gds
deleted file mode 100644
index 5b19d3a1c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_True_True_.gds
deleted file mode 100644
index 75e50f1c0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_False_False_.gds
deleted file mode 100644
index 32c569224..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_False_True_.gds
deleted file mode 100644
index 1760f4c27..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_True_False_.gds
deleted file mode 100644
index a169e751e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_True_True_.gds
deleted file mode 100644
index f2a2684c4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_False_False_.gds
deleted file mode 100644
index d6dac871e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_False_True_.gds
deleted file mode 100644
index dd80317cd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_True_False_.gds
deleted file mode 100644
index 7d9981432..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_True_True_.gds
deleted file mode 100644
index 071f13165..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_False_False_.gds
deleted file mode 100644
index 9010ce407..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_False_True_.gds
deleted file mode 100644
index e2fdb0de9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_True_False_.gds
deleted file mode 100644
index 013c52bc2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_True_True_.gds
deleted file mode 100644
index e11004745..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_False_False_.gds
deleted file mode 100644
index fc090fe7f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_False_True_.gds
deleted file mode 100644
index e9cc3951b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_True_False_.gds
deleted file mode 100644
index 8e94452c6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_True_True_.gds
deleted file mode 100644
index 4a36d983e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_False_False_.gds
deleted file mode 100644
index fc5f61dca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_False_True_.gds
deleted file mode 100644
index 9dd946943..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_True_False_.gds
deleted file mode 100644
index a825fc2b0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_True_True_.gds
deleted file mode 100644
index 821ff17d8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_False_False_.gds
deleted file mode 100644
index 7a8fde2ae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_False_True_.gds
deleted file mode 100644
index 26f383775..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_True_False_.gds
deleted file mode 100644
index 509fc0802..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_True_True_.gds
deleted file mode 100644
index c2f772db4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_False_False_.gds
deleted file mode 100644
index 820af01d5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_False_True_.gds
deleted file mode 100644
index 203b686d2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_True_False_.gds
deleted file mode 100644
index c587322df..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_True_True_.gds
deleted file mode 100644
index 65d185ee3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_False__2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_False_False_.gds
deleted file mode 100644
index 1d688a8e7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_False_True_.gds
deleted file mode 100644
index a18c6f844..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_True_False_.gds
deleted file mode 100644
index f5add4f87..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_True_True_.gds
deleted file mode 100644
index ec20a93b3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_False_False_.gds
deleted file mode 100644
index f0daf5284..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_False_True_.gds
deleted file mode 100644
index 52b23d844..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_True_False_.gds
deleted file mode 100644
index ab0d94e0c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_True_True_.gds
deleted file mode 100644
index ddec5bf2c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_False_False_.gds
deleted file mode 100644
index 882584257..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_False_True_.gds
deleted file mode 100644
index 0be32fe48..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_True_False_.gds
deleted file mode 100644
index abe015fd7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_True_True_.gds
deleted file mode 100644
index 9d4dd92d9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_False_False_.gds
deleted file mode 100644
index 38d0ddb60..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_False_True_.gds
deleted file mode 100644
index 3671ad051..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_True_False_.gds
deleted file mode 100644
index 73ee3b5c8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_True_True_.gds
deleted file mode 100644
index ef1beecda..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_False_False_.gds
deleted file mode 100644
index f4ac1151f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_False_True_.gds
deleted file mode 100644
index 0b62b2a2a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_True_False_.gds
deleted file mode 100644
index 19196f4e4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_True_True_.gds
deleted file mode 100644
index 71670db08..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_False_False_.gds
deleted file mode 100644
index 417350f2d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_False_True_.gds
deleted file mode 100644
index 7dedaf3fe..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_True_False_.gds
deleted file mode 100644
index 64f772377..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_True_True_.gds
deleted file mode 100644
index 792c9aa10..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_False_False_.gds
deleted file mode 100644
index 2e86db78e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_False_True_.gds
deleted file mode 100644
index 216a1e88b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_True_False_.gds
deleted file mode 100644
index 6583dcf21..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_True_True_.gds
deleted file mode 100644
index aaa64e9ac..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_False_False_.gds
deleted file mode 100644
index 38ded4909..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_False_True_.gds
deleted file mode 100644
index 51228bd7a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_True_False_.gds
deleted file mode 100644
index 45f0de5bb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_True_True_.gds
deleted file mode 100644
index 66998dcf1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_0_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_False_False_.gds
deleted file mode 100644
index 2d899e458..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_False_True_.gds
deleted file mode 100644
index 1099abba4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_True_False_.gds
deleted file mode 100644
index 5c3931b8c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_True_True_.gds
deleted file mode 100644
index 8a326c382..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_False_False_.gds
deleted file mode 100644
index 2e1167943..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_False_True_.gds
deleted file mode 100644
index a4edd6926..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_True_False_.gds
deleted file mode 100644
index 78fb2eafe..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_True_True_.gds
deleted file mode 100644
index 98fb27e99..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_False_False_.gds
deleted file mode 100644
index 85615cfcd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_False_True_.gds
deleted file mode 100644
index f7fb038a3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_True_False_.gds
deleted file mode 100644
index b6ed1f3ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_True_True_.gds
deleted file mode 100644
index 489c062ab..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_False_False_.gds
deleted file mode 100644
index eff9009b5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_False_True_.gds
deleted file mode 100644
index cdb32bcd6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_True_False_.gds
deleted file mode 100644
index b3362e03a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_True_True_.gds
deleted file mode 100644
index 5cf0ff189..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_False_False_.gds
deleted file mode 100644
index af69802e3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_False_True_.gds
deleted file mode 100644
index abf5470e3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_True_False_.gds
deleted file mode 100644
index e3ba60d50..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_True_True_.gds
deleted file mode 100644
index 7753c665f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_False_False_.gds
deleted file mode 100644
index 6909e55db..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_False_True_.gds
deleted file mode 100644
index 507f63bdd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_True_False_.gds
deleted file mode 100644
index a09e1e6b8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_True_True_.gds
deleted file mode 100644
index e88362106..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_False_False_.gds
deleted file mode 100644
index 262060e22..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_False_True_.gds
deleted file mode 100644
index 8412ad633..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_True_False_.gds
deleted file mode 100644
index 95e32409f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_True_True_.gds
deleted file mode 100644
index 8ef9b0368..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_False_False_.gds
deleted file mode 100644
index a29173bcf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_False_True_.gds
deleted file mode 100644
index e9cd90dce..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_True_False_.gds
deleted file mode 100644
index 967274778..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_True_True_.gds
deleted file mode 100644
index 0ace92714..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_False_False_.gds
deleted file mode 100644
index daa4309bf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_False_True_.gds
deleted file mode 100644
index 4ca82bf7a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_True_False_.gds
deleted file mode 100644
index 9b3d7be78..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_True_True_.gds
deleted file mode 100644
index ee32f4a0b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_False_False_.gds
deleted file mode 100644
index bd5ffcbd0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_False_True_.gds
deleted file mode 100644
index bde9a3bc0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_True_False_.gds
deleted file mode 100644
index 405a0cd4b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_True_True_.gds
deleted file mode 100644
index 81f6a06d0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_False_False_.gds
deleted file mode 100644
index d5dbc0b76..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_False_True_.gds
deleted file mode 100644
index df34f2008..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_True_False_.gds
deleted file mode 100644
index b42bdc84e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_True_True_.gds
deleted file mode 100644
index 561e502ae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_False_False_.gds
deleted file mode 100644
index 42109dbb4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_False_True_.gds
deleted file mode 100644
index e8472bbaf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_True_False_.gds
deleted file mode 100644
index 347f16691..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_True_True_.gds
deleted file mode 100644
index 4e3fc5593..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_False_False_.gds
deleted file mode 100644
index b41b59a02..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_False_True_.gds
deleted file mode 100644
index 18079883e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_True_False_.gds
deleted file mode 100644
index 5bfe1d228..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_True_True_.gds
deleted file mode 100644
index 73f413954..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_False_False_.gds
deleted file mode 100644
index 7d0c6839f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_False_True_.gds
deleted file mode 100644
index 9decb551d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_True_False_.gds
deleted file mode 100644
index 619d45825..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_True_True_.gds
deleted file mode 100644
index ac04c17a5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_False_False_.gds
deleted file mode 100644
index 1f19d87a5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_False_True_.gds
deleted file mode 100644
index 046d4cedb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_True_False_.gds
deleted file mode 100644
index de3e8fbbe..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_True_True_.gds
deleted file mode 100644
index 4e5adafb8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_False_False_.gds
deleted file mode 100644
index 956d74b03..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_False_True_.gds
deleted file mode 100644
index 065d07c2a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_True_False_.gds
deleted file mode 100644
index 08cfa9aa6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_True_True_.gds
deleted file mode 100644
index b0756ad47..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True_2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_False_False_.gds
deleted file mode 100644
index c72b11aab..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_False_True_.gds
deleted file mode 100644
index a87e5124f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_True_False_.gds
deleted file mode 100644
index 45d7b0ab5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_True_True_.gds
deleted file mode 100644
index 68cf352c1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_False_False_.gds
deleted file mode 100644
index c4b161cae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_False_True_.gds
deleted file mode 100644
index 4d5ae2261..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_True_False_.gds
deleted file mode 100644
index dfdd43ecb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_True_True_.gds
deleted file mode 100644
index d1243e2ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_False_False_.gds
deleted file mode 100644
index dc8fd36a0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_False_True_.gds
deleted file mode 100644
index 8caa3d6db..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_True_False_.gds
deleted file mode 100644
index 0bce3bfe5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_True_True_.gds
deleted file mode 100644
index 5537c96b6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_False_False_.gds
deleted file mode 100644
index 66e14979e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_False_True_.gds
deleted file mode 100644
index 49471975e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_True_False_.gds
deleted file mode 100644
index 5d8f07a45..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_True_True_.gds
deleted file mode 100644
index b52062396..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_False_False_.gds
deleted file mode 100644
index 7facd7900..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_False_True_.gds
deleted file mode 100644
index 12ef9b747..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_True_False_.gds
deleted file mode 100644
index 6ba9eeac9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_True_True_.gds
deleted file mode 100644
index f99f59e05..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_False_False_.gds
deleted file mode 100644
index 948c352bf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_False_True_.gds
deleted file mode 100644
index e1d0f7260..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_True_False_.gds
deleted file mode 100644
index c2303819c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_True_True_.gds
deleted file mode 100644
index 66ff556b3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_False_False_.gds
deleted file mode 100644
index 3cef4d715..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_False_True_.gds
deleted file mode 100644
index d41f115ac..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_True_False_.gds
deleted file mode 100644
index c9bd0ba89..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_True_True_.gds
deleted file mode 100644
index e161cb13a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_False_False_.gds
deleted file mode 100644
index 2ecbf13b3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_False_True_.gds
deleted file mode 100644
index 53480718f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_True_False_.gds
deleted file mode 100644
index 26f2c2d64..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_True_True_.gds
deleted file mode 100644
index 0dc0227b9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_False_False_.gds
deleted file mode 100644
index 05f57b14e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_False_True_.gds
deleted file mode 100644
index 0fa3bb88d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_True_False_.gds
deleted file mode 100644
index 6f4e24984..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_True_True_.gds
deleted file mode 100644
index d7bac663d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_False_False_.gds
deleted file mode 100644
index 316cee39d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_False_True_.gds
deleted file mode 100644
index 55540fd82..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_True_False_.gds
deleted file mode 100644
index a832a6e98..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_True_True_.gds
deleted file mode 100644
index 98d737247..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_False_False_.gds
deleted file mode 100644
index 31d4c3edb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_False_True_.gds
deleted file mode 100644
index 7aef7a02b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_True_False_.gds
deleted file mode 100644
index 10c44a876..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_True_True_.gds
deleted file mode 100644
index d4e6153cd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_False_False_.gds
deleted file mode 100644
index 227a954ec..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_False_True_.gds
deleted file mode 100644
index 56f2f2da1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_True_False_.gds
deleted file mode 100644
index 8901f1c56..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_True_True_.gds
deleted file mode 100644
index 6a71a05fb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_False_False_.gds
deleted file mode 100644
index 8b1284f7f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_False_True_.gds
deleted file mode 100644
index 342569a0f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_True_False_.gds
deleted file mode 100644
index 3ea51faf7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_True_True_.gds
deleted file mode 100644
index d85d08f4f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_False_False_.gds
deleted file mode 100644
index 2f5b243c2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_False_True_.gds
deleted file mode 100644
index 84f23cdc8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_True_False_.gds
deleted file mode 100644
index d1fe89adb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_True_True_.gds
deleted file mode 100644
index b436b94c4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_False_False_.gds
deleted file mode 100644
index 986c0074d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_False_True_.gds
deleted file mode 100644
index 70c458ddf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_True_False_.gds
deleted file mode 100644
index 2051ff550..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_True_True_.gds
deleted file mode 100644
index 1c3c98371..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_False_False_.gds
deleted file mode 100644
index 02817c453..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_False_True_.gds
deleted file mode 100644
index 9fa32f7e0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_True_False_.gds
deleted file mode 100644
index 5918150fc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_True_True_.gds
deleted file mode 100644
index 19e6cadd0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_False_True__2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_False_False_.gds
deleted file mode 100644
index 64a3c6eba..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_False_True_.gds
deleted file mode 100644
index 4879bd143..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_True_False_.gds
deleted file mode 100644
index 2cbef6506..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_True_True_.gds
deleted file mode 100644
index 91b6bdb6e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_False_False_.gds
deleted file mode 100644
index d93f936fc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_False_True_.gds
deleted file mode 100644
index d2b1243cf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_True_False_.gds
deleted file mode 100644
index 90c529da1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_True_True_.gds
deleted file mode 100644
index 152769d3b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_False_False_.gds
deleted file mode 100644
index 5e3391c4f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_False_True_.gds
deleted file mode 100644
index bc787f51e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_True_False_.gds
deleted file mode 100644
index 20e6106f1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_True_True_.gds
deleted file mode 100644
index d765d6890..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_False_False_.gds
deleted file mode 100644
index 46531c5cb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_False_True_.gds
deleted file mode 100644
index 88134ae36..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_True_False_.gds
deleted file mode 100644
index b80114c05..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_True_True_.gds
deleted file mode 100644
index 594bc1931..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_False_False_.gds
deleted file mode 100644
index 7b037f38d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_False_True_.gds
deleted file mode 100644
index 8dce2ba84..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_True_False_.gds
deleted file mode 100644
index 485211df0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_True_True_.gds
deleted file mode 100644
index 4db9e2f22..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_False_False_.gds
deleted file mode 100644
index a353db657..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_False_True_.gds
deleted file mode 100644
index 002951919..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_True_False_.gds
deleted file mode 100644
index 4e64d4b97..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_True_True_.gds
deleted file mode 100644
index 3d1438ec8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_False_False_.gds
deleted file mode 100644
index 7a795345e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_False_True_.gds
deleted file mode 100644
index 2540473e9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_True_False_.gds
deleted file mode 100644
index 214f595df..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_True_True_.gds
deleted file mode 100644
index 6899751c2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_False_False_.gds
deleted file mode 100644
index 36df1020c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_False_True_.gds
deleted file mode 100644
index 7476e0f6d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_True_False_.gds
deleted file mode 100644
index e99adea78..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_True_True_.gds
deleted file mode 100644
index 1ce7c007b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_0_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_False_False_.gds
deleted file mode 100644
index 1114b2dbb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_False_True_.gds
deleted file mode 100644
index 823b6f389..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_True_False_.gds
deleted file mode 100644
index adc6c201d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_True_True_.gds
deleted file mode 100644
index 2f7d25c5d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_False_False_.gds
deleted file mode 100644
index 96c74b156..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_False_True_.gds
deleted file mode 100644
index e9bd96b11..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_True_False_.gds
deleted file mode 100644
index fcae6032d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_True_True_.gds
deleted file mode 100644
index 0befd138b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_False_False_.gds
deleted file mode 100644
index 31ae4beb9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_False_True_.gds
deleted file mode 100644
index 400ee221b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_True_False_.gds
deleted file mode 100644
index 6f081048d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_True_True_.gds
deleted file mode 100644
index 534d35df1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_False_False_.gds
deleted file mode 100644
index e35aeb448..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_False_True_.gds
deleted file mode 100644
index fbcc437d1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_True_False_.gds
deleted file mode 100644
index ad247e1ed..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_True_True_.gds
deleted file mode 100644
index 407ae36ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_False_False_.gds
deleted file mode 100644
index 33291151a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_False_True_.gds
deleted file mode 100644
index d61a6dd77..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_True_False_.gds
deleted file mode 100644
index 7b623c24f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_True_True_.gds
deleted file mode 100644
index d72e3ccab..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_False_False_.gds
deleted file mode 100644
index 2c84f9552..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_False_True_.gds
deleted file mode 100644
index c007acf8a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_True_False_.gds
deleted file mode 100644
index 01902ebba..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_True_True_.gds
deleted file mode 100644
index 6a142ed8c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_False_False_.gds
deleted file mode 100644
index 727944581..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_False_True_.gds
deleted file mode 100644
index 41b5f8f52..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_True_False_.gds
deleted file mode 100644
index 6a24690ee..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_True_True_.gds
deleted file mode 100644
index ed617806f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_False_False_.gds
deleted file mode 100644
index 0f9326a90..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_False_True_.gds
deleted file mode 100644
index 5f9b6fa17..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_True_False_.gds
deleted file mode 100644
index 95a72aac9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_True_True_.gds
deleted file mode 100644
index 750f93a97..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_False_False_.gds
deleted file mode 100644
index 112127da0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_False_True_.gds
deleted file mode 100644
index 4aa4494f3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_True_False_.gds
deleted file mode 100644
index 6705ba740..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_True_True_.gds
deleted file mode 100644
index 528592c36..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_False_False_.gds
deleted file mode 100644
index f69184d8a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_False_True_.gds
deleted file mode 100644
index ff1b031cd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_True_False_.gds
deleted file mode 100644
index 6c01e012c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_True_True_.gds
deleted file mode 100644
index b916a3318..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_False_False_.gds
deleted file mode 100644
index 66680a783..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_False_True_.gds
deleted file mode 100644
index ec762cfd4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_True_False_.gds
deleted file mode 100644
index 3a2618808..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_True_True_.gds
deleted file mode 100644
index b124d571a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_False_False_.gds
deleted file mode 100644
index 0c9e2e1ba..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_False_True_.gds
deleted file mode 100644
index 4740f2fcc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_True_False_.gds
deleted file mode 100644
index c363cb53d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_True_True_.gds
deleted file mode 100644
index 8de8e0e0b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_False_False_.gds
deleted file mode 100644
index 669b45f02..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_False_True_.gds
deleted file mode 100644
index 8fe642455..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_True_False_.gds
deleted file mode 100644
index 68730f64e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_True_True_.gds
deleted file mode 100644
index e282ec843..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_False_False_.gds
deleted file mode 100644
index 4d6ca9032..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_False_True_.gds
deleted file mode 100644
index 8dccacd05..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_True_False_.gds
deleted file mode 100644
index 3f381d199..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_True_True_.gds
deleted file mode 100644
index 14a640283..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_False_False_.gds
deleted file mode 100644
index d4ff05ab0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_False_True_.gds
deleted file mode 100644
index cc39ae3e9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_True_False_.gds
deleted file mode 100644
index d2bf145c4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_True_True_.gds
deleted file mode 100644
index bbb8dca23..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_False_False_.gds
deleted file mode 100644
index 711fd4f26..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_False_True_.gds
deleted file mode 100644
index 977ae3264..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_True_False_.gds
deleted file mode 100644
index c8d1ee1d6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_True_True_.gds
deleted file mode 100644
index ac7179e1c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False_2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_False_False_.gds
deleted file mode 100644
index ce03edcdb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_False_True_.gds
deleted file mode 100644
index 944a60708..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_True_False_.gds
deleted file mode 100644
index 702f9a03b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_True_True_.gds
deleted file mode 100644
index 4b58fcfc8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_False_False_.gds
deleted file mode 100644
index 012b03b42..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_False_True_.gds
deleted file mode 100644
index 1a484f767..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_True_False_.gds
deleted file mode 100644
index 74ab1aa5a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_True_True_.gds
deleted file mode 100644
index 7df26d8d5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_False_False_.gds
deleted file mode 100644
index 6502e856f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_False_True_.gds
deleted file mode 100644
index a18b8ea2f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_True_False_.gds
deleted file mode 100644
index f538b4ff5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_True_True_.gds
deleted file mode 100644
index 73f1f89e1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_False_False_.gds
deleted file mode 100644
index 216567b64..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_False_True_.gds
deleted file mode 100644
index 270280eae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_True_False_.gds
deleted file mode 100644
index 3da247816..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_True_True_.gds
deleted file mode 100644
index d87d6f588..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_False_False_.gds
deleted file mode 100644
index cf19afe49..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_False_True_.gds
deleted file mode 100644
index 56000e378..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_True_False_.gds
deleted file mode 100644
index a5d8cf311..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_True_True_.gds
deleted file mode 100644
index b7339014b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_False_False_.gds
deleted file mode 100644
index 2e8c38de7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_False_True_.gds
deleted file mode 100644
index 1916d5ceb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_True_False_.gds
deleted file mode 100644
index b734ec871..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_True_True_.gds
deleted file mode 100644
index 7ab6b4f32..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_False_False_.gds
deleted file mode 100644
index 72327abbb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_False_True_.gds
deleted file mode 100644
index 68bc11d1b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_True_False_.gds
deleted file mode 100644
index 054560a2e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_True_True_.gds
deleted file mode 100644
index be84a7c2b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_False_False_.gds
deleted file mode 100644
index 67d27dc79..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_False_True_.gds
deleted file mode 100644
index 1b0bd4266..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_True_False_.gds
deleted file mode 100644
index 8c646cff2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_True_True_.gds
deleted file mode 100644
index a2042174b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_False_False_.gds
deleted file mode 100644
index 9e57c7617..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_False_True_.gds
deleted file mode 100644
index 0028479f5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_True_False_.gds
deleted file mode 100644
index 9e4e5c676..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_True_True_.gds
deleted file mode 100644
index f02b276e7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_False_False_.gds
deleted file mode 100644
index 26a81fe79..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_False_True_.gds
deleted file mode 100644
index e2fc798a0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_True_False_.gds
deleted file mode 100644
index 2fba398df..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_True_True_.gds
deleted file mode 100644
index 0a5c2e7bf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_False_False_.gds
deleted file mode 100644
index 36e8f3676..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_False_True_.gds
deleted file mode 100644
index a174bc966..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_True_False_.gds
deleted file mode 100644
index 9058f0af3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_True_True_.gds
deleted file mode 100644
index 2141985ad..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_False_False_.gds
deleted file mode 100644
index 54a9e7f98..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_False_True_.gds
deleted file mode 100644
index 0f3fa1ccf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_True_False_.gds
deleted file mode 100644
index 5dbca9b7b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_True_True_.gds
deleted file mode 100644
index 64df04c7b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_False_False_.gds
deleted file mode 100644
index 2beddcd31..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_False_True_.gds
deleted file mode 100644
index 1e7a5ac85..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_True_False_.gds
deleted file mode 100644
index 1185d6ea7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_True_True_.gds
deleted file mode 100644
index 220842f42..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_False_False_.gds
deleted file mode 100644
index 1f0e7b330..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_False_True_.gds
deleted file mode 100644
index 3ea7c0371..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_True_False_.gds
deleted file mode 100644
index 42e3d0e18..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_True_True_.gds
deleted file mode 100644
index 631bd773e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_False_False_.gds
deleted file mode 100644
index 3ac84bca5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_False_True_.gds
deleted file mode 100644
index 1ccd4605d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_True_False_.gds
deleted file mode 100644
index 895a46379..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_True_True_.gds
deleted file mode 100644
index 0956a14ea..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_False_False_.gds
deleted file mode 100644
index f1a01a8c5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_False_True_.gds
deleted file mode 100644
index f1ef5b4bf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_True_False_.gds
deleted file mode 100644
index 8e07ea81e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_True_True_.gds
deleted file mode 100644
index dd93733ae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_False__2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_False_False_.gds
deleted file mode 100644
index 3f8d05066..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_False_True_.gds
deleted file mode 100644
index 02c30f63b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_True_False_.gds
deleted file mode 100644
index 2be293dcc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_True_True_.gds
deleted file mode 100644
index 77fb4bb60..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_False_False_.gds
deleted file mode 100644
index fdff8e22b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_False_True_.gds
deleted file mode 100644
index 6592fe4fa..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_True_False_.gds
deleted file mode 100644
index 86f1e99af..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_True_True_.gds
deleted file mode 100644
index 5203f833a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_False_False_.gds
deleted file mode 100644
index d7c2b2777..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_False_True_.gds
deleted file mode 100644
index d796c64fb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_True_False_.gds
deleted file mode 100644
index d07c3d41a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_True_True_.gds
deleted file mode 100644
index 971228134..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_False_False_.gds
deleted file mode 100644
index b6350d4a9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_False_True_.gds
deleted file mode 100644
index 4c9e12047..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_True_False_.gds
deleted file mode 100644
index ba98b88c5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_True_True_.gds
deleted file mode 100644
index 383167090..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_False_False_.gds
deleted file mode 100644
index cc8ce3243..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_False_True_.gds
deleted file mode 100644
index d5e3daa9b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_True_False_.gds
deleted file mode 100644
index 1110725ce..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_True_True_.gds
deleted file mode 100644
index 99b6323b2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_False_False_.gds
deleted file mode 100644
index 53b781865..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_False_True_.gds
deleted file mode 100644
index 8e972875a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_True_False_.gds
deleted file mode 100644
index 47de338a3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_True_True_.gds
deleted file mode 100644
index 37abf2283..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_False_False_.gds
deleted file mode 100644
index d3cf8bc85..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_False_True_.gds
deleted file mode 100644
index 34c91e388..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_True_False_.gds
deleted file mode 100644
index 7372b31b2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_True_True_.gds
deleted file mode 100644
index eb495b177..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_False_False_.gds
deleted file mode 100644
index f2a0c8d3b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_False_True_.gds
deleted file mode 100644
index 1bf30aae0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_True_False_.gds
deleted file mode 100644
index 6e83210fb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_True_True_.gds
deleted file mode 100644
index 29cca5e2d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_0_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_False_False_.gds
deleted file mode 100644
index 8373bbf72..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_False_True_.gds
deleted file mode 100644
index f6c4b3893..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_True_False_.gds
deleted file mode 100644
index 715ac9656..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_True_True_.gds
deleted file mode 100644
index 899920b8e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_False_False_.gds
deleted file mode 100644
index 511299e22..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_False_True_.gds
deleted file mode 100644
index 95a4b4f70..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_True_False_.gds
deleted file mode 100644
index b7118baac..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_True_True_.gds
deleted file mode 100644
index f28cbdbde..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_False_False_.gds
deleted file mode 100644
index 759674b72..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_False_True_.gds
deleted file mode 100644
index 3f64e05b3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_True_False_.gds
deleted file mode 100644
index 021dccbfb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_True_True_.gds
deleted file mode 100644
index e6723f492..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_False_False_.gds
deleted file mode 100644
index 3caee7cd3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_False_True_.gds
deleted file mode 100644
index d05a39a62..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_True_False_.gds
deleted file mode 100644
index 883a870cc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_True_True_.gds
deleted file mode 100644
index 6cbfbcf32..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_False_False_.gds
deleted file mode 100644
index 71202f9a9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_False_True_.gds
deleted file mode 100644
index 4557a1cc6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_True_False_.gds
deleted file mode 100644
index 1a9c04984..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_True_True_.gds
deleted file mode 100644
index 813d68da7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_False_False_.gds
deleted file mode 100644
index 0f4661035..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_False_True_.gds
deleted file mode 100644
index 1e2cd8075..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_True_False_.gds
deleted file mode 100644
index e50c2405e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_True_True_.gds
deleted file mode 100644
index dda946aa9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_False_False_.gds
deleted file mode 100644
index 83be85b8f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_False_True_.gds
deleted file mode 100644
index c979926d0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_True_False_.gds
deleted file mode 100644
index 4e1fe4cd3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_True_True_.gds
deleted file mode 100644
index 732eb1163..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_False_False_.gds
deleted file mode 100644
index 18608138b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_False_True_.gds
deleted file mode 100644
index dafc69520..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_True_False_.gds
deleted file mode 100644
index 4c5b54d92..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_True_True_.gds
deleted file mode 100644
index e6f09ecf3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_False_False_.gds
deleted file mode 100644
index caf4be074..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_False_True_.gds
deleted file mode 100644
index 0fcd766cb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_True_False_.gds
deleted file mode 100644
index 0dccccd72..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_True_True_.gds
deleted file mode 100644
index 261c01b0d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_False_False_.gds
deleted file mode 100644
index 3a37b744d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_False_True_.gds
deleted file mode 100644
index e09d43ab6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_True_False_.gds
deleted file mode 100644
index 7b7b6a00b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_True_True_.gds
deleted file mode 100644
index 30412eda1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_False_False_.gds
deleted file mode 100644
index e40cae45c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_False_True_.gds
deleted file mode 100644
index cd76baac9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_True_False_.gds
deleted file mode 100644
index 8551d6ac1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_True_True_.gds
deleted file mode 100644
index f147c0715..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_False_False_.gds
deleted file mode 100644
index 50f3efe95..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_False_True_.gds
deleted file mode 100644
index 91edab8bc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_True_False_.gds
deleted file mode 100644
index dc21c85e6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_True_True_.gds
deleted file mode 100644
index 74475fc2e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_False_False_.gds
deleted file mode 100644
index fb473f786..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_False_True_.gds
deleted file mode 100644
index 467313d38..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_True_False_.gds
deleted file mode 100644
index 1d84fae7c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_True_True_.gds
deleted file mode 100644
index a4fbbc1e6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_False_False_.gds
deleted file mode 100644
index 43d6274c3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_False_True_.gds
deleted file mode 100644
index b0ace1670..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_True_False_.gds
deleted file mode 100644
index 8cd565b1c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_True_True_.gds
deleted file mode 100644
index 531469e5b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_False_False_.gds
deleted file mode 100644
index 5f209605b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_False_True_.gds
deleted file mode 100644
index 423fb6754..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_True_False_.gds
deleted file mode 100644
index 89229705f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_True_True_.gds
deleted file mode 100644
index 44f285295..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_False_False_.gds
deleted file mode 100644
index 90ed3904a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_False_True_.gds
deleted file mode 100644
index 633443c8d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_True_False_.gds
deleted file mode 100644
index 37c46f0ad..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_True_True_.gds
deleted file mode 100644
index 1e2951f68..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True_2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_False_False_.gds
deleted file mode 100644
index fe7a0ec3a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_False_True_.gds
deleted file mode 100644
index d3727c640..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_True_False_.gds
deleted file mode 100644
index b55005ba0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_True_True_.gds
deleted file mode 100644
index a165fadd5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_False_False_.gds
deleted file mode 100644
index b11ef5046..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_False_True_.gds
deleted file mode 100644
index 04b378138..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_True_False_.gds
deleted file mode 100644
index a70ad3ced..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_True_True_.gds
deleted file mode 100644
index 30208e8d2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_False_False_.gds
deleted file mode 100644
index 8c2f5f0de..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_False_True_.gds
deleted file mode 100644
index 3363a0760..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_True_False_.gds
deleted file mode 100644
index 9085999b5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_True_True_.gds
deleted file mode 100644
index 322a15556..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_False_False_.gds
deleted file mode 100644
index 1ec41ad2c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_False_True_.gds
deleted file mode 100644
index c1c7ab664..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_True_False_.gds
deleted file mode 100644
index 0b319a665..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_True_True_.gds
deleted file mode 100644
index 317059ad8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_False_False_.gds
deleted file mode 100644
index f228cfab3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_False_True_.gds
deleted file mode 100644
index b12001146..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_True_False_.gds
deleted file mode 100644
index fac6f606a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_True_True_.gds
deleted file mode 100644
index 7c63211ba..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_False_False_.gds
deleted file mode 100644
index 99f8e1f65..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_False_True_.gds
deleted file mode 100644
index 57d2a874e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_True_False_.gds
deleted file mode 100644
index 3c8704898..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_True_True_.gds
deleted file mode 100644
index dbb0c5e14..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_False_False_.gds
deleted file mode 100644
index 37a61a637..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_False_True_.gds
deleted file mode 100644
index 4cd26f6ed..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_True_False_.gds
deleted file mode 100644
index 3ccff01cd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_True_True_.gds
deleted file mode 100644
index 83ea8f8a4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_False_False_.gds
deleted file mode 100644
index 63cc7bd57..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_False_True_.gds
deleted file mode 100644
index 966026bfd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_True_False_.gds
deleted file mode 100644
index 51d0155bd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_True_True_.gds
deleted file mode 100644
index 93c4dd685..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_False_False_.gds
deleted file mode 100644
index 4ae2ca6fc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_False_True_.gds
deleted file mode 100644
index 5e47b1b7b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_True_False_.gds
deleted file mode 100644
index 79f4cf729..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_True_True_.gds
deleted file mode 100644
index d54c7ca69..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_False_False_.gds
deleted file mode 100644
index 717f5e7e8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_False_True_.gds
deleted file mode 100644
index c7f6fa044..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_True_False_.gds
deleted file mode 100644
index b10a3d666..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_True_True_.gds
deleted file mode 100644
index b23ac0732..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_False_False_.gds
deleted file mode 100644
index 2755c0eae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_False_True_.gds
deleted file mode 100644
index 3d1ef7b59..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_True_False_.gds
deleted file mode 100644
index 8314bced2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_True_True_.gds
deleted file mode 100644
index 0437515ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_False_False_.gds
deleted file mode 100644
index 61feb7c2e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_False_True_.gds
deleted file mode 100644
index fc94b6f7f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_True_False_.gds
deleted file mode 100644
index 00a191eae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_True_True_.gds
deleted file mode 100644
index f6c5ac774..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_False_False_.gds
deleted file mode 100644
index ffe49dbc4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_False_True_.gds
deleted file mode 100644
index 1169a1bd4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_True_False_.gds
deleted file mode 100644
index 0f5d56790..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_True_True_.gds
deleted file mode 100644
index dae98f667..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_False_False_.gds
deleted file mode 100644
index e7eb61d1d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_False_True_.gds
deleted file mode 100644
index b5ffab6ac..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_True_False_.gds
deleted file mode 100644
index 0cf5ce87f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_True_True_.gds
deleted file mode 100644
index 7211176b3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_False_False_.gds
deleted file mode 100644
index 411b92e48..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_False_True_.gds
deleted file mode 100644
index 59b733bc8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_True_False_.gds
deleted file mode 100644
index ecae67e8e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_True_True_.gds
deleted file mode 100644
index f0c708da8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_False_False_.gds
deleted file mode 100644
index 028f07bd1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_False_True_.gds
deleted file mode 100644
index 86e942aae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_True_False_.gds
deleted file mode 100644
index dc0fb7152..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_True_True_.gds
deleted file mode 100644
index 2a381ee18..000000000
Binary files a/tests/test_data/generated/test_smart_routing_False_True_True__2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_False_False_.gds
deleted file mode 100644
index a4b66b607..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_False_True_.gds
deleted file mode 100644
index 886370c30..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_True_False_.gds
deleted file mode 100644
index c921d024f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_True_True_.gds
deleted file mode 100644
index cfb9e6ce0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_False_False_.gds
deleted file mode 100644
index 6cd422511..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_False_True_.gds
deleted file mode 100644
index 1e33a5ee1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_True_False_.gds
deleted file mode 100644
index a309b5b68..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_True_True_.gds
deleted file mode 100644
index c1f91e61c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_False_False_.gds
deleted file mode 100644
index 5ffb099d8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_False_True_.gds
deleted file mode 100644
index fe89980ea..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_True_False_.gds
deleted file mode 100644
index e94812f63..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_True_True_.gds
deleted file mode 100644
index 406347ce2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_False_False_.gds
deleted file mode 100644
index 49b15f679..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_False_True_.gds
deleted file mode 100644
index b2317a094..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_True_False_.gds
deleted file mode 100644
index 9f61e3ded..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_True_True_.gds
deleted file mode 100644
index ae252e1d6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_False_False_.gds
deleted file mode 100644
index 2bfcdc88b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_False_True_.gds
deleted file mode 100644
index 597c6d3e1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_True_False_.gds
deleted file mode 100644
index 1fdd8f988..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_True_True_.gds
deleted file mode 100644
index 226ce9022..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_False_False_.gds
deleted file mode 100644
index 183eddd28..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_False_True_.gds
deleted file mode 100644
index a8e651cad..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_True_False_.gds
deleted file mode 100644
index 37dcd0c7b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_True_True_.gds
deleted file mode 100644
index 56acdc05f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_False_False_.gds
deleted file mode 100644
index 390d7a6ad..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_False_True_.gds
deleted file mode 100644
index 846fe3207..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_True_False_.gds
deleted file mode 100644
index bacb5b92f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_True_True_.gds
deleted file mode 100644
index ab3b05709..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_False_False_.gds
deleted file mode 100644
index babcb3b92..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_False_True_.gds
deleted file mode 100644
index df86f13fc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_True_False_.gds
deleted file mode 100644
index 5592bd68b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_True_True_.gds
deleted file mode 100644
index d7fcaaf5c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_0_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_False_False_.gds
deleted file mode 100644
index e009dbb79..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_False_True_.gds
deleted file mode 100644
index f9ede63ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_True_False_.gds
deleted file mode 100644
index 1c0cd056d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_True_True_.gds
deleted file mode 100644
index 376d274d4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_False_False_.gds
deleted file mode 100644
index cb728cf2e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_False_True_.gds
deleted file mode 100644
index fd40ab337..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_True_False_.gds
deleted file mode 100644
index 6875a9dde..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_True_True_.gds
deleted file mode 100644
index 975db5062..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_False_False_.gds
deleted file mode 100644
index bab760180..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_False_True_.gds
deleted file mode 100644
index 38f41f3f3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_True_False_.gds
deleted file mode 100644
index 938fe4f07..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_True_True_.gds
deleted file mode 100644
index 38e5bedb9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_False_False_.gds
deleted file mode 100644
index 9fb51f07b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_False_True_.gds
deleted file mode 100644
index 04a14fee2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_True_False_.gds
deleted file mode 100644
index 2047f880d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_True_True_.gds
deleted file mode 100644
index 0c74a8124..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_False_False_.gds
deleted file mode 100644
index 70cdc14eb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_False_True_.gds
deleted file mode 100644
index c5ca5b0a4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_True_False_.gds
deleted file mode 100644
index c92079c56..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_True_True_.gds
deleted file mode 100644
index c90cea3be..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_False_False_.gds
deleted file mode 100644
index c52d97640..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_False_True_.gds
deleted file mode 100644
index 0d10b3629..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_True_False_.gds
deleted file mode 100644
index 0e649893b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_True_True_.gds
deleted file mode 100644
index bba5d1719..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_False_False_.gds
deleted file mode 100644
index 287a6407a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_False_True_.gds
deleted file mode 100644
index f24076d0a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_True_False_.gds
deleted file mode 100644
index 8a5c4aa32..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_True_True_.gds
deleted file mode 100644
index 1ec1cb23f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_False_False_.gds
deleted file mode 100644
index 8c199d9c5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_False_True_.gds
deleted file mode 100644
index 15533d5f8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_True_False_.gds
deleted file mode 100644
index 90f8f74cf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_True_True_.gds
deleted file mode 100644
index 656a2ae02..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_False_False_.gds
deleted file mode 100644
index d70dd1cca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_False_True_.gds
deleted file mode 100644
index b93a3c239..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_True_False_.gds
deleted file mode 100644
index c1cce480b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_True_True_.gds
deleted file mode 100644
index 483d0e2b7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_False_False_.gds
deleted file mode 100644
index fa688eeb0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_False_True_.gds
deleted file mode 100644
index 8c7827750..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_True_False_.gds
deleted file mode 100644
index 6a759c434..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_True_True_.gds
deleted file mode 100644
index dd225e0ae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_False_False_.gds
deleted file mode 100644
index 25454769b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_False_True_.gds
deleted file mode 100644
index ba0630487..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_True_False_.gds
deleted file mode 100644
index e01742288..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_True_True_.gds
deleted file mode 100644
index 647079c90..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_False_False_.gds
deleted file mode 100644
index 1d4826bed..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_False_True_.gds
deleted file mode 100644
index 89e68914c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_True_False_.gds
deleted file mode 100644
index 0cfb7a743..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_True_True_.gds
deleted file mode 100644
index 02ad5814a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_False_False_.gds
deleted file mode 100644
index f57a2c5be..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_False_True_.gds
deleted file mode 100644
index 43e9982db..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_True_False_.gds
deleted file mode 100644
index 818ebc38b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_True_True_.gds
deleted file mode 100644
index eb6165b4b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_False_False_.gds
deleted file mode 100644
index 2e2e8565f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_False_True_.gds
deleted file mode 100644
index cf4a3bd7c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_True_False_.gds
deleted file mode 100644
index fc8f16b73..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_True_True_.gds
deleted file mode 100644
index b41ef16e5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_False_False_.gds
deleted file mode 100644
index eda7d4dd9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_False_True_.gds
deleted file mode 100644
index f32d50a22..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_True_False_.gds
deleted file mode 100644
index 3efa50c9a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_True_True_.gds
deleted file mode 100644
index db98aeaef..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_False_False_.gds
deleted file mode 100644
index aac6ceed2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_False_True_.gds
deleted file mode 100644
index 96f3dfc22..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_True_False_.gds
deleted file mode 100644
index 6552711eb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_True_True_.gds
deleted file mode 100644
index d88e8145b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False_2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_False_False_.gds
deleted file mode 100644
index 6b18caa05..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_False_True_.gds
deleted file mode 100644
index 55d2f5ebf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_True_False_.gds
deleted file mode 100644
index 87d707846..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_True_True_.gds
deleted file mode 100644
index f36e20d27..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_False_False_.gds
deleted file mode 100644
index 7e74b7230..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_False_True_.gds
deleted file mode 100644
index b8af8ceae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_True_False_.gds
deleted file mode 100644
index f68fdf3b4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_True_True_.gds
deleted file mode 100644
index da8f1714d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_False_False_.gds
deleted file mode 100644
index ee22a3a8d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_False_True_.gds
deleted file mode 100644
index f72797989..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_True_False_.gds
deleted file mode 100644
index a6f08f1de..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_True_True_.gds
deleted file mode 100644
index 01f261197..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_False_False_.gds
deleted file mode 100644
index e00dd8b6c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_False_True_.gds
deleted file mode 100644
index a98fc71f4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_True_False_.gds
deleted file mode 100644
index a27e7b544..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_True_True_.gds
deleted file mode 100644
index f582609c5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_False_False_.gds
deleted file mode 100644
index b01910d77..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_False_True_.gds
deleted file mode 100644
index 12c78bf24..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_True_False_.gds
deleted file mode 100644
index d52568b63..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_True_True_.gds
deleted file mode 100644
index f30049efc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_False_False_.gds
deleted file mode 100644
index 62627ca3f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_False_True_.gds
deleted file mode 100644
index 07395c1ba..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_True_False_.gds
deleted file mode 100644
index 8d5367415..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_True_True_.gds
deleted file mode 100644
index e7986219e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_False_False_.gds
deleted file mode 100644
index 1ca8257c7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_False_True_.gds
deleted file mode 100644
index 26e075675..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_True_False_.gds
deleted file mode 100644
index 7c826e52a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_True_True_.gds
deleted file mode 100644
index 0928506b5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_False_False_.gds
deleted file mode 100644
index b0cb911d1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_False_True_.gds
deleted file mode 100644
index cf5215038..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_True_False_.gds
deleted file mode 100644
index 5ff0eb262..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_True_True_.gds
deleted file mode 100644
index 42090f208..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_False_False_.gds
deleted file mode 100644
index d52541c11..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_False_True_.gds
deleted file mode 100644
index f67312990..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_True_False_.gds
deleted file mode 100644
index 2b3fe43ef..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_True_True_.gds
deleted file mode 100644
index a6ef3cf72..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_False_False_.gds
deleted file mode 100644
index 8a40155f1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_False_True_.gds
deleted file mode 100644
index 1fbfd8d0f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_True_False_.gds
deleted file mode 100644
index 0717e1db5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_True_True_.gds
deleted file mode 100644
index 6a0f85114..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_False_False_.gds
deleted file mode 100644
index a8f60aee4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_False_True_.gds
deleted file mode 100644
index 51c5bab33..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_True_False_.gds
deleted file mode 100644
index c4454aa3e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_True_True_.gds
deleted file mode 100644
index 5279ffded..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_False_False_.gds
deleted file mode 100644
index 9f5ebd49a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_False_True_.gds
deleted file mode 100644
index 6dffd1020..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_True_False_.gds
deleted file mode 100644
index 316afb40e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_True_True_.gds
deleted file mode 100644
index a73c0b5b3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_False_False_.gds
deleted file mode 100644
index 3a103c2a3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_False_True_.gds
deleted file mode 100644
index c1a6a59f8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_True_False_.gds
deleted file mode 100644
index 7961c105f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_True_True_.gds
deleted file mode 100644
index c7e4f314c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_False_False_.gds
deleted file mode 100644
index 7e180a396..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_False_True_.gds
deleted file mode 100644
index b605b79cb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_True_False_.gds
deleted file mode 100644
index 8614e6056..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_True_True_.gds
deleted file mode 100644
index 4c0f79541..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_False_False_.gds
deleted file mode 100644
index ad9fc9a3a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_False_True_.gds
deleted file mode 100644
index 7d287b267..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_True_False_.gds
deleted file mode 100644
index d3129cb58..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_True_True_.gds
deleted file mode 100644
index 838297886..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_False_False_.gds
deleted file mode 100644
index d19cf45d2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_False_True_.gds
deleted file mode 100644
index ff021b99a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_True_False_.gds
deleted file mode 100644
index 2322b8477..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_True_True_.gds
deleted file mode 100644
index 7d694cbec..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_False__2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_False_False_.gds
deleted file mode 100644
index a1e8bf48c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_False_True_.gds
deleted file mode 100644
index d2d314fc7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_True_False_.gds
deleted file mode 100644
index 37e1c78da..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_True_True_.gds
deleted file mode 100644
index 813778ec1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_False_False_.gds
deleted file mode 100644
index 24608f5f7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_False_True_.gds
deleted file mode 100644
index ae03cab89..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_True_False_.gds
deleted file mode 100644
index 42646d5c3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_True_True_.gds
deleted file mode 100644
index 1fa87a6e9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_False_False_.gds
deleted file mode 100644
index b0ad8776a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_False_True_.gds
deleted file mode 100644
index 3dc3b931f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_True_False_.gds
deleted file mode 100644
index b758c6f21..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_True_True_.gds
deleted file mode 100644
index 3cb1739d8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_False_False_.gds
deleted file mode 100644
index 4cf04b373..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_False_True_.gds
deleted file mode 100644
index e5e91855e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_True_False_.gds
deleted file mode 100644
index 6baaae5bc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_True_True_.gds
deleted file mode 100644
index 83f4977ce..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_False_False_.gds
deleted file mode 100644
index 0d719a020..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_False_True_.gds
deleted file mode 100644
index f946a91ad..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_True_False_.gds
deleted file mode 100644
index 52428c77e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_True_True_.gds
deleted file mode 100644
index 3ce2c8bce..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_False_False_.gds
deleted file mode 100644
index c5d10712d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_False_True_.gds
deleted file mode 100644
index c00ebc93a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_True_False_.gds
deleted file mode 100644
index 35bba7e3a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_True_True_.gds
deleted file mode 100644
index 56db1fec4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_False_False_.gds
deleted file mode 100644
index 077760f7c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_False_True_.gds
deleted file mode 100644
index e771210b1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_True_False_.gds
deleted file mode 100644
index 975be2a0b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_True_True_.gds
deleted file mode 100644
index bf3a8bc40..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_False_False_.gds
deleted file mode 100644
index 61f65ad9e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_False_True_.gds
deleted file mode 100644
index e36afaa41..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_True_False_.gds
deleted file mode 100644
index 5d2ad7cb7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_True_True_.gds
deleted file mode 100644
index 451dc2eda..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_0_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_False_False_.gds
deleted file mode 100644
index 347c32f1d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_False_True_.gds
deleted file mode 100644
index 46c576c5d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_True_False_.gds
deleted file mode 100644
index 223b31e6d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_True_True_.gds
deleted file mode 100644
index 07e1b680a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_False_False_.gds
deleted file mode 100644
index 4dc816089..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_False_True_.gds
deleted file mode 100644
index 805bbb827..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_True_False_.gds
deleted file mode 100644
index 9df85a93b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_True_True_.gds
deleted file mode 100644
index ab1086d1c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_False_False_.gds
deleted file mode 100644
index a34769e75..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_False_True_.gds
deleted file mode 100644
index c928e82d3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_True_False_.gds
deleted file mode 100644
index ae2b7489d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_True_True_.gds
deleted file mode 100644
index 0eef4049c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_False_False_.gds
deleted file mode 100644
index 00606303d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_False_True_.gds
deleted file mode 100644
index f95e1b637..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_True_False_.gds
deleted file mode 100644
index a65375660..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_True_True_.gds
deleted file mode 100644
index ca4b78213..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_False_False_.gds
deleted file mode 100644
index cfe464912..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_False_True_.gds
deleted file mode 100644
index a76020956..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_True_False_.gds
deleted file mode 100644
index 40cb1548c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_True_True_.gds
deleted file mode 100644
index cd70bbee0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_False_False_.gds
deleted file mode 100644
index 248be3fab..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_False_True_.gds
deleted file mode 100644
index d4c302192..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_True_False_.gds
deleted file mode 100644
index ea3079b8f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_True_True_.gds
deleted file mode 100644
index 651191253..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_False_False_.gds
deleted file mode 100644
index bc0607c81..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_False_True_.gds
deleted file mode 100644
index b75777d2d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_True_False_.gds
deleted file mode 100644
index e88ba934e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_True_True_.gds
deleted file mode 100644
index 30cd9fbf6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_False_False_.gds
deleted file mode 100644
index 96a8c8c63..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_False_True_.gds
deleted file mode 100644
index fe2c7c0a4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_True_False_.gds
deleted file mode 100644
index 71adf9430..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_True_True_.gds
deleted file mode 100644
index f076b7459..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_False_False_.gds
deleted file mode 100644
index ecc837e4b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_False_True_.gds
deleted file mode 100644
index 5a9376986..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_True_False_.gds
deleted file mode 100644
index d93cb9edd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_True_True_.gds
deleted file mode 100644
index a465d19a4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_False_False_.gds
deleted file mode 100644
index 1a46880d0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_False_True_.gds
deleted file mode 100644
index 0d8fb2f2c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_True_False_.gds
deleted file mode 100644
index 22e2317ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_True_True_.gds
deleted file mode 100644
index f69f0427e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_False_False_.gds
deleted file mode 100644
index f832b26f0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_False_True_.gds
deleted file mode 100644
index 030329ce6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_True_False_.gds
deleted file mode 100644
index 7064ea56b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_True_True_.gds
deleted file mode 100644
index 780d6a3c9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_False_False_.gds
deleted file mode 100644
index 3fc2d1775..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_False_True_.gds
deleted file mode 100644
index 001f05b03..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_True_False_.gds
deleted file mode 100644
index fbce5e435..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_True_True_.gds
deleted file mode 100644
index bbac592ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_False_False_.gds
deleted file mode 100644
index 255092a95..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_False_True_.gds
deleted file mode 100644
index 30cec04b9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_True_False_.gds
deleted file mode 100644
index 51d73b47e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_True_True_.gds
deleted file mode 100644
index 0aca4d068..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_False_False_.gds
deleted file mode 100644
index 2680ebfb7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_False_True_.gds
deleted file mode 100644
index 037f34988..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_True_False_.gds
deleted file mode 100644
index d95617228..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_True_True_.gds
deleted file mode 100644
index 1f0f81952..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_False_False_.gds
deleted file mode 100644
index 365d43f36..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_False_True_.gds
deleted file mode 100644
index ecfb3aaaa..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_True_False_.gds
deleted file mode 100644
index 04f6caba5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_True_True_.gds
deleted file mode 100644
index 947ea5b8e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_False_False_.gds
deleted file mode 100644
index b8ed856bb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_False_True_.gds
deleted file mode 100644
index f1b5496ee..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_True_False_.gds
deleted file mode 100644
index 1ecfe1467..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_True_True_.gds
deleted file mode 100644
index e467bde18..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True_2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_False_False_.gds
deleted file mode 100644
index 56c8527a5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_False_True_.gds
deleted file mode 100644
index ca2f9672f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_True_False_.gds
deleted file mode 100644
index be9cf6d0a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_True_True_.gds
deleted file mode 100644
index a8a9da186..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_False_False_.gds
deleted file mode 100644
index 202e52754..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_False_True_.gds
deleted file mode 100644
index e2548868f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_True_False_.gds
deleted file mode 100644
index d01c3957f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_True_True_.gds
deleted file mode 100644
index ece815871..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_False_False_.gds
deleted file mode 100644
index 9e3e786e8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_False_True_.gds
deleted file mode 100644
index acc554b67..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_True_False_.gds
deleted file mode 100644
index 48f23c6d8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_True_True_.gds
deleted file mode 100644
index 6f99f4965..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_False_False_.gds
deleted file mode 100644
index 1ce8cfa15..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_False_True_.gds
deleted file mode 100644
index 7cdbee649..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_True_False_.gds
deleted file mode 100644
index 5c0bfb36a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_True_True_.gds
deleted file mode 100644
index d42047bd7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_False_False_.gds
deleted file mode 100644
index 2b5a46a45..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_False_True_.gds
deleted file mode 100644
index 5b433e703..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_True_False_.gds
deleted file mode 100644
index aae6d1d1c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_True_True_.gds
deleted file mode 100644
index a31d6f648..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_False_False_.gds
deleted file mode 100644
index dbd2dbff7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_False_True_.gds
deleted file mode 100644
index ecbcdc216..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_True_False_.gds
deleted file mode 100644
index 41021d313..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_True_True_.gds
deleted file mode 100644
index 5e00aec0d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_False_False_.gds
deleted file mode 100644
index e598166a6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_False_True_.gds
deleted file mode 100644
index e9727f90e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_True_False_.gds
deleted file mode 100644
index 0a66161ee..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_True_True_.gds
deleted file mode 100644
index 7ff13a984..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_False_False_.gds
deleted file mode 100644
index cb11921b2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_False_True_.gds
deleted file mode 100644
index 92c14590c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_True_False_.gds
deleted file mode 100644
index af46265d6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_True_True_.gds
deleted file mode 100644
index 5970d0c2a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_False_False_.gds
deleted file mode 100644
index d9b8bd001..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_False_True_.gds
deleted file mode 100644
index 0530d12db..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_True_False_.gds
deleted file mode 100644
index 02fe1a23d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_True_True_.gds
deleted file mode 100644
index 376092f58..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_False_False_.gds
deleted file mode 100644
index 2c4a76a5b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_False_True_.gds
deleted file mode 100644
index bc3141aaf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_True_False_.gds
deleted file mode 100644
index da1478cd4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_True_True_.gds
deleted file mode 100644
index 8c80f64bf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_False_False_.gds
deleted file mode 100644
index b400c215c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_False_True_.gds
deleted file mode 100644
index 15e57af91..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_True_False_.gds
deleted file mode 100644
index b639182ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_True_True_.gds
deleted file mode 100644
index cd29a23b7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_False_False_.gds
deleted file mode 100644
index 16c7ef048..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_False_True_.gds
deleted file mode 100644
index 8358bc074..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_True_False_.gds
deleted file mode 100644
index a4e6202ae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_True_True_.gds
deleted file mode 100644
index 3274a22a2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_False_False_.gds
deleted file mode 100644
index 938648901..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_False_True_.gds
deleted file mode 100644
index 60dceca7c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_True_False_.gds
deleted file mode 100644
index 451ea4d13..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_True_True_.gds
deleted file mode 100644
index 05b9bfc3c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_False_False_.gds
deleted file mode 100644
index 977516761..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_False_True_.gds
deleted file mode 100644
index ca637507c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_True_False_.gds
deleted file mode 100644
index fbd15cdba..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_True_True_.gds
deleted file mode 100644
index 38d689e98..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_False_False_.gds
deleted file mode 100644
index cf282e89c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_False_True_.gds
deleted file mode 100644
index 5c85cea25..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_True_False_.gds
deleted file mode 100644
index dddde118d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_True_True_.gds
deleted file mode 100644
index 1c575f885..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_False_False_.gds
deleted file mode 100644
index fe382abf8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_False_True_.gds
deleted file mode 100644
index f157204b5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_True_False_.gds
deleted file mode 100644
index 0cfb20e67..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_True_True_.gds
deleted file mode 100644
index bd0bc6c6a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_False_True__2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_False_False_.gds
deleted file mode 100644
index 789782bb2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_False_True_.gds
deleted file mode 100644
index a5782ec32..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_True_False_.gds
deleted file mode 100644
index 88aceb38d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_True_True_.gds
deleted file mode 100644
index 18017e61c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_False_False_.gds
deleted file mode 100644
index d6c462861..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_False_True_.gds
deleted file mode 100644
index 26dfe23ec..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_True_False_.gds
deleted file mode 100644
index 8d3722554..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_True_True_.gds
deleted file mode 100644
index 459f4d889..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_False_False_.gds
deleted file mode 100644
index 54132ff18..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_False_True_.gds
deleted file mode 100644
index 6477e6687..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_True_False_.gds
deleted file mode 100644
index eb3834567..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_True_True_.gds
deleted file mode 100644
index 64227b864..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_False_False_.gds
deleted file mode 100644
index e7f0aebdb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_False_True_.gds
deleted file mode 100644
index f38fea279..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_True_False_.gds
deleted file mode 100644
index 418274772..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_True_True_.gds
deleted file mode 100644
index affc9d155..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_False_False_.gds
deleted file mode 100644
index add975f3b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_False_True_.gds
deleted file mode 100644
index b94681c2e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_True_False_.gds
deleted file mode 100644
index 09d80fd4b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_True_True_.gds
deleted file mode 100644
index 5c2e56b72..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_False_False_.gds
deleted file mode 100644
index 5d62c81c3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_False_True_.gds
deleted file mode 100644
index 8bd3d16c7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_True_False_.gds
deleted file mode 100644
index 0f32d7f32..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_True_True_.gds
deleted file mode 100644
index a0e643cc7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_False_False_.gds
deleted file mode 100644
index 6e6e977bd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_False_True_.gds
deleted file mode 100644
index ea25621a3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_True_False_.gds
deleted file mode 100644
index 70e2d3a0f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_True_True_.gds
deleted file mode 100644
index d03f066c8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_False_False_.gds
deleted file mode 100644
index 0e2600f75..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_False_True_.gds
deleted file mode 100644
index 983659f49..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_True_False_.gds
deleted file mode 100644
index c9d0cc357..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_True_True_.gds
deleted file mode 100644
index bd33b41b8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_0_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_False_False_.gds
deleted file mode 100644
index 43c970f1b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_False_True_.gds
deleted file mode 100644
index b0808cbe1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_True_False_.gds
deleted file mode 100644
index ee23795ec..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_True_True_.gds
deleted file mode 100644
index 0bbe36330..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_False_False_.gds
deleted file mode 100644
index 9335c44d8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_False_True_.gds
deleted file mode 100644
index 2896942c9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_True_False_.gds
deleted file mode 100644
index c23822864..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_True_True_.gds
deleted file mode 100644
index 115b30440..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_False_False_.gds
deleted file mode 100644
index 80254bd62..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_False_True_.gds
deleted file mode 100644
index da800b4c2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_True_False_.gds
deleted file mode 100644
index ca4707608..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_True_True_.gds
deleted file mode 100644
index 0058dab73..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_False_False_.gds
deleted file mode 100644
index 2e574fc19..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_False_True_.gds
deleted file mode 100644
index 172e97739..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_True_False_.gds
deleted file mode 100644
index 768033d96..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_True_True_.gds
deleted file mode 100644
index bb47d77d3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_False_False_.gds
deleted file mode 100644
index c5dc1b0f7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_False_True_.gds
deleted file mode 100644
index 8dfea4396..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_True_False_.gds
deleted file mode 100644
index e2005ce16..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_True_True_.gds
deleted file mode 100644
index 82e5e01f6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_False_False_.gds
deleted file mode 100644
index 963a2c206..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_False_True_.gds
deleted file mode 100644
index 95ec87018..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_True_False_.gds
deleted file mode 100644
index c9baf8729..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_True_True_.gds
deleted file mode 100644
index 7b44b90ce..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_False_False_.gds
deleted file mode 100644
index 8aa2b6c5c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_False_True_.gds
deleted file mode 100644
index bb478b965..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_True_False_.gds
deleted file mode 100644
index a6a49cca4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_True_True_.gds
deleted file mode 100644
index 52dff9381..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_False_False_.gds
deleted file mode 100644
index 9f37dfea6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_False_True_.gds
deleted file mode 100644
index c02eaa506..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_True_False_.gds
deleted file mode 100644
index b5e4eb026..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_True_True_.gds
deleted file mode 100644
index 3ce898467..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_False_False_.gds
deleted file mode 100644
index fba655790..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_False_True_.gds
deleted file mode 100644
index a897cabd0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_True_False_.gds
deleted file mode 100644
index 8a72db77f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_True_True_.gds
deleted file mode 100644
index 9cb9d0629..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_False_False_.gds
deleted file mode 100644
index d8a7f5cfb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_False_True_.gds
deleted file mode 100644
index 3d4b58efa..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_True_False_.gds
deleted file mode 100644
index f49502e60..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_True_True_.gds
deleted file mode 100644
index 6b89366de..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_False_False_.gds
deleted file mode 100644
index 438a865c4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_False_True_.gds
deleted file mode 100644
index 522cd6b24..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_True_False_.gds
deleted file mode 100644
index 1fcc19605..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_True_True_.gds
deleted file mode 100644
index 50f29dfb5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_False_False_.gds
deleted file mode 100644
index 755efc737..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_False_True_.gds
deleted file mode 100644
index 6f19fab05..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_True_False_.gds
deleted file mode 100644
index 9cabc9a2f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_True_True_.gds
deleted file mode 100644
index 8f50d792a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_False_False_.gds
deleted file mode 100644
index 35b601feb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_False_True_.gds
deleted file mode 100644
index ca3c7c00f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_True_False_.gds
deleted file mode 100644
index 755fd2134..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_True_True_.gds
deleted file mode 100644
index d45d549fb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_False_False_.gds
deleted file mode 100644
index d20648347..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_False_True_.gds
deleted file mode 100644
index 4b8ea9337..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_True_False_.gds
deleted file mode 100644
index 83025131d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_True_True_.gds
deleted file mode 100644
index 0f84d4159..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_False_False_.gds
deleted file mode 100644
index 1eab1a876..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_False_True_.gds
deleted file mode 100644
index e34512d66..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_True_False_.gds
deleted file mode 100644
index 0e73b6208..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_True_True_.gds
deleted file mode 100644
index b3f324f4d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_False_False_.gds
deleted file mode 100644
index b9d48dc93..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_False_True_.gds
deleted file mode 100644
index 603ed3bf3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_True_False_.gds
deleted file mode 100644
index 550dd2965..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_True_True_.gds
deleted file mode 100644
index bccd5dc1c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False_2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_False_False_.gds
deleted file mode 100644
index 9f6b7418f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_False_True_.gds
deleted file mode 100644
index 3ab965b3a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_True_False_.gds
deleted file mode 100644
index 1ab545bf5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_True_True_.gds
deleted file mode 100644
index 8e828d235..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_False_False_.gds
deleted file mode 100644
index 0c9eed9cb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_False_True_.gds
deleted file mode 100644
index de734bda1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_True_False_.gds
deleted file mode 100644
index fc1f838ba..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_True_True_.gds
deleted file mode 100644
index 53d5a9359..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_False_False_.gds
deleted file mode 100644
index 6562c1ab3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_False_True_.gds
deleted file mode 100644
index d8788187d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_True_False_.gds
deleted file mode 100644
index e987212c2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_True_True_.gds
deleted file mode 100644
index f9b9e01d2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_False_False_.gds
deleted file mode 100644
index e4748f95b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_False_True_.gds
deleted file mode 100644
index 015548583..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_True_False_.gds
deleted file mode 100644
index 8a3bb2056..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_True_True_.gds
deleted file mode 100644
index 0434cb805..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_False_False_.gds
deleted file mode 100644
index 0da60e1ef..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_False_True_.gds
deleted file mode 100644
index 67b829022..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_True_False_.gds
deleted file mode 100644
index db62285bf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_True_True_.gds
deleted file mode 100644
index c4ea7daa5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_False_False_.gds
deleted file mode 100644
index 77fa42707..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_False_True_.gds
deleted file mode 100644
index 40cc6866c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_True_False_.gds
deleted file mode 100644
index b3c776db1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_True_True_.gds
deleted file mode 100644
index 858b9df96..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_False_False_.gds
deleted file mode 100644
index a83b9098e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_False_True_.gds
deleted file mode 100644
index 793f3b33e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_True_False_.gds
deleted file mode 100644
index 2c37f9425..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_True_True_.gds
deleted file mode 100644
index 7be489d78..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_False_False_.gds
deleted file mode 100644
index 75dbb82d7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_False_True_.gds
deleted file mode 100644
index cc38ce17e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_True_False_.gds
deleted file mode 100644
index 439a9e139..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_True_True_.gds
deleted file mode 100644
index e0a7d5952..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_False_False_.gds
deleted file mode 100644
index f4fee066a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_False_True_.gds
deleted file mode 100644
index 54cfc6bdf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_True_False_.gds
deleted file mode 100644
index ed0582ec7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_True_True_.gds
deleted file mode 100644
index f15f53836..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_False_False_.gds
deleted file mode 100644
index 0834e0906..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_False_True_.gds
deleted file mode 100644
index 388ee3112..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_True_False_.gds
deleted file mode 100644
index 0cb46355a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_True_True_.gds
deleted file mode 100644
index 53d0fe559..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_False_False_.gds
deleted file mode 100644
index 4aaa10ef4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_False_True_.gds
deleted file mode 100644
index dd0dc6cfe..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_True_False_.gds
deleted file mode 100644
index b8d080b84..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_True_True_.gds
deleted file mode 100644
index e81fd89d9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_False_False_.gds
deleted file mode 100644
index fccc09bc6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_False_True_.gds
deleted file mode 100644
index 3927d3df0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_True_False_.gds
deleted file mode 100644
index 907a7dd7a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_True_True_.gds
deleted file mode 100644
index 89ace59cd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_False_False_.gds
deleted file mode 100644
index 274eae5f9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_False_True_.gds
deleted file mode 100644
index db1a67ad3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_True_False_.gds
deleted file mode 100644
index 0d63613d5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_True_True_.gds
deleted file mode 100644
index e7ff1d1b7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_False_False_.gds
deleted file mode 100644
index c68c4a5e1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_False_True_.gds
deleted file mode 100644
index cc442e4da..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_True_False_.gds
deleted file mode 100644
index 54a3f26a6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_True_True_.gds
deleted file mode 100644
index 322828088..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_False_False_.gds
deleted file mode 100644
index 4d25ee288..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_False_True_.gds
deleted file mode 100644
index cef755231..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_True_False_.gds
deleted file mode 100644
index 9ffb46648..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_True_True_.gds
deleted file mode 100644
index 557b49c83..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_False_False_.gds
deleted file mode 100644
index b2511aba5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_False_True_.gds
deleted file mode 100644
index f86b83a18..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_True_False_.gds
deleted file mode 100644
index 8ef019faf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_True_True_.gds
deleted file mode 100644
index 62ed22cd8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_False__2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_False_False_.gds
deleted file mode 100644
index f0c367c61..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_False_True_.gds
deleted file mode 100644
index 83d864160..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_True_False_.gds
deleted file mode 100644
index 5a46bd9b8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_True_True_.gds
deleted file mode 100644
index cdbd1704a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_False_False_.gds
deleted file mode 100644
index 58f63a30d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_False_True_.gds
deleted file mode 100644
index ad9a4d08f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_True_False_.gds
deleted file mode 100644
index 8f3f67b5c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_True_True_.gds
deleted file mode 100644
index 2905862bc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_False_False_.gds
deleted file mode 100644
index 37759d306..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_False_True_.gds
deleted file mode 100644
index 3ee9ad77e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_True_False_.gds
deleted file mode 100644
index 959214404..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_True_True_.gds
deleted file mode 100644
index 569165e73..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_False_False_.gds
deleted file mode 100644
index a0b4893aa..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_False_True_.gds
deleted file mode 100644
index ef68789b6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_True_False_.gds
deleted file mode 100644
index 30f0e5a35..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_True_True_.gds
deleted file mode 100644
index a5eade748..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_False_False_.gds
deleted file mode 100644
index f39140e96..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_False_True_.gds
deleted file mode 100644
index 1b404a548..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_True_False_.gds
deleted file mode 100644
index 3923f7039..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_True_True_.gds
deleted file mode 100644
index bbde35ca7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_False_False_.gds
deleted file mode 100644
index a3583fecf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_False_True_.gds
deleted file mode 100644
index 90ab58295..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_True_False_.gds
deleted file mode 100644
index 4348986f7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_True_True_.gds
deleted file mode 100644
index 422da28e1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_False_False_.gds
deleted file mode 100644
index 3e081aec4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_False_True_.gds
deleted file mode 100644
index 95bae42e6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_True_False_.gds
deleted file mode 100644
index c0b19badc..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_True_True_.gds
deleted file mode 100644
index 1ccd07d11..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_False_False_.gds
deleted file mode 100644
index 09955feee..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_False_True_.gds
deleted file mode 100644
index fe35d5017..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_True_False_.gds
deleted file mode 100644
index e01d1af3a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_True_True_.gds
deleted file mode 100644
index 1a33cedd2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_0_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_False_False_.gds
deleted file mode 100644
index ba74282c8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_False_True_.gds
deleted file mode 100644
index ac5920a89..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_True_False_.gds
deleted file mode 100644
index 208193e06..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_True_True_.gds
deleted file mode 100644
index c21e10068..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_False_False_.gds
deleted file mode 100644
index e38bc579c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_False_True_.gds
deleted file mode 100644
index 5f61dc3ee..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_True_False_.gds
deleted file mode 100644
index b81e52f5b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_True_True_.gds
deleted file mode 100644
index af6ca2e06..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_False_False_.gds
deleted file mode 100644
index 98e1cc006..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_False_True_.gds
deleted file mode 100644
index 343efd17e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_True_False_.gds
deleted file mode 100644
index 8f72dfd26..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_True_True_.gds
deleted file mode 100644
index 2726f426e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_False_False_.gds
deleted file mode 100644
index e91e0d9fa..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_False_True_.gds
deleted file mode 100644
index 3842cbfb0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_True_False_.gds
deleted file mode 100644
index 6010cb0d2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_True_True_.gds
deleted file mode 100644
index 405fe0bf3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_False_False_.gds
deleted file mode 100644
index bb8f5da43..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_False_True_.gds
deleted file mode 100644
index 2bf811b73..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_True_False_.gds
deleted file mode 100644
index d9ff3c444..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_True_True_.gds
deleted file mode 100644
index d597e3905..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_False_False_.gds
deleted file mode 100644
index 9ec9f685a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_False_True_.gds
deleted file mode 100644
index 4e0aad372..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_True_False_.gds
deleted file mode 100644
index 4eb8a74b4..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_True_True_.gds
deleted file mode 100644
index 9834b0d3e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_False_False_.gds
deleted file mode 100644
index b8aa0f48a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_False_True_.gds
deleted file mode 100644
index 98aac154b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_True_False_.gds
deleted file mode 100644
index 4fd702fe6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_True_True_.gds
deleted file mode 100644
index 9bc099e56..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_False_False_.gds
deleted file mode 100644
index 8ffd427af..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_False_True_.gds
deleted file mode 100644
index c72726101..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_True_False_.gds
deleted file mode 100644
index 537e7a84b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_True_True_.gds
deleted file mode 100644
index 84b218a9d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_False_False_.gds
deleted file mode 100644
index 28dcd2286..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_False_True_.gds
deleted file mode 100644
index 32aa1fa3b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_True_False_.gds
deleted file mode 100644
index 1ef9d8aba..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_True_True_.gds
deleted file mode 100644
index 1fa4c0deb..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_False_False_.gds
deleted file mode 100644
index 0b62224ee..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_False_True_.gds
deleted file mode 100644
index 4225daca1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_True_False_.gds
deleted file mode 100644
index 1209f7f92..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_True_True_.gds
deleted file mode 100644
index 64bad8a4d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_False_False_.gds
deleted file mode 100644
index 0ffb97d10..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_False_True_.gds
deleted file mode 100644
index e3197d5a6..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_True_False_.gds
deleted file mode 100644
index 2def9d630..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_True_True_.gds
deleted file mode 100644
index 0c0167d0a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_False_False_.gds
deleted file mode 100644
index 8b291e73c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_False_True_.gds
deleted file mode 100644
index a05fbf964..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_True_False_.gds
deleted file mode 100644
index 0ddb01c86..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_True_True_.gds
deleted file mode 100644
index 4c1da1b08..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_False_False_.gds
deleted file mode 100644
index be3ac8d0c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_False_True_.gds
deleted file mode 100644
index 2cbd59eb0..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_True_False_.gds
deleted file mode 100644
index d443f92cf..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_True_True_.gds
deleted file mode 100644
index 54eb11b4b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_False_False_.gds
deleted file mode 100644
index dbafabc70..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_False_True_.gds
deleted file mode 100644
index b2b70874d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_True_False_.gds
deleted file mode 100644
index 9f8bd2d5c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_True_True_.gds
deleted file mode 100644
index 1023d6a8b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_False_False_.gds
deleted file mode 100644
index 08bf58664..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_False_True_.gds
deleted file mode 100644
index 44299b62e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_True_False_.gds
deleted file mode 100644
index 46d4bf4b2..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_True_True_.gds
deleted file mode 100644
index ab7b43bd1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_False_False_.gds
deleted file mode 100644
index d38c79fae..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_False_True_.gds
deleted file mode 100644
index dad24b7ca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_True_False_.gds
deleted file mode 100644
index ad742654f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_True_True_.gds
deleted file mode 100644
index 54c5da309..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True_2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_False_False_.gds
deleted file mode 100644
index 3b1d1193b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_False_True_.gds
deleted file mode 100644
index 9050c9dca..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_True_False_.gds
deleted file mode 100644
index 0c581ce43..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_True_True_.gds
deleted file mode 100644
index c3e28f668..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_False_False_.gds
deleted file mode 100644
index 51bf4b069..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_False_True_.gds
deleted file mode 100644
index 3f3c73f6e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_True_False_.gds
deleted file mode 100644
index c6e5f99f9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_True_True_.gds
deleted file mode 100644
index 18db1ad71..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_False_False_.gds
deleted file mode 100644
index 9bf8cb24c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_False_True_.gds
deleted file mode 100644
index 85580e01d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_True_False_.gds
deleted file mode 100644
index a5669e49f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_True_True_.gds
deleted file mode 100644
index eb1f15047..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_False_False_.gds
deleted file mode 100644
index 7862078dd..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_False_True_.gds
deleted file mode 100644
index d2fde29ff..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_True_False_.gds
deleted file mode 100644
index 0941cd67d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_True_True_.gds
deleted file mode 100644
index 4c3ddd059..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_False_False_.gds
deleted file mode 100644
index 3edc9393d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_False_True_.gds
deleted file mode 100644
index 4e0a22d1b..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_True_False_.gds
deleted file mode 100644
index 2f23ff4f8..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_True_True_.gds
deleted file mode 100644
index b89ae178f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_False_False_.gds
deleted file mode 100644
index 82a228c73..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_False_True_.gds
deleted file mode 100644
index d9393ff6e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_True_False_.gds
deleted file mode 100644
index f71529227..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_True_True_.gds
deleted file mode 100644
index 28c220ae3..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_False_False_.gds
deleted file mode 100644
index f260db7a7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_False_True_.gds
deleted file mode 100644
index da2e906c5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_True_False_.gds
deleted file mode 100644
index 23083535f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_True_True_.gds
deleted file mode 100644
index 39883a842..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_False_False_.gds
deleted file mode 100644
index 0f8d2a81e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_False_True_.gds
deleted file mode 100644
index 476339903..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_True_False_.gds
deleted file mode 100644
index 5ebf2ef3e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_True_True_.gds
deleted file mode 100644
index ecbf90695..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__1_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_False_False_.gds
deleted file mode 100644
index 522986504..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_False_True_.gds
deleted file mode 100644
index 634f164b9..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_True_False_.gds
deleted file mode 100644
index 66e545bef..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_True_True_.gds
deleted file mode 100644
index d09deb630..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_False_False_.gds
deleted file mode 100644
index 800aa2eb7..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_False_True_.gds
deleted file mode 100644
index e6197e826..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_True_False_.gds
deleted file mode 100644
index 8673ae06a..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_True_True_.gds
deleted file mode 100644
index bf6245545..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_False_False_.gds
deleted file mode 100644
index 5878f5543..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_False_True_.gds
deleted file mode 100644
index e30692eec..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_True_False_.gds
deleted file mode 100644
index 83aae1807..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_True_True_.gds
deleted file mode 100644
index 1b9fb8841..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_False_False_.gds
deleted file mode 100644
index 0992e8697..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_False_True_.gds
deleted file mode 100644
index 5f6cb3b3c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_True_False_.gds
deleted file mode 100644
index 2ada67c33..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_True_True_.gds
deleted file mode 100644
index e4d6c3332..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_False_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_False_False_.gds
deleted file mode 100644
index 37ed6ad70..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_False_True_.gds
deleted file mode 100644
index 7cac57045..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_True_False_.gds
deleted file mode 100644
index c7ec0088c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_True_True_.gds
deleted file mode 100644
index a8d75d03c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_False_False_.gds
deleted file mode 100644
index 2c726041f..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_False_True_.gds
deleted file mode 100644
index 078238b91..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_True_False_.gds
deleted file mode 100644
index 05deee91e..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_True_True_.gds
deleted file mode 100644
index 892a1f847..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_False_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_False_False_.gds
deleted file mode 100644
index 9bc75979c..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_False_True_.gds
deleted file mode 100644
index 48bb7fc2d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_True_False_.gds
deleted file mode 100644
index fc5698785..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_True_True_.gds
deleted file mode 100644
index 0a8981e5d..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_False_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_False_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_False_False_.gds
deleted file mode 100644
index c12b2fee1..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_False_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_False_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_False_True_.gds
deleted file mode 100644
index e59ab7421..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_False_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_True_False_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_True_False_.gds
deleted file mode 100644
index 9f1e6c3d5..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_True_False_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_True_True_.gds b/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_True_True_.gds
deleted file mode 100644
index ea0586290..000000000
Binary files a/tests/test_data/generated/test_smart_routing_True_True_True__2_True_True_True_True_True_.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_spiral.gds b/tests/test_data/generated/test_spiral.gds
deleted file mode 100644
index cd206b023..000000000
Binary files a/tests/test_data/generated/test_spiral.gds and /dev/null differ
diff --git a/tests/test_data/generated/test_to_dtype.gds b/tests/test_data/generated/test_to_dtype.gds
deleted file mode 100644
index 7968a5ae8..000000000
Binary files a/tests/test_data/generated/test_to_dtype.gds and /dev/null differ
diff --git a/tests/test_data/nxn_chiplets.gds b/tests/test_data/nxn_chiplets.gds
deleted file mode 100644
index f6f18b6aa..000000000
Binary files a/tests/test_data/nxn_chiplets.gds and /dev/null differ
diff --git a/tests/test_difftest.py b/tests/test_difftest.py
new file mode 100644
index 000000000..a18fe749d
--- /dev/null
+++ b/tests/test_difftest.py
@@ -0,0 +1,184 @@
+"""Tests for kfactory.utils.difftest module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+import kfactory as kf
+from kfactory.utils.difftest import (
+    GeometryDifferenceError,
+    diff,
+    difftest,
+    overwrite,
+    read_top_cell,
+    xor,
+)
+from tests.conftest import Layers
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+def _write_simple_cell(
+    name: str, layer: kf.kdb.LayerInfo, path: Path, box: kf.kdb.Box | None = None
+) -> kf.KCLayout:
+    """Write a small KCLayout with a top cell containing a box."""
+    kcl = kf.KCLayout(name, infos=Layers)
+    c = kcl.kcell(name)
+    c.shapes(kcl.find_layer(layer)).insert(box or kf.kdb.Box(0, 0, 1000, 500))
+    kcl.write(str(path))
+    return kcl
+
+
+def test_read_top_cell(tmp_path: Path, layers: Layers) -> None:
+    f = tmp_path / "topcell.gds"
+    _write_simple_cell("DT_RTC", layers.WG, f)
+    cell = read_top_cell(f)
+    assert isinstance(cell, kf.DKCell)
+
+
+def test_diff_identical_files(tmp_path: Path, layers: Layers) -> None:
+    f1 = tmp_path / "a.gds"
+    f2 = tmp_path / "b.gds"
+    _write_simple_cell("DT_A", layers.WG, f1)
+    _write_simple_cell("DT_A", layers.WG, f2)
+    assert diff(f1, f2, test_name="ident") is False
+
+
+def test_diff_different_files(tmp_path: Path, layers: Layers) -> None:
+    f1 = tmp_path / "a.gds"
+    f2 = tmp_path / "b.gds"
+    _write_simple_cell("DT_DA", layers.WG, f1)
+    _write_simple_cell("DT_DA", layers.WG, f2, box=kf.kdb.Box(0, 0, 2000, 500))
+    assert diff(f1, f2, test_name="different") is True
+
+
+def test_diff_different_files_no_xor(tmp_path: Path, layers: Layers) -> None:
+    f1 = tmp_path / "a.gds"
+    f2 = tmp_path / "b.gds"
+    _write_simple_cell("DT_DAX", layers.WG, f1)
+    _write_simple_cell("DT_DAX", layers.WG, f2, box=kf.kdb.Box(0, 0, 2000, 500))
+    assert diff(f1, f2, xor=False, test_name="different_no_xor") is True
+
+
+def test_diff_dbu_mismatch(tmp_path: Path) -> None:
+    f1 = tmp_path / "a.gds"
+    f2 = tmp_path / "b.gds"
+    kcl1 = kf.KCLayout("DT_DBU1", infos=Layers)
+    c1 = kcl1.kcell("DT_DBU1")
+    c1.shapes(kcl1.find_layer(Layers().WG)).insert(kf.kdb.Box(0, 0, 1000, 500))
+    kcl1.write(str(f1))
+
+    kcl2 = kf.KCLayout("DT_DBU2", infos=Layers)
+    kcl2.layout.dbu = 0.005
+    c2 = kcl2.kcell("DT_DBU2")
+    c2.shapes(kcl2.find_layer(Layers().WG)).insert(kf.kdb.Box(0, 0, 1000, 500))
+    kcl2.write(str(f2))
+
+    with pytest.raises(ValueError, match=r"dbu is different"):
+        diff(f1, f2)
+
+
+def test_xor_identical_layouts(tmp_path: Path, layers: Layers) -> None:
+    f1 = tmp_path / "a.gds"
+    f2 = tmp_path / "b.gds"
+    _write_simple_cell("DT_XI", layers.WG, f1)
+    _write_simple_cell("DT_XI", layers.WG, f2)
+    old = read_top_cell(f1)
+    new = read_top_cell(f2)
+    res = xor(old, new, test_name="ident_xor")
+    assert isinstance(res, kf.DKCell)
+    assert res.name == "xor_empty"
+
+
+def test_xor_different_layouts(tmp_path: Path, layers: Layers) -> None:
+    f1 = tmp_path / "a.gds"
+    f2 = tmp_path / "b.gds"
+    _write_simple_cell("DT_XD", layers.WG, f1)
+    _write_simple_cell("DT_XD", layers.WG, f2, box=kf.kdb.Box(0, 0, 2000, 500))
+    old = read_top_cell(f1)
+    new = read_top_cell(f2)
+    res = xor(old, new, test_name="diff_xor")
+    assert isinstance(res, kf.DKCell)
+    # difftest cell should not be the empty marker
+    assert res.name == "diff_xor_difftest"
+
+
+def test_xor_dbu_mismatch(tmp_path: Path) -> None:
+    f1 = tmp_path / "a.gds"
+    f2 = tmp_path / "b.gds"
+    kcl1 = kf.KCLayout("DT_XDBU1", infos=Layers)
+    c1 = kcl1.kcell("DT_XDBU1")
+    c1.shapes(kcl1.find_layer(Layers().WG)).insert(kf.kdb.Box(0, 0, 1000, 500))
+    kcl1.write(str(f1))
+
+    kcl2 = kf.KCLayout("DT_XDBU2", infos=Layers)
+    kcl2.layout.dbu = 0.005
+    c2 = kcl2.kcell("DT_XDBU2")
+    c2.shapes(kcl2.find_layer(Layers().WG)).insert(kf.kdb.Box(0, 0, 1000, 500))
+    kcl2.write(str(f2))
+
+    old = read_top_cell(f1)
+    new = read_top_cell(f2)
+    with pytest.raises(ValueError, match=r"dbu is different"):
+        xor(old, new)
+
+
+def test_difftest_first_run_creates_ref(tmp_path: Path, layers: Layers) -> None:
+    kcl = kf.KCLayout("DT_FT", infos=Layers)
+    c = kcl.kcell("DT_FT_TOP")
+    c.shapes(kcl.find_layer(layers.WG)).insert(kf.kdb.Box(0, 0, 1000, 500))
+
+    ref_dir = tmp_path / "ref"
+    run_dir = tmp_path / "run"
+
+    with pytest.raises(AssertionError, match="Reference GDS file"):
+        difftest(c, dirpath=ref_dir, dirpath_run=run_dir, test_name="first")
+    assert (ref_dir / "first.gds").exists()
+    assert (run_dir / "first.gds").exists()
+
+
+def test_difftest_identical(tmp_path: Path, layers: Layers) -> None:
+    kcl = kf.KCLayout("DT_FT_ID", infos=Layers)
+    c = kcl.kcell("DT_FT_ID_TOP")
+    c.shapes(kcl.find_layer(layers.WG)).insert(kf.kdb.Box(0, 0, 1000, 500))
+
+    ref_dir = tmp_path / "ref"
+    run_dir = tmp_path / "run"
+
+    # First run creates the reference and raises
+    with pytest.raises(AssertionError):
+        difftest(c, dirpath=ref_dir, dirpath_run=run_dir, test_name="ident")
+    # Second run should pass since same cell -> same gds
+    difftest(c, dirpath=ref_dir, dirpath_run=run_dir, test_name="ident")
+
+
+def test_overwrite_decline(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, layers: Layers
+) -> None:
+    ref_file = tmp_path / "ref.gds"
+    run_file = tmp_path / "run.gds"
+    _write_simple_cell("DT_OW_R", layers.WG, ref_file)
+    _write_simple_cell("DT_OW_R", layers.WG, run_file)
+
+    monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "N")
+    with pytest.raises(GeometryDifferenceError):
+        overwrite(ref_file, run_file)
+
+
+def test_overwrite_accept(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, layers: Layers
+) -> None:
+    ref_file = tmp_path / "ref.gds"
+    run_file = tmp_path / "run.gds"
+    _write_simple_cell("DT_OW_A", layers.WG, ref_file)
+    _write_simple_cell("DT_OW_A", layers.WG, run_file, box=kf.kdb.Box(0, 0, 2000, 500))
+
+    monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "Y")
+    # Even on accept, the function raises GeometryDifferenceError as a sentinel
+    with pytest.raises(GeometryDifferenceError):
+        overwrite(ref_file, run_file)
+    # The reference should have been replaced with the run file's contents
+    assert ref_file.exists()
diff --git a/tests/test_dkcell.py b/tests/test_dkcell.py
index 2a1b2a548..ad597ebe3 100644
--- a/tests/test_dkcell.py
+++ b/tests/test_dkcell.py
@@ -2,7 +2,7 @@
 import pytest
 
 import kfactory as kf
-from kfactory.cross_section import CrossSection, CrossSectionSpec, DCrossSection
+from kfactory.cross_section import CrossSection, CrossSectionSpecDict, DCrossSection
 from kfactory.exceptions import LockedError
 from tests.conftest import Layers
 
@@ -39,7 +39,7 @@ def test_dkcell_ports() -> None:
     c = kcl.dkcell("test_dkcell_ports")
     assert isinstance(c.ports, kf.DPorts)
     assert list(c.ports) == []
-    p = c.create_port(width=1, layer=1, center=(0, 0), orientation=90)
+    p = c.create_port(name="o1", width=1, layer=1, center=(0, 0), orientation=90)
     assert p in c.ports
     assert c.ports == [p]
 
@@ -58,7 +58,7 @@ def test_dkcell_locked(layers: Layers) -> None:
         cross_section=DCrossSection(
             kcl,
             base=kcl.get_symmetrical_cross_section(
-                CrossSectionSpec(layer=layers.WG, width=2000)
+                CrossSectionSpecDict(layer=layers.WG, width=2000)
             ),
         ),
         port_type="optical",
@@ -69,7 +69,7 @@ def test_dkcell_locked(layers: Layers) -> None:
         c.ports = []
 
     with pytest.raises(LockedError):
-        c.create_port(width=1, layer=1, center=(0, 0), orientation=90)
+        c.create_port(name="o1", width=1, layer=1, center=(0, 0), orientation=90)
 
     with pytest.raises(LockedError):
         c.add_port(port=p)
@@ -83,7 +83,7 @@ def test_dkcell_locked(layers: Layers) -> None:
             cross_section=CrossSection(
                 kcl,
                 base=kcl.get_symmetrical_cross_section(
-                    CrossSectionSpec(layer=layers.WG, width=2000)
+                    CrossSectionSpecDict(layer=layers.WG, width=2000)
                 ),
             ),
             port_type="optical",
diff --git a/tests/test_enclosure.py b/tests/test_enclosure.py
index c0f10a8bd..157aa451b 100644
--- a/tests/test_enclosure.py
+++ b/tests/test_enclosure.py
@@ -125,11 +125,13 @@ def test_pdkenclosure(layers: Layers, straight_blank: kf.KCell) -> None:
     c.shapes(c.kcl.find_layer(layers.WG)).insert(wg_box)
     c.shapes(c.kcl.find_layer(layers.WGCLAD)).insert(wg_box.enlarged(0, 2500))
     c.create_port(
+        name="o1",
         trans=kf.kdb.Trans(0, False, wg_box.right, 0),
         width=wg_box.height(),
         layer=c.kcl.find_layer(layers.WG),
     )
     c.create_port(
+        name="o2",
         trans=kf.kdb.Trans(2, False, wg_box.left, 0),
         width=wg_box.height(),
         layer=c.kcl.find_layer(layers.WG),
@@ -167,8 +169,6 @@ def test_pdkenclosure(layers: Layers, straight_blank: kf.KCell) -> None:
 
     port_wg_ex.merge()
 
-    c.show()
-
     assert (
         kf.kdb.Region(c.shapes(c.kcl.find_layer(layers.WGEX))) & port_wg_ex
     ).is_empty()
@@ -176,3 +176,62 @@ def test_pdkenclosure(layers: Layers, straight_blank: kf.KCell) -> None:
         (kf.kdb.Region(c.shapes(c.kcl.find_layer(layers.WGCLADEX))) & port_wg_ex)
         - port_wg_ex
     ).is_empty()
+
+
+def test_extrude_path_cross_section_symmetric_matches_legacy(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    """A symmetric cross section extrudes identically to the legacy width path."""
+    enc = kcl.get_enclosure(
+        kf.LayerEnclosure(
+            sections=[(layers.WGCLAD, 0, 2000)], main_layer=layers.WG, name="enc_eq"
+        )
+    )
+    xs = kcl.get_symmetrical_cross_section(
+        kf.SymmetricalCrossSection(width=1000, enclosure=enc, name="wg_eq")
+    )
+    path = [kf.kdb.DPoint(0, 0), kf.kdb.DPoint(10, 0), kf.kdb.DPoint(10, 10)]
+
+    c_cs = kcl.kcell("cs_extrude")
+    kf.enclosure.extrude_path_cross_section(c_cs, path, xs)
+    c_legacy = kcl.kcell("legacy_extrude")
+    kf.enclosure.extrude_path(c_legacy, layers.WG, path, kcl.to_um(1000), enc)
+
+    for layer in (layers.WG, layers.WGCLAD):
+        li = kcl.layer(layer)
+        xor = kf.kdb.Region(c_cs.shapes(li)) ^ kf.kdb.Region(c_legacy.shapes(li))
+        assert xor.is_empty()
+
+
+def test_extrude_path_cross_section_asymmetric(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    """An asymmetric cross section extrudes one signed band per strip, per layer."""
+    acs = kcl.get_asymmetrical_cross_section(
+        kf.AsymmetricalCrossSection(
+            layer=layers.WG,
+            section_min=-200,
+            section_max=300,
+            sections=(
+                kf.CrossSectionLayer(
+                    layer=layers.WGCLAD, section_min=-100, section_max=900
+                ),
+            ),
+            name="asym_extrude",
+        )
+    )
+    c = kcl.kcell("asym_extrude_cell")
+    length = 10.0
+    kf.enclosure.extrude_path_cross_section(
+        c, [kf.kdb.DPoint(0, 0), kf.kdb.DPoint(length, 0)], acs
+    )
+    length_dbu = kcl.to_dbu(length)
+
+    # main strip on WG keeps its signed offsets [-200, 300]
+    assert kf.kdb.Region(c.shapes(kcl.layer(layers.WG))).bbox() == kf.kdb.Box(
+        0, -200, length_dbu, 300
+    )
+    # aux strip on WGCLAD keeps its signed offsets [-100, 900]
+    assert kf.kdb.Region(c.shapes(kcl.layer(layers.WGCLAD))).bbox() == kf.kdb.Box(
+        0, -100, length_dbu, 900
+    )
diff --git a/tests/test_factories.py b/tests/test_factories.py
index 29f28e3fa..10927c3c7 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -2,8 +2,10 @@
 
 import pytest
 
-from kfactory import KCell, KCLayout, LayerEnclosure, factories
+from kfactory import KCell, KCLayout, LayerEnclosure, VKCell, factories
 from kfactory.cells import demo
+from kfactory.exceptions import FactoriesLockedError
+from kfactory.factories import utils
 
 from .conftest import Layers
 
@@ -30,7 +32,8 @@ def test_factory_retrieval(
     straight: Callable[..., KCell], layers: Layers, wg_enc: LayerEnclosure
 ) -> None:
     straight_ = demo.factories["straight"]
-    c = straight_(width=1000, length=10_000, layer=layers.WG, enclosure=wg_enc)
+    xs = utils.cross_section_from_width(demo, 1000, layers.WG, wg_enc)
+    c = straight_(cross_section=xs, length=10_000)
     assert isinstance(c, KCell)
 
     with pytest.raises(
@@ -41,3 +44,47 @@ def test_factory_retrieval(
         ),
     ):
         demo.factories["straights"]
+
+
+def test_factories_unlocked_by_default(kcl: KCLayout) -> None:
+    assert kcl.factories.locked is False
+    assert kcl.virtual_factories.locked is False
+    assert kcl.factories_locked is False
+
+
+def test_lock_blocks_real_factory_registration(kcl: KCLayout) -> None:
+    kcl.lock_factories()
+    assert kcl.factories_locked is True
+
+    with pytest.raises(FactoriesLockedError, match="locked_cell"):
+
+        @kcl.cell
+        def locked_cell() -> KCell:
+            return kcl.kcell()
+
+
+def test_lock_blocks_virtual_factory_registration(kcl: KCLayout) -> None:
+    kcl.lock_factories()
+
+    with pytest.raises(FactoriesLockedError, match="locked_vcell"):
+
+        @kcl.vcell
+        def locked_vcell() -> VKCell:
+            return VKCell(kcl=kcl)
+
+
+def test_lock_factories_via_collection(kcl: KCLayout) -> None:
+    kcl.factories.lock()
+    assert kcl.factories.locked is True
+    assert kcl.virtual_factories.locked is False
+    assert kcl.factories_locked is False
+
+    with pytest.raises(FactoriesLockedError):
+        factories.straight.straight_dbu_factory(kcl=kcl)
+
+
+def test_factories_can_register_before_lock(kcl: KCLayout) -> None:
+    factories.straight.straight_dbu_factory(kcl=kcl)
+    kcl.lock_factories()
+    assert "straight" in kcl.factories
+    assert kcl.factories_locked is True
diff --git a/tests/test_factories_extra.py b/tests/test_factories_extra.py
new file mode 100644
index 000000000..6a0d3fae6
--- /dev/null
+++ b/tests/test_factories_extra.py
@@ -0,0 +1,115 @@
+"""Extra factory tests targeting coverage in bezier and virtual factories."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+import pytest
+
+import kfactory as kf
+from kfactory.factories.bezier import bend_s_bezier_factory, bezier_curve
+from kfactory.factories.virtual.circular import virtual_bend_circular_factory
+
+if TYPE_CHECKING:
+    from tests.conftest import Layers
+
+
+def test_bezier_curve_2_points() -> None:
+    pts = bezier_curve(
+        t=np.linspace(0, 1, 5),
+        control_points=[(0.0, 0.0), (10.0, 5.0)],
+    )
+    assert len(pts) == 5
+    assert pts[0].x == pytest.approx(0.0)
+    assert pts[-1].x == pytest.approx(10.0)
+
+
+def test_bezier_curve_cubic() -> None:
+    pts = bezier_curve(
+        t=np.linspace(0, 1, 50),
+        control_points=[(0.0, 0.0), (5.0, 0.0), (5.0, 10.0), (10.0, 10.0)],
+    )
+    assert len(pts) == 50
+    assert pts[0].x == pytest.approx(0.0)
+    assert pts[-1].x == pytest.approx(10.0)
+
+
+def test_bend_s_bezier_basic(kcl: kf.KCLayout, layers: Layers) -> None:
+    factory = bend_s_bezier_factory(kcl=kcl)
+    c = factory(width=0.5, height=2.0, length=10.0, layer=layers.WG)
+    assert isinstance(c, kf.KCell)
+    assert len(c.ports) >= 2
+
+
+def test_bend_s_bezier_with_enclosure(kcl: kf.KCLayout, layers: Layers) -> None:
+    enc = kf.LayerEnclosure(
+        [(layers.WGCLAD, 1000)], main_layer=layers.WG, kcl=kcl, name="benc"
+    )
+    factory = bend_s_bezier_factory(kcl=kcl)
+    c = factory(width=0.5, height=2.0, length=10.0, layer=layers.WG, enclosure=enc)
+    assert isinstance(c, kf.KCell)
+
+
+def test_bend_s_bezier_with_static_info(kcl: kf.KCLayout, layers: Layers) -> None:
+    factory = bend_s_bezier_factory(kcl=kcl, additional_info={"static": "val"})
+    c = factory(width=0.5, height=1.0, length=8.0, layer=layers.WG)
+    assert c.info["static"] == "val"
+
+
+def test_bend_s_bezier_with_callable_info(kcl: kf.KCLayout, layers: Layers) -> None:
+    def info_func(**kwargs: object) -> dict[str, object]:
+        xs = kwargs["cross_section"]
+        return {"computed_width": xs.width}  # ty:ignore[unresolved-attribute]
+
+    factory = bend_s_bezier_factory(kcl=kcl, additional_info=info_func)  # ty:ignore[invalid-argument-type]
+    c = factory(width=0.5, height=1.0, length=8.0, layer=layers.WG)
+    assert c.info["computed_width"] == 500
+
+
+def test_virtual_bend_circular_basic(kcl: kf.KCLayout, layers: Layers) -> None:
+    factory = virtual_bend_circular_factory(kcl=kcl)
+    c = factory(width=0.5, radius=10.0, layer=layers.WG)
+    assert isinstance(c, kf.VKCell)
+
+
+def test_virtual_bend_circular_negative_angle(kcl: kf.KCLayout, layers: Layers) -> None:
+    factory = virtual_bend_circular_factory(kcl=kcl)
+    # negative angle gets flipped positive
+    c = factory(width=0.5, radius=10.0, layer=layers.WG, angle=-90)
+    assert isinstance(c, kf.VKCell)
+
+
+def test_virtual_bend_circular_negative_width(kcl: kf.KCLayout, layers: Layers) -> None:
+    factory = virtual_bend_circular_factory(kcl=kcl)
+    # negative width gets flipped positive
+    c = factory(width=-0.5, radius=10.0, layer=layers.WG, angle=90)
+    assert isinstance(c, kf.VKCell)
+
+
+def test_virtual_bend_circular_with_enclosure(kcl: kf.KCLayout, layers: Layers) -> None:
+    enc = kf.LayerEnclosure(
+        [(layers.WGCLAD, 1000)], main_layer=layers.WG, kcl=kcl, name="vbenc"
+    )
+    factory = virtual_bend_circular_factory(kcl=kcl)
+    c = factory(width=0.5, radius=10.0, layer=layers.WG, enclosure=enc)
+    assert isinstance(c, kf.VKCell)
+
+
+def test_virtual_bend_circular_with_static_info(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    factory = virtual_bend_circular_factory(kcl=kcl, additional_info={"k": "v"})
+    c = factory(width=0.5, radius=10.0, layer=layers.WG)
+    assert c.info["k"] == "v"
+
+
+def test_virtual_bend_circular_with_callable_info(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    def info_func(**kwargs: object) -> dict[str, object]:
+        return {"rad": kwargs["radius"]}
+
+    factory = virtual_bend_circular_factory(kcl=kcl, additional_info=info_func)  # ty:ignore[invalid-argument-type]
+    c = factory(width=0.5, radius=10.0, layer=layers.WG)
+    assert c.info["rad"] == 10.0
diff --git a/tests/test_factories_utils.py b/tests/test_factories_utils.py
new file mode 100644
index 000000000..53714c25f
--- /dev/null
+++ b/tests/test_factories_utils.py
@@ -0,0 +1,143 @@
+"""Tests for kfactory.factories.utils module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import kfactory as kf
+from kfactory.factories.utils import (
+    _is_additional_info_func,
+    extrude_backbone,
+    extrude_backbone_dynamic,
+)
+
+if TYPE_CHECKING:
+    from tests.conftest import Layers
+
+
+def test_is_additional_info_func_callable() -> None:
+    def f() -> dict[str, str]:
+        return {}
+
+    assert _is_additional_info_func(f) is True  # ty:ignore[invalid-argument-type]
+
+
+def test_is_additional_info_func_dict() -> None:
+    assert _is_additional_info_func({"a": 1}) is False
+
+
+def test_is_additional_info_func_none() -> None:
+    assert _is_additional_info_func(None) is False
+
+
+def _make_backbone() -> list[kf.kdb.DPoint]:
+    return [kf.kdb.DPoint(0, 0), kf.kdb.DPoint(10, 0), kf.kdb.DPoint(20, 0)]
+
+
+def test_extrude_backbone_no_enclosure(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell("eb_no_enc")
+    extrude_backbone(
+        c,
+        backbone=_make_backbone(),
+        width=1.0,
+        layer=layers.WG,
+        start_angle=0,
+        end_angle=0,
+        dbu=c.kcl.dbu,
+    )
+    # We at least inserted shapes for the main layer
+    assert len(list(c.shapes(c.kcl.layer(layers.WG)).each())) >= 1
+
+
+def test_extrude_backbone_with_enclosure_dmax_only(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    enc = kf.LayerEnclosure([(layers.WGCLAD, 1000)], main_layer=layers.WG, kcl=kcl)
+    c = kcl.vkcell("eb_with_enc_dmax")
+    extrude_backbone(
+        c,
+        backbone=_make_backbone(),
+        width=1.0,
+        layer=layers.WG,
+        start_angle=0,
+        end_angle=0,
+        dbu=c.kcl.dbu,
+        enclosure=enc,
+    )
+    # main + enclosure layer should have shapes
+    assert len(list(c.shapes(c.kcl.layer(layers.WG)).each())) >= 1
+    assert len(list(c.shapes(c.kcl.layer(layers.WGCLAD)).each())) >= 1
+
+
+def test_extrude_backbone_with_enclosure_dmin_dmax(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    enc = kf.LayerEnclosure([(layers.WGCLAD, 500, 1000)], main_layer=layers.WG, kcl=kcl)
+    c = kcl.vkcell("eb_with_enc_dmin_dmax")
+    extrude_backbone(
+        c,
+        backbone=_make_backbone(),
+        width=1.0,
+        layer=layers.WG,
+        start_angle=0,
+        end_angle=0,
+        dbu=c.kcl.dbu,
+        enclosure=enc,
+    )
+    # the enclosure layer should have two polygons (one per side)
+    assert len(list(c.shapes(c.kcl.layer(layers.WGCLAD)).each())) >= 2
+
+
+def test_extrude_backbone_dynamic_no_enclosure(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    c = kcl.vkcell("ebd_no_enc")
+    extrude_backbone_dynamic(
+        c,
+        backbone=_make_backbone(),
+        width1=1.0,
+        width2=2.0,
+        layer=layers.WG,
+        start_angle=0,
+        end_angle=0,
+        dbu=c.kcl.dbu,
+    )
+    assert len(list(c.shapes(c.kcl.layer(layers.WG)).each())) >= 1
+
+
+def test_extrude_backbone_dynamic_with_enclosure_dmax(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    enc = kf.LayerEnclosure([(layers.WGCLAD, 1000)], main_layer=layers.WG, kcl=kcl)
+    c = kcl.vkcell("ebd_with_enc_dmax")
+    extrude_backbone_dynamic(
+        c,
+        backbone=_make_backbone(),
+        width1=1.0,
+        width2=2.0,
+        layer=layers.WG,
+        start_angle=0,
+        end_angle=0,
+        dbu=c.kcl.dbu,
+        enclosure=enc,
+    )
+    assert len(list(c.shapes(c.kcl.layer(layers.WGCLAD)).each())) >= 1
+
+
+def test_extrude_backbone_dynamic_with_enclosure_dmin_dmax(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    enc = kf.LayerEnclosure([(layers.WGCLAD, 500, 1000)], main_layer=layers.WG, kcl=kcl)
+    c = kcl.vkcell("ebd_with_enc_dmin_dmax")
+    extrude_backbone_dynamic(
+        c,
+        backbone=_make_backbone(),
+        width1=1.0,
+        width2=2.0,
+        layer=layers.WG,
+        start_angle=0,
+        end_angle=0,
+        dbu=c.kcl.dbu,
+        enclosure=enc,
+    )
+    assert len(list(c.shapes(c.kcl.layer(layers.WGCLAD)).each())) >= 2
diff --git a/tests/test_fill.py b/tests/test_fill.py
index 39007ac80..1238b8a00 100644
--- a/tests/test_fill.py
+++ b/tests/test_fill.py
@@ -8,7 +8,7 @@
 def test_tiled_fill_space(
     fill_cell: kf.KCell,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -39,7 +39,7 @@ def test_tiled_fill_space(
 def test_tiled_fill_vector(
     fill_cell: kf.KCell,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
diff --git a/tests/test_fill_extra.py b/tests/test_fill_extra.py
new file mode 100644
index 000000000..05b3a1a2f
--- /dev/null
+++ b/tests/test_fill_extra.py
@@ -0,0 +1,185 @@
+"""Extra tests for kfactory.utils.fill module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import kfactory as kf
+from kfactory.utils.fill import (
+    SparseFillOperator,
+    _get_coverage,
+    _get_placed_fc,
+    fill_tiled,
+)
+
+if TYPE_CHECKING:
+    from tests.conftest import Layers
+
+
+def _make_target(kcl: kf.KCLayout, layers: Layers, name: str) -> kf.KCell:
+    c = kcl.kcell(name)
+    c.shapes(layers.WG).insert(kf.kdb.DPolygon.ellipse(kf.kdb.DBox(5000, 3000), 64))
+    c.shapes(layers.WGCLAD).insert(
+        kf.kdb.DPolygon(
+            [
+                kf.kdb.DPoint(0, 0),
+                kf.kdb.DPoint(5000, 0),
+                kf.kdb.DPoint(5000, 3000),
+            ]
+        )
+    )
+    return c
+
+
+def test_fill_tiled_multi(
+    fill_cell: kf.KCell, layers: Layers, kcl: kf.KCLayout
+) -> None:
+    """Exercise fill_tiled with the multi=True codepath."""
+    c = _make_target(kcl, layers, "fill_tiled_multi")
+    fill_tiled(
+        c,
+        fill_cell,
+        [(layers.WG, 0)],
+        exclude_layers=[(layers.WGCLAD, 0)],
+        x_space=5,
+        y_space=5,
+        multi=True,
+    )
+
+
+def test_fill_tiled_with_fill_regions(
+    fill_cell: kf.KCell, layers: Layers, kcl: kf.KCLayout
+) -> None:
+    """Provide an explicit fill_regions input."""
+    c = _make_target(kcl, layers, "fill_tiled_fill_regions")
+    region = kf.kdb.Region(kf.kdb.Box(0, 0, 4_000, 2_000))
+    fill_tiled(
+        c,
+        fill_cell,
+        fill_regions=[(region, 0.0)],
+        exclude_layers=[(layers.WGCLAD, 0)],
+        x_space=5,
+        y_space=5,
+    )
+
+
+def test_fill_tiled_with_exclude_regions(
+    fill_cell: kf.KCell, layers: Layers, kcl: kf.KCLayout
+) -> None:
+    """Provide an explicit exclude_regions input alongside layers."""
+    c = _make_target(kcl, layers, "fill_tiled_exclude_regions")
+    exclude = kf.kdb.Region(kf.kdb.Box(0, 0, 1_000, 1_000))
+    fill_tiled(
+        c,
+        fill_cell,
+        [(layers.WG, 0)],
+        exclude_layers=[(layers.WGCLAD, 0)],
+        exclude_regions=[(exclude, 0.0)],
+        x_space=5,
+        y_space=5,
+    )
+
+
+def test_fill_tiled_no_excludes(
+    fill_cell: kf.KCell, layers: Layers, kcl: kf.KCLayout
+) -> None:
+    """Exercise the queue_str branch where there are no excludes."""
+    c = _make_target(kcl, layers, "fill_tiled_no_excludes")
+    fill_tiled(c, fill_cell, [(layers.WG, 0)], x_space=5, y_space=5)
+
+
+def test_fill_tiled_with_fill_regions_and_layers(
+    fill_cell: kf.KCell, layers: Layers, kcl: kf.KCLayout
+) -> None:
+    """Both fill_layers and fill_regions cover the `layers + regions` branch."""
+    c = _make_target(kcl, layers, "fill_tiled_layers_and_regions")
+    region = kf.kdb.Region(kf.kdb.Box(0, 0, 2_000, 1_000))
+    fill_tiled(
+        c,
+        fill_cell,
+        [(layers.WG, 0)],
+        fill_regions=[(region, 0.0)],
+        exclude_layers=[(layers.WGCLAD, 0)],
+        x_space=5,
+        y_space=5,
+    )
+
+
+def test_sparse_fill_operator_put() -> None:
+    op = SparseFillOperator()
+    region = kf.kdb.Region(kf.kdb.Box(0, 0, 100, 100))
+    op.put(0, 0, kf.kdb.Box(0, 0, 200, 200), region, dbu=0.001, clip=False)
+    assert not op.f_region.is_empty()
+
+
+def test_get_placed_fc_empty(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell("gpf_empty")
+    fill_cell = kcl.kcell("fc_for_gpf_empty")
+    fill_cell.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 100, 100))
+    pts = _get_placed_fc(parent, fill_cell.cell_index())
+    assert pts == set()
+
+
+def test_get_placed_fc_with_instances(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell("gpf_with_inst")
+    fill_cell = kcl.kcell("fc_for_gpf_with_inst")
+    fill_cell.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 100, 100))
+    parent.create_inst(fill_cell, trans=kf.kdb.Trans(1_000, 2_000))
+    parent.create_inst(fill_cell, trans=kf.kdb.Trans(3_000, 4_000))
+    pts = _get_placed_fc(parent, fill_cell.cell_index())
+    assert kf.kdb.Point(1_000, 2_000) in pts
+    assert kf.kdb.Point(3_000, 4_000) in pts
+
+
+def test_get_coverage(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell("gc_with_inst")
+    fill_cell = kcl.kcell("fc_for_gc_with_inst")
+    fill_cell.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 100, 100))
+    parent.create_inst(fill_cell, trans=kf.kdb.Trans(1_000, 2_000))
+    coverage = _get_coverage(parent, fill_cell.cell_index(), margin=200)
+    assert not coverage.is_empty()
+
+
+def test_cover_basic(layers: Layers, kcl: kf.KCLayout) -> None:
+    """Call cover() directly with bounded inputs to exercise its body."""
+    from kfactory.utils.fill import cover
+
+    top = kcl.kcell("cover_basic")
+    fill = kcl.kcell("cover_basic_fill")
+    fill.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 100, 100))
+
+    # placement_region is small enough that the while loop terminates fast
+    placement = kf.kdb.Region(kf.kdb.Box(0, 0, 600, 600))
+    cover_r = kf.kdb.Region(kf.kdb.Box(0, 0, 600, 600))
+
+    cover(
+        top_cell=top,
+        fill_cell=fill,
+        margin=200,
+        placement_region=placement,
+        cover_region=cover_r,
+        fc_bbox_sizing=(50, 25),
+    )
+    # The while loop should have terminated and the cell has placed at least one inst
+    # (or none — both acceptable). What matters is that cover() returned.
+
+
+def test_cover_warns_when_sizing_inverted(layers: Layers, kcl: kf.KCLayout) -> None:
+    """Exercise the warning branch where sizing[1] > sizing[0]."""
+    from kfactory.utils.fill import cover
+
+    top = kcl.kcell("cover_inv_sizing")
+    fill = kcl.kcell("cover_inv_sizing_fill")
+    fill.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 100, 100))
+
+    placement = kf.kdb.Region(kf.kdb.Box(0, 0, 600, 600))
+    cover_r = kf.kdb.Region(kf.kdb.Box(0, 0, 600, 600))
+
+    cover(
+        top_cell=top,
+        fill_cell=fill,
+        margin=200,
+        placement_region=placement,
+        cover_region=cover_r,
+        fc_bbox_sizing=(25, 50),  # inverted: 2nd > 1st triggers warning
+    )
diff --git a/tests/test_gdsfactory.py b/tests/test_gdsfactory.py
index 4d0ac4579..83b57a933 100644
--- a/tests/test_gdsfactory.py
+++ b/tests/test_gdsfactory.py
@@ -1,5 +1,6 @@
+from collections.abc import Callable, Sequence
 from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
 
 import pytest
 from ruamel.yaml import YAML
@@ -9,11 +10,47 @@
 if TYPE_CHECKING:
     from collections.abc import Callable
 
-gf = pytest.importorskip("gdsfactory")
+    import gdsfactory as gf  # ty: ignore[unresolved-import, unused-ignore-comment, unused-ignore-comment]
+else:
+    gf = pytest.importorskip("gdsfactory")
 jinja2 = pytest.importorskip("jinja2")
 gf_factories = pytest.importorskip("gdsfactory.routing.factories")
+
+
+gf.gpdk.PDK.activate()
+
+
+def _wrap_routing_strategy(
+    rs: Callable[
+        ...,
+        Sequence[
+            kf.routing.optical.ManhattanRoute
+            | kf.routing.aa.optical.OpticalAllAngleRoute
+        ],
+    ],
+) -> Callable[
+    ...,
+    Sequence[
+        kf.routing.optical.ManhattanRoute | kf.routing.aa.optical.OpticalAllAngleRoute
+    ],
+]:
+    def new_route(
+        c: gf.Component, ports: Sequence[tuple[gf.Port, ...]], /, **settings: Any
+    ) -> Sequence[
+        kf.routing.optical.ManhattanRoute | kf.routing.aa.optical.OpticalAllAngleRoute
+    ]:
+        ports1: list[gf.Port] = []
+        ports2: list[gf.Port] = []
+        for port_tuple in ports:
+            ports1.append(port_tuple[0])
+            ports2.append(port_tuple[1])
+        return rs(c, ports1, ports2, **settings)
+
+    return new_route
+
+
 # Find all YAML files
-yaml_dir = Path(__file__).parent / "gdsfactory-yaml-pics" / "notebooks" / "yaml_pics"
+yaml_dir = Path(__file__).parent / "gdsfactory-yaml-pics/docs/notebooks/yaml_pics"
 yaml_files = sorted(yaml_dir.glob("**/*.pic.yml"))
 skip_files = [
     "aar_bundles02",
@@ -178,9 +215,14 @@ def test_gdsfactory_yaml_build(path: Path) -> None:
     schematic.create_cell(
         output_type=gf.Component,
         factories=factories,
-        routing_strategies=pdk.routing_strategies or gf_factories.routing_strategies,
+        routing_strategies={
+            name: _wrap_routing_strategy(route)
+            for name, route in (
+                pdk.routing_strategies or gf_factories.routing_strategies
+            ).items()
+        },
         place_unknown=True,
-    ).show()
+    )
     print(schematic.code_str())  # noqa: T201
 
 
@@ -194,6 +236,11 @@ def test_gdsfactory_yaml_samples(sample: str) -> None:
     schematic.create_cell(
         output_type=gf.Component,
         factories=factories,
-        routing_strategies=pdk.routing_strategies or gf_factories.routing_strategies,
+        routing_strategies={
+            name: _wrap_routing_strategy(route)
+            for name, route in (
+                pdk.routing_strategies or gf_factories.routing_strategies
+            ).items()
+        },
         place_unknown=True,
     )
diff --git a/tests/test_generic_factories.py b/tests/test_generic_factories.py
new file mode 100644
index 000000000..e2bd81e8e
--- /dev/null
+++ b/tests/test_generic_factories.py
@@ -0,0 +1,78 @@
+"""Tests for `KCLayout.generic_factories` and the `@kcl.generic_factory` decorator."""
+
+import pytest
+
+import kfactory as kf
+from tests.conftest import Layers
+
+
+def test_generic_factory_registration_and_delegation(
+    kcl: kf.KCLayout, layers: Layers, wg_enc: kf.LayerEnclosure
+) -> None:
+    real_straight = kf.factories.straight.straight_dbu_factory(kcl=kcl)
+
+    @kcl.generic_factory
+    def my_straight(length: int) -> kf.KCell:
+        return real_straight(
+            width=500, length=length, layer=layers.WG, enclosure=wg_enc
+        )
+
+    # Registered under the function name and retrievable.
+    assert "my_straight" in kcl.generic_factories
+    assert kcl.generic_factories["my_straight"] is my_straight
+
+    # Delegation works: calling forwards to the cached real factory.
+    c = kcl.generic_factories["my_straight"](length=10_000)
+    assert isinstance(c, kf.KCell)
+    assert c.kcl is kcl
+    # Same args -> cached cell from the underlying factory.
+    assert my_straight(length=10_000) is c
+
+
+def test_generic_factory_guardrail_rejects_foreign_kcl(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    # The global default layout is a *different* layout than the fixture `kcl`.
+    other_straight = kf.factories.straight.straight_dbu_factory(kcl=kf.kcl)
+
+    @kcl.generic_factory
+    def foreign(length: int) -> kf.KCell:
+        # Builds a cell owned by `kf.kcl`, not by `kcl`.
+        return other_straight(width=500, length=length, layer=layers.WG)
+
+    with pytest.raises(ValueError, match="returned a cell from KCLayout"):
+        foreign(length=10_000)
+
+
+def test_generic_factories_independent_of_other_registries(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    real_straight = kf.factories.straight.straight_dbu_factory(kcl=kcl)
+
+    @kcl.generic_factory
+    def only_generic(length: int) -> kf.KCell:
+        return real_straight(width=500, length=length, layer=layers.WG)
+
+    assert "only_generic" in kcl.generic_factories
+    assert "only_generic" not in kcl.factories
+    assert "only_generic" not in kcl.virtual_factories
+
+
+def test_generic_factory_custom_name(kcl: kf.KCLayout, layers: Layers) -> None:
+    real_straight = kf.factories.straight.straight_dbu_factory(kcl=kcl)
+
+    # Decorator-with-name form.
+    @kcl.generic_factory(name="wg")
+    def my_straight(length: int) -> kf.KCell:
+        return real_straight(width=500, length=length, layer=layers.WG)
+
+    assert "wg" in kcl.generic_factories
+    assert "my_straight" not in kcl.generic_factories
+    assert isinstance(kcl.generic_factories["wg"](length=10_000), kf.KCell)
+
+    # Direct-call form with a name.
+    def another(length: int) -> kf.KCell:
+        return real_straight(width=500, length=length, layer=layers.WG)
+
+    kcl.generic_factory(another, name="wg2")
+    assert "wg2" in kcl.generic_factories
diff --git a/tests/test_grid.py b/tests/test_grid.py
index 615dc6700..ad06dceb2 100644
--- a/tests/test_grid.py
+++ b/tests/test_grid.py
@@ -6,7 +6,7 @@
 
 def test_grid_dbu_1d(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -30,12 +30,12 @@ def test_grid_dbu_1d(
         spacing=5000,
         align_x="origin",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_dbu_2d(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     """generated with
@@ -83,12 +83,12 @@ def test_grid_dbu_2d(
         spacing=5000,
         align_x="origin",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_dbu_2d_uneven(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     """generated with
@@ -204,12 +204,12 @@ def test_grid_dbu_2d_uneven(
         align_x="xmin",
         align_y="ymax",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_dbu_2d_rotation(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -253,12 +253,12 @@ def test_grid_dbu_2d_rotation(
         align_x="xmin",
         align_y="ymin",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_dbu_1d_shape(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -284,12 +284,12 @@ def test_grid_dbu_1d_shape(
         align_x="origin",
         shape=(1, 10),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_dbu_2d_shape(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -332,12 +332,12 @@ def test_grid_dbu_2d_shape(
         align_x="origin",
         shape=(4, 5),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_dbu_2d_shape_rotation(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -381,12 +381,12 @@ def test_grid_dbu_2d_shape_rotation(
         align_x="origin",
         shape=(3, 7),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_1d(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -411,12 +411,12 @@ def test_grid_1d(
         spacing=5000,
         align_x="origin",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_2d(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -458,12 +458,12 @@ def test_grid_2d(
         spacing=5000,
         align_x="origin",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_2d_uneven(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -573,12 +573,12 @@ def test_grid_2d_uneven(
         align_x="xmin",
         align_y="ymax",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_2d_rotation(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -622,12 +622,12 @@ def test_grid_2d_rotation(
         align_x="xmin",
         align_y="ymin",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_1d_shape(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -670,12 +670,12 @@ def test_grid_1d_shape(
         align_x="origin",
         shape=(2, 10),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_2d_shape(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -718,12 +718,12 @@ def test_grid_2d_shape(
         align_x="origin",
         shape=(4, 5),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_grid_2d_shape_rotation(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -767,12 +767,12 @@ def test_grid_2d_shape_rotation(
         align_x="origin",
         shape=(3, 7),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_flexgrid_dbu_1d(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -797,12 +797,12 @@ def test_flexgrid_dbu_1d(
         spacing=5000,
         align_x="origin",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_flexgrid_dbu_2d(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -844,12 +844,12 @@ def test_flexgrid_dbu_2d(
         spacing=5000,
         align_x="origin",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_flexgrid_dbu_2d_rotation(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -892,12 +892,12 @@ def test_flexgrid_dbu_2d_rotation(
         spacing=5000,
         align_x="center",
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_flexgrid_dbu_1d_shape(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -923,12 +923,12 @@ def test_flexgrid_dbu_1d_shape(
         align_x="origin",
         shape=(1, 10),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_flexgrid_dbu_2d_shape(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -971,12 +971,12 @@ def test_flexgrid_dbu_2d_shape(
         align_x="origin",
         shape=(4, 5),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_flexgrid_dbu_2d_shape_rotation(
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -1021,12 +1021,12 @@ def test_flexgrid_dbu_2d_shape_rotation(
         shape=(3, 7),
     )
 
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_flexgrid_2d_shape_rotation(
     straight_factory: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.dkcell()
@@ -1073,4 +1073,4 @@ def test_flexgrid_2d_shape_rotation(
         shape=(3, 7),
         target_trans=kf.kdb.DCplxTrans(1, 37, False, 0, 0),
     )
-    gds_regression(c)
+    oas_regression(c)
diff --git a/tests/test_instance.py b/tests/test_instance.py
index 89d9fc32c..5237c80f1 100644
--- a/tests/test_instance.py
+++ b/tests/test_instance.py
@@ -1,5 +1,5 @@
 from collections.abc import Callable
-from typing import Any, TypeAlias
+from typing import Any
 
 import klayout.db as kdb
 import pytest
@@ -169,12 +169,12 @@ def _instances_equal(
     )
 
 
-_DBUInstanceTuple: TypeAlias = tuple[
+type _DBUInstanceTuple = tuple[
     kf.instance.Instance, kf.instance.Instance, kf.instance.Instance
 ]
 
 
-_UMInstanceTuple: TypeAlias = tuple[
+type _UMInstanceTuple = tuple[
     kf.instance.DInstance, kf.instance.DInstance, kf.instance.DInstance
 ]
 
@@ -635,7 +635,7 @@ def test_vinstance_errors(kcl: kf.KCLayout, layers: Layers) -> None:
     with pytest.raises(exceptions.PortTypeMismatchError):
         ref.connect("o1", ref4.ports["o1"])
     with pytest.raises(ValueError):
-        ref.connect("o1", ref5)  # type: ignore[call-overload]
+        ref.connect("o1", ref5)  # ty:ignore[invalid-argument-type]
 
 
 def test_mirror_y_default_arg(dbu_instance_tuple: _DBUInstanceTuple) -> None:
diff --git a/tests/test_instance_group.py b/tests/test_instance_group.py
index 0942026c4..33aea341a 100644
--- a/tests/test_instance_group.py
+++ b/tests/test_instance_group.py
@@ -1,5 +1,3 @@
-from typing import TypeAlias
-
 import pytest
 
 import kfactory as kf
@@ -12,9 +10,7 @@ def _instances_equal(instance1: kf.Instance, instance2: kf.Instance) -> bool:
     )
 
 
-_InstanceGroupTuple: TypeAlias = tuple[
-    kf.InstanceGroup, kf.InstanceGroup, kf.InstanceGroup
-]
+type _InstanceGroupTuple = tuple[kf.InstanceGroup, kf.InstanceGroup, kf.InstanceGroup]
 
 
 def _instance_group_equal(
@@ -212,7 +208,7 @@ def test_instance_group_kcl(kcl: kf.KCLayout) -> None:
         _ = instance_group.kcl
 
     with pytest.raises(ValueError):
-        instance_group.kcl = kcl
+        instance_group.kcl = kcl  # ty:ignore[invalid-assignment]
 
 
 def test_instnace_group_iter(
diff --git a/tests/test_instance_group_extra.py b/tests/test_instance_group_extra.py
new file mode 100644
index 000000000..a7d09fd41
--- /dev/null
+++ b/tests/test_instance_group_extra.py
@@ -0,0 +1,249 @@
+"""Extra tests targeting instance_group.py coverage."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+import kfactory as kf
+from kfactory.exceptions import (
+    PortLayerMismatchError,
+    PortTypeMismatchError,
+    PortWidthMismatchError,
+)
+
+if TYPE_CHECKING:
+    from tests.conftest import Layers
+
+
+def _make_cell(
+    kcl: kf.KCLayout, layers: Layers, width: int = 500, port_type: str = "optical"
+) -> kf.KCell:
+    c = kcl.kcell()
+    c.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 1000, 1000))
+    c.create_port(
+        name="o1",
+        trans=kf.kdb.Trans(0, False, 0, 500),
+        width=width,
+        layer=kcl.find_layer(layers.WG),
+        port_type=port_type,
+    )
+    c.create_port(
+        name="o2",
+        trans=kf.kdb.Trans(2, False, 1000, 500),
+        width=width,
+        layer=kcl.find_layer(layers.WG),
+        port_type=port_type,
+    )
+    return c
+
+
+def test_instance_group_add(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    g = kf.InstanceGroup()
+    g.add(parent << c)
+    g.add(parent << c)
+    assert len(g.insts) == 2
+
+
+def test_instance_group_dadd(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.dkcell()
+    c = _make_cell(kcl, layers)
+    g = kf.DInstanceGroup()
+    g.add(parent << c.to_dtype())
+    assert len(g.insts) == 1
+
+
+def test_instance_group_add_port(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst = parent << c
+    g = kf.InstanceGroup(insts=[inst])
+    g.add_port(port=inst.ports[0], name="p1")
+    assert "p1" in [p.name for p in g.ports]
+
+
+def test_dinstance_group_add_port(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.dkcell()
+    c = _make_cell(kcl, layers)
+    inst = parent << c.to_dtype()
+    g = kf.DInstanceGroup(insts=[inst])
+    g.add_port(port=inst.ports[0], name="p1")
+    assert "p1" in [p.name for p in g.ports]
+
+
+def test_instance_group_connect_to_port(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    inst2 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    inst2.transform(kf.kdb.Trans(0, False, 5000, 0))
+    # Connect using the group's own port to inst2's port
+    g.connect(port="g_port", other=inst2, other_port_name="o2")
+
+
+def test_instance_group_connect_port_to_port(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    inst2 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    inst2.transform(kf.kdb.Trans(0, False, 5000, 0))
+    # Connect using ports directly
+    g.connect(port=g.ports["g_port"], other=inst2.ports["o2"])
+
+
+def test_instance_group_connect_other_port_name_none_raises(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    inst2 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    with pytest.raises(ValueError, match="portname cannot be None"):
+        g.connect(port="g_port", other=inst2, other_port_name=None)
+
+
+def test_instance_group_connect_width_mismatch_raises(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    parent = kcl.kcell()
+    c1 = _make_cell(kcl, layers, width=500)
+    c2 = _make_cell(kcl, layers, width=1000)
+    inst1 = parent << c1
+    inst2 = parent << c2
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    with pytest.raises(PortWidthMismatchError):
+        g.connect(
+            port="g_port",
+            other=inst2,
+            other_port_name="o2",
+            allow_width_mismatch=False,
+        )
+
+
+def test_instance_group_connect_type_mismatch_raises(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    parent = kcl.kcell()
+    c1 = _make_cell(kcl, layers, port_type="optical")
+    c2 = _make_cell(kcl, layers, port_type="electrical")
+    inst1 = parent << c1
+    inst2 = parent << c2
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    with pytest.raises(PortTypeMismatchError):
+        g.connect(
+            port="g_port",
+            other=inst2,
+            other_port_name="o2",
+            allow_type_mismatch=False,
+        )
+
+
+def test_instance_group_connect_layer_mismatch_raises(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    parent = kcl.kcell()
+    c1 = _make_cell(kcl, layers)
+    c2 = kcl.kcell()
+    c2.shapes(layers.METAL1).insert(kf.kdb.Box(0, 0, 1000, 1000))
+    c2.create_port(
+        name="o1",
+        trans=kf.kdb.Trans(0, False, 0, 500),
+        width=500,
+        layer=kcl.find_layer(layers.METAL1),
+    )
+    inst1 = parent << c1
+    inst2 = parent << c2
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    with pytest.raises(PortLayerMismatchError):
+        g.connect(
+            port="g_port",
+            other=inst2,
+            other_port_name="o1",
+            allow_layer_mismatch=False,
+        )
+
+
+def test_instance_group_connect_use_mirror_false(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    inst2 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    inst2.transform(kf.kdb.Trans(0, False, 5000, 0))
+    g.connect(port="g_port", other=inst2, other_port_name="o2", use_mirror=False)
+
+
+def test_instance_group_connect_use_angle_false(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    inst2 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    inst2.transform(kf.kdb.Trans(0, False, 5000, 0))
+    g.connect(
+        port="g_port",
+        other=inst2,
+        other_port_name="o2",
+        use_mirror=False,
+        use_angle=False,
+    )
+
+
+def test_instance_group_name(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    assert "InstanceGroup" in g.name
+    assert "InstanceGroup" in str(g)
+
+
+def test_instance_group_transform_dtrans(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    g.transform(kf.kdb.DTrans(0.0, False, 1.0, 1.0))  # ty:ignore[invalid-argument-type]
+
+
+def test_instance_group_transform_icplx(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    g.add_port(port=inst1.ports["o1"], name="g_port")
+    g.transform(kf.kdb.ICplxTrans.R90)
+
+
+def test_empty_instance_group_kcl_raises(kcl: kf.KCLayout) -> None:
+    g = kf.InstanceGroup()
+    with pytest.raises(ValueError, match="empty"):
+        _ = g.kcl
+
+
+def test_instance_group_kcl_setter_raises(kcl: kf.KCLayout, layers: Layers) -> None:
+    parent = kcl.kcell()
+    c = _make_cell(kcl, layers)
+    inst1 = parent << c
+    g = kf.InstanceGroup(insts=[inst1])
+    with pytest.raises(ValueError, match="KCLayout cannot be set"):
+        g.kcl = kcl  # ty:ignore[invalid-assignment]
diff --git a/tests/test_instances_extra.py b/tests/test_instances_extra.py
new file mode 100644
index 000000000..b1bc08898
--- /dev/null
+++ b/tests/test_instances_extra.py
@@ -0,0 +1,186 @@
+"""Extra tests for instances.py covering missing branches."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+import kfactory as kf
+
+if TYPE_CHECKING:
+    from tests.conftest import Layers
+
+
+def _straight(kcl: kf.KCLayout, layers: Layers) -> kf.KCell:
+    return kf.cells.straight.straight(width=0.5, length=1, layer=layers.WG)
+
+
+def test_instances_repr_str(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    _ = c << _straight(kcl, layers)
+    assert "n=1" in repr(c.insts)
+    assert "Instances" in str(c.insts)
+
+
+def test_instances_iter_returns_instance(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    _ = c << _straight(kcl, layers)
+    items = list(c.insts)
+    assert len(items) == 1
+    assert isinstance(items[0], kf.Instance)
+
+
+def test_dinstances_iter_returns_dinstance(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.dkcell()
+    _ = c << _straight(kcl, layers)
+    items = list(c.insts)
+    assert len(items) == 1
+    assert isinstance(items[0], kf.DInstance)
+
+
+def test_instances_getitem_by_name(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    ref = c << _straight(kcl, layers)
+    ref.name = "named_inst"
+    assert isinstance(c.insts["named_inst"], kf.Instance)
+
+
+def test_dinstances_getitem_by_name(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.dkcell()
+    ref = c << _straight(kcl, layers)
+    ref.name = "dnamed_inst"
+    assert isinstance(c.insts["dnamed_inst"], kf.DInstance)
+
+
+def test_instances_get_missing_name_raises(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    _ = c << _straight(kcl, layers)
+    with pytest.raises(ValueError, match="not found"):
+        c.insts["missing"]
+
+
+def test_instances_contains_false_on_missing(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    _ = c << _straight(kcl, layers)
+    assert "missing" not in c.insts
+
+
+def test_instances_clear(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    _ = c << _straight(kcl, layers)
+    _ = c << _straight(kcl, layers)
+    assert len(c.insts) == 2
+    c.insts.clear()
+    assert len(c.insts) == 0
+
+
+def test_instances_delitem_by_int(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    _ = c << _straight(kcl, layers)
+    _ = c << _straight(kcl, layers)
+    del c.insts[0]
+    assert len(c.insts) == 1
+
+
+def test_instances_delitem_by_instance(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    ref1 = c << _straight(kcl, layers)
+    _ = c << _straight(kcl, layers)
+    del c.insts[ref1]
+    assert len(c.insts) == 1
+
+
+def test_instances_remove(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    ref1 = c << _straight(kcl, layers)
+    c.insts.remove(ref1)
+    assert len(c.insts) == 0
+
+
+def test_instances_to_dtype_to_itype(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    _ = c << _straight(kcl, layers)
+    d = c.insts.to_dtype()
+    assert isinstance(d, kf.DInstances)
+    i = d.to_itype()
+    assert isinstance(i, kf.Instances)
+
+
+def test_instances_eq_not_an_instances(kcl: kf.KCLayout, layers: Layers) -> None:
+    c1 = kcl.kcell()
+    _ = c1 << _straight(kcl, layers)
+    assert c1.insts != "not an instances object"
+
+
+def test_vinstances_iter(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    _ = c << _straight(kcl, layers)
+    insts = list(c.insts)
+    assert len(insts) == 1
+
+
+def test_vinstances_getitem_by_int(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    _ = c << _straight(kcl, layers)
+    assert isinstance(c.insts[0], kf.VInstance)
+
+
+def test_vinstances_getitem_by_name(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    ref = c << _straight(kcl, layers)
+    ref.name = "vname"
+    assert c.insts["vname"] is ref
+
+
+def test_vinstances_getitem_missing_raises(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    _ = c << _straight(kcl, layers)
+    with pytest.raises(KeyError, match="No instance found"):
+        c.insts["missing"]
+
+
+def test_vinstances_contains_false(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    _ = c << _straight(kcl, layers)
+    assert "no_such_name" not in c.insts
+
+
+def test_vinstances_delitem_by_int(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    _ = c << _straight(kcl, layers)
+    _ = c << _straight(kcl, layers)
+    del c.insts[0]
+    assert len(c.insts) == 1
+
+
+def test_vinstances_delitem_by_vinstance(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    ref = c << _straight(kcl, layers)
+    _ = c << _straight(kcl, layers)
+    del c.insts[ref]
+    assert len(c.insts) == 1
+
+
+def test_vinstances_clear(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    _ = c << _straight(kcl, layers)
+    _ = c << _straight(kcl, layers)
+    c.insts.clear()
+    assert len(c.insts) == 0
+
+
+def test_vinstances_remove(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    ref = c << _straight(kcl, layers)
+    c.insts.remove(ref)
+    assert len(c.insts) == 0
+
+
+def test_vinstances_dup_copy(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.vkcell()
+    _ = c << _straight(kcl, layers)
+    dup = c.insts.dup()
+    assert len(dup) == 1
+    copy = c.insts.copy()
+    assert len(copy) == 1
diff --git a/tests/test_l2n.py b/tests/test_l2n.py
index 9a5901f59..dae00b016 100644
--- a/tests/test_l2n.py
+++ b/tests/test_l2n.py
@@ -92,11 +92,11 @@ def mzi() -> kf.KCell:
 
 
 def test_l2n(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
 ) -> None:
     c = kcl.kcell(name="L2N_TEST")
     mzi1 = c << mzi()
     mzi2 = c << mzi()
     mzi2.connect("o1", mzi1, "o2")
-    c.l2n()
-    gds_regression(c)
+    c.l2n_ports()
+    oas_regression(c)
diff --git a/tests/test_layer_map.py b/tests/test_layer_map.py
new file mode 100644
index 000000000..317559f43
--- /dev/null
+++ b/tests/test_layer_map.py
@@ -0,0 +1,201 @@
+"""Tests for kfactory.technology.layer_map module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from ruamel.yaml import YAML
+
+from kfactory.technology.layer_map import (
+    LayerGroupModel,
+    LayerPropertiesModel,
+    LypModel,
+    dither2index,
+    group2lp,
+    index2dither,
+    index2line,
+    line2index,
+    lp2kl,
+    lyp_to_lyp_model,
+    lyp_to_yaml,
+    yaml_to_lyp,
+)
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+def test_dither_mappings_consistent() -> None:
+    for k, v in dither2index.items():
+        assert index2dither[v] == k
+
+
+def test_line_mappings_consistent() -> None:
+    for k, v in line2index.items():
+        assert index2line[v] == k
+
+
+def test_layer_properties_model_defaults() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0))
+    assert lp.name == "WG"
+    assert lp.layer == (1, 0)
+    assert lp.frame_color is None
+    assert lp.fill_color is None
+    assert lp.dither_pattern == 1
+    assert lp.line_style == 1
+    assert lp.visible is True
+
+
+def test_layer_properties_model_dither_string() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0), dither_pattern="solid")  # ty:ignore[invalid-argument-type]
+    assert lp.dither_pattern == dither2index["solid"]
+
+
+def test_layer_properties_model_line_style_string() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0), line_style="dotted")  # ty:ignore[invalid-argument-type]
+    assert lp.line_style == line2index["dotted"]
+
+
+def test_layer_properties_model_color_to_frame_fill() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0), color="#ff0000")  # ty:ignore[unknown-argument]
+    assert lp.frame_color is not None
+    assert lp.fill_color is not None
+
+
+def test_layer_properties_model_color_overrides() -> None:
+    # If explicit fill/frame are provided, the shorthand "color" doesn't override
+    lp = LayerPropertiesModel(
+        name="WG",
+        layer=(1, 0),
+        color="#ff0000",  # ty:ignore[unknown-argument]
+        fill_color="#00ff00",  # ty:ignore[invalid-argument-type]
+        frame_color="#0000ff",  # ty:ignore[invalid-argument-type]
+    )
+    assert lp.fill_color is not None
+    assert lp.fill_color.as_hex().startswith("#0")
+    assert lp.frame_color is not None
+
+
+def test_layer_properties_model_serializes_dither() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0), dither_pattern=0)
+    dumped = lp.model_dump()
+    assert dumped["dither_pattern"] == index2dither[0]
+
+
+def test_layer_properties_model_serializes_line_style() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0), line_style=0)
+    dumped = lp.model_dump()
+    assert dumped["line_style"] == index2line[0]
+
+
+def test_layer_group_model_nesting() -> None:
+    leaf = LayerPropertiesModel(name="WG", layer=(1, 0))
+    inner = LayerGroupModel(name="inner", members=[leaf])
+    outer = LayerGroupModel(name="outer", members=[inner, leaf])
+    assert len(outer.members) == 2
+    assert isinstance(outer.members[0], LayerGroupModel)
+
+
+def test_lyp_model_with_groups_and_leaves() -> None:
+    leaf = LayerPropertiesModel(name="WG", layer=(1, 0))
+    group = LayerGroupModel(name="g", members=[leaf])
+    m = LypModel(layers=[leaf, group])
+    assert len(m.layers) == 2
+
+
+def test_lp2kl_no_colors() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0))
+    kl = lp2kl(lp)
+    assert "1/0" in kl.source
+
+
+def test_lp2kl_no_layer_to_name() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0), layer_to_name=False)
+    kl = lp2kl(lp)
+    assert kl.name == "WG"
+
+
+def test_lp2kl_with_layer_to_name() -> None:
+    lp = LayerPropertiesModel(name="WG", layer=(1, 0), layer_to_name=True)
+    kl = lp2kl(lp)
+    assert "1/0" in kl.name
+
+
+def test_lp2kl_with_colors() -> None:
+    lp = LayerPropertiesModel(
+        name="WG",
+        layer=(1, 0),
+        frame_color="#abcdef",  # ty:ignore[invalid-argument-type]
+        fill_color="#123456",  # ty:ignore[invalid-argument-type]
+    )
+    kl = lp2kl(lp)
+    # KLayout may store with alpha bits; compare only the low 24 bits
+    assert kl.frame_color & 0xFFFFFF == int("abcdef", 16)
+    assert kl.fill_color & 0xFFFFFF == int("123456", 16)
+
+
+def test_lp2kl_with_short_hex_colors() -> None:
+    # Pydantic Color may normalize "#fff" -> a long form. Force short by using e.g. red.
+    lp = LayerPropertiesModel(
+        name="WG",
+        layer=(1, 0),
+        frame_color="red",  # ty:ignore[invalid-argument-type]
+        fill_color="red",  # ty:ignore[invalid-argument-type]
+    )
+    kl = lp2kl(lp)
+    assert kl.frame_color > 0
+    assert kl.fill_color > 0
+
+
+def test_group2lp_nested() -> None:
+    leaf1 = LayerPropertiesModel(name="WG", layer=(1, 0))
+    leaf2 = LayerPropertiesModel(name="M1", layer=(2, 0))
+    sub = LayerGroupModel(name="subgroup", members=[leaf2])
+    group = LayerGroupModel(name="g", members=[leaf1, sub])
+    kl = group2lp(group)
+    assert kl.name == "g"
+
+
+def _build_yaml_file(path: Path) -> None:
+    data = {
+        "layers": [
+            {"name": "WG", "layer": [1, 0], "color": "#ff0000"},
+            {
+                "name": "G",
+                "members": [
+                    {"name": "M1", "layer": [2, 0]},
+                ],
+            },
+        ]
+    }
+    yaml = YAML()
+    with path.open("w") as f:
+        yaml.dump(data, f)
+
+
+def test_yaml_to_lyp_and_back(tmp_path: Path) -> None:
+    yaml_in = tmp_path / "in.yaml"
+    lyp_out = tmp_path / "out.lyp"
+    yaml_round = tmp_path / "round.yaml"
+    _build_yaml_file(yaml_in)
+    yaml_to_lyp(yaml_in, lyp_out)
+    assert lyp_out.exists()
+
+    model = lyp_to_lyp_model(lyp_out)
+    assert isinstance(model, LypModel)
+    assert len(model.layers) >= 1
+
+    lyp_to_yaml(lyp_out, yaml_round)
+    assert yaml_round.exists()
+
+
+def test_yaml_to_lyp_missing_input(tmp_path: Path) -> None:
+    bad = tmp_path / "does_not_exist.yaml"
+    with pytest.raises(AssertionError):
+        yaml_to_lyp(bad, tmp_path / "out.lyp")
+
+
+def test_lyp_to_lyp_model_missing_input(tmp_path: Path) -> None:
+    with pytest.raises(AssertionError):
+        lyp_to_lyp_model(tmp_path / "missing.lyp")
diff --git a/tests/test_layers.py b/tests/test_layers.py
index 691fc8da6..e44acad53 100644
--- a/tests/test_layers.py
+++ b/tests/test_layers.py
@@ -9,10 +9,10 @@ def test_layer_infos_valid() -> None:
     layer_info = kf.LayerInfos(
         layer1=kf.kdb.LayerInfo(1, 0), layer2=kf.kdb.LayerInfo(2, 0)
     )
-    assert layer_info.layer1.layer == 1  # type: ignore[attr-defined]
-    assert layer_info.layer1.datatype == 0  # type: ignore[attr-defined]
-    assert layer_info.layer2.layer == 2  # type: ignore[attr-defined]
-    assert layer_info.layer2.datatype == 0  # type: ignore[attr-defined]
+    assert layer_info.layer1.layer == 1  # ty:ignore[unresolved-attribute]
+    assert layer_info.layer1.datatype == 0  # ty:ignore[unresolved-attribute]
+    assert layer_info.layer2.layer == 2  # ty:ignore[unresolved-attribute]
+    assert layer_info.layer2.datatype == 0  # ty:ignore[unresolved-attribute]
 
 
 def test_layer_infos_invalid_type() -> None:
@@ -41,12 +41,12 @@ def test_layer_infos_missing_datatype() -> None:
 
 def test_layer_infos_named_layer() -> None:
     layer_info = kf.LayerInfos(layer1=kf.kdb.LayerInfo(1, 0, name="Layer1"))
-    assert layer_info.layer1.name == "Layer1"  # type: ignore[attr-defined]
+    assert layer_info.layer1.name == "Layer1"  # ty:ignore[unresolved-attribute]
 
 
 def test_layer_infos_unnamed_layer() -> None:
     layer_info = kf.LayerInfos(layer1=kf.kdb.LayerInfo(1, 0))
-    assert layer_info.layer1.name == "layer1"  # type: ignore[attr-defined]
+    assert layer_info.layer1.name == "layer1"  # ty:ignore[unresolved-attribute]
 
 
 def test_layer_enum_creation(layers: Layers) -> None:
@@ -62,13 +62,13 @@ def test_layer_enum_str(layers: Layers) -> None:
 
 def test_layer_enum_getitem(layers: Layers) -> None:
     layer_enum = kf.layer.layerenum_from_dict(name="LAYER", layers=layers)
-    assert layer_enum["WG"][0] == 1  # type: ignore[index]
-    assert layer_enum["WG"][1] == 0  # type: ignore[index]
+    assert layer_enum["WG"][0] == 1
+    assert layer_enum["WG"][1] == 0
 
 
 def test_layer_enum_len(layers: Layers) -> None:
     layer_enum = kf.layer.layerenum_from_dict(name="LAYER", layers=layers)
-    assert len(layer_enum) == 15  # type: ignore[arg-type]
+    assert len(layer_enum) == 15  # ty:ignore[invalid-argument-type]
 
 
 def test_layer_enum_iter(layers: Layers) -> None:
@@ -80,7 +80,7 @@ def test_layer_enum_iter(layers: Layers) -> None:
 def test_layer_enum_invalid_index(layers: Layers) -> None:
     layer_enum = kf.layer.layerenum_from_dict(name="LAYER", layers=layers)
     with pytest.raises(ValueError):
-        layer_enum["WG"][2]  # type: ignore[index]
+        layer_enum["WG"][2]
 
 
 def test_layer_stack(pdk: kf.KCLayout) -> None:
diff --git a/tests/test_layout.py b/tests/test_layout.py
index 424ebe0c7..4b429e93b 100644
--- a/tests/test_layout.py
+++ b/tests/test_layout.py
@@ -1,4 +1,5 @@
 import functools
+from typing import Any, cast
 
 import pytest
 
@@ -9,14 +10,14 @@
 def test_cell_decorator(kcl: kf.KCLayout, layers: Layers) -> None:
     count: int = 0
 
-    def rectangle_post_process(cell: kf.kcell.TKCell) -> None:
+    def rectangle_post_process(cell: kf.ProtoTKCell[Any]) -> None:
         assert cell.name == kf.serialization.clean_name(
             f"rectangle_W{cell.settings['width']}_H{cell.settings['height']}_LWG"
         )
         nonlocal count
         count += 1
 
-    @kcl.cell(post_process=[rectangle_post_process])  # type: ignore[type-var]
+    @kcl.cell(post_process=[rectangle_post_process])
     def rectangle(width: float, height: float, layer: kf.kdb.LayerInfo) -> kf.DKCell:
         c = kcl.dkcell()
         c.shapes(layer).insert(kf.kdb.DBox(0, 0, width, height))
@@ -143,7 +144,7 @@ def test_cell_with_empty_parameters(
         return kcl.kcell(name=name)
 
     with pytest.raises(TypeError):
-        test_cell_with_empty_parameters(name="test_cell_with_empty_parameters")  # type: ignore[call-arg]
+        test_cell_with_empty_parameters(name="test_cell_with_empty_parameters")  # ty:ignore[missing-argument]
 
 
 def test_check_instances(kcl: kf.KCLayout) -> None:
@@ -222,7 +223,7 @@ def test_dk_to_kcell(name: str) -> kf.DKCell:
     with pytest.raises(ValueError):
 
         @kcl.cell
-        def test_no_output_type():  # type: ignore[no-untyped-def]  # noqa: ANN202
+        def test_no_output_type():  # noqa: ANN202
             return kf.KCell()
 
         test_no_output_type()
@@ -262,3 +263,35 @@ def test_kclayout_assign(kcl: kf.KCLayout, layers: Layers) -> None:
     kcl.assign(kcl2.layout)
     assert len(kcl2.kcells) == 1
     assert len(list(kcl2.layout.each_cell())) == 1
+
+
+def test_kclayout_clear_keep_layers(kcl: kf.KCLayout, layers: Layers) -> None:
+    kf.factories.straight.straight_dbu_factory(kcl)(
+        length=1000, width=1000, layer=layers.WG
+    )
+    assert len(kcl.kcells) == 1
+    assert len(list(kcl.layout.each_cell())) == 1
+    infos_before = kcl.infos
+
+    kcl.clear(keep_layers=True)
+
+    assert len(kcl.kcells) == 0
+    assert len(list(kcl.layout.each_cell())) == 0
+    assert kcl.infos == infos_before
+    assert kcl.layers["WG"].layer == layers.WG.layer
+    assert kcl.layers["WG"].datatype == layers.WG.datatype
+
+
+def test_kclayout_clear_drop_layers(kcl: kf.KCLayout, layers: Layers) -> None:
+    kf.factories.straight.straight_dbu_factory(kcl)(
+        length=1000, width=1000, layer=layers.WG
+    )
+    assert len(kcl.kcells) == 1
+    assert len(list(kcl.layout.each_cell())) == 1
+
+    kcl.clear(keep_layers=False)
+
+    assert len(kcl.kcells) == 0
+    assert len(list(kcl.layout.each_cell())) == 0
+    assert kcl.infos == kf.LayerInfos()
+    assert len(list(cast("Any", kcl.layers))) == 0
diff --git a/tests/test_meta.py b/tests/test_meta.py
index f4f3e23e3..02ace077d 100644
--- a/tests/test_meta.py
+++ b/tests/test_meta.py
@@ -6,7 +6,7 @@
 from tests.conftest import Layers
 
 
-@kf.cell  # type: ignore[misc, unused-ignore]
+@kf.cell
 def sample(
     s: str = "a", i: int = 3, f: float = 2.0, t: tuple[int, ...] = (1,)
 ) -> kf.KCell:
@@ -28,9 +28,19 @@ def sample(
                         kf.kdb.Point(250, 250),
                     ]
                 ),
+                "d": "hello",
             },
+            "d": "hello",
         }
+        c.info["poly"] = kf.kdb.Polygon(
+            pts=[
+                kf.kdb.Point(0, 0),
+                kf.kdb.Point(500, 0),
+                kf.kdb.Point(250, 250),
+            ]
+        )
         c.info["e"] = None
+        c.info["g"] = {"c": 1}
         c.write(temp_file.name)
 
         kcl2 = kf.KCLayout("TEST_META_SAMPLE")
@@ -140,7 +150,7 @@ def test_metainfo_read_cell(straight: kf.KCell) -> None:
             " for ports, info, and settings. Therefore proceed at your own risk."
         )
         for cs in straight.kcl.cross_sections.cross_sections.values():
-            kcl.get_symmetrical_cross_section(cs)
+            kcl.get_base_cross_section(cs)
         kcell.read(t.name)
         kf.config.logfilter.regex = ""
 
@@ -160,20 +170,18 @@ def test_nometainfo_read(straight: kf.KCell) -> None:
         assert len(wg_read.ports) == 0
         assert len(straight.ports) == 2
         assert straight.settings.model_dump() == {
+            "cross_section": "f7fe636c_500",
             "length": 1000,
-            "width": 500,
-            "enclosure": "WGSTD",
-            "layer": Layers().WG,
         }
-        assert straight.function_name == "straight"
-        assert straight.basename is None
+        assert straight.function_name == "_straight"
+        assert straight.basename == "straight"
 
 
 def test_info_dump(kcl: kf.KCLayout) -> None:
     c = kcl.kcell()
     c.info = kf.Info(a="A")
     c.settings = kf.KCellSettings(a="A", c="C")
-    c.info.b = "B"  # type: ignore[attr-defined, unused-ignore]
+    c.info.b = "B"
     c.info["d"] = {"a": 1, "b": 2}
 
     assert c.info == c.info.model_copy()
diff --git a/tests/test_netlist.py b/tests/test_netlist.py
index 29eb212cd..efddc948d 100644
--- a/tests/test_netlist.py
+++ b/tests/test_netlist.py
@@ -17,7 +17,7 @@ def test_l2n(layers: Layers) -> None:
     s1.connect("o1", b3, "o2")
     c.add_port(port=b1.ports["o1"])
     p = s1.ports["o2"].copy()
-    p.name = None
+    p.name = "o2"
     c.add_port(port=p)
 
-    c.l2n()
+    c.l2n_ports()
diff --git a/tests/test_optical_placer.py b/tests/test_optical_placer.py
new file mode 100644
index 000000000..65e841f54
--- /dev/null
+++ b/tests/test_optical_placer.py
@@ -0,0 +1,732 @@
+"""Tests for kfactory.routing.optical placer internals."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+import kfactory as kf
+from kfactory.routing.generic import ManhattanRoute
+from kfactory.routing.optical import (
+    _place_straight,
+    _place_tapered_straight,
+    place_manhattan,
+    place_manhattan_with_sbends,
+    vec_angle_sbend,
+)
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+    from tests.conftest import Layers
+
+
+def _make_o_port(
+    kcl: kf.KCLayout, layers: Layers, name: str, angle: int, x: int, y: int
+) -> kf.Port:
+    return kf.Port(
+        name=name,
+        trans=kf.kdb.Trans(angle, False, x, y),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+        port_type="optical",
+    )
+
+
+# vec_angle_sbend
+
+
+def test_vec_angle_sbend_old_horizontal_up() -> None:
+    assert vec_angle_sbend(0, kf.kdb.Vector(10, 5)) == 1
+
+
+def test_vec_angle_sbend_old_horizontal_down() -> None:
+    assert vec_angle_sbend(0, kf.kdb.Vector(10, -5)) == 3
+
+
+def test_vec_angle_sbend_old_vertical_right() -> None:
+    assert vec_angle_sbend(1, kf.kdb.Vector(10, 5)) == 0
+
+
+def test_vec_angle_sbend_old_vertical_left() -> None:
+    assert vec_angle_sbend(1, kf.kdb.Vector(-10, 5)) == 2
+
+
+# _place_straight
+
+
+def test_place_straight_basic(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    c = kcl.kcell("place_straight_basic")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    route = ManhattanRoute(
+        backbone=[],
+        start_port=p1,
+        end_port=p2,
+        instances=[],
+    )
+    _new_p1, _new_p2 = _place_straight(
+        c=c,
+        straight_factory=straight_factory_dbu,
+        purpose=None,
+        w=500,
+        route=route,
+        p1=p1,
+        p2=p2,
+        route_width=None,
+        port_type="optical",
+        allow_width_mismatch=False,
+        allow_layer_mismatch=False,
+        allow_type_mismatch=False,
+    )
+    assert len(route.instances) == 1
+    assert route.length_straights == 50_000
+
+
+# _place_tapered_straight
+
+
+def test_place_tapered_straight(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+    wg_enc: kf.LayerEnclosure,
+) -> None:
+    c = kcl.kcell("place_tapered_straight")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    taper_factory = kf.factories.taper.taper_factory(kcl=kcl)
+    taper_cell = taper_factory(
+        width1=500, width2=1000, length=5_000, layer=layers.WG, enclosure=wg_enc
+    )
+    # Identify the two ports
+    tports = list(taper_cell.ports)
+
+    route = ManhattanRoute(
+        backbone=[],
+        start_port=p1,
+        end_port=p2,
+        instances=[],
+    )
+    _place_tapered_straight(
+        c=c,
+        straight_factory=straight_factory_dbu,
+        taper_cell=taper_cell,
+        purpose=None,
+        route=route,
+        p1=p1,
+        p2=p2,
+        route_width=None,
+        taper_ports=(tports[0], tports[1]),
+        port_type="optical",
+        allow_width_mismatch=True,
+        allow_layer_mismatch=False,
+        allow_type_mismatch=False,
+    )
+    # 2 tapers placed, may have 0 or 1 straight in middle depending on length
+    assert route.n_taper == 2
+    assert len(route.instances) >= 2
+
+
+# place_manhattan validation
+
+
+def test_place_manhattan_missing_straight_factory(
+    bend90: kf.KCell, kcl: kf.KCLayout, layers: Layers
+) -> None:
+    c = kcl.kcell("pm_no_sf")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(ValueError, match="straight_factory"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            bend90_cell=bend90,
+        )
+
+
+def test_place_manhattan_missing_bend90(
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    c = kcl.kcell("pm_no_b90")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(ValueError, match="bend90"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            straight_factory=straight_factory_dbu,
+        )
+
+
+def test_place_manhattan_extra_kwargs(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    c = kcl.kcell("pm_extra_kwargs")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(ValueError, match="not allowed"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            bend90_cell=bend90,
+            straight_factory=straight_factory_dbu,
+            unknown_kwarg=42,
+        )
+
+
+def test_place_manhattan_bend_wrong_port_count(
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """bend90 cell with no optical ports should error."""
+    c = kcl.kcell("pm_bad_b90")
+    bad_bend = kcl.kcell("bad_bend")
+    bad_bend.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 5000, 5000))
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(AttributeError, match="should have 2 ports"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            bend90_cell=bad_bend,
+            straight_factory=straight_factory_dbu,
+        )
+
+
+def test_place_manhattan_bend_ports_not_90(
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """bend90 cell with two ports at the same angle should error."""
+    c = kcl.kcell("pm_bend_not_90")
+    bad_bend = kcl.kcell("bad_bend_not_90")
+    bad_bend.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 5000, 5000))
+    bad_bend.create_port(
+        name="o1",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer=kcl.find_layer(layers.WG),
+        port_type="optical",
+    )
+    bad_bend.create_port(
+        name="o2",
+        trans=kf.kdb.Trans(2, False, 5000, 0),
+        width=500,
+        layer=kcl.find_layer(layers.WG),
+        port_type="optical",
+    )
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(AttributeError, match="90"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            bend90_cell=bad_bend,
+            straight_factory=straight_factory_dbu,
+        )
+
+
+def test_place_manhattan_too_few_points(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """Less than 2 points should return an empty route."""
+    c = kcl.kcell("pm_few_pts")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    route = place_manhattan(
+        c,
+        p1,
+        p2,
+        [kf.kdb.Point(0, 0)],
+        bend90_cell=bend90,
+        straight_factory=straight_factory_dbu,
+    )
+    assert route.instances == []
+
+
+def test_place_manhattan_two_points_straight(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """2 points → single straight."""
+    c = kcl.kcell("pm_two_pts")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    route = place_manhattan(
+        c,
+        p1,
+        p2,
+        [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+        bend90_cell=bend90,
+        straight_factory=straight_factory_dbu,
+    )
+    assert len(route.instances) == 1
+
+
+def test_place_manhattan_three_points_with_bend(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """3 points → straight + bend + straight."""
+    c = kcl.kcell("pm_three_pts")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 1, 50_000, 50_000)
+    route = place_manhattan(
+        c,
+        p1,
+        p2,
+        [
+            kf.kdb.Point(0, 0),
+            kf.kdb.Point(50_000, 0),
+            kf.kdb.Point(50_000, 50_000),
+        ],
+        bend90_cell=bend90,
+        straight_factory=straight_factory_dbu,
+    )
+    # Should have at least the bend
+    assert route.n_bend90 == 1
+    assert len(route.instances) >= 1
+
+
+def test_place_manhattan_small_distance_raises(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """Too small distance between points raises."""
+    c = kcl.kcell("pm_small_dist")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 1, 100, 100)
+    with pytest.raises(ValueError, match="too small"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [
+                kf.kdb.Point(0, 0),
+                kf.kdb.Point(100, 0),
+                kf.kdb.Point(100, 100),
+            ],
+            bend90_cell=bend90,
+            straight_factory=straight_factory_dbu,
+        )
+
+
+def test_place_manhattan_non_manhattan_vec_raises(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """Non-manhattan vector between points raises."""
+    c = kcl.kcell("pm_non_manhattan")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 1, 100_000, 100_000)
+    with pytest.raises(ValueError, match=r"[Mm]anhattan"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [
+                kf.kdb.Point(0, 0),
+                kf.kdb.Point(50_000, 50_000),
+                kf.kdb.Point(100_000, 100_000),
+            ],
+            bend90_cell=bend90,
+            straight_factory=straight_factory_dbu,
+            allow_small_routes=True,
+        )
+
+
+def test_place_manhattan_with_taper(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+    wg_enc: kf.LayerEnclosure,
+) -> None:
+    """Place manhattan with a taper cell for a 2-point route."""
+    c = kcl.kcell("pm_with_taper")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 200_000, 0)
+
+    taper_factory = kf.factories.taper.taper_factory(kcl=kcl)
+    taper_cell = taper_factory(
+        width1=500, width2=1000, length=10_000, layer=layers.WG, enclosure=wg_enc
+    )
+
+    route = place_manhattan(
+        c,
+        p1,
+        p2,
+        [kf.kdb.Point(0, 0), kf.kdb.Point(200_000, 0)],
+        bend90_cell=bend90,
+        straight_factory=straight_factory_dbu,
+        taper_cell=taper_cell,
+        min_straight_taper=0,
+    )
+    # Either tapered or plain - at least one instance
+    assert len(route.instances) >= 1
+
+
+def test_place_manhattan_with_bad_taper_widths(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """Taper whose port widths don't match bend's should raise."""
+    c = kcl.kcell("pm_bad_taper_widths")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 200_000, 0)
+
+    bad_taper = kcl.kcell("bad_taper")
+    bad_taper.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 10_000, 5000))
+    bad_taper.create_port(
+        name="o1",
+        trans=kf.kdb.Trans(2, False, 0, 0),
+        width=998,
+        layer=kcl.find_layer(layers.WG),
+        port_type="optical",
+    )
+    bad_taper.create_port(
+        name="o2",
+        trans=kf.kdb.Trans(0, False, 10_000, 0),
+        width=776,
+        layer=kcl.find_layer(layers.WG),
+        port_type="optical",
+    )
+
+    with pytest.raises(AttributeError, match="same width"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(200_000, 0)],
+            bend90_cell=bend90,
+            straight_factory=straight_factory_dbu,
+            taper_cell=bad_taper,
+        )
+
+
+def test_place_manhattan_with_bad_taper_orientation(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    """Taper with ports not 180° opposing should raise."""
+    c = kcl.kcell("pm_bad_taper_orient")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 200_000, 0)
+
+    bad_taper = kcl.kcell("bad_taper_orient")
+    bad_taper.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 10_000, 5000))
+    bad_taper.create_port(
+        name="o1",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer=kcl.find_layer(layers.WG),
+        port_type="optical",
+    )
+    bad_taper.create_port(
+        name="o2",
+        trans=kf.kdb.Trans(1, False, 10_000, 0),
+        width=1000,
+        layer=kcl.find_layer(layers.WG),
+        port_type="optical",
+    )
+
+    with pytest.raises(AttributeError, match="180"):
+        place_manhattan(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(200_000, 0)],
+            bend90_cell=bend90,
+            straight_factory=straight_factory_dbu,
+            taper_cell=bad_taper,
+        )
+
+
+# place_manhattan_with_sbends
+
+
+def test_place_manhattan_with_sbends_missing_straight_factory(
+    bend90: kf.KCell, kcl: kf.KCLayout, layers: Layers
+) -> None:
+    c = kcl.kcell("pmws_no_sf")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(ValueError, match="straight_factory"):
+        place_manhattan_with_sbends(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            bend90_cell=bend90,
+        )
+
+
+def test_place_manhattan_with_sbends_missing_bend90(
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    c = kcl.kcell("pmws_no_b90")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(ValueError, match="bend90"):
+        place_manhattan_with_sbends(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            straight_factory=straight_factory_dbu,
+        )
+
+
+def test_place_manhattan_with_sbends_missing_sbend_factory(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    c = kcl.kcell("pmws_no_sbend")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(ValueError, match="sbend_function"):
+        place_manhattan_with_sbends(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            bend90_cell=bend90,
+            straight_factory=straight_factory_dbu,
+        )
+
+
+def test_place_manhattan_with_sbends_extra_kwargs(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    c = kcl.kcell("pmws_extra_kwargs")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+    with pytest.raises(ValueError, match="not allowed"):
+        place_manhattan_with_sbends(
+            c,
+            p1,
+            p2,
+            [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+            bend90_cell=bend90,
+            straight_factory=straight_factory_dbu,
+            unknown_kwarg=42,
+        )
+
+
+def test_place_manhattan_with_sbends_too_few_points(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+    wg_enc: kf.LayerEnclosure,
+) -> None:
+    """Less than 2 points returns empty-instance route."""
+    c = kcl.kcell("pmws_few_pts")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+
+    def sbend_factory(
+        c: kf.ProtoTKCell[kf.kcell.Any], offset: int, length: int, width: int
+    ) -> kf.InstanceGroup:
+        ig = kf.InstanceGroup()
+        sbend = c << kf.cells.euler.bend_s_euler(
+            offset=c.kcl.to_um(offset),
+            width=c.kcl.to_um(width),
+            radius=10,
+            layer=layers.WG,
+            enclosure=wg_enc,
+        )
+        ig.add(sbend)
+        ig.add_port(name="o1", port=sbend.ports["o1"])
+        ig.add_port(name="o2", port=sbend.ports["o2"])
+        return ig
+
+    route = place_manhattan_with_sbends(
+        c,
+        p1,
+        p2,
+        [kf.kdb.Point(0, 0)],
+        bend90_cell=bend90,
+        straight_factory=straight_factory_dbu,
+        sbend_factory=sbend_factory,
+    )
+    assert route.instances == []
+
+
+def test_place_manhattan_with_sbends_straight_path(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+    wg_enc: kf.LayerEnclosure,
+) -> None:
+    """2 manhattan-aligned points → just a straight, no sbend."""
+    c = kcl.kcell("pmws_straight")
+    p1 = _make_o_port(kcl, layers, "p1", 0, 0, 0)
+    p2 = _make_o_port(kcl, layers, "p2", 2, 50_000, 0)
+
+    def sbend_factory(
+        c: kf.ProtoTKCell[kf.kcell.Any], offset: int, length: int, width: int
+    ) -> kf.InstanceGroup:
+        ig = kf.InstanceGroup()
+        sbend = c << kf.cells.euler.bend_s_euler(
+            offset=c.kcl.to_um(offset),
+            width=c.kcl.to_um(width),
+            radius=10,
+            layer=layers.WG,
+            enclosure=wg_enc,
+        )
+        ig.add(sbend)
+        ig.add_port(name="o1", port=sbend.ports["o1"])
+        ig.add_port(name="o2", port=sbend.ports["o2"])
+        return ig
+
+    route = place_manhattan_with_sbends(
+        c,
+        p1,
+        p2,
+        [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)],
+        bend90_cell=bend90,
+        straight_factory=straight_factory_dbu,
+        sbend_factory=sbend_factory,
+    )
+    assert len(route.instances) == 1
+
+
+# route_loopback parallel-error
+
+
+def test_route_loopback_non_parallel_raises(kcl: kf.KCLayout, layers: Layers) -> None:
+    from kfactory.routing.optical import route_loopback
+
+    p1 = kf.Port(
+        name="p1",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    # Different angle AND same x — triggers the error branch
+    p2 = kf.Port(
+        name="p2",
+        trans=kf.kdb.Trans(1, False, 0, 50_000),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    with pytest.raises(ValueError, match="parallel"):
+        route_loopback(p1, p2, bend90_radius=10_000)
+
+
+def test_route_loopback_with_start_end_straights(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    from kfactory.routing.optical import route_loopback
+
+    p1 = kf.Port(
+        name="p1",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    p2 = kf.Port(
+        name="p2",
+        trans=kf.kdb.Trans(0, False, 0, 50_000),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    pts = route_loopback(
+        p1,
+        p2,
+        bend90_radius=10_000,
+        bend180_radius=20_000,
+        start_straight=5_000,
+        end_straight=5_000,
+    )
+    assert isinstance(pts, list)
+
+
+def test_route_loopback_inside_with_bend180(kcl: kf.KCLayout, layers: Layers) -> None:
+    from kfactory.routing.optical import route_loopback
+
+    p1 = kf.Port(
+        name="p1",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    p2 = kf.Port(
+        name="p2",
+        trans=kf.kdb.Trans(0, False, 0, 50_000),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    pts = route_loopback(
+        p1,
+        p2,
+        bend90_radius=10_000,
+        bend180_radius=20_000,
+        inside=True,
+    )
+    assert isinstance(pts, list)
+
+
+def test_route_loopback_with_trans_inputs(layers: Layers) -> None:
+    from kfactory.routing.optical import route_loopback
+
+    t1 = kf.kdb.Trans(0, False, 0, 0)
+    t2 = kf.kdb.Trans(0, False, 0, 50_000)
+    pts = route_loopback(t1, t2, bend90_radius=10_000)
+    assert isinstance(pts, list)
diff --git a/tests/test_packing.py b/tests/test_packing.py
index c29c682ba..960c5ad5f 100644
--- a/tests/test_packing.py
+++ b/tests/test_packing.py
@@ -6,7 +6,7 @@
 
 def test_pack_kcells(
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     c = kcl.kcell()
     straight = kf.factories.straight.straight_dbu_factory(kcl)(
@@ -16,12 +16,12 @@ def test_pack_kcells(
         c, [straight] * 4, max_height=2000, max_width=2000
     )
     assert instance_group.bbox() == kf.kdb.DBox(0, 0, 2000, 2000)
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_pack_instances(
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     c = kcl.kcell()
     straight = kf.factories.straight.straight_dbu_factory(kcl)(
@@ -35,4 +35,4 @@ def test_pack_instances(
         c, [ref, ref2, ref3, ref4], max_height=2000, max_width=2000
     )
     assert instance_group.bbox() == kf.kdb.DBox(0, 0, 2000, 2000)
-    gds_regression(c)
+    oas_regression(c)
diff --git a/tests/test_partial.py b/tests/test_partial.py
index 6cb17e5d5..11679709f 100644
--- a/tests/test_partial.py
+++ b/tests/test_partial.py
@@ -9,11 +9,13 @@ def to_be_partialled(width: float, length: float, layer: kf.kdb.LayerInfo) -> kf
     box = c.shapes(c.kcl.find_layer(layer)).insert(kf.kdb.DBox(length, width))
 
     c.create_port(
+        name="o1",
         trans=kf.kdb.Trans(box.box_width // 2, 0),
         width=box.box_height,
         layer=c.kcl.find_layer(layer),
     )
     c.create_port(
+        name="o2",
         trans=kf.kdb.Trans(2, False, -box.box_width // 2, 0),
         width=box.box_height,
         layer=c.kcl.find_layer(layer),
diff --git a/tests/test_pdk.py b/tests/test_pdk.py
index 556553863..622bab582 100644
--- a/tests/test_pdk.py
+++ b/tests/test_pdk.py
@@ -33,8 +33,6 @@ def test_clear() -> None:
 def test_kcell_delete(layers: Layers) -> None:
     _kcl = kf.KCLayout("DELETE", infos=Layers)
 
-    _kcl.layers = _kcl.layerenum_from_dict(layers=layers)
-
     s = partial(
         kf.factories.straight.straight_dbu_factory(_kcl),
         width=1000,
@@ -52,7 +50,7 @@ def test_kcell_delete(layers: Layers) -> None:
 
 def test_multi_pdk(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     base_pdk = kf.KCLayout("BASE_MULTI", infos=Layers)
 
@@ -93,12 +91,12 @@ def test_multi_pdk(
     d1 = assembly << doe1
     d2 = assembly << doe2
     d2.connect("o1", d1, "o2")
-    gds_regression(assembly)
+    oas_regression(assembly)
 
 
 def test_multi_pdk_convert(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     with NamedTemporaryFile("a", suffix=".oas") as temp_file:
         base_pdk = kf.KCLayout("BASE_CONVERT", infos=Layers)
@@ -144,12 +142,12 @@ def test_multi_pdk_convert(
         d2.connect("o1", d1, "o2")
 
         assembly.write(temp_file.name, convert_external_cells=True)
-        gds_regression(assembly)
+        oas_regression(assembly)
 
 
 def test_multi_pdk_read_write(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     base_pdk = kf.KCLayout("BASE_RW", infos=Layers)
 
@@ -200,7 +198,7 @@ def test_multi_pdk_read_write(
     d1 = assembly << doe_pdk1_read[doe1.name]
     d2 = assembly << doe_pdk2_read[doe2.name]
     d2.connect("o1", d1, "o2")
-    gds_regression(assembly)
+    oas_regression(assembly)
 
 
 def test_merge_read_shapes(
@@ -291,7 +289,7 @@ def test_merge_properties() -> None:
 
 def test_pdk_cell_infosettings(
     straight: kf.KCell,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     kcl_ = kf.KCLayout("INFOSETTINGS", infos=Layers)
diff --git a/tests/test_pins.py b/tests/test_pins.py
index d4ffe4a56..436416bae 100644
--- a/tests/test_pins.py
+++ b/tests/test_pins.py
@@ -6,7 +6,7 @@
 
 
 def test_pins(
-    layers: Layers, gds_regression: Callable[[kf.ProtoTKCell[Any]], None]
+    layers: Layers, oas_regression: Callable[[kf.ProtoTKCell[Any]], None]
 ) -> None:
     kcl_1 = kf.KCLayout("PIN_PDK", infos=Layers)
 
@@ -20,21 +20,25 @@ def pad() -> kf.KCell:
 
         c.shapes(layers.METAL1).insert(kf.kdb.Box(50_000, 50_000))
         p1 = c.create_port(
+            name="e1",
             trans=kf.kdb.Trans(0, False, 25_000, 0),
             cross_section=xs,
             info={"variable_name": "p1"},
         )
         p2 = c.create_port(
+            name="e2",
             trans=kf.kdb.Trans(1, False, 0, 25_000),
             cross_section=xs,
             info={"variable_name": "p2"},
         )
         p3 = c.create_port(
+            name="e3",
             trans=kf.kdb.Trans(2, False, -25_000, 0),
             cross_section=xs,
             info={"variable_name": "p3"},
         )
         p4 = c.create_port(
+            name="e4",
             trans=kf.kdb.Trans(3, False, 0, -25_000),
             cross_section=xs,
             info={"variable_name": "p4"},
@@ -51,21 +55,25 @@ def pad_tapeout() -> kf.KCell:
 
         c.shapes(layers.METAL1).insert(kf.kdb.Box(50_000, 50_000))
         p1 = c.create_port(
+            name="e1",
             trans=kf.kdb.Trans(0, False, 25_000, 0),
             cross_section=xs,
             info={"variable_name": "p1"},
         )
         p2 = c.create_port(
+            name="e2",
             trans=kf.kdb.Trans(1, False, 0, 25_000),
             cross_section=xs,
             info={"variable_name": "p2"},
         )
         p3 = c.create_port(
+            name="e3",
             trans=kf.kdb.Trans(2, False, -25_000, 0),
             cross_section=xs,
             info={"variable_name": "p3"},
         )
         p4 = c.create_port(
+            name="e4",
             trans=kf.kdb.Trans(3, False, 0, -25_000),
             cross_section=xs,
             info={"variable_name": "p4"},
@@ -104,7 +112,7 @@ def pad_tapeout() -> kf.KCell:
 
     ci = pad1.cell.cell_index()
     ci2 = pad2.cell.cell_index()
-    gds_regression(pad())
+    oas_regression(pad())
     c.delete()
     kf.kcl[ci].delete()
     kf.kcl[ci2].delete()
diff --git a/tests/test_pins_extra.py b/tests/test_pins_extra.py
new file mode 100644
index 000000000..215f2b5f1
--- /dev/null
+++ b/tests/test_pins_extra.py
@@ -0,0 +1,462 @@
+"""Extra tests targeting pins.py / instance_pins.py coverage."""
+
+from __future__ import annotations
+
+import pytest
+
+import kfactory as kf
+from kfactory.pin import BasePin, DPin, Pin, filter_regex, filter_type
+from kfactory.pins import DPins, Pins
+from kfactory.settings import Info
+from tests.conftest import Layers
+
+
+def _make_kcell_with_pin(
+    kcl: kf.KCLayout, layers: Layers, pin_type: str = "DC", pin_name: str = "pin1"
+) -> kf.KCell:
+    xs = kf.SymmetricalCrossSection(
+        width=5000,
+        enclosure=kf.LayerEnclosure(main_layer=layers.METAL1, name="M1_PINX"),
+    )
+    c = kcl.kcell()
+    c.shapes(layers.METAL1).insert(kf.kdb.Box(50_000, 50_000))
+    p1 = c.create_port(
+        name="e1", trans=kf.kdb.Trans(0, False, 25_000, 0), cross_section=xs
+    )
+    p2 = c.create_port(
+        name="e2", trans=kf.kdb.Trans(1, False, 0, 25_000), cross_section=xs
+    )
+    c.create_pin(name=pin_name, ports=[p1, p2], pin_type=pin_type)
+    return c
+
+
+def test_pins_getitem_by_index(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    assert c.pins[0].name == "pin1"
+
+
+def test_pins_getitem_by_name(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    assert c.pins["pin1"].name == "pin1"
+
+
+def test_pins_getitem_missing_name_raises(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    with pytest.raises(KeyError, match="not a valid pin name"):
+        c.pins["does_not_exist"]
+
+
+def test_pins_contains_by_name(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    assert "pin1" in c.pins
+    assert "missing" not in c.pins
+
+
+def test_pins_contains_by_pin(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    pin = c.pins[0]
+    assert pin in c.pins
+
+
+def test_pins_contains_by_base(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    assert c.pins[0].base in c.pins
+
+
+def test_pins_iter(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    names = [p.name for p in c.pins]
+    assert names == ["pin1"]
+
+
+def test_pins_len(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    assert len(c.pins) == 1
+
+
+def test_pins_to_dtype_to_itype(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    d = c.pins.to_dtype()
+    assert isinstance(d, DPins)
+    i = d.to_itype()
+    assert isinstance(i, Pins)
+
+
+def test_pins_get_all_named(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    named = c.pins.get_all_named()
+    assert "pin1" in named
+
+
+def test_pins_create_pin_empty_ports_raises(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    with pytest.raises(ValueError, match="At least one port"):
+        c.pins.create_pin(name="p_empty", ports=[])
+
+
+def test_pins_create_pin_with_info(kcl: kf.KCLayout, layers: Layers) -> None:
+    xs = kf.SymmetricalCrossSection(
+        width=5000,
+        enclosure=kf.LayerEnclosure(main_layer=layers.METAL1, name="M1_INFO"),
+    )
+    c = kcl.kcell()
+    p1 = c.create_port(name="e1", trans=kf.kdb.Trans.R0, cross_section=xs)
+    pin = c.pins.create_pin(name="p_info", ports=[p1], info={"key": "value"})
+    assert pin.info["key"] == "value"
+
+
+def test_pins_create_pin_wrong_kcl_raises(layers: Layers) -> None:
+    kcl1 = kf.KCLayout("PINS_WRONG_KCL_1", infos=Layers)
+    kcl2 = kf.KCLayout("PINS_WRONG_KCL_2", infos=Layers)
+    xs = kf.SymmetricalCrossSection(
+        width=5000,
+        enclosure=kf.LayerEnclosure(main_layer=layers.METAL1, name="M1_WK"),
+    )
+    c1 = kcl1.kcell()
+    c2 = kcl2.kcell()
+    port = c1.create_port(name="e1", trans=kf.kdb.Trans.R0, cross_section=xs)
+    with pytest.raises(ValueError, match="different layout"):
+        c2.pins.create_pin(name="p_wrong", ports=[port])
+
+
+def test_cell_create_pin_port_from_other_cell_raises(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    """Same kcl, different cell: cell.create_pin must reject the foreign port."""
+    xs = kf.SymmetricalCrossSection(
+        width=5000,
+        enclosure=kf.LayerEnclosure(main_layer=layers.METAL1, name="M1_OC"),
+    )
+    c_owner = kcl.kcell()
+    c_other = kcl.kcell()
+    port = c_owner.create_port(name="e1", trans=kf.kdb.Trans.R0, cross_section=xs)
+    with pytest.raises(ValueError, match="not a port of cell"):
+        c_other.create_pin(name="p", ports=[port])
+
+
+def test_dcell_create_pin_port_from_other_cell_raises(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    """Same as above, but going through DKCell.create_pin."""
+    xs = kf.SymmetricalCrossSection(
+        width=5000,
+        enclosure=kf.LayerEnclosure(main_layer=layers.METAL1, name="M1_OC_D"),
+    )
+    c_owner = kcl.dkcell()
+    c_other = kcl.dkcell()
+    port = c_owner.create_port(name="e1", trans=kf.kdb.Trans.R0, cross_section=xs)
+    with pytest.raises(ValueError, match="not a port of cell"):
+        c_other.create_pin(name="p", ports=[port])
+
+
+def test_pin_repr(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    r = repr(c.pins[0])
+    assert "Pin" in r
+    assert "pin1" in r
+
+
+def test_pin_to_dtype_and_back(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    p = c.pins[0]
+    dp = p.to_dtype()
+    assert isinstance(dp, DPin)
+    ip = dp.to_itype()
+    assert isinstance(ip, Pin)
+
+
+def test_pin_setters(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    p = c.pins[0]
+    p.name = "renamed"
+    assert p.name == "renamed"
+    p.pin_type = "RF"
+    assert p.pin_type == "RF"
+    new_info = Info(extra="x")
+    p.info = new_info
+    assert p.info["extra"] == "x"
+
+
+def test_pin_kcl_setter(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    p = c.pins[0]
+    # setter just assigns; reading back should return same kcl
+    p.kcl = kcl
+    assert p.kcl is kcl
+
+
+def test_pin_getitem_by_index(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    pin = c.pins[0]
+    port = pin[0]
+    assert port.name in ("e1", "e2")
+
+
+def test_pin_getitem_by_name(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    pin = c.pins[0]
+    port = pin["e1"]
+    assert port.name == "e1"
+
+
+def test_pin_getitem_missing(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    pin = c.pins[0]
+    with pytest.raises(KeyError, match="not a valid port name"):
+        pin["missing_port"]
+
+
+def test_pin_ports_setter(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    pin = c.pins[0]
+    original = list(pin.ports)
+    pin.ports = list(reversed(original))
+    assert [p.name for p in pin.ports] == [original[1].name, original[0].name]
+
+
+def test_pin_copy_with_transform(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    p = c.pins[0]
+    trans = kf.kdb.Trans(0, False, 100, 100)
+    cp = p.copy(trans=trans)
+    assert isinstance(cp, Pin)
+    assert cp.name == p.name
+
+
+def test_dpin_copy_with_transform(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    p = c.pins[0].to_dtype()
+    trans = kf.kdb.DCplxTrans()
+    cp = p.copy(trans=trans)
+    assert isinstance(cp, DPin)
+
+
+def test_dpin_getitem(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    dpin = c.pins[0].to_dtype()
+    port = dpin["e1"]
+    assert port.name == "e1"
+    assert dpin[0].name in ("e1", "e2")
+    with pytest.raises(KeyError):
+        dpin["missing"]
+
+
+def test_dpin_ports_setter(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    dpin = c.pins[0].to_dtype()
+    orig = list(dpin.ports)
+    dpin.ports = list(reversed(orig))
+    assert [p.name for p in dpin.ports] == [orig[1].name, orig[0].name]
+
+
+def test_dpins_getitem_missing(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    dpins = c.pins.to_dtype()
+    with pytest.raises(KeyError):
+        dpins["missing"]
+
+
+def test_dpins_getitem_by_index(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    dpins = c.pins.to_dtype()
+    assert dpins[0].name == "pin1"
+
+
+def test_dpins_iter_and_named(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    dpins = c.pins.to_dtype()
+    names = [p.name for p in dpins]
+    assert names == ["pin1"]
+    assert "pin1" in dpins.get_all_named()
+
+
+def test_dpins_create_pin_empty_raises(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell()
+    dpins = c.pins.to_dtype()
+    with pytest.raises(ValueError, match="At least one port"):
+        dpins.create_pin(name="x", ports=[])
+
+
+def test_dpins_create_pin_other_kcl_raises(layers: Layers) -> None:
+    """DPins.create_pin rejects ports that belong to a different kcl."""
+    kcl1 = kf.KCLayout("DPINS_OTHER_KCL_1", infos=Layers)
+    kcl2 = kf.KCLayout("DPINS_OTHER_KCL_2", infos=Layers)
+    xs = kf.SymmetricalCrossSection(
+        width=5000,
+        enclosure=kf.LayerEnclosure(main_layer=layers.METAL1, name="M1_DXK"),
+    )
+    c1 = kcl1.kcell()
+    c2 = kcl2.kcell()
+    port = c1.create_port(name="e1", trans=kf.kdb.Trans.R0, cross_section=xs)
+    dpins = c2.pins.to_dtype()
+    with pytest.raises(ValueError, match="different layout"):
+        dpins.create_pin(name="p", ports=[port])
+
+
+def test_pins_filter_no_match(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers, pin_type="DC", pin_name="pin1")
+    assert c.pins.filter(pin_type="RF") == []
+    assert c.pins.filter(regex="^xx") == []
+
+
+def test_pins_filter_match(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers, pin_type="DC", pin_name="pin1")
+    assert len(c.pins.filter(pin_type="DC")) == 1
+    assert len(c.pins.filter(regex="^pin")) == 1
+
+
+def test_pins_print(
+    capsys: pytest.CaptureFixture[str], kcl: kf.KCLayout, layers: Layers
+) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    c.pins.print()
+    out = capsys.readouterr().out
+    assert "pin1" in out
+
+
+def test_filter_regex_handles_none_name() -> None:
+    kcl = kf.KCLayout("FILTER_REG_NONE", infos=Layers)
+    bp = BasePin(name="foo", kcl=kcl, ports=[], info=Info(), pin_type="DC")
+    p = Pin(base=bp)
+    # set name to "" or rely on regex - filter_regex returns False when name is None
+    pins = [p]
+    result = list(filter_regex(pins, regex="^foo"))
+    assert result == [p]
+
+
+def test_filter_type() -> None:
+    kcl = kf.KCLayout("FILTER_TYPE", infos=Layers)
+    bp1 = BasePin(name="a", kcl=kcl, ports=[], info=Info(), pin_type="DC")
+    bp2 = BasePin(name="b", kcl=kcl, ports=[], info=Info(), pin_type="RF")
+    pins = [Pin(base=bp1), Pin(base=bp2)]
+    dc = list(filter_type(pins, "DC"))
+    assert len(dc) == 1
+    assert dc[0].name == "a"
+
+
+def test_pin_iter_via_each_pin_on_instance(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top << c
+    # iter on instance.pins
+    pins = list(inst.pins)
+    assert len(pins) == 1
+
+
+def test_instance_pins_repr_str(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top << c
+    assert "n=1" in repr(inst.pins)
+    assert "pins" in str(inst.pins)
+
+
+def test_instance_pins_contains(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top << c
+    assert "pin1" in inst.pins
+    assert "missing" not in inst.pins
+
+
+def test_instance_pins_getitem_missing(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top << c
+    with pytest.raises(KeyError):
+        inst.pins["missing"]
+
+
+def test_instance_pins_array(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top.create_inst(
+        c, na=2, nb=2, a=kf.kdb.Vector(80_000, 0), b=kf.kdb.Vector(0, 80_000)
+    )
+    # 1 cell pin * 2 * 2
+    assert len(inst.pins) == 4
+
+    # Indexing into an array with tuple
+    pin = inst.pins[("pin1", 0, 1)]
+    assert pin.name == "pin1"
+
+    # Out of range raises
+    with pytest.raises(IndexError):
+        inst.pins[("pin1", 5, 5)]
+
+
+def test_instance_pins_array_default_indices(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top.create_inst(
+        c, na=2, nb=2, a=kf.kdb.Vector(80_000, 0), b=kf.kdb.Vector(0, 80_000)
+    )
+    # Single string key on an array -> defaults to (0,0)
+    pin = inst.pins["pin1"]
+    assert pin.name == "pin1"
+
+
+def test_instance_pins_filter(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top << c
+    assert len(inst.pins.filter(pin_type="DC")) == 1
+    assert inst.pins.filter(regex="^xx") == []
+
+
+def test_instance_pins_copy(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top << c
+    cp = inst.pins.copy()
+    assert isinstance(cp, Pins)
+    assert len(cp) == 1
+
+
+def test_instance_pins_copy_array(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top.create_inst(
+        c, na=2, nb=2, a=kf.kdb.Vector(80_000, 0), b=kf.kdb.Vector(0, 80_000)
+    )
+    cp = inst.pins.copy()
+    assert isinstance(cp, Pins)
+    assert len(cp) == 4
+
+
+def test_dinstance_pins_filter(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.dkcell()
+    inst = top << c.to_dtype()
+    assert len(inst.pins.filter(pin_type="DC")) == 1
+
+
+def test_dinstance_pins_getitem(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.dkcell()
+    inst = top << c.to_dtype()
+    pin = inst.pins["pin1"]
+    assert pin.name == "pin1"
+    assert len(list(inst.pins)) == 1
+
+
+def test_each_by_array_coord_non_array(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top << c
+    coords = list(inst.pins.each_by_array_coord())
+    assert len(coords) == 1
+    assert coords[0][:2] == (0, 0)
+
+
+def test_each_by_array_coord_array(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = _make_kcell_with_pin(kcl, layers)
+    top = kcl.kcell()
+    inst = top.create_inst(
+        c, na=2, nb=2, a=kf.kdb.Vector(80_000, 0), b=kf.kdb.Vector(0, 80_000)
+    )
+    coords = list(inst.pins.each_by_array_coord())
+    assert len(coords) == 4
+    # Should include all combinations
+    keys = {(a, b) for a, b, _ in coords}
+    assert keys == {(0, 0), (0, 1), (1, 0), (1, 1)}
diff --git a/tests/test_port.py b/tests/test_port.py
index eea055880..e1acc456c 100644
--- a/tests/test_port.py
+++ b/tests/test_port.py
@@ -5,7 +5,7 @@
 import pytest
 
 import kfactory as kf
-from kfactory.cross_section import CrossSection, CrossSectionSpec
+from kfactory.cross_section import CrossSection, CrossSectionSpecDict
 from tests.conftest import Layers
 
 _PortsType = tuple[kf.port.DPort, kf.port.Port, kf.port.DPort, kf.port.Port]
@@ -18,10 +18,10 @@
 
 def get_ports() -> _PortsType:
     base = kf.port.BasePort(
-        name=None,
+        name="o1",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         trans=kf.kdb.Trans(0, 0),
@@ -62,10 +62,10 @@ def test_create_port_error(kcl: kf.KCLayout, layers: Layers) -> None:
 def test_invalid_base_port_trans(kcl: kf.KCLayout, layers: Layers) -> None:
     with pytest.raises(ValueError, match=r"Both trans and dcplx_trans cannot be None."):
         kf.port.BasePort(
-            name=None,
+            name="o1",
             kcl=kcl,
             cross_section=kcl.get_symmetrical_cross_section(
-                CrossSectionSpec(layer=layers.WG, width=2000)
+                CrossSectionSpecDict(layer=layers.WG, width=2000)
             ),
             port_type="optical",
         )
@@ -74,10 +74,10 @@ def test_invalid_base_port_trans(kcl: kf.KCLayout, layers: Layers) -> None:
         ValueError, match=r"Only one of trans or dcplx_trans can be set."
     ):
         kf.port.BasePort(
-            name=None,
+            name="o2",
             kcl=kcl,
             cross_section=kcl.get_symmetrical_cross_section(
-                CrossSectionSpec(layer=layers.WG, width=2000)
+                CrossSectionSpecDict(layer=layers.WG, width=2000)
             ),
             port_type="optical",
             trans=kf.kdb.Trans(1, 0),
@@ -87,20 +87,20 @@ def test_invalid_base_port_trans(kcl: kf.KCLayout, layers: Layers) -> None:
 
 def test_base_port_ser_model(kcl: kf.KCLayout, layers: Layers) -> None:
     port = kf.port.BasePort(
-        name=None,
+        name="o1",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         trans=kf.kdb.Trans(1, 0),
     )
     assert port.ser_model()
     port = kf.port.BasePort(
-        name=None,
+        name="o2",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         dcplx_trans=kf.kdb.DCplxTrans(1, 0),
@@ -110,10 +110,10 @@ def test_base_port_ser_model(kcl: kf.KCLayout, layers: Layers) -> None:
 
 def test_base_port_get_trans(kcl: kf.KCLayout, layers: Layers) -> None:
     port = kf.port.BasePort(
-        name=None,
+        name="o1",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         trans=kf.kdb.Trans(1, 0),
@@ -123,10 +123,10 @@ def test_base_port_get_trans(kcl: kf.KCLayout, layers: Layers) -> None:
     assert port.get_dcplx_trans() == kf.kdb.DCplxTrans(0.001, 0)
 
     port = kf.port.BasePort(
-        name=None,
+        name="o1",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         dcplx_trans=kf.kdb.DCplxTrans(1, 0),
@@ -138,10 +138,10 @@ def test_base_port_get_trans(kcl: kf.KCLayout, layers: Layers) -> None:
 
 def test_base_port_eq(kcl: kf.KCLayout, layers: Layers) -> None:
     port1 = kf.port.BasePort(
-        name=None,
+        name="o1",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         trans=kf.kdb.Trans(1, 0),
@@ -164,10 +164,10 @@ def test_port_eq(port: kf.port.ProtoPort[Any]) -> None:
 
 def test_port_kcl(kcl: kf.KCLayout, pdk: kf.KCLayout, layers: Layers) -> None:
     port = kf.port.Port(
-        name=None,
+        name="o1",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         trans=kf.kdb.Trans(1, 0),
@@ -179,44 +179,44 @@ def test_port_kcl(kcl: kf.KCLayout, pdk: kf.KCLayout, layers: Layers) -> None:
 
 def test_port_cross_section(kcl: kf.KCLayout, layers: Layers) -> None:
     base_port = kf.port.BasePort(
-        name=None,
+        name="o1",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         trans=kf.kdb.Trans(1, 0),
     )
     port = kf.port.Port(base=base_port)
     assert port.cross_section.base is kcl.get_symmetrical_cross_section(
-        CrossSectionSpec(layer=layers.WG, width=2000)
+        CrossSectionSpecDict(layer=layers.WG, width=2000)
     )
     assert port.cross_section.width == 2000
     port.cross_section = kcl.get_symmetrical_cross_section(
-        CrossSectionSpec(layer=layers.WG, width=3000)
+        CrossSectionSpecDict(layer=layers.WG, width=3000)
     )
     assert port.cross_section.base is kcl.get_symmetrical_cross_section(
-        CrossSectionSpec(layer=layers.WG, width=3000)
+        CrossSectionSpecDict(layer=layers.WG, width=3000)
     )
     port.cross_section = CrossSection(
         kcl,
         base=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=3000)
+            CrossSectionSpecDict(layer=layers.WG, width=3000)
         ),
     )
     assert port.cross_section.base is kcl.get_symmetrical_cross_section(
-        CrossSectionSpec(layer=layers.WG, width=3000)
+        CrossSectionSpecDict(layer=layers.WG, width=3000)
     )
     assert port.width == 3000
     dport = port.to_dtype()
     dport.cross_section = CrossSection(
         kcl,
         base=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=3000)
+            CrossSectionSpecDict(layer=layers.WG, width=3000)
         ),
     )
     assert dport.cross_section.base is kcl.get_symmetrical_cross_section(
-        CrossSectionSpec(layer=layers.WG, width=3000)
+        CrossSectionSpecDict(layer=layers.WG, width=3000)
     )
 
 
@@ -290,20 +290,20 @@ def test_to_itype() -> None:
 
 def test_port_copy(kcl: kf.KCLayout, layers: Layers) -> None:
     port = kf.DPort(
-        name=None,
+        name="o1",
         kcl=kcl,
         cross_section=kcl.get_symmetrical_cross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         trans=kf.kdb.Trans(1, 0),
     )
     port2 = port.copy()
     port.trans = kf.kdb.Trans(2, 0)
-    assert port2.name is None
+    assert port2.name == "o1"
     assert port2.kcl is kcl
     assert port2.cross_section.base is kcl.get_symmetrical_cross_section(
-        CrossSectionSpec(layer=layers.WG, width=2000)
+        CrossSectionSpecDict(layer=layers.WG, width=2000)
     )
     assert port2.port_type == "optical"
     assert port2.trans == kf.kdb.Trans(1, 0)
@@ -374,13 +374,13 @@ def test_dport_init_with_port() -> None:
 
 def test_port_invalid_init() -> None:
     with pytest.raises(ValueError):
-        kf.Port(name="o1", layer=1, center=(1000, 1000), angle=1)  # type: ignore[call-overload]
+        kf.Port(name="o1", layer=1, center=(1000, 1000), angle=1)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError):
-        kf.Port(name="o1", width=10, center=(1000, 1000), angle=1)  # type: ignore[call-overload]
+        kf.Port(name="o1", width=10, center=(1000, 1000), angle=1)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError):
-        kf.Port(name="o1", layer=1, width=10)  # type: ignore[call-overload]
+        kf.Port(name="o1", layer=1, width=10)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError, match=r"Width must be greater than 0."):
         kf.Port(name="o1", width=-10, layer=1, center=(1000, 1000), angle=1)
@@ -388,10 +388,10 @@ def test_port_invalid_init() -> None:
 
 def test_dport_invalid_init() -> None:
     with pytest.raises(ValueError):
-        kf.DPort(name="o1", layer=1, center=(1000, 1000), orientation=90)  # type: ignore[call-overload]
+        kf.DPort(name="o1", layer=1, center=(1000, 1000), orientation=90)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError):
-        kf.DPort(name="o1", width=10, center=(1000, 1000), orientation=90)  # type: ignore[call-overload]
+        kf.DPort(name="o1", width=10, center=(1000, 1000), orientation=90)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError, match=r"Width must be greater than 0."):
         kf.DPort(name="o1", width=-10, layer=1, center=(1000, 1000), orientation=90)
@@ -438,7 +438,7 @@ def test_dport_copy_polar() -> None:
 def test_autorename(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     cell = kf.factories.straight.straight_dbu_factory(kcl)(
         length=10000, width=2000, layer=layers.WG
@@ -451,13 +451,13 @@ def _rename_ports(ports: kf.Ports) -> None:
     kf.port.autorename(cell, _rename_ports)
 
     assert cell.ports.get_all_named().keys() == {"o3", "o4"}
-    gds_regression(cell)
+    oas_regression(cell)
 
 
 def test_rename_clockwise(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     cell = kf.factories.straight.straight_dbu_factory(kcl)(
         length=10000, width=2000, layer=layers.WG
@@ -466,13 +466,13 @@ def test_rename_clockwise(
     kf.port.rename_clockwise(_ports, start=0)
     assert _ports[0].name == "o0"
     assert _ports[1].name == "o1"
-    gds_regression(cell)
+    oas_regression(cell)
 
 
 def test_filter_regex(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     cell = kf.factories.straight.straight_dbu_factory(kcl)(
         length=10000, width=2000, layer=layers.WG
@@ -481,16 +481,16 @@ def test_filter_regex(
     filtered = list(kf.port.filter_regex(ports, "o2"))
     assert len(filtered) == 1
 
-    filtered[0].name = None
+    filtered[0].name = "o1"
     filtered = list(kf.port.filter_regex(filtered, "o2"))
     assert len(list(filtered)) == 0
-    gds_regression(cell)
+    oas_regression(cell)
 
 
 def test_filter_layer_pt_reg(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     cell = kf.factories.straight.straight_dbu_factory(kcl)(
         length=10000, width=2000, layer=layers.WG
@@ -500,13 +500,37 @@ def test_filter_layer_pt_reg(
         ports, layer=0, port_type="optical", regex="o2"
     )
     assert len(list(filtered)) == 1
-    gds_regression(cell)
+    oas_regression(cell)
+
+
+def test_filter_layer_info(
+    kcl: kf.KCLayout,
+    layers: Layers,
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
+) -> None:
+    cell = kf.factories.straight.straight_dbu_factory(kcl)(
+        length=10000, width=2000, layer=layers.WG
+    )
+    ports = cell.ports
+    filtered = list(kf.port.filter_layer_info(ports, layers.WG))
+    assert len(filtered) == 2
+
+    filtered = list(kf.port.filter_layer_info(ports, kf.kdb.LayerInfo(42, 0)))
+    assert len(filtered) == 0
+
+    filtered = list(
+        kf.port.filter_layer_pt_reg(
+            ports, layer_info=layers.WG, port_type="optical", regex="o2"
+        )
+    )
+    assert len(filtered) == 1
+    oas_regression(cell)
 
 
 def test_rename_clockwise_multi(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     cell = kf.factories.straight.straight_dbu_factory(kcl)(
         length=10000, width=2000, layer=layers.WG
@@ -516,22 +540,22 @@ def test_rename_clockwise_multi(
     ports["o2"].name = "o5"
     kf.port.rename_clockwise_multi(ports, layers=[0], regex="o4")
     assert len(list(ports)) == 2
-    gds_regression(cell)
+    oas_regression(cell)
 
 
 def test_create(
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     cell = kcl.kcell()
 
     cell.create_port(
         name="o1",
         cross_section=kcl.get_icross_section(
-            CrossSectionSpec(layer=layers.WG, width=2000)
+            CrossSectionSpecDict(layer=layers.WG, width=2000)
         ),
         port_type="optical",
         trans=kf.kdb.Trans(1, 0),
     )
-    gds_regression(cell)
+    oas_regression(cell)
diff --git a/tests/test_ports.py b/tests/test_ports.py
index 80a31a879..547cd876b 100644
--- a/tests/test_ports.py
+++ b/tests/test_ports.py
@@ -128,7 +128,11 @@ def test_keep_mirror(layers: Layers) -> None:
     c = kf.KCell()
 
     p1 = kf.Port(
-        trans=kf.kdb.Trans.M90, width=1000, layer=c.kcl.find_layer(layers.WG), kcl=c.kcl
+        name="o1",
+        trans=kf.kdb.Trans.M90,
+        width=1000,
+        layer=c.kcl.find_layer(layers.WG),
+        kcl=c.kcl,
     )
 
     c.add_port(port=p1, name="o1")
@@ -218,6 +222,7 @@ def test_dplx_port_dbu_port_conversion(layers: Layers, kcl: kf.KCLayout) -> None
     t1 = kf.kdb.DCplxTrans(1, 90, False, 10, 10)
     t2 = kf.kdb.Trans(1, False, 10_000, 10_000)
     p = kf.Port(
+        name="o1",
         width=kcl.to_dbu(1),
         dcplx_trans=t1,
         layer=kcl.find_layer(layers.WG),
@@ -304,13 +309,13 @@ def test_ports_create_port(kcl: kf.KCLayout, layers: Layers) -> None:
     assert port in ports
 
     with pytest.raises(ValueError):
-        ports.create_port(name="o1", layer=1, center=(1000, 1000), angle=1)  # type: ignore[call-overload]
+        ports.create_port(name="o1", layer=1, center=(1000, 1000), angle=1)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError):
-        ports.create_port(name="o1", width=10, center=(1000, 1000), angle=1)  # type: ignore[call-overload]
+        ports.create_port(name="o1", width=10, center=(1000, 1000), angle=1)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError):
-        ports.create_port(name="o1", layer=1, width=10)  # type: ignore[call-overload]
+        ports.create_port(name="o1", layer=1, width=10)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError, match=r"and greater than 0."):
         ports.create_port(name="o1", width=-10, layer=1, center=(1000, 1000), angle=1)
@@ -417,13 +422,13 @@ def test_dports_create_port(kcl: kf.KCLayout, layers: Layers) -> None:
     assert port in ports
 
     with pytest.raises(ValueError):
-        ports.create_port(name="o1", layer=1, center=(1000, 1000), orientation=1)  # type: ignore[call-overload]
+        ports.create_port(name="o1", layer=1, center=(1000, 1000), orientation=1)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError):
-        ports.create_port(name="o1", width=10, center=(1000, 1000), orientation=1)  # type: ignore[call-overload]
+        ports.create_port(name="o1", width=10, center=(1000, 1000), orientation=1)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError):
-        ports.create_port(name="o1", layer=1, width=10)  # type: ignore[call-overload]
+        ports.create_port(name="o1", layer=1, width=10)  # ty:ignore[no-matching-overload]
 
     with pytest.raises(ValueError, match=r"and greater than 0."):
         ports.create_port(
diff --git a/tests/test_rename.py b/tests/test_rename.py
index b8c8ab36f..c1980487a 100644
--- a/tests/test_rename.py
+++ b/tests/test_rename.py
@@ -47,7 +47,7 @@ def port_tests(rename_f: Callable[..., None] | None = None) -> kf.KCell:
 @pytest.mark.parametrize("func", [None, port.rename_clockwise_multi])
 def test_rename_default(
     func: Callable[..., None],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     port_tests = _define_port_tests(kcl)
@@ -76,11 +76,11 @@ def test_rename_default(
     assert [p.name for p in port_list] == [
         f"o{i + 1}" for i in inds_east + inds_north + inds_west + inds_south
     ]
-    gds_regression(cell)
+    oas_regression(cell)
 
 
 def test_rename_orientation(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
 ) -> None:
     port_tests = _define_port_tests(kcl)
     cell = port_tests(port.rename_by_direction)
@@ -95,12 +95,12 @@ def test_rename_orientation(
     )
 
     assert [p.name for p in port_list] == names
-    gds_regression(cell)
+    oas_regression(cell)
 
 
 def test_rename_setter(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     kcl = kf.KCLayout("TEST_RENAME", infos=Layers)
     kcl.layers = kcl.layerenum_from_dict(layers=layers)
@@ -198,5 +198,5 @@ def test_rename_setter(
     kcl.rename_function = kf.port.rename_clockwise_multi
 
     assert c1.ports[0].name == "o1"
-    gds_regression(c1)
+    oas_regression(c1)
     assert c2.ports[0].name == "W0"
diff --git a/tests/test_routing.py b/tests/test_routing.py
index 176f5b49a..a33ae9d96 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -1,4 +1,4 @@
-from collections.abc import Callable
+from collections.abc import Callable, Sequence
 from functools import partial
 from typing import Any
 
@@ -6,6 +6,7 @@
 import pytest
 
 import kfactory as kf
+from kfactory.routing.utils import RouteDebug
 from tests.conftest import Layers
 
 smart_bundle_routing_params = [
@@ -22,35 +23,6 @@
 ]
 
 
-@pytest.mark.parametrize(
-    "x",
-    [
-        5000,
-        0,
-    ],
-)
-def test_route_straight(
-    x: int,
-    bend90: kf.KCell,
-    straight_factory_dbu: Callable[..., kf.KCell],
-    optical_port: kf.Port,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
-    kcl: kf.KCLayout,
-) -> None:
-    c = kcl.kcell()
-    p1 = optical_port.copy()
-    p2 = optical_port.copy()
-    p2.trans = kf.kdb.Trans(2, False, x, 0)
-    kf.routing.optical.route(
-        c,
-        p1,
-        p2,
-        straight_factory=straight_factory_dbu,
-        bend90_cell=bend90,
-    )
-    gds_regression(c)
-
-
 @pytest.mark.parametrize(
     ("element", "loops", "loop_side"), [(1, 1, 1), (2, 2, 0), (-1, 4, 0), (-2, 1, -1)]
 )
@@ -60,7 +32,7 @@ def test_route_length_match(
     loop_side: int,
     bend90: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     layers: Layers,
     kcl: kf.KCLayout,
 ) -> None:
@@ -68,6 +40,7 @@ def test_route_length_match(
 
     start_ports = [
         kf.Port(
+            name=f"in_{x1}",
             trans=kf.kdb.Trans(1, False, x1 * 200_000, -x1 * 150_000),
             width=500,
             layer_info=layers.WG,
@@ -77,6 +50,7 @@ def test_route_length_match(
     start_ports[1].y -= 1
     end_ports = [
         kf.Port(
+            name=f"out_{x1}",
             trans=kf.kdb.Trans(3, False, x1, 500_000),
             width=500,
             layer_info=layers.WG,
@@ -91,226 +65,30 @@ def test_route_length_match(
         straight_factory=straight_factory_dbu,
         bend90_cell=bend90,
         separation=10_000,
-        path_length_matching_config={
-            "element": element,
-            "loop_side": loop_side,
-            "loops": loops,
-            "loop_position": 0,
-        },
-    )
-    gds_regression(c)
-    c.show()
-
-
-def test_route_length_match_errors(
-    bend90: kf.KCell,
-    straight_factory_dbu: Callable[..., kf.KCell],
-    layers: Layers,
-    kcl: kf.KCLayout,
-) -> None:
-    c = kcl.kcell("route_length_match_errors")
-
-    start_ports = [
-        kf.Port(
-            trans=kf.kdb.Trans(1, False, x1 * 200_000, -x1 * 300_000),
-            width=500,
-            layer_info=layers.WG,
-        )
-        for x1 in range(3)
-    ]
-    end_ports = [
-        kf.Port(
-            trans=kf.kdb.Trans(3, False, x1, 500_000),
-            width=500,
-            layer_info=layers.WG,
-        )
-        for x1 in [230_000, 400_000, 500_000]
-    ]
-    with pytest.raises(ValueError):
-        kf.routing.optical.route_bundle(
-            c,
-            start_ports,
-            end_ports,
-            straight_factory=straight_factory_dbu,
-            bend90_cell=bend90,
-            separation=10_000,
-            path_length_matching_config={
-                "element": None,
-                "loop_side": 1,
-                "loops": 2,
-                "loop_position": 0,
-            },
-        )  # type: ignore[call-overload]
-    with pytest.raises(ValueError):
-        kf.routing.optical.route_bundle(
-            c,
-            start_ports,
-            end_ports,
-            straight_factory=straight_factory_dbu,
-            bend90_cell=bend90,
-            separation=10_000,
-            path_length_matching_config={
-                "element": None,
-                "loop_side": 1,
-                "loops": 2,
-                "loop_position": 0,
-            },
-        )  # type: ignore[call-overload]
-    with pytest.raises(ValueError):
-        kf.routing.optical.route_bundle(
-            c,
-            start_ports,
-            end_ports,
-            straight_factory=straight_factory_dbu,
-            bend90_cell=bend90,
-            separation=10_000,
-            path_length_matching_config={
-                "element": 1,
-                "loop_side": None,
-                "loops": 2,
-                "loop_position": 0,
-            },
-        )  # type: ignore[call-overload]
-    with pytest.raises(ValueError):
-        kf.routing.optical.route_bundle(
-            c,
-            start_ports,
-            end_ports,
-            straight_factory=straight_factory_dbu,
-            bend90_cell=bend90,
-            separation=10_000,
-            path_length_matching_config={
-                "element": 1,
-                "loop_side": 1,
-                "loops": 2,
-                "loop_position": None,
-            },
-        )  # type: ignore[call-overload]
-
-
-@pytest.mark.parametrize(
-    ("x", "y", "angle2"),
-    [
-        (20000, 20000, 2),
-        (10000, 10000, 3),
-        (150532, 12112, 3),
-        (5000, 10000, 3),  # the mean one where points will collide for radius 10000
-        (30000, 5000, 3),
-        (500, 500, 3),
-        (-500, 30000, 3),
-        (500, 30000, 3),
-        (-10000, 30000, 3),
-        (0, 0, 2),
-    ],
-)
-def test_route_bend90(
-    bend90: kf.KCell,
-    straight_factory_dbu: Callable[..., kf.KCell],
-    optical_port: kf.Port,
-    x: int,
-    y: int,
-    angle2: int,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
-    kcl: kf.KCLayout,
-) -> None:
-    c = kcl.kcell()
-    p1 = optical_port.copy()
-    p2 = optical_port.copy()
-    p2.trans = kf.kdb.Trans(angle2, False, x, y)
-    b90r = abs(bend90.ports[0].x - bend90.ports[1].x)
-    if abs(x) < b90r or abs(y) < b90r:
-        kf.config.logfilter.regex = "route is too small, potential collisions:"
-    kf.routing.optical.route(
-        c,
-        p1,
-        p2,
-        straight_factory=straight_factory_dbu,
-        bend90_cell=bend90,
+        constraints=[
+            kf.PathLengthMatch(
+                route_names=["path_length_matching"],
+                element=element,
+                loop_side=loop_side,
+                loops=loops,
+                loop_position=0,
+            )
+        ],
+        route_name="path_length_matching",
     )
+    oas_regression(c)
 
-    kf.config.logfilter.regex = None
-    gds_regression(c)
-
-
-@pytest.mark.parametrize(
-    ("x", "y", "angle2"),
-    [
-        (20000, 20000, 2),
-        (10000, 10000, 3),
-        (15212, 19921, 3),
-        (5000, 10000, 3),  # the mean one where points will collide for radius 10000
-        (30000, 5000, 3),
-        (500, 500, 3),
-        (-500, 30000, 3),
-        (500, 30000, 3),
-        (-10000, 30000, 3),
-        (0, 0, 2),
-        (500000, 50000, 2),
-    ],
-)
-def test_route_bend90_invert(
-    bend90: kf.KCell,
-    straight_factory_dbu: Callable[..., kf.KCell],
-    optical_port: kf.Port,
-    x: int,
-    y: int,
-    angle2: int,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
-    kcl: kf.KCLayout,
-) -> None:
-    c = kcl.kcell()
-    p1 = optical_port.copy()
-    p2 = optical_port.copy()
-    p2.trans = kf.kdb.Trans(angle2, False, x, y)
-    b90r = abs(bend90.ports[0].x - bend90.ports[1].x)
-    if abs(x) < b90r or abs(y) < b90r:
-        kf.config.logfilter.regex = "route is too small, potential collisions:"
-    kf.routing.optical.route(
-        c,
-        p1,
-        p2,
-        straight_factory=straight_factory_dbu,
-        bend90_cell=bend90,
-        route_kwargs={"invert": True},
-    )
-    kf.config.logfilter.regex = None
-    gds_regression(c)
 
+def test_route_length_match_errors() -> None:
+    # element, loop_side, loop_position must be int — Pydantic rejects non-int values
+    from pydantic import ValidationError
 
-@pytest.mark.parametrize(
-    ("x", "y", "angle2"),
-    [
-        (40000, 40000, 2),
-        (20000, 20000, 3),
-        (10000, 10000, 3),
-    ],
-)
-def test_route_bend90_euler(
-    bend90_euler: kf.KCell,
-    straight_factory_dbu: Callable[..., kf.KCell],
-    optical_port: kf.Port,
-    x: int,
-    y: int,
-    angle2: int,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
-    kcl: kf.KCLayout,
-) -> None:
-    c = kcl.kcell()
-    p1 = optical_port.copy()
-    p2 = optical_port.copy()
-    p2.trans = kf.kdb.Trans(angle2, False, x, y)
-    b90r = abs(bend90_euler.ports[0].x - bend90_euler.ports[1].x)
-    if abs(x) < b90r or abs(y) < b90r:
-        kf.config.logfilter.regex = "route is too small, potential collisions:"
-    kf.routing.optical.route(
-        c,
-        p1,
-        p2,
-        straight_factory=straight_factory_dbu,
-        bend90_cell=bend90_euler,
-    )
-    kf.config.logfilter.regex = None
-    gds_regression(c)
+    with pytest.raises(ValidationError):
+        kf.PathLengthMatch(route_names=[], element=None)  # ty:ignore[invalid-argument-type]
+    with pytest.raises(ValidationError):
+        kf.PathLengthMatch(route_names=[], loop_side=None)  # ty:ignore[invalid-argument-type]
+    with pytest.raises(ValidationError):
+        kf.PathLengthMatch(route_names=[], loop_position=None)  # ty:ignore[invalid-argument-type]
 
 
 def test_route_bundle(
@@ -318,7 +96,7 @@ def test_route_bundle(
     bend90_euler: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     c = kcl.kcell("TEST_ROUTE_BUNDLE")
 
@@ -373,7 +151,7 @@ def test_route_bundle(
         assert np.isclose(route.length, length)
 
     c.auto_rename_ports()
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_length_straight(
@@ -382,7 +160,7 @@ def test_route_length_straight(
     straight_factory_dbu: Callable[..., kf.KCell],
     kcl: kf.KCLayout,
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     c = kcl.kcell("TEST_ROUTE_BUNDLE_AREA_LENGTH")
     p1 = kf.Port(name="o1", width=1000, trans=kf.kdb.Trans.R0, layer_info=layers.WG)
@@ -400,7 +178,7 @@ def test_route_length_straight(
     )
 
     assert [r.length for r in routes] == [10_000]
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_bundle_route_width(
@@ -408,7 +186,7 @@ def test_route_bundle_route_width(
     bend90_euler_small: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
     kcl: kf.KCLayout,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     c = kcl.kcell("TEST_ROUTE_BUNDLE")
 
@@ -451,7 +229,7 @@ def test_route_bundle_route_width(
         c.add_port(port=route.end_port)
 
     c.auto_rename_ports()
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_length(
@@ -459,7 +237,7 @@ def test_route_length(
     straight_factory_dbu: Callable[..., kf.KCell],
     optical_port: kf.Port,
     taper: kf.KCell,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     x, y, angle2 = (55000, 70000, 2)
@@ -486,7 +264,7 @@ def test_route_length(
     assert route.length_straights == 30196
     assert route.length_backbone == 125000
     assert route.n_bend90 == 2
-    gds_regression(c)
+    oas_regression(c)
 
 
 _test_smart_routing_kcl = kf.KCLayout("TEST_SMART_ROUTING", infos=Layers)
@@ -518,7 +296,7 @@ def test_smart_routing(
     z: bool,
     p1: bool,
     p2: bool,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     """Tests all possible smart routing configs."""
@@ -681,7 +459,6 @@ def test_smart_routing(
         case (
             (True, False, False, -1, True, False, False, True, False)
             | (True, False, False, -1, False, False, False, True, False)
-            | (True, False, False, 0, False, False, True, False, False)
             | (True, False, False, 1, False, True, False, False, True)
             | (True, False, False, 1, False, True, False, False, False)
             | (True, True, False, -1, True, False, False, True, False)
@@ -690,17 +467,18 @@ def test_smart_routing(
             | (True, True, False, 1, False, True, False, False, True)
             | (True, True, False, 1, False, True, False, False, False)
         ):
-            with pytest.raises(RuntimeError):  # , match="Routing Collision"):i
-                routes = rf()
-                [route.length for route in routes]
+            # with pytest.raises(RuntimeError):  # , match="Routing Collision"):i
+            routes = rf()
+            [route.length for route in routes]
+            c.show()
         case _:
             rf()
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_custom_router(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     kcl = kf.KCLayout("test_custom_router")
     c = kcl.kcell("customRouter")
@@ -729,6 +507,28 @@ def test_custom_router(
         for i in range(10)
     ]
 
+    class ManhattanLengthMatch(kf.schematic.Constraint):
+        def enforce(
+            self,
+            c: kf.KCell,
+            routers: Sequence[kf.routing.manhattan.ManhattanRouter],
+            route_name: str | None,
+        ) -> None:
+            kf.routing.manhattan.path_length_match_manhattan_route(
+                routers=routers,
+                bend90_radius=b90r,
+                separation=5000,
+            )
+
+        def check(
+            self,
+            c: kf.KCell,
+            schematic: kf.schematic.TSchematic[Any],
+            instances: dict[str, kf.Instance],
+            routes: dict[str, list[kf.routing.optical.ManhattanRoute]],
+        ) -> bool:
+            return True
+
     kf.routing.generic.route_bundle(
         c=c,
         start_ports=[p.base for p in start_ports],
@@ -742,20 +542,16 @@ def test_custom_router(
         },
         placer_function=kf.routing.optical.place_manhattan,
         placer_kwargs={"bend90_cell": bend90, "straight_factory": sf},
-        router_post_process_function=kf.routing.manhattan.path_length_match_manhattan_route,
-        router_post_process_kwargs={
-            "bend90_radius": b90r,
-            "separation": 5000,
-        },
+        constraints=[ManhattanLengthMatch(route_names=["path_length_math"])],
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_smart_waypoints_trans_sort(
     bend90_small: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell(name="test_smart_route_waypoints_trans_sort")
@@ -764,17 +560,18 @@ def test_route_smart_waypoints_trans_sort(
         kf.kdb.Trans(1, False, -15_000 - i * 50_000, 15 * 50_000) for i in range(l_)
     ]
     start_ports = [
-        kf.Port(width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
-        for trans in transformations
+        kf.Port(name="in_{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
     ]
     end_ports = [
         kf.Port(
+            name=f"out_{i}",
             width=500,
             layer_info=layers.WG,
             kcl=c.kcl,
             trans=kf.kdb.Trans(2, False, 500_000, 0) * trans,
         )
-        for trans in transformations
+        for i, trans in enumerate(transformations)
     ]
     kf.routing.optical.route_bundle(
         c,
@@ -786,14 +583,14 @@ def test_route_smart_waypoints_trans_sort(
         waypoints=kf.kdb.Trans(250_000, 0),
         sort_ports=True,
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_smart_waypoints_pts_sort(
     bend90_small: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell(name="test_smart_route_waypoints_pts_sort")
@@ -802,17 +599,18 @@ def test_route_smart_waypoints_pts_sort(
         kf.kdb.Trans(1, False, -15_000 - i * 50_000, 15 * 50_000) for i in range(l_)
     ]
     start_ports = [
-        kf.Port(width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
-        for trans in transformations
+        kf.Port(name=f"in_{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
     ]
     end_ports = [
         kf.Port(
+            name=f"out_{i}",
             width=500,
             layer_info=layers.WG,
             kcl=c.kcl,
             trans=kf.kdb.Trans(2, False, 500_000, 0) * trans,
         )
-        for trans in transformations
+        for i, trans in enumerate(transformations)
     ]
     kf.routing.optical.route_bundle(
         c,
@@ -824,14 +622,14 @@ def test_route_smart_waypoints_pts_sort(
         waypoints=[kf.kdb.Point(250_000, 0), kf.kdb.Point(250_000, 100_000)],
         sort_ports=True,
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_waypoints_non_manhattan(
     bend90_small: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell(name="test_smart_route_waypoints_non_manhattan")
@@ -840,17 +638,18 @@ def test_route_waypoints_non_manhattan(
         kf.kdb.Trans(1, False, -15_000 - i * 50_000, 15 * 50_000) for i in range(l_)
     ]
     start_ports = [
-        kf.Port(width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
-        for trans in transformations
+        kf.Port(name=f"in_{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
     ]
     end_ports = [
         kf.Port(
+            name=f"out_{i}",
             width=500,
             layer_info=layers.WG,
             kcl=c.kcl,
             trans=kf.kdb.Trans(2, False, 500_000, 0) * trans,
         )
-        for trans in transformations
+        for i, trans in enumerate(transformations)
     ]
     with pytest.raises(
         ValueError,
@@ -878,7 +677,7 @@ def test_route_smart_waypoints_trans(
     bend90_small: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell(name="test_smart_route_waypoints_trans")
@@ -887,18 +686,19 @@ def test_route_smart_waypoints_trans(
         kf.kdb.Trans(1, False, -15_000 - i * 50_000, 15 * 50_000) for i in range(l_)
     ]
     start_ports = [
-        kf.Port(width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
-        for trans in transformations
+        kf.Port(name=f"in_{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
     ]
     start_ports.reverse()
     end_ports = [
         kf.Port(
+            name=f"out_{i}",
             width=500,
             layer_info=layers.WG,
             kcl=c.kcl,
             trans=kf.kdb.Trans(2, False, 500_000, 0) * trans,
         )
-        for trans in transformations
+        for i, trans in enumerate(transformations)
     ]
     kf.routing.optical.route_bundle(
         c,
@@ -909,14 +709,14 @@ def test_route_smart_waypoints_trans(
         bend90_cell=bend90_small,
         waypoints=kf.kdb.Trans(250_000, 0),
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_smart_waypoints_pts(
     bend90_small: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell(name="test_smart_route_waypoints_pts")
@@ -925,18 +725,19 @@ def test_route_smart_waypoints_pts(
         kf.kdb.Trans(1, False, -15_000 - i * 50_000, 15 * 50_000) for i in range(l_)
     ]
     start_ports = [
-        kf.Port(width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
-        for trans in transformations
+        kf.Port(name=f"in_{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
     ]
     start_ports.reverse()
     end_ports = [
         kf.Port(
+            name=f"out_{i}",
             width=500,
             layer_info=layers.WG,
             kcl=c.kcl,
             trans=kf.kdb.Trans(2, False, 500_000, 0) * trans,
         )
-        for trans in transformations
+        for i, trans in enumerate(transformations)
     ]
     kf.routing.optical.route_bundle(
         c,
@@ -947,13 +748,13 @@ def test_route_smart_waypoints_pts(
         bend90_cell=bend90_small,
         waypoints=[kf.kdb.Point(250_000, 0), kf.kdb.Point(250_000, 100_000)],
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_generic_reorient(
     bend90_small: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell(name="test_route_generic_reorient")
@@ -993,14 +794,14 @@ def test_route_generic_reorient(
         end_angles=end_angles,
     )
 
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_placer_error(
     bend90_small: kf.KCell,
     straight_factory_dbu: Callable[..., kf.KCell],
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell(name="test_placer_error")
@@ -1190,7 +991,7 @@ def wire(length: int, cross_section: kf.CrossSection) -> kf.KCell:
 
 
 def test_sbend_routing(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     layer_infos = Layers()
@@ -1279,7 +1080,7 @@ def sbend_factory(
         ),
         sbend_factory=sbend_factory,
     )
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_route_same_plane(
@@ -1372,3 +1173,358 @@ def wire_same_plane(length: int, cross_section: kf.CrossSection) -> kf.KCell:
     )
 
     c.add_ports(ports)
+
+
+def test_route_debug_waypoints_pts(
+    bend90_small: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    layers: Layers,
+    kcl: kf.KCLayout,
+) -> None:
+    """RouteDebug regions are populated when routing with point waypoints."""
+    c = kcl.kcell(name="test_route_debug_waypoints_pts")
+    l_ = 3
+    transformations = [kf.kdb.Trans(0, False, 0, i * 50_000) for i in range(l_)]
+    start_ports = [
+        kf.Port(name=f"in{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
+    ]
+    end_ports = [
+        kf.Port(
+            name=f"out_{i}",
+            width=500,
+            layer_info=layers.WG,
+            kcl=c.kcl,
+            trans=kf.kdb.Trans(2, False, 500_000, 0) * trans,
+        )
+        for i, trans in enumerate(transformations)
+    ]
+    debug = RouteDebug()
+    kf.routing.optical.route_bundle(
+        c,
+        start_ports,
+        end_ports,
+        separation=4000,
+        straight_factory=straight_factory_dbu,
+        bend90_cell=bend90_small,
+        waypoints=[
+            kf.kdb.Point(250_000, 0),
+            kf.kdb.Point(250_000, 100_000),
+            kf.kdb.Point(300_000, 100_000),
+        ],
+        sort_ports=True,
+        route_debug=debug,
+    )
+    assert not debug.fan_in_region.is_empty()
+    assert not debug.waypoints_region.is_empty()
+    assert not debug.fan_out_region.is_empty()
+
+    c.shapes(c.kcl.layer(9999, 0)).insert(debug.fan_in_region)
+    for poly in debug.fan_in_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 0)).insert(kf.kdb.Text.from_s(string))
+    c.shapes(c.kcl.layer(9999, 1)).insert(debug.waypoints_region)
+    for poly in debug.waypoints_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 1)).insert(kf.kdb.Text.from_s(string))
+    c.shapes(c.kcl.layer(9999, 2)).insert(debug.fan_out_region)
+    for poly in debug.fan_out_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 2)).insert(kf.kdb.Text.from_s(string))
+
+
+def test_route_debug(
+    bend90_small: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    layers: Layers,
+    kcl: kf.KCLayout,
+) -> None:
+    """RouteDebug regions are populated when routing with point waypoints."""
+    c = kcl.kcell(name="test_route_debug_waypoints_pts")
+    l_ = 3
+    transformations = [kf.kdb.Trans(0, False, 0, i * 50_000) for i in range(l_)]
+    start_ports = [
+        kf.Port(name=f"in{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
+    ]
+    end_ports = [
+        kf.Port(
+            name=f"out_{i}",
+            width=500,
+            layer_info=layers.WG,
+            kcl=c.kcl,
+            trans=kf.kdb.Trans(2, False, 500_000 + i * 200_000, 0) * trans,
+        )
+        for i, trans in enumerate(transformations)
+    ]
+    debug = RouteDebug()
+    kf.routing.optical.route_bundle(
+        c,
+        start_ports,
+        end_ports,
+        separation=4000,
+        straight_factory=straight_factory_dbu,
+        bend90_cell=bend90_small,
+        sort_ports=True,
+        route_debug=debug,
+    )
+    assert not debug.fan_in_region.is_empty()
+    assert debug.waypoints_region.is_empty()
+    assert not debug.fan_out_region.is_empty()
+
+    c.shapes(c.kcl.layer(9999, 0)).insert(debug.fan_in_region)
+    for poly in debug.fan_in_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 0)).insert(kf.kdb.Text.from_s(string))
+    c.shapes(c.kcl.layer(9999, 1)).insert(debug.waypoints_region)
+    for poly in debug.waypoints_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 1)).insert(kf.kdb.Text.from_s(string))
+    c.shapes(c.kcl.layer(9999, 2)).insert(debug.fan_out_region)
+    for poly in debug.fan_out_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 2)).insert(kf.kdb.Text.from_s(string))
+
+
+def test_route_debug_opposite(
+    bend90_small: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    layers: Layers,
+    kcl: kf.KCLayout,
+) -> None:
+    """RouteDebug regions are populated when routing with point waypoints."""
+    c = kcl.kcell(name="test_route_debug_waypoints_pts")
+    l_ = 3
+    transformations = [kf.kdb.Trans(0, False, 0, i * 50_000) for i in range(l_)]
+    start_ports = [
+        kf.Port(name=f"in{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
+    ]
+    end_ports = [
+        kf.Port(
+            name=f"out_{i}",
+            width=500,
+            layer_info=layers.WG,
+            kcl=c.kcl,
+            trans=kf.kdb.Trans(0, False, 500_000 + i * 200_000, 0) * trans,
+        )
+        for i, trans in enumerate(transformations)
+    ]
+    debug = RouteDebug()
+    kf.routing.optical.route_bundle(
+        c,
+        start_ports,
+        end_ports,
+        separation=4000,
+        straight_factory=straight_factory_dbu,
+        bend90_cell=bend90_small,
+        sort_ports=True,
+        route_debug=debug,
+    )
+    assert not debug.fan_in_region.is_empty()
+    assert debug.waypoints_region.is_empty()
+    assert not debug.fan_out_region.is_empty()
+
+    c.shapes(c.kcl.layer(9999, 0)).insert(debug.fan_in_region)
+    for poly in debug.fan_in_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 0)).insert(kf.kdb.Text.from_s(string))
+    c.shapes(c.kcl.layer(9999, 1)).insert(debug.waypoints_region)
+    for poly in debug.waypoints_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 1)).insert(kf.kdb.Text.from_s(string))
+    c.shapes(c.kcl.layer(9999, 2)).insert(debug.fan_out_region)
+    for poly in debug.fan_out_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(9999, 2)).insert(kf.kdb.Text.from_s(string))
+
+
+def test_route_debug_waypoints_trans(
+    bend90_small: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    layers: Layers,
+    kcl: kf.KCLayout,
+) -> None:
+    """RouteDebug fan_in/fan_out regions are populated with Trans waypoints."""
+    c = kcl.kcell(name="test_route_debug_waypoints_trans")
+    l_ = 3
+    transformations = [kf.kdb.Trans(0, False, 0, i * 50_000) for i in range(l_)]
+    start_ports = [
+        kf.Port(name=f"in{i}", width=500, layer_info=layers.WG, kcl=c.kcl, trans=trans)
+        for i, trans in enumerate(transformations)
+    ]
+    end_ports = [
+        kf.Port(
+            name=f"out_{i}",
+            width=500,
+            layer_info=layers.WG,
+            kcl=c.kcl,
+            trans=kf.kdb.Trans(2, False, 500_000, 0) * trans,
+        )
+        for i, trans in enumerate(transformations)
+    ]
+    debug = RouteDebug()
+    kf.routing.optical.route_bundle(
+        c,
+        start_ports,
+        end_ports,
+        separation=4000,
+        straight_factory=straight_factory_dbu,
+        bend90_cell=bend90_small,
+        waypoints=kf.kdb.Trans(250_000, 50_000),
+        sort_ports=True,
+        route_debug=debug,
+    )
+    assert not debug.fan_in_region.is_empty()
+    assert debug.waypoints_region.is_empty()  # Trans waypoint = zero-length tunnel
+    assert not debug.fan_out_region.is_empty()
+
+    c.shapes(c.kcl.layer(111, 1)).insert(debug.fan_in_region)
+    for poly in debug.fan_in_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(111, 1)).insert(kf.kdb.Text.from_s(string))
+    c.shapes(c.kcl.layer(111, 2)).insert(debug.waypoints_region)
+    for poly in debug.waypoints_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(111, 2)).insert(kf.kdb.Text.from_s(string))
+    c.shapes(c.kcl.layer(111, 3)).insert(debug.fan_out_region)
+    for poly in debug.fan_out_region.each():
+        for string in poly.properties().values():
+            c.shapes(c.kcl.layer(111, 3)).insert(kf.kdb.Text.from_s(string))
+
+
+def test_route_bundle_single_return(
+    bend90_euler: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    optical_port: kf.Port,
+    taper: kf.KCell,
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    kcl: kf.KCLayout,
+) -> None:
+    x, y, angle2 = (0, 7000, 0)
+
+    c = kcl.kcell()
+    p1 = optical_port.copy()
+    p2 = optical_port.copy()
+    p2.trans = kf.kdb.Trans(angle2, False, x, y)
+    p2.x -= 100
+    kf.routing.optical.route_bundle(
+        c=c,
+        start_ports=[p1],
+        end_ports=[p2],
+        separation=5000,
+        straight_factory=straight_factory_dbu,
+        bend90_cell=bend90_euler,
+        taper_cell=taper,
+        allow_width_mismatch=True,
+    )[0]
+    oas_regression(c)
+
+
+def test_route_bundle_multi_return(
+    bend90_euler: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    optical_port: kf.Port,
+    taper: kf.KCell,
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    kcl: kf.KCLayout,
+) -> None:
+    c = kcl.kcell()
+    ps = [
+        optical_port.copy(),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -2000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -14000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -24000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -84000)),
+        optical_port.copy(kf.kdb.Trans(3, False, -1000, -85000)),
+        optical_port.copy(kf.kdb.Trans(1, False, -50000, 5000)),
+        optical_port.copy(kf.kdb.Trans(0, False, -5000, 90_000)),
+    ]
+    pe = [
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 7000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 9000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 11_000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 21_000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 41_000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 51_000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 61_000)),
+        optical_port.copy(kf.kdb.Trans(0, False, -5000, 75_000)),
+    ]
+
+    for i, (ps_, pe_) in enumerate(zip(ps, pe, strict=True)):
+        c.add_port(port=ps_, name=f"in_{i + 1}")
+        c.add_port(port=pe_, name=f"out_{i + 1}")
+    b1 = kf.kdb.Box()
+    b2 = kf.kdb.Box()
+
+    for p1, p2 in zip(ps[:-1], pe[:-1], strict=True):
+        b1 += p1.trans.disp.to_p()
+        b2 += p2.trans.disp.to_p()
+
+    kf.routing.optical.route_bundle(
+        c=c,
+        start_ports=ps,
+        end_ports=pe,
+        separation=1000,
+        straight_factory=straight_factory_dbu,
+        bend90_cell=bend90_euler,
+        taper_cell=taper,
+        allow_width_mismatch=True,
+        sort_ports=False,
+        bboxes=[b1, b2],
+    )[0]
+    oas_regression(c)
+
+
+def test_route_bundle_multi_return_opposite(
+    bend90_euler: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    optical_port: kf.Port,
+    taper: kf.KCell,
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    kcl: kf.KCLayout,
+) -> None:
+    c = kcl.kcell()
+    ps = [
+        optical_port.copy(),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 2000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 14000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 24000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, 84000)),
+        optical_port.copy(kf.kdb.Trans(1, False, -1000, 85000)),
+        # optical_port.copy(kf.kdb.Trans(3, False, -50000, -5000)),  # noqa: ERA001
+    ]
+    pe = [
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -7000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -9000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -11_000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -21_000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -41_000)),
+        optical_port.copy(kf.kdb.Trans(0, False, 0, -51_000)),
+        # optical_port.copy(kf.kdb.Trans(0, False, 0, -61_000)),  # noqa: ERA001
+    ]
+
+    for i, (ps_, pe_) in enumerate(zip(ps, pe, strict=True)):
+        c.add_port(port=ps_, name=f"in_{i + 1}")
+        c.add_port(port=pe_, name=f"out_{i + 1}")
+
+    b1 = kf.kdb.Box()
+    b2 = kf.kdb.Box()
+
+    for p1, p2 in zip(ps, pe, strict=True):
+        b1 += p1.trans.disp.to_p()
+        b2 += p2.trans.disp.to_p()
+
+    kf.routing.optical.route_bundle(
+        c=c,
+        start_ports=ps,
+        end_ports=pe,
+        separation=1000,
+        straight_factory=straight_factory_dbu,
+        bend90_cell=bend90_euler,
+        taper_cell=taper,
+        allow_width_mismatch=True,
+        sort_ports=False,
+        bboxes=[b1, b2],
+    )[0]
+    oas_regression(c)
diff --git a/tests/test_routing_extra.py b/tests/test_routing_extra.py
new file mode 100644
index 000000000..44fa144f1
--- /dev/null
+++ b/tests/test_routing_extra.py
@@ -0,0 +1,604 @@
+"""Extra tests for kfactory.routing.{electrical,optical} modules."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+import kfactory as kf
+from kfactory.routing.electrical import (
+    place_dual_rails,
+    place_single_wire,
+    route_bundle,
+    route_bundle_dual_rails,
+    route_dual_rails,
+)
+from kfactory.routing.manhattan import ManhattanRouter
+from kfactory.routing.optical import (
+    LoopPosition,
+    LoopSide,
+    path_length_match,
+    route_loopback,
+    vec_angle,
+)
+from kfactory.routing.optical import (
+    route_bundle as optical_route_bundle,
+)
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+    from tests.conftest import Layers
+
+# vec_angle
+
+
+def test_vec_angle_positive_x() -> None:
+    assert vec_angle(kf.kdb.Vector(100, 0)) == 0
+
+
+def test_vec_angle_negative_x() -> None:
+    assert vec_angle(kf.kdb.Vector(-100, 0)) == 2
+
+
+def test_vec_angle_positive_y() -> None:
+    assert vec_angle(kf.kdb.Vector(0, 100)) == 1
+
+
+def test_vec_angle_negative_y() -> None:
+    assert vec_angle(kf.kdb.Vector(0, -100)) == 3
+
+
+def test_vec_angle_zero_vector() -> None:
+    # zero vector returns -1 with a log warning
+    assert vec_angle(kf.kdb.Vector(0, 0)) == -1
+
+
+def test_vec_angle_non_manhattan_raises() -> None:
+    with pytest.raises(ValueError, match="Non-manhattan"):
+        vec_angle(kf.kdb.Vector(100, 100))
+
+
+# place_single_wire / route_dual_rails
+
+
+def _e_port(
+    kcl: kf.KCLayout,
+    layers: Layers,
+    name: str,
+    angle: int,
+    x: int,
+    y: int,
+    width: int = 1000,
+) -> kf.Port:
+    return kf.Port(
+        name=name,
+        trans=kf.kdb.Trans(angle, False, x, y),
+        width=width,
+        layer_info=layers.METAL1,
+        kcl=kcl,
+        port_type="electrical",
+    )
+
+
+def test_place_single_wire_basic(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell("psw_basic")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0)
+    pts = [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)]
+    route = place_single_wire(c, p1, p2, pts)
+    assert route.start_port is p1
+    assert route.end_port is p2
+    assert route.length_straights == 50_000
+
+
+def test_place_single_wire_extra_kwargs_raises(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    c = kcl.kcell("psw_kwargs_err")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0)
+    pts = [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)]
+    with pytest.raises(ValueError, match="supported"):
+        place_single_wire(c, p1, p2, pts, junk_kwarg=42)
+
+
+def test_place_single_wire_explicit_layer_and_width(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    c = kcl.kcell("psw_explicit")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0)
+    pts = [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)]
+    route = place_single_wire(
+        c, p1, p2, pts, route_width=2000, layer_info=layers.METAL2
+    )
+    assert route.polygons[layers.METAL2]
+
+
+def test_place_dual_rails_basic(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell("pdr_basic")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=4000)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0, width=4000)
+    pts = [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)]
+    route = place_dual_rails(c, p1, p2, pts, separation_rails=1000)
+    assert route.start_port is p1
+    assert route.end_port is p2
+    # The shape polygons live on the port's layer
+    assert route.polygons[layers.METAL1]
+
+
+def test_place_dual_rails_missing_separation(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell("pdr_no_sep")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=4000)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0, width=4000)
+    pts = [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)]
+    with pytest.raises(ValueError, match="Must specify"):
+        place_dual_rails(c, p1, p2, pts)
+
+
+def test_place_dual_rails_separation_too_large(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    c = kcl.kcell("pdr_sep_too_large")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=2000)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0, width=2000)
+    pts = [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)]
+    with pytest.raises(ValueError, match="must be smaller"):
+        place_dual_rails(c, p1, p2, pts, separation_rails=5000)
+
+
+def test_place_dual_rails_extra_kwargs_raises(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell("pdr_kwargs_err")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=4000)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0, width=4000)
+    pts = [kf.kdb.Point(0, 0), kf.kdb.Point(50_000, 0)]
+    with pytest.raises(ValueError, match="supported"):
+        place_dual_rails(c, p1, p2, pts, separation_rails=1000, junk=1)
+
+
+def test_route_dual_rails(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell("rdr_basic")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=4000)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0, width=4000)
+    route_dual_rails(
+        c, p1, p2, width=4000, hole_width=1000, layer=kcl.find_layer(layers.METAL1)
+    )
+    # Some shapes should have been inserted
+    shapes_count = 0
+    for shape in c.shapes(kcl.find_layer(layers.METAL1)).each():
+        shapes_count += 1
+        _ = shape
+    assert shapes_count > 0
+
+
+def test_route_dual_rails_defaults(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell("rdr_defaults")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=4000)
+    p2 = _e_port(kcl, layers, "out", 2, 50_000, 0, width=4000)
+    # Use the port-inferred defaults for width/hole_width/layer
+    route_dual_rails(c, p1, p2)
+
+
+# Integration: electrical route_bundle with um (DKCell) path
+
+
+def test_electrical_route_bundle_dkcell(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.dkcell("e_route_bundle_dk")
+    p1 = kf.DPort(
+        name="in",
+        dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, 0, 0),
+        width=10.0,
+        layer_info=layers.METAL1,
+        kcl=kcl,
+        port_type="electrical",
+    )
+    p2 = kf.DPort(
+        name="out",
+        dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, 50.0, 0),
+        width=10.0,
+        layer_info=layers.METAL1,
+        kcl=kcl,
+        port_type="electrical",
+    )
+    routes = route_bundle(
+        c,
+        start_ports=[p1],
+        end_ports=[p2],
+        separation=2.0,
+        on_collision=None,
+        starts=[0.0],
+        ends=[0.0],
+    )
+    assert len(routes) == 1
+
+
+def test_electrical_route_bundle_non_manhattan_waypoints_dbu(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    """Non-manhattan waypoints should produce a descriptive ValueError."""
+    c = kcl.kcell("e_route_bundle_nmwp_dbu")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=4000)
+    p2 = _e_port(kcl, layers, "out", 2, 100_000, 0, width=4000)
+    with pytest.raises(ValueError, match="non-manhattan waypoints"):
+        route_bundle(
+            c,
+            start_ports=[p1],
+            end_ports=[p2],
+            separation=4_000,
+            on_collision=None,
+            on_placer_error=None,
+            waypoints=[
+                kf.kdb.Point(0, 0),
+                kf.kdb.Point(50_000, 10_000),
+            ],
+        )
+
+
+def test_electrical_route_bundle_non_manhattan_waypoints_dk(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    """Non-manhattan waypoints on the DKCell path."""
+    c = kcl.dkcell("e_route_bundle_nmwp_dk")
+    p1 = kf.DPort(
+        name="in",
+        dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, 0, 0),
+        width=10.0,
+        layer_info=layers.METAL1,
+        kcl=kcl,
+        port_type="electrical",
+    )
+    p2 = kf.DPort(
+        name="out",
+        dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, 100.0, 0),
+        width=10.0,
+        layer_info=layers.METAL1,
+        kcl=kcl,
+        port_type="electrical",
+    )
+    with pytest.raises(ValueError, match="non-manhattan waypoints"):
+        route_bundle(
+            c,
+            start_ports=[p1],
+            end_ports=[p2],
+            separation=2.0,
+            on_collision=None,
+            on_placer_error=None,
+            starts=[0.0],
+            ends=[0.0],
+            waypoints=[
+                kf.kdb.DPoint(0, 0),
+                kf.kdb.DPoint(50.0, 10.0),
+            ],
+        )
+
+
+def test_electrical_route_bundle_dual_rails_non_manhattan_waypoints(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    c = kcl.kcell("e_route_bundle_dr_nmwp")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=4000)
+    p2 = _e_port(kcl, layers, "out", 2, 100_000, 0, width=4000)
+    with pytest.raises(ValueError, match="non-manhattan waypoints"):
+        route_bundle_dual_rails(
+            c,
+            start_ports=[p1],
+            end_ports=[p2],
+            separation=4_000,
+            separation_rails=500,
+            on_collision=None,
+            on_placer_error=None,
+            waypoints=[
+                kf.kdb.Point(0, 0),
+                kf.kdb.Point(50_000, 10_000),
+            ],
+        )
+
+
+def test_optical_route_bundle_non_manhattan_waypoints(
+    bend90: kf.KCell,
+    straight_factory_dbu: Callable[..., kf.KCell],
+    kcl: kf.KCLayout,
+    layers: Layers,
+) -> None:
+    c = kcl.kcell("opt_route_bundle_nmwp")
+    p1 = kf.Port(
+        name="in",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    p2 = kf.Port(
+        name="out",
+        trans=kf.kdb.Trans(2, False, 500_000, 0),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    with pytest.raises(ValueError, match="non-manhattan waypoints"):
+        optical_route_bundle(
+            c,
+            start_ports=[p1],
+            end_ports=[p2],
+            separation=4_000,
+            straight_factory=straight_factory_dbu,
+            bend90_cell=bend90,
+            on_collision=None,
+            on_placer_error=None,
+            waypoints=[
+                kf.kdb.Point(0, 0),
+                kf.kdb.Point(50_000, 10_000),
+            ],
+        )
+
+
+def test_electrical_route_bundle_dual_rails(kcl: kf.KCLayout, layers: Layers) -> None:
+    c = kcl.kcell("e_route_bundle_dual_rails")
+    p1 = _e_port(kcl, layers, "in", 0, 0, 0, width=4000)
+    p2 = _e_port(kcl, layers, "out", 2, 100_000, 0, width=4000)
+    routes = route_bundle_dual_rails(
+        c,
+        start_ports=[p1],
+        end_ports=[p2],
+        separation=4_000,
+        separation_rails=500,
+        on_collision=None,
+    )
+    assert len(routes) == 1
+
+
+def test_route_loopback_basic(layers: Layers, kcl: kf.KCLayout) -> None:
+    # route_loopback returns a list of points for the loopback path
+    p1 = kf.Port(
+        name="p1",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    p2 = kf.Port(
+        name="p2",
+        trans=kf.kdb.Trans(0, False, 0, 50_000),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    pts = route_loopback(p1, p2, bend90_radius=10_000)
+    assert isinstance(pts, list)
+    assert len(pts) >= 2
+
+
+def test_route_loopback_with_bend180(layers: Layers, kcl: kf.KCLayout) -> None:
+    p1 = kf.Port(
+        name="p1",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    p2 = kf.Port(
+        name="p2",
+        trans=kf.kdb.Trans(0, False, 0, 50_000),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    pts = route_loopback(p1, p2, bend90_radius=10_000, bend180_radius=20_000)
+    assert isinstance(pts, list)
+
+
+def test_route_loopback_inside(layers: Layers, kcl: kf.KCLayout) -> None:
+    p1 = kf.Port(
+        name="p1",
+        trans=kf.kdb.Trans(0, False, 0, 0),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    p2 = kf.Port(
+        name="p2",
+        trans=kf.kdb.Trans(0, False, 0, 50_000),
+        width=500,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    pts = route_loopback(p1, p2, bend90_radius=10_000, inside=True)
+    assert isinstance(pts, list)
+
+
+def test_path_length_match_left(layers: Layers, kcl: kf.KCLayout) -> None:
+    routers = [
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 100_000, 0),
+        ),
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 50_000),
+            end_transformation=kf.kdb.Trans(2, False, 200_000, 50_000),
+        ),
+    ]
+    for r in routers:
+        r.start.straight(100_000)
+        r.start.pts.append(r.start.t.disp.to_p())
+        r.finished = True
+
+    # Should run without error; modifies router.start.pts in place
+    path_length_match(
+        routers=routers,
+        loops=1,
+        loop_side=LoopSide.left,
+        loop_position=LoopPosition.start,
+    )
+    # Path lengths should now match
+    lengths = [r.start.path_length for r in routers]
+    assert max(lengths) - min(lengths) <= 2  # 2 dbu rounding
+
+
+def test_path_length_match_right(layers: Layers, kcl: kf.KCLayout) -> None:
+    routers = [
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 100_000, 0),
+        ),
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 200_000, 0),
+        ),
+    ]
+    for r in routers:
+        r.start.straight(100_000)
+        r.start.pts.append(r.start.t.disp.to_p())
+        r.finished = True
+
+    path_length_match(routers=routers, loops=1, loop_side=LoopSide.right)
+
+
+def test_path_length_match_center(layers: Layers, kcl: kf.KCLayout) -> None:
+    routers = [
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 100_000, 0),
+        ),
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 200_000, 0),
+        ),
+    ]
+    for r in routers:
+        r.start.straight(100_000)
+        r.start.pts.append(r.start.t.disp.to_p())
+        r.finished = True
+
+    path_length_match(routers=routers, loops=1, loop_side=LoopSide.center)
+
+
+def test_path_length_match_explicit_short_path_length(
+    layers: Layers, kcl: kf.KCLayout
+) -> None:
+    routers = [
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 100_000, 0),
+        ),
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 200_000, 0),
+        ),
+    ]
+    for r in routers:
+        r.start.straight(100_000)
+        r.start.pts.append(r.start.t.disp.to_p())
+        r.finished = True
+    # path_length below max should log warning and use minimum
+    path_length_match(
+        routers=routers,
+        loops=1,
+        loop_side=LoopSide.left,
+        path_length=10,
+    )
+
+
+def test_path_length_match_invalid_side_raises(
+    layers: Layers, kcl: kf.KCLayout
+) -> None:
+    router = ManhattanRouter(
+        bend90_radius=10_000,
+        separation=2_000,
+        start_transformation=kf.kdb.Trans(0, False, 0, 0),
+        end_transformation=kf.kdb.Trans(2, False, 100_000, 0),
+    )
+    router.start.straight(100_000)
+    router.start.pts.append(router.start.t.disp.to_p())
+    router.finished = True
+    with pytest.raises(ValueError, match="must be of any value"):
+        path_length_match(routers=[router], loops=1, loop_side=42)  # ty:ignore[invalid-argument-type]
+
+
+def test_path_length_match_invalid_position_raises(
+    layers: Layers, kcl: kf.KCLayout
+) -> None:
+    routers = [
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 100_000, 0),
+        ),
+        ManhattanRouter(
+            bend90_radius=10_000,
+            separation=2_000,
+            start_transformation=kf.kdb.Trans(0, False, 0, 0),
+            end_transformation=kf.kdb.Trans(2, False, 200_000, 0),
+        ),
+    ]
+    for r in routers:
+        r.start.straight(100_000)
+        r.start.pts.append(r.start.t.disp.to_p())
+        r.finished = True
+    with pytest.raises(ValueError, match="loop_position must be"):
+        path_length_match(
+            routers=routers,
+            loops=1,
+            loop_side=LoopSide.left,
+            loop_position=42,  # ty:ignore[invalid-argument-type]
+        )
+
+
+# Optical route_bundle DKCell
+
+
+def test_optical_route_bundle_dkcell(
+    layers: Layers,
+    kcl: kf.KCLayout,
+    bend90: kf.KCell,
+    straight_factory: Callable[..., kf.KCell],
+) -> None:
+    c = kcl.dkcell("opt_route_bundle_dk")
+    p1 = kf.DPort(
+        name="in",
+        dcplx_trans=kf.kdb.DCplxTrans(1, 0, False, 0, 0),
+        width=0.5,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+    p2 = kf.DPort(
+        name="out",
+        dcplx_trans=kf.kdb.DCplxTrans(1, 180, False, 100.0, 50.0),
+        width=0.5,
+        layer_info=layers.WG,
+        kcl=kcl,
+    )
+
+    def sf(*, width: float, length: float, **kwargs: object) -> kf.DKCell:
+        return straight_factory(width=width, length=length).to_dtype()
+
+    routes = optical_route_bundle(  # ty:ignore[no-matching-overload]
+        c,
+        start_ports=[p1],
+        end_ports=[p2],
+        separation=4.0,
+        straight_factory=sf,
+        bend90_cell=bend90.to_dtype(),
+        on_collision=None,
+        starts=[0.0],
+        ends=[0.0],
+    )
+    assert len(routes) == 1
diff --git a/tests/test_routing_length_functions.py b/tests/test_routing_length_functions.py
new file mode 100644
index 000000000..18aadc387
--- /dev/null
+++ b/tests/test_routing_length_functions.py
@@ -0,0 +1,109 @@
+"""Tests for kfactory.routing.length_functions module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import kfactory as kf
+from kfactory.routing.generic import ManhattanRoute
+from kfactory.routing.length_functions import (
+    LengthFunction,
+    get_length_from_area,
+    get_length_from_backbone,
+    get_length_from_info,
+)
+
+if TYPE_CHECKING:
+    from tests.conftest import Layers
+
+
+def _make_port(kcl: kf.KCLayout, layers: Layers) -> kf.Port:
+    return kf.Port(
+        name="o1",
+        trans=kf.kdb.Trans.R0,
+        layer=kcl.find_layer(layers.WG),
+        width=500,
+        port_type="optical",
+        kcl=kcl,
+    )
+
+
+def test_length_function_protocol() -> None:
+    assert isinstance(get_length_from_backbone, LengthFunction)
+    assert isinstance(get_length_from_area(), LengthFunction)
+
+
+def test_get_length_from_backbone(kcl: kf.KCLayout, layers: Layers) -> None:
+    port = _make_port(kcl, layers)
+    route = ManhattanRoute(
+        backbone=[
+            kf.kdb.Point(0, 0),
+            kf.kdb.Point(1000, 0),
+            kf.kdb.Point(1000, 2000),
+        ],
+        start_port=port,
+        end_port=port,
+    )
+    assert get_length_from_backbone(route) == 3000
+
+
+def test_get_length_from_backbone_single_point(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    port = _make_port(kcl, layers)
+    route = ManhattanRoute(
+        backbone=[kf.kdb.Point(0, 0)],
+        start_port=port,
+        end_port=port,
+    )
+    assert get_length_from_backbone(route) == 0
+
+
+def test_get_length_from_area_empty_instances(kcl: kf.KCLayout, layers: Layers) -> None:
+    port = _make_port(kcl, layers)
+    route = ManhattanRoute(
+        backbone=[kf.kdb.Point(0, 0), kf.kdb.Point(1000, 0)],
+        start_port=port,
+        end_port=port,
+        instances=[],
+    )
+    assert get_length_from_area()(route) == 0
+
+
+def test_get_length_from_info(kcl: kf.KCLayout, layers: Layers) -> None:
+    port = _make_port(kcl, layers)
+    # Create a real instance whose cell has an info["length"] value
+    inner = kcl.kcell("len_info_inner")
+    inner.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 1000, 500))
+    inner.info["length"] = 1000
+
+    parent = kcl.kcell("len_info_parent")
+    inst = parent.create_inst(inner)
+
+    route = ManhattanRoute(
+        backbone=[kf.kdb.Point(0, 0), kf.kdb.Point(1000, 0)],
+        start_port=port,
+        end_port=port,
+        instances=[inst],
+    )
+    assert get_length_from_info(route) == 1000
+
+
+def test_get_length_from_info_custom_attribute(
+    kcl: kf.KCLayout, layers: Layers
+) -> None:
+    port = _make_port(kcl, layers)
+    inner = kcl.kcell("len_info_custom")
+    inner.shapes(layers.WG).insert(kf.kdb.Box(0, 0, 1000, 500))
+    inner.info["my_attr"] = 42
+
+    parent = kcl.kcell("len_info_custom_parent")
+    inst = parent.create_inst(inner)
+
+    route = ManhattanRoute(
+        backbone=[kf.kdb.Point(0, 0)],
+        start_port=port,
+        end_port=port,
+        instances=[inst],
+    )
+    assert get_length_from_info(route, attribute_name="my_attr") == 42
diff --git a/tests/test_routing_steps.py b/tests/test_routing_steps.py
new file mode 100644
index 000000000..fc496515b
--- /dev/null
+++ b/tests/test_routing_steps.py
@@ -0,0 +1,214 @@
+"""Tests for kfactory.routing.steps module."""
+
+from __future__ import annotations
+
+import pytest
+
+from kfactory import kdb
+from kfactory.routing.manhattan import ManhattanRouter
+from kfactory.routing.steps import (
+    XY,
+    Left,
+    Right,
+    Step,
+    Steps,
+    Straight,
+    X,
+    Y,
+)
+
+
+def _make_router(
+    bend90_radius: int = 1000,
+    start: kdb.Trans | None = None,
+    end: kdb.Trans | None = None,
+) -> ManhattanRouter:
+    return ManhattanRouter(
+        bend90_radius=bend90_radius,
+        separation=200,
+        start_transformation=start or kdb.Trans(0, False, 0, 0),
+        end_transformation=end or kdb.Trans(2, False, 50_000, 0),
+    )
+
+
+def test_step_is_abstract() -> None:
+    with pytest.raises(TypeError):
+        Step()  # type: ignore[abstract]
+
+
+def test_left_no_dist_executes() -> None:
+    router = _make_router()
+    start_angle = router.start.t.angle
+    Left().execute(router.start, include_bend=False)
+    # Left turn rotates +1 mod 4
+    assert router.start.t.angle == (start_angle + 1) % 4
+
+
+def test_left_with_dist_executes() -> None:
+    router = _make_router()
+    Left(dist=5000).execute(router.start, include_bend=False)
+    # After left + straight 5000 from origin facing +x, we are facing +y
+    assert router.start.t.angle == 1
+
+
+def test_left_dist_too_small_raises() -> None:
+    router = _make_router(bend90_radius=2000)
+    with pytest.raises(ValueError, match="bigger than"):
+        Left(dist=100).execute(router.start, include_bend=False)
+
+
+def test_left_include_bend_too_small_raises() -> None:
+    router = _make_router(bend90_radius=2000)
+    with pytest.raises(ValueError, match="bigger than"):
+        Left(dist=2000).execute(router.start, include_bend=True)
+
+
+def test_left_include_bend_ok() -> None:
+    router = _make_router(bend90_radius=1000)
+    Left(dist=10_000).execute(router.start, include_bend=True)
+    assert router.start.t.angle == 1
+
+
+def test_right_no_dist_executes() -> None:
+    router = _make_router()
+    Right().execute(router.start, include_bend=False)
+    assert router.start.t.angle == 3
+
+
+def test_right_with_dist_executes() -> None:
+    router = _make_router()
+    Right(dist=5000).execute(router.start, include_bend=False)
+    assert router.start.t.angle == 3
+
+
+def test_right_dist_too_small_raises() -> None:
+    router = _make_router(bend90_radius=2000)
+    with pytest.raises(ValueError, match="bigger than"):
+        Right(dist=100).execute(router.start, include_bend=False)
+
+
+def test_right_include_bend_too_small_raises() -> None:
+    router = _make_router(bend90_radius=2000)
+    with pytest.raises(ValueError, match="bigger than"):
+        Right(dist=2000).execute(router.start, include_bend=True)
+
+
+def test_right_include_bend_ok() -> None:
+    router = _make_router(bend90_radius=1000)
+    Right(dist=10_000).execute(router.start, include_bend=True)
+    assert router.start.t.angle == 3
+
+
+def test_straight_no_dist_noop() -> None:
+    router = _make_router()
+    pos_before = router.start.t.disp
+    Straight().execute(router.start, include_bend=False)
+    assert router.start.t.disp == pos_before
+
+
+def test_straight_with_dist() -> None:
+    router = _make_router()
+    x_before = router.start.t.disp.x
+    Straight(dist=5000).execute(router.start, include_bend=False)
+    assert router.start.t.disp.x == x_before + 5000
+
+
+def test_straight_with_dist_include_bend() -> None:
+    router = _make_router(bend90_radius=1000)
+    x_before = router.start.t.disp.x
+    Straight(dist=5000).execute(router.start, include_bend=True)
+    # straight_nobend subtracts the bend radius
+    assert router.start.t.disp.x == x_before + 4000
+
+
+def test_x_step_goes_along_x() -> None:
+    router = _make_router()
+    X(x=5000).execute(router.start, include_bend=False)
+    assert router.start.t.disp.x == 5000
+
+
+def test_x_step_zero_noop() -> None:
+    router = _make_router()
+    pos_before = router.start.t.disp
+    X(x=0).execute(router.start, include_bend=False)
+    assert router.start.t.disp == pos_before
+
+
+def test_x_step_wrong_angle_raises() -> None:
+    # angle 1 is +y direction, X step should error
+    router = _make_router(start=kdb.Trans(1, False, 0, 0))
+    with pytest.raises(ValueError, match="Cannot go to position"):
+        X(x=5000).execute(router.start, include_bend=False)
+
+
+def test_x_step_include_bend() -> None:
+    router = _make_router(bend90_radius=1000)
+    X(x=5000).execute(router.start, include_bend=True)
+    # straight_nobend subtracts bend radius
+    assert router.start.t.disp.x == 4000
+
+
+def test_y_step_goes_along_y() -> None:
+    router = _make_router(start=kdb.Trans(1, False, 0, 0))
+    Y(y=5000).execute(router.start, include_bend=False)
+    assert router.start.t.disp.y == 5000
+
+
+def test_y_step_zero_noop() -> None:
+    router = _make_router(start=kdb.Trans(1, False, 0, 0))
+    pos_before = router.start.t.disp
+    Y(y=0).execute(router.start, include_bend=False)
+    assert router.start.t.disp == pos_before
+
+
+def test_y_step_wrong_angle_raises() -> None:
+    # angle 0 is +x direction, Y step should error
+    router = _make_router()
+    with pytest.raises(ValueError, match="Cannot go to position"):
+        Y(y=5000).execute(router.start, include_bend=False)
+
+
+def test_y_step_include_bend() -> None:
+    router = _make_router(start=kdb.Trans(1, False, 0, 0), bend90_radius=1000)
+    Y(y=5000).execute(router.start, include_bend=True)
+    assert router.start.t.disp.y == 4000
+
+
+def test_xy_step_falls_through_default() -> None:
+    # If neither case matches, the step is essentially a no-op
+    router = _make_router()
+    pos_before = router.start.t.disp
+    XY(x=5000, y=2000).execute(router.start, include_bend=False)
+    assert router.start.t.disp == pos_before
+
+
+def test_steps_collection_executes() -> None:
+    router = _make_router()
+    steps = Steps([Straight(dist=2000), Left(), Straight(dist=2000)])
+    steps.execute(router.start)
+    # after Left followed by Straight, x stays at 2000+bend, angle 1
+    assert router.start.t.angle == 1
+
+
+def test_steps_invalid_member_raises() -> None:
+    with pytest.raises(TypeError, match="must implement"):
+        Steps([object()])
+
+
+def test_steps_propagates_error_with_step_index() -> None:
+    router = _make_router(bend90_radius=2000)
+    steps = Steps([Straight(dist=5000), Left(dist=100)])
+    with pytest.raises(ValueError, match="Error in step"):
+        steps.execute(router.start)
+
+
+def test_steps_empty_runs() -> None:
+    router = _make_router()
+    pos_before = router.start.t.disp
+    Steps([]).execute(router.start)
+    assert router.start.t.disp == pos_before
+
+
+def test_step_include_bend_default_property() -> None:
+    s = Left()
+    assert s.include_bend is None
diff --git a/tests/test_routing_utils.py b/tests/test_routing_utils.py
new file mode 100644
index 000000000..dec2ece94
--- /dev/null
+++ b/tests/test_routing_utils.py
@@ -0,0 +1,105 @@
+"""Tests for kfactory.routing.utils module."""
+
+from __future__ import annotations
+
+import kfactory as kf
+from kfactory.routing.utils import RouteDebug
+
+
+def test_route_debug_defaults() -> None:
+    rd = RouteDebug()
+    assert rd.fan_in_region.is_empty()
+    assert rd.fan_out_region.is_empty()
+    assert rd.waypoints_region.is_empty()
+    # post_init should set merged_semantics to False
+    assert rd.fan_in_region.merged_semantics is False
+    assert rd.fan_out_region.merged_semantics is False
+    assert rd.waypoints_region.merged_semantics is False
+
+
+def test_route_debug_repr_does_not_crash() -> None:
+    # to_dict is buggy (iterates over all fields including dicts), but the
+    # model itself should still be representable.
+    rd = RouteDebug()
+    assert isinstance(repr(rd), str)
+
+
+def test_route_debug_to_markers_empty() -> None:
+    rd = RouteDebug()
+    markers = rd.to_markers(dbu=0.001)
+    assert markers == []
+
+
+def test_route_debug_to_markers_with_shapes() -> None:
+    rd = RouteDebug()
+    rd.fan_in_region.insert(kf.kdb.Box(0, 0, 1000, 1000))
+    rd.fan_out_region.insert(kf.kdb.Box(0, 0, 500, 500))
+    rd.waypoints_region.insert(kf.kdb.Box(0, 0, 200, 200))
+
+    markers = rd.to_markers(dbu=0.001)
+    # one polygon per region
+    assert len(markers) == 3
+    for shape, cfg in markers:
+        assert hasattr(shape, "bbox") or hasattr(shape, "to_s")
+        assert "color" in cfg
+
+
+def test_route_debug_marker_configs_are_marker_config() -> None:
+    rd = RouteDebug()
+    # Each marker config field should be a MarkerConfig (TypedDict-shaped dict)
+    assert "color" in rd.fan_in_marker_config
+    assert "color" in rd.fan_out_marker_config
+    assert "color" in rd.waypoints_marker_config
+
+
+def test_route_debug_to_markers_with_text_properties() -> None:
+    """to_markers should also yield markers for text-valued properties."""
+    from tests.conftest import Layers
+
+    rd = RouteDebug()
+    layers = Layers()
+    kcl = kf.KCLayout("ROUTE_DEBUG_PROP", infos=Layers)
+    c = kcl.kcell(name="route_debug_to_markers_props")
+    l_ = 3
+    transformations = [kf.kdb.Trans(0, False, 0, i * 50_000) for i in range(l_)]
+    start_ports = [
+        kf.Port(name=f"in{i}", width=500, layer_info=layers.WG, kcl=kcl, trans=trans)
+        for i, trans in enumerate(transformations)
+    ]
+    end_ports = [
+        kf.Port(
+            name=f"out_{i}",
+            width=500,
+            layer_info=layers.WG,
+            kcl=kcl,
+            trans=kf.kdb.Trans(2, False, 500_000, 0) * trans,
+        )
+        for i, trans in enumerate(transformations)
+    ]
+
+    bend90 = kf.factories.circular.bend_circular_factory(kcl=kcl)(
+        width=0.5, radius=5, layer=layers.WG, angle=90
+    )
+    sf = kf.factories.straight.straight_dbu_factory(kcl=kcl)
+
+    kf.routing.optical.route_bundle(  # ty:ignore[no-matching-overload]
+        c,
+        start_ports,
+        end_ports,
+        separation=4000,
+        straight_factory=lambda **kwargs: sf(layer=layers.WG, **kwargs),
+        bend90_cell=bend90,
+        waypoints=[
+            kf.kdb.Point(250_000, 0),
+            kf.kdb.Point(250_000, 100_000),
+            kf.kdb.Point(300_000, 100_000),
+        ],
+        sort_ports=True,
+        route_debug=rd,
+    )
+    # Regions should now contain polygons with text properties
+    assert not rd.fan_in_region.is_empty()
+
+    markers = rd.to_markers(dbu=kcl.dbu)
+    # markers list should include polygons + their parsed text labels
+    assert len(markers) > 3
diff --git a/tests/test_schematic.py b/tests/test_schematic.py
index 2411b919e..416be7a2f 100644
--- a/tests/test_schematic.py
+++ b/tests/test_schematic.py
@@ -83,7 +83,7 @@ def test_schematic() -> None:
 
 
 def test_schematic_create(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
 ) -> None:
     pdk = kcl
     layers = Layers()
@@ -126,11 +126,11 @@ def straight(length: int) -> kf.KCell:
 
     c = schematic.create_cell(kf.KCell)
     c.name = "test_schematic_create"
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_schematic_create_cell(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
 ) -> None:
     layers = Layers()
     pdk = kcl
@@ -177,11 +177,11 @@ def long_straight(n: int) -> kf.schematic.TSchematic[int]:
 
     assert pdk.factories["long_straight"].schematic_driven()
 
-    gds_regression(long_straight(2))
+    oas_regression(long_straight(2))
 
 
 def test_schematic_mirror_connection(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None], kcl: kf.KCLayout
 ) -> None:
     layers = Layers()
     pdk = kcl
@@ -238,12 +238,14 @@ def bend_s_euler(
             p2 = c.kcl.to_dbu(backbone[-1])
         li = c.kcl.layer(pdk.infos["WG"])
         c.create_port(
+            name="o1",
             trans=kf.kdb.Trans(2, False, p1.to_v()),
             width=width,
             port_type="optical",
             layer=li,
         )
         c.create_port(
+            name="o2",
             trans=kf.kdb.Trans(0, False, p2.to_v()),
             width=width,
             port_type="optical",
@@ -282,11 +284,11 @@ def straight_sbend(length: int, offset: int) -> kf.schematic.TSchematic[int]:
 
     assert pdk.factories["straight_sbend"].schematic_driven()
 
-    gds_regression(straight_sbend(length=10_000, offset=20_000))
+    oas_regression(straight_sbend(length=10_000, offset=20_000))
 
 
 def test_schematic_kcl_mix_netlist(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None], layers: Layers
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None], layers: Layers
 ) -> None:
     layers = Layers()
     pdk = kf.KCLayout("schematic_pdk_decorator", infos=layers.__class__)
@@ -340,11 +342,11 @@ def long_straight(n: int) -> kf.schematic.TSchematic[int]:
 
     c = long_straight(n=2000)
     c.netlist()
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_schematic_route(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     layers = Layers()
     pdk = kf.KCLayout("schematic_pdk_routing", infos=Layers)
@@ -374,10 +376,19 @@ def straight(width: int, length: int) -> kf.KCell:
     @pdk.routing_strategy
     def route_bundle(
         c: kf.ProtoTKCell[Any],
-        start_ports: Sequence[kf.ProtoPort[Any]],
-        end_ports: Sequence[kf.ProtoPort[Any]],
+        ports: Sequence[Sequence[kf.ProtoPort[Any]]],
         separation: int = 5000,
     ) -> list[kf.routing.generic.ManhattanRoute]:
+        start_ports: list[kf.Port] = []
+        end_ports: list[kf.Port] = []
+        for port_seq in ports:
+            if len(port_seq) != 2:
+                raise ValueError(
+                    "route_bundle does only support routing between two-port problems, "
+                    f"not multiple ports. Found {port_seq}"
+                )
+            start_ports.append(kf.Port(base=port_seq[0].base))
+            end_ports.append(kf.Port(base=port_seq[1].base))
         return kf.routing.optical.route_bundle(
             c=kf.KCell(base=c._base),
             start_ports=[kf.Port(base=sp.base) for sp in start_ports],
@@ -402,18 +413,21 @@ def route_example() -> kf.schematic.TSchematic[int]:
         s2.place(x=1000, y=210_000)
 
         schematic.add_route(
-            "s1-s2", [s1["o2"]], [s2["o2"]], "route_bundle", separation=20_000
+            "s1-s2",
+            [[s1["o2"], s2["o2"]]],
+            "route_bundle",
+            settings={"separation": 20_000},
         )
 
         return schematic
 
-    assert pdk.factories["route_example"]._f_orig is route_example.__wrapped__  # type: ignore[attr-defined]
+    assert pdk.factories["route_example"]._f_orig is route_example.__wrapped__  # ty:ignore[unresolved-attribute]
 
-    gds_regression(route_example())
+    oas_regression(route_example())
 
 
 def test_netlist(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     class Layers(kf.LayerInfos):
         WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0)
@@ -487,10 +501,19 @@ def pad_m2() -> kf.KCell:
     @pdk.routing_strategy
     def route_bundle(
         c: kf.ProtoTKCell[Any],
-        start_ports: Sequence[kf.ProtoPort[Any]],
-        end_ports: Sequence[kf.ProtoPort[Any]],
+        ports: Sequence[Sequence[kf.ProtoPort[Any]]],
         separation: int = 5000,
     ) -> list[kf.routing.generic.ManhattanRoute]:
+        start_ports: list[kf.Port] = []
+        end_ports: list[kf.Port] = []
+        for port_seq in ports:
+            if len(port_seq) != 2:
+                raise ValueError(
+                    "route_bundle does only support routing between two-port problems, "
+                    f"not multiple ports. Found {port_seq}"
+                )
+            start_ports.append(kf.Port(base=port_seq[0].base))
+            end_ports.append(kf.Port(base=port_seq[1].base))
         return kf.routing.optical.route_bundle(
             c=kf.KCell(base=c._base),
             start_ports=[kf.Port(base=sp.base) for sp in start_ports],
@@ -503,12 +526,21 @@ def route_bundle(
     @pdk.routing_strategy
     def route_bundle_elec(
         c: kf.ProtoTKCell[Any],
-        start_ports: Sequence[kf.ProtoPort[Any]],
-        end_ports: Sequence[kf.ProtoPort[Any]],
+        ports: Sequence[Sequence[kf.ProtoPort[Any]]],
         separation: int = 5000,
         start_straight: int = 0,
         end_straight: int = 0,
     ) -> list[kf.routing.generic.ManhattanRoute]:
+        start_ports: list[kf.Port] = []
+        end_ports: list[kf.Port] = []
+        for port_seq in ports:
+            if len(port_seq) != 2:
+                raise ValueError(
+                    "route_bundle does only support routing between two-port problems, "
+                    f"not multiple ports. Found {port_seq}"
+                )
+            start_ports.append(kf.Port(base=port_seq[0].base))
+            end_ports.append(kf.Port(base=port_seq[1].base))
         return kf.routing.electrical.route_bundle(
             c=kf.KCell(base=c._base),
             start_ports=[kf.Port(base=sp.base) for sp in start_ports],
@@ -541,21 +573,19 @@ def route_bundle_elec(
     padm2_2.place(x=0, y=-100_000, orientation=90)
 
     schematic.add_route(
-        "s1-s2", [s1["o2"]], [s2["o2"]], "route_bundle", separation=20_000
+        "s1-s2", [[s1["o2"], s2["o2"]]], "route_bundle", settings={"separation": 20_000}
     )
     schematic.add_route(
         "pm1_1-pm1_2",
-        [padm1_1["e1"]],
-        [padm1_2["e1"]],
+        [[padm1_1["e1"], padm1_2["e1"]]],
         "route_bundle_elec",
-        separation=20_000,
+        settings={"separation": 20_000},
     )
     schematic.add_route(
         "pm2_1-pm2_2",
-        [padm2_1["e1"]],
-        [padm2_2["e1"]],
+        [[padm2_1["e1"], padm2_2["e1"]]],
         "route_bundle_elec",
-        separation=20_000,
+        settings={"separation": 20_000},
     )
     schematic.add_port("o1", port=s1["o1"])
     schematic.add_port("o2", port=s2["o1"])
@@ -568,11 +598,11 @@ def route_bundle_elec(
         connectivity=[(layers.METAL1, layers.VIA1, layers.METAL2)],
     )
     assert nl == nl2[c.name]
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_netlist_equivalent(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     layers = Layers()
     pdk = kf.KCLayout(
@@ -640,10 +670,19 @@ def pad_m1() -> kf.KCell:
     @pdk.routing_strategy
     def route_bundle(
         c: kf.ProtoTKCell[Any],
-        start_ports: Sequence[kf.ProtoPort[Any]],
-        end_ports: Sequence[kf.ProtoPort[Any]],
+        ports: Sequence[Sequence[kf.ProtoPort[Any]]],
         separation: int = 5000,
     ) -> list[kf.routing.generic.ManhattanRoute]:
+        start_ports: list[kf.Port] = []
+        end_ports: list[kf.Port] = []
+        for port_seq in ports:
+            if len(port_seq) != 2:
+                raise ValueError(
+                    "route_bundle does only support routing between two-port problems, "
+                    f"not multiple ports. Found {port_seq}"
+                )
+            start_ports.append(kf.Port(base=port_seq[0].base))
+            end_ports.append(kf.Port(base=port_seq[1].base))
         return kf.routing.optical.route_bundle(
             c=kf.KCell(base=c._base),
             start_ports=[kf.Port(base=sp.base) for sp in start_ports],
@@ -656,12 +695,21 @@ def route_bundle(
     @pdk.routing_strategy
     def route_bundle_elec(
         c: kf.ProtoTKCell[Any],
-        start_ports: Sequence[kf.ProtoPort[Any]],
-        end_ports: Sequence[kf.ProtoPort[Any]],
+        ports: Sequence[Sequence[kf.ProtoPort[Any]]],
         separation: int = 5000,
         start_straight: int = 0,
         end_straight: int = 0,
     ) -> list[kf.routing.generic.ManhattanRoute]:
+        start_ports: list[kf.Port] = []
+        end_ports: list[kf.Port] = []
+        for port_seq in ports:
+            if len(port_seq) != 2:
+                raise ValueError(
+                    "route_bundle does only support routing between two-port problems, "
+                    f"not multiple ports. Found {port_seq}"
+                )
+            start_ports.append(kf.Port(base=port_seq[0].base))
+            end_ports.append(kf.Port(base=port_seq[1].base))
         return kf.routing.electrical.route_bundle(
             c=kf.KCell(base=c._base),
             start_ports=[kf.Port(base=sp.base) for sp in start_ports],
@@ -689,31 +737,27 @@ def route_bundle_elec(
 
     schematic.add_route(
         "pm1_1-pm1_2",
-        [padm1_1["e3"]],
-        [padm1_2["e1"]],
+        [(padm1_1["e3"], padm1_2["e1"])],
         "route_bundle_elec",
-        separation=20_000,
+        settings={"separation": 20_000},
     )
     schematic.add_route(
         "pm1_2-pm1_4",
-        [padm1_2["e4"]],
-        [padm1_4["e2"]],
+        [[padm1_2["e4"], padm1_4["e2"]]],
         "route_bundle_elec",
-        separation=20_000,
+        settings={"separation": 20_000},
     )
     schematic.add_route(
         "pm1_3-pm1_4",
-        [padm1_1["e4"]],
-        [padm1_3["e2"]],
+        [[padm1_1["e4"], padm1_3["e2"]]],
         "route_bundle_elec",
-        separation=20_000,
+        settings={"separation": 20_000},
     )
     schematic.add_route(
         "pm1_4-pm1_1",
-        [padm1_4["e1"]],
-        [padm1_3["e3"]],
+        [[padm1_4["e1"], padm1_3["e3"]]],
         "route_bundle_elec",
-        separation=20_000,
+        settings={"separation": 20_000},
     )
 
     nl = schematic.netlist()
@@ -722,7 +766,7 @@ def route_bundle_elec(
         ignore_unnamed=True, connectivity=[(layers.METAL1, layers.VIA1, layers.METAL2)]
     )
     assert (
-        nl.lvs_equivalent(
+        nl.normalize(
             cell_name=c.name, equivalent_ports={"pad_m1": [["e1", "e2", "e3", "e4"]]}
         )
         == nl2[c.name]
@@ -732,11 +776,11 @@ def route_bundle_elec(
 
     assert schema_str is not None
     c.name = "test_schematic_anchor"
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_schematic_anchor(
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
 ) -> None:
     pdk = kf.KCLayout("schematic_pdk_anchor_port", infos=Layers)
     layers = Layers()
@@ -784,7 +828,7 @@ def straight(length: int) -> kf.KCell:
 
     c = schematic.create_cell(kf.KCell)
     c.name = "test_schematic_anchor"
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_schematic_function_get_port_positions(
@@ -821,7 +865,7 @@ def bend_euler(
         """
         c = kcl.kcell()
 
-        xs = c.kcl.get_icross_section(cross_section)
+        xs = c.kcl.get_icross_section(cross_section, symmetrical=True)
         width = c.kcl.to_um(xs.width)
         radius = c.kcl.to_um(xs.radius)
         assert radius is not None, "Radius of cross section must not be None"
@@ -856,6 +900,7 @@ def bend_euler(
         )
         li = c.kcl.layer(layer)
         c.create_port(
+            name="o1",
             layer=li,
             width=c.kcl.to_dbu(width),
             trans=kf.kdb.Trans(2, False, c.kcl.to_dbu(backbone[0]).to_v()),
@@ -864,6 +909,7 @@ def bend_euler(
         if abs(angle % 90) < 0.001:
             _ang = round(angle)
             c.create_port(
+                name="o2",
                 trans=kf.kdb.Trans(
                     _ang // 90, False, c.kcl.to_dbu(backbone[-1]).to_v()
                 ),
@@ -872,6 +918,7 @@ def bend_euler(
             )
         else:
             c.create_port(
+                name="o2",
                 dcplx_trans=kf.kdb.DCplxTrans(1, angle, False, backbone[-1].to_v()),
                 width=c.kcl.to_dbu(width),
                 layer=li,
@@ -898,7 +945,7 @@ def bend_s_euler(
             resolution: Angle resolution for the backbone.
         """
         c = kcl.kcell()
-        xs = c.kcl.get_icross_section(cross_section)
+        xs = c.kcl.get_icross_section(cross_section, symmetrical=True)
 
         width = c.kcl.to_um(xs.width)
         radius = c.kcl.to_um(xs.radius)
@@ -931,12 +978,14 @@ def bend_s_euler(
             p2 = c.kcl.to_dbu(backbone[-1])
         li = c.kcl.layer(xs.layer)
         c.create_port(
+            name="o1",
             trans=kf.kdb.Trans(2, False, p1.to_v()),
             width=c.kcl.to_dbu(width),
             port_type="optical",
             layer=li,
         )
         c.create_port(
+            name="o2",
             trans=kf.kdb.Trans(0, False, p2.to_v()),
             width=c.kcl.to_dbu(width),
             port_type="optical",
@@ -966,7 +1015,7 @@ def straight(length: kf.typings.um, cross_section: str) -> kf.KCell:
             enclosure: Definition of slab/excludes. [dbu]
         """
         c = kcl.kcell()
-        xs = c.kcl.get_icross_section(cross_section)
+        xs = c.kcl.get_icross_section(cross_section, symmetrical=True)
 
         length_ = c.kcl.to_dbu(length)
 
@@ -980,14 +1029,19 @@ def straight(length: kf.typings.um, cross_section: str) -> kf.KCell:
 
         li = c.kcl.layer(xs.layer)
         c.shapes(li).insert(kf.kdb.Box(0, -xs.width // 2, length_, xs.width // 2))
-        c.create_port(trans=kf.kdb.Trans(2, False, 0, 0), layer=li, width=xs.width)
         c.create_port(
-            trans=kf.kdb.Trans(0, False, length_, 0), layer=li, width=xs.width
+            name="o1", trans=kf.kdb.Trans(2, False, 0, 0), layer=li, width=xs.width
+        )
+        c.create_port(
+            name="o2",
+            trans=kf.kdb.Trans(0, False, length_, 0),
+            layer=li,
+            width=xs.width,
         )
 
         xs.enclosure.apply_minkowski_y(c, xs.layer)
 
-        c.boundary = c.dbbox()  # type: ignore[assignment]
+        c.boundary = c.dbbox()  # ty:ignore[invalid-assignment]
         c.auto_rename_ports()
         return c
 
@@ -1183,6 +1237,27 @@ def tree(n: int) -> kf.Schematic:
         "bottom": ["o15", "o16"],
     }
 
+    # Port with PortRef orientation on a non-schematic-driven instance.
+    # Exercises the cell.ports[port.name] lookup: "o1" on `straight` faces
+    # left (180°) while "o2" faces right (0°). Using port.name="o1" must
+    # yield 180° (left), NOT 0° (right) which port.orientation.port would give.
+    s = kf.Schematic(kcl=kcl)
+    inst = s.create_inst(
+        "s1", "straight", settings={"cross_section": xs.name, "length": 10.0}
+    )
+    inst.place(x=0, y=0)
+    s.ports["p"] = kf.schematic.Port(
+        name="o1",
+        x=0,
+        y=0,
+        cross_section=xs.name,
+        orientation=kf.schematic.PortRef(instance="s1", port="o2"),
+    )
+    positions = s.get_port_positions(factories)
+    assert "p" in positions["left"], (
+        f"Expected port 'p' on 'left' (port.name='o1' → 180°), got {positions}"
+    )
+
 
 @pytest.mark.parametrize(
     "path",
@@ -1203,3 +1278,330 @@ def test_gdsfactory_yaml(path: Path) -> None:
     schematic = kf.read_schematic(path)
     for inst in schematic.instances.values():
         _ = inst.parent_schematic.name
+
+
+def test_route_multi(
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    yaml_regression: Callable[[kf.schematic.TSchematic[Any]], None],
+) -> None:
+    class Layers(kf.LayerInfos):
+        WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0)
+        WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(111, 0)
+        WGEXCLUDE: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 1)
+        WGCLADEXCLUDE: kf.kdb.LayerInfo = kf.kdb.LayerInfo(111, 1)
+        FILL1: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0)
+        FILL2: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0)
+        FILL3: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0)
+        METAL1: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0)
+        METAL1EX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 1)
+        VIA1: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0)
+        METAL2: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 0)
+        METAL2EX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(4, 1)
+
+    layers = Layers()
+    pdk = kf.KCLayout(
+        "schematic_route_multi",
+        infos=Layers,
+        connectivity=[(layers.METAL1, layers.VIA1, layers.METAL2)],
+    )
+
+    @pdk.routing_strategy
+    def route_bundle_elec(
+        c: kf.ProtoTKCell[Any],
+        ports: Sequence[Sequence[kf.ProtoPort[Any]]],
+        separation: int = 5000,
+    ) -> list[kf.routing.generic.ManhattanRoute]:
+        routes: list[kf.routing.generic.ManhattanRoute] = []
+        kc = kf.KCell(base=c._base)
+        for ports_ in ports:
+            p = kf.kdb.Point()
+            for port in ports_:
+                p += port.trans.disp
+
+            center = p / len(ports_)
+
+            for port in ports_:
+                port_ = kf.Port(base=port.base)
+                pc = port_.copy()
+                pc.trans = kf.kdb.Trans(
+                    pc.trans.angle % 2, False, center.to_v()
+                ) * kf.kdb.Trans(x=-port.iwidth // 2, y=0)
+
+                routes_ = kf.routing.electrical.route_bundle(
+                    kc, start_ports=[port_], end_ports=[pc], separation=separation
+                )
+                routes.extend(routes_)
+        return routes
+
+    @pdk.cell
+    def pad_m1() -> kf.KCell:
+        c = pdk.kcell()
+        c.shapes(layers.METAL1).insert(kf.kdb.Box(100_000))
+        c.create_port(
+            name="e1",
+            trans=kf.kdb.Trans(2, False, -50_000, 0),
+            width=10_000,
+            layer_info=layers.METAL1,
+            port_type="electrical",
+        )
+        c.create_port(
+            name="e2",
+            trans=kf.kdb.Trans(1, False, 0, 50_000),
+            width=10_000,
+            layer_info=layers.METAL1,
+            port_type="electrical",
+        )
+        c.create_port(
+            name="e3",
+            trans=kf.kdb.Trans(0, False, 50_000, 0),
+            width=10_000,
+            layer_info=layers.METAL1,
+            port_type="electrical",
+        )
+        c.create_port(
+            name="e4",
+            trans=kf.kdb.Trans(3, False, 0, -50_000),
+            width=10_000,
+            layer_info=layers.METAL1,
+            port_type="electrical",
+        )
+        return c
+
+    @pdk.schematic_cell
+    def multi_pad() -> kf.Schematic:
+        schematic = kf.Schematic(kcl=pdk)
+
+        padm1_1 = schematic.create_inst(name="padm1_1", component="pad_m1")
+        padm1_2 = schematic.create_inst(name="padm1_2", component="pad_m1")
+        padm1_3 = schematic.create_inst(name="padm1_3", component="pad_m1")
+
+        padm1_4 = schematic.create_inst(name="padm1_4", component="pad_m1")
+        padm1_5 = schematic.create_inst(name="padm1_5", component="pad_m1")
+        padm1_6 = schematic.create_inst(name="padm1_6", component="pad_m1")
+
+        padm1_1.place(x=0, y=0)
+        padm1_2.place(x=200_000, y=0)
+        padm1_3.place(x=100_000, y=200_000)
+        padm1_4.place(x=400_000, y=0)
+        padm1_5.place(x=600_000, y=0)
+        padm1_6.place(x=500_000, y=200_000)
+
+        schematic.add_route(
+            name="connect_all",
+            nets=[
+                [
+                    padm1_1["e2"],
+                    padm1_2["e2"],
+                    padm1_3["e4"],
+                    padm1_4["e2"],
+                    padm1_5["e2"],
+                    padm1_6["e4"],
+                ],
+            ],
+            routing_strategy="route_bundle_elec",
+            settings={"separation": 9000},
+        )
+
+        return schematic
+
+    c = multi_pad()
+    oas_regression(c)
+    yaml_regression(c.schematic)  # ty:ignore[invalid-argument-type]
+
+
+def _two_port_factory(kcl: kf.KCLayout, layers: Layers) -> None:
+    """Register a `straight` cell on `kcl` with a "dc" pin grouping o1+o2."""
+
+    @kcl.cell
+    def straight(length: int = 1000) -> kf.KCell:
+        c = kcl.kcell()
+        c.shapes(layers.WG).insert(kf.kdb.Box(0, -250, length, 250))
+        p1 = c.create_port(
+            name="o1",
+            width=500,
+            trans=kf.kdb.Trans(rot=2, x=0, y=0),
+            layer_info=layers.WG,
+        )
+        p2 = c.create_port(
+            name="o2",
+            width=500,
+            trans=kf.kdb.Trans(x=length, y=0),
+            layer_info=layers.WG,
+        )
+        c.create_pin(name="dc", ports=[p1, p2], pin_type="DC", info={"role": "bus"})
+        return c
+
+
+def test_schematic_create_pin(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(kcl=kcl)
+    a = schematic.create_inst(name="a", component="straight")
+    a.place(x=0, y=0)
+    schematic.add_port(name="left", port=a.ports["o1"])
+    schematic.add_port(name="right", port=a.ports["o2"])
+
+    pin = schematic.create_pin(
+        name="bus", ports=["left", "right"], pin_type="RF", info={"freq": 5}
+    )
+    assert pin.name == "bus"
+    assert pin.ports == ["left", "right"]
+    assert pin.pin_type == "RF"
+    assert pin.info == {"freq": 5}
+    assert "bus" in schematic.pins
+
+    c = schematic.create_cell(kf.KCell)
+    cell_pin_names = [p.name for p in c.pins]
+    assert "bus" in cell_pin_names
+    bus_pin = next(p for p in c.pins if p.name == "bus")
+    assert [p.name for p in bus_pin.ports] == ["left", "right"]
+    assert bus_pin.pin_type == "RF"
+
+
+def test_schematic_add_pin_forwards_instance_pin(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(kcl=kcl)
+    a = schematic.create_inst(name="a", component="straight")
+    a.place(x=0, y=0)
+    schematic.add_port(name="left", port=a.ports["o1"])
+    schematic.add_port(name="right", port=a.ports["o2"])
+
+    pin_ref = a.pins["dc"]
+    assert isinstance(pin_ref, kf.schematic.PinRef)
+    assert pin_ref.instance == "a"
+    assert pin_ref.pin == "dc"
+
+    schematic.add_pin(name="forwarded", pin=pin_ref)
+    assert isinstance(schematic.pins["forwarded"], kf.schematic.PinRef)
+
+    c = schematic.create_cell(kf.KCell)
+    fwd = next(p for p in c.pins if p.name == "forwarded")
+    assert {p.name for p in fwd.ports} == {"left", "right"}
+    # pin_type and info are propagated from the underlying instance pin
+    assert fwd.pin_type == "DC"
+    assert fwd.info["role"] == "bus"
+
+
+def test_schematic_create_pin_unknown_port_raises(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(kcl=kcl)
+    a = schematic.create_inst(name="a", component="straight")
+    a.place(x=0, y=0)
+    schematic.add_port(name="left", port=a.ports["o1"])
+
+    with pytest.raises(ValueError, match="not registered as schematic ports"):
+        schematic.create_pin(name="bus", ports=["left", "missing"])
+
+
+def test_schematic_create_pin_duplicate_raises(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(kcl=kcl)
+    a = schematic.create_inst(name="a", component="straight")
+    a.place(x=0, y=0)
+    schematic.add_port(name="left", port=a.ports["o1"])
+    schematic.create_pin(name="bus", ports=["left"])
+
+    with pytest.raises(ValueError, match="already exists"):
+        schematic.create_pin(name="bus", ports=["left"])
+
+
+def test_schematic_create_pin_empty_ports_raises(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(kcl=kcl)
+    with pytest.raises(ValueError, match="At least one port"):
+        schematic.create_pin(name="bus", ports=[])
+
+
+def test_schematic_add_pin_duplicate_raises(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(kcl=kcl)
+    a = schematic.create_inst(name="a", component="straight")
+    a.place(x=0, y=0)
+    schematic.add_pin(name="dc", pin=a.pins["dc"])
+
+    with pytest.raises(ValueError, match="already exists"):
+        schematic.add_pin(name="dc", pin=a.pins["dc"])
+
+
+def test_schematic_add_pin_missing_underlying_port_raises(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(kcl=kcl)
+    a = schematic.create_inst(name="a", component="straight")
+    a.place(x=0, y=0)
+    # only expose o1 as a top-level port; "dc" pin needs both o1 and o2.
+    schematic.add_port(name="left", port=a.ports["o1"])
+    schematic.add_pin(name="forwarded", pin=a.pins["dc"])
+
+    with pytest.raises(ValueError, match="not exposed as top-level"):
+        schematic.create_cell(kf.KCell)
+
+
+def test_schematic_pin_yaml_round_trip(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(kcl=kcl)
+    a = schematic.create_inst(name="a", component="straight")
+    a.place(x=0, y=0)
+    schematic.add_port(name="left", port=a.ports["o1"])
+    schematic.add_port(name="right", port=a.ports["o2"])
+    schematic.create_pin(name="bus", ports=["left", "right"], pin_type="RF")
+    schematic.add_pin(name="forwarded", pin=a.pins["dc"])
+
+    dumped = schematic.model_dump()
+    dumped.pop("unit", None)
+    reloaded = kf.Schematic.model_validate(dumped)
+
+    bus = reloaded.pins["bus"]
+    assert isinstance(bus, kf.schematic.Pin)
+    assert bus.ports == ["left", "right"]
+    assert bus.pin_type == "RF"
+
+    fwd = reloaded.pins["forwarded"]
+    assert isinstance(fwd, kf.schematic.PinRef)
+    assert fwd.instance == "a"
+    assert fwd.pin == "dc"
+
+
+def test_schematic_pin_yaml_string_shorthand() -> None:
+    yaml = YAML(typ=["rt", "safe", "string"])
+    schema_yaml = """
+instances:
+  s:
+    component: straight
+    settings:
+      length: 5
+
+pins:
+  forwarded: s,dc
+"""
+    schematic = kf.DSchematic.model_validate(yaml.load(schema_yaml))
+    fwd = schematic.pins["forwarded"]
+    assert isinstance(fwd, kf.schematic.PinRef)
+    assert fwd.instance == "s"
+    assert fwd.pin == "dc"
+
+
+def test_schematic_pin_code_str(kcl: kf.KCLayout) -> None:
+    _two_port_factory(kcl, Layers())
+
+    schematic = kf.Schematic(name="schem", kcl=kcl)
+    a = schematic.create_inst(name="a", component="straight")
+    a.place(x=0, y=0)
+    schematic.add_port(name="left", port=a.ports["o1"])
+    schematic.add_port(name="right", port=a.ports["o2"])
+    schematic.create_pin(name="bus", ports=["left", "right"], pin_type="RF")
+    schematic.add_pin(name="forwarded", pin=a.pins["dc"])
+
+    code = schematic.code_str(ruff_format=False)
+    assert "schematic.create_pin(" in code
+    assert "name='bus'" in code
+    assert "ports=['left', 'right']" in code
+    assert "pin_type='RF'" in code
+    assert "schematic.add_pin(name='forwarded'" in code
+    assert "a.pins['dc']" in code
diff --git a/tests/test_session.py b/tests/test_session.py
index 7e83472f6..cc8686b39 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -1,8 +1,25 @@
+import pickle
+from pathlib import Path
+
+import pytest
+
 import kfactory as kf
-from tests.session import session1, session2, session3
+from tests.session import session1, session2, session3, session_func
+
 
+@pytest.fixture
+def session_dir(tmp_path: Path) -> Path:
+    """Per-test session directory.
 
-def test_session_cache() -> None:
+    The default `build/session/kcls` is shared across xdist workers, which
+    causes Windows-only races between concurrent save_session/load_session
+    calls (rmtree fails or files vanish mid-read). pytest gives each test a
+    unique tmp_path even under xdist, so threading it through isolates them.
+    """
+    return tmp_path / "kcls"
+
+
+def test_session_cache(session_dir: Path) -> None:
     c = session3.my_cell(a=5)
 
     f2 = session2.kcl2.factories["my_other_cell"]
@@ -12,7 +29,7 @@ def test_session_cache() -> None:
     assert session2.cell_created
     assert session3.cell_created
 
-    kf.save_session(c=c)
+    kf.save_session(c=c, session_dir=session_dir)
 
     session1.cell_created = False
     session2.cell_created = False
@@ -21,7 +38,7 @@ def test_session_cache() -> None:
     f1.prune()
     f2.prune()
 
-    kf.load_session()
+    kf.load_session(session_dir=session_dir)
 
     session3.my_cell(a=5)
 
@@ -41,7 +58,7 @@ def test_session_cache() -> None:
     assert session2.cell_created
     assert session3.cell_created
 
-    kf.save_session(session1.test())
+    kf.save_session(session1.test(), session_dir=session_dir)
 
     f1.prune()
     f2.prune()
@@ -50,10 +67,56 @@ def test_session_cache() -> None:
     session2.cell_created = False
     session3.cell_created = False
 
-    kf.load_session()
+    kf.load_session(session_dir=session_dir)
 
     session3.my_cell(a=5)
 
     assert not session1.cell_created
     assert session2.cell_created
     assert session3.cell_created
+
+
+def test_session_cache_function_arg(session_dir: Path) -> None:
+    """Test that factories with function arguments can be saved/loaded."""
+    c = session_func.cell_with_func_arg(func=session_func.make_box, size=500)
+    f = session_func.kcl_func.factories["cell_with_func_arg"]
+
+    assert session_func.cell_created
+
+    kf.save_session(c=c, session_dir=session_dir)
+
+    session_func.cell_created = False
+    f.prune()
+
+    kf.load_session(session_dir=session_dir)
+
+    session_func.cell_with_func_arg(func=session_func.make_box, size=500)
+
+    assert not session_func.cell_created
+
+
+def test_session_cache_lambda_rejected() -> None:
+    """Test that FunctionPickler rejects lambda functions."""
+    import io
+
+    from kfactory.session_cache import FunctionPickler
+
+    data = {"key": lambda x: x}
+    buf = io.BytesIO()
+    with pytest.raises(pickle.PicklingError, match="Cannot pickle lambda"):
+        FunctionPickler(buf).dump(data)
+
+
+def test_session_cache_nested_func_rejected() -> None:
+    """Test that FunctionPickler rejects nested (closure) functions."""
+    import io
+
+    from kfactory.session_cache import FunctionPickler
+
+    def nested_func() -> None:
+        pass
+
+    data = {"key": nested_func}
+    buf = io.BytesIO()
+    with pytest.raises(pickle.PicklingError, match="Cannot pickle nested function"):
+        FunctionPickler(buf).dump(data)
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 38b91fcdf..474937e92 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,5 +1,5 @@
 import pytest
-from pydantic import ValidationError
+from pydantic import BaseModel, ValidationError
 
 from kfactory.settings import Info, KCellSettings, KCellSettingsUnits
 
@@ -60,10 +60,39 @@ def test_info_setitem() -> None:
 
 def test_info_setitem_rejects_bad_type() -> None:
     info = Info(key1=42)
-    with pytest.raises(ValidationError):
-        info["bad"] = object()
-    with pytest.raises(ValidationError):
-        info["bad"] = [object()]
+    with pytest.raises(ValueError, match=r"^Values of the info dict only support"):
+        info["bad"] = object()  # ty:ignore[invalid-assignment]
+    with pytest.raises(ValueError, match=r"^Values of the info dict only support"):
+        info["bad"] = [object()]  # ty:ignore[invalid-assignment]
+    assert "bad" not in info
+
+
+def test_info_setitem_does_not_corrupt_existing_keys() -> None:
+    """Regression for gdsfactory/kfactory#944.
+
+    Setting a second key must not silently mutate values stored under
+    earlier keys.
+    """
+    info = Info()
+    info["my_data"] = [1, 2, 3]
+    info["nested"] = {"a": (4, 5), "b": [6, 7]}
+    info["unrelated"] = "hello"
+    assert info["my_data"] == [1, 2, 3]
+    assert info["nested"] == {"a": (4, 5), "b": [6, 7]}
+    assert info["unrelated"] == "hello"
+
+
+def test_info_rejects_basemodel_in_list() -> None:
+    """Exact reproducer from gdsfactory/kfactory#944."""
+
+    class MyModel(BaseModel):
+        name: str = "important"
+        value: int = 42
+
+    info = Info()
+    with pytest.raises(ValueError, match=r"^Values of the info dict only support"):
+        info["my_data"] = [MyModel()]  # ty:ignore[invalid-assignment]
+    assert "my_data" not in info
 
 
 def test_info_update() -> None:
@@ -75,10 +104,10 @@ def test_info_update() -> None:
 
 def test_info_update_rejects_bad_type() -> None:
     info = Info(key1=42)
-    with pytest.raises(ValidationError):
-        info.update({"bad": object()})
-    with pytest.raises(ValidationError):
-        info.update({"nested_bad": [{"deeper": object()}]})
+    with pytest.raises(ValueError, match=r"^Values of the info dict only support"):
+        info.update({"bad": object()})  # ty:ignore[invalid-argument-type]
+    with pytest.raises(ValueError, match=r"^Values of the info dict only support"):
+        info.update({"nested_bad": [{"deeper": object()}]})  # ty:ignore[invalid-argument-type]
 
 
 def test_info_contains() -> None:
diff --git a/tests/test_spiral.py b/tests/test_spiral.py
index 26e2de251..906a6a17e 100644
--- a/tests/test_spiral.py
+++ b/tests/test_spiral.py
@@ -10,7 +10,7 @@
 
 def test_spiral(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell("spiral")
@@ -93,12 +93,12 @@ def bend_circular(
         b = c << bend_circular(width=1000, radius=r2, layer=layers.WG)
         b.connect("W0", p)
         p = b.ports["N0"]
-    gds_regression(c)
+    oas_regression(c)
 
 
 def test_dspiral(
     layers: Layers,
-    gds_regression: Callable[[kf.ProtoTKCell[Any]], None],
+    oas_regression: Callable[[kf.ProtoTKCell[Any]], None],
     kcl: kf.KCLayout,
 ) -> None:
     c = kcl.kcell()
@@ -186,4 +186,4 @@ def dbend_circular(
         b = c << dbend_circular(width=1, radius=r2, layer=layers.WG)
         b.connect("W0", p)
         p = b.ports["N0"]
-    gds_regression(c)
+    oas_regression(c)
diff --git a/tests/test_utilities.py b/tests/test_utilities.py
index b71fd950b..12a204097 100644
--- a/tests/test_utilities.py
+++ b/tests/test_utilities.py
@@ -38,16 +38,26 @@ def test_check_metadata_type() -> None:
     assert check_metadata_type([1, 2, 3]) == [1, 2, 3]
     assert check_metadata_type({"key": "value"}) == {"key": "value"}
 
-    with pytest.raises(ValueError, match=r"^Values of the info dict only support.*"):
-        check_metadata_type({1, 2, 3})  # type: ignore[arg-type]
-
-    with pytest.raises(ValueError, match=r"^Values of the info dict only support.*"):
+    with pytest.raises(
+        ValueError, match=r"^MetaData values of the info dict only support.*"
+    ):
+        check_metadata_type({1, 2, 3})
+
+    with pytest.raises(
+        ValueError, match=r"^MetaData values of the info dict only support.*"
+    ):
         check_metadata_type([object()])
-    with pytest.raises(ValueError, match=r"^Values of the info dict only support.*"):
+    with pytest.raises(
+        ValueError, match=r"^MetaData values of the info dict only support.*"
+    ):
         check_metadata_type((object(),))
-    with pytest.raises(ValueError, match=r"^Values of the info dict only support.*"):
+    with pytest.raises(
+        ValueError, match=r"^MetaData values of the info dict only support.*"
+    ):
         check_metadata_type({"k": object()})
-    with pytest.raises(ValueError, match=r"^Values of the info dict only support.*"):
+    with pytest.raises(
+        ValueError, match=r"^MetaData values of the info dict only support.*"
+    ):
         check_metadata_type([[object()]])
 
 
@@ -63,9 +73,9 @@ def test_polygon_from_array() -> None:
 
 
 def test_check_inst_ports() -> None:
-    p1 = Port(width=10, angle=0, port_type="input", layer=1, center=(0, 0))
-    p2 = Port(width=10, angle=2, port_type="input", layer=1, center=(0, 0))
-    p3 = Port(width=6, angle=1, port_type="output", layer=1, center=(0, 0))
+    p1 = Port(name="o1", width=10, angle=0, port_type="input", layer=1, center=(0, 0))
+    p2 = Port(name="o2", width=10, angle=2, port_type="input", layer=1, center=(0, 0))
+    p3 = Port(name="o3", width=6, angle=1, port_type="output", layer=1, center=(0, 0))
 
     assert check_inst_ports(p1, p2) == 0
     assert check_inst_ports(p1, p3) == 7
@@ -95,7 +105,7 @@ def test_instance_port_name(layers: Layers, kcl: kf.KCLayout) -> None:
 
     assert (
         instance_port_name(inst, inst.ports[0])
-        == 'straight_W5000_L10000_LWG_ENone_0_0["o1"]'
+        == 'straight_CS028523d7_5000_L10000_0_0["o1"]'
     )
 
 
diff --git a/tests/test_utilities_extra.py b/tests/test_utilities_extra.py
new file mode 100644
index 000000000..47bc48f11
--- /dev/null
+++ b/tests/test_utilities_extra.py
@@ -0,0 +1,111 @@
+"""Extra tests for kfactory.utilities module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import kfactory as kf
+from kfactory.utilities import (
+    dpolygon_from_array,
+    ensure_build_directory,
+    get_build_path,
+    get_session_directory,
+    save_layout_options,
+    update_default_trans,
+)
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    import pytest
+
+
+def test_save_layout_options() -> None:
+    save = save_layout_options(gds2_write_timestamps=False)
+    assert save.gds2_write_timestamps is False
+
+
+def test_update_default_trans() -> None:
+    from kfactory.conf import DEFAULT_TRANS
+
+    snapshot = dict(DEFAULT_TRANS)
+    try:
+        update_default_trans({"_test_extra_key": "value"})
+        assert DEFAULT_TRANS["_test_extra_key"] == "value"
+    finally:
+        DEFAULT_TRANS.clear()
+        DEFAULT_TRANS.update(snapshot)
+
+
+def test_dpolygon_from_array() -> None:
+    poly = dpolygon_from_array([(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)])
+    assert isinstance(poly, kf.kdb.DPolygon)
+
+
+def test_ensure_build_directory_with_project_dir(
+    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+    monkeypatch.setattr(kf.config, "project_dir", tmp_path)
+    target = ensure_build_directory("mask", create_gitignore=True)
+    assert target is not None
+    assert target.exists()
+    assert (tmp_path / "build" / ".gitignore").exists()
+
+
+def test_ensure_build_directory_no_gitignore(
+    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+    monkeypatch.setattr(kf.config, "project_dir", tmp_path)
+    target = ensure_build_directory("mask2", create_gitignore=False)
+    assert target is not None
+    # gitignore may or may not exist depending on prior tests; just verify dir
+    assert target.exists()
+
+
+def test_ensure_build_directory_no_project_dir(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setattr(kf.config, "project_dir", None)
+    assert ensure_build_directory() is None
+
+
+def test_get_build_path_with_project_dir(
+    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+    monkeypatch.setattr(kf.config, "project_dir", tmp_path)
+    path, should_delete = get_build_path("myfile", subdirectory="gds")
+    assert should_delete is False
+    assert path.suffix == ".gds"
+    assert path.parent.exists()
+
+
+def test_get_build_path_no_project_dir(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setattr(kf.config, "project_dir", None)
+    path, should_delete = get_build_path("tempfile", file_format="oas")
+    assert should_delete is True
+    assert path.suffix == ".oas"
+
+
+def test_get_session_directory_custom_dir(tmp_path: Path) -> None:
+    custom = tmp_path / "custom_session"
+    assert get_session_directory(custom_dir=custom) == custom
+
+
+def test_get_session_directory_with_project_dir(
+    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+    monkeypatch.setattr(kf.config, "project_dir", tmp_path)
+    target = get_session_directory()
+    assert target.exists()
+
+
+def test_get_session_directory_no_project_dir(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setattr(kf.config, "project_dir", None)
+    target = get_session_directory()
+    from pathlib import Path
+
+    assert Path("session/kcls").parts == target.parts[-2:]
diff --git a/tests/test_vkcell.py b/tests/test_vkcell.py
index f81df210a..1c901a508 100644
--- a/tests/test_vkcell.py
+++ b/tests/test_vkcell.py
@@ -106,3 +106,53 @@ def test_vkcell_attributes() -> None:
     assert c.size_info.nc == (5, 10)
     assert c.size_info.cc == (5, 5)
     assert c.size_info.center == (5, 5)
+
+
+def test_insert_kcell_into_vkcell() -> None:
+    kcl = kf.KCLayout("test_insert_kcell_into_vkcell")
+    layer = kcl.layer(kdb.LayerInfo(1, 0))
+    trans = kdb.DCplxTrans(1.0, 90, False, 5.0, 5.0)
+
+    src = kcl.kcell("src")
+    src.shapes(layer).insert(kdb.Box(0, 0, 10_000, 10_000))
+
+    dst = kcl.kcell("dst")
+    kf.VInstance(src, trans=trans).insert_into_flat(dst)
+
+    vk_dst = kcl.vkcell("vk")
+    kf.VInstance(src, trans=trans).insert_into_flat(vk_dst)
+
+    assert vk_dst.shapes(layer).size() == 1
+    assert vk_dst.dbbox() == dst.dbbox() == kdb.DBox(-5, 5, 5, 15)
+    assert all(isinstance(s, kdb.DPolygon) for s in vk_dst.shapes(layer))
+
+
+def test_vkcell_flatten_applies_instance_trans_once() -> None:
+    kcl = kf.KCLayout("test_vkcell_flatten_applies_instance_trans_once")
+    layer = kcl.layer(kdb.LayerInfo(1, 0))
+
+    src = kcl.vkcell("src")
+    src.shapes(layer).insert(kdb.DBox(0, 0, 10, 10))
+
+    parent = kcl.vkcell("parent")
+    parent.create_inst(src, trans=kdb.DCplxTrans(1.0, 0, False, 100.0, 0.0))
+    parent.flatten()
+
+    assert parent.shapes(layer).size() == 1
+    assert next(iter(parent.shapes(layer))).bbox() == kdb.DBox(100, 0, 110, 10)
+
+
+def test_vkcell_flatten_is_idempotent() -> None:
+    kcl = kf.KCLayout("test_vkcell_flatten_is_idempotent")
+    layer = kcl.layer(kdb.LayerInfo(1, 0))
+
+    src = kcl.vkcell("src")
+    src.shapes(layer).insert(kdb.DBox(0, 0, 10, 10))
+
+    parent = kcl.vkcell("parent")
+    parent.create_inst(src)
+
+    parent.flatten()
+    parent.flatten()
+
+    assert parent.shapes(layer).size() == 1
diff --git a/uv.lock b/uv.lock
index b25252690..3edb3050a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,18 +1,23 @@
 version = 1
 revision = 3
-requires-python = ">=3.11"
-resolution-markers = [
-    "python_full_version >= '3.12'",
-    "python_full_version < '3.12'",
-]
+requires-python = ">=3.12"
 
 [[package]]
 name = "aenum"
-version = "3.1.16"
+version = "3.1.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/e9/8b283567c1fef7c24d1f390b37daede8b61593d8cdaffb8e95d571699e83/aenum-3.1.17.tar.gz", hash = "sha256:a969a4516b194895de72c875ece355f17c0d272146f7fda346ef74f93cf4d5ba", size = 137648, upload-time = "2026-03-20T20:43:29.846Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/48/8d/1fe30c6fd8999b9d462547c4a1bb6690bda24af38f2913c4bec7decb81f2/aenum-3.1.17-py3-none-any.whl", hash = "sha256:8b883a37a04e74cc838ac442bdd28c266eae5bbf13e1342c7ef123ed25230139", size = 165560, upload-time = "2026-03-20T20:43:27.681Z" },
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/09/7a/61ed58e8be9e30c3fe518899cc78c284896d246d51381bab59b5db11e1f3/aenum-3.1.16.tar.gz", hash = "sha256:bfaf9589bdb418ee3a986d85750c7318d9d2839c1b1a1d6fe8fc53ec201cf140", size = 137693, upload-time = "2026-01-12T22:34:38.819Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/e3/52/6ad8f63ec8da1bf40f96996d25d5b650fdd38f5975f8c813732c47388f18/aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf", size = 165627, upload-time = "2025-04-25T03:17:58.89Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
 ]
 
 [[package]]
@@ -35,11 +40,11 @@ wheels = [
 
 [[package]]
 name = "astroid"
-version = "4.0.3"
+version = "4.0.4"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/c17d0f83016532a1ad87d1de96837164c99d47a3b6bbba28bd597c25b37a/astroid-4.0.3.tar.gz", hash = "sha256:08d1de40d251cc3dc4a7a12726721d475ac189e4e583d596ece7422bc176bda3", size = 406224, upload-time = "2026-01-03T22:14:26.096Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ce/66/686ac4fc6ef48f5bacde625adac698f41d5316a9753c2b20bb0931c9d4e2/astroid-4.0.3-py3-none-any.whl", hash = "sha256:864a0a34af1bd70e1049ba1e61cee843a7252c826d97825fcee9b2fcbd9e1b14", size = 276443, upload-time = "2026-01-03T22:14:24.412Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
 ]
 
 [[package]]
@@ -53,11 +58,11 @@ wheels = [
 
 [[package]]
 name = "attrs"
-version = "25.4.0"
+version = "26.1.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+    { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
 ]
 
 [[package]]
@@ -72,45 +77,22 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/d8/f2/e63c9f9c485cd90df8e4e7ae90fa3be2469c9641888558c7b45fa98a76f8/autopep8-2.0.4-py2.py3-none-any.whl", hash = "sha256:067959ca4a07b24dbd5345efa8325f5f58da4298dab0dde0443d5ed765de80cb", size = 45340, upload-time = "2023-08-26T13:49:56.111Z" },
 ]
 
-[[package]]
-name = "babel"
-version = "2.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
-]
-
-[[package]]
-name = "backrefs"
-version = "6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" },
-    { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" },
-    { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" },
-    { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" },
-    { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" },
-    { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" },
-]
-
 [[package]]
 name = "beautifulsoup4"
-version = "4.14.3"
+version = "4.15.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "soupsieve" },
     { name = "typing-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+    { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
 ]
 
 [[package]]
 name = "black"
-version = "26.1.0"
+version = "26.5.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "click" },
@@ -120,41 +102,36 @@ dependencies = [
     { name = "platformdirs" },
     { name = "pytokens" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/30/83/f05f22ff13756e1a8ce7891db517dbc06200796a16326258268f4658a745/black-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5", size = 1831956, upload-time = "2026-01-18T04:59:21.38Z" },
-    { url = "https://files.pythonhosted.org/packages/7d/f2/b2c570550e39bedc157715e43927360312d6dd677eed2cc149a802577491/black-26.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68", size = 1672499, upload-time = "2026-01-18T04:59:23.257Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/d7/990d6a94dc9e169f61374b1c3d4f4dd3037e93c2cc12b6f3b12bc663aa7b/black-26.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14", size = 1735431, upload-time = "2026-01-18T04:59:24.729Z" },
-    { url = "https://files.pythonhosted.org/packages/36/1c/cbd7bae7dd3cb315dfe6eeca802bb56662cc92b89af272e014d98c1f2286/black-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c", size = 1400468, upload-time = "2026-01-18T04:59:27.381Z" },
-    { url = "https://files.pythonhosted.org/packages/59/b1/9fe6132bb2d0d1f7094613320b56297a108ae19ecf3041d9678aec381b37/black-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4", size = 1207332, upload-time = "2026-01-18T04:59:28.711Z" },
-    { url = "https://files.pythonhosted.org/packages/f5/13/710298938a61f0f54cdb4d1c0baeb672c01ff0358712eddaf29f76d32a0b/black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f", size = 1878189, upload-time = "2026-01-18T04:59:30.682Z" },
-    { url = "https://files.pythonhosted.org/packages/79/a6/5179beaa57e5dbd2ec9f1c64016214057b4265647c62125aa6aeffb05392/black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6", size = 1700178, upload-time = "2026-01-18T04:59:32.387Z" },
-    { url = "https://files.pythonhosted.org/packages/8c/04/c96f79d7b93e8f09d9298b333ca0d31cd9b2ee6c46c274fd0f531de9dc61/black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a", size = 1777029, upload-time = "2026-01-18T04:59:33.767Z" },
-    { url = "https://files.pythonhosted.org/packages/49/f9/71c161c4c7aa18bdda3776b66ac2dc07aed62053c7c0ff8bbda8c2624fe2/black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791", size = 1406466, upload-time = "2026-01-18T04:59:35.177Z" },
-    { url = "https://files.pythonhosted.org/packages/4a/8b/a7b0f974e473b159d0ac1b6bcefffeb6bec465898a516ee5cc989503cbc7/black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954", size = 1216393, upload-time = "2026-01-18T04:59:37.18Z" },
-    { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" },
-    { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" },
-    { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" },
-    { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" },
-    { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" },
-    { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" },
-    { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" },
-    { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" },
-    { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" },
-    { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" },
+    { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" },
+    { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" },
+    { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" },
+    { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" },
+    { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" },
+    { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" },
+    { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
 ]
 
 [[package]]
 name = "bleach"
-version = "6.3.0"
+version = "6.4.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "webencodings" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" },
+    { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" },
 ]
 
 [package.optional-dependencies]
@@ -164,20 +141,20 @@ css = [
 
 [[package]]
 name = "cachetools"
-version = "6.2.4"
+version = "7.1.4"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" },
 ]
 
 [[package]]
 name = "certifi"
-version = "2026.1.4"
+version = "2026.5.20"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+    { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
 ]
 
 [[package]]
@@ -189,19 +166,6 @@ dependencies = [
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
-    { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
-    { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
-    { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
-    { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
-    { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
-    { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
-    { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
-    { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
-    { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
-    { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
     { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
     { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
     { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
@@ -261,75 +225,75 @@ wheels = [
 
 [[package]]
 name = "charset-normalizer"
-version = "3.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
-    { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
-    { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
-    { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
-    { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
-    { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
-    { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
-    { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
-    { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
-    { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
-    { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
-    { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
-    { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
-    { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
-    { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
-    { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
-    { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
-    { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
-    { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
-    { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
-    { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
-    { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
-    { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
-    { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
-    { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
-    { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
-    { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
-    { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
-    { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
-    { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
-    { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
-    { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
-    { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
-    { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
-    { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
-    { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
-    { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
-    { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
-    { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
-    { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
-    { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
-    { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
-    { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
-    { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
-    { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
-    { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
-    { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
-    { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
-    { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
-    { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
-    { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
-    { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
-    { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
-    { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
-    { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
-    { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
-    { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
-    { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
-    { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
-    { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
-    { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
-    { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
-    { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+    { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+    { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+    { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+    { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+    { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
 ]
 
 [[package]]
@@ -348,14 +312,14 @@ wheels = [
 
 [[package]]
 name = "click"
-version = "8.3.1"
+version = "8.4.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "colorama", marker = "sys_platform == 'win32'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
 ]
 
 [[package]]
@@ -378,128 +342,125 @@ wheels = [
 
 [[package]]
 name = "coverage"
-version = "7.13.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" },
-    { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" },
-    { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" },
-    { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" },
-    { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" },
-    { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" },
-    { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" },
-    { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" },
-    { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" },
-    { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" },
-    { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" },
-    { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" },
-    { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" },
-    { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
-    { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
-    { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
-    { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
-    { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
-    { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
-    { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
-    { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
-    { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
-    { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
-    { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
-    { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
-    { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" },
-    { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" },
-    { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" },
-    { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" },
-    { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" },
-    { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" },
-    { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" },
-    { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" },
-    { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" },
-    { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" },
-    { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" },
-    { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" },
-    { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" },
-    { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" },
-    { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" },
-    { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" },
-    { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" },
-    { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" },
-    { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" },
-    { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" },
-    { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" },
-    { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" },
-    { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" },
-    { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" },
-    { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" },
-    { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" },
-    { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" },
-    { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" },
-    { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" },
-    { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" },
-    { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" },
-    { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" },
-    { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" },
-    { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" },
-    { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" },
-    { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" },
-    { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" },
-    { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" },
-    { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" },
-    { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" },
-    { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" },
-    { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" },
-    { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" },
-    { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" },
-    { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" },
-    { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" },
-    { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" },
-    { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" },
-    { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" },
-    { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" },
-    { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
-]
-
-[package.optional-dependencies]
-toml = [
-    { name = "tomli", marker = "python_full_version <= '3.11'" },
+version = "7.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" },
+    { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" },
+    { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" },
+    { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" },
+    { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" },
+    { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" },
+    { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" },
+    { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" },
+    { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" },
+    { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" },
+    { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" },
+    { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" },
+    { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" },
+    { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" },
+    { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" },
+    { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" },
+    { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" },
+    { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" },
+    { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" },
+    { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" },
+    { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" },
+    { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" },
+    { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" },
+    { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" },
+    { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" },
 ]
 
 [[package]]
 name = "debugpy"
-version = "1.8.19"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/80/e2/48531a609b5a2aa94c6b6853afdfec8da05630ab9aaa96f1349e772119e9/debugpy-1.8.19-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:c5dcfa21de1f735a4f7ced4556339a109aa0f618d366ede9da0a3600f2516d8b", size = 2207620, upload-time = "2025-12-15T21:53:37.1Z" },
-    { url = "https://files.pythonhosted.org/packages/1b/d4/97775c01d56071969f57d93928899e5616a4cfbbf4c8cc75390d3a51c4a4/debugpy-1.8.19-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:806d6800246244004625d5222d7765874ab2d22f3ba5f615416cf1342d61c488", size = 3170796, upload-time = "2025-12-15T21:53:38.513Z" },
-    { url = "https://files.pythonhosted.org/packages/8d/7e/8c7681bdb05be9ec972bbb1245eb7c4c7b0679bb6a9e6408d808bc876d3d/debugpy-1.8.19-cp311-cp311-win32.whl", hash = "sha256:783a519e6dfb1f3cd773a9bda592f4887a65040cb0c7bd38dde410f4e53c40d4", size = 5164287, upload-time = "2025-12-15T21:53:40.857Z" },
-    { url = "https://files.pythonhosted.org/packages/f2/a8/aaac7ff12ddf5d68a39e13a423a8490426f5f661384f5ad8d9062761bd8e/debugpy-1.8.19-cp311-cp311-win_amd64.whl", hash = "sha256:14035cbdbb1fe4b642babcdcb5935c2da3b1067ac211c5c5a8fdc0bb31adbcaa", size = 5188269, upload-time = "2025-12-15T21:53:42.359Z" },
-    { url = "https://files.pythonhosted.org/packages/4a/15/d762e5263d9e25b763b78be72dc084c7a32113a0bac119e2f7acae7700ed/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e", size = 2549995, upload-time = "2025-12-15T21:53:43.773Z" },
-    { url = "https://files.pythonhosted.org/packages/a7/88/f7d25c68b18873b7c53d7c156ca7a7ffd8e77073aa0eac170a9b679cf786/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de", size = 4309891, upload-time = "2025-12-15T21:53:45.26Z" },
-    { url = "https://files.pythonhosted.org/packages/c5/4f/a65e973aba3865794da65f71971dca01ae66666132c7b2647182d5be0c5f/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee", size = 5286355, upload-time = "2025-12-15T21:53:46.763Z" },
-    { url = "https://files.pythonhosted.org/packages/d8/3a/d3d8b48fec96e3d824e404bf428276fb8419dfa766f78f10b08da1cb2986/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d", size = 5328239, upload-time = "2025-12-15T21:53:48.868Z" },
-    { url = "https://files.pythonhosted.org/packages/71/3d/388035a31a59c26f1ecc8d86af607d0c42e20ef80074147cd07b180c4349/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d", size = 2538859, upload-time = "2025-12-15T21:53:50.478Z" },
-    { url = "https://files.pythonhosted.org/packages/4a/19/c93a0772d0962294f083dbdb113af1a7427bb632d36e5314297068f55db7/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606", size = 4292575, upload-time = "2025-12-15T21:53:51.821Z" },
-    { url = "https://files.pythonhosted.org/packages/5c/56/09e48ab796b0a77e3d7dc250f95251832b8bf6838c9632f6100c98bdf426/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976", size = 5286209, upload-time = "2025-12-15T21:53:53.602Z" },
-    { url = "https://files.pythonhosted.org/packages/fb/4e/931480b9552c7d0feebe40c73725dd7703dcc578ba9efc14fe0e6d31cfd1/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73", size = 5328206, upload-time = "2025-12-15T21:53:55.433Z" },
-    { url = "https://files.pythonhosted.org/packages/f6/b9/cbec520c3a00508327476c7fce26fbafef98f412707e511eb9d19a2ef467/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af", size = 2537372, upload-time = "2025-12-15T21:53:57.318Z" },
-    { url = "https://files.pythonhosted.org/packages/88/5e/cf4e4dc712a141e10d58405c58c8268554aec3c35c09cdcda7535ff13f76/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809", size = 4268729, upload-time = "2025-12-15T21:53:58.712Z" },
-    { url = "https://files.pythonhosted.org/packages/82/a3/c91a087ab21f1047db328c1d3eb5d1ff0e52de9e74f9f6f6fa14cdd93d58/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b", size = 5286388, upload-time = "2025-12-15T21:54:00.687Z" },
-    { url = "https://files.pythonhosted.org/packages/17/b8/bfdc30b6e94f1eff09f2dc9cc1f9cd1c6cde3d996bcbd36ce2d9a4956e99/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f", size = 5327741, upload-time = "2025-12-15T21:54:02.148Z" },
-    { url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" },
+version = "1.8.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" },
+    { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" },
+    { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" },
+    { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" },
+    { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" },
+    { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" },
+    { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" },
 ]
 
 [[package]]
 name = "decorator"
-version = "5.2.1"
+version = "5.3.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
+    { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
+]
+
+[[package]]
+name = "deepmerge"
+version = "2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" },
 ]
 
 [[package]]
@@ -522,11 +483,11 @@ wheels = [
 
 [[package]]
 name = "distlib"
-version = "0.4.0"
+version = "0.4.3"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+    { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
 ]
 
 [[package]]
@@ -550,7 +511,7 @@ wheels = [
 
 [[package]]
 name = "erdantic"
-version = "1.2.0"
+version = "1.2.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pydantic" },
@@ -559,11 +520,10 @@ dependencies = [
     { name = "sortedcontainers-pydantic" },
     { name = "typenames" },
     { name = "typer" },
-    { name = "typing-extensions", marker = "python_full_version < '3.12'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/6f/17/5c9709349221b1a972b4470e735570eee3dd2e2ee0ed71f28fb0e8c0d79a/erdantic-1.2.0.tar.gz", hash = "sha256:165979caf26984055e476d0201b24cdf1c5f38b60a6f141e82f9352a40d12b65", size = 920914, upload-time = "2025-09-15T18:07:11.351Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/42/b3/d814074dc6076a97682dc8a62a3da672fd982a533cc1f29854b6a4433ef2/erdantic-1.2.1.tar.gz", hash = "sha256:9105fc51260ee58b531690d1ef87db81f849257e73e106cd8f2a7f8a26d8d9b0", size = 1152018, upload-time = "2026-02-15T23:56:00.294Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/06/ee/dc88399e2a872fd7ce5a192cfbcf8bd996574bb6c7470912c8c2fca20527/erdantic-1.2.0-py3-none-any.whl", hash = "sha256:7c67adcf22150b77a24609734c7efc676f71824f8f5662fd21450ec1cdbe0a0c", size = 34428, upload-time = "2025-09-15T18:07:09.766Z" },
+    { url = "https://files.pythonhosted.org/packages/84/39/c8982be2f72454ef135f247fd9c0fd800444d8e28767c9c6cb25e2df7c2e/erdantic-1.2.1-py3-none-any.whl", hash = "sha256:f548da3e9933e298f862eb33469ab375a372097ae289a4ca47a06ff69a1a6210", size = 34855, upload-time = "2026-02-15T23:55:58.536Z" },
 ]
 
 [[package]]
@@ -595,11 +555,11 @@ wheels = [
 
 [[package]]
 name = "filelock"
-version = "3.20.3"
+version = "3.29.4"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
+    { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" },
 ]
 
 [[package]]
@@ -629,81 +589,78 @@ wheels = [
 ]
 
 [[package]]
-name = "griffe"
-version = "1.15.0"
+name = "griffe-inherited-docstrings"
+version = "1.1.3"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "colorama" },
+    { name = "griffelib" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/da/fd002dc5f215cd896bfccaebe8b4aa1cdeed8ea1d9d60633685bd61ff933/griffe_inherited_docstrings-1.1.3.tar.gz", hash = "sha256:cd1f937ec9336a790e5425e7f9b92f5a5ab17f292ba86917f1c681c0704cb64e", size = 26738, upload-time = "2026-02-21T09:38:44.312Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" },
+    { url = "https://files.pythonhosted.org/packages/16/20/4bc15f242181daad1c104e0a7d33be49e712461ea89e548152be0365b9ea/griffe_inherited_docstrings-1.1.3-py3-none-any.whl", hash = "sha256:aa7f6e624515c50d9325a5cfdf4b2acac547f1889aca89092d5da7278f739695", size = 6710, upload-time = "2026-02-20T11:06:38.75Z" },
 ]
 
 [[package]]
-name = "griffe-inherited-docstrings"
-version = "1.1.2"
+name = "griffe-pydantic"
+version = "1.3.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "griffe" },
+    { name = "griffelib" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/28/02/36d9929bb8ad929941b27117aba4d850b8a9f2c12f982e2b59ab4bc4d80b/griffe_inherited_docstrings-1.1.2.tar.gz", hash = "sha256:0a489ac4bb6093a7789d014b23083b4cbb1ab139f0b8dd878c8f3a4f8e892624", size = 27541, upload-time = "2025-09-05T15:17:13.081Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/bd/d2eaeaf3f9910c9cd72793af0de18ee3d3a3a27bb30ab01cfd7659c08dc4/griffe_pydantic-1.3.1.tar.gz", hash = "sha256:f7caedfa0effedb22893bf01cc411fd567614f7b4de7ce0c1f4293eb7acb5c44", size = 38176, upload-time = "2026-02-21T09:38:49.674Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ad/12/4c67b644dc5965000874908dfa89d05ba878d5ca22a9b4ebfbfadc41467b/griffe_inherited_docstrings-1.1.2-py3-none-any.whl", hash = "sha256:b1cf61fff6e12a769db75de5718ddbbb5361b2cc4155af1f1ad86c13f56c197b", size = 6709, upload-time = "2025-09-05T15:17:11.853Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/88/17d656aa97406cd509e2be0e6eddcf81fc1666104bf81042b89721d4a44c/griffe_pydantic-1.3.1-py3-none-any.whl", hash = "sha256:475103794a1d5da3933d3968e82a2bd9d9a1fa507c06d5ee1a39ee1fadcca628", size = 15189, upload-time = "2026-02-20T11:34:14.965Z" },
 ]
 
 [[package]]
-name = "griffe-pydantic"
-version = "1.2.0"
+name = "griffe-warnings-deprecated"
+version = "1.1.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "griffe" },
+    { name = "griffelib" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/81/ef/045c7cfbd7c42dc99c5ae5da438f0ceeb3506937560f08c7511b375ee813/griffe_pydantic-1.2.0.tar.gz", hash = "sha256:e9a9b885daceaf164b3e25308217180f4f1fd99af4fa0a46882bfd8f66aae819", size = 34534, upload-time = "2026-01-13T14:11:35.857Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/9e/fc86f1e9270f143a395a601de81aa42a871722c34d4b3c7763658dc2e04d/griffe_warnings_deprecated-1.1.1.tar.gz", hash = "sha256:9261369bf2acb8b5d24a0dc7895cce788208513d4349031d4ea315b979b2e99f", size = 26262, upload-time = "2026-02-21T09:38:55.858Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/6d/af/109722a073c0abf02794e67429c0d834ca397e23f8bdd5d8322c3f3c9663/griffe_pydantic-1.2.0-py3-none-any.whl", hash = "sha256:9f287a883decbb76cccfdfbe72a659d8d97a46c95b008691c9c9cf9595068d4f", size = 13157, upload-time = "2026-01-13T14:11:34.295Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/3c/c2a9eee79bf2c8002d2fa370534bee93fdca39e8b1fc82e83d552d5d2c07/griffe_warnings_deprecated-1.1.1-py3-none-any.whl", hash = "sha256:4b7d765e82ca9139ed44ffe7bdebed0d3a46ce014ad5a35a2c22e9a16288737a", size = 6565, upload-time = "2026-02-20T15:35:23.577Z" },
 ]
 
 [[package]]
-name = "griffe-warnings-deprecated"
-version = "1.1.0"
+name = "griffelib"
+version = "2.0.2"
 source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "griffe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7e/0e/f034e1714eb2c694d6196c75f77a02f9c69d19f9961c4804a016397bf3e5/griffe_warnings_deprecated-1.1.0.tar.gz", hash = "sha256:7bf21de327d59c66c7ce08d0166aa4292ce0577ff113de5878f428d102b6f7c5", size = 33260, upload-time = "2024-12-10T21:02:18.395Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/43/4c/b7241f03ad1f22ec2eed33b0f90c4f8c949e3395c4b7488670b07225a20b/griffe_warnings_deprecated-1.1.0-py3-none-any.whl", hash = "sha256:e7b0e8bfd6e5add3945d4d9805b2a41c72409e456733965be276d55f01e8a7a2", size = 5854, upload-time = "2024-12-10T21:02:16.96Z" },
+    { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
 ]
 
 [[package]]
 name = "identify"
-version = "2.6.16"
+version = "2.6.19"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
+    { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
 ]
 
 [[package]]
 name = "idna"
-version = "3.11"
+version = "3.18"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
 ]
 
 [[package]]
 name = "importlib-metadata"
-version = "8.7.1"
+version = "9.0.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "zipp" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
+    { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
 ]
 
 [[package]]
@@ -729,7 +686,7 @@ wheels = [
 
 [[package]]
 name = "ipykernel"
-version = "6.31.0"
+version = "7.2.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "appnope", marker = "sys_platform == 'darwin'" },
@@ -746,14 +703,14 @@ dependencies = [
     { name = "tornado" },
     { name = "traitlets" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/a5/1d/d5ba6edbfe6fae4c3105bca3a9c889563cc752c7f2de45e333164c7f4846/ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6", size = 167493, upload-time = "2025-10-20T11:42:39.948Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af", size = 117003, upload-time = "2025-10-20T11:42:37.502Z" },
+    { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" },
 ]
 
 [[package]]
 name = "ipython"
-version = "9.9.0"
+version = "9.14.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -763,14 +720,14 @@ dependencies = [
     { name = "matplotlib-inline" },
     { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
     { name = "prompt-toolkit" },
+    { name = "psutil", marker = "sys_platform != 'emscripten'" },
     { name = "pygments" },
     { name = "stack-data" },
     { name = "traitlets" },
-    { name = "typing-extensions", marker = "python_full_version < '3.12'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/46/dd/fb08d22ec0c27e73c8bc8f71810709870d51cadaf27b7ddd3f011236c100/ipython-9.9.0.tar.gz", hash = "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", size = 4425043, upload-time = "2026-01-05T12:36:46.233Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/86/92/162cfaee4ccf370465c5af1ce36a9eacec1becb552f2033bb3584e6f640a/ipython-9.9.0-py3-none-any.whl", hash = "sha256:b457fe9165df2b84e8ec909a97abcf2ed88f565970efba16b1f7229c283d252b", size = 621431, upload-time = "2026-01-05T12:36:44.669Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/22/58818a63eaf8982b67632b1bc20585c811611b15a8da19d6012323dc76a5/ipython-9.14.1-py3-none-any.whl", hash = "sha256:5d4a9ecaa3b10e6e5f269dd0948bdb58ca9cb851899cd23e07c320d3eb11613c", size = 627770, upload-time = "2026-06-05T08:12:33.045Z" },
 ]
 
 [[package]]
@@ -815,11 +772,11 @@ wheels = [
 
 [[package]]
 name = "isort"
-version = "7.0.0"
+version = "8.0.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
 ]
 
 [[package]]
@@ -875,7 +832,7 @@ wheels = [
 
 [[package]]
 name = "jupyter-client"
-version = "8.8.0"
+version = "8.9.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "jupyter-core" },
@@ -883,10 +840,11 @@ dependencies = [
     { name = "pyzmq" },
     { name = "tornado" },
     { name = "traitlets" },
+    { name = "typing-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" },
 ]
 
 [[package]]
@@ -922,7 +880,7 @@ wheels = [
 
 [[package]]
 name = "jupytext"
-version = "1.19.0"
+version = "1.19.3"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "markdown-it-py" },
@@ -931,18 +889,19 @@ dependencies = [
     { name = "packaging" },
     { name = "pyyaml" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/2b/84/79a28abd8e6a9376fa623670ab8ac7ebcf45b10f2974e0121bb5e8e086a2/jupytext-1.19.0.tar.gz", hash = "sha256:724c1f75c850a12892ccbcdff33004ede33965d0da8520ab9ea74b39ff51283a", size = 4306554, upload-time = "2026-01-18T17:41:58.959Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/2d/15624c3d9440d85a280ff13d2d23afd989802f25470ac59932f4fef6f0c6/jupytext-1.19.3.tar.gz", hash = "sha256:713c3ed4441afe0f31474d28ea2e6b61a268c04c40fd78e5ccfd7f7ac9e9f766", size = 4305350, upload-time = "2026-05-17T09:09:29.294Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/fe/8c/e27aaea0a3fbea002f0e138902432e64f35b39d942cfa13bdc5dd63ce310/jupytext-1.19.0-py3-none-any.whl", hash = "sha256:6e82527920600883088c5825f5d4a5bd06a2676d4958d4f3bc622bad2439c0ac", size = 169904, upload-time = "2026-01-18T17:41:57.467Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-py3-none-any.whl", hash = "sha256:acf75492f80895ad8e664fd8db1708b617008dd0e71c341a1abc3d0d07310ed0", size = 170579, upload-time = "2026-05-17T09:09:27.478Z" },
 ]
 
 [[package]]
 name = "kfactory"
-version = "2.4.1"
+version = "3.0.0rc4"
 source = { editable = "." }
 dependencies = [
     { name = "aenum" },
     { name = "cachetools" },
+    { name = "kfnetlist" },
     { name = "klayout" },
     { name = "loguru" },
     { name = "pydantic" },
@@ -970,11 +929,10 @@ ci = [
     { name = "pytest-xdist" },
     { name = "types-cachetools" },
     { name = "types-requests" },
+    { name = "uv" },
 ]
 dev = [
-    { name = "mypy" },
     { name = "pre-commit" },
-    { name = "pylsp-mypy" },
     { name = "pytest" },
     { name = "pytest-cov" },
     { name = "pytest-datadir" },
@@ -992,6 +950,7 @@ dev = [
     { name = "types-pygments" },
     { name = "types-requests" },
     { name = "types-setuptools" },
+    { name = "uv" },
 ]
 docs = [
     { name = "erdantic" },
@@ -999,20 +958,18 @@ docs = [
     { name = "griffe-pydantic" },
     { name = "griffe-warnings-deprecated" },
     { name = "ipyevents" },
+    { name = "ipykernel" },
     { name = "ipython" },
     { name = "ipytree" },
     { name = "ipywidgets" },
-    { name = "markdown-exec" },
-    { name = "mkdocs" },
-    { name = "mkdocs-gen-files" },
-    { name = "mkdocs-jupyter" },
+    { name = "jupytext" },
     { name = "mkdocs-literate-nav" },
-    { name = "mkdocs-material" },
-    { name = "mkdocs-section-index" },
     { name = "mkdocs-video" },
     { name = "mkdocstrings", extra = ["python"] },
+    { name = "nbconvert" },
     { name = "pymdown-extensions" },
     { name = "ruff" },
+    { name = "zensical" },
 ]
 ipy = [
     { name = "ipyevents" },
@@ -1020,181 +977,137 @@ ipy = [
     { name = "ipytree" },
     { name = "ipywidgets" },
 ]
+notebooks = [
+    { name = "ipyevents" },
+    { name = "ipykernel" },
+    { name = "ipython" },
+    { name = "ipytree" },
+    { name = "ipywidgets" },
+    { name = "jupytext" },
+    { name = "nbconvert" },
+]
 
 [package.dev-dependencies]
 dev = [
-    { name = "ty" },
+    { name = "pytest" },
+    { name = "pytest-regressions" },
 ]
 
 [package.metadata]
 requires-dist = [
-    { name = "aenum", specifier = ">=3.1.16,<4" },
-    { name = "cachetools", specifier = ">=6.2.4" },
-    { name = "erdantic", marker = "extra == 'docs'", specifier = ">=1.1.1" },
-    { name = "griffe-inherited-docstrings", marker = "extra == 'docs'", specifier = ">=1.1.1" },
-    { name = "griffe-pydantic", marker = "extra == 'docs'", specifier = ">=1.1.4" },
-    { name = "griffe-warnings-deprecated", marker = "extra == 'docs'", specifier = ">=1.1.0" },
-    { name = "ipyevents", marker = "extra == 'ipy'", specifier = ">=2.0.2" },
-    { name = "ipython", marker = "extra == 'ipy'", specifier = ">=9.0.2" },
-    { name = "ipytree", marker = "extra == 'ipy'", specifier = ">=0.2.2" },
-    { name = "ipywidgets", marker = "extra == 'ipy'", specifier = ">=8.1.5" },
+    { name = "aenum", specifier = ">=3.1.17,<4" },
+    { name = "cachetools", specifier = ">=7.1.4,<7.2" },
+    { name = "erdantic", marker = "extra == 'docs'", specifier = ">=1.2.1,<1.3" },
+    { name = "griffe-inherited-docstrings", marker = "extra == 'docs'", specifier = ">=1.1.3,<1.2" },
+    { name = "griffe-pydantic", marker = "extra == 'docs'", specifier = ">=1.3.1,<1.4" },
+    { name = "griffe-warnings-deprecated", marker = "extra == 'docs'", specifier = ">=1.1.1,<1.2" },
+    { name = "ipyevents", marker = "extra == 'ipy'", specifier = ">=2.0.4,<2.1" },
+    { name = "ipykernel", marker = "extra == 'notebooks'", specifier = ">=7.2,<7.3" },
+    { name = "ipython", marker = "extra == 'ipy'", specifier = ">=9.14,<9.15" },
+    { name = "ipytree", marker = "extra == 'ipy'", specifier = ">=0.2.2,<0.3" },
+    { name = "ipywidgets", marker = "extra == 'ipy'", specifier = ">=8.1.8,<8.2" },
+    { name = "jupytext", marker = "extra == 'notebooks'", specifier = ">=1.19.3,<1.20" },
     { name = "kfactory", extras = ["ci"], marker = "extra == 'dev'" },
-    { name = "kfactory", extras = ["ipy"], marker = "extra == 'docs'" },
-    { name = "klayout", specifier = ">=0.30.5,<0.31.0" },
+    { name = "kfactory", extras = ["ipy"], marker = "extra == 'notebooks'" },
+    { name = "kfactory", extras = ["notebooks"], marker = "extra == 'docs'" },
+    { name = "kfnetlist", specifier = ">=0.2" },
+    { name = "klayout", specifier = ">=0.30.9,<0.31" },
     { name = "loguru", specifier = ">=0.7.3,<0.8" },
-    { name = "markdown-exec", marker = "extra == 'docs'", specifier = ">=1.10.3" },
-    { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.1" },
-    { name = "mkdocs-gen-files", marker = "extra == 'docs'", specifier = ">=0.5.0" },
-    { name = "mkdocs-jupyter", marker = "extra == 'docs'", specifier = ">=0.25.1" },
-    { name = "mkdocs-literate-nav", marker = "extra == 'docs'", specifier = ">=0.6.2" },
-    { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.6.9" },
-    { name = "mkdocs-section-index", marker = "extra == 'docs'", specifier = ">=0.3.9" },
-    { name = "mkdocs-video", marker = "extra == 'docs'", specifier = ">=1.5.0" },
-    { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.29.0" },
-    { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15.0" },
-    { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" },
-    { name = "pydantic", specifier = ">=2.12.5,<2.13" },
-    { name = "pydantic-extra-types", specifier = ">=2.11" },
-    { name = "pydantic-settings", specifier = ">=2.12,<3" },
-    { name = "pygit2", specifier = ">=1.19.1,<2" },
-    { name = "pylsp-mypy", marker = "extra == 'dev'", specifier = ">=0.7.0" },
-    { name = "pymdown-extensions", marker = "extra == 'docs'", specifier = ">=10.14.3" },
-    { name = "pytest", marker = "extra == 'ci'", specifier = ">=8.3.5" },
-    { name = "pytest-cov", marker = "extra == 'ci'", specifier = ">=6.0.0" },
-    { name = "pytest-datadir", marker = "extra == 'ci'", specifier = ">=1.8.0" },
-    { name = "pytest-randomly", marker = "extra == 'ci'", specifier = ">=3.16.0" },
-    { name = "pytest-regressions", marker = "extra == 'ci'", specifier = ">=2.7.0" },
-    { name = "pytest-regressions", marker = "extra == 'ci'", specifier = ">=2.8.3" },
-    { name = "pytest-xdist", marker = "extra == 'ci'", specifier = ">=3.6.1" },
-    { name = "python-lsp-server", extras = ["all"], marker = "extra == 'dev'", specifier = ">=1.13.1" },
-    { name = "rapidfuzz", specifier = ">=3.14.3,<4" },
-    { name = "rectangle-packer", specifier = ">=2.0.5,<3" },
-    { name = "requests", specifier = ">=2.32.5,<3" },
+    { name = "mkdocs-literate-nav", marker = "extra == 'docs'", specifier = ">=0.6.3,<0.7" },
+    { name = "mkdocs-video", marker = "extra == 'docs'", specifier = ">=1.5,<1.6" },
+    { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=1.0.4,<1.1" },
+    { name = "nbconvert", marker = "extra == 'notebooks'", specifier = ">=7.17.1,<7.18" },
+    { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.6,<4.7" },
+    { name = "pydantic", specifier = ">=2.13.4,<2.14" },
+    { name = "pydantic-extra-types", specifier = ">=2.11.1,<2.12" },
+    { name = "pydantic-settings", specifier = ">=2.14.1,<3" },
+    { name = "pygit2", specifier = ">=1.19.2,<2" },
+    { name = "pymdown-extensions", marker = "extra == 'docs'", specifier = ">=10.21.3,<10.22" },
+    { name = "pytest", marker = "extra == 'ci'", specifier = ">=9.0.3,<9.1" },
+    { name = "pytest-cov", marker = "extra == 'ci'", specifier = ">=7.1,<7.2" },
+    { name = "pytest-datadir", marker = "extra == 'ci'", specifier = ">=1.8,<1.9" },
+    { name = "pytest-randomly", marker = "extra == 'ci'", specifier = ">=4.1,<4.2" },
+    { name = "pytest-regressions", marker = "extra == 'ci'", specifier = ">=2.11,<2.12" },
+    { name = "pytest-xdist", marker = "extra == 'ci'", specifier = ">=3.8,<3.9" },
+    { name = "python-lsp-server", extras = ["all"], marker = "extra == 'dev'", specifier = ">=1.14,<1.15" },
+    { name = "rapidfuzz", specifier = ">=3.14.5,<4" },
+    { name = "rectangle-packer", specifier = ">=2.1,<3" },
+    { name = "requests", specifier = ">=2.34.2,<3" },
     { name = "ruamel-yaml", specifier = ">=0.19.1,<0.20" },
     { name = "ruamel-yaml-string", specifier = ">=0.1.1,<0.2" },
-    { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.2" },
-    { name = "ruff", marker = "extra == 'docs'", specifier = ">=0.9.2" },
-    { name = "rust-just", marker = "extra == 'dev'", specifier = ">=1.42.4" },
-    { name = "scipy", specifier = ">=1.17.0,<2" },
-    { name = "scipy-stubs", marker = "extra == 'dev'" },
+    { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.16,<0.16" },
+    { name = "ruff", marker = "extra == 'docs'", specifier = ">=0.15.16,<0.16" },
+    { name = "rust-just", marker = "extra == 'dev'", specifier = "~=1.51.0" },
+    { name = "scipy", specifier = ">=1.17.1,<2" },
+    { name = "scipy-stubs", marker = "extra == 'dev'", specifier = ">=1.17.1.5,<1.18" },
     { name = "semver", specifier = ">=3.0.4,<4" },
-    { name = "tbump", marker = "extra == 'dev'", specifier = ">=6.11.0" },
-    { name = "toolz", specifier = ">=1.1.0,<2" },
-    { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a17" },
-    { name = "typer", specifier = ">=0.21.1,<0.22" },
-    { name = "types-cachetools", marker = "extra == 'ci'", specifier = ">=5.5.0.20240820" },
-    { name = "types-cachetools", marker = "extra == 'dev'", specifier = ">=5.5.0.20240820" },
-    { name = "types-docutils", marker = "extra == 'dev'", specifier = ">=0.21.0.20241128" },
-    { name = "types-pygments", marker = "extra == 'dev'", specifier = ">=2.19.0.20250305" },
-    { name = "types-requests", marker = "extra == 'ci'", specifier = ">=2.32.0.20250328" },
-    { name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.32.0.20250328" },
-    { name = "types-setuptools", marker = "extra == 'dev'", specifier = ">=76.0.0.20250328" },
-]
-provides-extras = ["dev", "docs", "ci", "ipy"]
+    { name = "tbump", marker = "extra == 'dev'", specifier = ">=6.11,<6.12" },
+    { name = "toolz", specifier = ">=1.1,<2" },
+    { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.43,<0.1" },
+    { name = "typer", specifier = ">=0.26.7,<0.27" },
+    { name = "types-cachetools", marker = "extra == 'ci'", specifier = ">=7.0.0.20260518,<7.1" },
+    { name = "types-cachetools", marker = "extra == 'dev'", specifier = ">=7.0.0.20260518,<7.1" },
+    { name = "types-docutils", marker = "extra == 'dev'", specifier = ">=0.22.3.20260518,<0.23" },
+    { name = "types-pygments", marker = "extra == 'dev'", specifier = ">=2.20.0.20260518,<2.21" },
+    { name = "types-requests", marker = "extra == 'ci'", specifier = ">=2.33.0.20260518,<2.34" },
+    { name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.33.0.20260518,<2.34" },
+    { name = "types-setuptools", marker = "extra == 'dev'", specifier = ">=82.0.0.20260518,<82.1" },
+    { name = "uv", marker = "extra == 'ci'", specifier = ">=0.11.19,<0.12" },
+    { name = "zensical", marker = "extra == 'docs'", specifier = ">=0.0.44,<0.1" },
+]
+provides-extras = ["ci", "dev", "docs", "ipy", "notebooks"]
 
 [package.metadata.requires-dev]
-dev = [{ name = "ty", specifier = ">=0.0.1a17" }]
+dev = [
+    { name = "pytest", specifier = ">=9.0.3,<9.1" },
+    { name = "pytest-regressions", specifier = ">=2.11,<2.12" },
+]
 
 [[package]]
-name = "klayout"
-version = "0.30.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/05/a9/04d916676303309c7d3ca78f63f7a8641a2d9bd10ca7a1c075763ac02381/klayout-0.30.5.tar.gz", hash = "sha256:579e4eecd763c3760e85ef0e2ac1dcc05878413e7649cacfe75eb8c0d3f8e207", size = 4135426, upload-time = "2025-11-12T23:46:11.222Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/81/d5/50016574d348aca66f3b07594174bc82cac552732acf025767b16b37dd36/klayout-0.30.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:09c27eed9ca802e92c2dbc7699507ea57f21313118f4fc9ca811936d8aa2f043", size = 22467790, upload-time = "2025-11-12T23:44:35.675Z" },
-    { url = "https://files.pythonhosted.org/packages/65/2a/914064f4135d46605f6c2911647fec862f5a384ba5d829415df31d0cd9b1/klayout-0.30.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:961639210694254dd6d6b0371fa20bf292142e5cf651a202f5d3b20a200bcdde", size = 20531152, upload-time = "2025-11-12T23:44:37.956Z" },
-    { url = "https://files.pythonhosted.org/packages/a7/0d/9f8de368707e36907af921739e970fbc27f22c965d5150e71c9f472004e2/klayout-0.30.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48a753617a612959f60c7a74366095e230a3de8b9887d5fd30c45a2b97d87a69", size = 24857739, upload-time = "2025-11-12T23:44:40.755Z" },
-    { url = "https://files.pythonhosted.org/packages/73/0a/ad160223ed99dfd7018cdf130844692e02d9c31cb1ef9c72dc88ba02b270/klayout-0.30.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa3541fa4e63b4b6fbeadc07d9b2840b8aba2194ecb4208f5b0c82065e89500d", size = 26782754, upload-time = "2025-11-12T23:44:43.373Z" },
-    { url = "https://files.pythonhosted.org/packages/82/00/ee191fefab2cce8926673c4e5d80c512bd4dbb848bedaaea5ee2c3a0f3b3/klayout-0.30.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6c006eeae38ddee42fbb13ce766301ce7d07ea025ea2b4c2fc3b9fc8ede26d0", size = 28312297, upload-time = "2025-11-12T23:44:45.876Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/70/c8bc217615cc61b7c6ae0a91c15d1202cd9851ecda2baac0f7b5152e63de/klayout-0.30.5-cp311-cp311-win32.whl", hash = "sha256:1b133197f056d8073211024e541b63e17c49e4a3c4916a5ab75ccd74384d9e0d", size = 11981599, upload-time = "2025-11-13T02:47:01.528Z" },
-    { url = "https://files.pythonhosted.org/packages/b9/80/daa28eaf867282ede2e5f362c00fed82b98bf84a1a8dea703c16efdf6a13/klayout-0.30.5-cp311-cp311-win_amd64.whl", hash = "sha256:29ac703810382aafaef20529195bd9a9b1fc0420c3122ead3800b39e06b8b327", size = 13723791, upload-time = "2025-11-13T02:47:04.259Z" },
-    { url = "https://files.pythonhosted.org/packages/6e/f1/c6d9c1eb914c0bd654b181e39f57cde809b2d90f6cfad31b79aa3931211c/klayout-0.30.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ca7552609802e462989df7e2b4f3d0d244257a14e63aa01b223a88a66437618", size = 22046172, upload-time = "2025-11-12T23:44:48.298Z" },
-    { url = "https://files.pythonhosted.org/packages/8f/84/1c9294605223aaccaa73c119ae06f4b8172b028262c80334e7e9f390c34b/klayout-0.30.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:831e55ba1432ae96512adcf7a6e29a6f0239264e1350f0815bd4dcadbed7c73b", size = 20531429, upload-time = "2025-11-12T23:44:51.281Z" },
-    { url = "https://files.pythonhosted.org/packages/17/f2/1fceb496096f7f9b9939ce8f2e79b54a569c7fced9db4b93c392d4dfaa23/klayout-0.30.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7bd33bc4ffe2befe9277aee5aa98d59d06e9316021a2eef843525a42a4fa8cbb", size = 24874084, upload-time = "2025-11-12T23:44:53.918Z" },
-    { url = "https://files.pythonhosted.org/packages/55/17/ab3a667695cb4456fce45455724d24cddd86d7525001e1b218dbb54a1c01/klayout-0.30.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:395c803b1a48c50094afadc15094c65bb3b29f59ab8660810cfe7e456c50000d", size = 26802251, upload-time = "2025-11-12T23:44:57.393Z" },
-    { url = "https://files.pythonhosted.org/packages/fd/1e/d1ddf199d76a329ab4b0b324831246eeebbde72a58ebe9ccd4df20738c05/klayout-0.30.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0961760dd31afb3f234a71a869a7badf370cc9f0422ee5af7098a0148007b2d1", size = 28335526, upload-time = "2025-11-12T23:45:00.433Z" },
-    { url = "https://files.pythonhosted.org/packages/e6/6d/daa1e7d9d7726ad40ef1b17a027180ff797141614d2722ae07344670e939/klayout-0.30.5-cp312-cp312-win32.whl", hash = "sha256:8153c920978878d1938f615c758355d3aef2d2bece580c68c7111031fbd442e6", size = 11982053, upload-time = "2025-11-13T02:47:08.281Z" },
-    { url = "https://files.pythonhosted.org/packages/a6/4b/0841b123470d0869df92d24cc9df90c2df496aaf9f7c1332628159b97d84/klayout-0.30.5-cp312-cp312-win_amd64.whl", hash = "sha256:a07884870a4190042dafd5d414e6146f4f7c0fe54ec3082a3c113f77efc8d274", size = 13723476, upload-time = "2025-11-13T02:47:10.355Z" },
-    { url = "https://files.pythonhosted.org/packages/66/44/96fc689fcba5506aa840286866185ce5d6bb43df9dc33350128e2d7fbd93/klayout-0.30.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f73fd5575e55768c2dcbe7a4370dc86e138c6c6321a2f7edba5694889c822696", size = 22046091, upload-time = "2025-11-12T23:45:03.534Z" },
-    { url = "https://files.pythonhosted.org/packages/65/7b/b96ffac84012ee7adcce171ae7632cb9228e0274e4e28c9b2587a0a7a23a/klayout-0.30.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f694e6c86f1445e58015c4635b5847369c38be1825673dfb941e56e0d1d3b67", size = 20531338, upload-time = "2025-11-12T23:45:05.899Z" },
-    { url = "https://files.pythonhosted.org/packages/00/50/899ead3f20520df6ace7738070ce738b4bc9a5d52cafa0400c0a01c10226/klayout-0.30.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24ee7724f3871c1c469cd8994337b42cd784445d4414482aaf08809523f237ad", size = 24874077, upload-time = "2025-11-12T23:45:08.751Z" },
-    { url = "https://files.pythonhosted.org/packages/d3/0a/df726d14586987968c00c6af10a6694b7fa6b652e316218cff3f8e10607a/klayout-0.30.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd601badf644ea1d7e93bb7ea43dce0231661c082c7dd25064489696a282ee63", size = 26802265, upload-time = "2025-11-12T23:45:11.317Z" },
-    { url = "https://files.pythonhosted.org/packages/c2/38/3810fac2274ccee453b8b39cb4953e692219181c4021291f9d89652e7b8a/klayout-0.30.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e57fa2b039cc1714be1a4ae2252f6b7c80572192db8b2c906aefe6df26316560", size = 28335531, upload-time = "2025-11-12T23:45:14.281Z" },
-    { url = "https://files.pythonhosted.org/packages/b3/85/a5a8876a98028b2748231723f03551a0f6ea60bbb0a210ee059ce852e2f6/klayout-0.30.5-cp313-cp313-win32.whl", hash = "sha256:89fde645dbced23ceeb54cbf895e109da364fb406ecc647f53b7ab3990d6b295", size = 11982054, upload-time = "2025-11-13T02:47:12.241Z" },
-    { url = "https://files.pythonhosted.org/packages/83/6d/ee53d0b6ead3bfb998659301eaacf827265dbbeceac75c65dfb9d2d2e0fd/klayout-0.30.5-cp313-cp313-win_amd64.whl", hash = "sha256:6cace034e42f13dfddf67c4e747f6e839f415073d7faa30e0eec9f6b4c369bf2", size = 13723626, upload-time = "2025-11-13T02:47:15.899Z" },
-    { url = "https://files.pythonhosted.org/packages/c7/fc/5886c49448762e0358a885612b76885c24f7f9f738a50df24bddeb73e684/klayout-0.30.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3e3eb67f6937b1a7eaea3b37c6ff21803b95e25843bddf8faba71bdfcc99d148", size = 22052273, upload-time = "2025-11-12T23:45:17.362Z" },
-    { url = "https://files.pythonhosted.org/packages/55/74/77a7a7aef466838bafc7a9893c09e948c494a9a2ec6b5b106ccfb2f51330/klayout-0.30.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2ce1f3e46684806a331dafa62a9f484a7287d932732b4c44ee03c5cb39cb740b", size = 20531622, upload-time = "2025-11-12T23:45:19.727Z" },
-    { url = "https://files.pythonhosted.org/packages/7d/79/5e5f6467a67ec8cc04b8b6c25e9e216ab7193b477500ed96dba7ca2db0d7/klayout-0.30.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94e3096d23770aa17f968be3d6dd9453c0706e0f77cc53f7e6e71b62c0cdff03", size = 24874300, upload-time = "2025-11-12T23:45:22.166Z" },
-    { url = "https://files.pythonhosted.org/packages/d9/c0/8e72224b1b7dd8241594960ca44aedc2b7d8273e1fda8502cf480eb0568e/klayout-0.30.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c157cac232f6a5d87f3655dc8a9ca976ba7868d8b68db931402fa51f717eae", size = 26802270, upload-time = "2025-11-12T23:45:25.159Z" },
-    { url = "https://files.pythonhosted.org/packages/46/f5/a91c6bf7acb8a7cafeaef363986fdaacf93e9aea5b7fa9e0e2a3b9a600ac/klayout-0.30.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be732154031bdca5fe3c6e309d91cc918a7d78540c8dd93a8a9dc51a227b1c4", size = 28335645, upload-time = "2025-11-12T23:45:27.743Z" },
-    { url = "https://files.pythonhosted.org/packages/85/88/fb712a414203da4ef3c075752f7f16952709b851822f08d11cb918fcca6d/klayout-0.30.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:af71fd2d29bf9a0dd3223d197403f3cbe3678d13aa53d020fdbd54c301b3e3a8", size = 22053175, upload-time = "2025-11-12T23:45:30.726Z" },
-    { url = "https://files.pythonhosted.org/packages/d9/85/57adc797e55f9ef8267af229fa9354ccd7f86e5b616a1ee382e4949d7239/klayout-0.30.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd190fa927e0e85c3778e22b2c1ad23f845c9a695a4aa5b4d067e6ecfc4c1623", size = 20532655, upload-time = "2025-11-12T23:45:33.114Z" },
-    { url = "https://files.pythonhosted.org/packages/e4/35/b47575102f7da1b918ed953bcf14e19acb3c00262b225f70362818340e76/klayout-0.30.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe5ec9d7295c148926ddb1a2d42238db561de8d05de8677df9fd90c22f8a10c", size = 24875229, upload-time = "2025-11-12T23:45:35.541Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/82/3a4f94841fad46ac679a8ea60972a45ad8d5036ee14c6d3ad367a8e0745d/klayout-0.30.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15a13cfa09096fa633ace9b49eb33f5318e60d7137aa7b05c8b60723daf1373d", size = 26803116, upload-time = "2025-11-12T23:45:37.954Z" },
-    { url = "https://files.pythonhosted.org/packages/7f/ec/be973badafd191275585d7c57745e42d00254873022b15e9e3ff1e366ad3/klayout-0.30.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fb143396bed678e86ea613604809fa719f7ac1696742927e84fd4ccfc1c37f3c", size = 28336680, upload-time = "2025-11-12T23:45:40.972Z" },
-]
-
-[[package]]
-name = "librt"
-version = "0.7.8"
+name = "kfnetlist"
+version = "0.2.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" },
-    { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" },
-    { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" },
-    { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" },
-    { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" },
-    { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" },
-    { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" },
-    { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" },
-    { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" },
-    { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" },
-    { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" },
-    { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" },
-    { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" },
-    { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" },
-    { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" },
-    { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" },
-    { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" },
-    { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" },
-    { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" },
-    { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" },
-    { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" },
-    { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" },
-    { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" },
-    { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" },
-    { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" },
-    { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" },
-    { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" },
-    { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" },
-    { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" },
-    { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" },
-    { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" },
-    { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" },
-    { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" },
-    { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" },
-    { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" },
-    { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" },
-    { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" },
-    { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" },
-    { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" },
-    { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" },
-    { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" },
-    { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" },
-    { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" },
-    { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" },
-    { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" },
-    { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" },
-    { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" },
-    { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" },
-    { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" },
-    { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" },
-    { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" },
-    { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" },
-    { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/90/a4/1a5a15cd14e1689b41ba50f288d2209b98df5715f5f4ddea5394c1ff7509/kfnetlist-0.2.0.tar.gz", hash = "sha256:6b142ee99661338ab055ab26d0cb49fea0128b6dc27786991cecce24c8c98848", size = 111370, upload-time = "2026-06-04T23:29:54.716Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6e/c0/6f99e9600e5486b59b69d0553e60c953b7d1af272cf5f2246ce6b65390a2/kfnetlist-0.2.0-cp312-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7f34d6d9de68394e4172955371ab296dd7b7494f611ea9dc90648468dd8f2fb4", size = 513652, upload-time = "2026-06-04T23:29:47.035Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/d2/d3507424c38dc3c78a9ed9c8dfd64a272f0e6248d34a9ac08188ab0048a9/kfnetlist-0.2.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:4cc9fa05a991fcfdf1f1771c30e78b4aac84b02a36c66c9a35b6987fb1e2efa1", size = 488611, upload-time = "2026-06-04T23:29:48.77Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/ca/a91b48bef13a1f9b15b3cc66468423b29d600780cbbaa89071b868c52781/kfnetlist-0.2.0-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b961052df68fb8e3979eadabde6fb299d49080216aee48214bceb95f9f022208", size = 518412, upload-time = "2026-06-04T23:29:50.248Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/81/a2ab8155dd2f159e65d642a1d64dd5f0edb7dc90d0ef94da6232fba164c3/kfnetlist-0.2.0-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5958a57166b408408ab525a11e7a00fb5a91eda29dadb4de2da15f65ba079f0c", size = 534357, upload-time = "2026-06-04T23:29:51.772Z" },
+    { url = "https://files.pythonhosted.org/packages/48/ee/aaede84ae950476410af44df737f7b7feed95012a8f402b69ea68a10be72/kfnetlist-0.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:a4a501e2d7ae9b834aedd669d54be585739df03ec1160096dc4c9e6070df6c9b", size = 405844, upload-time = "2026-06-04T23:29:53.36Z" },
+]
+
+[[package]]
+name = "klayout"
+version = "0.30.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/2f/3a789f9a74b731e23bb3a61ea4120d146c911e829a512e73fe500c779d5a/klayout-0.30.9.tar.gz", hash = "sha256:6dd556bc19e19627b5138e9c6c742244e3b56768d6cbdfa0c5988c86f8787c0e", size = 4481894, upload-time = "2026-05-29T17:43:54.234Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a4/28/ef082e28d6822b89e690152bf2860b09479c6ec39c538ece5f31282c92e8/klayout-0.30.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:178224557a416d3364d8cf5289e6dce471f125e46b3184cb9d23be29457b72be", size = 21141644, upload-time = "2026-05-29T17:43:06.954Z" },
+    { url = "https://files.pythonhosted.org/packages/71/b9/2f68b979834ad92fe7517d7ad937b8b820ef291dd1d2936f3862137ceabd/klayout-0.30.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:635ebdd2a428c0571b8c5e1b67a49cc2364d22587a84a3d95782d8b6f5f01723", size = 21280827, upload-time = "2026-05-29T17:43:09.12Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/cc/15eec10804e0e9e1ce85626db34fbd331c1dbd23bbea63be9246380e0e58/klayout-0.30.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:323747458174a1b17115cca91cbcf0e3ec435283ed19fe9b4a63aa344d0a184c", size = 25290725, upload-time = "2026-05-29T17:43:11.211Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/99/23462f0f6834048f32a815b1cdc1033f20c359b28cf56c8fc9cf13a771aa/klayout-0.30.9-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6209c7068f1521349de0f0974902e41807d5cfe4e9417ec68c74925e3f30c21", size = 27277862, upload-time = "2026-05-29T17:43:13.734Z" },
+    { url = "https://files.pythonhosted.org/packages/00/fd/203ea33b3a0b4dc10832104f21feb7a814e8e5bf3fec1b305d6820dbda74/klayout-0.30.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b49386d24431e4c553d8abb9a1c174476946d990326b1cf5f44e08a0bfa9f5d9", size = 28840850, upload-time = "2026-05-29T17:43:16.15Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/96/25872ad06299a3ad4ab28345b92090af1a3c334ebfa3cf7c383555fa7271/klayout-0.30.9-cp312-cp312-win32.whl", hash = "sha256:a6731fe01816adbd60868f5f30729caaa9a2161bc11de49e4966c8b909038b2d", size = 12255871, upload-time = "2026-05-29T17:07:35.299Z" },
+    { url = "https://files.pythonhosted.org/packages/43/63/59f587c4b49f72eb8c6a33b6b52beb1366e83dd386b1a3c16a8ce9cd74ca/klayout-0.30.9-cp312-cp312-win_amd64.whl", hash = "sha256:6868d25ea27561b83ee560611bcc4369818c9d7ab3fd14a7def6708ddbe49715", size = 13828584, upload-time = "2026-05-29T17:07:37.375Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/e1/ca7530aa09a450f59bce94aa19f5c418fe71c61c530aa197d4c542af02a2/klayout-0.30.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:99351fd36bf3e17439539fa7ce4c34bfbcdea41b31710a77d47b13c5d6a64744", size = 21141659, upload-time = "2026-05-29T17:43:18.661Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/fb/0c6ae7549c80f30b406eaca108b909e28abd3834a3ac2a2bd767cb266529/klayout-0.30.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:baa9d13c00f315a5b93a5e237633a08720770c2058dc7fa22232f98b2b769e1a", size = 21280758, upload-time = "2026-05-29T17:43:21.117Z" },
+    { url = "https://files.pythonhosted.org/packages/52/88/07707418256002c801866bbcdee4e0df52af0c35c9bfa90e64a938e5eebd/klayout-0.30.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dcfbf3bb3783bd086653fd2810babf336433200d029a875165d491c3ba3d9e4", size = 25290750, upload-time = "2026-05-29T17:43:23.571Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/79/0a33fcaec854f19b5ee85fd02cda412fd2d1b2315b967aa898c59675447f/klayout-0.30.9-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85bcb847edf297a6732fc1c456b0d851002217ea2b83915ffcfea42172eb288b", size = 27277892, upload-time = "2026-05-29T17:43:25.821Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/72/ccaf1cee4138bf27acee7a4502ad0f5405994f49325ac6f63745e558a157/klayout-0.30.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45623009a739a7b9f467d1a9324ec1825ec0e76c721d8385a2a369b683ed4d43", size = 28840859, upload-time = "2026-05-29T17:43:28.451Z" },
+    { url = "https://files.pythonhosted.org/packages/34/7b/e02c4cab8fe0e9a6660d780d645e56919fcddb7f3393ec9db555917e9b9b/klayout-0.30.9-cp313-cp313-win32.whl", hash = "sha256:d94a5fd915a4163368d9c9e8d46ccdf923213b5f7ac11d700be4ae14ea7b612b", size = 12255855, upload-time = "2026-05-29T17:07:39.401Z" },
+    { url = "https://files.pythonhosted.org/packages/31/2c/c641abc57993bef7c7d3e866ff3b073eac6bda1c0bb9f09c793049c42e0a/klayout-0.30.9-cp313-cp313-win_amd64.whl", hash = "sha256:8e397364edfd438f1bc4928917f836e1830c8e8e75a11d5e655430d3ff854294", size = 13828597, upload-time = "2026-05-29T17:07:41.473Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/99/a7411fc8fed3e6c36ec15217f227c6bf54515497c192ac07ddd69701e97d/klayout-0.30.9-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1167056513fa924ceea8e8b55f745e12739404a4d3b59c4c5771d460f8157b58", size = 21144480, upload-time = "2026-05-29T17:43:31.229Z" },
+    { url = "https://files.pythonhosted.org/packages/66/2a/f4162069bbfc972c2f9b1bf6ef0272cdfa3bfb2a828874d050ecaf93af66/klayout-0.30.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:135b50e37f0113de7a8c9a4b9602bc6b6268cc533a0c5be263cfaf73e65c0f1d", size = 21281121, upload-time = "2026-05-29T17:43:33.651Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/cb/7e5dec00a9d44135eabb4bd4c7ddb481a694e60a3d350c97f08668bcb13f/klayout-0.30.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0599b33db47d4a777f01fe754f5341836f6073312518e519922898029c999bbe", size = 25291064, upload-time = "2026-05-29T17:43:35.844Z" },
+    { url = "https://files.pythonhosted.org/packages/53/1e/39a6f06b571d05d555b4e6381b4428ebece2708200b7cbb6fb63118f6bed/klayout-0.30.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb805fd1636492199fcd7c2b41241cec4cc8d1d7722e65f519c03728305d4944", size = 27277942, upload-time = "2026-05-29T17:43:38.091Z" },
+    { url = "https://files.pythonhosted.org/packages/14/b3/e3d2fcd0393e2b8a158a2b45e68b477276835629345d8dde8ad676f07b9e/klayout-0.30.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:add087dc5b6674e384746cdedfb507a1b927c68b138788ebfd509b7fa7472e91", size = 28841009, upload-time = "2026-05-29T17:43:40.515Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/ab/a4544c157b8991f3989e9bb7b56b905baabb095a9728c54508996e41370d/klayout-0.30.9-cp314-cp314-win32.whl", hash = "sha256:50af6550e8c39608c708446686cf9179597d55f831aed961a89b742e40dfb123", size = 12547663, upload-time = "2026-05-29T17:07:43.625Z" },
+    { url = "https://files.pythonhosted.org/packages/86/45/2161f26927968dfd6669ad7244ca39fb047c6e62a7f0e9f7298a8137e039/klayout-0.30.9-cp314-cp314-win_amd64.whl", hash = "sha256:f090ce31d7b9d8385fbb3428ea2089a8d568087a7e8d70754ea3d436b26e3dcb", size = 14198175, upload-time = "2026-05-29T17:07:45.58Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/b3/752b9c647d8b136d0892fb3f352243a472f3d189f159c5aec51460ba40db/klayout-0.30.9-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3e8407f90f5d13c3dc799c534185619717795384f8cdbedea5655b4f675e2cea", size = 21145028, upload-time = "2026-05-29T17:43:42.795Z" },
+    { url = "https://files.pythonhosted.org/packages/70/46/8c357b35519a09cb02d92f3b71d44d9d1eea0f4389d1bc79bbff139b14ef/klayout-0.30.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:07055a032469e72945800178ca8dae94083fd8b4395aac9ebd1dad7aee55b160", size = 21282087, upload-time = "2026-05-29T17:43:45.259Z" },
+    { url = "https://files.pythonhosted.org/packages/98/50/4610f474d09028323f27d2c763bdb27edfea23d9b63d13c3fc684141a12a/klayout-0.30.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25724ff47338dee5a2e06f1549a52e6808068bfb6ed67bbedd8c7c792f57194b", size = 25291885, upload-time = "2026-05-29T17:43:47.475Z" },
+    { url = "https://files.pythonhosted.org/packages/24/36/aa6701ac3807fcde7e6229e7988719518d8664306a17e56701f2bfc970d9/klayout-0.30.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fb53c9018cdd133a72689095aa1c62b783f722a20c8a25bf3d82a25cd18cda4", size = 27278773, upload-time = "2026-05-29T17:43:49.795Z" },
+    { url = "https://files.pythonhosted.org/packages/26/51/5f5a61df8553f431c4c1108b872a4d2dc10ce32d7f2561ade397a0bf86a1/klayout-0.30.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e2a202ddf302c30bef4272566b4af8c2275054ff5a1e928d538baca1b353af1", size = 28841959, upload-time = "2026-05-29T17:43:52.014Z" },
 ]
 
 [[package]]
@@ -1212,137 +1125,103 @@ wheels = [
 
 [[package]]
 name = "lxml"
-version = "6.0.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" },
-    { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" },
-    { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" },
-    { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" },
-    { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" },
-    { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" },
-    { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" },
-    { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" },
-    { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" },
-    { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" },
-    { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" },
-    { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" },
-    { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" },
-    { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" },
-    { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
-    { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
-    { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
-    { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
-    { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
-    { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
-    { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
-    { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
-    { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
-    { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
-    { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
-    { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
-    { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
-    { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
-    { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
-    { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
-    { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
-    { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
-    { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
-    { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
-    { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
-    { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
-    { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
-    { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
-    { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
-    { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
-    { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
-    { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
-    { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
-    { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
-    { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
-    { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
-    { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
-    { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
-    { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
-    { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
-    { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
-    { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
-    { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
-    { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
-    { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
-    { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
-    { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
-    { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
-    { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
-    { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
-    { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
-    { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
-    { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
-    { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
-    { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
-    { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
-    { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
-    { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
-    { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
-    { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
-    { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
-    { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
-    { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
-    { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
-    { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
-    { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
-    { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
-    { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
-    { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
-    { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
-    { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
-    { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
-    { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" },
-    { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" },
-    { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" },
-    { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" },
-    { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
+version = "6.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
+    { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
+    { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
+    { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
+    { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
+    { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
+    { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
+    { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
+    { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
+    { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
+    { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
+    { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
+    { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
+    { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
+    { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
+    { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
+    { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
+    { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
+    { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
+    { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
+    { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
+    { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
+    { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
+    { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
+    { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
+    { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
+    { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
+    { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
+    { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
 ]
 
 [[package]]
 name = "markdown"
-version = "3.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" },
-]
-
-[[package]]
-name = "markdown-exec"
-version = "1.12.1"
+version = "3.10.2"
 source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "pymdown-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/96/73/1f20927d075c83c0e2bc814d3b8f9bd254d919069f78c5423224b4407944/markdown_exec-1.12.1.tar.gz", hash = "sha256:eee8ba0df99a5400092eeda80212ba3968f3cbbf3a33f86f1cd25161538e6534", size = 78105, upload-time = "2025-11-11T19:25:05.44Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ea/22/7b684ddb01b423b79eaba9726954bbe559540d510abc7a72a84d8eee1b26/markdown_exec-1.12.1-py3-none-any.whl", hash = "sha256:a645dce411fee297f5b4a4169c245ec51e20061d5b71e225bef006e87f3e465f", size = 38046, upload-time = "2025-11-11T19:25:03.878Z" },
+    { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
 ]
 
 [[package]]
 name = "markdown-it-py"
-version = "4.0.0"
+version = "4.2.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "mdurl" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
 ]
 
 [[package]]
@@ -1351,17 +1230,6 @@ version = "3.0.3"
 source = { registry = "https://pypi.org/simple" }
 sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
-    { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
-    { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
-    { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
-    { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
-    { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
-    { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
-    { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
-    { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
-    { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
-    { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
     { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
     { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
     { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
@@ -1421,14 +1289,14 @@ wheels = [
 
 [[package]]
 name = "matplotlib-inline"
-version = "0.2.1"
+version = "0.2.2"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "traitlets" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" },
+    { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
 ]
 
 [[package]]
@@ -1442,14 +1310,14 @@ wheels = [
 
 [[package]]
 name = "mdit-py-plugins"
-version = "0.5.0"
+version = "0.6.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "markdown-it-py" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
 ]
 
 [[package]]
@@ -1472,11 +1340,11 @@ wheels = [
 
 [[package]]
 name = "mistune"
-version = "3.2.0"
+version = "3.2.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" },
 ]
 
 [[package]]
@@ -1505,114 +1373,43 @@ wheels = [
 
 [[package]]
 name = "mkdocs-autorefs"
-version = "1.4.3"
+version = "1.4.4"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "markdown" },
     { name = "markupsafe" },
     { name = "mkdocs" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" },
-]
-
-[[package]]
-name = "mkdocs-gen-files"
-version = "0.6.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "mkdocs" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/61/35/f26349f7fa18414eb2e25d75a6fa9c7e3186c36e1d227c0b2d785a7bd5c4/mkdocs_gen_files-0.6.0.tar.gz", hash = "sha256:52022dc14dcc0451e05e54a8f5d5e7760351b6701eff816d1e9739577ec5635e", size = 8642, upload-time = "2025-11-23T12:13:22.124Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/8d/ec/72417415563c60ae01b36f0d497f1f4c803972f447ef4fb7f7746d6e07db/mkdocs_gen_files-0.6.0-py3-none-any.whl", hash = "sha256:815af15f3e2dbfda379629c1b95c02c8e6f232edf2a901186ea3b204ab1135b2", size = 8182, upload-time = "2025-11-23T12:13:20.756Z" },
+    { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" },
 ]
 
 [[package]]
 name = "mkdocs-get-deps"
-version = "0.2.0"
+version = "0.2.2"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "mergedeep" },
     { name = "platformdirs" },
     { name = "pyyaml" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" },
-]
-
-[[package]]
-name = "mkdocs-jupyter"
-version = "0.25.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "ipykernel" },
-    { name = "jupytext" },
-    { name = "mkdocs" },
-    { name = "mkdocs-material" },
-    { name = "nbconvert" },
-    { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/6c/23/6ffb8d2fd2117aa860a04c6fe2510b21bc3c3c085907ffdd851caba53152/mkdocs_jupyter-0.25.1.tar.gz", hash = "sha256:0e9272ff4947e0ec683c92423a4bfb42a26477c103ab1a6ab8277e2dcc8f7afe", size = 1626747, upload-time = "2024-10-15T14:56:32.373Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/08/37/5f1fd5c3f6954b3256f8126275e62af493b96fb6aef6c0dbc4ee326032ad/mkdocs_jupyter-0.25.1-py3-none-any.whl", hash = "sha256:3f679a857609885d322880e72533ef5255561bbfdb13cfee2a1e92ef4d4ad8d8", size = 1456197, upload-time = "2024-10-15T14:56:29.854Z" },
+    { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" },
 ]
 
 [[package]]
 name = "mkdocs-literate-nav"
-version = "0.6.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "mkdocs" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" },
-]
-
-[[package]]
-name = "mkdocs-material"
-version = "9.7.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "babel" },
-    { name = "backrefs" },
-    { name = "colorama" },
-    { name = "jinja2" },
-    { name = "markdown" },
-    { name = "mkdocs" },
-    { name = "mkdocs-material-extensions" },
-    { name = "paginate" },
-    { name = "pygments" },
-    { name = "pymdown-extensions" },
-    { name = "requests" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" },
-]
-
-[[package]]
-name = "mkdocs-material-extensions"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
-]
-
-[[package]]
-name = "mkdocs-section-index"
-version = "0.3.10"
+version = "0.6.3"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "mkdocs" },
+    { name = "properdocs" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/93/40/4aa9d3cfa2ac6528b91048847a35f005b97ec293204c02b179762a85b7f2/mkdocs_section_index-0.3.10.tar.gz", hash = "sha256:a82afbda633c82c5568f0e3b008176b9b365bf4bd8b6f919d6eff09ee146b9f8", size = 14446, upload-time = "2025-04-05T20:56:45.387Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/af/dd3776a7a713f798f79bec7eb9c661d5cfb83ddc17d9a3667595e53e1559/mkdocs_literate_nav-0.6.3.tar.gz", hash = "sha256:edbaca22343f861fe4e34aac47d55a0c9955c640dbf02eea99fe631e914cf9ee", size = 17526, upload-time = "2026-03-16T23:26:50.688Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/01/53/76c109e6f822a6d19befb0450c87330b9a6ce52353de6a9dda7892060a1f/mkdocs_section_index-0.3.10-py3-none-any.whl", hash = "sha256:bc27c0d0dc497c0ebaee1fc72839362aed77be7318b5ec0c30628f65918e4776", size = 8796, upload-time = "2025-04-05T20:56:43.975Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/2c/bcf1ae903975ad6f169abb05c1eb0f94395478364deb89270cf034081b29/mkdocs_literate_nav-0.6.3-py3-none-any.whl", hash = "sha256:2c421561280fa9184f88cbf399bebbd4cc17ee507e978a31ce11fd6f3aabf233", size = 13355, upload-time = "2026-03-16T23:26:49.562Z" },
 ]
 
 [[package]]
@@ -1630,7 +1427,7 @@ wheels = [
 
 [[package]]
 name = "mkdocstrings"
-version = "1.0.1"
+version = "1.0.4"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "jinja2" },
@@ -1640,9 +1437,9 @@ dependencies = [
     { name = "mkdocs-autorefs" },
     { name = "pymdown-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/bd/ec/680e3bc7c88704d3fb9c658a517ec10f2f2aed3b9340136978675e581688/mkdocstrings-1.0.1.tar.gz", hash = "sha256:caa7d311c85ac0a0674831725ecfdeee4348e3b8a2c91ab193ee319a41dbeb3d", size = 100794, upload-time = "2026-01-19T11:36:24.429Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/d0/f9/ecd3e5cf258d63eddc13e354bd090df3aa458b64be50d737d52a8ad9df22/mkdocstrings-1.0.1-py3-none-any.whl", hash = "sha256:10deb908e310e6d427a5b8f69026361dac06b77de860f46043043e26f121db02", size = 35245, upload-time = "2026-01-19T11:36:23.067Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" },
 ]
 
 [package.optional-dependencies]
@@ -1652,55 +1449,16 @@ python = [
 
 [[package]]
 name = "mkdocstrings-python"
-version = "2.0.1"
+version = "2.0.4"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "griffe" },
+    { name = "griffelib" },
     { name = "mkdocs-autorefs" },
     { name = "mkdocstrings" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a4/b4/5fed370d8ebd96e4e399460a7146ae989263f16588b05a6facd6dbd51e60/mkdocstrings_python-2.0.4.tar.gz", hash = "sha256:58c73c5d358e64e9b1673447663f4a2f8a8941e392e225fc0a0c893758cc452f", size = 199219, upload-time = "2026-06-05T08:13:01.819Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" },
-]
-
-[[package]]
-name = "mypy"
-version = "1.19.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
-    { name = "mypy-extensions" },
-    { name = "pathspec" },
-    { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" },
-    { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" },
-    { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" },
-    { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" },
-    { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" },
-    { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
-    { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
-    { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
-    { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
-    { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
-    { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
-    { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
-    { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
-    { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
-    { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
-    { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
-    { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
-    { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
-    { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
-    { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
-    { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
-    { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
-    { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
-    { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/e3/00ec594aef5f55522e6d373bc2ac53e53a8f5e9ae32f2d6854b0de4270f3/mkdocstrings_python-2.0.4-py3-none-any.whl", hash = "sha256:fd87c173e1e719a85997b6d4f852cdc55f36710e0ed08da3a7bd9abe79c9db00", size = 104790, upload-time = "2026-06-05T08:13:00.393Z" },
 ]
 
 [[package]]
@@ -1714,7 +1472,7 @@ wheels = [
 
 [[package]]
 name = "nbclient"
-version = "0.10.4"
+version = "0.11.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "jupyter-client" },
@@ -1722,14 +1480,14 @@ dependencies = [
     { name = "nbformat" },
     { name = "traitlets" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" },
+    { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" },
 ]
 
 [[package]]
 name = "nbconvert"
-version = "7.17.0"
+version = "7.17.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "beautifulsoup4" },
@@ -1747,9 +1505,9 @@ dependencies = [
     { name = "pygments" },
     { name = "traitlets" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" },
+    { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" },
 ]
 
 [[package]]
@@ -1787,81 +1545,63 @@ wheels = [
 
 [[package]]
 name = "numpy"
-version = "2.4.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" },
-    { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" },
-    { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" },
-    { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" },
-    { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" },
-    { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" },
-    { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" },
-    { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" },
-    { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" },
-    { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" },
-    { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" },
-    { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" },
-    { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" },
-    { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" },
-    { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" },
-    { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" },
-    { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" },
-    { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" },
-    { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" },
-    { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" },
-    { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" },
-    { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" },
-    { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" },
-    { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" },
-    { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" },
-    { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" },
-    { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" },
-    { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" },
-    { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" },
-    { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" },
-    { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" },
-    { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" },
-    { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" },
-    { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" },
-    { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" },
-    { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" },
-    { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" },
-    { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" },
-    { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" },
-    { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" },
-    { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" },
-    { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" },
-    { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" },
-    { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" },
-    { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" },
-    { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" },
-    { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" },
-    { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" },
-    { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" },
-    { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" },
-    { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" },
-    { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" },
-    { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" },
-    { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" },
-    { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" },
-    { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" },
-    { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" },
-    { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" },
-    { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" },
-    { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" },
-    { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" },
-    { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" },
-    { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" },
-    { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" },
-    { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" },
+version = "2.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
+    { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
+    { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
+    { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
+    { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
+    { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
+    { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
+    { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
+    { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
+    { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
+    { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
+    { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
+    { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
+    { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
+    { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
+    { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
+    { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
+    { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
+    { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
+    { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
+    { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
+    { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
 ]
 
 [[package]]
@@ -1878,14 +1618,14 @@ wheels = [
 
 [[package]]
 name = "optype"
-version = "0.15.0"
+version = "0.17.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/d7/93/6b9e43138ce36fbad134bd1a50460a7bbda61105b5a964e4cf773fe4d845/optype-0.15.0.tar.gz", hash = "sha256:457d6ca9e7da19967ec16d42bdf94e240b33b5d70a56fbbf5b427e5ea39cf41e", size = 99978, upload-time = "2025-12-08T12:32:41.422Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/86/e6f1f6f3487492dfcf3b7a2d4e2534d27af6ac05b364b276706906c34865/optype-0.17.1.tar.gz", hash = "sha256:07bfa32b795dea28fba8605a6288d36370d072f25183fb9c29b5a90f4b6f5638", size = 53572, upload-time = "2026-05-17T22:13:28.725Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/07/8b/93f6c496fc5da062fd7e7c4745b5a8dd09b7b576c626075844fe97951a7d/optype-0.15.0-py3-none-any.whl", hash = "sha256:caba40ece9ea39b499fa76c036a82e0d452a432dd4dd3e8e0d30892be2e8c76c", size = 88716, upload-time = "2025-12-08T12:32:39.669Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/d4/c6a2b043e33f0dd012486dcebe0593585588d400175d22aad42049c88321/optype-0.17.1-py3-none-any.whl", hash = "sha256:82f2508ca31cb21e53a41648482d890fe1f5c6cb153720551af41161555adaf1", size = 65954, upload-time = "2026-05-17T22:13:27.549Z" },
 ]
 
 [package.optional-dependencies]
@@ -1896,20 +1636,11 @@ numpy = [
 
 [[package]]
 name = "packaging"
-version = "25.0"
+version = "26.2"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
-]
-
-[[package]]
-name = "paginate"
-version = "0.5.7"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
+    { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
 ]
 
 [[package]]
@@ -1923,20 +1654,20 @@ wheels = [
 
 [[package]]
 name = "parso"
-version = "0.8.5"
+version = "0.8.7"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" },
+    { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
 ]
 
 [[package]]
 name = "pathspec"
-version = "1.0.3"
+version = "1.1.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
 ]
 
 [[package]]
@@ -1953,11 +1684,11 @@ wheels = [
 
 [[package]]
 name = "platformdirs"
-version = "4.5.1"
+version = "4.10.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
+    { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
 ]
 
 [[package]]
@@ -1971,7 +1702,7 @@ wheels = [
 
 [[package]]
 name = "pre-commit"
-version = "4.5.1"
+version = "4.6.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "cfgv" },
@@ -1980,9 +1711,9 @@ dependencies = [
     { name = "pyyaml" },
     { name = "virtualenv" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
+    { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
 ]
 
 [[package]]
@@ -1997,32 +1728,55 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
 ]
 
+[[package]]
+name = "properdocs"
+version = "1.6.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "ghp-import" },
+    { name = "jinja2" },
+    { name = "markdown" },
+    { name = "markupsafe" },
+    { name = "packaging" },
+    { name = "pathspec" },
+    { name = "platformdirs" },
+    { name = "pyyaml" },
+    { name = "pyyaml-env-tag" },
+    { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ec/29/f27a4e1eddf72ed3db6e47818fbafe6debbf09fd7051f9c1a007239b46ef/properdocs-1.6.7.tar.gz", hash = "sha256:adc7b16e562890af0e098a7e5b02e3a81c20894a87d6a28d345c9300de73c26e", size = 276141, upload-time = "2026-03-20T20:07:48.167Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/bd/4d/fc923f5c85318ee8cc903566dc4e0ebe41b2dfc1d2ecf5546db232397ed6/properdocs-1.6.7-py3-none-any.whl", hash = "sha256:6fa0cfa2e01bf338f684892c8a506cf70ea88ae7f3479c933b6fa20168101cbd", size = 225406, upload-time = "2026-03-20T20:07:46.875Z" },
+]
+
 [[package]]
 name = "psutil"
-version = "7.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" },
-    { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" },
-    { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" },
-    { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" },
-    { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" },
-    { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" },
-    { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" },
-    { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" },
-    { url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" },
-    { url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" },
-    { url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" },
-    { url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" },
-    { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" },
-    { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" },
-    { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" },
-    { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" },
-    { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" },
-    { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" },
-    { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" },
-    { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+    { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+    { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+    { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+    { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+    { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+    { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+    { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
 ]
 
 [[package]]
@@ -2054,16 +1808,16 @@ wheels = [
 
 [[package]]
 name = "pycparser"
-version = "2.23"
+version = "3.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
 ]
 
 [[package]]
 name = "pydantic"
-version = "2.12.5"
+version = "2.13.4"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "annotated-types" },
@@ -2071,133 +1825,111 @@ dependencies = [
     { name = "typing-extensions" },
     { name = "typing-inspection" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
 ]
 
 [[package]]
 name = "pydantic-core"
-version = "2.41.5"
+version = "2.46.4"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "typing-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
-    { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
-    { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
-    { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
-    { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
-    { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
-    { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
-    { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
-    { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
-    { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
-    { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
-    { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
-    { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
-    { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
-    { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
-    { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
-    { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
-    { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
-    { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
-    { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
-    { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
-    { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
-    { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
-    { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
-    { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
-    { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
-    { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
-    { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
-    { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
-    { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
-    { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
-    { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
-    { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
-    { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
-    { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
-    { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
-    { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
-    { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
-    { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
-    { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
-    { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
-    { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
-    { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
-    { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
-    { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
-    { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
-    { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
-    { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
-    { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
-    { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
-    { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
-    { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
-    { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
-    { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
-    { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
-    { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
-    { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
-    { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
-    { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
-    { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
-    { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
-    { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
-    { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
-    { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
-    { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
-    { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
-    { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
-    { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
-    { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
-    { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
-    { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
-    { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
-    { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
-    { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
-    { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
-    { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
-    { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
-    { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
-    { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
-    { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
-    { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+    { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+    { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+    { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+    { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+    { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+    { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+    { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+    { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+    { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+    { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+    { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+    { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+    { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+    { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+    { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+    { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+    { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+    { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+    { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+    { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+    { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+    { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+    { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+    { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+    { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+    { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
 ]
 
 [[package]]
 name = "pydantic-extra-types"
-version = "2.11.0"
+version = "2.11.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pydantic" },
     { name = "typing-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" },
+    { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
 ]
 
 [[package]]
 name = "pydantic-settings"
-version = "2.12.0"
+version = "2.14.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pydantic" },
     { name = "python-dotenv" },
     { name = "typing-inspection" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
 ]
 
 [[package]]
@@ -2223,77 +1955,62 @@ wheels = [
 
 [[package]]
 name = "pygit2"
-version = "1.19.1"
+version = "1.19.3"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "cffi" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/17/49/cf8350817de19f4cafe4ae47881e38f56d9bbebaa9e5ef31a5458af4bcf8/pygit2-1.19.1.tar.gz", hash = "sha256:3165f784aae56a309a27d8eeae7923d53da2e8f6094308c7f5b428deec925cf9", size = 800869, upload-time = "2025-12-29T11:47:48.618Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/f8/4f/c8c29c4af2de6b8b7e086cad24e200ec7f165587caa77b7d2d495366204e/pygit2-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b54f3a94648ac8e287f5e4333710d9fe05f9e09de3da232d50df753bb01b643", size = 5702353, upload-time = "2025-12-29T11:46:28.548Z" },
-    { url = "https://files.pythonhosted.org/packages/b9/04/814b305804f067fd8d1cd7166dc3704900704a8fa71280703212abbacf9f/pygit2-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e46618a912fc984b8a9f4d8322704620f1315264359c7fa61c899128e23e226", size = 5691612, upload-time = "2025-12-29T11:46:30.754Z" },
-    { url = "https://files.pythonhosted.org/packages/cb/04/61c84d1ab2585f50a2551199e4228f3a800635c482e451e93f2cd0c0ae3d/pygit2-1.19.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb386b3e98f7056d76bc7e805e8fce3cd0a773cbbb30b0f7e144c0ac37270f2", size = 6021372, upload-time = "2025-12-29T11:46:32.439Z" },
-    { url = "https://files.pythonhosted.org/packages/be/7a/daca8780c72b0d5a56165e0bff3b76d2fa8e0a8f7269f40aa17f10ed0356/pygit2-1.19.1-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f41a9b866676922ac9b0ec60f0dc9735a5d1ba6bb34146a6212dc0012d7959f", size = 4623817, upload-time = "2025-12-29T11:46:33.964Z" },
-    { url = "https://files.pythonhosted.org/packages/92/f6/d065bb189c9fd86c5e540eb264567b4fe3eb06447da1408c03a35e15096b/pygit2-1.19.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2cdc81ecffd990d8c6dce44a16b1dc4494b5dd5381d6e1f508e459c4bca09e0", size = 5781284, upload-time = "2025-12-29T11:46:35.703Z" },
-    { url = "https://files.pythonhosted.org/packages/ad/8a/2b9195619a9a0dc6e25525e784f7474174614ebc064a91b2a2087952a583/pygit2-1.19.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a1c8645287556aa9b670886dbc0d5daa1d49040511940822fd43dbda13cfe4e8", size = 6027281, upload-time = "2025-12-29T11:46:37.331Z" },
-    { url = "https://files.pythonhosted.org/packages/d7/b7/20837029e8f5177d4ac48396a4448d02dfe455e988bb722d43dc42f6b0af/pygit2-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e388d1eb0c44d92d8ff01b25eb9a969fc28748966843c2e26e9e084e42567f7d", size = 5750642, upload-time = "2025-12-29T11:46:38.626Z" },
-    { url = "https://files.pythonhosted.org/packages/41/42/18cc94976a35451a5653abf047356f94b5f503b1c0b02223a6d9e72979d3/pygit2-1.19.1-cp311-cp311-win32.whl", hash = "sha256:815c0b12845253929f2275759d623b3b4093e67e6536d2463177e6ff1d9ff0df", size = 942173, upload-time = "2025-12-29T11:46:40.087Z" },
-    { url = "https://files.pythonhosted.org/packages/61/19/590708fc3182d47b40f0274f80671ccdf9c1a8fa5a838b554e6fe15a2bb3/pygit2-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:93f4986b35984aaaa5e7613ceb1ba4c184d890589df60b0d8d74e7dccec1d8cb", size = 1159463, upload-time = "2025-12-29T11:46:41.338Z" },
-    { url = "https://files.pythonhosted.org/packages/90/a8/a2c1eb6f8c5f30cb5633a3c21e60ee6be2e4a3148b302f578e4b48e769ef/pygit2-1.19.1-cp311-cp311-win_arm64.whl", hash = "sha256:fef27b206955e66e3a63664e2ec93821e00ce2d917f8b4eae87c738163c00e14", size = 966795, upload-time = "2025-12-29T11:46:42.842Z" },
-    { url = "https://files.pythonhosted.org/packages/1a/36/0784870218794d6069bf8ebae55679964edc44b8e59279f4526aa1220569/pygit2-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8e6a4f4a711750c286a13cea0007b40f7466c4d741c3d9b223ffbc3bbfbafe7", size = 5700218, upload-time = "2025-12-29T11:46:44.537Z" },
-    { url = "https://files.pythonhosted.org/packages/56/65/47206823900ddca606022025369ba3e136de9d2310585acac10d8cef81fd/pygit2-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f2340a668eb3e2d8927dcbeb1a043d3a65d2dd39a913995b34fc437da5e73af", size = 5692231, upload-time = "2025-12-29T11:46:45.821Z" },
-    { url = "https://files.pythonhosted.org/packages/19/27/c6b52f53ee16b9d7eaacc575f08add3c336f53b5561cf94fe41ceeab1589/pygit2-1.19.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe41f09b1e334c43def6636b1133d2f4c91a20d9a6691bb4e7558e42a31bcb4e", size = 6022217, upload-time = "2025-12-29T11:46:47.086Z" },
-    { url = "https://files.pythonhosted.org/packages/dc/ac/41d7a1ed69e117e9cd99b2f40f63898f9725ac6c4245b2b531ae0b7e59da/pygit2-1.19.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527e57133d30ff6ea96634da6bf428f7d551958207fa73f9e9a18582b885e192", size = 4622846, upload-time = "2025-12-29T11:46:48.679Z" },
-    { url = "https://files.pythonhosted.org/packages/09/22/f8fc7860b7b7ba15f7bf802ae3bce52b3e765b48846db115cb1c8372f971/pygit2-1.19.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a9340cb85b7be40080186a9d4dbf712a6be8a842556acbbfb305baebfb854f3", size = 5785236, upload-time = "2025-12-29T11:46:50.24Z" },
-    { url = "https://files.pythonhosted.org/packages/ec/62/ee9275c48ecc119a7f5c48209aaa06d5f71d8476703c7700182c49c8a7a8/pygit2-1.19.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66ecfa69f2287f50ec95dfc04821219c2f664c4cd292c7b33c10ed9afe975132", size = 6028266, upload-time = "2025-12-29T11:46:51.5Z" },
-    { url = "https://files.pythonhosted.org/packages/7c/98/311112a50e6e319921f06c20ff237360c10bb2e8a1f959361567e48835f3/pygit2-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14c76ec968ae20a6689c7b6fa833ef546c7bc176127d71e7b67cb2345a9813fb", size = 5755041, upload-time = "2025-12-29T11:46:53.337Z" },
-    { url = "https://files.pythonhosted.org/packages/e1/45/f6a24326fb94e56ddae9906e21d4e4a006a36131a3a73819be1177e30e93/pygit2-1.19.1-cp312-cp312-win32.whl", hash = "sha256:ffe94118d39f6969fda594224b2b6df1ae79306adaf090ede65bcaf1a41b3a81", size = 942948, upload-time = "2025-12-29T11:46:54.465Z" },
-    { url = "https://files.pythonhosted.org/packages/a3/1a/912ee3a33ba665f82cf8ed0087e7446f1f8e117aba1627e0c4ccc9b2a8c5/pygit2-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:c2ee3f2e91b0a5674ab7cb373234c23cf5f1cf6d84e56e6d12ff3db21414cf47", size = 1159880, upload-time = "2025-12-29T11:46:55.523Z" },
-    { url = "https://files.pythonhosted.org/packages/24/fc/784eeceab43c2b4978aa46f03c267409f2502331fa18d0a8e58116d143d0/pygit2-1.19.1-cp312-cp312-win_arm64.whl", hash = "sha256:c8747d968d8d6b9d390263907f014d38a0f67bd26d8243e5bc3384cb252ec3d3", size = 966904, upload-time = "2025-12-29T11:46:56.888Z" },
-    { url = "https://files.pythonhosted.org/packages/a0/2b/b3c8661e710ec49f7f38f992b913d6fef21e21ef6b9b327111b85bf1460c/pygit2-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:39af62f3e18dfdfb15c347c12b51231fdb3db3c9d5105d9046847ead14b42fce", size = 5700202, upload-time = "2025-12-29T11:46:58.294Z" },
-    { url = "https://files.pythonhosted.org/packages/e2/1f/f67ec7f78a34ed14dbd3acf05ed23c4c8c2336ba6f3ca78d6b9962878435/pygit2-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed39106f1d9560709191093ed5251471dfb6b9e4aa35299dde45f4b91f7c984e", size = 5692171, upload-time = "2025-12-29T11:46:59.535Z" },
-    { url = "https://files.pythonhosted.org/packages/a7/02/02f0f56b9b0b044018d9047adf68ba842ebda662ba43ace942ed904f8e9d/pygit2-1.19.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb4da746c92e23281890e865887d83f24e662fc3e1c481420e4993c5a13203fe", size = 6023018, upload-time = "2025-12-29T11:47:00.984Z" },
-    { url = "https://files.pythonhosted.org/packages/da/a6/5ec78c14ca00fbffe6aa32eb6f5fbeb7fb06eb39e6929b06f7635f501a45/pygit2-1.19.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93ccfab2340d38374f91ecf6cae6658bebc73883c376eb81eeb293781f6aef94", size = 4623392, upload-time = "2025-12-29T11:47:02.598Z" },
-    { url = "https://files.pythonhosted.org/packages/d5/80/1a87f6e043e04cfa125380a73ef9f87a8c58292b7d4a6ed2e6203b4cd534/pygit2-1.19.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef18f1208422d3cac1c109417a5fc6143704cfff8e5de4e1665fa4a89ffe3902", size = 5786360, upload-time = "2025-12-29T11:47:03.898Z" },
-    { url = "https://files.pythonhosted.org/packages/f7/6e/f5e38a4645d7fbba40083f94278814b9863b0afd14e905ebbd7ef31a27ec/pygit2-1.19.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:344f4c1e84eaa2434fbb43d96a1dd79796ab9559587a8533331fef92eab0ec7d", size = 6029576, upload-time = "2025-12-29T11:47:05.109Z" },
-    { url = "https://files.pythonhosted.org/packages/53/cc/e5ff546f003c3fa635495105e3e039de3a863da66c82289b7a8baf6d5b48/pygit2-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ae2f408206c67d395e8dc77425f8ab457cad59faaa58c700164398a62823e82", size = 5756457, upload-time = "2025-12-29T11:47:06.483Z" },
-    { url = "https://files.pythonhosted.org/packages/da/dd/1331e3bdabd811992f511ebfa96f56c7b13d5f16837d74ac34dac93ce999/pygit2-1.19.1-cp313-cp313-win32.whl", hash = "sha256:9d6cf97c2da5c589b65371a8115be920cf417c46a80a2b12edb26e54a5238190", size = 942919, upload-time = "2025-12-29T11:47:07.833Z" },
-    { url = "https://files.pythonhosted.org/packages/6d/01/98f74ecbe92f042d27e4de3cd7f093422d523cc67fdc74e6a65dbe4efbb8/pygit2-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d73aedffad280f6b655394e303533fcff15545d4d8f322011179c9474bb1b13", size = 1159846, upload-time = "2025-12-29T11:47:09.228Z" },
-    { url = "https://files.pythonhosted.org/packages/27/4e/df8fa9a9f4e4e9aec417f8a674466d613985efb67453aa206f0455003738/pygit2-1.19.1-cp313-cp313-win_arm64.whl", hash = "sha256:8b067241c03a29440507e78637e233998fe1a11d2082169bd8177694ec4ee747", size = 966896, upload-time = "2025-12-29T11:47:10.233Z" },
-    { url = "https://files.pythonhosted.org/packages/dd/45/1284c7714070b51e3413e66b677fa4ecf8c840d2f86d1bebc77d2390fe3d/pygit2-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d10a46285b9ae39b9de2d9f44ac7f933993aecfab189c2932320b3df596311c8", size = 5702338, upload-time = "2025-12-29T11:47:12.807Z" },
-    { url = "https://files.pythonhosted.org/packages/a3/9f/7a39d4c612e12966130504e1610f500b397d7968feb6d25e1353614dab74/pygit2-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d0f3924d8d0d54a7fe186761c76dc1b6e5fcf41794a6daba1630db3bc216b9ba", size = 5692261, upload-time = "2025-12-29T11:47:14.276Z" },
-    { url = "https://files.pythonhosted.org/packages/0b/7c/7806cf0ae9200bd773628be6d8c345d277b8f0161de950b572a4ce200105/pygit2-1.19.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4fcc301cfe9c29f3e29f0f80d81ae65c0bee368672b23566467dc91b5edae4b", size = 6025106, upload-time = "2025-12-29T11:47:15.904Z" },
-    { url = "https://files.pythonhosted.org/packages/dc/30/7f1b67711705eb0220dcc4581a97b4aebad4ffde2f6f6b94314690e1cfa1/pygit2-1.19.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6eacf82f15e001121dc0f60057f462627045447d8bd8587b33b13159ae5155", size = 4627355, upload-time = "2025-12-29T11:47:17.365Z" },
-    { url = "https://files.pythonhosted.org/packages/14/88/25f1e65ff6ed678e1be9aaeabeedcb26531d17b6b86c4b1d50d8f0c50825/pygit2-1.19.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:074b0b14c6f3c7e2c6ea0b01a90832407a71520c920918aa07f509c91f1691f9", size = 5788548, upload-time = "2025-12-29T11:47:18.98Z" },
-    { url = "https://files.pythonhosted.org/packages/e7/5d/ff1b12d3682918ac6c3c6629a6c6272db1b4041994d38045d3c334034570/pygit2-1.19.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ada5d3e813e21918e004a33c66aba4a2b829cd5c0c0e85b92dd70f84cf95ac56", size = 6030078, upload-time = "2025-12-29T11:47:20.324Z" },
-    { url = "https://files.pythonhosted.org/packages/1c/c5/c078ed6f1f5d7f3feba4b86d53e464c8358112ec32943e11e36557009818/pygit2-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:19ebe25fd8e95ed8a0be0a9dd4cecc1233db4f2a44a2a73984620909e98e907f", size = 5757154, upload-time = "2025-12-29T11:47:21.971Z" },
-    { url = "https://files.pythonhosted.org/packages/76/90/1722d7c2db5d563becb59a54b2f49b44964ff699826629f96594064d972a/pygit2-1.19.1-cp314-cp314-win32.whl", hash = "sha256:5bc0738a49cceb76f0fba7cdb24532857a980e4a36b9a0da025c359dfe3676b4", size = 964159, upload-time = "2025-12-29T11:47:23.508Z" },
-    { url = "https://files.pythonhosted.org/packages/74/72/80558b71ed780a732c9ff10003c3a73b68fbf320c3125ae11bb664a8076c/pygit2-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:527d40925bb85b86da0e96ecc90e9ca74d0a0273ab645bac0787b95923d93160", size = 1190612, upload-time = "2025-12-29T11:47:24.889Z" },
-    { url = "https://files.pythonhosted.org/packages/28/68/c60ff9ae38543a520ca93c0d52a52c2e375ac44b9a5c5da99044cca8c5c5/pygit2-1.19.1-cp314-cp314-win_arm64.whl", hash = "sha256:21c7c8b5aa2f48cefdb8521185f0cd3c110a340e2d9f62a46a94db01a907db73", size = 994766, upload-time = "2025-12-29T11:47:25.902Z" },
-    { url = "https://files.pythonhosted.org/packages/ee/42/4da546bf55183877e7da4327594ab138db92aa00921d46d513626bcad19f/pygit2-1.19.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c5e4eb975b664b6821fe6a05b03bbc51052d1fb22f20652e1d4349ae24ed7ac", size = 5705642, upload-time = "2025-12-29T11:47:27.034Z" },
-    { url = "https://files.pythonhosted.org/packages/0a/b7/74a9cf3d2e6cd6bd2fa6a7bc3530054c2f720fc59e3b731251bbdebd8983/pygit2-1.19.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8752eae5780ee51edae326cac394868917704624b63d03a5217c5e94a532a0e3", size = 5695192, upload-time = "2025-12-29T11:47:28.98Z" },
-    { url = "https://files.pythonhosted.org/packages/b6/b9/bde02249c2c5deecc8e483ee9132f86f67114eec154ee10219d23a1ce9f9/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:457f5a2e6d8527b5ad7a8bd16586c72ad2ce0aa218a37380f16d07520569ceaf", size = 6085318, upload-time = "2025-12-29T11:47:30.268Z" },
-    { url = "https://files.pythonhosted.org/packages/d2/ae/b3a14edaa579700aee33a25a788f5f4fe67713a6e2273a897635e6742b35/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c8a9d53c84724c97d7e298f6628655c19f9911a90b88c362cb7d5daa645464f", size = 4684691, upload-time = "2025-12-29T11:47:31.829Z" },
-    { url = "https://files.pythonhosted.org/packages/00/8d/5f557be149931ef7d692b66296a44263a1769070466eb1e63d6d1b3b97c1/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d8442ad863be83be86baff006a6e11de3cddf17c7ee77eac2d389765987b554", size = 5841500, upload-time = "2025-12-29T11:47:33.636Z" },
-    { url = "https://files.pythonhosted.org/packages/ce/f7/0101b3058e64df334c48193dfd6f1493a24b0c7813382c6b2e4db7a09ffa/pygit2-1.19.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ae9c775be518c7f20bf340091d329d3b9203cbd4273bf1b5505dc82dccf08147", size = 6087805, upload-time = "2025-12-29T11:47:34.926Z" },
-    { url = "https://files.pythonhosted.org/packages/60/26/7d3fa88362b1703cd5b9bde411f37cded3b1f99dc83b720fc0c65ac8f37b/pygit2-1.19.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d5a45d466a4bc5d9eb0619ffc26b17e4018285e35ba9e2fe39576f13480b63bc", size = 5809156, upload-time = "2025-12-29T11:47:36.396Z" },
-    { url = "https://files.pythonhosted.org/packages/90/38/f1952af3f61b3a7a49c417ffb67a5140c1183e6b04ec714c8941937860bf/pygit2-1.19.1-cp314-cp314t-win32.whl", hash = "sha256:6621acaaf2670e8fd0727c15271e5209a99769b127300ef7fc56b49babc8b1c1", size = 969317, upload-time = "2025-12-29T11:47:38.01Z" },
-    { url = "https://files.pythonhosted.org/packages/31/02/205a4d10cb1195f6abf0a509883ede90caddefca6d9c3b54ef96e79e8e8a/pygit2-1.19.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4418dea6936fe3c1a9375d7cd31a69e72997e645e588ed31c40d785c71adde35", size = 1197068, upload-time = "2025-12-29T11:47:39.065Z" },
-    { url = "https://files.pythonhosted.org/packages/00/20/4571edf9bebc9d60dcf5d5c3cd0a12e55a79b91b02ef960c44e4ffc24c70/pygit2-1.19.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3cbb8ab952224c0b305aa56f8759bcad5d9a9de885b00fe0ff8bed9ac365472e", size = 995635, upload-time = "2025-12-29T11:47:40.327Z" },
-    { url = "https://files.pythonhosted.org/packages/45/01/607b8a400ffe46340df083d67cb05296f90e0d302d09addac5dc1afee47f/pygit2-1.19.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3c56ef9ac89e020ca005a39db4e045792b1ce98c2450a53f79815e9d831c006a", size = 5646594, upload-time = "2025-12-29T11:47:41.437Z" },
-    { url = "https://files.pythonhosted.org/packages/18/59/45e517b86692120fd927b8949916203c50ffce0cd7a7124131d90d816fde/pygit2-1.19.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a6d89079f3af32f25abb8680eabea31143a4f02f3d1da6644c296ba89b6a2fc", size = 5644506, upload-time = "2025-12-29T11:47:42.779Z" },
-    { url = "https://files.pythonhosted.org/packages/db/25/41c0c37c0f8b23677364d9f82ddbb1377d2342666045d39b508acc3d6f97/pygit2-1.19.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bfd44dc6f1d5b1165cc2097c39000c4a5cc05443d27a3a5f2791ad338f52b07", size = 5559864, upload-time = "2025-12-29T11:47:44.399Z" },
-    { url = "https://files.pythonhosted.org/packages/76/c0/16ff6c4d732d8644ab84a5d48141b55f6b353e08da5ffcbee03a5c58c3a5/pygit2-1.19.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0aca00ff7e3420f9c06d9386b0bfc76c18fd8a2c5234412db0e200a6cc47ed03", size = 5312681, upload-time = "2025-12-29T11:47:46.022Z" },
-    { url = "https://files.pythonhosted.org/packages/08/cc/f762a2378d148ae40766fcac3f1ae1b5d925ae80128422366788eea9f5e6/pygit2-1.19.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f89f047667a218b71ebc96c398aca1e5109f149045a8d59ca9fd4a557d1e932e", size = 1130023, upload-time = "2025-12-29T11:47:47.55Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/a6/44/415aa93422b4bfc21a6448acb7e16280d5f33a9a3fae38a384e37b046ae4/pygit2-1.19.3.tar.gz", hash = "sha256:a543e6d4ebb43825564935758dc234e770016fed673b84370d46ae9580558831", size = 810489, upload-time = "2026-06-13T08:06:04.982Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a2/9c/9bc9a8d727b2ae8ae77306ecfb77a5dcf836da4997d4f7053c893f8e0d11/pygit2-1.19.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d696ee240cc8b84e747b04443d85768c859ef0b88abe4a3505dc0b8e2a953ca0", size = 5708797, upload-time = "2026-06-13T08:05:00.097Z" },
+    { url = "https://files.pythonhosted.org/packages/63/7e/9099ea2f90791549185f8ac737a8b448e05cf3882cc79928a380be9bf38c/pygit2-1.19.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86318a704bef836067fa9ac06c56591738d548dcdf2ea24ec227257e3f8115f8", size = 5700295, upload-time = "2026-06-13T08:05:01.676Z" },
+    { url = "https://files.pythonhosted.org/packages/39/84/e9610f041dc43699fca8af55050f9bf9fe0d340ab9902ab8e3fa67bece23/pygit2-1.19.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc3ddb3e5d16cf662d2d0faeb53259e31ae584cc990cd76df909beeb4a36736a", size = 6036822, upload-time = "2026-06-13T08:05:03.473Z" },
+    { url = "https://files.pythonhosted.org/packages/64/bd/419ec17df3f1f1182de8c8fe800b698ff2a489cc50ec2d34990092bf3d61/pygit2-1.19.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73820fb34339fe3c52731d1c46c298a3239ee75b61bc3a2d9b45f8b8873acc47", size = 4638181, upload-time = "2026-06-13T08:05:05.145Z" },
+    { url = "https://files.pythonhosted.org/packages/90/5f/aecca9c7f4ffdc1facc4b2402ea40073848e67c8c607a5f0698e1e8a61e4/pygit2-1.19.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f6ad50608f51b053bf6c321ae2c31cc57f15e4131a4609261c44e29bdae7cb", size = 5799910, upload-time = "2026-06-13T08:05:06.48Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/2a/e131af7752f75f70e04f7f3ae724481d0e2db9c6c688a2081ffb5948657e/pygit2-1.19.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b2c63c7a371dbd5825e5fb7069b759c79aa5dec97806d49cb2c9f9a8a373209d", size = 6042766, upload-time = "2026-06-13T08:05:08.052Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/a5/978ee5233379b2aa725c290a7cc3baca9b141f4fc37d55d9a473631b529b/pygit2-1.19.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:977eb52cd4134ff60d3faa168327a03f8724278c38bfcd347d8e3a11041ee9cd", size = 5771434, upload-time = "2026-06-13T08:05:09.398Z" },
+    { url = "https://files.pythonhosted.org/packages/26/33/7c506492fffe3e92d9e911fae43a4b4a3dee43682b71b7793d586c25acfc/pygit2-1.19.3-cp312-cp312-win32.whl", hash = "sha256:14a2734d793d2d937d3bfc9d35b280ec83fc97a91b1934b1013554d41e6d3d70", size = 945030, upload-time = "2026-06-13T08:05:11.187Z" },
+    { url = "https://files.pythonhosted.org/packages/82/ff/57ad08d1e87ae6e4208f80923ce1a6ea8a03ea776376e39d64816637b168/pygit2-1.19.3-cp312-cp312-win_amd64.whl", hash = "sha256:a7caeaf46aaa8e51c512af9ac1377f388e4836c9d042e134bbc5e22519fbd1bf", size = 1254825, upload-time = "2026-06-13T08:05:12.315Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/cf/476c217a4ab3c9b4abf2506fb1135e54b19d205ce7ddecb1d961bf93a9dc/pygit2-1.19.3-cp312-cp312-win_arm64.whl", hash = "sha256:2aafa010e3ac227913f398c8a680bce419e396dfe651685fffb779ed1c109a00", size = 969650, upload-time = "2026-06-13T08:05:13.438Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/12/1e0f0fc54a24ddfdabb0da0bafb3237c82b5628a8c5258ba66fb8d39b5b2/pygit2-1.19.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9238bf7a8a953f4faedcbe90eb097b760a2709c7e7ab38edcf18dc2d0ce9dcff", size = 5708790, upload-time = "2026-06-13T08:05:14.755Z" },
+    { url = "https://files.pythonhosted.org/packages/82/12/9093b7b81de7fe869f6ae51319a05b9f0a8b2567674e0d61fd54aacb833f/pygit2-1.19.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f999a4995504a6c14443de9481b7c6c54802663fe80c7504e1b946348f43786", size = 5700256, upload-time = "2026-06-13T08:05:16.363Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/07/a5133f79cf86e8a0cadf9f424cdd5b912ba47410c7254c0237b6e5290ddb/pygit2-1.19.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0918bc9b7597f243aa4b9c785912ae853e9aee4f572bcd5c9c591fcb703542f7", size = 6037556, upload-time = "2026-06-13T08:05:17.823Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/fc/3aa049d88eaa51f14f1fbfc0b70ac4c2362d3306dbeb8d1e5e3ca301e1dc/pygit2-1.19.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18403fee0171867be5fc1c1415bcd5d5a7fab51299bdd44bcf467c22a868fd22", size = 4638864, upload-time = "2026-06-13T08:05:19.325Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/b7/5e772367db14f800048fa2a0981abd45baae6dcd8b83453da6ac0f92cead/pygit2-1.19.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cacece712337029092a98cf1af8d244e92fa8b36e5a64d7e14212ff73ea821e8", size = 5801146, upload-time = "2026-06-13T08:05:20.748Z" },
+    { url = "https://files.pythonhosted.org/packages/07/79/075b37b9a971cc7df9721add0e03cded83cab9e7d9736a80820357b2a972/pygit2-1.19.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49d7c494bdaa5a8da366e4c4b7c822e52bd33bb6f22c67f8585b5396e91f8ac7", size = 6043951, upload-time = "2026-06-13T08:05:22.232Z" },
+    { url = "https://files.pythonhosted.org/packages/94/76/72e13c487d013f21f97690f0662cb03533d91aad39e0c7c4c8cd3e4453d0/pygit2-1.19.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2bbe74fbe553c6f991512391822c27c9321e3a0b45cab872f45c8d458393eee3", size = 5772767, upload-time = "2026-06-13T08:05:23.487Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/24/5ba37f80fcb303bcf1a3813631e5b5dddfbac771cce7efc0b03428f4fa2e/pygit2-1.19.3-cp313-cp313-win32.whl", hash = "sha256:3ab184751ca7a92007dbd8ae4ae4ac3b7228ef60ae2d2e18129b44bbe5c0aa4c", size = 945026, upload-time = "2026-06-13T08:05:25.053Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/48/4ae19f2e4dc8ff2ddc2fd64de7fd1d26aeb2b453069d6c9ee251dd567983/pygit2-1.19.3-cp313-cp313-win_amd64.whl", hash = "sha256:522d9c6ab9206880dceaae5516720c0f0df706859d2ba97f49adcaeea2d661bd", size = 1254793, upload-time = "2026-06-13T08:05:26.174Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/7b/473ca9e35dfd82c758b700a6cf4ecc107b849674e91ab219383063ebb12f/pygit2-1.19.3-cp313-cp313-win_arm64.whl", hash = "sha256:c77d5a1334ca3f1432c83457e03be8759d8e9040e0d25c59c738f2318545654a", size = 969666, upload-time = "2026-06-13T08:05:27.351Z" },
+    { url = "https://files.pythonhosted.org/packages/99/15/ec8a777b497853edab4e150c9ea46019b6c709b3c2b06fd06454a1aaa8c6/pygit2-1.19.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:214140f2201474e9d8e3889e5635e323aa90a58e6ae224a9de56be83d3e9b747", size = 5710849, upload-time = "2026-06-13T08:05:28.71Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/be/a8c449fba4c70239d6e9d0023b1fdd783c2dbb52872fef9c4de1652e930a/pygit2-1.19.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:46fc9fd234b2a38176625af6bfd2dbad99ab937379a980761c59a343bdff4910", size = 5700188, upload-time = "2026-06-13T08:05:30.466Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/19/8e92b4008324137878e9f9e130b4e349956d2a2b719521b8ce45aad6a0de/pygit2-1.19.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb9f46b561ee53f22cb479e7fdefd7b8ae478d12656436a48166c2ad81a3c021", size = 6039510, upload-time = "2026-06-13T08:05:31.892Z" },
+    { url = "https://files.pythonhosted.org/packages/90/8f/baf3a0944f54acc27ceaa22b47ca52cd64c8bb1ec69473f80d3f4bafe54d/pygit2-1.19.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9f393932fadd8e74f26d7ebb58da649e7a188d9156b732dd6c9f0dfc273d4430", size = 4643029, upload-time = "2026-06-13T08:05:33.385Z" },
+    { url = "https://files.pythonhosted.org/packages/69/1b/0b045e74fe01b29af28a5d54a9c89693db97e7e6cbd700f8086f4294e85e/pygit2-1.19.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9521a9b45de3affd82618e94f7e5116685e695e65fcbf67b2f1e44a086c0f4a", size = 5803099, upload-time = "2026-06-13T08:05:34.873Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/d2/0d4eb93b7088316b9885b9c29b6a971009a20bf93aebd5762d3b809d72bf/pygit2-1.19.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:241f0fcd2aaf025e9fb5e1887eac5f244cb6b0a6e83dc27d673fa634ab0e0ee3", size = 6044603, upload-time = "2026-06-13T08:05:36.191Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/8a/5a486a8aa9a1aa6a1ed5189b71b1b96e92bd164cc3e4d010e285032a2662/pygit2-1.19.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1729caee68cdf35c0cdb913de3850724438e4bdf480742da4f21a3e86d83662", size = 5773603, upload-time = "2026-06-13T08:05:37.605Z" },
+    { url = "https://files.pythonhosted.org/packages/94/ff/e6a5da1fe3b104fc153160bfe56a85298a6c81b5411ee8de97e4039d3a2f/pygit2-1.19.3-cp314-cp314-win32.whl", hash = "sha256:2c300d234ef15e4c557a7e9ac880163606b364d0d4e20ddbf1204b4ee9f9b61c", size = 965699, upload-time = "2026-06-13T08:05:39.151Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/a1/a8f21afe574f22fde3d04fb102c4e75b5a1077fa1ea1ee3f7b072aa8f858/pygit2-1.19.3-cp314-cp314-win_amd64.whl", hash = "sha256:0ff9f187b01d6629c14ad069b100e147093b70279496984faa012783c5832b66", size = 1288277, upload-time = "2026-06-13T08:05:40.315Z" },
+    { url = "https://files.pythonhosted.org/packages/95/65/f5268ac09c91f25e349a658cd9722b3528b1c4d6b18aab7472132e067912/pygit2-1.19.3-cp314-cp314-win_arm64.whl", hash = "sha256:cdfbf879ee0d468d7929eb26e35db7442b56a411af1d5b7d28e858cccf71598a", size = 997247, upload-time = "2026-06-13T08:05:41.629Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/98/9825e08b78a58bc99e979e322b8c776e6a4b49dcebf68471b650bf97e1ed/pygit2-1.19.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79a3b565e20bbbbf21d29de1675a18e0e556545a2c738f38c0ac0435269f56bd", size = 5714170, upload-time = "2026-06-13T08:05:43.326Z" },
+    { url = "https://files.pythonhosted.org/packages/82/00/8836f806512ac0a9e8613b2b446a0fe2578834c01c2234ca6d6f2abd6751/pygit2-1.19.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:828856512070cda223c19046b02af50135a5b0efa274a80fbf7c82d773f8e89f", size = 5703231, upload-time = "2026-06-13T08:05:44.997Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/9b/117fcbd26d30729e9f46889747823e51cb5597d191c100da298faa79bb90/pygit2-1.19.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eec2b1d50bf52d2139d3d478d6d5f5ee56103da4a6dbd786344e8a85254dd07b", size = 6097836, upload-time = "2026-06-13T08:05:46.269Z" },
+    { url = "https://files.pythonhosted.org/packages/70/8c/c1fa28b4e3064ef0e06e49af3cbcbf50b62f36fab3f9832c15cd46d0a175/pygit2-1.19.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbfa10f22670e7fef13bf249c32aebcafe23115790155d6b71efafb9aa7d4cba", size = 4697771, upload-time = "2026-06-13T08:05:47.629Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/c5/1d72a285fe794e2ce87ebe4b107c7cc713917e6e62b24264580b966740ea/pygit2-1.19.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d22c2940bde27f9ffe095aac87e7c8b8a9c787f91782c7463387d39a2592d04c", size = 5854885, upload-time = "2026-06-13T08:05:48.988Z" },
+    { url = "https://files.pythonhosted.org/packages/60/ac/250da1cff67e38af607792ed98a49c64b5980d5332d5f26c1882f722cefe/pygit2-1.19.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ed9f15b4b0b80191669fdf5b702b697ffbc245d24995befa80e46bef3aea25e", size = 6100802, upload-time = "2026-06-13T08:05:50.395Z" },
+    { url = "https://files.pythonhosted.org/packages/33/fc/1208276e01868b7ec1eb4c108dedabee114e24809eecf08820f9a52c3435/pygit2-1.19.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:40f1253e042d0a5dfed16eec92b5b28a529c2873f4ee77ca60d1ea7b79a14600", size = 5824124, upload-time = "2026-06-13T08:05:51.787Z" },
+    { url = "https://files.pythonhosted.org/packages/03/3a/04d44e2ff972a8a7846d48ae288f717a058331d6e056a652cafbcd7f6867/pygit2-1.19.3-cp314-cp314t-win32.whl", hash = "sha256:245d627a8ae3f35dafd9ce5b35538ddb5ba0c3fe6be8d2f9aa410f93a9184b83", size = 968784, upload-time = "2026-06-13T08:05:53.107Z" },
+    { url = "https://files.pythonhosted.org/packages/40/89/d42e745838fa0d9072bf425adf5b590b521b7a196d3b92f40951af9e4514/pygit2-1.19.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8d2e3044ac1b09ef9120deb1896a56423c6dd17eb5ac4012c3c49dd590ee4910", size = 1291338, upload-time = "2026-06-13T08:05:54.546Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/10/61513d337a9241e9ce54edfd8fc1d8f2b0f36cbfa37de227ded0ffdbdc96/pygit2-1.19.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ef2790c9ff7e7673d7262e9e059c5af86c09197037073fd004d22a63e4151291", size = 998330, upload-time = "2026-06-13T08:05:55.888Z" },
 ]
 
 [[package]]
 name = "pygments"
-version = "2.19.2"
+version = "2.20.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
 ]
 
 [[package]]
@@ -2304,7 +2021,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/66/ca/823d5c74a73d6b8b0
 
 [[package]]
 name = "pylint"
-version = "4.0.4"
+version = "4.0.5"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "astroid" },
@@ -2315,40 +2032,27 @@ dependencies = [
     { name = "platformdirs" },
     { name = "tomlkit" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" },
-]
-
-[[package]]
-name = "pylsp-mypy"
-version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "mypy" },
-    { name = "python-lsp-server" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/4d/9683a57f2e8b9263910ef497a99d88622f4fb1c158decb867fd40a41bfdd/pylsp_mypy-0.7.0.tar.gz", hash = "sha256:e94f531d4ce523222c2af7471abe396cfeb4cc3c4b181d54462fb6d553e1e0b3", size = 18529, upload-time = "2025-01-25T13:15:38.978Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/a6/7d/324859fa4af565db32ff8d924fd10dd49922756736be12d783be3813ffc8/pylsp_mypy-0.7.0-py3-none-any.whl", hash = "sha256:756377d05d251d2e31d1963397654149b9c1ea5b0ba1aedd74adef76decd32e9", size = 12232, upload-time = "2025-01-25T13:15:37.472Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
 ]
 
 [[package]]
 name = "pymdown-extensions"
-version = "10.20"
+version = "10.21.3"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "markdown" },
     { name = "pyyaml" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/3e/35/e3814a5b7df295df69d035cfb8aab78b2967cdf11fcfae7faed726b66664/pymdown_extensions-10.20.tar.gz", hash = "sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52", size = 852774, upload-time = "2025-12-31T19:59:42.211Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
 ]
 
 [[package]]
 name = "pytest"
-version = "9.0.2"
+version = "9.0.3"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2357,23 +2061,23 @@ dependencies = [
     { name = "pluggy" },
     { name = "pygments" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
 ]
 
 [[package]]
 name = "pytest-cov"
-version = "7.0.0"
+version = "7.1.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "coverage", extra = ["toml"] },
+    { name = "coverage" },
     { name = "pluggy" },
     { name = "pytest" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
 ]
 
 [[package]]
@@ -2390,28 +2094,28 @@ wheels = [
 
 [[package]]
 name = "pytest-randomly"
-version = "4.0.1"
+version = "4.1.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pytest" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/c4/1d/258a4bf1109258c00c35043f40433be5c16647387b6e7cd5582d638c116b/pytest_randomly-4.0.1.tar.gz", hash = "sha256:174e57bb12ac2c26f3578188490bd333f0e80620c3f47340158a86eca0593cd8", size = 14130, upload-time = "2025-09-12T15:23:00.085Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/33/3e/a4a9227807b56869790aad3e24472a554b585974fe7e551ea350f50897ae/pytest_randomly-4.0.1-py3-none-any.whl", hash = "sha256:e0dfad2fd4f35e07beff1e47c17fbafcf98f9bf4531fd369d9260e2f858bfcb7", size = 8304, upload-time = "2025-09-12T15:22:58.946Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" },
 ]
 
 [[package]]
 name = "pytest-regressions"
-version = "2.9.1"
+version = "2.11.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pytest" },
     { name = "pytest-datadir" },
     { name = "pyyaml" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/1a/6f/fa2e2971f502b65cc3a056daf275d5d72ad98615a09da1493397a88dc797/pytest_regressions-2.9.1.tar.gz", hash = "sha256:987ed799560ab0fb3bdd7ad06c904cbee6cbad4a42c4a2a56891a54b24ee6908", size = 115361, upload-time = "2026-01-09T16:48:01.88Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/0f/1d6b8cc851596be52c401af53fd0d844e72921b0b11866b88995673cd320/pytest_regressions-2.11.0.tar.gz", hash = "sha256:d4a86092f979eb25d2403c51b2b61039781c4c7c7a364a56b9fa838872a650f7", size = 117474, upload-time = "2026-05-25T12:09:01.796Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/2c/ac/4218ae0f0c775c043e2911d99424cb42e81c83555716c86b7fabae854b0b/pytest_regressions-2.9.1-py3-none-any.whl", hash = "sha256:6cd73913e8c3e1ee623950376f107a47a4c055fe48ca1d9214612373893d232b", size = 25052, upload-time = "2026-01-09T16:48:00.441Z" },
+    { url = "https://files.pythonhosted.org/packages/97/61/fe772eda66bb8e2ee72da2937382aa4f803ca1e41092ca8d59953e96262c/pytest_regressions-2.11.0-py3-none-any.whl", hash = "sha256:fcb2bbcfb2fda256624d434c88cb1e4003667b7e078ee4084887cf839bbdf831", size = 25556, upload-time = "2026-05-25T12:09:00.292Z" },
 ]
 
 [[package]]
@@ -2439,13 +2143,26 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
 ]
 
+[[package]]
+name = "python-discovery"
+version = "1.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "filelock" },
+    { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" },
+]
+
 [[package]]
 name = "python-dotenv"
-version = "1.2.1"
+version = "1.2.2"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
 ]
 
 [[package]]
@@ -2493,36 +2210,31 @@ all = [
 
 [[package]]
 name = "pytokens"
-version = "0.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e5/16/4b9cfd90d55e66ffdb277d7ebe3bc25250c2311336ec3fc73b2673c794d5/pytokens-0.4.0.tar.gz", hash = "sha256:6b0b03e6ea7c9f9d47c5c61164b69ad30f4f0d70a5d9fe7eac4d19f24f77af2d", size = 15039, upload-time = "2026-01-19T07:59:50.623Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/b4/05/3196399a353dd4cd99138a88f662810979ee2f1a1cdb0b417cb2f4507836/pytokens-0.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:92eb3ef88f27c22dc9dbab966ace4d61f6826e02ba04dac8e2d65ea31df56c8e", size = 160075, upload-time = "2026-01-19T07:59:00.316Z" },
-    { url = "https://files.pythonhosted.org/packages/28/1d/c8fc4ed0a1c4f660391b201cda00b1d5bbcc00e2998e8bcd48b15eefd708/pytokens-0.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4b77858a680635ee9904306f54b0ee4781effb89e211ba0a773d76539537165", size = 247318, upload-time = "2026-01-19T07:59:01.636Z" },
-    { url = "https://files.pythonhosted.org/packages/8e/0e/53e55ba01f3e858d229cd84b02481542f42ba59050483a78bf2447ee1af7/pytokens-0.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25cacc20c2ad90acb56f3739d87905473c54ca1fa5967ffcd675463fe965865e", size = 259752, upload-time = "2026-01-19T07:59:04.229Z" },
-    { url = "https://files.pythonhosted.org/packages/dc/56/2d930d7f899e3f21868ca6e8ec739ac31e8fc532f66e09cbe45d3df0a84f/pytokens-0.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:628fab535ebc9079e4db35cd63cb401901c7ce8720a9834f9ad44b9eb4e0f1d4", size = 262842, upload-time = "2026-01-19T07:59:06.14Z" },
-    { url = "https://files.pythonhosted.org/packages/42/dd/4e7e6920d23deffaf66e6f40d45f7610dcbc132ca5d90ab4faccef22f624/pytokens-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:4d0f568d7e82b7e96be56d03b5081de40e43c904eb6492bf09aaca47cd55f35b", size = 102620, upload-time = "2026-01-19T07:59:07.839Z" },
-    { url = "https://files.pythonhosted.org/packages/3d/65/65460ebbfefd0bc1b160457904370d44f269e6e4582e0a9b6cba7c267b04/pytokens-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd8da894e5a29ba6b6da8be06a4f7589d7220c099b5e363cb0643234b9b38c2a", size = 159864, upload-time = "2026-01-19T07:59:08.908Z" },
-    { url = "https://files.pythonhosted.org/packages/25/70/a46669ec55876c392036b4da9808b5c3b1c5870bbca3d4cc923bf68bdbc1/pytokens-0.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:237ba7cfb677dbd3b01b09860810aceb448871150566b93cd24501d5734a04b1", size = 254448, upload-time = "2026-01-19T07:59:10.594Z" },
-    { url = "https://files.pythonhosted.org/packages/62/0b/c486fc61299c2fc3b7f88ee4e115d4c8b6ffd1a7f88dc94b398b5b1bc4b8/pytokens-0.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d1a61e36812e4e971cfe2c0e4c1f2d66d8311031dac8bf168af8a249fa04dd", size = 268863, upload-time = "2026-01-19T07:59:12.31Z" },
-    { url = "https://files.pythonhosted.org/packages/79/92/b036af846707d25feaff7cafbd5280f1bd6a1034c16bb06a7c910209c1ab/pytokens-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47e2ef3ec6ee86909e520d79f965f9b23389fda47460303cf715d510a6fe544", size = 267181, upload-time = "2026-01-19T07:59:13.856Z" },
-    { url = "https://files.pythonhosted.org/packages/0d/c0/6d011fc00fefa74ce34816c84a923d2dd7c46b8dbc6ee52d13419786834c/pytokens-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d36954aba4557fd5a418a03cf595ecbb1cdcce119f91a49b19ef09d691a22ae", size = 102814, upload-time = "2026-01-19T07:59:15.288Z" },
-    { url = "https://files.pythonhosted.org/packages/98/63/627b7e71d557383da5a97f473ad50f8d9c2c1f55c7d3c2531a120c796f6e/pytokens-0.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73eff3bdd8ad08da679867992782568db0529b887bed4c85694f84cdf35eafc6", size = 159744, upload-time = "2026-01-19T07:59:16.88Z" },
-    { url = "https://files.pythonhosted.org/packages/28/d7/16f434c37ec3824eba6bcb6e798e5381a8dc83af7a1eda0f95c16fe3ade5/pytokens-0.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d97cc1f91b1a8e8ebccf31c367f28225699bea26592df27141deade771ed0afb", size = 253207, upload-time = "2026-01-19T07:59:18.069Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/96/04102856b9527701ae57d74a6393d1aca5bad18a1b1ca48ccffb3c93b392/pytokens-0.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c8952c537cb73a1a74369501a83b7f9d208c3cf92c41dd88a17814e68d48ce", size = 267452, upload-time = "2026-01-19T07:59:19.328Z" },
-    { url = "https://files.pythonhosted.org/packages/0e/ef/0936eb472b89ab2d2c2c24bb81c50417e803fa89c731930d9fb01176fe9f/pytokens-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5dbf56f3c748aed9310b310d5b8b14e2c96d3ad682ad5a943f381bdbbdddf753", size = 265965, upload-time = "2026-01-19T07:59:20.613Z" },
-    { url = "https://files.pythonhosted.org/packages/ae/f5/64f3d6f7df4a9e92ebda35ee85061f6260e16eac82df9396020eebbca775/pytokens-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:e131804513597f2dff2b18f9911d9b6276e21ef3699abeffc1c087c65a3d975e", size = 102813, upload-time = "2026-01-19T07:59:22.012Z" },
-    { url = "https://files.pythonhosted.org/packages/5f/f1/d07e6209f18ef378fc2ae9dee8d1dfe91fd2447c2e2dbfa32867b6dd30cf/pytokens-0.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0d7374c917197106d3c4761374718bc55ea2e9ac0fb94171588ef5840ee1f016", size = 159968, upload-time = "2026-01-19T07:59:23.07Z" },
-    { url = "https://files.pythonhosted.org/packages/0a/73/0eb111400abd382a04f253b269819db9fcc748aa40748441cebdcb6d068f/pytokens-0.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cd3fa1caf9e47a72ee134a29ca6b5bea84712724bba165d6628baa190c6ea5b", size = 253373, upload-time = "2026-01-19T07:59:24.381Z" },
-    { url = "https://files.pythonhosted.org/packages/bd/8d/9e4e2fdb5bcaba679e54afcc304e9f13f488eb4d626e6b613f9553e03dbd/pytokens-0.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c6986576b7b07fe9791854caa5347923005a80b079d45b63b0be70d50cce5f1", size = 267024, upload-time = "2026-01-19T07:59:25.74Z" },
-    { url = "https://files.pythonhosted.org/packages/cb/b7/e0a370321af2deb772cff14ff337e1140d1eac2c29a8876bfee995f486f0/pytokens-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9940f7c2e2f54fb1cb5fe17d0803c54da7a2bf62222704eb4217433664a186a7", size = 270912, upload-time = "2026-01-19T07:59:27.072Z" },
-    { url = "https://files.pythonhosted.org/packages/7c/54/4348f916c440d4c3e68b53b4ed0e66b292d119e799fa07afa159566dcc86/pytokens-0.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:54691cf8f299e7efabcc25adb4ce715d3cef1491e1c930eaf555182f898ef66a", size = 103836, upload-time = "2026-01-19T07:59:28.112Z" },
-    { url = "https://files.pythonhosted.org/packages/e8/f8/a693c0cfa9c783a2a8c4500b7b2a8bab420f8ca4f2d496153226bf1c12e3/pytokens-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94ff5db97a0d3cd7248a5b07ba2167bd3edc1db92f76c6db00137bbaf068ddf8", size = 167643, upload-time = "2026-01-19T07:59:29.292Z" },
-    { url = "https://files.pythonhosted.org/packages/c0/dd/a64eb1e9f3ec277b69b33ef1b40ffbcc8f0a3bafcde120997efc7bdefebf/pytokens-0.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dd6261cd9cc95fae1227b1b6ebee023a5fd4a4b6330b071c73a516f5f59b63", size = 289553, upload-time = "2026-01-19T07:59:30.537Z" },
-    { url = "https://files.pythonhosted.org/packages/df/22/06c1079d93dbc3bca5d013e1795f3d8b9ed6c87290acd6913c1c526a6bb2/pytokens-0.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdca8159df407dbd669145af4171a0d967006e0be25f3b520896bc7068f02c4", size = 302490, upload-time = "2026-01-19T07:59:32.352Z" },
-    { url = "https://files.pythonhosted.org/packages/8d/de/a6f5e43115b4fbf4b93aa87d6c83c79932cdb084f9711daae04549e1e4ad/pytokens-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4b5770abeb2a24347380a1164a558f0ebe06e98aedbd54c45f7929527a5fb26e", size = 305652, upload-time = "2026-01-19T07:59:33.685Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/3d/c136e057cb622e36e0c3ff7a8aaa19ff9720050c4078235691da885fe6ee/pytokens-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:74500d72c561dad14c037a9e86a657afd63e277dd5a3bb7570932ab7a3b12551", size = 115472, upload-time = "2026-01-19T07:59:34.734Z" },
-    { url = "https://files.pythonhosted.org/packages/7c/3c/6941a82f4f130af6e1c68c076b6789069ef10c04559bd4733650f902fd3b/pytokens-0.4.0-py3-none-any.whl", hash = "sha256:0508d11b4de157ee12063901603be87fb0253e8f4cb9305eb168b1202ab92068", size = 13224, upload-time = "2026-01-19T07:59:49.822Z" },
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" },
+    { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
+    { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
+    { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
+    { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
+    { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
+    { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
+    { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
+    { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
+    { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
 ]
 
 [[package]]
@@ -2548,15 +2260,6 @@ version = "6.0.3"
 source = { registry = "https://pypi.org/simple" }
 sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
-    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
-    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
-    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
-    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
-    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
-    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
-    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
-    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
     { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
     { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
     { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
@@ -2618,16 +2321,6 @@ dependencies = [
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" },
-    { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" },
-    { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" },
-    { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" },
-    { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" },
-    { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" },
-    { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" },
-    { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" },
-    { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" },
     { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
     { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
     { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
@@ -2660,130 +2353,101 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" },
     { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" },
     { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
-    { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" },
-    { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" },
-    { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" },
-    { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" },
-    { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" },
 ]
 
 [[package]]
 name = "rapidfuzz"
-version = "3.14.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/76/25/5b0a33ad3332ee1213068c66f7c14e9e221be90bab434f0cb4defa9d6660/rapidfuzz-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea2d113e260a5da0c4003e0a5e9fdf24a9dc2bb9eaa43abd030a1e46ce7837d", size = 1953885, upload-time = "2025-11-01T11:52:47.75Z" },
-    { url = "https://files.pythonhosted.org/packages/2d/ab/f1181f500c32c8fcf7c966f5920c7e56b9b1d03193386d19c956505c312d/rapidfuzz-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6c31a4aa68cfa75d7eede8b0ed24b9e458447db604c2db53f358be9843d81d3", size = 1390200, upload-time = "2025-11-01T11:52:49.491Z" },
-    { url = "https://files.pythonhosted.org/packages/14/2a/0f2de974ececad873865c6bb3ea3ad07c976ac293d5025b2d73325aac1d4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02821366d928e68ddcb567fed8723dad7ea3a979fada6283e6914d5858674850", size = 1389319, upload-time = "2025-11-01T11:52:51.224Z" },
-    { url = "https://files.pythonhosted.org/packages/ed/69/309d8f3a0bb3031fd9b667174cc4af56000645298af7c2931be5c3d14bb4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe8df315ab4e6db4e1be72c5170f8e66021acde22cd2f9d04d2058a9fd8162e", size = 3178495, upload-time = "2025-11-01T11:52:53.005Z" },
-    { url = "https://files.pythonhosted.org/packages/10/b7/f9c44a99269ea5bf6fd6a40b84e858414b6e241288b9f2b74af470d222b1/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:769f31c60cd79420188fcdb3c823227fc4a6deb35cafec9d14045c7f6743acae", size = 1228443, upload-time = "2025-11-01T11:52:54.991Z" },
-    { url = "https://files.pythonhosted.org/packages/f2/0a/3b3137abac7f19c9220e14cd7ce993e35071a7655e7ef697785a3edfea1a/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54fa03062124e73086dae66a3451c553c1e20a39c077fd704dc7154092c34c63", size = 2411998, upload-time = "2025-11-01T11:52:56.629Z" },
-    { url = "https://files.pythonhosted.org/packages/f3/b6/983805a844d44670eaae63831024cdc97ada4e9c62abc6b20703e81e7f9b/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:834d1e818005ed0d4ae38f6b87b86fad9b0a74085467ece0727d20e15077c094", size = 2530120, upload-time = "2025-11-01T11:52:58.298Z" },
-    { url = "https://files.pythonhosted.org/packages/b4/cc/2c97beb2b1be2d7595d805682472f1b1b844111027d5ad89b65e16bdbaaa/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:948b00e8476a91f510dd1ec07272efc7d78c275d83b630455559671d4e33b678", size = 4283129, upload-time = "2025-11-01T11:53:00.188Z" },
-    { url = "https://files.pythonhosted.org/packages/4d/03/2f0e5e94941045aefe7eafab72320e61285c07b752df9884ce88d6b8b835/rapidfuzz-3.14.3-cp311-cp311-win32.whl", hash = "sha256:43d0305c36f504232f18ea04e55f2059bb89f169d3119c4ea96a0e15b59e2a91", size = 1724224, upload-time = "2025-11-01T11:53:02.149Z" },
-    { url = "https://files.pythonhosted.org/packages/cf/99/5fa23e204435803875daefda73fd61baeabc3c36b8fc0e34c1705aab8c7b/rapidfuzz-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:ef6bf930b947bd0735c550683939a032090f1d688dfd8861d6b45307b96fd5c5", size = 1544259, upload-time = "2025-11-01T11:53:03.66Z" },
-    { url = "https://files.pythonhosted.org/packages/48/35/d657b85fcc615a42661b98ac90ce8e95bd32af474603a105643963749886/rapidfuzz-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:f3eb0ff3b75d6fdccd40b55e7414bb859a1cda77c52762c9c82b85569f5088e7", size = 814734, upload-time = "2025-11-01T11:53:05.008Z" },
-    { url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306, upload-time = "2025-11-01T11:53:06.452Z" },
-    { url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788, upload-time = "2025-11-01T11:53:08.721Z" },
-    { url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580, upload-time = "2025-11-01T11:53:10.164Z" },
-    { url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947, upload-time = "2025-11-01T11:53:12.093Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872, upload-time = "2025-11-01T11:53:13.664Z" },
-    { url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512, upload-time = "2025-11-01T11:53:15.109Z" },
-    { url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398, upload-time = "2025-11-01T11:53:17.146Z" },
-    { url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416, upload-time = "2025-11-01T11:53:19.34Z" },
-    { url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527, upload-time = "2025-11-01T11:53:20.949Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989, upload-time = "2025-11-01T11:53:22.428Z" },
-    { url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161, upload-time = "2025-11-01T11:53:23.811Z" },
-    { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" },
-    { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" },
-    { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" },
-    { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" },
-    { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" },
-    { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" },
-    { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" },
-    { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" },
-    { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" },
-    { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" },
-    { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" },
-    { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" },
-    { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" },
-    { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" },
-    { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" },
-    { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" },
-    { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" },
-    { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" },
-    { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" },
-    { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" },
-    { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" },
-    { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" },
-    { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" },
-    { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" },
-    { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" },
-    { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" },
-    { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" },
-    { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" },
-    { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" },
-    { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" },
-    { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" },
-    { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" },
-    { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" },
-    { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" },
-    { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" },
-    { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" },
-    { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" },
-    { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" },
-    { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" },
-    { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" },
-    { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" },
-    { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" },
-    { url = "https://files.pythonhosted.org/packages/c9/33/b5bd6475c7c27164b5becc9b0e3eb978f1e3640fea590dd3dced6006ee83/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7cf174b52cb3ef5d49e45d0a1133b7e7d0ecf770ed01f97ae9962c5c91d97d23", size = 1888499, upload-time = "2025-11-01T11:54:42.094Z" },
-    { url = "https://files.pythonhosted.org/packages/30/d2/89d65d4db4bb931beade9121bc71ad916b5fa9396e807d11b33731494e8e/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:442cba39957a008dfc5bdef21a9c3f4379e30ffb4e41b8555dbaf4887eca9300", size = 1336747, upload-time = "2025-11-01T11:54:43.957Z" },
-    { url = "https://files.pythonhosted.org/packages/85/33/cd87d92b23f0b06e8914a61cea6850c6d495ca027f669fab7a379041827a/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1faa0f8f76ba75fd7b142c984947c280ef6558b5067af2ae9b8729b0a0f99ede", size = 1352187, upload-time = "2025-11-01T11:54:45.518Z" },
-    { url = "https://files.pythonhosted.org/packages/22/20/9d30b4a1ab26aac22fff17d21dec7e9089ccddfe25151d0a8bb57001dc3d/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e6eefec45625c634926a9fd46c9e4f31118ac8f3156fff9494422cee45207e6", size = 3101472, upload-time = "2025-11-01T11:54:47.255Z" },
-    { url = "https://files.pythonhosted.org/packages/b1/ad/fa2d3e5c29a04ead7eaa731c7cd1f30f9ec3c77b3a578fdf90280797cbcb/rapidfuzz-3.14.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56fefb4382bb12250f164250240b9dd7772e41c5c8ae976fd598a32292449cc5", size = 1511361, upload-time = "2025-11-01T11:54:49.057Z" },
+version = "3.14.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" },
+    { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" },
+    { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" },
+    { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" },
+    { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" },
+    { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" },
+    { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" },
+    { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" },
+    { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" },
+    { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" },
+    { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" },
+    { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" },
+    { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" },
+    { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" },
+    { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" },
+    { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" },
+    { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" },
+    { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" },
+    { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" },
+    { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" },
+    { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" },
+    { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" },
+    { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" },
+    { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" },
+    { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" },
+    { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" },
 ]
 
 [[package]]
 name = "rectangle-packer"
-version = "2.0.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c9/06/84541ab4e69447fdcaed9e899a53a512806b7bd7d805cb30ef8f7205059c/rectangle_packer-2.0.5.tar.gz", hash = "sha256:389f5d24af0d61daae0dac3f0493f9aff032557268ad88335f194c31c3f5dcc6", size = 99586, upload-time = "2025-11-07T18:54:25.388Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/60/24/66b8359b520e594b192d7fa2776ab1e2aab825cb1e9cf0a6b8bdecbf8cf5/rectangle_packer-2.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2d60d7cfddcf42cb81688b14cb6738a1f7af644bcb78a6298438778ba5a7e2e3", size = 67080, upload-time = "2025-11-07T18:53:29.817Z" },
-    { url = "https://files.pythonhosted.org/packages/9e/0d/2029083c9c7e9d3c72e2bcc05a6e6a3bf4736da135c6a561078ce95fc504/rectangle_packer-2.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:627c4e80f034dd42fa34a1975076817267c904b02223fb9e9c97765e47e3ea10", size = 64940, upload-time = "2025-11-07T18:53:30.881Z" },
-    { url = "https://files.pythonhosted.org/packages/ca/29/cc5e8b666a04aa1bbdfc2cda4c1769ed0db18ec41a736228e678626218b1/rectangle_packer-2.0.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04029f2c3c72634260be98a66d150309811ca355d1fe1093f00e53e9396148ff", size = 350391, upload-time = "2025-11-07T18:53:32.11Z" },
-    { url = "https://files.pythonhosted.org/packages/cf/e7/53dccdb5c544df09b922ed69aae3fee66cb723fc3b711f0d48c8e0d02bc0/rectangle_packer-2.0.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2ce117f2d2f08e98d4881dc46409845b6fcc198663deb76dace5dfafb1405fe", size = 350185, upload-time = "2025-11-07T18:53:33.325Z" },
-    { url = "https://files.pythonhosted.org/packages/a4/ae/348226ca21f65f2e43aca197e5895b1ce2534fcb0a4d72c66403c909fdba/rectangle_packer-2.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7084ffd50b7906c61df6ed13d5d0ca5bb281d574163dc35075f53d1bcdeeab1b", size = 344772, upload-time = "2025-11-07T18:53:34.552Z" },
-    { url = "https://files.pythonhosted.org/packages/df/89/46d5e5600db1d44634d9bb0c2486a404444d6b94eb147f868b51d82da8a3/rectangle_packer-2.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da940be6ed89ec67a3c7d5a7b44d885527756b77c7a01e3ee1c3b4b38098ac4d", size = 350609, upload-time = "2025-11-07T18:53:36.256Z" },
-    { url = "https://files.pythonhosted.org/packages/3f/d1/b663b6305d30aacf12c1275efc719ed634a553ec11647353e93c574e7951/rectangle_packer-2.0.5-cp311-cp311-win32.whl", hash = "sha256:0b0ab38a4c25182f20f381254586080064b092227531a4d61f7683a66d40a31d", size = 59595, upload-time = "2025-11-07T18:53:37.79Z" },
-    { url = "https://files.pythonhosted.org/packages/6d/74/db3ca7cb201e0422cc488b2e6aeeefd7955c12c39211bd3fff43415195c6/rectangle_packer-2.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:14e3e23d15bfc1b7b68e8aec66df305a27b9d085f9878368cbc9db9de468864f", size = 67165, upload-time = "2025-11-07T18:53:39.527Z" },
-    { url = "https://files.pythonhosted.org/packages/96/06/fd38301926a3e708f87d858dff8cb7ff322b820b5899e1bcdb9d8cf5548e/rectangle_packer-2.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:86b15e7b1c6772d950be7b97176a3dbec9ab01488060db3d60750816faac7058", size = 68073, upload-time = "2025-11-07T18:53:41.104Z" },
-    { url = "https://files.pythonhosted.org/packages/c5/37/335b0d63d1d37af532b6f05df182fe5a864ba1e1ebb5b8f2b8a005b82066/rectangle_packer-2.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5f55903601b01d781ef935d34e19028984d88cf36de9857d3bf61e385b61cc6", size = 65008, upload-time = "2025-11-07T18:53:42.206Z" },
-    { url = "https://files.pythonhosted.org/packages/88/5b/6f4b0772d6b648c03420bad32cbdd354799816e84d0961c6f5435036e594/rectangle_packer-2.0.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7083c4cbc90c5cac58673bfa51edf39e870eb582814c0dee9e96052a706d9a18", size = 366616, upload-time = "2025-11-07T18:53:43.36Z" },
-    { url = "https://files.pythonhosted.org/packages/05/dc/51b905c90167b25264067ef405a2831b6d2841cbbd26ea5414c2f56d8a46/rectangle_packer-2.0.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3035e87d6ed1dfad024130ed0ab8deb5fec414855d8335e93e633d56877ec74", size = 364521, upload-time = "2025-11-07T18:53:44.664Z" },
-    { url = "https://files.pythonhosted.org/packages/3f/39/92e188892209c760507d1a10fe702ef4fbc1bf679aacd282ed7f8824ad2a/rectangle_packer-2.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e8503b0e3ec25b3fb5c595c3dcf959520b91efbf32cc934cc477144458f90e01", size = 355986, upload-time = "2025-11-07T18:53:46.002Z" },
-    { url = "https://files.pythonhosted.org/packages/15/b4/2fa533ce8ec709d1a76121f273ab9ec5d75d37ecdac4f7df4ac975c8b7fd/rectangle_packer-2.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0807deb90029b36d97c0f2b72142a06847b089d9db40dfe69c8014a8fb9ed27b", size = 365488, upload-time = "2025-11-07T18:53:47.351Z" },
-    { url = "https://files.pythonhosted.org/packages/92/67/16b9c96ce86ba1b7de098ffb36673ece16b46436a7f4972525b3ad225a5b/rectangle_packer-2.0.5-cp312-cp312-win32.whl", hash = "sha256:c3b3a89b141f75d1b753b9a49872160e9ad7ffe5c4ca52f822d48fd3b3179d14", size = 59748, upload-time = "2025-11-07T18:53:48.582Z" },
-    { url = "https://files.pythonhosted.org/packages/9d/c8/77fd2b29df75352e2b93009b2bb855a2d44aa1e1ee854863627738c940ec/rectangle_packer-2.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d7f748e33ca295b56a4e6160f6cb1a0cb4a64b3867003285ad9b59d64edb49eb", size = 67455, upload-time = "2025-11-07T18:53:50.123Z" },
-    { url = "https://files.pythonhosted.org/packages/22/23/7c397073b6f71f68935f34dcb68170483138fa5db68d0e618701130653ed/rectangle_packer-2.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8f957382b5934e260f76ffdc568356e13811517d6ba864c1ca634579eec5c76", size = 67363, upload-time = "2025-11-07T18:53:51.634Z" },
-    { url = "https://files.pythonhosted.org/packages/4c/2f/a2f2202c2e2777b7d2b4a93966c0050f43d5035567eda977f0710d0051db/rectangle_packer-2.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8dca65a12abb9e20f7eb7a3b796d55764fa426b8b109cf327f03464480041e69", size = 64318, upload-time = "2025-11-07T18:53:52.687Z" },
-    { url = "https://files.pythonhosted.org/packages/2a/06/30f0dba218cf1068fb64cfe759613ca1dd332b2dd89e6cd59c950f609574/rectangle_packer-2.0.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e9ee3ede924846feee20ee1afd8d81e93a326b4e331adb1ed2b5b2aee57ce19", size = 365021, upload-time = "2025-11-07T18:53:53.908Z" },
-    { url = "https://files.pythonhosted.org/packages/ea/6f/848e6c0c58fcd45289891fd45c8d014bf3cb65829966a1f3201ea8fe9605/rectangle_packer-2.0.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81923c810d8495242541628ebdc630a4d729eb07edabd15bc1687a03abeea981", size = 362185, upload-time = "2025-11-07T18:53:55.848Z" },
-    { url = "https://files.pythonhosted.org/packages/68/76/a022ba41e2064cb60af009d312720f2311b4433a271221a6ec2b0ec22bf2/rectangle_packer-2.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975ac449573c716a49756263e796d30a748b6382e196d0b4c1fd6165b4e2077f", size = 354257, upload-time = "2025-11-07T18:53:57.737Z" },
-    { url = "https://files.pythonhosted.org/packages/63/c0/3e4cfcbe734aa9ca5ddc8fc1cd79d9c2279322dd34dda229c36df98204c0/rectangle_packer-2.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7fd9da901571a778e180664396428d70edbd026761e9822d4e466e4c2a03828d", size = 362737, upload-time = "2025-11-07T18:53:59.776Z" },
-    { url = "https://files.pythonhosted.org/packages/b0/ba/260f71fe9dfdce196f5bbc21b4b4086785462f67b8cd41a01ce81f115f89/rectangle_packer-2.0.5-cp313-cp313-win32.whl", hash = "sha256:cbd36aab29260a22f8ae694272f74179f8b672ed8224c4ca1d1fe05916f4ef53", size = 59524, upload-time = "2025-11-07T18:54:01.077Z" },
-    { url = "https://files.pythonhosted.org/packages/43/a2/1def9bd173295275fa198e5a266fbb260de33e375f5e1832b374ff8fa2a8/rectangle_packer-2.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:319201d2d41eca39077e7bc36429c141f81da15305c201e19de2731f70c88d02", size = 66859, upload-time = "2025-11-07T18:54:02.098Z" },
-    { url = "https://files.pythonhosted.org/packages/e7/4d/e43569e3901245c6ab854b89c86cd74b689325f57cb2199727b298617bd3/rectangle_packer-2.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ac452b7a274a3c3b0f32c5e8aa0c41f425abf6464b62451a69dc8aec63c9ed38", size = 67409, upload-time = "2025-11-07T18:54:03.125Z" },
-    { url = "https://files.pythonhosted.org/packages/a3/f8/fa746dd7606e5d0a12d94c1435721a54994247891f1153fecb1b6f8df0d5/rectangle_packer-2.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b1f32a96981820646ffc0e7c9d7328d0a0604aecee944d24795db06bd618bc8", size = 64366, upload-time = "2025-11-07T18:54:04.664Z" },
-    { url = "https://files.pythonhosted.org/packages/7d/1c/6a8fa25b55bb511124130c8fdf201d67139da44f92c80cf8200150dc9f16/rectangle_packer-2.0.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e32657aec44ca0a80e6cddc9979563b12a015930588f9faa72a8f433298a770c", size = 357721, upload-time = "2025-11-07T18:54:05.819Z" },
-    { url = "https://files.pythonhosted.org/packages/a6/44/8703d5e5d1bcbd939c1d5c4414c173152f0a54fee09414a6bde1ab40d259/rectangle_packer-2.0.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eda697c14674875b11b4ba374416079f98142f2e96ee79f547b0337c7f8f3f4c", size = 359405, upload-time = "2025-11-07T18:54:07.444Z" },
-    { url = "https://files.pythonhosted.org/packages/8c/1e/57f5ffed179244672f132c519fd62ef7871436e773dcdf36a736c823393b/rectangle_packer-2.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:67690dc2ef1cfb0535d05ac279d7a841f0d878fee05673c43020cc94586fa175", size = 352256, upload-time = "2025-11-07T18:54:08.672Z" },
-    { url = "https://files.pythonhosted.org/packages/ac/2e/53a13e31d237f722adb4fce0e466632fc49df029b70f846f92237baf4035/rectangle_packer-2.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9573169e05e0337ff4b9336bba75d17f77975545cf327c79c3b4f42c2cbb157b", size = 355615, upload-time = "2025-11-07T18:54:09.993Z" },
-    { url = "https://files.pythonhosted.org/packages/da/65/ea23ebc812e0d2f18e20a9158b07b856a8bf694330daabe437d7409037b8/rectangle_packer-2.0.5-cp314-cp314-win32.whl", hash = "sha256:32171ad7cf470fb5e8a34beb619280d7420b3253b4795c87698861848e9b351c", size = 61187, upload-time = "2025-11-07T18:54:11.418Z" },
-    { url = "https://files.pythonhosted.org/packages/b5/12/d780d6a4e5d389b8168c6be10c0884645c3635bda440beb140c0d5ba381e/rectangle_packer-2.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:d8d7a82dad306d675b224e8c49ebe5a32be0e33d628e47f7528e13cfc719ef17", size = 68242, upload-time = "2025-11-07T18:54:12.98Z" },
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/42/ec/c3118f708b31080059f5dfff5d2ade4f8d94f7de240717dfa1142ea4daf9/rectangle_packer-2.1.0.tar.gz", hash = "sha256:64d18e4e2fb0bec05b3e6cf4f133a54677c3001061186dd93d8442568e3eaa17", size = 35770, upload-time = "2026-03-12T21:46:46.537Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8a/d4/78304378b215cb9e0772cb2bb21d13194b2b9881101102f599211bd2c72a/rectangle_packer-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a2c36bb36eccf981255f73cadbaf7bd8eb296867949eb5f61ac59ec0bfeb3459", size = 71841, upload-time = "2026-03-12T21:46:11.101Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/29/bb878f369a2dfdb2075f211d44e364b78efa9443ff0a15c7cda2399a0acc/rectangle_packer-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9016f1a5ffe90467838130091b6ff72d78d00d95dc3a7252fb31ccae6fb8c1", size = 67180, upload-time = "2026-03-12T21:46:12.579Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/f9/e39666bd4cedd422987542f7fac93432232d39dd936a719c581ca3a6fc40/rectangle_packer-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2e9611ca1477f9cf55d61703e970b6a2bd28d3afa45ee629e9a82985f2afd31", size = 364247, upload-time = "2026-03-12T21:46:13.823Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/fb/b39d311d90400166c73f91d2007704df0c08d8ee076f7ad63b2ef7e82c2d/rectangle_packer-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47eea3707f31052848c8f9c4193f51c6f87a3aa1644ba1f3667cc4442428225b", size = 372258, upload-time = "2026-03-12T21:46:15.506Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/28/68f6aefbe1da01464e60716f37d01818b746059a2e38b3bd11cdd3ba88e6/rectangle_packer-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7686e0eddfbb03cce22702ba69c3e94dce8979cbd16b2acce55eff1cf5276eae", size = 357832, upload-time = "2026-03-12T21:46:16.807Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/4d/7628915a61a6d2349f67129208869ecf0abfbb3c8c8638437d86c96d3d5d/rectangle_packer-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:071eeee77f0dbc453538a4f3433a2864db29ea01bfcf416e180d47ba7757a0ce", size = 371970, upload-time = "2026-03-12T21:46:18.28Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/7e/ca395a467374ef22f0e13899caf599610e73b10792007015ba234c0f3614/rectangle_packer-2.1.0-cp312-cp312-win32.whl", hash = "sha256:fb3c61fe6c320aa38e9dfc3985c79f954dbf10bb802040ac37b8f5a7b6e30080", size = 58657, upload-time = "2026-03-12T21:46:20.065Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/89/18355ba4e1981776248e7d8379c451a8aa7958b27481de2da26f1fc3ea00/rectangle_packer-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3effda08baf191a63177496850c7b8dc3d21d64be0c9300e203f265780944d0", size = 65247, upload-time = "2026-03-12T21:46:21.317Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/3d/e1f1404b415774caefa34fd5b7a437ab7c00356b4a00fdbbb370f496b92a/rectangle_packer-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd8c2895491d819cae871a7de7b055d6039aae6d73ef4813cf4f025fd915cde6", size = 71350, upload-time = "2026-03-12T21:46:22.836Z" },
+    { url = "https://files.pythonhosted.org/packages/05/fc/71591b07c377fc2985ecb74247ce93bddfac7040a81378460c52a787bbac/rectangle_packer-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43231eb8cf5cd18702781df46ca1cd4e7ecbb172ed45df4b3d671c2b55cf7081", size = 66526, upload-time = "2026-03-12T21:46:24.283Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/33/521896208cc5b70a717583dc8810463643584a633f68f7f1a076b2f00eef/rectangle_packer-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2fff55731d2b2a36d943d40d1063d9c34547cfd51c1d154ddb3fec678d2986", size = 362506, upload-time = "2026-03-12T21:46:25.555Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/53/80dcf25177a9807489c3d036c5ebfad4f87cb8618cf860041920d71b6a41/rectangle_packer-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39df7066f129dec0afa14d2916cd1d035e763f0bf07e6fc97e247f53e7d7102b", size = 369220, upload-time = "2026-03-12T21:46:27.286Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/50/a808fa2a5078411faa6ea3cd55cee6477d74c33b5cadb72b7f9d376b2160/rectangle_packer-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eab3febfb8f32fd7217893d62c388edb7a1d118f3c603269350d9baabbe6c58b", size = 355053, upload-time = "2026-03-12T21:46:28.798Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/a0/8b376ef58e72122616abde74c0ca7bf930ec1b207ad763f51bb9dcd22382/rectangle_packer-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07a8e3a0710b02f08a5c62a7139efd35fa37ad694429cfad00a3e25173c5ad56", size = 369184, upload-time = "2026-03-12T21:46:30.306Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/a8/3e94246a376aa723f2825a40ea0d9a9c6bdc74ca50a9e52e38178baf5ac4/rectangle_packer-2.1.0-cp313-cp313-win32.whl", hash = "sha256:d4de9ee025073bb1565f5b97bb663ca2e87ceb21092bae512cee4f1b800e5503", size = 58455, upload-time = "2026-03-12T21:46:32.292Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/51/4df51c4739ab3a303dd96f4df4005006c35d520a9d48625b2c39c3c316fc/rectangle_packer-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c7c4762b7ef76bf87e06311d9afc222618b9d38d9419c53c7f9bed260fad424", size = 64768, upload-time = "2026-03-12T21:46:33.568Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/93/4bde124836642259489a948ac0308c42df604a2603315a72cf86d2034902/rectangle_packer-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:75215390af195d0cea3dec643a2523c41a782c31ef33955c5909d755ba6fd22a", size = 71309, upload-time = "2026-03-12T21:46:34.958Z" },
+    { url = "https://files.pythonhosted.org/packages/22/f3/95d7f2f10b079a6b4b0dd12d71761623946d5235f8ceaad0465c53f0ac7a/rectangle_packer-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f8cff43b198ef068c1eec366bf7d361d378e52f58fc1a333c6a9618b345d9a08", size = 66650, upload-time = "2026-03-12T21:46:36.16Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/49/54e8d4818d09f58f855aa4de2a25521ce5196257b8a333d4bc028c6242e5/rectangle_packer-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5536c5ae2de482d6c49a06b96837ab25678a93ee7bdfc8c2f9e0390e81eaa0ff", size = 360035, upload-time = "2026-03-12T21:46:37.65Z" },
+    { url = "https://files.pythonhosted.org/packages/07/74/0003a88f7f7ca0274c463a43ca18d28a663fb4f7ee0cd3d0a674a78606de/rectangle_packer-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:beb7f9e0580961064f058fc191b47e8642dba66c83004e2ad7c6c3129e1af19c", size = 363362, upload-time = "2026-03-12T21:46:39.047Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/43/4a7dc02ce687fc36f284d786aa814e8878c06522b030d2d856aaadfc89ba/rectangle_packer-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ebf68bbdf32167f9c3d331400ed90f5a8213bb3f56d864f7a36247c335d8c6a0", size = 354149, upload-time = "2026-03-12T21:46:40.59Z" },
+    { url = "https://files.pythonhosted.org/packages/67/ce/e64f687eff3f818334d71a44b0fba8bcca24e81551f102626dd458ea5e1b/rectangle_packer-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b3733ee0c4c2a175558126e93441233744d6c1fa32eb7f22db6fb35046485bcd", size = 363006, upload-time = "2026-03-12T21:46:42.445Z" },
+    { url = "https://files.pythonhosted.org/packages/55/5d/1db0767464be8567e23d86f5589fbfd29c7db22b8dfac6f1672b250eb7d8/rectangle_packer-2.1.0-cp314-cp314-win32.whl", hash = "sha256:e56e3d64b6537911bd46a3b3712e84cd21f77b8f917343d5fca85a9cce63d4c3", size = 59647, upload-time = "2026-03-12T21:46:44.291Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/98/b46ac7a0612c55232df46577bb2a29ae3db3e97fa239643990ab96eb7718/rectangle_packer-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:ef26eb0b0ab42084e812a4a8094e1f7b36354b2982425925f0d82e3c6e2a3ffe", size = 66116, upload-time = "2026-03-12T21:46:45.428Z" },
 ]
 
 [[package]]
@@ -2802,7 +2466,7 @@ wheels = [
 
 [[package]]
 name = "requests"
-version = "2.32.5"
+version = "2.34.2"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "certifi" },
@@ -2810,22 +2474,22 @@ dependencies = [
     { name = "idna" },
     { name = "urllib3" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
 ]
 
 [[package]]
 name = "rich"
-version = "14.2.0"
+version = "15.0.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "markdown-it-py" },
     { name = "pygments" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
+    { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
 ]
 
 [[package]]
@@ -2842,110 +2506,112 @@ wheels = [
 
 [[package]]
 name = "rpds-py"
-version = "0.30.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" },
-    { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" },
-    { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" },
-    { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" },
-    { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" },
-    { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" },
-    { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" },
-    { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" },
-    { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" },
-    { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" },
-    { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" },
-    { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" },
-    { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" },
-    { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" },
-    { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
-    { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
-    { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
-    { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
-    { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
-    { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
-    { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
-    { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
-    { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
-    { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
-    { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
-    { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
-    { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
-    { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
-    { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
-    { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
-    { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
-    { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
-    { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
-    { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
-    { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
-    { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
-    { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
-    { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
-    { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
-    { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
-    { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
-    { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
-    { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
-    { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
-    { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
-    { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
-    { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
-    { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
-    { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
-    { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
-    { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
-    { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
-    { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
-    { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
-    { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
-    { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
-    { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
-    { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
-    { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
-    { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
-    { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
-    { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
-    { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
-    { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
-    { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
-    { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
-    { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
-    { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
-    { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
-    { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
-    { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
-    { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
-    { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
-    { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
-    { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
-    { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
-    { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
-    { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
-    { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
-    { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
-    { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
-    { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
-    { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
-    { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
-    { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" },
-    { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" },
-    { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" },
-    { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" },
-    { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" },
-    { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" },
-    { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" },
-    { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" },
-    { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" },
-    { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
+version = "2026.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" },
+    { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" },
+    { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" },
+    { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" },
+    { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" },
+    { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" },
+    { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" },
+    { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" },
+    { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" },
+    { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" },
+    { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" },
+    { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" },
+    { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" },
+    { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" },
+    { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" },
+    { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" },
+    { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" },
+    { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" },
+    { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" },
+    { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" },
+    { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" },
+    { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" },
+    { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" },
+    { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" },
+    { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" },
+    { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" },
+    { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" },
+    { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" },
+    { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" },
+    { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" },
+    { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" },
+    { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" },
+    { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" },
+    { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" },
+    { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" },
+    { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" },
+    { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" },
+    { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" },
+    { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" },
+    { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" },
+    { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" },
+    { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" },
 ]
 
 [[package]]
@@ -2971,50 +2637,51 @@ wheels = [
 
 [[package]]
 name = "ruff"
-version = "0.14.13"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" },
-    { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" },
-    { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" },
-    { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" },
-    { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" },
-    { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" },
-    { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" },
-    { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" },
-    { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" },
-    { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" },
-    { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" },
-    { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" },
-    { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" },
-    { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" },
-    { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" },
-    { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" },
-    { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" },
+version = "0.15.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
+    { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
+    { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
+    { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
+    { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
+    { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
+    { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
+    { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
 ]
 
 [[package]]
 name = "rust-just"
-version = "1.46.0"
+version = "1.51.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/26/50/3828817f76e19977a4048c2c8b39a7f48babc21dd9dbed4af2f3c18d4570/rust_just-1.46.0.tar.gz", hash = "sha256:84437481c814577529835132e2cc5fcc35a981c1712e4877cb20fc2f5ec5b2d6", size = 1447346, upload-time = "2026-01-03T02:03:17.948Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4b/97/b3fcff8f582fa7941bb0a1675f0230f84134423dd6948f589c1fce176b08/rust_just-1.51.0.tar.gz", hash = "sha256:b05f9c3d1bf32b4a2297514c1e03a82377cf51cd8fbb39a7c40f6be2b3bbbf31", size = 1918349, upload-time = "2026-05-11T04:14:13.103Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/53/0b/a5bf2707b02a484d91f8275efa39f76fe19304f5bfba82293fa4b18608d2/rust_just-1.46.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7d6d4c67a443f1acb1f78f9ba4b3349fa04f17e8be2d4448b771cdc93a382812", size = 1739556, upload-time = "2026-01-03T02:02:42.835Z" },
-    { url = "https://files.pythonhosted.org/packages/3a/ae/40bcd996ccb2fcb0152b5bfde7beaf3840877a8837611421c495b45c82da/rust_just-1.46.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0caf9b77d30455558d017c9e625ce94c373f88d81656477127727604fa5d36ab", size = 1620974, upload-time = "2026-01-03T02:02:45.341Z" },
-    { url = "https://files.pythonhosted.org/packages/62/36/7067e0eaf674ed7c98b35ed50d713c0c885f2d2b57847a627e11502da1b8/rust_just-1.46.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b63521acd91c65164c202ded3ae730130c7fb4377f59cd2f9847b45161c94fd", size = 1703423, upload-time = "2026-01-03T02:02:47.681Z" },
-    { url = "https://files.pythonhosted.org/packages/dd/47/3e98182f5e03c48880d647651385863552a3e24cfec5c51d116c06e6f180/rust_just-1.46.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46a35110c7acf27bdded79cf3bbfea9eb80a53f6f81f374248fe3340584c92e5", size = 1666645, upload-time = "2026-01-03T02:02:50.38Z" },
-    { url = "https://files.pythonhosted.org/packages/95/5e/b9badf6e6982e5744f076d12ab911e5ac8b4b03a0674bab4f498ed9d0b4c/rust_just-1.46.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a2407cefc2e5ed4527297747bd5bcb61a885776021cb2438c3a7b118b2cabc2", size = 1847430, upload-time = "2026-01-03T02:02:52.716Z" },
-    { url = "https://files.pythonhosted.org/packages/98/10/6916d7c862b99de600a1fd3739d13353c220dfbc0229a0b2c5012c2f801d/rust_just-1.46.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0dab319619f600561b993242312a344953b1ea44637b30257af905a70ce6f568", size = 1926224, upload-time = "2026-01-03T02:02:55.194Z" },
-    { url = "https://files.pythonhosted.org/packages/24/93/18bc615e68a80f43105d5e7cc3571e85776aec829ac40faae4de5d5dc2f3/rust_just-1.46.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3b4c26dd86e5d96047fc0935967f22cb9f49c687767d78b7d3fe511eba39ffa", size = 1902165, upload-time = "2026-01-03T02:02:57.636Z" },
-    { url = "https://files.pythonhosted.org/packages/2c/1e/e3c19a24ff64e78a04df0bdf4c61e15c28dcac8b7b5c3a5505eb5749d40a/rust_just-1.46.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acdb496ca26efc508be0e625309b74b1f6316b4f7295d13247c3b791dfa77eb1", size = 1835209, upload-time = "2026-01-03T02:03:00.256Z" },
-    { url = "https://files.pythonhosted.org/packages/26/75/0850c38e41025794826165329a097f657152902a785c0579f213b7d61ae6/rust_just-1.46.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3cf1c32a258f8ee44af877ec271e2eea257923a3303a6d2610b0b5f1523daaab", size = 1719519, upload-time = "2026-01-03T02:03:02.774Z" },
-    { url = "https://files.pythonhosted.org/packages/48/85/53c6ee2b9cdbbe1bd43cd0f8096036c29e9e6ba2d3d6344206c490e2ce18/rust_just-1.46.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:657ab85882c124b0fbcd75763035d0dbd20b06c582cc6d4f55017d7b517d5a89", size = 1685664, upload-time = "2026-01-03T02:03:05.514Z" },
-    { url = "https://files.pythonhosted.org/packages/33/04/1ad3a66bef0d0f554f0f9971b048bbaf7b3955458f3fda47b48fbf8ff009/rust_just-1.46.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:644b71bfe68863b71ee2618a88dbfd446ea70e2dcfa7b0e5eaec7b6dc4faceca", size = 1838231, upload-time = "2026-01-03T02:03:07.618Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/75/33c2e887a68e57b356cda74d325d6ebe406bb72ad8c4e2d067d4fa9b697b/rust_just-1.46.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9d8a879fb86eb1c7f7f83953999ae4ce53ea4e5c0ca531cf6ff09e1e9335ff7", size = 1900319, upload-time = "2026-01-03T02:03:09.873Z" },
-    { url = "https://files.pythonhosted.org/packages/44/30/6b1677aa64a4f69f3ec174b5e2a9a49e0ffd06946d4b4dc8295366fbd9dd/rust_just-1.46.0-py3-none-win32.whl", hash = "sha256:100701de91bded3f6f2bf564d09c2f8e483b8dfb490d1c74008ce3c01ff0ff67", size = 1623463, upload-time = "2026-01-03T02:03:12.343Z" },
-    { url = "https://files.pythonhosted.org/packages/64/61/97ad7a1ea67b9485404b18150c258015842cf116a1ce626421863fd8f0e1/rust_just-1.46.0-py3-none-win_amd64.whl", hash = "sha256:ccaf8e473f64f5c815b0039e883a1feaf5634b9cdffd1dbff9e5fde77b5926f4", size = 1801103, upload-time = "2026-01-03T02:03:15.256Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/10/4bb1a0865ff38f45c2032f76bd0686fb05719b7dc8f520990de1460df5d4/rust_just-1.51.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d6fd9233cce9550cbbae817648919f1f8bc5e7b44b36be3659d044036691690", size = 1987826, upload-time = "2026-05-11T04:13:52.092Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/7b/d2dd697b265487a7fa7b90416231b310100b32f87052c1f96a45fa8e9ff4/rust_just-1.51.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f0c8eed2316da1663db7fcb293986eb46fa70c5575f3cf3388535f1c4ac65b1b", size = 1854900, upload-time = "2026-05-11T04:04:08.599Z" },
+    { url = "https://files.pythonhosted.org/packages/29/b6/2910efca846f35d2960ad816e3cbade987858b4d46158cbdcc8ef99de155/rust_just-1.51.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ce6192f5160c78e1add2d5f496e04f38e30762f66012bb193ffe09c68f38dcb", size = 1936582, upload-time = "2026-05-11T04:13:54.383Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/07/60b0b47d3f1287d798164404737029c3a391066787bfd2786f58aef0281e/rust_just-1.51.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:28cb32c9304873c1b55c9c7aab110c3198458bfd92e98014e8973184635c9877", size = 1919519, upload-time = "2026-05-11T04:13:56.061Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/96/8bfba323b1de9ee19f647252c8170f2db732c1ca3df4cb26df0e25955d76/rust_just-1.51.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f0e9396f8f83cedd4d8e2f7532f23aaf674f57ca7803084c28d18212216546f", size = 2104936, upload-time = "2026-05-11T04:13:57.823Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/a0/cf6180bf7a82fc6d4b75d8d10a34454cc4df7d32f9dd46a0747a5c734237/rust_just-1.51.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddb7c64fe140f47ace4e3a81064f21b2bfb3c774081ee56b91453b867df0565f", size = 2178074, upload-time = "2026-05-11T04:04:09.952Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/7f/260579bbb3f7e72c15179f99d62e5dc88a7f64f766e9e03a6646ebed77a3/rust_just-1.51.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ee211392f5f080c3a1e07b85f58787e35c8a975f52e15a24ffb94e1fc1b08fc", size = 2121560, upload-time = "2026-05-11T04:04:06.746Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/96/ae3eca10e3ab476a372279df61c09c9968b540ea2adbffe74078d939710d/rust_just-1.51.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71fdbd29ad68d67d0ee885ed0eb74f0e906f3e89601b860f69d88bdd4454a869", size = 2093583, upload-time = "2026-05-11T04:13:59.702Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/42/8a5c573c5f154172e5835de0f230e7d1db959ee5f742697247467b9a07a7/rust_just-1.51.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6084d6122f36552626c913a55d658c80975a32dbe51b0d27ed0a48cd037c0c25", size = 1952338, upload-time = "2026-05-11T04:14:01.577Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/65/dfb7a8be144ff16e74091879c55bc38d4c9102db1e831cab5379762b4691/rust_just-1.51.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262a39565ad782d2621ea31de02acda02ef9640074adfc6e2bfe4f12b48b51ae", size = 1960672, upload-time = "2026-05-11T04:04:11.92Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/39/4d2b03d96accf8a1ff46d7c2102e96ae797d3efa765d70cdbda51dbc4d1e/rust_just-1.51.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e3825497e14bc17d9f1944f45a73f7a996b2d090474477d0d0c16095b7e07e31", size = 1949086, upload-time = "2026-05-11T04:14:03.52Z" },
+    { url = "https://files.pythonhosted.org/packages/05/db/f41378dc317abdb6560f13f7b3ea2d7227d39b2c8272c75f28ba77086d4c/rust_just-1.51.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7015698dcdaac6f1d64610f684a29d42c17431e5802de429d8400a562afe953", size = 2080788, upload-time = "2026-05-11T04:14:05.158Z" },
+    { url = "https://files.pythonhosted.org/packages/67/da/dc67c2d85347c02c1d3c523a89ace6d1a03e621571bb8c02987d6e443e65/rust_just-1.51.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:9dc7543b899d97f8b6c20946fc88d12f2c8593aefc706b1e6e56314de87e3d41", size = 2122670, upload-time = "2026-05-11T04:14:06.596Z" },
+    { url = "https://files.pythonhosted.org/packages/51/07/47faef6b1271cba107e4a70d486516b00e3eb2f1db005dc177bb7edc8aa9/rust_just-1.51.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2fe6e241f4a192c2c7fbb9ee592792422c561c328d0890671c966bac18eee27", size = 2165842, upload-time = "2026-05-11T04:14:08.514Z" },
+    { url = "https://files.pythonhosted.org/packages/99/e9/78ec8d185efd12449572d806cc5bbc87815d186e5e387198d07941070904/rust_just-1.51.0-py3-none-win32.whl", hash = "sha256:3806a2611fc67d2255a1ef796b45efb0a511b360011d700c3eac8ef5be8173c6", size = 1858593, upload-time = "2026-05-11T04:14:09.875Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/37/01dddc2d969613ab23e9edd05bd90e6354591c9b3b9c40f2cf56e2c2af23/rust_just-1.51.0-py3-none-win_amd64.whl", hash = "sha256:e85ce73a2e38cd7b4da9f8389bcd547e8f7a11261671acf1dda13f09754c3bf0", size = 2063796, upload-time = "2026-05-11T04:14:11.317Z" },
 ]
 
 [[package]]
@@ -3028,85 +2695,75 @@ wheels = [
 
 [[package]]
 name = "scipy"
-version = "1.17.0"
+version = "1.17.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "numpy" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/1e/4b/c89c131aa87cad2b77a54eb0fb94d633a842420fa7e919dc2f922037c3d8/scipy-1.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:2abd71643797bd8a106dff97894ff7869eeeb0af0f7a5ce02e4227c6a2e9d6fd", size = 31381316, upload-time = "2026-01-10T21:24:33.42Z" },
-    { url = "https://files.pythonhosted.org/packages/5e/5f/a6b38f79a07d74989224d5f11b55267714707582908a5f1ae854cf9a9b84/scipy-1.17.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:ef28d815f4d2686503e5f4f00edc387ae58dfd7a2f42e348bb53359538f01558", size = 27966760, upload-time = "2026-01-10T21:24:38.911Z" },
-    { url = "https://files.pythonhosted.org/packages/c1/20/095ad24e031ee8ed3c5975954d816b8e7e2abd731e04f8be573de8740885/scipy-1.17.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:272a9f16d6bb4667e8b50d25d71eddcc2158a214df1b566319298de0939d2ab7", size = 20138701, upload-time = "2026-01-10T21:24:43.249Z" },
-    { url = "https://files.pythonhosted.org/packages/89/11/4aad2b3858d0337756f3323f8960755704e530b27eb2a94386c970c32cbe/scipy-1.17.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:7204fddcbec2fe6598f1c5fdf027e9f259106d05202a959a9f1aecf036adc9f6", size = 22480574, upload-time = "2026-01-10T21:24:47.266Z" },
-    { url = "https://files.pythonhosted.org/packages/85/bd/f5af70c28c6da2227e510875cadf64879855193a687fb19951f0f44cfd6b/scipy-1.17.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc02c37a5639ee67d8fb646ffded6d793c06c5622d36b35cfa8fe5ececb8f042", size = 32862414, upload-time = "2026-01-10T21:24:52.566Z" },
-    { url = "https://files.pythonhosted.org/packages/ef/df/df1457c4df3826e908879fe3d76bc5b6e60aae45f4ee42539512438cfd5d/scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dac97a27520d66c12a34fd90a4fe65f43766c18c0d6e1c0a80f114d2260080e4", size = 35112380, upload-time = "2026-01-10T21:24:58.433Z" },
-    { url = "https://files.pythonhosted.org/packages/5f/bb/88e2c16bd1dd4de19d80d7c5e238387182993c2fb13b4b8111e3927ad422/scipy-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb7446a39b3ae0fe8f416a9a3fdc6fba3f11c634f680f16a239c5187bc487c0", size = 34922676, upload-time = "2026-01-10T21:25:04.287Z" },
-    { url = "https://files.pythonhosted.org/packages/02/ba/5120242cc735f71fc002cff0303d536af4405eb265f7c60742851e7ccfe9/scipy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:474da16199f6af66601a01546144922ce402cb17362e07d82f5a6cf8f963e449", size = 37507599, upload-time = "2026-01-10T21:25:09.851Z" },
-    { url = "https://files.pythonhosted.org/packages/52/c8/08629657ac6c0da198487ce8cd3de78e02cfde42b7f34117d56a3fe249dc/scipy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:255c0da161bd7b32a6c898e7891509e8a9289f0b1c6c7d96142ee0d2b114c2ea", size = 36380284, upload-time = "2026-01-10T21:25:15.632Z" },
-    { url = "https://files.pythonhosted.org/packages/6c/4a/465f96d42c6f33ad324a40049dfd63269891db9324aa66c4a1c108c6f994/scipy-1.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b0ac3ad17fa3be50abd7e69d583d98792d7edc08367e01445a1e2076005379", size = 24370427, upload-time = "2026-01-10T21:25:20.514Z" },
-    { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" },
-    { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" },
-    { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" },
-    { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" },
-    { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" },
-    { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" },
-    { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" },
-    { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" },
-    { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" },
-    { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" },
-    { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" },
-    { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" },
-    { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" },
-    { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" },
-    { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" },
-    { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" },
-    { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" },
-    { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" },
-    { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" },
-    { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" },
-    { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" },
-    { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" },
-    { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" },
-    { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" },
-    { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" },
-    { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" },
-    { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" },
-    { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" },
-    { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" },
-    { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" },
-    { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" },
-    { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" },
-    { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" },
-    { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" },
-    { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" },
-    { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" },
-    { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" },
-    { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" },
-    { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" },
-    { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" },
-    { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" },
-    { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" },
-    { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" },
-    { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" },
-    { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" },
-    { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
+    { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
+    { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
+    { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
+    { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
+    { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
+    { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
+    { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
+    { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
+    { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" },
+    { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" },
+    { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" },
+    { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" },
+    { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" },
+    { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" },
+    { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" },
+    { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" },
+    { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
 ]
 
 [[package]]
 name = "scipy-stubs"
-version = "1.17.0.1"
+version = "1.17.1.5"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "optype", extra = ["numpy"] },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/ae/a2/7f52edf1185ffcbf26cae1adede995f923c60a3a1f366bd1cb4cbae41817/scipy_stubs-1.17.0.1.tar.gz", hash = "sha256:029ef77b3984be53a914ac90af3b78c5543af7275eb126c8cec09e7bc72f623c", size = 372323, upload-time = "2026-01-14T16:34:52.838Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/30/7a2e621918d1317ab972f797161131f2635648ad5d92baf0695dd009e4f9/scipy_stubs-1.17.1.5.tar.gz", hash = "sha256:284b1dd1dd46107a614971d170030d310cd88b2ac6b483f85285ee0ff87720bd", size = 399933, upload-time = "2026-05-25T21:34:33.6Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/df/7f/6b99d2f0b75738487e3127dc8fbfff04214cd29900118f5a1f945c34271f/scipy_stubs-1.17.0.1-py3-none-any.whl", hash = "sha256:235bdebce396a9bb48236525aedf04a6efa66dcca8b46105549f35f1f5c4cbb7", size = 577357, upload-time = "2026-01-14T16:34:50.907Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/26/d4bc2ba3427a623f79a6c10c8f427c7a55b56eb8b3eddc369319d97f741b/scipy_stubs-1.17.1.5-py3-none-any.whl", hash = "sha256:58ebf054a86c000c72e8982e121c4ead0d3d9ba7a6c38aa5fa71b07f96a427fd", size = 607388, upload-time = "2026-05-25T21:34:32.073Z" },
 ]
 
 [[package]]
@@ -3138,11 +2795,11 @@ wheels = [
 
 [[package]]
 name = "snowballstemmer"
-version = "3.0.1"
+version = "3.1.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" },
 ]
 
 [[package]]
@@ -3170,11 +2827,11 @@ wheels = [
 
 [[package]]
 name = "soupsieve"
-version = "2.8.2"
+version = "2.8.4"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/93/f2/21d6ca70c3cf35d01ae9e01be534bf6b6b103c157a728082a5028350c310/soupsieve-2.8.2.tar.gz", hash = "sha256:78a66b0fdee2ab40b7199dc3e747ee6c6e231899feeaae0b9b98a353afd48fd8", size = 118601, upload-time = "2026-01-18T16:21:31.09Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/a6/9a/b4450ccce353e2430621b3bb571899ffe1033d5cd72c9e065110f95b1a63/soupsieve-2.8.2-py3-none-any.whl", hash = "sha256:0f4c2f6b5a5fb97a641cf69c0bd163670a0e45e6d6c01a2107f93a6a6f93c51a", size = 37016, upload-time = "2026-01-18T16:21:29.7Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
 ]
 
 [[package]]
@@ -3217,68 +2874,59 @@ wheels = [
 
 [[package]]
 name = "tinycss2"
-version = "1.4.0"
+version = "1.5.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "webencodings" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" },
+    { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
 ]
 
 [[package]]
 name = "tomli"
-version = "2.4.0"
+version = "2.4.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
-    { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
-    { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
-    { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
-    { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
-    { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
-    { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
-    { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
-    { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
-    { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
-    { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
-    { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
-    { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
-    { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
-    { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
-    { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
-    { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
-    { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
-    { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
-    { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
-    { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
-    { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
-    { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
-    { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
-    { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
-    { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
-    { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
-    { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
-    { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
-    { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
-    { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
-    { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
-    { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
-    { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
-    { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
-    { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
-    { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
-    { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
-    { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
-    { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
-    { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+    { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+    { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+    { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+    { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+    { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+    { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+    { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+    { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+    { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+    { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+    { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+    { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+    { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+    { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+    { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+    { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+    { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+    { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+    { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+    { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
 ]
 
 [[package]]
@@ -3301,55 +2949,53 @@ wheels = [
 
 [[package]]
 name = "tornado"
-version = "6.5.4"
+version = "6.5.7"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
-    { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
-    { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
-    { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
-    { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
-    { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
-    { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
-    { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
-    { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
-    { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
+    { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
+    { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
+    { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
+    { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
+    { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
+    { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
 ]
 
 [[package]]
 name = "traitlets"
-version = "5.14.3"
+version = "5.15.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
+    { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" },
 ]
 
 [[package]]
 name = "ty"
-version = "0.0.12"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b5/78/ba1a4ad403c748fbba8be63b7e774a90e80b67192f6443d624c64fe4aaab/ty-0.0.12.tar.gz", hash = "sha256:cd01810e106c3b652a01b8f784dd21741de9fdc47bd595d02c122a7d5cefeee7", size = 4981303, upload-time = "2026-01-14T22:30:48.537Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/7d/8f/c21314d074dda5fb13d3300fa6733fd0d8ff23ea83a721818740665b6314/ty-0.0.12-py3-none-linux_armv6l.whl", hash = "sha256:eb9da1e2c68bd754e090eab39ed65edf95168d36cbeb43ff2bd9f86b4edd56d1", size = 9614164, upload-time = "2026-01-14T22:30:44.016Z" },
-    { url = "https://files.pythonhosted.org/packages/09/28/f8a4d944d13519d70c486e8f96d6fa95647ac2aa94432e97d5cfec1f42f6/ty-0.0.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c181f42aa19b0ed7f1b0c2d559980b1f1d77cc09419f51c8321c7ddf67758853", size = 9542337, upload-time = "2026-01-14T22:30:05.687Z" },
-    { url = "https://files.pythonhosted.org/packages/e1/9c/f576e360441de7a8201daa6dc4ebc362853bc5305e059cceeb02ebdd9a48/ty-0.0.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1f829e1eecd39c3e1b032149db7ae6a3284f72fc36b42436e65243a9ed1173db", size = 8909582, upload-time = "2026-01-14T22:30:46.089Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/13/0898e494032a5d8af3060733d12929e3e7716db6c75eac63fa125730a3e7/ty-0.0.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45162e7826e1789cf3374627883cdeb0d56b82473a0771923e4572928e90be3", size = 9384932, upload-time = "2026-01-14T22:30:13.769Z" },
-    { url = "https://files.pythonhosted.org/packages/e4/1a/b35b6c697008a11d4cedfd34d9672db2f0a0621ec80ece109e13fca4dfef/ty-0.0.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d11fec40b269bec01e751b2337d1c7ffa959a2c2090a950d7e21c2792442cccd", size = 9453140, upload-time = "2026-01-14T22:30:11.131Z" },
-    { url = "https://files.pythonhosted.org/packages/dd/1e/71c9edbc79a3c88a0711324458f29c7dbf6c23452c6e760dc25725483064/ty-0.0.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09d99e37e761a4d2651ad9d5a610d11235fbcbf35dc6d4bc04abf54e7cf894f1", size = 9960680, upload-time = "2026-01-14T22:30:33.621Z" },
-    { url = "https://files.pythonhosted.org/packages/0e/75/39375129f62dd22f6ad5a99cd2a42fd27d8b91b235ce2db86875cdad397d/ty-0.0.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d9ca0cdb17bd37397da7b16a7cd23423fc65c3f9691e453ad46c723d121225a1", size = 10904518, upload-time = "2026-01-14T22:30:08.464Z" },
-    { url = "https://files.pythonhosted.org/packages/32/5e/26c6d88fafa11a9d31ca9f4d12989f57782ec61e7291d4802d685b5be118/ty-0.0.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcf2757b905e7eddb7e456140066335b18eb68b634a9f72d6f54a427ab042c64", size = 10525001, upload-time = "2026-01-14T22:30:16.454Z" },
-    { url = "https://files.pythonhosted.org/packages/c2/a5/2f0b91894af13187110f9ad7ee926d86e4e6efa755c9c88a820ed7f84c85/ty-0.0.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00cf34c1ebe1147efeda3021a1064baa222c18cdac114b7b050bbe42deb4ca80", size = 10307103, upload-time = "2026-01-14T22:30:41.221Z" },
-    { url = "https://files.pythonhosted.org/packages/4b/77/13d0410827e4bc713ebb7fdaf6b3590b37dcb1b82e0a81717b65548f2442/ty-0.0.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3a655bd869352e9a22938d707631ac9fbca1016242b1f6d132d78f347c851", size = 10072737, upload-time = "2026-01-14T22:30:51.783Z" },
-    { url = "https://files.pythonhosted.org/packages/e1/dd/fc36d8bac806c74cf04b4ca735bca14d19967ca84d88f31e121767880df1/ty-0.0.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4658e282c7cb82be304052f8f64f9925f23c3c4f90eeeb32663c74c4b095d7ba", size = 9368726, upload-time = "2026-01-14T22:30:18.683Z" },
-    { url = "https://files.pythonhosted.org/packages/54/70/9e8e461647550f83e2fe54bc632ccbdc17a4909644783cdbdd17f7296059/ty-0.0.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c167d838eaaa06e03bb66a517f75296b643d950fbd93c1d1686a187e5a8dbd1f", size = 9454704, upload-time = "2026-01-14T22:30:22.759Z" },
-    { url = "https://files.pythonhosted.org/packages/04/9b/6292cf7c14a0efeca0539cf7d78f453beff0475cb039fbea0eb5d07d343d/ty-0.0.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2956e0c9ab7023533b461d8a0e6b2ea7b78e01a8dde0688e8234d0fce10c4c1c", size = 9649829, upload-time = "2026-01-14T22:30:31.234Z" },
-    { url = "https://files.pythonhosted.org/packages/49/bd/472a5d2013371e4870886cff791c94abdf0b92d43d305dd0f8e06b6ff719/ty-0.0.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c6a3fd7479580009f21002f3828320621d8a82d53b7ba36993234e3ccad58c8", size = 10162814, upload-time = "2026-01-14T22:30:36.174Z" },
-    { url = "https://files.pythonhosted.org/packages/31/e9/2ecbe56826759845a7c21d80aa28187865ea62bc9757b056f6cbc06f78ed/ty-0.0.12-py3-none-win32.whl", hash = "sha256:a91c24fd75c0f1796d8ede9083e2c0ec96f106dbda73a09fe3135e075d31f742", size = 9140115, upload-time = "2026-01-14T22:30:38.903Z" },
-    { url = "https://files.pythonhosted.org/packages/5d/6d/d9531eff35a5c0ec9dbc10231fac21f9dd6504814048e81d6ce1c84dc566/ty-0.0.12-py3-none-win_amd64.whl", hash = "sha256:df151894be55c22d47068b0f3b484aff9e638761e2267e115d515fcc9c5b4a4b", size = 9884532, upload-time = "2026-01-14T22:30:25.112Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/f3/20b49e75967023b123a221134548ad7000f9429f13fdcdda115b4c26305f/ty-0.0.12-py3-none-win_arm64.whl", hash = "sha256:cea99d334b05629de937ce52f43278acf155d3a316ad6a35356635f886be20ea", size = 9313974, upload-time = "2026-01-14T22:30:27.44Z" },
+version = "0.0.49"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/8d/37cb91808069509d43a2a11743e12f1e854fd808dbef2203309d256718cd/ty-0.0.49.tar.gz", hash = "sha256:0a027bd0c9c75d035641a365d087ad883446057f9be0b9826251c2aecafbf145", size = 5884753, upload-time = "2026-06-12T03:08:20.221Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ca/de/9237c6a96356612dd0393db1e94cf21f903616adf3a3701bf3da6e4adc92/ty-0.0.49-py3-none-linux_armv6l.whl", hash = "sha256:12c0c4310b936d762a8586c210b53d4fa4bb361a04429afa89bf84b922e5e065", size = 11834671, upload-time = "2026-06-12T03:07:53.062Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/15/daf5a14a5e07012277d450c75325c94614e2acfec4c620c881486118c410/ty-0.0.49-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:737bfdc2caf9712a8580944dcdc80a450a37a4f2bc83c8fa9b7433b374f9e471", size = 11589570, upload-time = "2026-06-12T03:08:25.779Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/58/30bdf98436488aca25f0763bf7f92a061528d42461b686453029e845e4c5/ty-0.0.49-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab90c1baf3b1701d282fce4b02fa552a962d109f8972c46ef6b22429503bfea4", size = 10985236, upload-time = "2026-06-12T03:08:36.664Z" },
+    { url = "https://files.pythonhosted.org/packages/22/45/ece503e4a1396e13a1a9a0cde51afe476a6506a1d557eeadf8ad45c83bc0/ty-0.0.49-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ce8ecf6ba6fc79bd137cc0557a754f7e5f2dfe9436412551d480d680e248ad", size = 11504302, upload-time = "2026-06-12T03:08:01.664Z" },
+    { url = "https://files.pythonhosted.org/packages/17/dc/5d09333d289dfbca1804eaade125c9e8a1a992a2a592a8b80c5e9b589ca9/ty-0.0.49-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10d85c6865c984e78661e0bd20b180514b4a289739224e84816e342bdf381e04", size = 11626629, upload-time = "2026-06-12T03:08:06.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/36/155f41c9dd7237c4b609211f29f77755a139ee6218605dadc7fe21d5e3c8/ty-0.0.49-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d96a67a206619e01fa92f35a22267ec634bba62be24b1d0e947020cc179995b", size = 12074481, upload-time = "2026-06-12T03:08:09.643Z" },
+    { url = "https://files.pythonhosted.org/packages/96/4c/998ee13cd5045f1f8b36982de7343163832ac53f27debe01b0de0e8bd968/ty-0.0.49-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3de9f648564e0a66344ef397770387cb0d093735f8679d2c5a08a4741e79814d", size = 12678042, upload-time = "2026-06-12T03:08:39.319Z" },
+    { url = "https://files.pythonhosted.org/packages/85/c9/9a505aba85c41ce54cbcaa14f8d79aa084b86151d2d70df11c4655b92898/ty-0.0.49-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5779179ab397d15f8c9dbb8f506ec1b1745f54eac639982f76ef3ce538943b50", size = 12316194, upload-time = "2026-06-12T03:08:18.023Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/b8/ded37fb93503294abbc83c36470bb1413bea05048b745881d4470b518a06/ty-0.0.49-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792d4974e93cc09bd32f934586080bbbe21b8e777099cb521cb2de18b68a49f0", size = 12145507, upload-time = "2026-06-12T03:07:56.505Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/07/392e80d78f02445f695b815bb9eb0fffacda68b03faee38c900f7b990815/ty-0.0.49-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:727bda86deb136073e525c2e78d60e38aedcce5d80579170844a52bbf7c1440d", size = 12365967, upload-time = "2026-06-12T03:08:12.553Z" },
+    { url = "https://files.pythonhosted.org/packages/50/d3/31b0c2a7fbedd3373e389cb1d81b8d2128f6f868fafb46557736a6f9aca8/ty-0.0.49-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f2fc2bc4a8d2ff1cca59fd94772cabdfec4062d47a0b3a0784be46d94d0540b", size = 11475283, upload-time = "2026-06-12T03:08:28.334Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/5b/329e101638920b468a3bb63059c9f66ef99b44aac501222c44832a507321/ty-0.0.49-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3724bd9badef333321578b6a941fbc571ebf49141ec2356a8590fbe4c9aa588d", size = 11645343, upload-time = "2026-06-12T03:08:15.246Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/76/c897e615e32f80ca81c8c1bc49b9a1f72ff9e3cfea0f8345ba505fe28472/ty-0.0.49-py3-none-musllinux_1_2_i686.whl", hash = "sha256:166c6eb52ee4af3c5a9bb267d165d93000daa55c6758cd8ff3199741fb75917d", size = 11725585, upload-time = "2026-06-12T03:08:33.915Z" },
+    { url = "https://files.pythonhosted.org/packages/59/e1/fdb42ee239f618800842681af5bb8598117e74512c10974a8b7b9086a898/ty-0.0.49-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:91e81d832c287b05782ee32eb1b801f62c1fa08df37d589d2b88c3f1d51c9731", size = 12237261, upload-time = "2026-06-12T03:08:31.105Z" },
+    { url = "https://files.pythonhosted.org/packages/98/0f/a2d6a5fc9d0786cbeb3c200786da4e18c203589be3984bb5def83ca92320/ty-0.0.49-py3-none-win32.whl", hash = "sha256:7186af5ca9829d1f5d8916bcf767b8e819bfbf61b1b8ec843bb3fc699cb502e1", size = 11100789, upload-time = "2026-06-12T03:07:59.092Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/9d/473ac8bc57b5a2d121da893bf9dd74a118efb19a01d711df1a6e397f05cc/ty-0.0.49-py3-none-win_amd64.whl", hash = "sha256:ae2142fc126a01effcca0c222908b0e6654b5ba1266d4e4d406e4866aef8e1d1", size = 12204644, upload-time = "2026-06-12T03:08:04.327Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/a2/8959249da951ba3977fee20e688d28678b8a1d30a9ed4464228a85d45853/ty-0.0.49-py3-none-win_arm64.whl", hash = "sha256:75d5e2e7649765f31f4bed6c8adb149a75b18edd3fa6336dac4d0efc1a66466f", size = 11558965, upload-time = "2026-06-12T03:08:23.012Z" },
 ]
 
 [[package]]
@@ -3363,68 +3009,68 @@ wheels = [
 
 [[package]]
 name = "typer"
-version = "0.21.1"
+version = "0.26.7"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "click" },
+    { name = "annotated-doc" },
+    { name = "colorama", marker = "sys_platform == 'win32'" },
     { name = "rich" },
     { name = "shellingham" },
-    { name = "typing-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
+    { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" },
 ]
 
 [[package]]
 name = "types-cachetools"
-version = "6.2.0.20251022"
+version = "7.0.0.20260518"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/3b/a8/f9bcc7f1be63af43ef0170a773e2d88817bcc7c9d8769f2228c802826efe/types_cachetools-6.2.0.20251022.tar.gz", hash = "sha256:f1d3c736f0f741e89ec10f0e1b0138625023e21eb33603a930c149e0318c0cef", size = 9608, upload-time = "2025-10-22T03:03:58.16Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c8/14/1e4fca2b250dbc75be9f0beab083acb3cd1151711e1031eb4a854dfd71be/types_cachetools-7.0.0.20260518.tar.gz", hash = "sha256:7730014e4fef0c6f01e2cd0f980f8ce6d1b1d2472c8459c1f382348ec1a6b435", size = 10072, upload-time = "2026-05-18T06:02:20.396Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/98/2d/8d821ed80f6c2c5b427f650bf4dc25b80676ed63d03388e4b637d2557107/types_cachetools-6.2.0.20251022-py3-none-any.whl", hash = "sha256:698eb17b8f16b661b90624708b6915f33dbac2d185db499ed57e4997e7962cad", size = 9341, upload-time = "2025-10-22T03:03:57.036Z" },
+    { url = "https://files.pythonhosted.org/packages/45/e0/767be6b60859fd2edc4512fabdedbce703fc8d4ec5007b31abaf37a51c6c/types_cachetools-7.0.0.20260518-py3-none-any.whl", hash = "sha256:997b356870915f8bbc9b2cdb4e7271c01d487996fdac2a9c8e91cc5b1261b3d1", size = 9500, upload-time = "2026-05-18T06:02:19.042Z" },
 ]
 
 [[package]]
 name = "types-docutils"
-version = "0.22.3.20251115"
+version = "0.22.3.20260518"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/eb/d7/576ec24bf61a280f571e1f22284793adc321610b9bcfba1bf468cf7b334f/types_docutils-0.22.3.20251115.tar.gz", hash = "sha256:0f79ea6a7bd4d12d56c9f824a0090ffae0ea4204203eb0006392906850913e16", size = 56828, upload-time = "2025-11-15T02:59:57.371Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/91/81b520d0869a41aa0d86e713417b63e8604b4280c304bc09b02d74c7efd4/types_docutils-0.22.3.20260518.tar.gz", hash = "sha256:2c45ba63a9ac64246335359b68fe9c27602926499c9b67caec3780745f6aadee", size = 57504, upload-time = "2026-05-18T06:03:53.325Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/9c/01/61ac9eb38f1f978b47443dc6fd2e0a3b0f647c2da741ddad30771f1b2b6f/types_docutils-0.22.3.20251115-py3-none-any.whl", hash = "sha256:c6e53715b65395d00a75a3a8a74e352c669bc63959e65a207dffaa22f4a2ad6e", size = 91951, upload-time = "2025-11-15T02:59:56.413Z" },
+    { url = "https://files.pythonhosted.org/packages/01/9b/243fb84ede4f987f303a89e734c5def35ead2f8bd962ad301c1e99680958/types_docutils-0.22.3.20260518-py3-none-any.whl", hash = "sha256:9c4cbc37d9e37f47dfe971ac09e9880380dc948ff1f23956892e810d0bf08676", size = 91970, upload-time = "2026-05-18T06:03:52.388Z" },
 ]
 
 [[package]]
 name = "types-pygments"
-version = "2.19.0.20251121"
+version = "2.20.0.20260518"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "types-docutils" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/90/3b/cd650700ce9e26b56bd1a6aa4af397bbbc1784e22a03971cb633cdb0b601/types_pygments-2.19.0.20251121.tar.gz", hash = "sha256:eef114fde2ef6265365522045eac0f8354978a566852f69e75c531f0553822b1", size = 18590, upload-time = "2025-11-21T03:03:46.623Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/66/3e27e8dbe72947d51355d1fcba222a55bf5f0770ee660e9cc9df0d1ce5d7/types_pygments-2.20.0.20260518.tar.gz", hash = "sha256:bcab233d0389cb0a91146eb860e7bdcbceaa0f30bee73cefc7b367129cc4a330", size = 21152, upload-time = "2026-05-18T06:07:26.927Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/99/8a/9244b21f1d60dcc62e261435d76b02f1853b4771663d7ec7d287e47a9ba9/types_pygments-2.19.0.20251121-py3-none-any.whl", hash = "sha256:cb3bfde34eb75b984c98fb733ce4f795213bd3378f855c32e75b49318371bb25", size = 25674, upload-time = "2025-11-21T03:03:45.72Z" },
+    { url = "https://files.pythonhosted.org/packages/66/d6/5f19f5b633af6a7666c6096fdd06b70f0d4a8f585af5e18a3ef58ad47ec1/types_pygments-2.20.0.20260518-py3-none-any.whl", hash = "sha256:e40728efd00c9da5936366648d5b3c55e72ed52bdd80495a96577b4d717c4fcb", size = 29002, upload-time = "2026-05-18T06:07:26.054Z" },
 ]
 
 [[package]]
 name = "types-requests"
-version = "2.32.4.20260107"
+version = "2.33.0.20260518"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "urllib3" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" },
 ]
 
 [[package]]
 name = "types-setuptools"
-version = "80.9.0.20251223"
+version = "82.0.0.20260518"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/00/07/d1b605230730990de20477150191d6dccf6aecc037da94c9960a5d563bc8/types_setuptools-80.9.0.20251223.tar.gz", hash = "sha256:d3411059ae2f5f03985217d86ac6084efea2c9e9cacd5f0869ef950f308169b2", size = 42420, upload-time = "2025-12-23T03:18:26.752Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/bc/73c2c27e047e42f114ac50fb3bdef986c56cbdb68096f8690eeafb839a93/types_setuptools-82.0.0.20260518.tar.gz", hash = "sha256:3b743cfe63d0981ea4c15b90710fc1ed41e3464a537d51e705be514e891c1d07", size = 44999, upload-time = "2026-05-18T06:02:55.642Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/78/5c/b8877da94012dbc6643e4eeca22bca9b99b295be05d161f8a403ae9387c0/types_setuptools-80.9.0.20251223-py3-none-any.whl", hash = "sha256:1b36db79d724c2287d83dc052cf887b47c0da6a2fff044378be0b019545f56e6", size = 64318, upload-time = "2025-12-23T03:18:25.868Z" },
+    { url = "https://files.pythonhosted.org/packages/32/8f/d5e2d493f09a7a98c95619edda1cb37cee377626c0a869d53274c26f2858/types_setuptools-82.0.0.20260518-py3-none-any.whl", hash = "sha256:31c04a62b57a653a5021caf191be0f10f70df890f813b51f02bab3969d300f20", size = 68444, upload-time = "2026-05-18T06:02:54.582Z" },
 ]
 
 [[package]]
@@ -3450,71 +3096,57 @@ wheels = [
 
 [[package]]
 name = "ujson"
-version = "5.11.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/da/ea/80346b826349d60ca4d612a47cdf3533694e49b45e9d1c07071bb867a184/ujson-5.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d7c46cb0fe5e7056b9acb748a4c35aa1b428025853032540bb7e41f46767321f", size = 55248, upload-time = "2025-08-20T11:55:19.033Z" },
-    { url = "https://files.pythonhosted.org/packages/57/df/b53e747562c89515e18156513cc7c8ced2e5e3fd6c654acaa8752ffd7cd9/ujson-5.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8951bb7a505ab2a700e26f691bdfacf395bc7e3111e3416d325b513eea03a58", size = 53156, upload-time = "2025-08-20T11:55:20.174Z" },
-    { url = "https://files.pythonhosted.org/packages/41/b8/ab67ec8c01b8a3721fd13e5cb9d85ab2a6066a3a5e9148d661a6870d6293/ujson-5.11.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952c0be400229940248c0f5356514123d428cba1946af6fa2bbd7503395fef26", size = 57657, upload-time = "2025-08-20T11:55:21.296Z" },
-    { url = "https://files.pythonhosted.org/packages/7b/c7/fb84f27cd80a2c7e2d3c6012367aecade0da936790429801803fa8d4bffc/ujson-5.11.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:94fcae844f1e302f6f8095c5d1c45a2f0bfb928cccf9f1b99e3ace634b980a2a", size = 59779, upload-time = "2025-08-20T11:55:22.772Z" },
-    { url = "https://files.pythonhosted.org/packages/5d/7c/48706f7c1e917ecb97ddcfb7b1d756040b86ed38290e28579d63bd3fcc48/ujson-5.11.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e0ec1646db172beb8d3df4c32a9d78015e671d2000af548252769e33079d9a6", size = 57284, upload-time = "2025-08-20T11:55:24.01Z" },
-    { url = "https://files.pythonhosted.org/packages/ec/ce/48877c6eb4afddfd6bd1db6be34456538c07ca2d6ed233d3f6c6efc2efe8/ujson-5.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:da473b23e3a54448b008d33f742bcd6d5fb2a897e42d1fc6e7bf306ea5d18b1b", size = 1036395, upload-time = "2025-08-20T11:55:25.725Z" },
-    { url = "https://files.pythonhosted.org/packages/8b/7a/2c20dc97ad70cd7c31ad0596ba8e2cf8794d77191ba4d1e0bded69865477/ujson-5.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:aa6b3d4f1c0d3f82930f4cbd7fe46d905a4a9205a7c13279789c1263faf06dba", size = 1195731, upload-time = "2025-08-20T11:55:27.915Z" },
-    { url = "https://files.pythonhosted.org/packages/15/f5/ca454f2f6a2c840394b6f162fff2801450803f4ff56c7af8ce37640b8a2a/ujson-5.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4843f3ab4fe1cc596bb7e02228ef4c25d35b4bb0809d6a260852a4bfcab37ba3", size = 1088710, upload-time = "2025-08-20T11:55:29.426Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/d3/9ba310e07969bc9906eb7548731e33a0f448b122ad9705fed699c9b29345/ujson-5.11.0-cp311-cp311-win32.whl", hash = "sha256:e979fbc469a7f77f04ec2f4e853ba00c441bf2b06720aa259f0f720561335e34", size = 39648, upload-time = "2025-08-20T11:55:31.194Z" },
-    { url = "https://files.pythonhosted.org/packages/57/f7/da05b4a8819f1360be9e71fb20182f0bb3ec611a36c3f213f4d20709e099/ujson-5.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:683f57f0dd3acdd7d9aff1de0528d603aafcb0e6d126e3dc7ce8b020a28f5d01", size = 43717, upload-time = "2025-08-20T11:55:32.241Z" },
-    { url = "https://files.pythonhosted.org/packages/9a/cc/f3f9ac0f24f00a623a48d97dc3814df5c2dc368cfb00031aa4141527a24b/ujson-5.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:7855ccea3f8dad5e66d8445d754fc1cf80265a4272b5f8059ebc7ec29b8d0835", size = 38402, upload-time = "2025-08-20T11:55:33.641Z" },
-    { url = "https://files.pythonhosted.org/packages/b9/ef/a9cb1fce38f699123ff012161599fb9f2ff3f8d482b4b18c43a2dc35073f/ujson-5.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7895f0d2d53bd6aea11743bd56e3cb82d729980636cd0ed9b89418bf66591702", size = 55434, upload-time = "2025-08-20T11:55:34.987Z" },
-    { url = "https://files.pythonhosted.org/packages/b1/05/dba51a00eb30bd947791b173766cbed3492269c150a7771d2750000c965f/ujson-5.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12b5e7e22a1fe01058000d1b317d3b65cc3daf61bd2ea7a2b76721fe160fa74d", size = 53190, upload-time = "2025-08-20T11:55:36.384Z" },
-    { url = "https://files.pythonhosted.org/packages/03/3c/fd11a224f73fbffa299fb9644e425f38b38b30231f7923a088dd513aabb4/ujson-5.11.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0180a480a7d099082501cad1fe85252e4d4bf926b40960fb3d9e87a3a6fbbc80", size = 57600, upload-time = "2025-08-20T11:55:37.692Z" },
-    { url = "https://files.pythonhosted.org/packages/55/b9/405103cae24899df688a3431c776e00528bd4799e7d68820e7ebcf824f92/ujson-5.11.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:fa79fdb47701942c2132a9dd2297a1a85941d966d8c87bfd9e29b0cf423f26cc", size = 59791, upload-time = "2025-08-20T11:55:38.877Z" },
-    { url = "https://files.pythonhosted.org/packages/17/7b/2dcbc2bbfdbf68f2368fb21ab0f6735e872290bb604c75f6e06b81edcb3f/ujson-5.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8254e858437c00f17cb72e7a644fc42dad0ebb21ea981b71df6e84b1072aaa7c", size = 57356, upload-time = "2025-08-20T11:55:40.036Z" },
-    { url = "https://files.pythonhosted.org/packages/d1/71/fea2ca18986a366c750767b694430d5ded6b20b6985fddca72f74af38a4c/ujson-5.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aa8a2ab482f09f6c10fba37112af5f957689a79ea598399c85009f2f29898b5", size = 1036313, upload-time = "2025-08-20T11:55:41.408Z" },
-    { url = "https://files.pythonhosted.org/packages/a3/bb/d4220bd7532eac6288d8115db51710fa2d7d271250797b0bfba9f1e755af/ujson-5.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a638425d3c6eed0318df663df44480f4a40dc87cc7c6da44d221418312f6413b", size = 1195782, upload-time = "2025-08-20T11:55:43.357Z" },
-    { url = "https://files.pythonhosted.org/packages/80/47/226e540aa38878ce1194454385701d82df538ccb5ff8db2cf1641dde849a/ujson-5.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e3cff632c1d78023b15f7e3a81c3745cd3f94c044d1e8fa8efbd6b161997bbc", size = 1088817, upload-time = "2025-08-20T11:55:45.262Z" },
-    { url = "https://files.pythonhosted.org/packages/7e/81/546042f0b23c9040d61d46ea5ca76f0cc5e0d399180ddfb2ae976ebff5b5/ujson-5.11.0-cp312-cp312-win32.whl", hash = "sha256:be6b0eaf92cae8cdee4d4c9e074bde43ef1c590ed5ba037ea26c9632fb479c88", size = 39757, upload-time = "2025-08-20T11:55:46.522Z" },
-    { url = "https://files.pythonhosted.org/packages/44/1b/27c05dc8c9728f44875d74b5bfa948ce91f6c33349232619279f35c6e817/ujson-5.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7b136cc6abc7619124fd897ef75f8e63105298b5ca9bdf43ebd0e1fa0ee105f", size = 43859, upload-time = "2025-08-20T11:55:47.987Z" },
-    { url = "https://files.pythonhosted.org/packages/22/2d/37b6557c97c3409c202c838aa9c960ca3896843b4295c4b7bb2bbd260664/ujson-5.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cd2df62f24c506a0ba322d5e4fe4466d47a9467b57e881ee15a31f7ecf68ff6", size = 38361, upload-time = "2025-08-20T11:55:49.122Z" },
-    { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" },
-    { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" },
-    { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" },
-    { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" },
-    { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" },
-    { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" },
-    { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" },
-    { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" },
-    { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" },
-    { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" },
-    { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" },
-    { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" },
-    { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" },
-    { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" },
-    { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" },
-    { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" },
-    { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" },
-    { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" },
-    { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" },
-    { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" },
-    { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" },
-    { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" },
-    { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" },
-    { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" },
-    { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" },
-    { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" },
-    { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" },
-    { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" },
-    { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" },
-    { url = "https://files.pythonhosted.org/packages/50/17/30275aa2933430d8c0c4ead951cc4fdb922f575a349aa0b48a6f35449e97/ujson-5.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:abae0fb58cc820092a0e9e8ba0051ac4583958495bfa5262a12f628249e3b362", size = 51206, upload-time = "2025-08-20T11:56:48.797Z" },
-    { url = "https://files.pythonhosted.org/packages/c3/15/42b3924258eac2551f8f33fa4e35da20a06a53857ccf3d4deb5e5d7c0b6c/ujson-5.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fac6c0649d6b7c3682a0a6e18d3de6857977378dce8d419f57a0b20e3d775b39", size = 48907, upload-time = "2025-08-20T11:56:50.136Z" },
-    { url = "https://files.pythonhosted.org/packages/94/7e/0519ff7955aba581d1fe1fb1ca0e452471250455d182f686db5ac9e46119/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b42c115c7c6012506e8168315150d1e3f76e7ba0f4f95616f4ee599a1372bbc", size = 50319, upload-time = "2025-08-20T11:56:51.63Z" },
-    { url = "https://files.pythonhosted.org/packages/74/cf/209d90506b7d6c5873f82c5a226d7aad1a1da153364e9ebf61eff0740c33/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:86baf341d90b566d61a394869ce77188cc8668f76d7bb2c311d77a00f4bdf844", size = 56584, upload-time = "2025-08-20T11:56:52.89Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/97/bd939bb76943cb0e1d2b692d7e68629f51c711ef60425fa5bb6968037ecd/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4598bf3965fc1a936bd84034312bcbe00ba87880ef1ee33e33c1e88f2c398b49", size = 51588, upload-time = "2025-08-20T11:56:54.054Z" },
-    { url = "https://files.pythonhosted.org/packages/52/5b/8c5e33228f7f83f05719964db59f3f9f276d272dc43752fa3bbf0df53e7b/ujson-5.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:416389ec19ef5f2013592f791486bef712ebce0cd59299bf9df1ba40bb2f6e04", size = 43835, upload-time = "2025-08-20T11:56:55.237Z" },
+version = "5.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bc/78/937198ea8708182dd1edbf0237bf255a96feab3f511691ad08b84da98e5d/ujson-5.12.1.tar.gz", hash = "sha256:5b7e96406c301a1366534479a7352ec40ec68bb327c0c119091635acd5925e35", size = 7164538, upload-time = "2026-05-05T22:05:01.354Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d7/40/dbb8e2fe6ee33769602fba203dacaa3963b6599f0d0aefdf2b8811af5f70/ujson-5.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:10f44bd08ae52ee23ca6e8b472692e5da1768af2d53ff1bad6f40b532e0bc7ee", size = 57951, upload-time = "2026-05-05T22:03:31.606Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/db/627472e6b4ac34148ea52e6d3d15f6f366fc21c72fe7d6c7d3729d4b3ac5/ujson-5.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cc6ea753b7303fa5629fa9ac9257ea4b001c4d72583b2bb36ff1855a07db49f", size = 55562, upload-time = "2026-05-05T22:03:32.853Z" },
+    { url = "https://files.pythonhosted.org/packages/be/59/1248c966da197ae7d2673542444a2d9a1ff7c46e3ec2a302c3caf902b922/ujson-5.12.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:289f13095764d03734adfa10107da9b530ceb64dc1b02a5f507588d978d5b7df", size = 59448, upload-time = "2026-05-05T22:03:34.143Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/d7/60c1ca71a09c0654c3edca1192a18fc55e6cc06107be86d7d3f2b39fb29b/ujson-5.12.1-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:427893168d074e59214b0ee058337c57f5bb80175cdd5b4799a9c931aae22022", size = 61608, upload-time = "2026-05-05T22:03:35.386Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/0a/c619525576219bfc50084100117481b1a732a16716a3878355570995de4e/ujson-5.12.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7a81724d5d90a2da7155d15d8b156ce57eaed7cdd622df813f36a8e612fd4c8", size = 59113, upload-time = "2026-05-05T22:03:37.555Z" },
+    { url = "https://files.pythonhosted.org/packages/18/4d/79c1674036085e8dfdb77f8d87c1fd2896e97e6affd117c5e8ecc40f0ae4/ujson-5.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a6efff7dc6515416366819de4a1bc449b77107c5b48508b101fd40f7f8bec08", size = 1038914, upload-time = "2026-05-05T22:03:38.954Z" },
+    { url = "https://files.pythonhosted.org/packages/94/b1/9409bba17189ee282b6314cdf0ecdcc72e3d38cd565c870c0227d0494569/ujson-5.12.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77a71fe53427a0cf49d56eafd801d9f7e203b784b7f99cc717783fd6f6f7b732", size = 1198408, upload-time = "2026-05-05T22:03:40.943Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/ad/fafbce7ac59f1a10a83892d0a34add23cc06492308e1330493aab707dc20/ujson-5.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea3bed53d2ea8e5642e814a9e41f3e29420a8067874ba03ace8c0462e160490c", size = 1091451, upload-time = "2026-05-05T22:03:42.739Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/1f/76fc9d5b1dcb9eb73ed45fd56e5114391bd30808eb1cea7f8bc5c9a64324/ujson-5.12.1-cp312-cp312-win32.whl", hash = "sha256:758e5c8fbe4e6d483041e03b307b01fb5d2f2dd4452d4d4b927ab902e188939e", size = 41049, upload-time = "2026-05-05T22:03:44.341Z" },
+    { url = "https://files.pythonhosted.org/packages/35/2a/7ce3b6fda10d05b79a245db03405734b521ba3da6c377f173b018dce6d4e/ujson-5.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:f6074d3d3267ba1914c624b6e1fa3d8152648ff36b0ab77ddf83b92db488c30d", size = 45330, upload-time = "2026-05-05T22:03:45.828Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/66/5a37bba7a2e2ab36ae467521c4511e6593ad74c869f62ec4ba6330f3f71e/ujson-5.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:7642a41520ac1b2bc25ea282b66b8da522cc43424442e6fb5e039be4d4f96530", size = 39828, upload-time = "2026-05-05T22:03:47.123Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/f0/985b351771ebf095e2c1aaad18f4d251831226a767a32593310e4f181f19/ujson-5.12.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c4bdc052a5d097f0a2e56d93aed97355f9f7a62ef9baa4f8517e43245434af9c", size = 57959, upload-time = "2026-05-05T22:03:48.348Z" },
+    { url = "https://files.pythonhosted.org/packages/61/73/03c7473372e1a538206fc655e474fa15f8bf9c46bb7c73c5fec9a544e429/ujson-5.12.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5dc91fa06ea35920b704fd9d70871897680145998071cfbf5ee3e19f2c9fc242", size = 55564, upload-time = "2026-05-05T22:03:49.869Z" },
+    { url = "https://files.pythonhosted.org/packages/04/e6/104ebc35fa8dbaca66bf027c53c0c9c572271c2984576f4fd7d349d1a2e4/ujson-5.12.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5db0849c0e3da54822a5834f2dc51d7c51072d7f7d665014ee34600dc10889b", size = 59448, upload-time = "2026-05-05T22:03:51.224Z" },
+    { url = "https://files.pythonhosted.org/packages/11/d2/55274e80fe1806cdb5cb97483be16cd6163337ab11c3bd7e28ff8a8aad26/ujson-5.12.1-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:949cb4863a5d4847edeb47c5364b334e8cadf23a7cbdaa547d86098a4b093106", size = 61611, upload-time = "2026-05-05T22:03:52.731Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/15/ec46b1757c8f7770d8c101b8a463bec67c19e89c46c608d01e4b193cc64a/ujson-5.12.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aa731138d6dfca4ab84501b72384e6c544bfb48cb87a0dd4d304df3246cac25", size = 59120, upload-time = "2026-05-05T22:03:54.064Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/27/ec73bc8908c33eb1f5be29d696084e531cbcfbd5c7b89ce54c025f66c682/ujson-5.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:727e983ef27892d86ee2d28fd517eeb02b2c1165aafcbe929dce988aeee81bfe", size = 1038913, upload-time = "2026-05-05T22:03:55.792Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/30/907e47569bed5f5eb258fef5e587c6759a7a062048796e40024497137e28/ujson-5.12.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d57d731ecf492d3d011e65369f8330654f0875b19f646be5270d478e843d3b81", size = 1198409, upload-time = "2026-05-05T22:03:57.947Z" },
+    { url = "https://files.pythonhosted.org/packages/46/aa/f135f4b741baf14d5350be5511076408e7540353d3d850a430cb89d585a6/ujson-5.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a09636220f26c66f80c6c6283023cb53120e843825f890be92696cd1aa43f39", size = 1091456, upload-time = "2026-05-05T22:04:00.355Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/81/5e6ef1115c0f700a74a150857c66cb22245f0e43f79667af9bf2b88f9452/ujson-5.12.1-cp313-cp313-win32.whl", hash = "sha256:ee83fbac03a0896faf190177c938f94eb610b798d495a19d50997242c4eca685", size = 41055, upload-time = "2026-05-05T22:04:02.372Z" },
+    { url = "https://files.pythonhosted.org/packages/98/76/8b423bc72a02f3fcf90f911a16382f360442c1a8887955c023d517f5d4ba/ujson-5.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:e08d9e096c416ddc34519241f97c201258b42639f2012d9547d8ae32921800dd", size = 45331, upload-time = "2026-05-05T22:04:03.946Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/f2/c839a923da49384d4a319ddd5ce666e50e45a5c8417cec742c65667a1864/ujson-5.12.1-cp313-cp313-win_arm64.whl", hash = "sha256:963287e4b1bc463735c4056968a2dfa59bb831b6daba68bddd14f451191fe9e5", size = 39828, upload-time = "2026-05-05T22:04:05.52Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/ca/d88d86f90f8f237985f3e347b9a4f9fa24e8d30d19ec7d477ed18aa58393/ujson-5.12.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f19e9a407a24230df0cc1ec1c0f5999872ba526b14a780f80ad6479f5eed9bc", size = 58099, upload-time = "2026-05-05T22:04:06.688Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/2d/a0a88407cee3550f7ed1e49b41157ee2d410f51905ed51fb134844255280/ujson-5.12.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8b657e870c77aaacdeea86cfad3e6d2ef9b52517e45988c9c367f7ee764fe4dd", size = 55631, upload-time = "2026-05-05T22:04:07.925Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/6d/12a3b8e72132db244ae048075e71a0079b3c5f61ff45b7ca81d5193ab3e7/ujson-5.12.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984b5a99d1e0a037c2046c3c4b34cec832565d62d5017be0a035bf3cbfab72dc", size = 59469, upload-time = "2026-05-05T22:04:09.208Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/72/310f8c21737554f2d2b4f1883e1a71e8a6ab0d8f92f0feb8aaa85e0f4b66/ujson-5.12.1-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:f48ef8a16f1d85bd7982beac7adfd3fb704058631db84c1c61c8a1b7072b1508", size = 61611, upload-time = "2026-05-05T22:04:10.836Z" },
+    { url = "https://files.pythonhosted.org/packages/50/50/ab4b2f7bab6c7a67298c8f2aca80e2082eaf6f332cf2d099762647b5301e/ujson-5.12.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f39ba3b65cc637b59731532f7e7c807786bff1d0332ab2d5b96a04d2584d78f", size = 59122, upload-time = "2026-05-05T22:04:12.137Z" },
+    { url = "https://files.pythonhosted.org/packages/21/48/5d81cbe76fc2aa9e071aa489a3041cf0712f5e0663d60d501641f92b7bb4/ujson-5.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:07f307780f85b49cba93f291718421b6f5f3b627a323b431fad937a18f6587cb", size = 1038938, upload-time = "2026-05-05T22:04:13.548Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/a7/abe1acb0e5d8b8d724b35533a44c89684c88100a5fd9f2fee7f7155528d5/ujson-5.12.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1c335caea51c31494e514b82d50763b9792d3960d2c7d9fdb6b6fb8ed50ebdd0", size = 1198416, upload-time = "2026-05-05T22:04:15.609Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/6e/087067d6ee22bd01bfba9fb1f32ce98c24ae2bcbab53bd2fbf8f7a80fe9e/ujson-5.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:19ea07e29a45d199f926aadf93a9974128438c01b83141fba32477c0ee604b33", size = 1091425, upload-time = "2026-05-05T22:04:17.909Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/d2/28938574b766980f873b68962abb4c68a944d939446768982934ad3bcd93/ujson-5.12.1-cp314-cp314-win32.whl", hash = "sha256:c8e626b6bc9bdd2e8f7393b7d99f3daa2ca4022e6203662e70de7bb3604b21b9", size = 42334, upload-time = "2026-05-05T22:04:19.85Z" },
+    { url = "https://files.pythonhosted.org/packages/49/b0/0af30bf65d96b73c28054b344ebbe24bc96780ae8a7f2973f5dad979510a/ujson-5.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:c6d3bdd020333688ee60559437021ed68a98a28fdd609b5af16de5dd58f90cba", size = 46586, upload-time = "2026-05-05T22:04:21.298Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/3b/0ee2555823724e60cc847c715c299f5792aa444bdde69c51d4aa42d885c2/ujson-5.12.1-cp314-cp314-win_arm64.whl", hash = "sha256:e3c9c894971f4ada3ded16a804ed4640e1f2b3e5239beaeec7c48296f39f4232", size = 41178, upload-time = "2026-05-05T22:04:22.597Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/3d/7547835cd0b7fa22eb1122702f81b2403c38a0027a2cc0d75acc449a4a66/ujson-5.12.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:49dd9c378e1c8e676785ff2b62cb490074229f15ab54abf45b623713cb2c36b5", size = 58565, upload-time = "2026-05-05T22:04:23.75Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/6a/1784e0b24aab50623eb47b2f7a8dc22c9d809d798854d2568a9cb7c3560f/ujson-5.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d8827904358d7da59ccf2e1fd8de59e78248036d17fecc0462e62c6721f1102", size = 56157, upload-time = "2026-05-05T22:04:25.028Z" },
+    { url = "https://files.pythonhosted.org/packages/91/2d/2c1b24df24eee309047d81460c3a1acf0d047207327edc6f3cab8a614985/ujson-5.12.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc26caebea90425662ef0b979f945f6ac832651881107d6ec9a3c4d4a4ba929c", size = 60288, upload-time = "2026-05-05T22:04:26.273Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/14/c0c603e3dff2ef98f7deee2df7795e6055abbc5825c6ef530024b3b06a15/ujson-5.12.1-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:45022aae09ac3d45bda6fbfc631088d1aff9a0465542d40bd6d295ced378c430", size = 62302, upload-time = "2026-05-05T22:04:27.516Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/0d/889bbc044561d9adc9bf413620fbd9878f352c9fd36da829d319bca2f5ad/ujson-5.12.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b22aa0f644516d3d5b29464949e4b23fe784f84b4a1030ab9ac3cb42aaedabb1", size = 59784, upload-time = "2026-05-05T22:04:28.776Z" },
+    { url = "https://files.pythonhosted.org/packages/18/35/3b1d8ff8cd6dc048f5c495af6ee6ded43055562610a7e9b78b438dc6421e/ujson-5.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7dc5cf44ea42365cd1b66e6ed3fc6ca040c86587b024a6659b98e99d31cff2cd", size = 1039759, upload-time = "2026-05-05T22:04:30.291Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/d8/3c66cdf839420a6da2d6140a54a882c15efd135bcced103bd4473d577636/ujson-5.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8df5d984ff4ac1ef292d70f30da03417038a7e1e0bc272d28ca9d34f02f41682", size = 1199121, upload-time = "2026-05-05T22:04:31.961Z" },
+    { url = "https://files.pythonhosted.org/packages/54/51/c3d1b94a4ad27dc7532e9f7d00b869463157cede2295ba6d57566afeb8cd/ujson-5.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:485f0182a0c0b54c304061cdc826d8343ce595c4055f7a24e72772a8520e5f7b", size = 1092085, upload-time = "2026-05-05T22:04:33.697Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/52/4d4a6e78290a5eef3f576f6d281e6355535db903a08483fd1bb393bf8cb9/ujson-5.12.1-cp314-cp314t-win32.whl", hash = "sha256:4e12ca368b397aed7fa1eec534ea1ba8d94977b376f9df3e93ae1acfd004ec40", size = 43243, upload-time = "2026-05-05T22:04:35.486Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/c8/849366785de52b513e5fc89d7aea0b531e71bb5641407cbdfdf47a99ede8/ujson-5.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:cec6b9b539539affc1f01a795c99574592a635ce22331b64f2b42e0af570659e", size = 47662, upload-time = "2026-05-05T22:04:37.07Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/46/36a67f5a531a15308124786f3e2b7b96414b9d23dbcdc2a182dd3ffa2e1d/ujson-5.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:696224d4cfb8883fa5c0285dff31e5ce924704dd9ccd38e9ea8b5bf4a42b12fc", size = 41680, upload-time = "2026-05-05T22:04:39.083Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/26/c9d0479236b3f5690d6a8bb45f708aabc2c91ca80d275eba24b1e9e464ab/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c419bf42ae40963fc27f70c59e24e9a97f5cf168dbce2c572f3c0ce3595912", size = 56153, upload-time = "2026-05-05T22:04:40.326Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/c8/785f4e132500aff2f1fd2bd4a4b86fe396a5519f830a098358c90ebb92ee/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0be2b4f2f547b9f0f3d902640e410e5a2fc851576cbe033c88445a23e3e7aef1", size = 57352, upload-time = "2026-05-05T22:04:42.005Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/13/b688a905653871b10b4ff0403c2ff562c17a0bd50be0d44324f3c85ca48f/ujson-5.12.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:4ea0c490c702c20495e97345acfcf0c2f3153e658ef537ff111929c48b89e10a", size = 45988, upload-time = "2026-05-05T22:04:43.36Z" },
 ]
 
 [[package]]
@@ -3528,25 +3160,52 @@ wheels = [
 
 [[package]]
 name = "urllib3"
-version = "2.6.3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "uv"
+version = "0.11.21"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/f9/f45bb1c251962ee614afd58ccd3dc06ada7869d04987efc2858a81cc4e0f/uv-0.11.21.tar.gz", hash = "sha256:083882c73373a16de4c136d54e3386a52388dead5048a07505e25578b157182f", size = 4259001, upload-time = "2026-06-11T18:18:26.468Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/a5/1c863b931f3aba6e07547929b8cb45875038de00678bfd2fbabcd76faeef/uv-0.11.21-py3-none-linux_armv6l.whl", hash = "sha256:48c36eb170a5e7a668c1d13d2c8edeb017a3e6484c224f1521b540a6bda9e50b", size = 23747368, upload-time = "2026-06-11T18:19:21.724Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/8c/66d22f9152a014fbb17b1308394efe274e860b8beb4933f051396f96dd9f/uv-0.11.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:88d8283f6ea9f0cdbb7717e6e08e916c32a8b8b7e11c72fcc6426a4c4eeb89e0", size = 22992460, upload-time = "2026-06-11T18:18:33.543Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/f7/31d62c17837c9ae79cc6d5351fc5d54e8926e78b0315b4b6c187e0d1d50d/uv-0.11.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9c11169a049ec8bf9ddc6a9f55fba9a240942ec8005faaaf4393f00ff7a4c16e", size = 21762931, upload-time = "2026-06-11T18:18:41.155Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/04/c5503fc1015095db71c280526f45537f3bb06855ce281ff1761b85d149bf/uv-0.11.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:00193e4e077c27ee3d66da356744dbf0b3aa59356dfbd9a9efb1dc8469af8ad7", size = 23716032, upload-time = "2026-06-11T18:19:17.03Z" },
+    { url = "https://files.pythonhosted.org/packages/13/ac/46132335772fcdc38e5b5ec76701a8df8e3707605909b5fed46783689501/uv-0.11.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:870f48082df673016f465b068f40ad5aa7d2d3cfbcfb4e73724630684003a2ab", size = 23330010, upload-time = "2026-06-11T18:19:00.825Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/d4/cfa1ea36706c32006dea9bf0a819b56c22af8270ea3a2b57562ce96c2d45/uv-0.11.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:af08e0d8f43da43bc68930aee56ca5f38ccfbc79d45b6e8a7d5051f1e975684f", size = 23339731, upload-time = "2026-06-11T18:18:52.395Z" },
+    { url = "https://files.pythonhosted.org/packages/96/c5/b34d3cdf05a069c583ef368e6db90242f842d7eb26b246981b3ca8799c27/uv-0.11.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4530761c565f3a519a68f36628ee51f2b467b66573e2023e9073641219b60d23", size = 24657820, upload-time = "2026-06-11T18:19:25.62Z" },
+    { url = "https://files.pythonhosted.org/packages/be/b9/89b4e3909111c14311d4a1551afb37f0669587dc1f4ae7e26ec5baea6c09/uv-0.11.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66906cfa7c29c2cf4ea5117cf5614b0b83078ff669e664e2187071fcb24c85c1", size = 25744586, upload-time = "2026-06-11T18:19:09.311Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/7b/51d53d9fb1aaf38a613c2d20b40583ee2aa47fc000724a00aecbd5e61431/uv-0.11.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:525ef0eb56ff982357a321eca953307d824ab6f58473630c69521e8085f12b0a", size = 24990030, upload-time = "2026-06-11T18:18:29.618Z" },
+    { url = "https://files.pythonhosted.org/packages/de/70/3347f736911b73df1f31c0823d6502891f3c49fdeb157fe8060b18c08d1c/uv-0.11.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9ecdefa81db7e966d1655988cad6f840316228381dd69131ebc4ae9362bbccd", size = 25110133, upload-time = "2026-06-11T18:19:13.307Z" },
+    { url = "https://files.pythonhosted.org/packages/61/b5/b92538042d78550626ec7ac98b525bcb81ded8605c7ca9d6e35a1454ba71/uv-0.11.21-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4ed98ff3165bf7b339692d0df918b87e6d36eb0bed5183466330d27d5730d57b", size = 23755172, upload-time = "2026-06-11T18:18:19.189Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/1a/5c8993f95d4384baeaf00b96df0111af3c941a34e4466cde0d52b0b6ad99/uv-0.11.21-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:0e7916874f125a6f6af4cddd95f892ef19a4bb65c146afea7e544b0f98c63d02", size = 24468447, upload-time = "2026-06-11T18:19:04.572Z" },
+    { url = "https://files.pythonhosted.org/packages/66/2c/d4db24f9aeab8fce106633cd0388df4c0cf9f0991a2b5d9f58d061a031f7/uv-0.11.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:05e2f2e0fbf7c423f8287011ba0d2d69464f26a5f13b33df05cd491fbe5a910a", size = 24564716, upload-time = "2026-06-11T18:19:29.559Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/53/c61711e81f9f8d34dd020340ace968499b2539d3bb4ac09d39339df54a9d/uv-0.11.21-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b756dd2b368d7cc4aeb48249d06e1250bfcf81f0313ff7d7ec2ccafcd3ee4c93", size = 23917742, upload-time = "2026-06-11T18:18:57.187Z" },
+    { url = "https://files.pythonhosted.org/packages/84/21/210a5562a6a0eddfbe4890eb48e67f167be0307e75f029ca46b8f6386e5d/uv-0.11.21-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:88668a27959df9188ff72b0314f6b14f6acf6090964bb0748974239183ecb51c", size = 25330418, upload-time = "2026-06-11T18:18:37.383Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/3c/81979463de0278facaa59ed3940b9c62f25a68d737d1a6f11cc3f922fba3/uv-0.11.21-py3-none-win32.whl", hash = "sha256:a00c78f3eea6db7967d98a505b01b7d80354517c7ff34f51701949f39c7b53e6", size = 22633520, upload-time = "2026-06-11T18:18:44.992Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/51/e682e060813424467f14ae964dd7022f8fc537fea5803b5aab0ba1eca9cc/uv-0.11.21-py3-none-win_amd64.whl", hash = "sha256:d956ba9470d5267cc0ea3d7572cac3bf045bc78adad5b031b5558c6df13d2e19", size = 25291878, upload-time = "2026-06-11T18:18:23.832Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/ef/8b1d92f9501963ef8694bb17ad80ba9926d049240d2da0a4f879aa37f3e2/uv-0.11.21-py3-none-win_arm64.whl", hash = "sha256:f64a851e429e6afb96f3a0b688995757ed3697bf1078509e2da8220ffc9805cd", size = 23715885, upload-time = "2026-06-11T18:18:48.596Z" },
 ]
 
 [[package]]
 name = "virtualenv"
-version = "20.36.1"
+version = "21.4.3"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "distlib" },
     { name = "filelock" },
     { name = "platformdirs" },
+    { name = "python-discovery" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4b/50/7564c805bb8966d9771caaba8a143fa5e57c848ce4e7fdf2d55a1feb2ead/virtualenv-21.4.3.tar.gz", hash = "sha256:938ff0fd3f4e0f0d3a025f67a3d2f25e3c3aabbcd5857ea6170619138d72d141", size = 7644454, upload-time = "2026-06-11T16:47:04.843Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/8d/84b0d07c6b5f685f85ddf6c87a59d3a8a895a3dfd89e759666fabe951b94/virtualenv-21.4.3-py3-none-any.whl", hash = "sha256:75f4127d4067397c64f38579ce918fec6bf9ca2cd4f48685e82952cc3c035840", size = 7625544, upload-time = "2026-06-11T16:47:01.78Z" },
 ]
 
 [[package]]
@@ -3555,9 +3214,6 @@ version = "6.0.0"
 source = { registry = "https://pypi.org/simple" }
 sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
-    { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
-    { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
     { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
     { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
     { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
@@ -3578,11 +3234,11 @@ wheels = [
 
 [[package]]
 name = "wcwidth"
-version = "0.2.14"
+version = "0.8.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
 ]
 
 [[package]]
@@ -3633,11 +3289,41 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
 ]
 
+[[package]]
+name = "zensical"
+version = "0.0.45"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "deepmerge" },
+    { name = "jinja2" },
+    { name = "markdown" },
+    { name = "pygments" },
+    { name = "pymdown-extensions" },
+    { name = "pyyaml" },
+    { name = "tomli" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/d1/ecb1889fd2208b2d577e6ff952d9bee201302eec7966b5b61cc64adfd8f5/zensical-0.0.45.tar.gz", hash = "sha256:315bce4ab0470338dd3588add38fb325f840856c375722e6802bd58a06446266", size = 3935947, upload-time = "2026-06-09T11:23:32.349Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ad/fd/6b84115e3bbe6b76ebb1265e8ff2161c0bc88dcd6499eaf29c61a66421e9/zensical-0.0.45-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c4cb2e11132f02ae824e246e016e073458e12e9de1eaf86fd39f01890d41204c", size = 12698844, upload-time = "2026-06-09T11:22:56.537Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/dc/4ddf05d77c1455c32cb26da71f2a19d355927a45a3db5b26fb258a07ce8f/zensical-0.0.45-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:799a01de2102b5f731744ad31bdbc464d0c07d484e67ba148f6923679afa6ce6", size = 12571590, upload-time = "2026-06-09T11:23:00.192Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/53/60c6cc7b2ce8b1a83eb87bff3f7289447995552fd9a30ca76ffba22ca9d5/zensical-0.0.45-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6201e79ea8a64bd3ced3f05ef4b1529da0e675d67b1395987c0ba942e4e10dc4", size = 12939590, upload-time = "2026-06-09T11:23:02.721Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/1e/e9217ed75dba323a6f9a4eee28eb40416eff99932cd0ee6c394bf07b9ead/zensical-0.0.45-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:854aaf500e4a3ce64adea1faa7a1820c7cf9a4f66be1043e4e9ba727fe9cf2b5", size = 12911669, upload-time = "2026-06-09T11:23:05.407Z" },
+    { url = "https://files.pythonhosted.org/packages/71/3c/6fc9fe2334bb4460a8a8d732e23a30d2ddc2ecf63c2eb3487d9e7405e70d/zensical-0.0.45-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a80c57fd50fc60415914388286ac10a7d8b6f70b8ca7235597d09fb12c3171b0", size = 13267643, upload-time = "2026-06-09T11:23:07.915Z" },
+    { url = "https://files.pythonhosted.org/packages/be/f9/5696114af4ede5f1bd01e641a4ff24ee8ca49810bfaa28e5be12d930c0ef/zensical-0.0.45-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58c3510f69e08b6ed8bb9596fc9393e4687f90394aa0ef2d6118b1375ad97be5", size = 12972147, upload-time = "2026-06-09T11:23:12.069Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/9e/5c6acde480c43f8c993b13260925df8db31d51ab8a9977618e9efdd98d45/zensical-0.0.45-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:01c484bb2ee85e98e21e24b397ff52ffc31101f7485935eee5d3afa6cca6cc08", size = 13117360, upload-time = "2026-06-09T11:23:15.155Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/31/ea21f102049b35a8fe5218c5331857a15eeb60deb1bb21823a4c0701e274/zensical-0.0.45-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3654b708830303759e866a58a60c483cd2a1c56a44acdaae5bbb341a3f40ebce", size = 13185593, upload-time = "2026-06-09T11:23:18.166Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/97/6ded39fe27fa8a292d17d9af713b018e4919315233b60fa4b4b0aca737a6/zensical-0.0.45-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4da1c37eca1474b487def0ef40d7ac2aff31a9d7a029cb7479ef7c354437361", size = 13326882, upload-time = "2026-06-09T11:23:21.027Z" },
+    { url = "https://files.pythonhosted.org/packages/79/80/075975032a9e20f319c0134f8ca659d295ee4908f15ab212702a2728247f/zensical-0.0.45-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f8a1966c186feebd3b795f9d420000bfd582e16eefdd9bc7a286d878faabae52", size = 13253961, upload-time = "2026-06-09T11:23:23.99Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/6a/0eab0eb311af6a07cde15ca58d5d720cbfa02cd509e4c7fb5fa20cda0b46/zensical-0.0.45-cp310-abi3-win32.whl", hash = "sha256:a1dd63a5efb8d0e5f2fadf862f02771a279dc5cbe9a982700194650065758f01", size = 12257083, upload-time = "2026-06-09T11:23:26.769Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/cd/b117e749c60b1d1e16b8450db1355f69f38376f783b8c6c8815202988933/zensical-0.0.45-cp310-abi3-win_amd64.whl", hash = "sha256:1f2c0e69839ce4274bde34d18139d3b0d96bbf02b245ada46243590c9eedebc1", size = 12498335, upload-time = "2026-06-09T11:23:29.702Z" },
+]
+
 [[package]]
 name = "zipp"
-version = "3.23.0"
+version = "4.1.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },
 ]