diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..b2a5352
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,33 @@
+name: tests
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ UV_PYTHON: "3.12"
+
+jobs:
+ lint:
+ name: lint and type-check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ cache-dependency-glob: uv.lock
+ - name: Install dependencies
+ # The train extra is needed for ty to resolve torch & co.; the cpu selector
+ # keeps that to the small CPU-only wheel, which setup-uv caches.
+ run: uv sync --locked --extra train --extra cpu
+ - name: Ruff
+ run: uv run ruff check .
+ - name: Ty
+ run: uv run ty check
diff --git a/README.md b/README.md
index b17e2e4..1d10e8c 100644
--- a/README.md
+++ b/README.md
@@ -246,7 +246,7 @@ ORCA runs a linear pipeline. Each stage receives a context dictionary and adds i
### Stage 1 — GDS generation (`GDSGenerator`)
-The geometry class's `input_parameter_iterator` samples parameter combinations (randomly or on a grid). For each combination, `create_gds_file()` is called to produce a GDS layout file. The number of samples is set by `num_samples`.
+The geometry class's `input_parameter_iterator` samples parameter combinations (randomly or on a grid). For each combination, `create_gds_file()` is called to produce a GDS layout file. The number of samples is set by `num_samples`; `seed` makes the `"random"` picking strategy reproducible.
### Stage 2 — GDS conversion (`GDSConverter`)
diff --git a/docs/pipeline.md b/docs/pipeline.md
index 18c27be..bf43508 100644
--- a/docs/pipeline.md
+++ b/docs/pipeline.md
@@ -21,7 +21,7 @@ flowchart TB
## Stage 1 — GDS generation (`GDSGenerator`)
-The geometry class's `input_parameter_iterator` samples parameter combinations (randomly or on a grid). For each combination, `create_gds_file()` is called to produce a GDS layout file. The number of samples is set by `num_samples`.
+The geometry class's `input_parameter_iterator` samples parameter combinations (randomly or on a grid). For each combination, `create_gds_file()` is called to produce a GDS layout file. The number of samples is set by `num_samples`; `seed` makes the `"random"` picking strategy reproducible.
## Stage 2 — GDS conversion (`GDSConverter`)
diff --git a/examples/main.py b/examples/main.py
index 4e3accd..0fa1b32 100644
--- a/examples/main.py
+++ b/examples/main.py
@@ -1,18 +1,12 @@
import orca
-import numpy as np
-
from orca.geometry.presets.tf_octa_c_ports import TransformerOcta
PLOT = False
-### Example of using a custom geometry
-# geometry = MyCustomGeometry( # Python class that inherits from BaseGeometry
-# name = "my_geometry",
-# stackup_xml = "/path/to/stackup.xml", # XML file defining the physical layer stackup
-# simconfig_filename = "/path/to/simconfig.simcfg" # Simulation configuration file generated manually or with setupEM
-# )
+# To run the pipeline on your own geometry, subclass BaseGeometry (name, stackup_xml,
+# simconfig_filename, input_parameter_iterator, create_gds_file) and pass an instance
+# to orca_instance.run() below; see docs/custom_class.md for a complete example.
-# hyperparameters = {'learning_rate': 0.0008166998266605425, 'batch_size': 128, 'epochs': 10, 'num_layers': 4, 'hidden_size': 512, 'activation_function': 'GELU'}
hyperparameters = {
"learning_rate": 0.0005,
"batch_size": 256,
@@ -24,7 +18,6 @@
def main():
# Use predefined geometry from examples
- np.random.seed(40)
try:
import torch # optional: only installed with ORCA's "train" extra
torch.manual_seed(40)
@@ -34,7 +27,7 @@ def main():
orca_instance = orca.ORCA(
[
- orca.GDSGenerator(num_samples=6000),
+ orca.GDSGenerator(num_samples=6000, seed=40),
orca.GDSConverter(),
#orca.PalaceSimulator(palace_executable="apptainer exec ~/Documents/git/palace/palace.sif palace"),
#orca.ModelTrainer(model=orca.OrcaMLP, hyperparameters=hyperparameters, n_train_samples=1000),
diff --git a/examples/slurm_runs/main.py b/examples/slurm_runs/main.py
index 626f75e..22a7960 100644
--- a/examples/slurm_runs/main.py
+++ b/examples/slurm_runs/main.py
@@ -1,11 +1,9 @@
import orca
-import numpy as np
-
from orca.geometry.presets.tf_octa_c_ports import TransformerOcta
+
def main():
# Use predefined geometry from examples
- np.random.seed(40)
try:
import torch # optional: only installed with ORCA's "train" extra
torch.manual_seed(40)
@@ -15,7 +13,7 @@ def main():
orca_instance = orca.ORCA(
[
- orca.GDSGenerator(num_samples=6000),
+ orca.GDSGenerator(num_samples=6000, seed=40),
orca.GDSConverter(),
# launcher="slurm" runs the simulations as srun job steps on the nodes of this allocation
# (#SBATCH --nodes in the job script). bind="numa" with num_parallel_sims=0 runs one
diff --git a/pyproject.toml b/pyproject.toml
index 65c67ed..d809d47 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,8 +36,13 @@ authors = [
]
classifiers = [
"Development Status :: 4 - Beta",
+ "Environment :: Console",
+ "Environment :: X11 Applications :: Qt",
"Intended Audience :: Developers",
- "Operating System :: Unix",
+ "Intended Audience :: Science/Research",
+ "Intended Audience :: Education",
+ "Natural Language :: English",
+ "Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
@@ -83,6 +88,13 @@ cpu = ["torch"]
cu126 = ["torch"]
cu130 = ["torch"]
+[dependency-groups]
+# Installed by `uv sync` (not by `pip install`): the linters CI runs, pinned by uv.lock.
+dev = [
+ "ruff",
+ "ty",
+]
+
[tool.uv]
conflicts = [
[{ extra = "cpu" }, { extra = "cu126" }],
@@ -134,10 +146,103 @@ orca = "src/orca"
[tool.setuptools.package-data]
"orca" = ["**/*.simcfg", "**/*.xml"]
-[tool.black]
-line-length = 79
-target-version = ['py311', 'py312', 'py313']
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-ra --strict-markers --strict-config"
+xfail_strict = true
+
+[tool.coverage.run]
+source = ["src/orca"]
+branch = true
+
+[tool.coverage.report]
+show_missing = true
+exclude_also = [
+ "if TYPE_CHECKING:",
+ "raise NotImplementedError",
+ "@(abc\\.)?abstractmethod",
+]
+
+
+[tool.ruff]
+line-length = 100
+# Resolve first-party imports out of the src layout.
+src = ["src"]
+# Scratch/vendored trees that are not part of the package.
+extend-exclude = ["Ignore"]
+
+[tool.ruff.lint]
+select = [
+ "ALL", # include all the rules, including new ones
+]
+ignore = [
+ #### modules
+ "ANN", # flake8-annotations: full annotation coverage is not a project goal
+ "COM", # flake8-commas: trailing commas are the formatter's job
+ "C90", # mccabe complexity: PLR0912/PLR0915 cover this better
+ "CPY", # flake8-copyright: no per-file copyright headers in this project
+ "DJ", # django
+ "EM", # exception messages inline, consistent with TRY003 below
+ "EXE", # flake8-executable
+ "FBT", # boolean traps: unavoidable in Qt slots and config flags
+ "PD", # pandas-vet: false positives on numpy/scikit-rf `.values`
+ "T10", # debugger
+ "TID", # flake8-tidy-imports
+
+ #### specific rules
+ "D100", # ignore missing docs
+ "D101",
+ "D102",
+ "D103",
+ "D104",
+ "D105",
+ "D106",
+ "D107",
+ "D200",
+ "D203", # incompatible with D211; D211 is the convention here
+ "D205",
+ "D212", # summary placement is not enforced; both styles exist in the tree
+ "D400",
+ "D401",
+ "D415",
+ "E402", # false positives for local imports
+ "E501", # line too long
+ "N802", # RF symbols keep their conventional casing (S, Z, Y, N, K, ...)
+ "N803",
+ "N806",
+ "PLC0415", # imports are deliberately local to keep Palace/ORCA/GUI optional
+ "PLR0911", # simulation and GUI setup code is legitimately branch-heavy
+ "PLR0912",
+ "PLR0913",
+ "PLR0915",
+ "PLR0917",
+ "PLR2004", # magic-value comparison is noise in numeric/engineering code
+ "PTH", # os.path is used throughout; migrating is a separate refactor
+ "RUF001", # ambiguous unicode: units and symbols (µ, Ω, °) are intentional
+ "RUF002",
+ "RUF003",
+ "S603", # the Xyce subprocess call is the point of the simulator
+ "TRY003", # external messages in exceptions are too verbose
+ "TD002",
+ "TD003",
+ "FIX002", # too verbose descriptions of todos
+]
+
+[tool.ruff.lint.per-file-ignores]
+# Examples are standalone scripts, not an importable package. Optional pipeline
+# stages are left in as commented-out lines to be enabled by the reader.
+"examples/*" = ["INP001", "T201", "ERA001"]
+# Tests assert, reach into private helpers deliberately, and use fixtures that
+# look like shadowed or unused arguments to the linter.
+"tests/*" = [
+ "S101", # assert is how pytest works
+ "ARG001",
+ "ARG002",
+ "PLR6301",
+ "SLF001", # pinning private behaviour (_tell, _parse_target) is the point
+ "TC003",
+ "T201", # printing is how a test script reports
+]
-[tool.isort]
-profile = "black"
-line_length = 79
+[tool.ruff.lint.pydocstyle]
+convention = "google"
diff --git a/src/orca/__init__.py b/src/orca/__init__.py
index fad95cb..343a1f8 100644
--- a/src/orca/__init__.py
+++ b/src/orca/__init__.py
@@ -1,17 +1,34 @@
# Import stuff here so that they are available at the package level (i.e. from orca import ORCA, BaseGeometry, InputParameters)
+from .geometry.base_geometry import BaseGeometry
+from .geometry.input_parameters import InputParameterIterator
from .orca import ORCA
from .pipeline.context import PipelineContext
-from .pipeline.pipeline_stage import PipelineStage
-from .pipeline.gds_gen_stage import GDSGenerator
from .pipeline.gds_conversion_stage import GDSConverter
+from .pipeline.gds_gen_stage import GDSGenerator
+from .pipeline.pipeline_stage import PipelineStage
from .pipeline.simulation_stage import PalaceSimulator
-from .geometry.base_geometry import BaseGeometry
-from .geometry.input_parameters import InputParameterIterator
+from .training.codecs import FlatReImCodec, OutputCodec, UpperTriangleReImCodec
from .training.guarantees import PhysicsGuarantees
-from .training.codecs import OutputCodec, FlatReImCodec, UpperTriangleReImCodec
from .training.spec import FrequencyMode, IOSpec
+__all__ = [
+ "ORCA",
+ "BaseGeometry",
+ "FlatReImCodec",
+ "FrequencyMode",
+ "GDSConverter",
+ "GDSGenerator",
+ "IOSpec",
+ "InputParameterIterator",
+ "OutputCodec",
+ "PalaceSimulator",
+ "PhysicsGuarantees",
+ "PipelineContext",
+ "PipelineStage",
+ "UpperTriangleReImCodec",
+]
+
# Everything that needs PyTorch (the "train" extra) is imported on first access
# instead of here, so that `import orca` works in a simulation-only install, e.g.
# on an HPC cluster. The names stay importable as `orca.ModelTrainer` etc.; a
diff --git a/src/orca/__main__.py b/src/orca/__main__.py
index 9bd6117..b71a386 100644
--- a/src/orca/__main__.py
+++ b/src/orca/__main__.py
@@ -1,6 +1,8 @@
import sys
+
from orca.gui.app import run_gui
+
def main():
"""
Main entry point for ORCA.
@@ -10,7 +12,7 @@ def main():
if len(sys.argv) == 1 or (len(sys.argv) > 1 and sys.argv[1] == "--gui"):
run_gui()
else:
- print("CLI usage not yet implemented. Use --gui to launch the graphical interface.")
+ sys.exit("CLI usage not yet implemented. Use --gui to launch the graphical interface.")
if __name__ == "__main__":
main()
diff --git a/src/orca/geometry/__init__.py b/src/orca/geometry/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/geometry/base_geometry.py b/src/orca/geometry/base_geometry.py
index a9c3e8d..c62759d 100644
--- a/src/orca/geometry/base_geometry.py
+++ b/src/orca/geometry/base_geometry.py
@@ -1,12 +1,12 @@
from __future__ import annotations
+
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import cached_property
-from typing import Any, TYPE_CHECKING
-
-from orca.geometry.input_parameters import InputParameterIterator
+from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
+ from orca.geometry.input_parameters import InputParameterIterator
from orca.training.datasets.base_dataset import BaseDataset
diff --git a/src/orca/geometry/cells/__init__.py b/src/orca/geometry/cells/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/geometry/cells/inductor.py b/src/orca/geometry/cells/inductor.py
index 4c3a57f..37d8118 100644
--- a/src/orca/geometry/cells/inductor.py
+++ b/src/orca/geometry/cells/inductor.py
@@ -125,7 +125,7 @@ def add_poly(all_geometries_list, layer, purpose, points):
return p
-def add_via(all_geometries_list, layer, purpose, p1, p2, forEM):
+def add_via(all_geometries_list, layer, purpose, p1, p2):
draw_via_array(all_geometries_list, layer, purpose, p1, p2)
@@ -172,8 +172,7 @@ def get_min_outer_diameter(N, w, s):
min_crossover_size = (2 * s + w) * (math.sqrt(2) - 1) + (s + w) + 2 * overlap_size
- if crossover_size < min_crossover_size:
- crossover_size = min_crossover_size
+ crossover_size = max(crossover_size, min_crossover_size)
if N < 3:
inner_segment_size = crossover_size
@@ -181,15 +180,14 @@ def get_min_outer_diameter(N, w, s):
feedline_spacing = crossover_size + w + 2 * s
inner_segment_size = feedline_spacing
- if N > 1:
- Di_min = inner_segment_size * (1 + math.sqrt(2))
- else:
- Di_min = 2 * (w + s) * (1 + math.sqrt(2)) # for single turn inductor
+ # single turn inductors have no inner segment
+ Di_min = (
+ inner_segment_size * (1 + math.sqrt(2)) if N > 1 else 2 * (w + s) * (1 + math.sqrt(2))
+ )
Do_min = (Di_min + 2 * N * w + 2 * (N - 1) * s)
# round to 2 decimal digits
- Do_min = math.ceil(100 * Do_min) / 100
- return Do_min
+ return math.ceil(100 * Do_min) / 100
def calculate_octa_diameter(N, w, s, Ltarget, K1=2.15522, K2=3.61868, L0=0):
@@ -203,8 +201,7 @@ def calculate_octa_diameter(N, w, s, Ltarget, K1=2.15522, K2=3.61868, L0=0):
p = -(b + Lsyn / c)
q = b * b / 4 - Lsyn * b * (K2 - 1) / (2 * c)
Dout = (-p / 2 + math.sqrt(p * p / 4 - q)) / um # output is in micron
- Dout = math.ceil(Dout * 100) / 100
- return Dout
+ return math.ceil(Dout * 100) / 100
# ====================
@@ -235,7 +232,7 @@ def symmetric_octa_IHP(N, D, w, s, includeCenterTap=False, LBE=False, forEM=Fals
try:
cell = lib.new_cell(cellname, overwrite_duplicate=True)
- except Exception:
+ except ValueError:
cell = lib.new_cell("final_" + cellname, overwrite_duplicate=True)
# list with all geometries that we created
@@ -268,29 +265,17 @@ def symmetric_octa_IHP(N, D, w, s, includeCenterTap=False, LBE=False, forEM=Fals
# Ground-ring geometry (only present when forEM). Computed up front so the
# feed length can reach the ring.
- if ring_width is None:
- frame_width = min(20, gridsnap(5 * w))
- else:
- frame_width = gridsnap(ring_width)
- if ring_spacing is None:
- frame_margin = gridsnap(D / 2)
- else:
- frame_margin = gridsnap(ring_spacing)
+ frame_width = min(20, gridsnap(5 * w)) if ring_width is None else gridsnap(ring_width)
+ frame_margin = gridsnap(D / 2) if ring_spacing is None else gridsnap(ring_spacing)
# Feed length: when forEM, extend the feedlines so the pins/ports always
# land on the OUTER edge of the ground ring; otherwise keep the default.
- if forEM:
- feed_length = gridsnap(frame_margin + frame_width)
- else:
- feed_length = 30
+ feed_length = gridsnap(frame_margin + frame_width) if forEM else 30
# --- Feedline drawing ---
- if N == 1:
- # for single turn, we draw everything on single layer TopMetal2
- feed_layer = SPIRAL_LAYER_NUM
- else:
- # for multi turn, we draw trace on TopMetal2 and feedline on TopMetal1
- feed_layer = CROSSOVER_LAYER_NUM
+ # for single turn, we draw everything on single layer TopMetal2;
+ # for multi turn, we draw trace on TopMetal2 and feedline on TopMetal1
+ feed_layer = SPIRAL_LAYER_NUM if N == 1 else CROSSOVER_LAYER_NUM
add_box(all_geometries_list, layer=feed_layer, purpose=PURPOSE_DRAWING,
p1=(x0 - w / 2 - feedline_spacing / 2, y0 - Di / 2),
@@ -304,13 +289,11 @@ def symmetric_octa_IHP(N, D, w, s, includeCenterTap=False, LBE=False, forEM=Fals
# for all N except single turn, we need via from TopMetal1 feedline to TopMetal2 trace
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 - w / 2 - feedline_spacing / 2, y0 - Di / 2),
- p2=(x0 + w / 2 - feedline_spacing / 2, y0 - Di / 2 - w),
- forEM=forEM)
+ p2=(x0 + w / 2 - feedline_spacing / 2, y0 - Di / 2 - w))
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 - w / 2 + feedline_spacing / 2, y0 - Di / 2),
- p2=(x0 + w / 2 + feedline_spacing / 2, y0 - Di / 2 - w),
- forEM=forEM)
+ p2=(x0 + w / 2 + feedline_spacing / 2, y0 - Di / 2 - w))
# create pin label in IHP PDK-style on layer IND.text
cell.add(gdspy.Label("LA", (x0 - feedline_spacing / 2, y0 - D / 2 - feed_length + w / 4),
@@ -493,22 +476,18 @@ def symmetric_octa_IHP(N, D, w, s, includeCenterTap=False, LBE=False, forEM=Fals
# add vias also
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 - crossover_size / 2, y0 - D / 2 + 2 * i * (w + s) - s),
- p2=(x0 - crossover_size / 2 + via_size, y0 - D / 2 + 2 * i * (w + s) - w - s),
- forEM=forEM)
+ p2=(x0 - crossover_size / 2 + via_size, y0 - D / 2 + 2 * i * (w + s) - w - s))
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 + crossover_size / 2, y0 - D / 2 + (2 * i + 1) * (w + s) - s),
- p2=(x0 + crossover_size / 2 - via_size, y0 - D / 2 + (2 * i + 1) * (w + s) - w - s),
- forEM=forEM)
+ p2=(x0 + crossover_size / 2 - via_size, y0 - D / 2 + (2 * i + 1) * (w + s) - w - s))
else:
# add vias also
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 - crossover_size / 2, y0 - D / 2 - w - s + (2 * i - 1) * (w + s)),
- p2=(x0 - crossover_size / 2 + via_size, y0 - D / 2 + (2 * i - 1) * (w + s) - s),
- forEM=forEM)
+ p2=(x0 - crossover_size / 2 + via_size, y0 - D / 2 + (2 * i - 1) * (w + s) - s))
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 + crossover_size / 2, y0 - D / 2 - w - s + (2 * i) * (w + s)),
- p2=(x0 + crossover_size / 2 - via_size, y0 - D / 2 + (2 * i) * (w + s) - s),
- forEM=forEM)
+ p2=(x0 + crossover_size / 2 - via_size, y0 - D / 2 + (2 * i) * (w + s) - s))
# top side
for i in range(1, num_top + 1):
@@ -549,22 +528,18 @@ def symmetric_octa_IHP(N, D, w, s, includeCenterTap=False, LBE=False, forEM=Fals
# add via also
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 - crossover_size / 2, y0 + D / 2 - (2 * i - 1) * (w + s) + w + s),
- p2=(x0 - crossover_size / 2 + via_size, y0 + D / 2 - (2 * i - 1) * (w + s) + s),
- forEM=forEM)
+ p2=(x0 - crossover_size / 2 + via_size, y0 + D / 2 - (2 * i - 1) * (w + s) + s))
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 + crossover_size / 2, y0 + D / 2 - (2 * i) * (w + s) + w + s),
- p2=(x0 + crossover_size / 2 - via_size, y0 + D / 2 - (2 * i) * (w + s) + s),
- forEM=forEM)
+ p2=(x0 + crossover_size / 2 - via_size, y0 + D / 2 - (2 * i) * (w + s) + s))
else:
# add via also
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 - crossover_size / 2, y0 + D / 2 - w - s - (2 * i - 1) * (w + s) + w + s),
- p2=(x0 - crossover_size / 2 + via_size, y0 + D / 2 - w - s - (2 * i - 1) * (w + s) + s),
- forEM=forEM)
+ p2=(x0 - crossover_size / 2 + via_size, y0 + D / 2 - w - s - (2 * i - 1) * (w + s) + s))
add_via(all_geometries_list, layer=VIA_LAYER_NUM, purpose=PURPOSE_DRAWING,
p1=(x0 + crossover_size / 2, y0 + D / 2 - w - s - (2 * i) * (w + s) + w + s),
- p2=(x0 + crossover_size / 2 - via_size, y0 + D / 2 - w - s - (2 * i) * (w + s) + s),
- forEM=forEM)
+ p2=(x0 + crossover_size / 2 - via_size, y0 + D / 2 - w - s - (2 * i) * (w + s) + s))
# one straight segment at outer turn
if is_even(N):
diff --git a/src/orca/geometry/cells/transformer.py b/src/orca/geometry/cells/transformer.py
index 894fc38..f08d02c 100644
--- a/src/orca/geometry/cells/transformer.py
+++ b/src/orca/geometry/cells/transformer.py
@@ -1,14 +1,16 @@
import gdsfactory as gf
import klayout.db as kdb
import numpy as np
+
from orca.geometry.layers import SG13G2
GRID_NM = 10 # 10 nm manufacturing grid = 0.01 µm
def _ensure_active_pdk() -> None:
- """gdsfactory refuses to extrude paths without an active PDK. We only use it as a
- polygon generator with explicit SG13G2 layer tuples, so its generic PDK is enough."""
+ """Gdsfactory refuses to extrude paths without an active PDK. We only use it as a
+ polygon generator with explicit SG13G2 layer tuples, so its generic PDK is enough.
+ """
try:
gf.get_active_pdk()
except ValueError:
@@ -61,20 +63,24 @@ def tf_octa_c(
Octagon Transformer Component with Feed Extensions and Overlap Checks.
Args:
- di: Diameter of input (lower/bot) winding (trace center to center).
- do: Diameter of output (upper/top) winding (trace center to center).
- dis: Displacement (offset between winding centers).
- wi: Trace width of lower winding.
- wic: Trace width of lower center tap.
- fi: Feed type lower (0=no, 1=fwd, 2=rev, 3=both).
- wo: Trace width of upper winding.
- woc: Trace width of upper center tap.
- fo: Feed type upper (0=no, 1=fwd, 2=rev, 3=both).
- fs: Feedline spacing (gap between inner sides of feed lines).
- ro: Ring spacing on upper winding side.
- ri: Ring spacing on lower winding side.
- rs: Ring spacing at side.
- rw: Ring width.
+ name: Name of the generated cell.
+ bottom_winding_diameter: Diameter of the input (lower/bot) winding (trace center to center).
+ top_winding_diameter: Diameter of the output (upper/top) winding (trace center to center).
+ center_displacement: Displacement (offset between winding centers).
+ bottom_linewidth: Trace width of the lower winding.
+ bottom_center_tap_width: Trace width of the lower center tap (<= 0.1 uses bottom_linewidth).
+ lower_feed_type: Center tap of the lower winding: 0 = none, 1 = tap from the back of
+ the winding to the right ring edge (port ``ico`` on layer 206). 2 (tap through
+ the winding's own feed gap) and 3 (both) are not implemented.
+ top_linewidth: Trace width of the upper winding.
+ upper_center_tap_width: Trace width of the upper center tap (<= 0.1 uses top_linewidth).
+ upper_feed_type: Center tap of the upper winding, same encoding as lower_feed_type
+ (port ``oci`` on layer 205 when set to 1).
+ feedline_spacing: Feedline spacing (gap between inner sides of feed lines).
+ gnd_upper_spacing: Ring spacing on the upper winding side.
+ gnd_lower_spacing: Ring spacing on the lower winding side.
+ gnd_side_spacing: Ring spacing at the side.
+ gnd_ring_width: Ring width.
"""
_ensure_active_pdk()
@@ -92,17 +98,22 @@ def tf_octa_c(
top_centertap_width = (
upper_center_tap_width if upper_center_tap_width > 0.1 else top_linewidth
)
- fo_int = int(round(upper_feed_type))
- fi_int = int(round(lower_feed_type))
+ for label, feed_type in (("lower_feed_type", lower_feed_type), ("upper_feed_type", upper_feed_type)):
+ if feed_type not in (0, 1):
+ raise NotImplementedError(
+ f"{label}={feed_type}: only 0 (no center tap) and 1 (center tap) are implemented."
+ )
+ draw_bottom_tap = lower_feed_type == 1
+ draw_top_tap = upper_feed_type == 1
# --- Safety Check: Octagon Opening Width ---
# Top Winding Gap is on the RIGHT.
- # Bot Center Tap (if fi_int & 1) goes RIGHT. It crosses Top Gap.
- fs_top = max(feedline_spacing, bottom_centertap_width)
+ # Bot Center Tap goes RIGHT. It crosses Top Gap.
+ fs_top = max(feedline_spacing, bottom_centertap_width) if draw_bottom_tap else feedline_spacing
# Bot Winding Gap is on the LEFT.
- # Top Center Tap (if fo_int & 2) goes LEFT. It crosses Bot Gap.
- fs_bot = max(feedline_spacing, top_centertap_width)
+ # Top Center Tap goes LEFT. It crosses Bot Gap.
+ fs_bot = max(feedline_spacing, top_centertap_width) if draw_top_tap else feedline_spacing
# Geometry Limits
tf_y = max(top_winding_diameter, bottom_winding_diameter) / 2.0 + gnd_side_spacing
@@ -120,18 +131,18 @@ def tf_octa_c(
# Check if linewidth is too large for winding diameter
if bottom_linewidth > bottom_winding_diameter / 3.0:
raise ValueError("bottom_linewidth is too large for input_winding_diameter.")
- elif top_linewidth > top_winding_diameter / 3.0:
+ if top_linewidth > top_winding_diameter / 3.0:
raise ValueError("upper_linewidth is too large for output_winding_diameter.")
# Check if center tap width is too large for winding diameter of the other winding
- if bottom_centertap_width > top_winding_diameter / 3.0:
+ if draw_bottom_tap and bottom_centertap_width > top_winding_diameter / 3.0:
raise ValueError(
"bottom_center_tap_width is too large for output_winding_diameter."
)
- elif top_centertap_width > bottom_winding_diameter / 3.0:
+ if draw_top_tap and top_centertap_width > bottom_winding_diameter / 3.0:
raise ValueError(
"upper_center_tap_width is too large for input_winding_diameter."
)
- elif abs(bottom_winding_diameter - top_winding_diameter) > 40.0:
+ if abs(bottom_winding_diameter - top_winding_diameter) > 40.0:
raise ValueError(
"input_winding_diameter and output_winding_diameter difference is too large. No sufficient coupling."
)
@@ -149,10 +160,12 @@ def create_octa_winding(
rotation_deg,
feed_target_x,
centertap_target_x,
+ centertap_width,
):
"""
Creates octagon winding AND the feed extension lines (rectangles) to the port.
gap_size: spacing between inner edges of feed lines.
+ centertap_target_x: x of the center tap port, or None for no center tap.
"""
r = diameter / 2.0
# Feed Y positions (Trace Centers)
@@ -219,13 +232,14 @@ def transform(pt):
path_l = gf.Path([start_lo, end_lo])
c << path_l.extrude(width=width, layer=layer)
- # Create center tap - find point
- p_center_local = (round(x_end + width / 2.0, 2), round(center_y, 2))
- start_ct = transform(p_center_local)
- end_ct = (round(centertap_target_x, 2), round(center_y, 2))
+ # Center tap (optional): from the back of the winding straight out to its port
+ if centertap_target_x is not None:
+ p_center_local = (round(x_end + width / 2.0, 2), round(center_y, 2))
+ start_ct = transform(p_center_local)
+ end_ct = (round(centertap_target_x, 2), round(center_y, 2))
- path_ct = gf.Path([start_ct, end_ct])
- c << path_ct.extrude(width=width, layer=layer)
+ path_ct = gf.Path([start_ct, end_ct])
+ c << path_ct.extrude(width=centertap_width, layer=layer)
return start_up, start_lo # Return actual start points for reference if needed
@@ -243,7 +257,8 @@ def transform(pt):
center_y=0,
rotation_deg=0,
feed_target_x=port_xr - gnd_ring_width,
- centertap_target_x=port_xl + gnd_ring_width,
+ centertap_target_x=port_xl + gnd_ring_width if draw_top_tap else None,
+ centertap_width=top_centertap_width,
)
# Bot Winding (Rot 180, Gap Left -> connects to port_xl)
@@ -256,7 +271,8 @@ def transform(pt):
center_y=0,
rotation_deg=180,
feed_target_x=port_xl + gnd_ring_width,
- centertap_target_x=port_xr - gnd_ring_width,
+ centertap_target_x=port_xr - gnd_ring_width if draw_bottom_tap else None,
+ centertap_width=bottom_centertap_width,
)
# -------------------------------------------------
@@ -287,7 +303,7 @@ def add_port_marker(center, width, layer, orientation):
c.shapes(layer_index).insert(kdb.Path([start, end], 0))
### TOP LAYER (ports on the RIGHT) -> Port 1 and 2 -> Layer 201, 202
- # OP (Top, Right, Upper)
+ # OP: top winding, right side, upper port
c.add_port(
name="op",
center=(round(port_xr - gnd_ring_width, 2), round(y_top_p, 2)),
@@ -301,7 +317,7 @@ def add_port_marker(center, width, layer, orientation):
(201, 0),
0,
)
- # ON (Top, Right, Lower)
+ # ON: top winding, right side, lower port
c.add_port(
name="on",
center=(round(port_xr - gnd_ring_width, 2), round(y_top_n, 2)),
@@ -315,20 +331,21 @@ def add_port_marker(center, width, layer, orientation):
(202, 0),
0,
)
- # Center Tap (Top, Center)
- c.add_port(
- name="oci",
- center=(round(port_xl + gnd_ring_width, 2), 0.0),
- width=top_centertap_width,
- orientation=180,
- layer=(205, 0),
- )
- add_port_marker(
- (round(port_xl + gnd_ring_width, 2), 0.0), top_centertap_width, (205, 0), 180
- )
+ # Center Tap (Top, Center) -> Layer 205
+ if draw_top_tap:
+ c.add_port(
+ name="oci",
+ center=(round(port_xl + gnd_ring_width, 2), 0.0),
+ width=top_centertap_width,
+ orientation=180,
+ layer=(205, 0),
+ )
+ add_port_marker(
+ (round(port_xl + gnd_ring_width, 2), 0.0), top_centertap_width, (205, 0), 180
+ )
### BOT LAYER (ports on the LEFT) -> Port 3 and 4 -> Layer 203, 204
- # IP (Bot, Left, Upper)
+ # IP: bottom winding, left side, upper port
c.add_port(
name="ip",
center=(round(port_xl + gnd_ring_width, 2), round(y_bot_p, 2)),
@@ -342,7 +359,7 @@ def add_port_marker(center, width, layer, orientation):
(203, 0),
180,
)
- # IN (Bot, Left, Lower)
+ # IN: bottom winding, left side, lower port
c.add_port(
name="in",
center=(round(port_xl + gnd_ring_width, 2), round(y_bot_n, 2)),
@@ -356,60 +373,18 @@ def add_port_marker(center, width, layer, orientation):
(204, 0),
180,
)
- # Center Tap (Bot, Center)
- c.add_port(
- name="ico",
- center=(round(port_xr - gnd_ring_width, 2), 0.0),
- width=bottom_centertap_width,
- orientation=0,
- layer=(206, 0),
- )
- add_port_marker(
- (round(port_xr - gnd_ring_width, 2), 0.0), bottom_centertap_width, (206, 0), 0
- )
-
- # -------------------------------------------------
- # 5. Center Taps (ici, ico, oci, oco)
- # -------------------------------------------------
- # Top Winding (Layer Top)
- # "Left" edge of Top winding (Back of C-shape)
- # top_back_x = center_displacement/2.0 - output_winding_diameter/2.0
-
- # OCI (Top, goes Left?)
- # if fo_int & 1:
-
- # else:
- # # Default placeholder port
- # c.add_port(name="oci", center=(top_back_x - upper_linewidth/2.0 , 0), width=0.25, orientation=180, layer=(205, 0))
- # add_port_marker((top_back_x - upper_linewidth/2.0 , 0), 0.25, (205, 0))
-
- # # OCO (Top, goes Right)
- # if fo_int & 2:
- # c << gf.Path([(top_back_x, 0), (port_xr, 0)]).extrude(width=woc_int, layer=LAYER_TOP)
- # c.add_port(name="oco", center=(port_xr, 0), width=woc_int, orientation=0, layer=(207, 0))
- # add_port_marker((port_xr, 0), woc_int, (207, 0))
- # else:
- # c.add_port(name="oco", center=(top_back_x + upper_linewidth/2.0, 0), width=0.25, orientation=0, layer=(207, 0))
- # add_port_marker((top_back_x + upper_linewidth/2.0, 0), 0.25, (207, 0))
-
- # Bot Winding (Layer Bot)
- # bot_back_x = -center_displacement/2.0 + input_winding_diameter/2
-
- # ICO (Bot, goes Right)
- # if fi_int & 1:
-
- # else:
- # c.add_port(name="ico", center=(bot_back_x + bottom_linewidth/2.0, 0), width=0.25, orientation=0, layer=(206, 0))
- # add_port_marker((bot_back_x + bottom_linewidth/2.0, 0), 0.25, (206, 0))
-
- # # ICI (Bot, goes Left)
- # if fi_int & 2:
- # c << gf.Path([(port_xl, 0), (bot_back_x, 0)]).extrude(width=wic_int, layer=LAYER_BOT)
- # c.add_port(name="ici", center=(port_xl, 0), width=wic_int, orientation=180, layer=(208, 0))
- # add_port_marker((port_xl, 0), wic_int, (208, 0))
- # else:
- # c.add_port(name="ici", center=(bot_back_x - bottom_linewidth/2.0, 0), width=0.25, orientation=180, layer=(208, 0))
- # add_port_marker((bot_back_x - bottom_linewidth/2.0, 0), 0.25, (208, 0))
+ # Center Tap (Bot, Center) -> Layer 206
+ if draw_bottom_tap:
+ c.add_port(
+ name="ico",
+ center=(round(port_xr - gnd_ring_width, 2), 0.0),
+ width=bottom_centertap_width,
+ orientation=0,
+ layer=(206, 0),
+ )
+ add_port_marker(
+ (round(port_xr - gnd_ring_width, 2), 0.0), bottom_centertap_width, (206, 0), 0
+ )
# -------------------------------------------------
# 6. Ground Ring
@@ -463,4 +438,4 @@ def add_port_marker(center, width, layer, orientation):
# so the cutout geometry and reference offsets stay untouched.
_snap_inplace(c)
- return c
\ No newline at end of file
+ return c
diff --git a/src/orca/geometry/input_parameters.py b/src/orca/geometry/input_parameters.py
index fac3ac9..f345ba6 100644
--- a/src/orca/geometry/input_parameters.py
+++ b/src/orca/geometry/input_parameters.py
@@ -1,8 +1,14 @@
+from __future__ import annotations
+
import threading
from itertools import product
-from typing import Any
+from typing import TYPE_CHECKING, Any
+
import numpy as np
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
class InputParameterIterator:
"""
@@ -16,6 +22,7 @@ def __init__(
self,
picking_strategy: str = "grid",
frequency: list | range | np.ndarray | None = None,
+ seed: int | None = None,
**input_values,
):
"""
@@ -24,16 +31,19 @@ def __init__(
Args:
picking_strategy (str): Strategy for picking parameters ('grid', 'random', etc.).
frequency (list|range|np.ndarray|None): Optional frequency values to include as an additional input dimension. Does not get returned by __next__ (since it's handled by palace) but is considered for min/max calculations.
+ seed (int|None): Seed for the 'random' picking strategy. None draws fresh entropy on every set_sample_count() call.
+ **input_values: One list, range or numpy array of possible values per geometry parameter.
"""
# Check if all input_values are lists or ranges
for name, values in input_values.items():
if not isinstance(values, (list, range, np.ndarray)):
- raise ValueError(
+ raise TypeError(
f"Input kwarg '{name}' must be a list, range, or numpy array of possible values. Found type: {type(values)} (value: {values})"
)
self.picking_strategy = picking_strategy
self.frequency = frequency
+ self.seed = seed
self.n_inputs = len(input_values)
self.input_values = input_values
self.input_names = list(input_values.keys())
@@ -41,21 +51,24 @@ def __init__(
self._lock = threading.Lock() # For thread-safe iteration
# Created after set_sample_count is called
- self._iterator = None
+ self._iterator: Iterator[Any] | None = None
- def set_sample_count(self, n_samples: int):
+ def set_sample_count(self, n_samples: int, seed: int | None = None):
"""
Sets the number of samples to generate. This is used for strategies
that depend on the total number of samples, such as 'uniform_grid' and 'random'.
Args:
n_samples (int): Number of samples to generate.
+ seed (int|None): Overrides the seed given to __init__ for the 'random' strategy.
"""
self.n_samples = n_samples
+ if seed is not None:
+ self.seed = seed
# Reinitialize the iterator based on the picking strategy
if self.picking_strategy == "step_grid":
self._iterator = self.step_grid()
- elif self.picking_strategy == "uniform_grid" or self.picking_strategy == "grid":
+ elif self.picking_strategy in ("uniform_grid", "grid"):
self._iterator = self.uniform_grid()
elif self.picking_strategy == "random":
self._iterator = self.random_sampling()
@@ -72,24 +85,24 @@ def __iter__(self):
def __next__(self) -> dict[str, Any]:
with self._lock: # Ensure thread-safe access
+ if self._iterator is None:
+ raise RuntimeError(
+ "set_sample_count() must be called before iterating over the input parameters."
+ )
self.n_geometries_created += 1 # May be used for logging or tracking
- try:
- params = next(
- self._iterator
- ) # Raises StopIteration when exhausted, which is propagated to this iterator
- # Convert numpy types to Python native types to avoid type issues with downstream libraries
- params = [
- param.item() if isinstance(param, np.generic) else param
- for param in params
- ]
- return dict(zip(self.input_names, params))
- except StopIteration:
- raise
+ # Raises StopIteration when exhausted, which is propagated to this iterator
+ params = next(self._iterator)
+ # Convert numpy types to Python native types to avoid type issues with downstream libraries
+ params = [
+ param.item() if isinstance(param, np.generic) else param for param in params
+ ]
+ return dict(zip(self.input_names, params, strict=True))
def get_min_max_values(self) -> tuple[list[float], list[float]]:
"""
Returns the minimum and maximum values for each input parameter.
Useful for normalization purposes.
+
Returns:
tuple: A tuple containing two lists - (min_values, max_values).
"""
@@ -131,7 +144,6 @@ def uniform_grid(self):
This method creates a grid by uniformly sampling each parameter's range by
selecting values such that the total number of samples is approximately equal to num_samples.
"""
-
# Calculate number of steps per parameter (nth root of n_samples)
steps_per_param = int(np.ceil(self.n_samples ** (1 / self.n_inputs)))
@@ -146,9 +158,7 @@ def uniform_grid(self):
sampled_values = values[indices]
param_samples.append(sampled_values)
- result = product(*param_samples)
-
- return result
+ return product(*param_samples)
def random_sampling(self):
"""
@@ -157,10 +167,11 @@ def random_sampling(self):
Given a dict of {"name": range(start, end)}, it randomly picks values from each range
and returns a dict of {"name": value} for each sample.
"""
+ rng = np.random.default_rng(self.seed)
for _ in range(self.n_samples):
sampled_params = []
for name in self.input_names:
values = self.input_values[name]
- sampled_value = np.random.choice(values)
+ sampled_value = rng.choice(values)
sampled_params.append(sampled_value)
yield sampled_params
diff --git a/src/orca/geometry/presets/__init__.py b/src/orca/geometry/presets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/geometry/presets/inductor_octa.py b/src/orca/geometry/presets/inductor_octa.py
index 7d59202..4adcda8 100644
--- a/src/orca/geometry/presets/inductor_octa.py
+++ b/src/orca/geometry/presets/inductor_octa.py
@@ -1,10 +1,9 @@
-from dataclasses import dataclass, field
-from typing import Any, TYPE_CHECKING
-import numpy as np
import os
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
from orca import BaseGeometry
-from orca.geometry.cells.inductor import symmetric_octa_IHP, get_min_outer_diameter
+from orca.geometry.cells.inductor import get_min_outer_diameter, symmetric_octa_IHP
from orca.geometry.input_parameters import InputParameterIterator
if TYPE_CHECKING:
@@ -33,7 +32,6 @@ def _input_parameters() -> InputParameterIterator:
)
-
@dataclass
class InductorOcta(BaseGeometry):
"""
@@ -71,16 +69,15 @@ def create_dataset(self) -> "BaseDataset":
)
@staticmethod
- def create_gds_file(name: str, output_path: str, params: dict[str, Any]) -> str:
- N = int(round(params["turns"]))
+ def create_gds_file(name: str, output_path: str, params: dict[str, Any]) -> str: # noqa: ARG004 - the cell name is derived from the parameters
+ N = round(params["turns"])
w = float(params["width"])
s = float(params["space"])
D = float(params["diameter"])
# clamp the outer diameter to the minimum buildable (DRC-valid) value
do_min = get_min_outer_diameter(N, w, s)
- if D < do_min:
- D = do_min
+ D = max(D, do_min)
symmetric_octa_IHP(
N=N, D=D, w=w, s=s,
diff --git a/src/orca/geometry/presets/tf_octa_c_ports.py b/src/orca/geometry/presets/tf_octa_c_ports.py
index 3de4458..99e2e1c 100644
--- a/src/orca/geometry/presets/tf_octa_c_ports.py
+++ b/src/orca/geometry/presets/tf_octa_c_ports.py
@@ -1,31 +1,14 @@
-from dataclasses import dataclass, field
-from typing import Any, TYPE_CHECKING
-import numpy as np
import os
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
from orca import BaseGeometry
from orca.geometry.cells.transformer import tf_octa_c
from orca.geometry.input_parameters import InputParameterIterator
-from orca.utils.postprocessing import *
if TYPE_CHECKING:
from orca.training.datasets.base_dataset import BaseDataset
-# import gdstk
-# def _snap_gds_inplace(path: str, grid_nm: int = 10) -> None:
-# """Snap all polygon vertices in a GDS file to the nearest grid_nm grid.
-
-# gf.Path.extrude() produces off-grid vertices for angled octagon segments
-# (e.g. width/2 * sin(22.5°) = 0.957 µm is not on the 10 nm grid).
-# gdstk is always available as a gdsfactory dependency.
-# """
-# grid_um = grid_nm / 1000.0
-# lib = gdstk.read_gds(path)
-# for cell in lib.cells:
-# for poly in cell.polygons:
-# poly.points = np.round(poly.points / grid_um) * grid_um
-# lib.write_gds(path)
-
# Built per instance rather than shared as a class attribute: a dataclass default
# holds one object for every instance of the class, so two geometries would share
# one iterator. The dataset is built per instance too, by create_dataset().
@@ -49,7 +32,6 @@ def _input_parameters() -> InputParameterIterator:
)
-
@dataclass
class TransformerOcta(BaseGeometry):
"""
@@ -102,45 +84,5 @@ def create_gds_file(name: str, output_path: str, params: dict[str, Any]) -> str:
gnd_side_spacing=40,
gnd_ring_width=20,
)
- # c.show()
c.write_gds(output_path, with_metadata=False)
return output_path
-
- # def postprocess_outputs(self, output, frequency_points=None):
- # """
- # Converts model outputs (Re/Im) into a .sNp Touchstone file format.
- # Plots the S-parameters for visualization.
-
- # Parameters
- # ----------
- # output : dict
- # Dictionary containing S-parameters split into real and imaginary parts.
- # Example keys: 'S11_real', 'S11_imag', ..., 'SNN_real', 'SNN_imag'.
- # Each value is a 1D array of length equal to len(f).
- # f : array-like
- # 1D array of frequencies corresponding to the S-parameters.
- # filename : str, optional
- # Name of the Touchstone file to save, default "output.sNp".
- # """
- # # Frequency points are just from 1 to 200 in 1 GHz steps
- # if frequency_points is None:
- # frequency_points = np.arange(1, 201) # 1 GHz to 200 GHz
- # N, ntwk, output_dict = s_param_dict_to_network(output, frequency_points)
- # filename = f"{self.name}.s{N}p"
- # ntwk.write_touchstone(filename)
-
- # # N, ntwk = single_ended_to_mixed_mode(ntwk)
- # plot_rfic_transformer_metrics(ntwk)
- # # plot_diff_s_params_and_k(ntwk)
-
- # # Write Touchstone
- # print(f"Touchstone file saved as {filename}")
-
- # return output_dict
-
-# if __name__ == "__main__":
-# geometry = TransformerOcta()
-# input_params = np.array([70.6, 74.6, 13.2, 6.4, 5.4]) # Example input parameters
-# onnx_session = onnxruntime.InferenceSession("/home/david/Documents/git/ORCA/output/tf_octa_c_ports/models/tf_octa_c_ports.onnx")
-# ntwk = geometry.inference_snp(onnx_session, input_params)
-# plot_rfic_transformer_metrics(ntwk)
\ No newline at end of file
diff --git a/src/orca/gui/__init__.py b/src/orca/gui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/gui/app.py b/src/orca/gui/app.py
index 4cfd6ae..c4750b9 100644
--- a/src/orca/gui/app.py
+++ b/src/orca/gui/app.py
@@ -1,11 +1,14 @@
import sys
+
from PySide6.QtWidgets import QApplication
-from orca.gui.theme import apply_theme
+
from orca.gui.pipeline_window import PipelineWindow
+from orca.gui.theme import apply_theme
+
def run_gui():
app = QApplication(sys.argv)
-
+
window = PipelineWindow()
apply_theme(window)
window.show()
diff --git a/src/orca/gui/pipeline_window.py b/src/orca/gui/pipeline_window.py
index 78061af..0888c90 100644
--- a/src/orca/gui/pipeline_window.py
+++ b/src/orca/gui/pipeline_window.py
@@ -1,16 +1,26 @@
-from PySide6.QtWidgets import (
- QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QScrollArea, QLabel, QProgressBar, QMessageBox, QPlainTextEdit
-)
+import logging
+
from PySide6.QtCore import QThread, Signal
from PySide6.QtGui import QFontDatabase
+from PySide6.QtWidgets import (
+ QHBoxLayout,
+ QLabel,
+ QMainWindow,
+ QMessageBox,
+ QPlainTextEdit,
+ QProgressBar,
+ QPushButton,
+ QScrollArea,
+ QVBoxLayout,
+ QWidget,
+)
from orca import ORCA
from orca.gui.utils import get_available_stages
from orca.gui.widgets.geometry_selector import GeometrySelector
from orca.gui.widgets.stage_widget import StageConfigWidget
from orca.logger import logger
-import logging
+
class LogSignalHandler(logging.Handler):
def __init__(self, signal):
@@ -37,12 +47,12 @@ def __init__(self, orca_instance, geometry):
def run(self):
try:
self.orca.run(
- geometry=self.geometry,
+ geometry=self.geometry,
progress_callback=self.progress_callback,
overwrite_callback=self.overwrite_callback
)
self.finished.emit()
- except Exception as e:
+ except Exception as e: # noqa: BLE001 - worker thread: report every failure to the GUI
import traceback
self.error.emit(str(e) + "\n" + traceback.format_exc())
@@ -53,10 +63,10 @@ def overwrite_callback(self, base_dir):
self._overwrite_result = None
self._waiting_for_overwrite = True
self.confirm_overwrite.emit(base_dir)
-
+
while self._waiting_for_overwrite:
self.msleep(100)
-
+
return self._overwrite_result
def set_overwrite_result(self, result):
@@ -70,19 +80,19 @@ def __init__(self):
super().__init__()
self.setWindowTitle("ORCA Pipeline")
self.resize(1000, 800)
-
+
self.stages_widgets = []
self._log_handler = None
self.init_ui()
self.setup_logging()
-
+
def init_ui(self):
central_widget = QWidget()
self.setCentralWidget(central_widget)
-
+
main_layout = QHBoxLayout()
central_widget.setLayout(main_layout)
-
+
# Left Panel: Configuration
config_scroll = QScrollArea()
config_scroll.setWidgetResizable(True)
@@ -90,53 +100,53 @@ def init_ui(self):
config_layout = QVBoxLayout()
config_widget.setLayout(config_layout)
config_scroll.setWidget(config_widget)
-
+
# Geometry Section
config_layout.addWidget(QLabel("
1. Geometry Selection
"))
self.geometry_selector = GeometrySelector()
config_layout.addWidget(self.geometry_selector)
-
+
# Stages Section
config_layout.addWidget(QLabel("2. Pipeline Stages
"))
config_layout.addWidget(QLabel("Select active stages and configure parameters:"))
-
+
available_stages = get_available_stages()
for stage_cls in available_stages:
sw = StageConfigWidget(stage_cls)
self.stages_widgets.append(sw)
config_layout.addWidget(sw)
-
+
config_layout.addStretch()
-
+
# Right Panel: Logs and button
right_panel = QWidget()
right_layout = QVBoxLayout()
right_panel.setLayout(right_layout)
-
+
# Log output
self.log_output = QPlainTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setPlaceholderText("Logs will appear here...")
self.log_output.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.log_output.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
-
+
# Progress bar that uses the callbacks
self.progress_bar = QProgressBar()
self.progress_label = QLabel("Ready")
-
+
# Run Button
self.btn_run = QPushButton("Run Pipeline")
self.btn_run.setMinimumHeight(50)
self.btn_run.setStyleSheet("font-size: 16px; font-weight: bold;")
self.btn_run.clicked.connect(self.run_pipeline)
-
+
# Right side: "Console"-style log output and progress
right_layout.addWidget(QLabel("Logs & Status
"))
right_layout.addWidget(self.log_output)
right_layout.addWidget(self.progress_label)
right_layout.addWidget(self.progress_bar)
right_layout.addWidget(self.btn_run)
-
+
# Add sub-widgets to main layout
main_layout.addWidget(config_scroll, 1)
main_layout.addWidget(right_panel, 1)
@@ -157,38 +167,38 @@ def run_pipeline(self):
if not geometry:
QMessageBox.warning(self, "Invalid Geometry", "Please select a valid geometry first.")
return
-
+
stages = []
for sw in self.stages_widgets:
stage_instance = sw.get_instance()
if stage_instance:
stages.append(stage_instance)
-
+
if not stages:
QMessageBox.warning(self, "No Stages", "Please select at least one pipeline stage.")
return
-
+
# Instantiate ORCA
orca_instance = ORCA(stages)
-
+
# Disable button
self.btn_run.setEnabled(False)
self.log_output.clear()
self.log_output.appendPlainText("Starting pipeline...")
-
+
self.worker = PipelineWorker(orca_instance, geometry)
self.worker.progress.connect(self.update_progress)
self.worker.confirm_overwrite.connect(self.handle_overwrite_confirmation)
self.worker.finished.connect(self.pipeline_finished)
self.worker.error.connect(self.pipeline_error)
self.worker.start()
-
+
def handle_overwrite_confirmation(self, base_dir):
reply = QMessageBox.question(
- self,
- "Overwrite Confirmation",
+ self,
+ "Overwrite Confirmation",
f"Output directory {base_dir} already exists. Stages may overwrite existing files. Continue?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
self.worker.set_overwrite_result(reply == QMessageBox.StandardButton.Yes)
@@ -199,19 +209,19 @@ def update_progress(self, stage_name, current, total, message):
self.progress_bar.setValue(int(current / total * 100))
else:
self.progress_bar.setValue(0)
-
+
def pipeline_finished(self):
self.btn_run.setEnabled(True)
self.progress_label.setText("Pipeline Completed Successfully")
self.progress_bar.setValue(100)
QMessageBox.information(self, "Success", "ORCA Pipeline finished successfully!")
-
+
def pipeline_error(self, error_msg):
self.btn_run.setEnabled(True)
self.progress_label.setText("Error Occurred")
self.log_output.appendPlainText(f"ERROR: {error_msg}")
QMessageBox.critical(self, "Pipeline Error", f"An error occurred:\n{error_msg}")
-
+
def closeEvent(self, event):
if self._log_handler is not None:
logger.removeHandler(self._log_handler)
diff --git a/src/orca/gui/theme.py b/src/orca/gui/theme.py
index 944c5f2..7c4bcee 100644
--- a/src/orca/gui/theme.py
+++ b/src/orca/gui/theme.py
@@ -109,4 +109,4 @@
def apply_theme(widget) -> None:
- widget.setStyleSheet(THEME_STYLESHEET)
\ No newline at end of file
+ widget.setStyleSheet(THEME_STYLESHEET)
diff --git a/src/orca/gui/utils.py b/src/orca/gui/utils.py
index d79adf0..af8b6f0 100644
--- a/src/orca/gui/utils.py
+++ b/src/orca/gui/utils.py
@@ -1,27 +1,27 @@
import importlib.util
-import sys
import inspect
+import sys
from pathlib import Path
-from typing import List, Type, Any
+from typing import Any
from orca.geometry.base_geometry import BaseGeometry
from orca.geometry.presets.inductor_octa import InductorOcta
from orca.logger import logger
-from orca.pipeline.pipeline_stage import PipelineStage
+from orca.pipeline.gds_conversion_stage import GDSConverter
# Import all pipeline stages
from orca.pipeline.gds_gen_stage import GDSGenerator
-from orca.pipeline.gds_conversion_stage import GDSConverter
+from orca.pipeline.pipeline_stage import PipelineStage
from orca.pipeline.simulation_stage import PalaceSimulator
# The training stages need PyTorch (the "train" extra). Without it the GUI still
# offers the GDS generation, conversion and simulation stages.
try:
- from orca.pipeline.training_stage import ModelTrainer
from orca.pipeline.export_onnx_stage import OnnxExporter
from orca.pipeline.test_model_stage import ModelTester
+ from orca.pipeline.training_stage import ModelTrainer
except ModuleNotFoundError as e:
- _TRAINING_STAGES: List[Type[PipelineStage]] = []
+ _TRAINING_STAGES: list[type[PipelineStage]] = []
logger.warning(
f"Training stages are unavailable ({e}). Install ORCA's optional "
"training dependencies with `pip install -e '.[train]'` to enable them."
@@ -29,46 +29,46 @@
else:
_TRAINING_STAGES = [ModelTrainer, OnnxExporter, ModelTester]
-# Import all preset geometries
+# Import all preset geometries
from orca.geometry.presets.tf_octa_c_ports import TransformerOcta
-def load_class_from_file(file_path: str, base_class: Type) -> Type[Any] | None:
+
+def load_class_from_file(file_path: str, base_class: type) -> type[Any] | None:
"""
Loads a class that inherits from `base_class` from a given file path.
"""
path = Path(file_path)
- if not path.exists() or not path.suffix == ".py":
+ if not path.exists() or path.suffix != ".py":
return None
# Module name from file name
module_name = path.stem
-
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
if not spec or not spec.loader:
return None
-
+
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
- except Exception as e:
- print(f"Error loading module {file_path}: {e}")
+ except Exception as e: # noqa: BLE001 - a broken user file must not take the GUI down
+ logger.error(f"Error loading module {file_path}: {e}")
return None
- for name, obj in inspect.getmembers(module, inspect.isclass):
- if issubclass(obj, base_class) and obj is not base_class:
- # Avoid importing abstract classes or the base class itself if it's imported in the file
- if not inspect.isabstract(obj):
- return obj
+ for _name, obj in inspect.getmembers(module, inspect.isclass):
+ # Avoid importing abstract classes or the base class itself if it's imported in the file
+ if issubclass(obj, base_class) and obj is not base_class and not inspect.isabstract(obj):
+ return obj
return None
-def get_available_stages() -> List[Type[PipelineStage]]:
+def get_available_stages() -> list[type[PipelineStage]]:
"""
Returns a list of available PipelineStage subclasses.
"""
return [GDSGenerator, GDSConverter, PalaceSimulator, *_TRAINING_STAGES]
-def get_preset_geometries() -> List[Type[BaseGeometry]]:
+def get_preset_geometries() -> list[type[BaseGeometry]]:
"""
Returns a list of preset BaseGeometry subclasses.
"""
diff --git a/src/orca/gui/widgets/__init__.py b/src/orca/gui/widgets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/gui/widgets/geometry_selector.py b/src/orca/gui/widgets/geometry_selector.py
index bc4d1bb..eb6b929 100644
--- a/src/orca/gui/widgets/geometry_selector.py
+++ b/src/orca/gui/widgets/geometry_selector.py
@@ -1,9 +1,18 @@
from PySide6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QComboBox,
- QPushButton, QLabel, QLineEdit, QFileDialog, QMessageBox
+ QComboBox,
+ QFileDialog,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
)
-from orca.gui.utils import get_preset_geometries, load_class_from_file
+
from orca.geometry.base_geometry import BaseGeometry
+from orca.gui.utils import get_preset_geometries, load_class_from_file
+
class GeometrySelector(QWidget):
"""
@@ -14,55 +23,55 @@ def __init__(self, parent=None):
super().__init__(parent)
self.current_geometry_class = None
self.current_geometry_instance = None
-
+
self.init_ui()
self.load_presets()
-
+
def init_ui(self):
layout = QVBoxLayout()
self.setLayout(layout)
-
+
# Selection Mode
mode_layout = QHBoxLayout()
self.combo_presets = QComboBox()
self.combo_presets.currentIndexChanged.connect(self.on_preset_changed)
-
+
self.btn_load_custom = QPushButton("Load Custom .py")
self.btn_load_custom.clicked.connect(self.load_custom_file)
-
+
layout.addWidget(QLabel("Select Geometry:"))
layout.addLayout(mode_layout)
mode_layout.addWidget(self.combo_presets)
mode_layout.addWidget(self.btn_load_custom)
-
+
# Name Override
name_layout = QHBoxLayout()
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Geometry Name")
-
+
name_layout.addWidget(QLabel("Geometry Name:"))
name_layout.addWidget(self.name_input)
layout.addLayout(name_layout)
-
+
self.lbl_status = QLabel("No geometry loaded")
layout.addWidget(self.lbl_status)
-
+
def load_presets(self):
self.combo_presets.clear()
self.combo_presets.addItem("Select a preset...", None)
-
+
presets = get_preset_geometries()
for cls in presets:
self.combo_presets.addItem(cls.__name__, cls)
-
+
def on_preset_changed(self, index):
if index == 0:
return
-
+
cls = self.combo_presets.currentData()
if cls and issubclass(cls, BaseGeometry):
self.load_geometry_from_class(cls)
-
+
def load_custom_file(self):
file_path, _ = QFileDialog.getOpenFileName(
self, "Open Geometry File", "", "Python Files (*.py)"
@@ -75,7 +84,7 @@ def load_custom_file(self):
self.lbl_status.setText("Error loading geometry")
return
self.load_geometry_from_class(cls)
-
+
def load_geometry_from_class(self, cls):
try:
# Instantiate with default arguments
@@ -84,17 +93,17 @@ def load_geometry_from_class(self, cls):
self.current_geometry_instance = instance
self.name_input.setText(instance.name)
self.lbl_status.setText(f"Loaded: {cls.__name__}")
- except Exception as e:
+ except Exception as e: # noqa: BLE001 - user-supplied class: show the error instead of crashing
QMessageBox.critical(self, "Error", f"Failed to instantiate geometry class: {e}")
self.lbl_status.setText("Error instantiating class")
def get_geometry(self):
if not self.current_geometry_instance:
return None
-
+
# Update name
new_name = self.name_input.text()
if new_name:
self.current_geometry_instance.name = new_name
-
+
return self.current_geometry_instance
diff --git a/src/orca/gui/widgets/stage_widget.py b/src/orca/gui/widgets/stage_widget.py
index 5d2950f..7e8e36f 100644
--- a/src/orca/gui/widgets/stage_widget.py
+++ b/src/orca/gui/widgets/stage_widget.py
@@ -1,72 +1,78 @@
import inspect
import json
-from typing import Type
from PySide6.QtWidgets import (
- QWidget, QVBoxLayout, QCheckBox, QLabel, QLineEdit,
- QSpinBox, QDoubleSpinBox, QFormLayout, QGroupBox
+ QCheckBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QGroupBox,
+ QLabel,
+ QLineEdit,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
)
from orca.pipeline.pipeline_stage import PipelineStage
+
class StageConfigWidget(QWidget):
"""
This widget represents a single PipelineStage configuration panel.
"""
- def __init__(self, stage_class: Type[PipelineStage], parent=None):
+ def __init__(self, stage_class: type[PipelineStage], parent=None):
super().__init__(parent)
self.stage_class = stage_class
self.parameter_inputs = {}
-
+
self.init_ui()
-
+
def init_ui(self):
layout = QVBoxLayout()
self.setLayout(layout)
-
+
self.group_box = QGroupBox(self.stage_class.__name__)
self.group_box.setCheckable(True)
self.group_box.setChecked(True)
-
+
form_layout = QFormLayout()
self.group_box.setLayout(form_layout)
-
+
# Introspect __init__
sig = inspect.signature(self.stage_class.__init__)
-
+
for name, param in sig.parameters.items():
if name == "self":
continue
-
+
label = QLabel(name)
input_widget = self.create_input_widget(param)
-
+
self.parameter_inputs[name] = {"widget": input_widget, "type": param.annotation}
form_layout.addRow(label, input_widget)
-
+
# Set default value if available
if param.default is not param.empty:
self.set_widget_value(input_widget, param.default)
-
+
layout.addWidget(self.group_box)
-
+
def create_input_widget(self, param: inspect.Parameter):
annotation = param.annotation
-
+
# Handle simple types
if annotation is int:
widget = QSpinBox()
widget.setRange(-999999, 999999)
return widget
- elif annotation is float:
+ if annotation is float:
widget = QDoubleSpinBox()
widget.setRange(-999999.0, 999999.0)
return widget
- elif annotation is bool:
+ if annotation is bool:
return QCheckBox()
- else:
- # Fallback for str, complex types, or unannotated
- return QLineEdit()
+ # Fallback for str, complex types, or unannotated
+ return QLineEdit()
def set_widget_value(self, widget, value):
if isinstance(widget, QSpinBox):
@@ -80,45 +86,43 @@ def set_widget_value(self, widget, value):
widget.setText(json.dumps(value))
else:
widget.setText(str(value))
-
+
def get_widget_value(self, widget, annotation):
- if isinstance(widget, QSpinBox):
- return widget.value()
- elif isinstance(widget, QDoubleSpinBox):
+ if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
return widget.value()
- elif isinstance(widget, QCheckBox):
+ if isinstance(widget, QCheckBox):
return widget.isChecked()
- elif isinstance(widget, QLineEdit):
+ if isinstance(widget, QLineEdit):
text = widget.text()
if annotation is int:
return int(text)
- elif annotation is float:
+ if annotation is float:
return float(text)
- elif annotation is bool:
+ if annotation is bool:
return text.lower() == "true"
- elif annotation is str:
+ if annotation is str:
return text
# Try to parse JSON for lists/dicts if it looks like one
if (text.startswith("[") and text.endswith("]")) or (text.startswith("{") and text.endswith("}")):
try:
return json.loads(text)
- except:
+ except json.JSONDecodeError:
return text
return text
return None
def is_enabled(self):
return self.group_box.isChecked()
-
+
def get_instance(self) -> PipelineStage | None:
if not self.is_enabled():
return None
-
+
kwargs = {}
for name, info in self.parameter_inputs.items():
widget = info["widget"]
annotation = info["type"]
kwargs[name] = self.get_widget_value(widget, annotation)
-
+
return self.stage_class(**kwargs)
diff --git a/src/orca/logger.py b/src/orca/logger.py
index a1569ab..61cf6eb 100644
--- a/src/orca/logger.py
+++ b/src/orca/logger.py
@@ -1,7 +1,8 @@
import logging
-import colorlog
import sys
+import colorlog
+
logger = logging.getLogger("ORCA")
stdout = colorlog.StreamHandler(stream=sys.stdout)
diff --git a/src/orca/orca.py b/src/orca/orca.py
index 6398f5e..97c8414 100644
--- a/src/orca/orca.py
+++ b/src/orca/orca.py
@@ -1,11 +1,12 @@
import json
import multiprocessing
import os
-from typing import Callable, Optional
-from orca.pipeline.context import PipelineContext
-from orca.pipeline.pipeline_stage import PipelineStage
+from collections.abc import Callable
+
from orca.geometry.base_geometry import BaseGeometry
from orca.logger import logger
+from orca.pipeline.context import PipelineContext
+from orca.pipeline.pipeline_stage import PipelineStage
def default_process_count() -> int:
@@ -33,14 +34,14 @@ def __init__(self, stages: list[PipelineStage]):
def run(
self,
geometry: BaseGeometry,
- num_processes: Optional[int] = None,
+ num_processes: int | None = None,
force_overwrite: bool = False,
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
- overwrite_callback: Optional[Callable[[str], bool]] = None,
- base_dir: Optional[str] = None,
- result_dir: Optional[str] = None,
- result_csv: Optional[str] = None,
- ) -> Optional[PipelineContext]:
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
+ overwrite_callback: Callable[[str], bool] | None = None,
+ base_dir: str | None = None,
+ result_dir: str | None = None,
+ result_csv: str | None = None,
+ ) -> PipelineContext | None:
"""
Runs the ORCA pipeline with the specified geometry and CPU cores.
diff --git a/src/orca/pipeline/__init__.py b/src/orca/pipeline/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/pipeline/context.py b/src/orca/pipeline/context.py
index 672d9f7..02db491 100644
--- a/src/orca/pipeline/context.py
+++ b/src/orca/pipeline/context.py
@@ -19,10 +19,10 @@
if TYPE_CHECKING:
import pandas as pd
- import torch.nn as nn
from orca.geometry.base_geometry import BaseGeometry
from orca.training.datasets.base_dataset import BaseDataset
+ from orca.training.models.base_model import OrcaModel
from orca.training.trainer import EpochResult
#: Fields left out of :meth:`PipelineContext.to_json_dict`, because they are
@@ -87,7 +87,7 @@ class PipelineContext:
"""Parameter table for the generated Palace models."""
# --- Written by ModelTrainer --------------------------------------------
- trained_model: nn.Module | None = None
+ trained_model: OrcaModel | None = None
"""Best model from the training run, restored to its best-validation weights."""
dataset: BaseDataset | None = None
"""The training split, carrying the feature pipeline and fitted normalizers.
diff --git a/src/orca/pipeline/export_onnx_stage.py b/src/orca/pipeline/export_onnx_stage.py
index 4f46f6a..bc3d16c 100644
--- a/src/orca/pipeline/export_onnx_stage.py
+++ b/src/orca/pipeline/export_onnx_stage.py
@@ -1,15 +1,17 @@
import json
-from typing import Optional, Callable, TYPE_CHECKING
import os
-import torch
+from collections.abc import Callable
+from typing import TYPE_CHECKING
+
import onnx
+import torch
-from orca.pipeline.pipeline_stage import PipelineStage
-from orca.geometry.base_geometry import BaseGeometry
from orca.logger import logger
+from orca.pipeline.pipeline_stage import PipelineStage
from orca.training.onnx_wrapper import ONNXWrapper
if TYPE_CHECKING:
+ from orca.geometry.base_geometry import BaseGeometry
from orca.pipeline.context import PipelineContext
@@ -24,7 +26,7 @@ def __init__(self):
def run(
self,
context: "PipelineContext",
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
) -> "PipelineContext":
geometry: BaseGeometry = context.geometry
@@ -94,5 +96,8 @@ def run(
onnx.save(onnx_model, output_path)
+ if progress_callback:
+ progress_callback(self.name, 1, 1, f"Exported ONNX model to {output_path}.")
+
context.model_path = output_path
return context
diff --git a/src/orca/pipeline/gds_conversion_stage.py b/src/orca/pipeline/gds_conversion_stage.py
index 3ebb249..0fdd86c 100644
--- a/src/orca/pipeline/gds_conversion_stage.py
+++ b/src/orca/pipeline/gds_conversion_stage.py
@@ -1,17 +1,18 @@
+import os
+from collections.abc import Callable
from concurrent.futures import as_completed
-import multiprocessing
+from typing import TYPE_CHECKING, Any
+
import pandas as pd
-import os
import tqdm
from pebble import ProcessPool
-from typing import Any, Callable, Optional, TYPE_CHECKING
-from orca.geometry.base_geometry import BaseGeometry
-from orca.pipeline.pipeline_stage import PipelineStage
from orca.logger import logger
+from orca.pipeline.pipeline_stage import PipelineStage
from orca.simulation.gds_converter import create_palace_model_from_gds
if TYPE_CHECKING:
+ from orca.geometry.base_geometry import BaseGeometry
from orca.pipeline.context import PipelineContext
@@ -38,7 +39,7 @@ def __init__(self, timeout: float = 60.0):
def run(
self,
context: "PipelineContext",
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
) -> "PipelineContext":
geometry: BaseGeometry = context.geometry
cpu_cores: int = context.num_processes
@@ -65,7 +66,7 @@ def run(
) as pool:
futures = {}
gds_dir = os.path.dirname(gds_csv)
- for i, row in gds_data.iterrows():
+ for _, row in gds_data.iterrows():
# CSV layout:
# name,input_winding_diameter,output_winding_diameter,center_displacement,bottom_linewidth,upper_linewidth
# everything after name is input parameters
@@ -77,15 +78,15 @@ def run(
# Submit GDS conversion tasks
future = pool.schedule(
create_palace_model_from_gds,
- kwargs=dict(
- geometry_name=name,
- params=params,
- output_dir=base_dir,
- gds_filename=gds_path,
- stackup_xml=geometry.stackup_xml,
- simconfig_filename=geometry.simconfig_filename,
- show_mesh_results=False,
- ),
+ kwargs={
+ "geometry_name": name,
+ "params": params,
+ "output_dir": base_dir,
+ "gds_filename": gds_path,
+ "stackup_xml": geometry.stackup_xml,
+ "simconfig_filename": geometry.simconfig_filename,
+ "show_mesh_results": False,
+ },
timeout=self.timeout,
)
futures[future] = name
@@ -109,7 +110,7 @@ def run(
f"GDS conversion of {name} exceeded {self.timeout:g} s "
"(gmsh did not finish meshing); skipping this sample."
)
- except Exception as e:
+ except Exception as e: # noqa: BLE001 - one bad sample must not abort the batch
logger.error(f"GDS conversion failed for {name} with error: {e}")
finally: # and call progress_callback even on failure
if progress_callback:
@@ -143,6 +144,7 @@ def _save_csv(
params (dict[str, Any]): Input parameters to save.
data_dir (str): Directory where data is stored.
sim_path (str): Path to the simulation file.
+ config_name (str): Name of the Palace config file written for this sample.
"""
df = pd.DataFrame(
[
diff --git a/src/orca/pipeline/gds_gen_stage.py b/src/orca/pipeline/gds_gen_stage.py
index 9a8665d..c5c957d 100644
--- a/src/orca/pipeline/gds_gen_stage.py
+++ b/src/orca/pipeline/gds_gen_stage.py
@@ -1,31 +1,40 @@
+import os
+from collections.abc import Callable
from concurrent.futures import ProcessPoolExecutor, as_completed
from concurrent.futures.process import BrokenProcessPool
+from typing import TYPE_CHECKING, Any
+
import pandas as pd
-from typing import Any, Callable, Optional, TYPE_CHECKING
-from orca.geometry.base_geometry import BaseGeometry
-from orca.pipeline.pipeline_stage import PipelineStage
-import multiprocessing
-from orca.logger import logger
import tqdm
-import os
+
+from orca.logger import logger
+from orca.pipeline.pipeline_stage import PipelineStage
if TYPE_CHECKING:
+ from orca.geometry.base_geometry import BaseGeometry
from orca.pipeline.context import PipelineContext
class GDSGenerator(PipelineStage):
"""
Pipeline stage for generating GDS files from trained models.
+
+ Args:
+ num_samples (int): Number of parameter combinations to draw and lay out.
+ seed (int|None): Seed for the geometry's 'random' picking strategy, so that
+ a run can be reproduced. None keeps the seed set on the geometry's
+ input parameter iterator (fresh entropy by default).
"""
- def __init__(self, num_samples: int = 1000):
+ def __init__(self, num_samples: int = 1000, seed: int | None = None):
super().__init__(name="GDS Generator", index=0)
self.num_samples = num_samples
+ self.seed = seed
def run(
self,
context: "PipelineContext",
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
) -> "PipelineContext":
geometry: BaseGeometry = context.geometry
cpu_cores: int = context.num_processes
@@ -44,7 +53,7 @@ def run(
os.makedirs(output_dir)
# Tell the input parameter iterator the number of samples to generate
- geometry.input_parameter_iterator.set_sample_count(self.num_samples)
+ geometry.input_parameter_iterator.set_sample_count(self.num_samples, seed=self.seed)
futures = []
with ProcessPoolExecutor(max_workers=cpu_cores) as executor:
@@ -70,7 +79,7 @@ def run(
)
):
try:
- gds_path, name, params = future.result()
+ _gds_path, name, params = future.result()
# Save instance name + input parameters to CSV
self._save_csv(gds_csv, name, params)
@@ -78,7 +87,7 @@ def run(
# A worker died (e.g. the calling script re-ran the pipeline on
# import); nothing else will finish, so abort instead of skipping
raise
- except Exception as e:
+ except Exception as e: # noqa: BLE001 - one bad sample must not abort the batch
logger.debug(f"Worker task failed: {e}")
finally:
if progress_callback:
@@ -102,7 +111,7 @@ def _generate_gds_file(
This is used for multiprocessing to avoid pickling issues with instance methods.
Args:
- geometry_class (Type[BaseGeometry]): The geometry class to instantiate.
+ gds_method (Callable): The geometry's create_gds_file(name, output_path, params).
name (str): The name of the GDS file to create.
output_dir (str): The directory to save the GDS file.
params (dict[str, Any]): The input parameters for the geometry.
diff --git a/src/orca/pipeline/pipeline_stage.py b/src/orca/pipeline/pipeline_stage.py
index 8e7af3e..be6cd08 100644
--- a/src/orca/pipeline/pipeline_stage.py
+++ b/src/orca/pipeline/pipeline_stage.py
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
-from typing import Callable, Optional, TYPE_CHECKING
+from collections.abc import Callable
+from typing import TYPE_CHECKING
from orca.logger import logger
@@ -17,7 +18,7 @@ def __init__(self, name: str, index: int = 0):
def run(
self,
context: "PipelineContext",
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
) -> "PipelineContext":
"""
Execute the pipeline stage.
diff --git a/src/orca/pipeline/simulation_stage.py b/src/orca/pipeline/simulation_stage.py
index 3c4af94..7619d23 100644
--- a/src/orca/pipeline/simulation_stage.py
+++ b/src/orca/pipeline/simulation_stage.py
@@ -1,16 +1,18 @@
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from queue import Queue
-from typing import Optional, Any, Callable, TYPE_CHECKING
import json
import os
+from collections.abc import Callable
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from queue import Queue
+from typing import TYPE_CHECKING, Any
+
import pandas as pd
import tqdm
-from orca.pipeline.pipeline_stage import PipelineStage
from orca.logger import logger
+from orca.pipeline.pipeline_stage import PipelineStage
+from orca.simulation.combine_snp_results import touchstone_filename
from orca.simulation.launchers import BIND_CHOICES, LocalLauncher, SimulationLauncher
from orca.simulation.simulate import run_palace
-from orca.simulation.combine_snp_results import touchstone_filename
if TYPE_CHECKING:
from orca.pipeline.context import PipelineContext
@@ -97,7 +99,7 @@ def _create_launcher(self) -> SimulationLauncher:
def run(
self,
context: "PipelineContext",
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
) -> "PipelineContext":
num_processes: int = context.num_processes
output_dir = context.result_dir
@@ -235,7 +237,7 @@ def _patch_palace_config(self, config_path: str) -> None:
Args:
config_path (str): Path to the Palace config.json file to patch.
"""
- with open(config_path, "r") as f:
+ with open(config_path) as f:
config = json.load(f)
config.setdefault("Problem", {})["OutputFormats"] = {"Paraview": False}
diff --git a/src/orca/pipeline/test_model_stage.py b/src/orca/pipeline/test_model_stage.py
index bcd2c9c..1caaf6a 100644
--- a/src/orca/pipeline/test_model_stage.py
+++ b/src/orca/pipeline/test_model_stage.py
@@ -1,12 +1,12 @@
-from typing import Optional, Any, Dict, Callable, TYPE_CHECKING
import os
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any
import numpy as np
import pandas as pd
import tqdm
from sklearn.model_selection import train_test_split
-from orca.geometry.base_geometry import BaseGeometry
from orca.logger import logger
from orca.pipeline.pipeline_stage import PipelineStage
from orca.training.datasets.geo_to_ntwk import GeoToNtwkDataset
@@ -22,6 +22,7 @@
)
if TYPE_CHECKING:
+ from orca.geometry.base_geometry import BaseGeometry
from orca.pipeline.context import PipelineContext
@@ -34,7 +35,7 @@ class ModelTester(PipelineStage):
physical units, on geometries the model was never trained on.
"""
- def __init__(self, n_test_samples: Optional[int] = None, plot: bool = False):
+ def __init__(self, n_test_samples: int | None = None, plot: bool = False):
"""
Args:
n_test_samples: Limit the evaluation to the first N held-out geometries.
@@ -49,7 +50,7 @@ def __init__(self, n_test_samples: Optional[int] = None, plot: bool = False):
def run(
self,
context: "PipelineContext",
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
) -> "PipelineContext":
result_dir = context.result_dir
test_df = context.test_df
@@ -123,8 +124,8 @@ def test_model(
self,
test_dataset: GeoToNtwkDataset,
predictor: NetworkPredictor,
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
- ) -> Dict[str, Any]:
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
+ ) -> dict[str, Any]:
"""
Evaluates the predictor on the test dataset.
@@ -155,7 +156,7 @@ def test_model(
try:
predicted = calculate_electrical_parameters(ntwk_pred)
reference = calculate_electrical_parameters(ntwk_gt)
- except Exception as e:
+ except Exception as e: # noqa: BLE001 - skip samples whose metrics cannot be derived
logger.debug(f"Could not compute electrical parameters for sample {i}: {e}")
continue
diff --git a/src/orca/pipeline/training_stage.py b/src/orca/pipeline/training_stage.py
index 939adcc..1300d77 100644
--- a/src/orca/pipeline/training_stage.py
+++ b/src/orca/pipeline/training_stage.py
@@ -1,18 +1,20 @@
-from typing import Optional, Any, Callable, TYPE_CHECKING
import os
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any
+
import pandas as pd
from sklearn.model_selection import train_test_split
-from orca.pipeline.pipeline_stage import PipelineStage
-from orca.geometry.base_geometry import BaseGeometry
from orca.logger import logger
-from orca.training.datasets.base_dataset import BaseDataset
+from orca.pipeline.pipeline_stage import PipelineStage
from orca.training.basis_expansion import BasisExpansion, get_basis_class
+from orca.training.datasets.base_dataset import BaseDataset
from orca.training.models.base_model import OrcaModel, get_model_class
from orca.training.trainer import Trainer, TrainingConfig
from orca.training.tuner import HyperparameterTuner
if TYPE_CHECKING:
+ from orca.geometry.base_geometry import BaseGeometry
from orca.pipeline.context import PipelineContext
@@ -27,7 +29,7 @@ def __init__(
basis: str | type[BasisExpansion] | None = None,
hyperparameters: dict[str, Any] | None = None,
test_frac: float = 0.15,
- n_train_samples: Optional[int] = None,
+ n_train_samples: int | None = None,
n_fold_cv: int = 5,
n_trials: int = 200,
):
@@ -61,7 +63,7 @@ def __init__(
def run(
self,
context: "PipelineContext",
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
) -> "PipelineContext":
geometry: BaseGeometry = context.geometry
result_dir = context.result_dir
diff --git a/src/orca/simulation/__init__.py b/src/orca/simulation/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/simulation/combine_snp_results.py b/src/orca/simulation/combine_snp_results.py
index 2a9ea23..2c0d560 100644
--- a/src/orca/simulation/combine_snp_results.py
+++ b/src/orca/simulation/combine_snp_results.py
@@ -9,15 +9,17 @@
# updated 13-Nov-2025 Mue: added simple de-embedding of parasitic port inductance (flat ribbon calculation)
# updated 26-Nov-2025 Mue: also read Elmer FEM files
-import os
-import re
import json
import math
+import os
+import re
+import sys
+
+import numpy as np
import skrf as rf
from skrf.network import connect
-import numpy as np
-from orca.logger import logger
+from orca.logger import logger
#: Filename suffix written for each touchstone_type, appended before the .sNp extension.
#: "all" writes every variant; the fully corrected one is treated as canonical.
@@ -67,7 +69,7 @@ def parse_elmer_results(found_filename, freq, S_dB, S_arg):
# Parse column names
column_names = []
- with open(names_filename, "r") as namesfile:
+ with open(names_filename) as namesfile:
for line in namesfile:
if ":" in line and line.strip()[0].isdigit():
parts = line.split(":")
@@ -98,17 +100,13 @@ def parse_elmer_results(found_filename, freq, S_dB, S_arg):
logger.debug(
"Incorrect number of values in data file, does not match port count"
)
- exit(1)
+ sys.exit(1)
# read data file
data = np.loadtxt(data_filename)
- if data.ndim == 2:
- # we have multiple frequencies
- omegalist = data[:, omega_column]
- else:
- # we have only one freqiency point
- omegalist = [data[omega_column]]
+ # 2D data holds multiple frequencies, 1D data a single frequency point
+ omegalist = data[:, omega_column] if data.ndim == 2 else [data[omega_column]]
for omega in omegalist:
freq.append(omega / (1e9 * 2 * math.pi))
@@ -232,9 +230,7 @@ def traverse_directories(path, level=0):
if os.path.isdir(item_path):
traverse_directories(item_path, level + 1)
- elif item == "port-S.csv":
- found_datafiles.append(item_path)
- elif item == "scalar_results.names":
+ elif item in ("port-S.csv", "scalar_results.names"):
found_datafiles.append(item_path)
except PermissionError:
@@ -257,19 +253,13 @@ def extrapolate_to_DC(snp_filename):
if nw.frequency.npoints > 20:
if nw.frequency.start <= 1e9:
# extrapolate to DC
- extrapolated = nw.extrapolate_to_dc(
- points=None, dc_sparam=None, kind="cubic", coords="polar"
- )
- filename, file_extension = os.path.splitext(snp_filename)
+ extrapolated = nw.extrapolate_to_dc(kind="cubic", coords="polar")
+ filename, _ = os.path.splitext(snp_filename)
out_filename = filename + "_dc" # without extension
- extrapolated.write_touchstone(
- out_filename,
- skrf_comment="DC point added by extrapolation",
- form="db",
- write_noise=True,
- )
+ extrapolated.comments = "DC point added by extrapolation"
+ extrapolated.write_touchstone(out_filename, form="db", write_noise=True)
returnval = out_filename
- logger.debug("Created file with DC extrapolation: ", out_filename, "\n")
+ logger.debug(f"Created file with DC extrapolation: {out_filename}")
else:
logger.debug("No data at low frequency, skipping DC extrapolation")
else:
@@ -319,16 +309,14 @@ def port_deembedding(snp_filename, port_info_available, port_info_data):
Lport[str(portnum)] = L
# convert the dict with port L into a list, to have the final values in correct order
- L_values = []
- for key in Lport.keys():
- L_values.append(-Lport[key])
+ L_values = [-L for L in Lport.values()]
# load SnP data and apply negative series L at each port
ntwk = rf.Network(snp_filename)
freq = ntwk.frequency
- # Create a Media object (needed for Media.inductor)
- media = rf.media.DefinedGammaZ0(frequency=freq, z0=50)
+ # Create a Media object (needed for Media.inductor), z0 defaults to 50 Ohm
+ media = rf.media.DefinedGammaZ0(frequency=freq)
for n, L in enumerate(L_values):
logger.debug(f"Cascading L= {L * 1e12:.2f} pH at port {n + 1}")
@@ -339,19 +327,11 @@ def port_deembedding(snp_filename, port_info_available, port_info_data):
# after iterating over all ports we have the correct order again
ntwk = connect(inductor, 0, ntwk, 0)
- filename, file_extension = os.path.splitext(snp_filename)
+ filename, _ = os.path.splitext(snp_filename)
out_filename = filename + "_deembedded" # without extension
- ntwk.write_touchstone(
- out_filename,
- skrf_comment="De-embedded by adding negative series L at ports",
- form="db",
- write_noise=True,
- )
- logger.debug(
- "Created file with de-embedding (cascaded negative port L): ",
- out_filename,
- "\n",
- )
+ ntwk.comments = "De-embedded by adding negative series L at ports"
+ ntwk.write_touchstone(out_filename, form="db", write_noise=True)
+ logger.debug(f"Created file with de-embedding (cascaded negative port L): {out_filename}")
else:
logger.debug(
"Skipping port de-embedding, not port geometry information available"
@@ -368,8 +348,6 @@ def convert_to_touchstone(workdir, output_dir, touchstone_type: str):
# evaluate the found data files
for found_filename in found_datafiles:
- # logger.debug(str(f))
-
# Before we evaluate S-parameters, also check if we have a file port_information.json
port_info_available = False
# Get the directory two levels up
@@ -395,7 +373,7 @@ def convert_to_touchstone(workdir, output_dir, touchstone_type: str):
)
# Load the JSON data
- with open(port_info_filename, "r") as f:
+ with open(port_info_filename) as f:
port_info_data = json.load(f)
# Extract all Z0 values
@@ -406,7 +384,7 @@ def convert_to_touchstone(workdir, output_dir, touchstone_type: str):
Z0_string = str(Z0_values[0])
for Z in Z0_values:
- if Z != Z0_values[0]:
+ if Z0_values[0] != Z:
Z0_string = Z0_string + " " + str(Z)
# If string is filled, we have a Z0 parameter for Touchstone header line.
# For mixed port impedance, we have multiple values there
@@ -439,23 +417,18 @@ def convert_to_touchstone(workdir, output_dir, touchstone_type: str):
)
else:
logger.debug("Invalid file, exit")
- exit(1)
+ sys.exit(1)
data_lines = []
for frequency in freq:
- # line = str(frequency)
-
index = freq.index(frequency)
data_line = [frequency]
for i in range(1, num_ports + 1):
for j in range(1, num_ports + 1):
# special case 2-port data: the output is S11 S21 S12 S22
- if num_ports == 2:
- param = str(j) + " " + str(i)
- else:
- param = str(i) + " " + str(j)
+ param = f"{j} {i}" if num_ports == 2 else f"{i} {j}"
found_params = S_dB[index].keys()
# assume that we also have phase data then
@@ -492,22 +465,18 @@ def convert_to_touchstone(workdir, output_dir, touchstone_type: str):
if not os.path.exists(output_dir):
os.makedirs(output_dir)
- output_file = open(output_filename, "w")
- # write Touchstone header line
- output_file.write(f"# {freq_unit.upper()} S DB R {Z0_string}\n")
+ with open(output_filename, "w") as output_file:
+ # write Touchstone header line
+ output_file.write(f"# {freq_unit.upper()} S DB R {Z0_string}\n")
- for data_line in data_lines:
- line = ""
- for value in data_line:
- line = line + " " + str(value)
- output_file.write(line + "\n")
+ for data_line in data_lines:
+ line = ""
+ for value in data_line:
+ line = line + " " + str(value)
+ output_file.write(line + "\n")
- output_file.close()
logger.debug(
- "Created combined S-parameter file for ",
- num_ports,
- "ports, filename: ",
- output_filename,
+ f"Created combined S-parameter file for {num_ports} ports, filename: {output_filename}"
)
if not port_info_available:
diff --git a/src/orca/simulation/gds_converter.py b/src/orca/simulation/gds_converter.py
index 063235d..1494670 100644
--- a/src/orca/simulation/gds_converter.py
+++ b/src/orca/simulation/gds_converter.py
@@ -1,9 +1,10 @@
-from gds2palace import gds_reader, stackup_reader, utilities, simulation_setup
import os
-from contextlib import redirect_stdout, ExitStack
+from contextlib import ExitStack, redirect_stdout
+from typing import Any
import gmsh
-from typing import Any
+from gds2palace import gds_reader, simulation_setup, stackup_reader, utilities
+
from orca.simulation.simulate import read_simconfig
@@ -23,11 +24,17 @@ def create_palace_model_from_gds(
Based on: https://github.com/VolkerMuehlhaus/gds2palace_ihp_sg13g2/blob/main/workflow/palace_L2n0.py
Args:
+ geometry_name (str): Name of the sample; passed through to the result for bookkeeping.
+ params (dict[str, Any]): Input parameters of the sample; passed through to the result.
+ output_dir (str): Directory in which the Palace model directory is created.
gds_filename (str): Path to the GDS file.
+ stackup_xml (str): Path to the XML file describing the layer stackup.
simconfig_filename (str): Path to the simulation configuration file (json).
+ show_mesh_results (bool): Show the gmsh GUI with the mesh and keep gds2palace's console output.
Returns:
- tuple[str, str]: Palace config name and data directory of the created Palace model.
+ tuple[str, dict, str, str, str]: geometry_name, params, Palace config name, simulation
+ directory and data directory of the created Palace model.
"""
# ExitStack is used to suppress stdout output in the conversion worker processes to avoid cluttering the console
with ExitStack() as stack:
diff --git a/src/orca/simulation/launchers.py b/src/orca/simulation/launchers.py
index 59bd415..b4498d0 100644
--- a/src/orca/simulation/launchers.py
+++ b/src/orca/simulation/launchers.py
@@ -127,7 +127,7 @@ def ranks_per_simulation(self, requested: int) -> int:
def command(self, slot: str, palace_executable: str, num_processes: int, config_name: str) -> str:
"""Shell command that runs `config_name` with `num_processes` MPI ranks in `slot`."""
- def check(self, num_processes: int) -> None:
+ def check(self, num_processes: int) -> None: # noqa: B027 - optional hook, not abstract
"""
Pre-flight check run once before the first simulation, so a broken launch setup fails
fast with a clear message instead of a hanging or failing simulation. No-op by default.
diff --git a/src/orca/simulation/simulate.py b/src/orca/simulation/simulate.py
index e53307c..1669dd6 100644
--- a/src/orca/simulation/simulate.py
+++ b/src/orca/simulation/simulate.py
@@ -79,10 +79,11 @@ def read_simconfig(simconfig_filename: str) -> dict:
Args:
simconfig_filename (str): Path to the simulation configuration file.
+
Returns:
dict: A dictionary containing simulation configuration parameters.
"""
- with open(simconfig_filename, "r") as file:
+ with open(simconfig_filename) as file:
simconfig = json.load(file)
# Add e9 suffix to frequency values if they are in GHz
diff --git a/src/orca/simulation/slurm.py b/src/orca/simulation/slurm.py
index 3307d36..558425c 100644
--- a/src/orca/simulation/slurm.py
+++ b/src/orca/simulation/slurm.py
@@ -33,7 +33,10 @@ def slurm_allocation() -> tuple[list[str], int]:
# Expand the compact node list (e.g. "f[0101-0102,0110]") into hostnames
ret = subprocess.run(
- ["scontrol", "show", "hostnames", nodelist], capture_output=True, text=True, check=False
+ ["scontrol", "show", "hostnames", nodelist], # noqa: S607 - scontrol comes from the Slurm PATH
+ capture_output=True,
+ text=True,
+ check=False,
)
if ret.returncode != 0:
raise RuntimeError(f"Could not expand Slurm node list '{nodelist}': {ret.stderr.strip()}")
@@ -186,7 +189,7 @@ def check(self, num_processes: int, timeout: float = 120) -> None:
RuntimeError: If any test step fails or does not complete within `timeout`.
"""
procs = {
- slot: subprocess.Popen(
+ slot: subprocess.Popen( # noqa: S602 - the srun prefix is built by this launcher
f"{self._srun_prefix(slot, num_processes)} hostname",
shell=True,
stdout=subprocess.PIPE,
diff --git a/src/orca/training/__init__.py b/src/orca/training/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/training/basis_expansion.py b/src/orca/training/basis_expansion.py
index 46ebca0..ba712c9 100644
--- a/src/orca/training/basis_expansion.py
+++ b/src/orca/training/basis_expansion.py
@@ -23,13 +23,16 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from typing import Any, Callable
+from typing import TYPE_CHECKING, Any, TypeVar
import optuna
import torch
-import torch.nn as nn
+from torch import nn
-from orca.training.spec import IOSpec
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from orca.training.spec import IOSpec
class BasisExpansion(nn.Module, ABC):
@@ -50,7 +53,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
@classmethod
@abstractmethod
- def from_spec(cls, spec: IOSpec, hyperparameters: dict[str, Any]) -> "BasisExpansion":
+ def from_spec(cls, spec: IOSpec, hyperparameters: dict[str, Any]) -> BasisExpansion:
"""Build a basis expansion for ``spec``, configured by ``hyperparameters``.
Implementations must tolerate extra keys: the trainer's and the model's
@@ -65,11 +68,15 @@ def hyperparameter_search_space() -> dict[str, Any]:
_BASIS_REGISTRY: dict[str, type[BasisExpansion]] = {}
+# The decorator hands back the very class it was given, so the type checker
+# keeps seeing e.g. ``IdentityBasis`` and not just ``type[BasisExpansion]``.
+_BasisT = TypeVar("_BasisT", bound=BasisExpansion)
+
-def register_basis(name: str) -> Callable[[type[BasisExpansion]], type[BasisExpansion]]:
+def register_basis(name: str) -> Callable[[type[_BasisT]], type[_BasisT]]:
"""Class decorator registering a basis expansion under a short name."""
- def decorator(cls: type[BasisExpansion]) -> type[BasisExpansion]:
+ def decorator(cls: type[_BasisT]) -> type[_BasisT]:
if name in _BASIS_REGISTRY and _BASIS_REGISTRY[name] is not cls:
raise ValueError(f"Basis expansion '{name}' is already registered.")
_BASIS_REGISTRY[name] = cls
@@ -108,7 +115,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
return x
@classmethod
- def from_spec(cls, spec: IOSpec, hyperparameters: dict[str, Any]) -> "IdentityBasis":
+ def from_spec(cls, spec: IOSpec, hyperparameters: dict[str, Any]) -> IdentityBasis: # noqa: ARG003 - nothing to configure
return cls()
@@ -168,7 +175,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
return torch.cat([x, torch.stack(terms[1:], dim=1)], dim=1)
@classmethod
- def from_spec(cls, spec: IOSpec, hyperparameters: dict[str, Any]) -> "ChebyshevBasis":
+ def from_spec(cls, spec: IOSpec, hyperparameters: dict[str, Any]) -> ChebyshevBasis:
column = hyperparameters.get("basis_column")
if column is None:
if "frequency" not in spec.input_names:
diff --git a/src/orca/training/datasets/__init__.py b/src/orca/training/datasets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/training/datasets/base_dataset.py b/src/orca/training/datasets/base_dataset.py
index 353a3ae..9c68653 100644
--- a/src/orca/training/datasets/base_dataset.py
+++ b/src/orca/training/datasets/base_dataset.py
@@ -10,7 +10,7 @@
from orca.training.spec import FrequencyMode, IOSpec
-class BaseDataset(ABC, torch.utils.data.Dataset):
+class BaseDataset(ABC, torch.utils.data.Dataset[tuple[torch.Tensor, torch.Tensor]]):
#: Frequency layout of the samples this dataset produces.
frequency_mode: ClassVar[FrequencyMode]
@@ -30,7 +30,7 @@ def __init__(
input_normalizer (Normalizer|None): Normalizer for input parameters.
output_normalizer (Normalizer|None): Normalizer for output parameters.
"""
- super(BaseDataset, self).__init__()
+ super().__init__()
self.codec = codec
self.samples: list[tuple[torch.Tensor, torch.Tensor]] = []
@@ -105,7 +105,7 @@ def _load_samples_and_normalize(
"directory and the CSV describe the same set of simulations."
)
- inputs, outputs = zip(*self.samples)
+ inputs, outputs = zip(*self.samples, strict=True)
# The normalizers are shared across the splits of a run, so only the split
# that owns them fits; the rest reuse those statistics unchanged.
@@ -123,19 +123,13 @@ def _load_samples_and_normalize(
"training split first with new_split(..., fit_normalizers=True)."
)
- self.samples = list(
- map(
- lambda s: (
- self.input_normalizer.normalize(s[0])
- if self.input_normalizer is not None
- else s[0],
- self.output_normalizer.normalize(s[1])
- if self.output_normalizer is not None
- else s[1],
- ),
- self.samples,
+ self.samples = [
+ (
+ self.input_normalizer.normalize(x) if self.input_normalizer is not None else x,
+ self.output_normalizer.normalize(y) if self.output_normalizer is not None else y,
)
- )
+ for x, y in self.samples
+ ]
@abstractmethod
def load_samples(self, directory: str, data_df: pd.DataFrame) -> None:
@@ -143,10 +137,9 @@ def load_samples(self, directory: str, data_df: pd.DataFrame) -> None:
Load samples from the dataset.
This method should be implemented by subclasses to load data specific from its self.data_dir.
"""
- pass
- def __getitem__(self, idx) -> tuple[torch.Tensor, torch.Tensor]:
- return self.samples[idx]
+ def __getitem__(self, index) -> tuple[torch.Tensor, torch.Tensor]:
+ return self.samples[index]
def __len__(self):
return len(self.samples)
@@ -165,6 +158,7 @@ def new_split(
normalizers. Pass True for the training split and False for the
validation and test splits, which must reuse the training
statistics.
+
Returns:
BaseDataset: New dataset split instance.
"""
@@ -173,5 +167,5 @@ def new_split(
input_normalizer=self.input_normalizer,
output_normalizer=self.output_normalizer,
)
- new_dataset._load_samples_and_normalize(directory, data_df, fit_normalizers)
+ new_dataset._load_samples_and_normalize(directory, data_df, fit_normalizers) # noqa: SLF001 - same class
return new_dataset
diff --git a/src/orca/training/datasets/geo_to_ntwk.py b/src/orca/training/datasets/geo_to_ntwk.py
index 9ad1e75..6abc4f4 100644
--- a/src/orca/training/datasets/geo_to_ntwk.py
+++ b/src/orca/training/datasets/geo_to_ntwk.py
@@ -1,17 +1,17 @@
-import pandas as pd
-import skrf as rf
import os
+
import numpy as np
+import pandas as pd
+import skrf as rf
import torch
import tqdm
-from orca.training.normalize import Normalizer
from orca.logger import logger
-class GeoToNtwkDataset(torch.utils.data.Dataset):
+class GeoToNtwkDataset(torch.utils.data.Dataset[tuple[np.ndarray, rf.Network]]):
"""
- This dataset class is designed to load a scikit-rf Network object from a .snp file and associate it with the corresponding geometry parameters.
+ This dataset class is designed to load a scikit-rf Network object from a .snp file and associate it with the corresponding geometry parameters.
This can be used in the testing stage to compare the predicted S-parameters with the actual S-parameters from the .snp file.
"""
@@ -20,7 +20,7 @@ def __init__(
directory: str,
data_df: pd.DataFrame,
):
- self.samples = []
+ self.samples: list[tuple[np.ndarray, rf.Network]] = []
self.load_samples(directory, data_df)
@@ -28,7 +28,7 @@ def load_samples(self, directory: str, data_df: pd.DataFrame) -> None:
self.input_param_names = list(data_df.columns)
self.input_param_names.remove("name") # Remove 'name' column
- for idx, row in tqdm.tqdm(
+ for _, row in tqdm.tqdm(
data_df.iterrows(), total=len(data_df), desc="Loading test network samples"
):
snp_path = os.path.join(directory, row["name"])
@@ -46,11 +46,10 @@ def load_single_sample(
self, sparam_path: str, geometry_params: np.ndarray
) -> list[tuple[np.ndarray, rf.Network]]:
"""Load S-parameter data from a Touchstone file."""
-
return [(geometry_params, rf.Network(sparam_path, f_unit="Hz"))]
-
+
def __len__(self):
return len(self.samples)
-
- def __getitem__(self, idx):
- return self.samples[idx]
\ No newline at end of file
+
+ def __getitem__(self, index) -> tuple[np.ndarray, rf.Network]:
+ return self.samples[index]
diff --git a/src/orca/training/datasets/geo_to_s_param.py b/src/orca/training/datasets/geo_to_s_param.py
index 8136f8f..a24c011 100644
--- a/src/orca/training/datasets/geo_to_s_param.py
+++ b/src/orca/training/datasets/geo_to_s_param.py
@@ -27,7 +27,7 @@ def __init__(
input_normalizer: Normalizer | None = None,
output_normalizer: Normalizer | None = None,
):
- super(GeoToSParamDataset, self).__init__(
+ super().__init__(
codec, input_normalizer, output_normalizer
)
@@ -35,7 +35,7 @@ def load_samples(self, directory: str, data_df: pd.DataFrame) -> None:
self.input_param_names = list(data_df.columns)
self.input_param_names.remove("name") # Remove 'name' column
- for idx, row in data_df.iterrows():
+ for _, row in data_df.iterrows():
snp_path = os.path.join(directory, row["name"])
if not os.path.exists(snp_path):
diff --git a/src/orca/training/datasets/geo_to_s_param_single_f.py b/src/orca/training/datasets/geo_to_s_param_single_f.py
index f99939c..4f70977 100644
--- a/src/orca/training/datasets/geo_to_s_param_single_f.py
+++ b/src/orca/training/datasets/geo_to_s_param_single_f.py
@@ -28,15 +28,15 @@ def __init__(
input_normalizer: Normalizer | None = None,
output_normalizer: Normalizer | None = None,
):
- super(GeoToSParamDatasetSingleFrequency, self).__init__(
+ super().__init__(
codec, input_normalizer, output_normalizer
)
def load_samples(self, directory: str, data_df: pd.DataFrame) -> None:
- self.input_param_names = list(data_df.columns) + ["frequency"]
+ self.input_param_names = [*data_df.columns, "frequency"]
self.input_param_names.remove("name") # Remove 'name' column
- for idx, row in tqdm.tqdm(
+ for _, row in tqdm.tqdm(
data_df.iterrows(), total=len(data_df), desc="Loading samples"
):
snp_path = os.path.join(directory, row["name"])
@@ -60,7 +60,7 @@ def load_single_sample(
samples = []
for i in range(len(freq)):
- # Input = geometry + frequency
+ # the model input is the geometry vector with the frequency appended
x = np.hstack((geometry_params, freq[i])).astype(np.float32)
x, y = (
diff --git a/src/orca/training/guarantees.py b/src/orca/training/guarantees.py
index a43e642..41e736a 100644
--- a/src/orca/training/guarantees.py
+++ b/src/orca/training/guarantees.py
@@ -10,7 +10,7 @@
from __future__ import annotations
-from dataclasses import dataclass, asdict, fields
+from dataclasses import asdict, dataclass, fields
@dataclass(frozen=True)
@@ -38,7 +38,7 @@ class PhysicsGuarantees:
causal: bool = False
stable: bool = False
- def __or__(self, other: "PhysicsGuarantees") -> "PhysicsGuarantees":
+ def __or__(self, other: PhysicsGuarantees) -> PhysicsGuarantees:
"""Combine two sets of guarantees, keeping every property either one promises.
Used to merge what the architecture guarantees with what the output
diff --git a/src/orca/training/losses.py b/src/orca/training/losses.py
index 6312350..b767895 100644
--- a/src/orca/training/losses.py
+++ b/src/orca/training/losses.py
@@ -10,7 +10,7 @@
import math
import torch
-import torch.nn as nn
+from torch import nn
class ComplexMSELoss(nn.Module):
diff --git a/src/orca/training/models/__init__.py b/src/orca/training/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/training/models/base_model.py b/src/orca/training/models/base_model.py
index b0b52b8..6e32905 100644
--- a/src/orca/training/models/base_model.py
+++ b/src/orca/training/models/base_model.py
@@ -11,16 +11,21 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from typing import Any, Callable, ClassVar
+from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
-import numpy as np
-import skrf as rf
import torch
-import torch.nn as nn
+from torch import nn
-from orca.training.basis_expansion import IdentityBasis, BasisExpansion
+from orca.training.basis_expansion import BasisExpansion, IdentityBasis
from orca.training.guarantees import PhysicsGuarantees
-from orca.training.spec import FrequencyMode, IOSpec
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ import numpy as np
+ import skrf as rf
+
+ from orca.training.spec import FrequencyMode, IOSpec
class OrcaModel(nn.Module, ABC):
@@ -70,7 +75,7 @@ def from_spec(
spec: IOSpec,
hyperparameters: dict[str, Any],
basis: BasisExpansion | None = None,
- ) -> "OrcaModel":
+ ) -> OrcaModel:
"""Build a model sized for ``spec`` and configured by ``hyperparameters``.
Implementations must tolerate extra keys in ``hyperparameters`` (the
@@ -105,15 +110,19 @@ def to_network(self, raw: np.ndarray, frequencies: np.ndarray) -> rf.Network:
_MODEL_REGISTRY: dict[str, type[OrcaModel]] = {}
+# The decorator hands back the very class it was given, so the type checker
+# keeps seeing e.g. ``OrcaMLP`` and not just ``type[OrcaModel]``.
+_ModelT = TypeVar("_ModelT", bound=OrcaModel)
+
-def register_model(name: str) -> Callable[[type[OrcaModel]], type[OrcaModel]]:
+def register_model(name: str) -> Callable[[type[_ModelT]], type[_ModelT]]:
"""Class decorator registering a model under a short name.
Args:
name (str): Name to register the model under, e.g. ``"mlp"``.
"""
- def decorator(cls: type[OrcaModel]) -> type[OrcaModel]:
+ def decorator(cls: type[_ModelT]) -> type[_ModelT]:
if name in _MODEL_REGISTRY and _MODEL_REGISTRY[name] is not cls:
raise ValueError(f"Model name '{name}' is already registered.")
_MODEL_REGISTRY[name] = cls
diff --git a/src/orca/training/models/mlp.py b/src/orca/training/models/mlp.py
index b17b378..6598894 100644
--- a/src/orca/training/models/mlp.py
+++ b/src/orca/training/models/mlp.py
@@ -2,7 +2,7 @@
import optuna
import torch
-import torch.nn as nn
+from torch import nn
from orca.training.basis_expansion import BasisExpansion
from orca.training.models.base_model import OrcaModel, register_model
diff --git a/src/orca/training/models/transformer.py b/src/orca/training/models/transformer.py
index 59d0347..629c40e 100644
--- a/src/orca/training/models/transformer.py
+++ b/src/orca/training/models/transformer.py
@@ -1,5 +1,5 @@
-import torch.nn as nn
import torch
+from torch import nn
class SParamTransformer(nn.Module):
diff --git a/src/orca/training/normalize.py b/src/orca/training/normalize.py
index 93eb28d..1d0996c 100644
--- a/src/orca/training/normalize.py
+++ b/src/orca/training/normalize.py
@@ -1,7 +1,7 @@
-import torch
-import torch.nn as nn
from abc import ABC, abstractmethod
-import numpy as np
+
+import torch
+from torch import nn
from orca.geometry.input_parameters import InputParameterIterator
from orca.logger import logger
@@ -21,6 +21,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
Args:
x (torch.Tensor): Input tensor of shape (batch_size, n_input_parameters).
+
Returns:
torch.Tensor: Normalized tensor of shape (batch_size, n_input_parameters).
"""
@@ -32,6 +33,7 @@ def denormalize(self, x: torch.Tensor) -> torch.Tensor:
Args:
x (torch.Tensor): Normalized tensor of shape (batch_size, n_output_parameters).
+
Returns:
torch.Tensor: Denormalized tensor of shape (batch_size, n_output_parameters).
"""
@@ -42,6 +44,7 @@ def normalize(self, x: torch.Tensor) -> torch.Tensor:
Args:
x (torch.Tensor): Input tensor of shape (batch_size, n_input_parameters).
+
Returns:
torch.Tensor: Normalized tensor of shape (batch_size, n_input_parameters).
"""
@@ -106,14 +109,13 @@ def is_fitted(self) -> bool:
return getattr(self, "_fitted", False)
@abstractmethod
- def process_samples(self, samples: list):
+ def process_samples(self, samples: list[torch.Tensor]):
"""
Process the samples to compute normalization statistics.
Args:
samples (list): List of output parameter samples.
"""
- pass
class MinMaxNormalizer(InputNormalizer):
@@ -121,6 +123,10 @@ class MinMaxNormalizer(InputNormalizer):
A normalizer that applies min-max normalization to input parameters.
"""
+ # Buffers registered in __init__; declared here so they are typed as tensors
+ input_mins: torch.Tensor
+ input_maxs: torch.Tensor
+
def __init__(self, input_parameter_iterator: InputParameterIterator):
"""
Applies component-wise min-max normalization to the input tensor.
@@ -151,12 +157,16 @@ class OutputMinMaxNormalizer(OutputNormalizer):
A normalizer that applies min-max normalization to output parameters.
"""
- def process_samples(self, samples: list[tuple[np.ndarray, np.ndarray]]):
+ # Buffers registered by process_samples; declared here so they are typed as tensors
+ output_mins: torch.Tensor
+ output_maxs: torch.Tensor
+
+ def process_samples(self, samples: list[torch.Tensor]):
output_mins, output_maxs = self.get_output_min_max(samples)
self.register_buffer("output_mins", output_mins)
self.register_buffer("output_maxs", output_maxs)
- def get_output_min_max(self, samples) -> tuple[list[float], list[float]]:
+ def get_output_min_max(self, samples: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
"""Calculate min and max of output parameters for normalization."""
stacked_samples = torch.vstack(samples)
mins = torch.min(stacked_samples, dim=0).values
@@ -177,12 +187,18 @@ class StandardNormalizer(OutputNormalizer):
A normalizer that applies standard score normalization to input parameters.
"""
- def process_samples(self, samples: list[tuple[np.ndarray, np.ndarray]]):
+ # Buffers registered by process_samples; declared here so they are typed as tensors
+ input_means: torch.Tensor
+ input_stds: torch.Tensor
+
+ def process_samples(self, samples: list[torch.Tensor]):
input_means, input_stds = self.get_output_means_stds(samples)
self.register_buffer("input_means", input_means)
self.register_buffer("input_stds", input_stds)
- def get_output_means_stds(self, samples) -> tuple[list[float], list[float]]:
+ def get_output_means_stds(
+ self, samples: list[torch.Tensor]
+ ) -> tuple[torch.Tensor, torch.Tensor]:
"""Calculate means and standard deviations of output parameters for normalization."""
stacked_samples = torch.vstack(samples)
means = torch.mean(stacked_samples, dim=0)
diff --git a/src/orca/training/onnx_wrapper.py b/src/orca/training/onnx_wrapper.py
index b6207ac..7594704 100644
--- a/src/orca/training/onnx_wrapper.py
+++ b/src/orca/training/onnx_wrapper.py
@@ -1,4 +1,5 @@
import torch
+
from orca.training.normalize import Normalizer
@@ -12,15 +13,18 @@ class ONNXWrapper(torch.nn.Module):
Args:
model: The PyTorch model to be wrapped.
+ input_normalizer: Applied to the concatenated inputs before the model, if given.
+ output_denormalizer: Its ``denormalize`` is applied to the model output, if given.
+
Returns:
A torch.nn.Module that can be passed to torch.onnx.export.
"""
def __init__(
self,
- model,
- input_normalizer: Normalizer,
- output_denormalizer: Normalizer,
+ model: torch.nn.Module,
+ input_normalizer: Normalizer | None,
+ output_denormalizer: Normalizer | None,
):
super().__init__()
self.model = model
diff --git a/src/orca/training/predictors.py b/src/orca/training/predictors.py
index 088c752..135a238 100644
--- a/src/orca/training/predictors.py
+++ b/src/orca/training/predictors.py
@@ -10,15 +10,19 @@
from __future__ import annotations
from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING
import numpy as np
-import skrf as rf
import torch
-from orca.training.codecs import OutputCodec
-from orca.training.models.base_model import OrcaModel
from orca.training.spec import FrequencyMode
+if TYPE_CHECKING:
+ import skrf as rf
+
+ from orca.training.codecs import OutputCodec
+ from orca.training.models.base_model import OrcaModel
+
class NetworkPredictor(ABC):
"""Predicts the response of a geometry over a frequency grid."""
@@ -101,14 +105,14 @@ def __init__(self, session, codec: OutputCodec):
def predict(self, params: np.ndarray, frequencies: np.ndarray) -> rf.Network:
frequencies = np.asarray(frequencies)
- params = iter(np.asarray(params, dtype=np.float32))
+ # Geometry parameters are consumed in model-input order; frequency is the sweep
+ param_values = iter(np.asarray(params, dtype=np.float32))
feed = {}
for name in self.input_names:
- if name == "frequency":
- column = frequencies
- else:
- column = np.full(len(frequencies), next(params))
+ column = (
+ frequencies if name == "frequency" else np.full(len(frequencies), next(param_values))
+ )
feed[name] = column.reshape(-1, 1).astype(np.float32)
outputs = self.session.run(self.output_names, feed)
diff --git a/src/orca/training/spec.py b/src/orca/training/spec.py
index 19f3e6e..873d020 100644
--- a/src/orca/training/spec.py
+++ b/src/orca/training/spec.py
@@ -9,10 +9,12 @@
from dataclasses import dataclass
from enum import Enum, auto
+from typing import TYPE_CHECKING
-import numpy as np
+if TYPE_CHECKING:
+ import numpy as np
-from orca.training.codecs import OutputCodec
+ from orca.training.codecs import OutputCodec
class FrequencyMode(Enum):
diff --git a/src/orca/training/trainer.py b/src/orca/training/trainer.py
index d6fcd93..60ff27d 100644
--- a/src/orca/training/trainer.py
+++ b/src/orca/training/trainer.py
@@ -11,16 +11,18 @@
import copy
from dataclasses import dataclass, field
-from typing import Any, Callable, Optional
+from typing import TYPE_CHECKING, Any
import optuna
import torch
-import torch.nn as nn
import tqdm
from torch.optim import AdamW, Optimizer
from torch.utils.data import DataLoader, Dataset
-from orca.training.models.base_model import OrcaModel
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from orca.training.models.base_model import OrcaModel
def default_device() -> torch.device:
@@ -36,7 +38,7 @@ class TrainingConfig:
batch_size: Mini-batch size for both training and validation.
learning_rate: Initial learning rate handed to the optimizer.
patience: Epochs without validation improvement before stopping early.
- optimizer_cls: Optimizer class, constructed as ``optimizer_cls(params, lr=...)``.
+ optimizer_cls: Optimizer factory, called as ``optimizer_cls(params, lr=...)``.
scheduler_factor: Factor by which ReduceLROnPlateau scales the learning rate.
scheduler_patience: Plateau length, in epochs, before the scheduler reacts.
device: Device to train on.
@@ -46,13 +48,13 @@ class TrainingConfig:
batch_size: int = 128
learning_rate: float = 1e-3
patience: int = 10
- optimizer_cls: type[Optimizer] = AdamW
+ optimizer_cls: Callable[..., Optimizer] = AdamW
scheduler_factor: float = 0.5
scheduler_patience: int = 10
device: torch.device = field(default_factory=default_device)
@classmethod
- def from_hyperparameters(cls, hyperparameters: dict[str, Any], **overrides) -> "TrainingConfig":
+ def from_hyperparameters(cls, hyperparameters: dict[str, Any], **overrides) -> TrainingConfig:
"""Build a config from a hyperparameter dict, ignoring architecture keys.
Args:
@@ -95,7 +97,7 @@ class TrainingResult:
stopped_early: Whether early stopping ended the run before ``epochs``.
"""
- model: nn.Module
+ model: OrcaModel
best_loss: float
history: list[EpochResult]
stopped_early: bool
@@ -121,8 +123,8 @@ class Trainer:
def __init__(
self,
config: TrainingConfig | None = None,
- criterion: Optional[Callable[[torch.Tensor, torch.Tensor], torch.Tensor]] = None,
- progress_callback: Optional[Callable[[str, int, int, str], None]] = None,
+ criterion: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] | None = None,
+ progress_callback: Callable[[str, int, int, str], None] | None = None,
stage_name: str = "Training",
verbose: bool = True,
):
@@ -132,24 +134,22 @@ def __init__(
self.stage_name = stage_name
self.verbose = verbose
- def resolve_criterion(self, model: nn.Module) -> Callable:
+ def resolve_criterion(self, model: OrcaModel) -> Callable:
"""The configured loss, or the one the model asks to be trained with."""
if self.criterion is not None:
return self.criterion
- if isinstance(model, OrcaModel):
- return model.default_loss()
- return nn.L1Loss()
+ return model.default_loss()
def fit(
self,
- model: nn.Module,
+ model: OrcaModel,
train_dataset: Dataset,
val_dataset: Dataset,
) -> TrainingResult:
"""Train ``model``, keeping the weights with the lowest validation loss.
Args:
- model (nn.Module): Model to train. Moved to the configured device.
+ model (OrcaModel): Model to train. Moved to the configured device.
train_dataset (Dataset): Samples to optimize on.
val_dataset (Dataset): Samples used for early stopping and scheduling.
@@ -200,12 +200,13 @@ def fit(
message = f"Train: {train_loss:.4f} | Val: {val_loss:.4f}"
self._report(epoch + 1, message)
if self.verbose:
- print(f"Epoch {epoch + 1:4d} | {message}")
+ # tqdm.write keeps the line clear of the epoch progress bar
+ tqdm.tqdm.write(f"Epoch {epoch + 1:4d} | {message}")
if patience_counter >= config.patience:
stopped_early = True
if self.verbose:
- print("Early stopping triggered")
+ tqdm.tqdm.write("Early stopping triggered")
break
if best_state is not None:
@@ -217,9 +218,9 @@ def fit(
def evaluate(
self,
- model: nn.Module,
+ model: OrcaModel,
dataset: Dataset,
- criterion: Optional[Callable] = None,
+ criterion: Callable | None = None,
batch_size: int | None = None,
) -> float:
"""Mean loss of ``model`` over ``dataset``, without updating any weights."""
@@ -232,9 +233,11 @@ def _train_epoch(self, model, criterion, optimizer, loader) -> float:
model.train()
total = 0.0
- for x, y in tqdm.tqdm(loader, desc="Training", leave=False, disable=not self.verbose):
- x = x.to(self.config.device)
- y = y.to(self.config.device)
+ for batch_x, batch_y in tqdm.tqdm(
+ loader, desc="Training", leave=False, disable=not self.verbose
+ ):
+ x = batch_x.to(self.config.device)
+ y = batch_y.to(self.config.device)
optimizer.zero_grad()
loss = criterion(model(x), y)
@@ -257,9 +260,9 @@ def _run_eval(self, model, criterion, loader, desc: str, show_progress: bool = T
iterator = tqdm.tqdm(loader, desc=desc, leave=False, disable=not self.verbose)
with torch.no_grad():
- for x, y in iterator:
- x = x.to(self.config.device)
- y = y.to(self.config.device)
+ for batch_x, batch_y in iterator:
+ x = batch_x.to(self.config.device)
+ y = batch_y.to(self.config.device)
total += criterion(model(x), y).item()
return total / len(loader)
diff --git a/src/orca/training/tuner.py b/src/orca/training/tuner.py
index 442ff52..4d9a79a 100644
--- a/src/orca/training/tuner.py
+++ b/src/orca/training/tuner.py
@@ -8,17 +8,20 @@
from __future__ import annotations
import traceback
-from typing import Any, Optional
+from typing import TYPE_CHECKING, Any
import optuna
from sklearn.model_selection import KFold
-from torch.utils.data import Dataset, Subset
+from torch.utils.data import Subset
from orca.logger import logger
-from orca.training.basis_expansion import BasisExpansion
-from orca.training.models.base_model import OrcaModel
from orca.training.trainer import Trainer, TrainingConfig
+if TYPE_CHECKING:
+ from orca.training.basis_expansion import BasisExpansion
+ from orca.training.datasets.base_dataset import BaseDataset
+ from orca.training.models.base_model import OrcaModel
+
def suggest_hyperparameters(trial: optuna.Trial, search_space: dict[str, Any]) -> dict[str, Any]:
"""Draw one value per entry of a search space from an optuna trial.
@@ -44,7 +47,7 @@ def suggest_hyperparameters(trial: optuna.Trial, search_space: dict[str, Any]) -
step=distribution.step,
log=distribution.log,
)
- elif isinstance(distribution, optuna.distributions.FloatDistribution):
+ elif isinstance(distribution, optuna.distributions.FloatDistribution):
values[key] = trial.suggest_float(
key,
distribution.low,
@@ -67,8 +70,8 @@ class HyperparameterTuner:
Args:
model_cls (type[OrcaModel]): Architecture to tune.
- dataset (Dataset): Train/validation data, split into folds internally.
- Must expose an ``io_spec`` (any :class:`~orca.training.datasets.base_dataset.BaseDataset`).
+ dataset (BaseDataset): Train/validation data, split into folds internally;
+ its ``io_spec`` sizes the models.
basis_cls (type[BasisExpansion] | None): Basis expansion to build each model
with. Its search space is tuned alongside the model's. ``None`` trains
on the raw inputs.
@@ -82,13 +85,13 @@ class HyperparameterTuner:
def __init__(
self,
model_cls: type[OrcaModel],
- dataset: Dataset,
- basis_cls: Optional[type[BasisExpansion]] = None,
+ dataset: BaseDataset,
+ basis_cls: type[BasisExpansion] | None = None,
n_fold_cv: int = 5,
n_trials: int = 200,
seed: int = 42,
- sampler: Optional[optuna.samplers.BaseSampler] = None,
- pruner: Optional[optuna.pruners.BasePruner] = None,
+ sampler: optuna.samplers.BaseSampler | None = None,
+ pruner: optuna.pruners.BasePruner | None = None,
):
self.model_cls = model_cls
self.dataset = dataset
@@ -159,15 +162,15 @@ def _run_fold(self, trial, config, hyperparameters, fold_idx, train_indices, val
train_dataset=Subset(self.dataset, list(train_indices)),
val_dataset=Subset(self.dataset, list(val_indices)),
)
- except Exception:
+ except Exception as e:
traceback.print_exc()
- raise optuna.exceptions.TrialPruned()
+ raise optuna.exceptions.TrialPruned from e
logger.info(f"{fold_label} | Val Loss: {result.best_loss:.4f}")
# Report once per fold, using the fold index as the pruning step
trial.report(result.best_loss, fold_idx)
if trial.should_prune():
- raise optuna.exceptions.TrialPruned()
+ raise optuna.exceptions.TrialPruned
return result.best_loss
diff --git a/src/orca/utils/__init__.py b/src/orca/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/orca/utils/class_finder.py b/src/orca/utils/class_finder.py
index 5419175..6fb2dad 100644
--- a/src/orca/utils/class_finder.py
+++ b/src/orca/utils/class_finder.py
@@ -6,17 +6,17 @@
import inspect
import os
from pathlib import Path
-from typing import Type, Dict, Any
+from typing import Any
from orca.logger import logger
def discover_classes(
- base_class: Type,
+ base_class: type,
search_dir: str,
module_prefix: str,
extract_default_params: bool = False,
-) -> Dict[str, Dict[str, Any]]:
+) -> dict[str, dict[str, Any]]:
"""
Automatically discover and load classes that inherit from a specified base class.
@@ -85,10 +85,10 @@ def discover_classes(
f"Discovered {base_class.__name__} subclass: {display_name}"
)
- except Exception as e:
+ except Exception as e: # noqa: BLE001 - skip the class, keep discovering
logger.warning(f"Could not process {name}: {e}")
- except Exception as e:
+ except Exception as e: # noqa: BLE001 - a broken plugin file must not stop discovery
logger.warning(f"Failed to load classes from {py_file.name}: {e}")
if not classes:
diff --git a/src/orca/utils/postprocessing.py b/src/orca/utils/postprocessing.py
index cbc001b..d75048f 100644
--- a/src/orca/utils/postprocessing.py
+++ b/src/orca/utils/postprocessing.py
@@ -1,6 +1,8 @@
-import skrf as rf
-import numpy as np
import matplotlib.pyplot as plt
+import numpy as np
+import skrf as rf
+
+from orca.logger import logger
def to_mixed_mode(ntwk):
@@ -38,10 +40,8 @@ def calculate_electrical_parameters(ntwk):
Qs = np.imag(z_d22) / np.real(z_d22)
k = np.abs(np.imag(z_d12)) / np.sqrt(np.abs(np.imag(z_d11) * np.imag(z_d22)))
- #srf_idx = np.where(np.diff(np.sign(np.imag(z_d11))))[0]
- #srf_f = freq_ghz[srf_idx[0]] if len(srf_idx) > 0 else None
im = np.imag(z_d11)
- cross = np.where(im[:-1] * im[1:] < 0)[0] # echte Vorzeichenwechsel
+ cross = np.where(im[:-1] * im[1:] < 0)[0] # actual sign changes only
f_min = 20.0
cross = cross[freq_ghz[cross] >= f_min]
@@ -59,8 +59,6 @@ def calculate_electrical_parameters(ntwk):
srf_f = float(f0 - y0 * (f1 - f0) / (y1 - y0))
return {
- #"mm_ntwk": mm_ntwk,
- #"freq_ghz": freq_ghz,
"Lp": np.array(Lp),
"Ls": np.array(Ls),
"Rp": np.array(Rp),
@@ -261,7 +259,7 @@ def s_param_list_to_network(s_param_list: np.ndarray) -> tuple[int, list[rf.Netw
# Assume s_param_list shape is (batch_size, num_params)
num_params = s_param_list.shape[1]
N = int(np.sqrt(num_params // 2)) # number of ports
- print(f"Number of ports inferred: {N}")
+ logger.debug(f"Number of ports inferred: {N}")
# Create a network for each sample in the batch
ntwk_list = []
for sample in s_param_list:
@@ -280,20 +278,23 @@ def single_ended_to_mixed_mode(ntwk: rf.Network) -> rf.Network:
"""
Converts a 4-port single-ended network to a 2-port mixed-mode network using rf.se2gmm.
Usually port 1 and 2 are considered differential pair 1, and port 3 and 4 differential pair 2.
+
Args:
- network (rf.Network): 4-port single-ended network.
+ ntwk (rf.Network): 4-port single-ended network, converted in place.
+
Returns:
rf.Network: 2-port mixed-mode network.
"""
ntwk.se2gmm(p=2)
- return ntwk.nports, ntwk
+ return ntwk
def plot_diff_s_params_and_k(ntwk: rf.Network):
"""
Plots the differential S-parameters and coupling factor k for a 4-port single-ended network.
+
Args:
- network (rf.Network): 4-port single-ended network.
+ ntwk (rf.Network): 4-port single-ended network.
"""
# Calculate k
z = ntwk.z
@@ -311,9 +312,12 @@ def plot_diff_s_params_and_k(ntwk: rf.Network):
# Primary Y-Axis (S-parameters)
ax1.set_xlabel("Frequency")
ax1.set_ylabel("S-Parameters (dB)")
- ntwk.plot_s_db(m=1, n=0, ax=ax1, label="Insertion Loss ($S_{d2d1}$)")
- ntwk.plot_s_db(m=0, n=0, ax=ax1, label="Return Loss ($S_{d1d1}$)")
- ntwk.plot_s_db(m=3, n=0, ax=ax1, label="Mode Conversion ($S_{c2d1}$)")
+ # Plotted against the same frequency axis as k below (ntwk.f in Hz), which
+ # Network.plot_s_db would not do: it scales the axis to the network's unit.
+ ax1.plot(freq_ghz, ntwk.s_db[:, 1, 0], label="Insertion Loss ($S_{d2d1}$)")
+ ax1.plot(freq_ghz, ntwk.s_db[:, 0, 0], label="Return Loss ($S_{d1d1}$)")
+ ax1.plot(freq_ghz, ntwk.s_db[:, 3, 0], label="Mode Conversion ($S_{c2d1}$)")
+ ax1.legend(loc="lower left")
# Secondary Y-Axis (k)
ax2 = ax1.twinx()
diff --git a/uv.lock b/uv.lock
index dd1c740..2c20a8a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2271,6 +2271,12 @@ train = [
{ name = "torch", version = "2.14.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "(extra == 'extra-4-orca-cpu' and extra == 'extra-4-orca-cu126') or (extra != 'extra-4-orca-cu126' and extra == 'extra-4-orca-cu130') or (extra != 'extra-4-orca-cpu' and extra == 'extra-4-orca-cu130')" },
]
+[package.dev-dependencies]
+dev = [
+ { name = "ruff" },
+ { name = "ty" },
+]
+
[package.metadata]
requires-dist = [
{ name = "colorlog" },
@@ -2297,6 +2303,12 @@ requires-dist = [
]
provides-extras = ["train", "cpu", "cu126", "cu130"]
+[package.metadata.requires-dev]
+dev = [
+ { name = "ruff" },
+ { name = "ty" },
+]
+
[[package]]
name = "orjson"
version = "3.12.0"
@@ -3212,6 +3224,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/2a/b508237a7fcceab8a8724405480eb55d527523419c9dbcde369f954656ad/ruamel.yaml.string-0.1.1-py3-none-any.whl", hash = "sha256:eb146bcb42b116216638034a434e9cf3ae2a5d3933aa37183a9854b5f3ff42de", size = 4118, upload-time = "2023-05-02T05:37:20.332Z" },
]
+[[package]]
+name = "ruff"
+version = "0.16.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
+ { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
+ { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
+ { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
+ { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
+ { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
+]
+
[[package]]
name = "scikit-image"
version = "0.26.0"
@@ -3942,6 +3979,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/b6/3394d5548404c1cabd1dadadd28d0b3f9478db1dff8180da53bb3f0a1e19/triton-3.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0497218e26b7d79773ad9c2a3fa3b539ee69f587a13fac2e552b1d322a8015", size = 247975122, upload-time = "2026-08-28T15:56:04.112Z" },
]
+[[package]]
+name = "ty"
+version = "0.0.81"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/b7/c9d736f48585f5a711ea47bb97a353d3771834f89481d747ea9687b74fa9/ty-0.0.81.tar.gz", hash = "sha256:ef721aa649bf41d665ba86e1ea726fd3feab6800e2c4887a062a704baf304ca8", size = 7225871, upload-time = "2026-09-15T02:05:29.536Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/70/649a5ec8fd6cc9dfa731c905523b19e677ad36dd9dcc4fcadfed8235507f/ty-0.0.81-py3-none-linux_armv6l.whl", hash = "sha256:8e9e3dd6edb1462633ddba2bf337d1c05023a5a13c2f315e445565c7ad43d247", size = 13679646, upload-time = "2026-09-15T02:04:51.436Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d0/3769b760918c3dde1bbaeee87422cd442fc550baadb5c8621e479b363282/ty-0.0.81-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:649c350873e4b3e937d856512ee57b8a345d80079cbead73a5308c2cdafe594c", size = 13211367, upload-time = "2026-09-15T02:04:54.18Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/5f/b8fe3ab7eee87bbf1f5ff1b65c55526a3f31a9604630bc9e7d84199bbac5/ty-0.0.81-py3-none-macosx_11_0_arm64.whl", hash = "sha256:480c78706ba78239f901d68cf1ba53e98b84aa51e19b9241daa0fb62fdecd2e2", size = 13084312, upload-time = "2026-09-15T02:04:56.133Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/d8/ce22278014f14ae8205d2f9622696d39c623a0c9e80056c258bb89bb8322/ty-0.0.81-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cdbfa4b634b1d21542eedc11e424dedcc4871c2128d0d46b55b0089d3220492", size = 13127965, upload-time = "2026-09-15T02:04:58.306Z" },
+ { url = "https://files.pythonhosted.org/packages/82/d0/4e75c61f6a241a0804f25117af4f91a5aaa4a48408b57f8d4c34d8a3d91e/ty-0.0.81-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f7a34743a21669d4f3d5fda98c36189cb4d9dceee1415a8a42d7426d55995db", size = 13432523, upload-time = "2026-09-15T02:05:00.471Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/02/e0b6b783418515c8ba40c13d5e333da9e0576a7a69582fc2b5831bb7d913/ty-0.0.81-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:742eab849947bc10b4c09810d37bd743afe9502e114b7bcfd6389f0ae3f425aa", size = 14211082, upload-time = "2026-09-15T02:05:02.455Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/af/85cb68d13ff617f2b213212d674e1266b933f2d051b63a1e5116e8ca0bfc/ty-0.0.81-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b4cef395eee7007449aa3fc3fc8dc6bc58fe7a8956d56db078569061741bf52", size = 14692204, upload-time = "2026-09-15T02:05:04.705Z" },
+ { url = "https://files.pythonhosted.org/packages/24/c6/791891f3fa8fec91e9ec3e8c9d6774ab903297defe847a3e5d7e047360ad/ty-0.0.81-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80cf8c34973a1da2ec2a043efcba8f4c0a984d45210957a8ddd2c16f2b8f8e85", size = 14371010, upload-time = "2026-09-15T02:05:06.63Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b2/c9fba5b4ed7f63f26e83432fa2062ca04606b61dce7bbacc1238232af649/ty-0.0.81-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f37a8f66c444eea29f17adfe0664f42062520773f18031fd4913f28eb56d2966", size = 13758537, upload-time = "2026-09-15T02:05:08.722Z" },
+ { url = "https://files.pythonhosted.org/packages/77/e6/09c41e96bbcbe53dd18c9a1a6dbd2e456b0d03b94440a4bd9bab55a4e19a/ty-0.0.81-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e8134cc03e84a27f409877b9b62053fd4aaac9c3ce0ce0fdace8b5ed58d78e04", size = 14280789, upload-time = "2026-09-15T02:05:10.719Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d1/87eb8e01fb17602cde5911cbadb9b11141a04f728dbda0a2a00243f337ca/ty-0.0.81-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb6f269c5f14a07047e05cbf3d06dd9a64a048050f736afcf744a180a272f034", size = 13169638, upload-time = "2026-09-15T02:05:12.806Z" },
+ { url = "https://files.pythonhosted.org/packages/94/3c/137ceb792858f408c48f20a066d12ee76108e5f28a13242720879c024172/ty-0.0.81-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:38355a5e6294ebd3cee616ed581d051963d61b293a1b3351c248747f73a45b74", size = 13449856, upload-time = "2026-09-15T02:05:15.505Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0a/4a9b31fadfc80a2141f3862316eb31944843e01bc1f6533794291cce767e/ty-0.0.81-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59a35e128a9831423aee19db804aa8a1358ee3ce615b3fc0dec20d40bfd0d733", size = 13675303, upload-time = "2026-09-15T02:05:17.537Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/16/dbcd2c10ab80b015a227110a4ec75314468b6720d800261098521df4eee9/ty-0.0.81-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f0f4219fbc068ea3414fa804c6bd57ef4229a11884ab9ca71cd8308072520aad", size = 14050298, upload-time = "2026-09-15T02:05:20.424Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ba/a70ced0c0c078ff09a3a80224d045c4267d07811dbaf125c749af5f72606/ty-0.0.81-py3-none-win32.whl", hash = "sha256:82ff952e64ba4c4da1c0c89723e326c768ae4f9ff20624dee04a51c3f4e4b96c", size = 12866650, upload-time = "2026-09-15T02:05:22.824Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/51/4b79453df93dfb9edecb1bd6cb04e772277d8c7260693165358a033b6ed1/ty-0.0.81-py3-none-win_amd64.whl", hash = "sha256:ef82788744da0b7f2a598e4e9776f9c5998df4eef7e2750ae3a5537e7c734b69", size = 13572976, upload-time = "2026-09-15T02:05:24.907Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/56/09594d55a0c49543eeac149c0dbc80b9b04409cc26ca48f1c1a9595c0916/ty-0.0.81-py3-none-win_arm64.whl", hash = "sha256:bdb1563990c5d3abfe5a09e7512733f302773ede6a5d3e4e5c6ad46393d8df4d", size = 13364408, upload-time = "2026-09-15T02:05:27.247Z" },
+]
+
[[package]]
name = "typer"
version = "0.24.2"