"""Minimal command-line interface for reASITIC.
A tiny REPL that loads a tech file, parses ASITIC-style ``NAME=VALUE``
arguments, and runs a subset of the original commands.
Currently supported:
* ``LOAD-TECH <path>`` (alias: ``T``) — load a ``.tek`` file
* Geometry builders:
* ``W NAME=...:LEN=...:WID=...:METAL=...`` — wire
* ``SQ NAME=...:LEN=...:W=...:S=...:N=...:METAL=...`` — square spiral
* ``SP NAME=...:RADIUS=...:W=...:S=...:N=...:SIDES=...:METAL=...`` — polygon spiral
* ``RING NAME=...:RADIUS=...:W=...:METAL=...:SIDES=...`` — ring
* ``VIA NAME=...:VIA=<idx>:NX=...:NY=...:XORG=...:YORG=...`` — via
* ``3DTRANS NAME=...:LEN=...:W=...:S=...:N=...:METAL_TOP=...:METAL_BOTTOM=...:VIA=...``
* Analysis commands:
* ``IND <name>`` — self-inductance in nH
* ``RES <name> [freq_ghz]`` — DC (and optional AC) resistance in Ω
* ``Q <name> <freq_ghz>`` — metal-only quality factor
* ``K <name1> <name2>`` — mutual inductance / coupling coefficient
* ``2PORT <name> <f0> <f1> <step>`` — frequency sweep, prints each f
* ``PI <name> <freq_ghz>`` — Pi-model L/R/C breakout
* ``ZIN <name> <freq_ghz> [Z_re Z_im]`` — input impedance with load
* ``SELFRES <name> <f_lo> <f_hi>`` — self-resonance frequency
* ``CAP <name>`` — substrate shunt capacitance in F
* ``METAREA <name>`` — metal area in μm²
* ``LISTSEGS <name>`` — print all segments
* ``LRMAT <name> [path]`` — partial-L matrix
* ``SHUNTR <name> <freq_ghz> [S|D]`` — parallel resistance
* ``PI3 <name> <freq_ghz> [<gnd_name>]`` — 3-port Pi model
* ``PI4 <name> <freq_ghz> [<pad1> [<pad2>]]`` — 4-port Pi model
* ``CALCTRANS <pri> <sec> <freq_ghz>`` — transformer analysis
* Export / persistence:
* ``SAVE <path>`` / ``LOAD <path>`` — JSON session round-trip
* ``CIFSAVE <path> [name [name ...]]`` — write CIF
* ``TEKSAVE <path> [name [name ...]]`` — write Tek/gnuplot dump
* ``SONNETSAVE <path> [name [name ...]]`` — write Sonnet ``.son``
* ``S2PSAVE <name> <f0> <f1> <step> <path>`` — Touchstone export
* Optimisation:
* ``OPTSQ <target_L_nH> <freq_ghz> [metal]`` — square-spiral OptSq
* ``OPTPOLY <target_L_nH> <freq_ghz> [sides] [metal]`` — polygon-spiral
* ``OPTAREA <target_L_nH> <freq_ghz> [metal]`` — minimise footprint
* ``OPTSYMSQ <target_L_nH> <freq_ghz> [metal]`` — symmetric square
* ``BATCHOPT [<targets_file>]`` — batch optimise across many points
* ``SWEEP LMIN=...:LMAX=...:...:FREQ=...:[PATH=...]`` — Cartesian (L, W, S, N) sweep
* ``SPICESAVE <name> <freq_ghz> <path>`` — emit SPICE Pi-model
* Misc:
* ``GEOM <name>`` — geometry summary
* ``LIST`` — list shapes
* ``QUIT`` / ``EXIT``
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from reasitic import (
Polygon,
Shape,
Tech,
balun,
capacitor,
multi_metal_square,
parse_tech_file,
polygon_spiral,
ring,
square_spiral,
symmetric_polygon,
symmetric_square,
transformer,
transformer_3d,
via,
wire,
)
from reasitic.exports import (
write_cif_file,
write_sonnet,
write_spice_subckt_file,
write_tek_file,
)
from reasitic.inductance import (
compute_mutual_inductance,
compute_self_inductance,
coupling_coefficient,
)
from reasitic.info import (
format_lr_matrix,
format_segments,
metal_area,
)
from reasitic.network import (
linear_freqs,
two_port_sweep,
write_touchstone_file,
)
from reasitic.network.analysis import (
calc_transformer,
pi3_model,
pi4_model,
pi_model_at_freq,
pix_model,
self_resonance,
shunt_resistance,
zin_terminated,
)
from reasitic.optimise import (
OptResult,
batch_opt_square,
optimise_area_square_spiral,
optimise_polygon_spiral,
optimise_square_spiral,
optimise_symmetric_square,
sweep_square_spiral,
sweep_to_tsv,
)
from reasitic.persistence import load_session, load_viewport, save_session
from reasitic.quality import metal_only_q
from reasitic.resistance import compute_ac_resistance, compute_dc_resistance
from reasitic.substrate import shape_shunt_capacitance
_COMMAND_CATEGORIES = """\
Categories of commands (use HELP <command> for details):
Create: SQ, SP, RING, W, VIA, 3DTRANS, BALUN, CAPACITOR
Edit: MOVE, MOVETO, ROTATE, FLIPV, FLIPH, ERASE, RENAME, COPY
Calc: IND, RES, Q, K, CAP, METAREA, LISTSEGS, LRMAT
Network: PI, ZIN, SELFRES, SHUNTR, PI3, PI4, CALCTRANS, 2PORT,
2PORTGND, 2PORTPAD, 3PORT, REPORT
Optimise: OPTSQ, OPTPOLY, OPTAREA, OPTSYMSQ, BATCHOPT, SWEEP
Export: SAVE, LOAD, CIFSAVE, TEKSAVE, SONNETSAVE, S2PSAVE, SPICESAVE
Session: VERBOSE, TIMER, SAVEMAT, LOG, RECORD, EXEC, CAT, VERSION,
HELP, LIST, GEOM, QUIT
"""
_COMMAND_HELP = {
"SQ": "SQ NAME=...:LEN=...:W=...:S=...:N=...:METAL=... — Square spiral",
"SP": "SP NAME=...:RADIUS=...:W=...:S=...:N=...:SIDES=...:METAL=... — Polygon spiral",
"RING": "RING NAME=...:RADIUS=...:W=...:METAL=...:SIDES=... — Single ring",
"W": "W NAME=...:LEN=...:WID=...:METAL=... — Single wire",
"VIA": "VIA NAME=...:VIA=<idx>:NX=...:NY=... — Via cluster",
"3DTRANS": (
"3DTRANS NAME=...:LEN=...:W=...:S=...:N=...:METAL_TOP=...:METAL_BOTTOM=..."
" — 3D transformer"
),
"IND": "IND <name> — Self-inductance in nH",
"RES": "RES <name> [freq_ghz] — DC and optional AC resistance",
"Q": "Q <name> <freq_ghz> — Metal-only quality factor",
"K": "K <name1> <name2> — Mutual inductance and coupling coefficient",
"CAP": "CAP <name> — Substrate shunt capacitance",
"METAREA": "METAREA <name> — Metal area in μm²",
"LISTSEGS": "LISTSEGS <name> — List all conductor segments",
"LRMAT": "LRMAT <name> [path] — Partial-L matrix",
"PI": "PI <name> <freq_ghz> — Pi-equivalent (L_s, R_s, C_p1, C_p2)",
"PIX": "PIX <name> <freq_ghz> — Extended Pi with R-C substrate split",
"PI3": "PI3 <name> <freq_ghz> [<gnd>] — 3-port Pi model",
"PI4": "PI4 <name> <freq_ghz> [<pad1> [<pad2>]] — 4-port Pi model",
"ZIN": "ZIN <name> <freq_ghz> [Z_re Z_im] — Input impedance with load",
"SELFRES": "SELFRES <name> <f_lo> <f_hi> — Self-resonance frequency",
"SHUNTR": "SHUNTR <name> <freq_ghz> [S|D] — Parallel-equivalent resistance",
"CALCTRANS": "CALCTRANS <pri> <sec> <freq_ghz> — Transformer L, M, k, n analysis",
"2PORT": "2PORT <name> <f0> <f1> <step> — Frequency sweep of S parameters",
"2PORTGND": "2PORTGND <name> <gnd> <f0> <f1> <step> — Sweep with ground spiral",
"2PORTPAD": "2PORTPAD <name> <pad1> <pad2> <f0> <f1> <step> — Sweep with bond pads",
"2PORTTRANS": "2PORTTRANS <pri> <sec> <f0> <f1> <step> — Transformer 2-port sweep",
"2PZIN": "2PZIN <name> <freq_ghz> [Z_re Z_im] — 2-port input impedance",
"3PORT": "3PORT <name> <gnd> <freq_ghz> — 3-port reduction",
"REPORT": "REPORT <name> <freq_ghz> [<freq_ghz> ...] — Multi-frequency design report",
"OPTSQ": "OPTSQ <target_L_nH> <freq_ghz> [metal] — Square-spiral optimiser",
"OPTPOLY": "OPTPOLY <target_L_nH> <freq_ghz> [sides] [metal] — Polygon-spiral optimiser",
"OPTAREA": "OPTAREA <target_L_nH> <freq_ghz> [metal] — Area-minimising optimiser",
"OPTSYMSQ": "OPTSYMSQ <target_L_nH> <freq_ghz> [metal] — Symmetric square optimiser",
"BATCHOPT": "BATCHOPT [<targets_file>] — Batch optimiser",
"SWEEP": "SWEEP LMIN=...:LMAX=...:LSTEP=...:WMIN=...:...:FREQ=... — Cartesian sweep",
"MOVE": "MOVE <name> <dx> <dy> — Translate a shape",
"MOVETO": "MOVETO <name> <x> <y> — Set shape origin",
"ROTATE": "ROTATE <name> <angle_deg> — Rotate about origin",
"FLIPV": "FLIPV <name> — Mirror across x-axis",
"FLIPH": "FLIPH <name> — Mirror across y-axis",
"ERASE": "ERASE <name> ... — Delete one or more shapes",
"RENAME": "RENAME <old> <new> — Rename a shape",
"COPY": "COPY <src> <dst> — Duplicate a shape",
"HIDE": "HIDE <name> ... — Toggle visibility (no-op headless)",
"BEFRIEND": "BEFRIEND <s1> <s2> — Mark two shapes as electrically connected",
"UNFRIEND": "UNFRIEND <s1> <s2> — Remove a befriended pair",
"INTERSECT": "INTERSECT <name> — Detect self-intersecting polygons",
"TRANS": "TRANS NAME=...:LEN=...:W=...:S=...:N=...:METAL=...:METAL2=... — Planar transformer",
"BALUN": "BALUN NAME=...:LEN=...:W=...:S=...:N=...:METAL=...:METAL2=... — Planar balun",
"CAPACITOR": "CAPACITOR NAME=...:LEN=...:WID=...:METAL1=...:METAL2=... — MIM capacitor",
"SYMSQ": "SYMSQ NAME=...:LEN=...:W=...:S=...:N=...:METAL=... — Symmetric square spiral",
"SYMPOLY": (
"SYMPOLY NAME=...:RAD=...:W=...:S=...:N=...:SIDES=...:METAL=..."
" — Symmetric polygon spiral"
),
"MMSQUARE": (
"MMSQUARE NAME=...:LEN=...:W=...:S=...:N=...:METALS=m1,m2,m3"
" — Multi-metal series spiral"
),
"OPTSYMPOLY": (
"OPTSYMPOLY <target_L_nH> <freq_ghz> [sides] [metal]"
" — Symmetric polygon optimiser"
),
"LDIV": "LDIV <name> <n_l> <n_w> <n_t> — Inductance with filament discretisation",
"SPLIT": "SPLIT <name> <segment_index> <new_name> — Split a shape",
"JOIN": "JOIN <s1> <s2> [<s3> ...] — Concatenate polygon lists into <s1>",
"PHASE": "PHASE <name> <+1|-1> — Set current direction sign",
"MODIFYTECHLAYER": "MODIFYTECHLAYER <rho|t|eps> <layer> <value> — Edit tech layer",
"CELL": "CELL [max_l] [max_w] [max_t] — Cell-size constraints",
"AUTOCELL": "AUTOCELL <alpha> <beta> — Adaptive cell size",
"CHIP": "CHIP [chipx] [chipy] — Resize chip extents",
"EDDY": "EDDY [on|off] — Toggle eddy-current calculation",
"PAUSE": "PAUSE — No-op (for binary parity)",
"INPUT": "INPUT <path> — Alias for EXEC",
"SAVE": "SAVE <path> — Save the current session as JSON",
"LOAD": "LOAD <path> — Load a JSON session",
"CIFSAVE": "CIFSAVE <path> [<name> ...] — Write CIF layout",
"TEKSAVE": "TEKSAVE <path> [<name> ...] — Write gnuplot/Tek dump",
"SONNETSAVE": "SONNETSAVE <path> [<name> ...] — Write Sonnet .son",
"S2PSAVE": "S2PSAVE <name> <f0> <f1> <step> <path> — Touchstone S2P export",
"SPICESAVE": "SPICESAVE <name> <freq_ghz> <path> — SPICE Pi-model",
"VERBOSE": "VERBOSE [true|false] — Toggle diagnostic output",
"TIMER": "TIMER [true|false] — Toggle per-command timing",
"SAVEMAT": "SAVEMAT [true|false] — Toggle matrix dumps",
"LOG": "LOG [<filename>] — Start/stop a session log",
"RECORD": "RECORD [<filename>] — Start/stop macro recording",
"EXEC": "EXEC <path> — Execute commands from a script file",
"CAT": "CAT <path> — Print contents of a file",
"VERSION": "VERSION — Print build info",
"HELP": "HELP [<command>] — Print this help",
"GEOM": "GEOM <name> — Print geometry summary",
"LIST": "LIST — List all built shapes",
"QUIT": "QUIT / EXIT — Leave the REPL",
}
def _polygons_overlap(p_i: Polygon, p_j: Polygon) -> bool:
"""Cheap bounding-box test for polygon overlap (xy plane)."""
if not p_i.vertices or not p_j.vertices:
return False
xs_i = [v.x for v in p_i.vertices]
ys_i = [v.y for v in p_i.vertices]
xs_j = [v.x for v in p_j.vertices]
ys_j = [v.y for v in p_j.vertices]
if min(xs_i) > max(xs_j) or max(xs_i) < min(xs_j):
return False
return not (min(ys_i) > max(ys_j) or max(ys_i) < min(ys_j))
def _frange(lo: float, hi: float, step: float) -> list[float]:
"""Inclusive linear range; matches `linear_freqs` semantics."""
if step <= 0:
raise ValueError("step must be > 0")
if hi < lo:
raise ValueError("hi must be >= lo")
n = round((hi - lo) / step) + 1
return [lo + i * step for i in range(n)]
def _parse_kv_args(arg_string: str) -> dict[str, str]:
"""Parse ``NAME=value:other=value`` style arguments.
Both ``:`` and whitespace separate fields. Values are taken as
strings; numeric coercion is the caller's responsibility.
"""
out: dict[str, str] = {}
parts = arg_string.replace(":", " ").split()
for tok in parts:
if "=" not in tok:
continue
k, _, v = tok.partition("=")
out[k.strip().upper()] = v.strip()
return out
[docs]
class Repl:
[docs]
def __init__(self, tech: Tech | None = None) -> None:
self.tech: Tech | None = tech
self.shapes: dict[str, Shape] = {}
# Toggles
self.verbose: bool = False
self.timer: bool = False
self.save_mat: bool = False
# Recording
self.log_path: Path | None = None
self.macro: list[str] | None = None
# Shape relations
self.friendships: set[frozenset[str]] = set()
self.selected_shape: str | None = None
# Viewport state (binary parity; mostly cosmetic in headless mode)
self.viewport: dict[str, float] = {
"scale": 1.0,
"pan_x": 0.0,
"pan_y": 0.0,
"origin_x": 0.0,
"origin_y": 0.0,
"grid": 0.0,
"snap": 0.0,
}
# Command handlers ----------------------------------------------------
[docs]
def cmd_load_tech(self, path: str) -> None:
self.tech = parse_tech_file(path)
print(f"Loaded tech file <{path}>")
[docs]
def cmd_wire(self, args: dict[str, str]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = wire(
args["NAME"],
length=float(args["LEN"]),
width=float(args["WID"]),
metal=args.get("METAL", 0),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
tech=self.tech,
)
self.shapes[sh.name] = sh
[docs]
def cmd_square(self, args: dict[str, str]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = square_spiral(
args["NAME"],
length=float(args["LEN"]),
width=float(args["W"]),
spacing=float(args["S"]),
turns=float(args["N"]),
tech=self.tech,
metal=args.get("METAL", 0),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
[docs]
def cmd_spiral(self, args: dict[str, str]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = polygon_spiral(
args["NAME"],
radius=float(args["RADIUS"]) if "RADIUS" in args else float(args["LEN"]) * 0.5,
width=float(args["W"]),
spacing=float(args["S"]),
turns=float(args["N"]),
sides=int(float(args.get("SIDES", "8"))),
metal=args.get("METAL", 0),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
tech=self.tech,
)
self.shapes[sh.name] = sh
[docs]
def cmd_ind(self, name: str) -> None:
sh = self.shapes[name]
L = compute_self_inductance(sh)
print(f"L({name}) = {L:.6f} nH")
[docs]
def cmd_res(self, name: str, freq_ghz: float | None = None) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
R_dc = compute_dc_resistance(sh, self.tech)
if freq_ghz is None:
print(f"R_dc({name}) = {R_dc:.6f} Ohm")
return
R_ac = compute_ac_resistance(sh, self.tech, freq_ghz)
print(f"R_dc({name}) = {R_dc:.6f} Ohm")
print(f"R_ac({name}, {freq_ghz} GHz) = {R_ac:.6f} Ohm")
[docs]
def cmd_coupling(self, name_a: str, name_b: str) -> None:
sh_a = self.shapes[name_a]
sh_b = self.shapes[name_b]
M = compute_mutual_inductance(sh_a, sh_b)
k = coupling_coefficient(sh_a, sh_b)
print(f"M({name_a}, {name_b}) = {M:.6f} nH")
print(f"k({name_a}, {name_b}) = {k:.4f}")
[docs]
def cmd_q(self, name: str, freq_ghz: float) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
Q = metal_only_q(sh, self.tech, freq_ghz)
L = compute_self_inductance(sh)
R = compute_ac_resistance(sh, self.tech, freq_ghz)
print(f"Q_metal({name}, {freq_ghz} GHz) = {Q:.3f}")
print(f" L = {L:.4f} nH, R_ac = {R:.4f} Ohm")
[docs]
def cmd_geom(self, name: str) -> None:
sh = self.shapes[name]
segs = sh.segments()
total_length = sum(s.length for s in segs)
xmin, ymin, xmax, ymax = sh.bounding_box()
print(f"Shape <{sh.name}> ({len(sh.polygons)} polygons, {len(segs)} segments)")
print(f" width = {sh.width:.2f}, spacing = {sh.spacing:.2f}, turns = {sh.turns:.2f}")
print(f" total length = {total_length:.2f} um")
print(f" bounding box = ({xmin:.2f}, {ymin:.2f}) -- ({xmax:.2f}, {ymax:.2f})")
[docs]
def cmd_list(self) -> None:
if not self.shapes:
print("(no shapes built)")
return
for name, sh in self.shapes.items():
print(f" {name}: {len(sh.polygons)} polygons, {len(sh.segments())} segments")
# Tech-edit & cell-size commands -------------------------------------
[docs]
def cmd_modify_tech_layer(
self, prop: str, layer_index: int, value: float
) -> None:
"""MODIFYTECHLAYER <rho|t|eps> <layer> <value> (case 222)."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
if not (0 <= layer_index < len(self.tech.layers)):
print(f"Layer {layer_index} out of range")
return
layer = self.tech.layers[layer_index]
prop = prop.lower()
if prop == "rho":
layer.rho = value
elif prop == "t":
layer.t = value
elif prop == "eps":
layer.eps = value
else:
print(f"Unknown property {prop!r}; use rho|t|eps")
return
print(f"Set layer {layer_index}.{prop} = {value}")
[docs]
def cmd_cell(self, *, max_l: float = 0.0, max_w: float = 0.0,
max_t: float = 0.0) -> None:
"""CELL [<max_length> <max_width> <max_thickness>] (case 207).
Sets per-direction cell-size limits used by the filament
discretiser. We store them on the Repl state for later use.
"""
if not hasattr(self, "cell_constraints"):
self.cell_constraints: dict[str, float] = {}
for k, v in [("max_l", max_l), ("max_w", max_w), ("max_t", max_t)]:
if v > 0:
self.cell_constraints[k] = v
print(f"Cell constraints: {self.cell_constraints}")
[docs]
def cmd_auto_cell(self, alpha: float = 0.5, beta: float = 1.0) -> None:
"""AUTOCELL <alpha> <beta> (case 212).
Records two scalars used by the filament discretiser to
adapt cell size automatically (the binary uses these to
scale (alpha)·skin_depth and (beta)·width for cell choice).
"""
if not hasattr(self, "auto_cell_alpha"):
self.auto_cell_alpha = alpha
self.auto_cell_beta = beta
else:
self.auto_cell_alpha = alpha
self.auto_cell_beta = beta
print(f"AutoCell: alpha={alpha:g}, beta={beta:g}")
[docs]
def cmd_chip(self, x: float | None = None, y: float | None = None) -> None:
"""CHIP [chipx] [chipy] (case 217) — resize the chip extents."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
if x is not None:
self.tech.chip.chipx = x
if y is not None:
self.tech.chip.chipy = y
print(
f"Chip: chipx={self.tech.chip.chipx}, chipy={self.tech.chip.chipy}"
)
[docs]
def cmd_eddy(self, on: bool | None = None) -> None:
"""CALCEDDY [on|off] (case 221)."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
if on is not None:
self.tech.chip.eddy = on
print(f"Eddy: {'on' if self.tech.chip.eddy else 'off'}")
# View commands (no-op headless) -------------------------------------
[docs]
def cmd_view_set(self, key: str, value: float) -> None:
"""Set a viewport-state field; reported on next LIST."""
self.viewport[key] = value
if self.verbose:
print(f"viewport.{key} = {value}")
[docs]
def cmd_no_op_view(self, label: str) -> None:
"""No-op stub for view-related commands (SCALE, PAN, ZOOM, BB,
REFRESH, ORIGIN, GRID, SNAP, RULER, SHOWPHASE, etc.). The
binary uses these to update the X11 window; we silently accept
them so scripts that mix view and analysis commands run
without errors."""
if self.verbose:
print(f"({label} — no-op headless)")
# Pause / Input session controls -------------------------------------
[docs]
def cmd_pause(self) -> None:
"""PAUSE (case 216) — wait for keypress (no-op headless)."""
if self.verbose:
print("(PAUSE — no-op headless)")
# Help / Version -----------------------------------------------------
[docs]
def cmd_version(self) -> None:
from reasitic import __version__
print(f"reASITIC version {__version__}")
print("Reverse-engineered Python implementation of ASITIC")
[docs]
def cmd_help(self, topic: str | None = None) -> None:
"""HELP [topic] — print command help."""
if topic:
self._help_for_command(topic.upper())
else:
print(_COMMAND_CATEGORIES)
def _help_for_command(self, name: str) -> None:
info = _COMMAND_HELP.get(name)
if info is None:
print(f"No help for command: {name!r}")
return
print(info)
# Toggles & session controls ------------------------------------------
[docs]
def cmd_verbose(self, value: str | None = None) -> None:
"""VERBOSE [true|false] — toggle diagnostic output."""
if value is not None:
self.verbose = value.lower() in ("1", "true", "yes", "on")
else:
self.verbose = not self.verbose
print(f"Verbose: {'on' if self.verbose else 'off'}")
[docs]
def cmd_timer(self, value: str | None = None) -> None:
"""TIMER [true|false] — toggle per-command timing."""
if value is not None:
self.timer = value.lower() in ("1", "true", "yes", "on")
else:
self.timer = not self.timer
print(f"Timer: {'on' if self.timer else 'off'}")
[docs]
def cmd_savemat(self, value: str | None = None) -> None:
"""SAVEMAT [true|false] — toggle dumping the L matrix to disk."""
if value is not None:
self.save_mat = value.lower() in ("1", "true", "yes", "on")
else:
self.save_mat = not self.save_mat
print(f"SaveMat: {'on' if self.save_mat else 'off'}")
[docs]
def cmd_log(self, path: str | None = None) -> None:
"""LOG [<filename>] — start/stop logging input + output to a file."""
if path:
self.log_path = Path(path)
self.log_path.write_text(
f"# reASITIC LOG started at {Path(__file__).name}\n"
)
print(f"Logging to <{path}>")
else:
if self.log_path:
print(f"Stopped logging to <{self.log_path}>")
self.log_path = None
else:
print("Logging is off")
[docs]
def cmd_record(self, path: str | None = None) -> None:
"""RECORD [<filename>] — start/stop a macro recording."""
if self.macro is None:
self.macro = []
print("Recording macro — call RECORD without args to stop.")
else:
text = "\n".join(self.macro)
if path:
Path(path).write_text(text + "\n")
print(f"Saved macro to <{path}>")
else:
print(text)
self.macro = None
[docs]
def cmd_exec_script(self, path: str) -> None:
"""EXEC <path> — execute commands from a script file."""
for line in Path(path).read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
print(f"reASITIC> {line}")
if not self.execute(line):
return
[docs]
def cmd_cat(self, path: str) -> None:
"""CAT <path> — print contents of a file."""
try:
print(Path(path).read_text(), end="")
except OSError as e:
print(f"Error: {e}")
# Shape-management commands --------------------------------------------
[docs]
def cmd_erase(self, names: list[str]) -> None:
"""ERASE <name> ... — delete one or more shapes."""
for n in names:
if n in self.shapes:
del self.shapes[n]
print(f"Erased <{n}>")
else:
print(f"No shape named <{n}>")
[docs]
def cmd_rename(self, old: str, new: str) -> None:
"""RENAME <old> <new>."""
if old not in self.shapes:
print(f"No shape named <{old}>")
return
if new in self.shapes:
print(f"Shape <{new}> already exists; refusing to overwrite")
return
sh = self.shapes.pop(old)
# Update the name on the dataclass too
sh.name = new
self.shapes[new] = sh
print(f"Renamed <{old}> → <{new}>")
[docs]
def cmd_copy(self, src: str, dst: str) -> None:
"""COPY <src> <dst>."""
if src not in self.shapes:
print(f"No shape named <{src}>")
return
from copy import deepcopy
cp = deepcopy(self.shapes[src])
cp.name = dst
self.shapes[dst] = cp
print(f"Copied <{src}> → <{dst}>")
[docs]
def cmd_split(self, name: str, index: int, new_name: str) -> None:
"""SPLIT <name> <segment_index> <new_name>.
Splits a shape's polygon list into two halves at the given
polygon index, keeping the head as ``<name>`` and creating a
new shape ``<new_name>`` with the tail polygons.
"""
if name not in self.shapes:
print(f"No shape named <{name}>")
return
sh = self.shapes[name]
if index < 0 or index > len(sh.polygons):
print(f"Index {index} out of range [0, {len(sh.polygons)}]")
return
head = sh.polygons[:index]
tail = sh.polygons[index:]
if not tail:
print("Tail is empty; nothing to split")
return
sh.polygons = head
from copy import deepcopy
new_sh = deepcopy(sh)
new_sh.name = new_name
new_sh.polygons = tail
self.shapes[new_name] = new_sh
print(f"Split <{name}> at {index} → <{name}> + <{new_name}>")
[docs]
def cmd_join(self, names: list[str]) -> None:
"""JOIN <s1> <s2> [<s3> ...] — concatenate polygon lists.
The result is stored back into ``<s1>``; the others are
deleted from the shapes dict.
"""
if len(names) < 2:
print("Usage: JOIN <s1> <s2> [<s3> ...]")
return
target = names[0]
if target not in self.shapes:
print(f"No shape named <{target}>")
return
for n in names[1:]:
if n not in self.shapes:
print(f"No shape named <{n}>; skipping")
continue
self.shapes[target].polygons.extend(self.shapes[n].polygons)
del self.shapes[n]
print(f"Joined → <{target}> ({len(names)} input shapes)")
[docs]
def cmd_movex(self, name: str, dx: float) -> None:
"""MOVEX <name> <dx> — translate in x only."""
if name not in self.shapes:
print(f"No shape named <{name}>")
return
self.shapes[name] = self.shapes[name].translate(dx, 0.0)
[docs]
def cmd_movey(self, name: str, dy: float) -> None:
"""MOVEY <name> <dy> — translate in y only."""
if name not in self.shapes:
print(f"No shape named <{name}>")
return
self.shapes[name] = self.shapes[name].translate(0.0, dy)
[docs]
def cmd_flip(self, name: str) -> None:
"""FLIP <name> — reverse current direction (case 414).
Reverses polygon order and vertex order within each polygon
(so the spiral traces in the opposite direction).
"""
if name not in self.shapes:
print(f"No shape named <{name}>")
return
sh = self.shapes[name]
sh.polygons = list(reversed(sh.polygons))
for p in sh.polygons:
p.vertices = list(reversed(p.vertices))
sh.orientation = -1 if sh.orientation == 0 else -sh.orientation
[docs]
def cmd_joinshunt(self, names: list[str]) -> None:
"""JOINSHUNT <s1> <s2> [...] — mark shapes as parallel-friends.
Mirrors case 421. Adds friendship pairs so analysis paths
can treat the shapes as a parallel (shunt) combination.
"""
if len(names) < 2:
print("Usage: JOINSHUNT <s1> <s2> [<s3> ...]")
return
for n in names[1:]:
if n in self.shapes and names[0] in self.shapes:
self.friendships.add(frozenset({names[0], n}))
print(f"JoinShunt: marked {len(names)} shapes as parallel-friends")
[docs]
def cmd_select(self, name: str | None = None) -> None:
"""SELECT [<name>] — highlight a shape (case 422).
Headless mode records the selection but doesn't draw it.
"""
self.selected_shape = name
if name:
print(f"Selected: <{name}>")
else:
print("Selection cleared")
[docs]
def cmd_unselect(self) -> None:
"""UNSELECT (case 423) — clear the current selection."""
self.selected_shape = None
print("Selection cleared")
[docs]
def cmd_sptowire(self, name: str) -> None:
"""SPTOWIRE <name> — break a spiral into N single-polygon wires.
Mirrors case 424. Useful for analysing each turn of a spiral
separately.
"""
if name not in self.shapes:
print(f"No shape named <{name}>")
return
sh = self.shapes[name]
from copy import deepcopy
for i, poly in enumerate(sh.polygons):
new_name = f"{name}_{i}"
new_sh = deepcopy(sh)
new_sh.name = new_name
new_sh.polygons = [poly]
self.shapes[new_name] = new_sh
del self.shapes[name]
print(f"SpToWire: split <{name}> into {len(sh.polygons)} wires")
[docs]
def cmd_phase(self, name: str, sign: int) -> None:
"""PHASE <name> <+1|-1> — flip current direction (no-op
in our simple geometry model; we record the sign on the
shape's ``orientation`` field)."""
if name not in self.shapes:
print(f"No shape named <{name}>")
return
if sign not in (1, -1):
print("Phase sign must be +1 or -1")
return
self.shapes[name].orientation = sign
print(f"<{name}> orientation = {sign:+d}")
[docs]
def cmd_befriend(self, name1: str, name2: str) -> None:
"""BEFRIEND <s1> <s2> — mark two shapes as electrically
connected for analysis (case 417).
We track the friendship pairs in a set on the REPL state;
analysis paths can use this to merge segment lists when
computing inductance / resistance.
"""
if name1 not in self.shapes or name2 not in self.shapes:
print("One of the shapes doesn't exist")
return
self.friendships.add(frozenset({name1, name2}))
print(f"Befriended <{name1}> ↔ <{name2}>")
[docs]
def cmd_unfriend(self, name1: str, name2: str) -> None:
"""UNFRIEND <s1> <s2> — remove the friendship link (case 418)."""
key = frozenset({name1, name2})
if key in self.friendships:
self.friendships.remove(key)
print(f"Unfriended <{name1}> ↔ <{name2}>")
else:
print(f"<{name1}> and <{name2}> were not befriended")
[docs]
def cmd_intersect(self, name: str) -> None:
"""INTERSECT/FINDI <name> — check if a shape's polygons
self-intersect (case 419)."""
if name not in self.shapes:
print(f"No shape named <{name}>")
return
sh = self.shapes[name]
# Pairwise polygon-edge intersection check (axis-aligned only)
intersections = 0
for i, p_i in enumerate(sh.polygons):
for p_j in sh.polygons[i + 1:]:
if _polygons_overlap(p_i, p_j):
intersections += 1
if intersections == 0:
print(f"<{name}> has no detected self-intersections")
else:
print(f"<{name}> has {intersections} pair(s) of intersecting polygons")
[docs]
def cmd_hide(self, names: list[str]) -> None:
"""HIDE <name> ... — toggle visibility (no-op storage flag).
We don't model the X11 visibility state; HIDE is a no-op
included for command-name parity with the binary.
"""
for n in names:
if n in self.shapes:
print(f"(visibility toggle on <{n}> — no-op in headless mode)")
# New geometry builders ----------------------------------------------
[docs]
def cmd_ring(self, args: dict[str, str]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = ring(
args["NAME"],
radius=float(args.get("RADIUS", args.get("RAD", "0"))),
width=float(args["W"]),
gap=float(args.get("GAP", "0")),
sides=int(float(args.get("SIDES", "32"))),
tech=self.tech,
metal=args.get("METAL", 0),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
[docs]
def cmd_via(self, args: dict[str, str]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = via(
args["NAME"],
tech=self.tech,
via_index=int(float(args.get("VIA", "0"))),
nx=int(float(args.get("NX", "1"))),
ny=int(float(args.get("NY", "1"))),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
# Analysis -----------------------------------------------------------
[docs]
def cmd_2port_gnd(
self, name: str, gnd_name: str, f0: float, f1: float, step: float
) -> None:
"""2-port sweep with explicit ground spiral coupling included
in the series leg (mirrors case 529, ``2PortGnd``)."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
gnd = self.shapes[gnd_name]
# The ground-coupling reduces the effective series L by M.
# We construct an "effective" series path by translating the
# spiral and computing M, then printing the adjusted Pi at
# each frequency.
from reasitic.inductance import compute_mutual_inductance
M = compute_mutual_inductance(sh, gnd)
fs = linear_freqs(f0, f1, step)
sweep = two_port_sweep(sh, self.tech, fs)
print(f"# 2PortGnd <{name}, gnd={gnd_name}>: M={M:.4f} nH")
print(f"# {'f_GHz':>7} {'L_eff_nH':>10} {'Q':>7}")
from reasitic.units import GHZ_TO_HZ, NH_TO_H, TWO_PI
for f, pi in zip(fs, sweep.pi, strict=True):
omega = TWO_PI * f * GHZ_TO_HZ
L_eff = pi.Z_s.imag / omega / NH_TO_H - M
R = pi.Z_s.real
Q = omega * L_eff * NH_TO_H / max(R, 1e-30)
print(f" {f:7.3f} {L_eff:10.4f} {Q:7.2f}")
[docs]
def cmd_2port_pad(
self,
name: str,
pad1_name: str,
pad2_name: str,
f0: float,
f1: float,
step: float,
) -> None:
"""2-port sweep with pad capacitors at each port (case 530)."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
pad1 = self.shapes[pad1_name]
pad2 = self.shapes[pad2_name]
from reasitic.network.analysis import pi4_model
fs = linear_freqs(f0, f1, step)
print(f"# 2PortPad <{name}, pads={pad1_name},{pad2_name}>")
print(f"# {'f_GHz':>7} {'L_nH':>8} {'C_pad1_fF':>10} {'C_pad2_fF':>10}")
for f in fs:
res = pi4_model(sh, self.tech, f, pad1=pad1, pad2=pad2)
print(
f" {f:7.3f} {res.L_series_nH:8.4f}"
f" {res.C_pad1_fF:10.3f} {res.C_pad2_fF:10.3f}"
)
[docs]
def cmd_3port(
self, name: str, gnd_name: str, freq_ghz: float
) -> None:
"""3-port reduction to 2-port Y at one frequency (case 536)."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
import numpy as np
from reasitic.network import (
reduce_3port_z_to_2port_y,
spiral_y_at_freq,
y_to_z,
)
sh = self.shapes[name]
gnd = self.shapes[gnd_name]
# Build the 3-port Z for (signal pos, signal neg, ground)
Y_sig = spiral_y_at_freq(sh, self.tech, freq_ghz)
Y_gnd = spiral_y_at_freq(gnd, self.tech, freq_ghz)
with np.errstate(divide="ignore", invalid="ignore"):
Z_sig = y_to_z(Y_sig)
Z_gnd = y_to_z(Y_gnd)
# Build a 3x3 Z by stacking diagonal blocks (ports 0,1 are
# signal, port 2 is ground)
Z3 = np.zeros((3, 3), dtype=complex)
Z3[:2, :2] = Z_sig
Z3[2, 2] = Z_gnd[0, 0]
Y2 = reduce_3port_z_to_2port_y(Z3)
print(f"3Port <{name}, gnd={gnd_name}> at {freq_ghz:g} GHz:")
print(" Y reduced (port-3 grounded):")
for i in range(2):
for j in range(2):
print(
f" Y[{i},{j}] = {Y2[i, j].real:.4e}"
f" + {Y2[i, j].imag:.4e}j S"
)
[docs]
def cmd_2port_trans(
self,
pri_name: str,
sec_name: str,
f0: float,
f1: float,
step: float,
) -> None:
"""2-port transformer sweep (case 524, 2PortTrans).
Reports L_pri, L_sec, M, k, n at every frequency point.
"""
if self.tech is None:
raise RuntimeError("no tech file loaded")
from reasitic.network.analysis import calc_transformer
pri = self.shapes[pri_name]
sec = self.shapes[sec_name]
fs = linear_freqs(f0, f1, step)
print(f"# 2PortTrans <{pri_name}, {sec_name}>")
print(
f"# {'f_GHz':>7} {'L_pri':>8} {'L_sec':>8} {'M':>8}"
f" {'k':>8} {'Q_pri':>7}"
)
for f in fs:
r = calc_transformer(pri, sec, self.tech, f)
print(
f" {f:7.3f} {r.L_pri_nH:8.4f} {r.L_sec_nH:8.4f}"
f" {r.M_nH:8.4f} {r.k:8.4f} {r.Q_pri:7.2f}"
)
[docs]
def cmd_2pzin(
self,
name: str,
freq_ghz: float,
z_load_re: float = 50.0,
z_load_im: float = 0.0,
) -> None:
"""2-port input impedance with arbitrary load (case 537)."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
from reasitic.network.analysis import zin_terminated
sh = self.shapes[name]
z_load = complex(z_load_re, z_load_im)
try:
z = zin_terminated(sh, self.tech, freq_ghz, z_load_ohm=z_load)
except ValueError as e:
print(f"2PZin({name}): {e}")
return
# Also report in admittance form
y = 1.0 / z if z != 0 else complex("nan")
print(
f"2PZin({name}, {freq_ghz:g} GHz, ZL={z_load}) = "
f"{z.real:.3f}{z.imag:+.3f}j Ohm "
f"({y.real:.4e}{y.imag:+.4e}j S)"
)
[docs]
def cmd_2port(self, name: str, f0: float, f1: float, step: float) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
fs = linear_freqs(f0, f1, step)
sweep = two_port_sweep(sh, self.tech, fs)
print(f"# 2Port {name} ({len(fs)} freq points)")
print(f"# {'f_GHz':>7} {'|S11|':>10} {'∠S11':>8} {'|S21|':>10} {'∠S21':>8}")
import math
for f, S in zip(fs, sweep.S, strict=True):
mag11 = abs(S[0, 0])
ang11 = math.degrees(math.atan2(S[0, 0].imag, S[0, 0].real))
mag21 = abs(S[1, 0])
ang21 = math.degrees(math.atan2(S[1, 0].imag, S[1, 0].real))
print(f" {f:7.3f} {mag11:10.6f} {ang11:8.2f} {mag21:10.6f} {ang21:8.2f}")
[docs]
def cmd_cap(self, name: str) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
C = shape_shunt_capacitance(sh, self.tech)
print(f"C_shunt({name}) = {C * 1e15:.3f} fF")
# Persistence --------------------------------------------------------
[docs]
def cmd_save(self, path: str) -> None:
save_session(
path,
tech=self.tech,
shapes=self.shapes,
viewport=self.viewport,
)
print(f"Saved {len(self.shapes)} shapes to <{path}>")
[docs]
def cmd_load(self, path: str) -> None:
tech, shapes = load_session(path)
if tech is not None:
self.tech = tech
self.shapes.update(shapes)
vp = load_viewport(path)
if vp:
self.viewport.update(vp)
print(f"Loaded {len(shapes)} shapes from <{path}>")
# Exports ------------------------------------------------------------
def _select_shapes(self, names: list[str]) -> list[Shape]:
if not names:
return list(self.shapes.values())
return [self.shapes[n] for n in names]
[docs]
def cmd_cifsave(self, path: str, names: list[str]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
shapes = self._select_shapes(names)
write_cif_file(path, shapes, self.tech)
print(f"Wrote CIF to <{path}> ({len(shapes)} shapes)")
[docs]
def cmd_teksave(self, path: str, names: list[str]) -> None:
shapes = self._select_shapes(names)
write_tek_file(path, shapes)
print(f"Wrote Tek/gnuplot to <{path}> ({len(shapes)} shapes)")
[docs]
def cmd_sonnetsave(self, path: str, names: list[str]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
shapes = self._select_shapes(names)
Path(path).write_text(write_sonnet(shapes, self.tech))
print(f"Wrote Sonnet to <{path}> ({len(shapes)} shapes)")
[docs]
def cmd_spicesave(
self, name: str, freq_ghz: float, path: str
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
write_spice_subckt_file(path, sh, self.tech, freq_ghz)
print(f"Wrote SPICE sub-circuit to <{path}>")
[docs]
def cmd_s2psave(
self, name: str, f0: float, f1: float, step: float, path: str
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
fs = linear_freqs(f0, f1, step)
sweep = two_port_sweep(sh, self.tech, fs)
write_touchstone_file(path, sweep.to_touchstone_points(param="S"))
print(f"Wrote Touchstone S2P to <{path}> ({len(fs)} freq points)")
# Shape transforms ---------------------------------------------------
[docs]
def cmd_move(self, name: str, dx: float, dy: float) -> None:
sh = self.shapes[name]
self.shapes[name] = sh.translate(dx, dy)
[docs]
def cmd_moveto(self, name: str, x: float, y: float) -> None:
sh = self.shapes[name]
self.shapes[name] = sh.translate(x - sh.x_origin, y - sh.y_origin)
[docs]
def cmd_flipv(self, name: str) -> None:
self.shapes[name] = self.shapes[name].flip_vertical()
[docs]
def cmd_fliph(self, name: str) -> None:
self.shapes[name] = self.shapes[name].flip_horizontal()
[docs]
def cmd_rotate(self, name: str, angle_deg: float) -> None:
import math as _m
self.shapes[name] = self.shapes[name].rotate_xy(_m.radians(angle_deg))
[docs]
def cmd_report(self, name: str, freqs: list[float]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
from reasitic.report import design_report
sh = self.shapes[name]
rpt = design_report(sh, self.tech, freqs_ghz=freqs)
print(rpt.format_text(), end="")
[docs]
def cmd_trans(self, args: dict[str, str]) -> None:
"""TRANS: planar two-coil transformer."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = transformer(
args["NAME"],
length=float(args["LEN"]),
width=float(args["W"]),
spacing=float(args["S"]),
turns=float(args["N"]),
tech=self.tech,
metal_primary=args.get("METAL", 0),
metal_secondary=args.get("METAL2"),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
[docs]
def cmd_balun(self, args: dict[str, str]) -> None:
"""BALUN: stacked counter-wound spirals."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = balun(
args["NAME"],
length=float(args["LEN"]),
width=float(args["W"]),
spacing=float(args["S"]),
turns=float(args["N"]),
tech=self.tech,
metal=args.get("METAL", 0),
metal2=args.get("METAL2"),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
[docs]
def cmd_capacitor(self, args: dict[str, str]) -> None:
"""CAPACITOR: MIM capacitor."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = capacitor(
args["NAME"],
length=float(args["LEN"]),
width=float(args.get("WID", args["LEN"])),
metal_top=args["METAL1"],
metal_bottom=args["METAL2"],
tech=self.tech,
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
[docs]
def cmd_symsq(self, args: dict[str, str]) -> None:
"""SYMSQ: symmetric centre-tapped square spiral."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = symmetric_square(
args["NAME"],
length=float(args["LEN"]),
width=float(args["W"]),
spacing=float(args["S"]),
turns=float(args["N"]),
tech=self.tech,
metal=args.get("METAL", 0),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
[docs]
def cmd_sympoly(self, args: dict[str, str]) -> None:
"""SYMPOLY: symmetric centre-tapped polygon spiral."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = symmetric_polygon(
args["NAME"],
radius=float(args.get("RAD", args.get("RADIUS", "0"))),
width=float(args["W"]),
spacing=float(args["S"]),
turns=float(args["N"]),
sides=int(float(args.get("SIDES", "8"))),
tech=self.tech,
metal=args.get("METAL", 0),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
[docs]
def cmd_mmsquare(self, args: dict[str, str]) -> None:
"""MMSQUARE: multi-metal series square inductor."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
# METALS is a comma-separated list, e.g. METALS=m1,m2,m3
metals_str = args.get("METALS", args.get("METAL", "0"))
metals: list[int | str] = [m.strip() for m in metals_str.split(",")]
sh = multi_metal_square(
args["NAME"],
length=float(args["LEN"]),
width=float(args["W"]),
spacing=float(args["S"]),
turns=float(args["N"]),
tech=self.tech,
metals=metals,
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
[docs]
def cmd_3dtrans(self, args: dict[str, str]) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = transformer_3d(
args["NAME"],
length=float(args["LEN"]),
width=float(args["W"]),
spacing=float(args["S"]),
turns=float(args["N"]),
tech=self.tech,
metal_top=args.get("METAL_TOP", args.get("METAL", 0)),
metal_bottom=args.get("METAL_BOTTOM", args.get("METAL2", 0)),
via_index=int(float(args.get("VIA", "0"))),
x_origin=float(args.get("XORG", "0")),
y_origin=float(args.get("YORG", "0")),
)
self.shapes[sh.name] = sh
# Pi / Zin / SelfRes / ShuntR / Pi3 / Pi4 / CalcTrans ----------------
[docs]
def cmd_pi2(
self, name: str, freq_ghz: float, gnd_name: str | None = None
) -> None:
"""PI2 <name> <freq> [<gnd>] — same as PI3 but with the
binary's case-515 numbering. Aliased here for parity."""
self.cmd_pi3(name, freq_ghz, gnd_name)
[docs]
def cmd_2port_x(
self, name: str, f0: float, f1: float, step: float
) -> None:
"""2PORTX <name> <f0> <f1> <step> — sweep using the extended
PiX model (case 539). Reports R_sub / C_sub at each f."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
from reasitic.network.analysis import pix_model
sh = self.shapes[name]
fs = linear_freqs(f0, f1, step)
print(f"# 2PortX <{name}>")
print(
f"# {'f_GHz':>7} {'L_nH':>8} {'R_s':>8}"
f" {'R_sub1':>8} {'C_sub1_fF':>10}"
)
for f in fs:
r = pix_model(sh, self.tech, f)
print(
f" {f:7.3f} {r.L_nH:8.4f} {r.R_series_ohm:8.4f}"
f" {r.R_sub1_ohm:8.3f} {r.C_sub1_fF:10.3f}"
)
[docs]
def cmd_resishf(self, name: str, freq_ghz: float) -> None:
"""RESISHF <name> <freq_ghz> (case 527).
High-frequency resistance: same as the AC branch of RES.
Aliased for binary parity.
"""
if self.tech is None:
raise RuntimeError("no tech file loaded")
from reasitic.resistance import compute_ac_resistance
sh = self.shapes[name]
R_ac = compute_ac_resistance(sh, self.tech, freq_ghz)
print(f"R_ac({name}, {freq_ghz} GHz) = {R_ac:.6f} Ohm")
[docs]
def cmd_ccell(self, max_l: float = 0.0, max_w: float = 0.0) -> None:
"""CCELL [max_l] [max_w] (case 211) — centred-cell constraints.
Unlike CELL, CCELL doesn't take a thickness; the binary uses
it for 2D-only filament discretisation.
"""
if not hasattr(self, "cell_constraints"):
self.cell_constraints = {}
if max_l > 0:
self.cell_constraints["max_l"] = max_l
if max_w > 0:
self.cell_constraints["max_w"] = max_w
print(f"CCell constraints: {self.cell_constraints}")
[docs]
def cmd_setmaxnw(self, value: int = 0) -> None:
"""SETMAXNW [value] (case 218) — set the max sub-filament count."""
if value > 0:
self.max_nw = value
print(f"MaxNW: {getattr(self, 'max_nw', 'unset')}")
[docs]
def cmd_sweep_mm(self, args: dict[str, str]) -> None:
"""SWEEPMM (case 715) — multi-metal sweep variant of SWEEP.
We treat it as an alias for SWEEP since our SWEEP already
accepts a METALS list.
"""
self.cmd_sweep(args)
[docs]
def cmd_pix(self, name: str, freq_ghz: float) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
r = pix_model(sh, self.tech, freq_ghz)
print(f"PiX-model for <{name}> at {freq_ghz:g} GHz:")
print(f" L_series = {r.L_nH:.4f} nH")
print(f" R_series = {r.R_series_ohm:.4f} Ohm")
print(f" Port 1: R_sub = {r.R_sub1_ohm:.3f} Ω, C_sub = {r.C_sub1_fF:.3f} fF")
print(f" Port 2: R_sub = {r.R_sub2_ohm:.3f} Ω, C_sub = {r.C_sub2_fF:.3f} fF")
[docs]
def cmd_pi(self, name: str, freq_ghz: float) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
pi = pi_model_at_freq(sh, self.tech, freq_ghz)
print(f"Pi-model for <{name}> at {freq_ghz:g} GHz:")
print(f" L_series = {pi.L_nH:.4f} nH")
print(f" R_series = {pi.R_series:.4f} Ohm")
print(f" C_p1 = {pi.C_p1_fF:.3f} fF, g_p1 = {pi.g_p1:.3e} S")
print(f" C_p2 = {pi.C_p2_fF:.3f} fF, g_p2 = {pi.g_p2:.3e} S")
[docs]
def cmd_zin(
self, name: str, freq_ghz: float, *, z_load: complex = 50.0 + 0j
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
try:
z = zin_terminated(sh, self.tech, freq_ghz, z_load_ohm=z_load)
except ValueError as e:
print(f"Zin({name}): {e}")
return
print(
f"Zin({name}, {freq_ghz:g} GHz, ZL={z_load}) "
f"= {z.real:.3f}{z.imag:+.3f}j Ohm"
)
[docs]
def cmd_shuntr(
self, name: str, freq_ghz: float, mode: str = "S"
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
diff = mode.upper() == "D"
r = shunt_resistance(sh, self.tech, freq_ghz, differential=diff)
print(f"ShuntR({name}, {freq_ghz:g} GHz, {mode}-mode):")
print(f" R_p = {r.R_p_ohm:.3f} Ohm, Q = {r.Q:.3f}")
print(f" L = {r.L_nH:.4f} nH, R_series = {r.R_series_ohm:.3f} Ohm")
[docs]
def cmd_pi3(
self, name: str, freq_ghz: float, gnd_name: str | None = None
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
gnd = self.shapes.get(gnd_name) if gnd_name else None
res = pi3_model(sh, self.tech, freq_ghz, ground_shape=gnd)
print(f"Pi3-model for <{name}> at {freq_ghz:g} GHz:")
print(f" L_series = {res.L_series_nH:.4f} nH")
print(f" R_series = {res.R_series_ohm:.4f} Ohm")
print(f" C_p1_to_gnd = {res.C_p1_to_gnd_fF:.3f} fF")
print(f" C_p2_to_gnd = {res.C_p2_to_gnd_fF:.3f} fF")
[docs]
def cmd_pi4(
self,
name: str,
freq_ghz: float,
pad1_name: str | None = None,
pad2_name: str | None = None,
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
pad1 = self.shapes.get(pad1_name) if pad1_name else None
pad2 = self.shapes.get(pad2_name) if pad2_name else None
res = pi4_model(sh, self.tech, freq_ghz, pad1=pad1, pad2=pad2)
print(f"Pi4-model for <{name}> at {freq_ghz:g} GHz:")
print(f" L_series = {res.L_series_nH:.4f} nH")
print(f" R_series = {res.R_series_ohm:.4f} Ohm")
print(f" C_pad1 = {res.C_pad1_fF:.3f} fF, C_sub1 = {res.C_sub1_fF:.3f} fF")
print(f" C_pad2 = {res.C_pad2_fF:.3f} fF, C_sub2 = {res.C_sub2_fF:.3f} fF")
[docs]
def cmd_calctrans(
self, pri_name: str, sec_name: str, freq_ghz: float
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
pri = self.shapes[pri_name]
sec = self.shapes[sec_name]
t = calc_transformer(pri, sec, self.tech, freq_ghz)
print(f"CalcTrans <{pri_name}, {sec_name}> at {freq_ghz:g} GHz:")
print(f" L_pri = {t.L_pri_nH:.4f} nH, R_pri = {t.R_pri_ohm:.3f} Ohm,"
f" Q_pri = {t.Q_pri:.2f}")
print(f" L_sec = {t.L_sec_nH:.4f} nH, R_sec = {t.R_sec_ohm:.3f} Ohm,"
f" Q_sec = {t.Q_sec:.2f}")
print(f" M = {t.M_nH:.4f} nH, k = {t.k:.4f}, n = {t.n_turns_ratio:.3f}")
[docs]
def cmd_selfres(self, name: str, f_lo: float, f_hi: float) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
sh = self.shapes[name]
res = self_resonance(sh, self.tech, f_low_ghz=f_lo, f_high_ghz=f_hi)
if res.converged:
print(f"Self-resonance({name}) = {res.freq_ghz:.4f} GHz")
print(f" Q just below resonance = {res.Q_at_resonance:.2f}")
else:
print(
f"Self-resonance({name}): no zero crossing in "
f"[{f_lo:g}, {f_hi:g}] GHz "
f"(likely a lossless-substrate model with no shunt cap)"
)
# Info commands ------------------------------------------------------
[docs]
def cmd_listsegs(self, name: str) -> None:
sh = self.shapes[name]
print(format_segments(sh), end="")
[docs]
def cmd_lrmat(self, name: str, path: str | None = None) -> None:
sh = self.shapes[name]
text = format_lr_matrix(sh)
if path:
Path(path).write_text(text)
print(f"Wrote LRMAT to <{path}>")
else:
print(text, end="")
# Sweep --------------------------------------------------------------
[docs]
def cmd_sweep(self, args: dict[str, str]) -> None:
"""SWEEP NAME=...:LMIN=...:LMAX=...:LSTEP=...:WMIN=...:WMAX=...:WSTEP=...
SMIN=...:SMAX=...:SSTEP=...:NMIN=...:NMAX=...:NSTEP=...:FREQ=...:METAL=...
[PATH=<output.tsv>]
"""
if self.tech is None:
raise RuntimeError("no tech file loaded")
f = float(args.get("FREQ", "1.0"))
metal = args.get("METAL", 0)
Ls = _frange(float(args["LMIN"]), float(args["LMAX"]), float(args["LSTEP"]))
Ws = _frange(float(args["WMIN"]), float(args["WMAX"]), float(args["WSTEP"]))
Ss = _frange(float(args["SMIN"]), float(args["SMAX"]), float(args["SSTEP"]))
Ns = _frange(float(args["NMIN"]), float(args["NMAX"]), float(args["NSTEP"]))
arr = sweep_square_spiral(
self.tech,
length_um=Ls,
width_um=Ws,
spacing_um=Ss,
turns=Ns,
freq_ghz=f,
metal=metal,
)
path = args.get("PATH")
if path:
Path(path).write_text(sweep_to_tsv(arr))
print(f"Sweep: {len(arr)} points written to <{path}>")
else:
print(sweep_to_tsv(arr), end="")
# Optimisation -------------------------------------------------------
[docs]
def cmd_optsq(
self, target_L_nH: float, freq_ghz: float, metal: str | int = 0
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
res = optimise_square_spiral(
self.tech,
target_L_nH=target_L_nH,
freq_ghz=freq_ghz,
metal=metal,
)
self._print_opt_result("OptSq", res)
[docs]
def cmd_optpoly(
self,
target_L_nH: float,
freq_ghz: float,
sides: int = 8,
metal: str | int = 0,
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
res = optimise_polygon_spiral(
self.tech,
target_L_nH=target_L_nH,
freq_ghz=freq_ghz,
sides=sides,
metal=metal,
)
self._print_opt_result(f"OptPoly({sides})", res)
[docs]
def cmd_optarea(
self, target_L_nH: float, freq_ghz: float, metal: str | int = 0
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
res = optimise_area_square_spiral(
self.tech,
target_L_nH=target_L_nH,
freq_ghz=freq_ghz,
metal=metal,
)
self._print_opt_result("OptArea", res)
[docs]
def cmd_ldiv(
self, name: str, n_l: int, n_w: int, n_t: int
) -> None:
"""LDIV <name> <n_l> <n_w> <n_t> (case 800).
Print the inductance with given filament discretisation. The
binary's LDIV shows the L matrix split per length / width /
thickness; we print the impedance-matrix solve at low freq
with the given (n_w, n_t) subdivision (n_l is ignored — we
don't subdivide along length yet).
"""
if self.tech is None:
raise RuntimeError("no tech file loaded")
from reasitic.inductance import solve_inductance_mna
sh = self.shapes[name]
L, R = solve_inductance_mna(
sh, self.tech, freq_ghz=0.001, n_w=n_w, n_t=n_t
)
print(
f"LDiv({name}, n_l={n_l}, n_w={n_w}, n_t={n_t}):"
f" L = {L:.4f} nH, R = {R:.4f} Ohm"
)
[docs]
def cmd_optsympoly(
self,
target_L_nH: float,
freq_ghz: float,
sides: int = 8,
metal: str | int = 0,
) -> None:
"""OPTSYMPOLY (case 714) — symmetric polygon-spiral optimiser.
We implement this as a thin wrapper around the existing
polygon-spiral optimiser, treating the SymPoly variant the
same as a regular polygon spiral for the purposes of L/Q
targeting (the symmetric topology adds a small Q penalty
that we don't model in detail here).
"""
if self.tech is None:
raise RuntimeError("no tech file loaded")
res = optimise_polygon_spiral(
self.tech,
target_L_nH=target_L_nH,
freq_ghz=freq_ghz,
sides=sides,
metal=metal,
)
self._print_opt_result(f"OptSymPoly({sides})", res)
[docs]
def cmd_optsymsq(
self, target_L_nH: float, freq_ghz: float, metal: str | int = 0
) -> None:
if self.tech is None:
raise RuntimeError("no tech file loaded")
res = optimise_symmetric_square(
self.tech,
target_L_nH=target_L_nH,
freq_ghz=freq_ghz,
metal=metal,
)
self._print_opt_result("OptSymSq", res)
def _print_opt_result(self, label: str, res: OptResult) -> None:
print(f"{label}: success={res.success}")
print(
f" L={res.length_um:.2f}um W={res.width_um:.2f} S={res.spacing_um:.2f}"
f" N={res.turns:.2f}"
)
print(f" L={res.L_nH:.4f} nH, Q={res.Q:.2f} ({res.message})")
[docs]
def cmd_batchopt(self, path: str | None = None) -> None:
"""BatchOpt: read targets from stdin (or file), run OptSq each."""
if self.tech is None:
raise RuntimeError("no tech file loaded")
if path:
text = Path(path).read_text()
else:
print("BatchOpt: enter <target_L_nH> <freq_ghz> per line, blank to end")
lines = []
while True:
try:
ln = input(" ")
except EOFError:
break
if not ln.strip():
break
lines.append(ln)
text = "\n".join(lines)
targets = []
for line in text.splitlines():
parts = line.split()
if len(parts) >= 2:
targets.append((float(parts[0]), float(parts[1])))
if not targets:
print("BatchOpt: no targets")
return
arr = batch_opt_square(self.tech, targets=targets)
names = arr.dtype.names
if names is None:
print("BatchOpt: empty result")
return
print("\t".join(names))
for row in arr:
print("\t".join(f"{row[name]:.4g}" for name in names))
# Dispatcher ----------------------------------------------------------
[docs]
def execute(self, line: str) -> bool:
"""Execute one line, catching errors. Returns False on quit."""
try:
return self._execute_inner(line)
except (ValueError, KeyError, RuntimeError, FileNotFoundError) as e:
print(f"Error: {e}")
if self.verbose:
import traceback
traceback.print_exc()
return True
def _execute_inner(self, line: str) -> bool:
line = line.strip()
if not line or line.startswith("#"):
return True
# Optional macro recording
if self.macro is not None and not line.upper().startswith("RECORD"):
self.macro.append(line)
# Optional log file
if self.log_path is not None:
with self.log_path.open("a") as fp:
fp.write(line + "\n")
head, _, rest = line.partition(" ")
head_upper = head.upper()
# `Q` alone is the Q-factor command, not quit; only QUIT/EXIT
# leave the loop. Matches the original ASITIC convention.
if head_upper in ("QUIT", "EXIT"):
return False
if head_upper in ("LOAD-TECH", "T", "TECH"):
self.cmd_load_tech(rest.strip())
elif head_upper in ("W", "WIRE"):
self.cmd_wire(_parse_kv_args(rest))
elif head_upper in ("SQ", "SQUARE"):
self.cmd_square(_parse_kv_args(rest))
elif head_upper in ("SP", "SPIRAL"):
self.cmd_spiral(_parse_kv_args(rest))
elif head_upper in ("IND", "L"):
self.cmd_ind(rest.strip())
elif head_upper in ("RES", "R"):
parts = rest.split()
if not parts:
print("Usage: RES <name> [freq_ghz]")
return True
freq = float(parts[1]) if len(parts) > 1 else None
self.cmd_res(parts[0], freq)
elif head_upper == "Q":
parts = rest.split()
if len(parts) != 2:
print("Usage: Q <name> <freq_ghz>")
return True
self.cmd_q(parts[0], float(parts[1]))
elif head_upper in ("K", "COUPLING"):
parts = rest.split()
if len(parts) != 2:
print("Usage: K <name1> <name2>")
return True
self.cmd_coupling(parts[0], parts[1])
elif head_upper == "GEOM":
self.cmd_geom(rest.strip())
elif head_upper == "LIST":
self.cmd_list()
elif head_upper == "RING":
self.cmd_ring(_parse_kv_args(rest))
elif head_upper == "VIA":
self.cmd_via(_parse_kv_args(rest))
elif head_upper in ("2PORT", "TWOPORT"):
parts = rest.split()
if len(parts) != 4:
print("Usage: 2PORT <name> <f0_ghz> <f1_ghz> <step_ghz>")
return True
self.cmd_2port(parts[0], float(parts[1]), float(parts[2]), float(parts[3]))
elif head_upper == "CAP":
self.cmd_cap(rest.strip())
elif head_upper in ("SAVE", "BSAVE", "BWRITE", "BPUT", "BSTORE",
"WRITE", "PUT", "STORE"):
# The binary's BSAVE / SAVE / BWRITE etc. all map to our
# single JSON-based persistence (binary format compatibility
# is sacrificed for portability — we use JSON instead).
self.cmd_save(rest.strip())
elif head_upper in ("LOAD", "READ", "BLOAD", "BREAD", "BGET", "BLD",
"GET", "LD"):
self.cmd_load(rest.strip())
elif head_upper == "CIFSAVE":
parts = rest.split()
if not parts:
print("Usage: CIFSAVE <path> [<name> ...]")
return True
self.cmd_cifsave(parts[0], parts[1:])
elif head_upper in ("TEKSAVE", "PRINTTEKFILE"):
parts = rest.split()
if not parts:
print("Usage: TEKSAVE <path> [<name> ...]")
return True
self.cmd_teksave(parts[0], parts[1:])
elif head_upper == "SONNETSAVE":
parts = rest.split()
if not parts:
print("Usage: SONNETSAVE <path> [<name> ...]")
return True
self.cmd_sonnetsave(parts[0], parts[1:])
elif head_upper == "S2PSAVE":
parts = rest.split()
if len(parts) != 5:
print(
"Usage: S2PSAVE <name> <f0_ghz> <f1_ghz> <step_ghz> <path>"
)
return True
self.cmd_s2psave(
parts[0], float(parts[1]), float(parts[2]), float(parts[3]), parts[4]
)
elif head_upper == "OPTSQ":
parts = rest.split()
if len(parts) < 2:
print("Usage: OPTSQ <target_L_nH> <freq_ghz> [metal]")
return True
metal: str | int = parts[2] if len(parts) > 2 else 0
self.cmd_optsq(float(parts[0]), float(parts[1]), metal)
elif head_upper in ("PI", "PIMODEL"):
parts = rest.split()
if len(parts) != 2:
print("Usage: PI <name> <freq_ghz>")
return True
self.cmd_pi(parts[0], float(parts[1]))
elif head_upper in ("PIX", "PIMODELX"):
parts = rest.split()
if len(parts) != 2:
print("Usage: PIX <name> <freq_ghz>")
return True
self.cmd_pix(parts[0], float(parts[1]))
elif head_upper == "ZIN":
parts = rest.split()
if len(parts) < 2:
print("Usage: ZIN <name> <freq_ghz> [<Z_load_re> <Z_load_im>]")
return True
zload = 50.0 + 0j
if len(parts) >= 4:
zload = complex(float(parts[2]), float(parts[3]))
self.cmd_zin(parts[0], float(parts[1]), z_load=zload)
elif head_upper in ("SELFRES", "SR"):
parts = rest.split()
if len(parts) != 3:
print("Usage: SELFRES <name> <f_lo_ghz> <f_hi_ghz>")
return True
self.cmd_selfres(parts[0], float(parts[1]), float(parts[2]))
elif head_upper in ("LISTSEGS", "PSEGS"):
self.cmd_listsegs(rest.strip())
elif head_upper in ("METAREA", "METALAREA"):
self.cmd_metarea(rest.strip())
elif head_upper in ("LRMAT", "LMAT"):
parts = rest.split()
if not parts:
print("Usage: LRMAT <name> [<output_path>]")
return True
path = parts[1] if len(parts) > 1 else None
self.cmd_lrmat(parts[0], path)
elif head_upper in ("SWEEP", "SW"):
self.cmd_sweep(_parse_kv_args(rest))
elif head_upper == "3DTRANS":
self.cmd_3dtrans(_parse_kv_args(rest))
elif head_upper in ("SHUNTR", "PR"):
parts = rest.split()
if len(parts) < 2:
print("Usage: SHUNTR <name> <freq_ghz> [S|D]")
return True
mode = parts[2] if len(parts) > 2 else "S"
self.cmd_shuntr(parts[0], float(parts[1]), mode)
elif head_upper == "PI3":
parts = rest.split()
if len(parts) < 2:
print("Usage: PI3 <name> <freq_ghz> [<gnd_name>]")
return True
gnd = parts[2] if len(parts) > 2 else None
self.cmd_pi3(parts[0], float(parts[1]), gnd)
elif head_upper == "PI4":
parts = rest.split()
if len(parts) < 2:
print("Usage: PI4 <name> <freq_ghz> [<pad1> [<pad2>]]")
return True
pad1 = parts[2] if len(parts) > 2 else None
pad2 = parts[3] if len(parts) > 3 else None
self.cmd_pi4(parts[0], float(parts[1]), pad1, pad2)
elif head_upper in ("CALCTRANS", "TT"):
parts = rest.split()
if len(parts) < 3:
print("Usage: CALCTRANS <pri> <sec> <freq_ghz>")
return True
self.cmd_calctrans(parts[0], parts[1], float(parts[2]))
elif head_upper == "OPTPOLY":
parts = rest.split()
if len(parts) < 2:
print("Usage: OPTPOLY <target_L_nH> <freq_ghz> [sides] [metal]")
return True
sides = int(float(parts[2])) if len(parts) > 2 else 8
opt_metal: str | int = parts[3] if len(parts) > 3 else 0
self.cmd_optpoly(float(parts[0]), float(parts[1]), sides, opt_metal)
elif head_upper == "OPTAREA":
parts = rest.split()
if len(parts) < 2:
print("Usage: OPTAREA <target_L_nH> <freq_ghz> [metal]")
return True
opt_metal_a: str | int = parts[2] if len(parts) > 2 else 0
self.cmd_optarea(float(parts[0]), float(parts[1]), opt_metal_a)
elif head_upper in ("OPTSYMSQ", "OPTBALSQ"):
parts = rest.split()
if len(parts) < 2:
print("Usage: OPTSYMSQ <target_L_nH> <freq_ghz> [metal]")
return True
opt_metal_s: str | int = parts[2] if len(parts) > 2 else 0
self.cmd_optsymsq(float(parts[0]), float(parts[1]), opt_metal_s)
elif head_upper in ("OPTSYMPOLY", "OPTLSYMPOLY", "OPTBALPOLY"):
parts = rest.split()
if len(parts) < 2:
print(
"Usage: OPTSYMPOLY <target_L_nH> <freq_ghz> [sides] [metal]"
)
return True
sides = int(float(parts[2])) if len(parts) > 2 else 8
opt_metal_p: str | int = parts[3] if len(parts) > 3 else 0
self.cmd_optsympoly(
float(parts[0]), float(parts[1]), sides, opt_metal_p
)
elif head_upper in ("LDIV", "SHOWLDIV"):
parts = rest.split()
if len(parts) != 4:
print("Usage: LDIV <name> <n_l> <n_w> <n_t>")
return True
self.cmd_ldiv(
parts[0],
int(float(parts[1])),
int(float(parts[2])),
int(float(parts[3])),
)
# Move-axis variants
elif head_upper in ("MOVEX", "MVX"):
parts = rest.split()
if len(parts) != 2:
print("Usage: MOVEX <name> <dx>")
return True
self.cmd_movex(parts[0], float(parts[1]))
elif head_upper in ("MOVEY", "MVY"):
parts = rest.split()
if len(parts) != 2:
print("Usage: MOVEY <name> <dy>")
return True
self.cmd_movey(parts[0], float(parts[1]))
elif head_upper in ("FLIP", "REVERSE", "REV", "REORDER"):
self.cmd_flip(rest.strip())
elif head_upper in ("JOINSHUNT", "ADDSHUNT", "MATESHUNT", "SHUNT"):
self.cmd_joinshunt(rest.split())
elif head_upper in ("SELECT", "HIGHLIGHT", "CHOOSE", "FAVORITE"):
self.cmd_select(rest.strip() or None)
elif head_upper in ("UNSELECT", "UNHIGHLIGHT", "UNCHOOSE", "UNFAVORITE"):
self.cmd_unselect()
elif head_upper in ("SPTOWIRE", "DEMOLISH", "SP2WIRE", "BREAKUP"):
self.cmd_sptowire(rest.strip())
# Pi2 / 2PortX / extended forms
elif head_upper in ("PI2", "PIMODEL2"):
parts = rest.split()
if len(parts) < 2:
print("Usage: PI2 <name> <freq_ghz> [<gnd>]")
return True
gnd = parts[2] if len(parts) > 2 else None
self.cmd_pi2(parts[0], float(parts[1]), gnd)
elif head_upper in ("2PORTX", "TWOPORTX", "2PX"):
parts = rest.split()
if len(parts) != 4:
print("Usage: 2PORTX <name> <f0> <f1> <step>")
return True
self.cmd_2port_x(
parts[0], float(parts[1]), float(parts[2]), float(parts[3])
)
elif head_upper in ("RESISHF", "RESHF", "RHF", "RF"):
parts = rest.split()
if len(parts) != 2:
print("Usage: RESISHF <name> <freq_ghz>")
return True
self.cmd_resishf(parts[0], float(parts[1]))
elif head_upper in ("CCELL", "CCELLSIZE", "CCELLUNIT", "CMAXCELL"):
parts = rest.split()
ml = float(parts[0]) if len(parts) >= 1 else 0.0
mw = float(parts[1]) if len(parts) >= 2 else 0.0
self.cmd_ccell(ml, mw)
elif head_upper in ("SETMAXNW", "MAXNW"):
v = int(float(rest.strip())) if rest.strip() else 0
self.cmd_setmaxnw(v)
elif head_upper in ("SWEEPMM", "SWEEPOPTMM", "OPTSWEEPMM", "SWMM"):
self.cmd_sweep_mm(_parse_kv_args(rest))
elif head_upper in ("BCAT", "BDIR", "BHEAD", "BCONTENTS"):
if not rest.strip():
print("Usage: BCAT <path>")
return True
self.cmd_cat(rest.strip())
elif head_upper in ("BATCHOPT", "OPTBATCH"):
path = rest.strip() if rest.strip() else None
self.cmd_batchopt(path)
elif head_upper == "SPICESAVE":
parts = rest.split()
if len(parts) != 3:
print("Usage: SPICESAVE <name> <freq_ghz> <path>")
return True
self.cmd_spicesave(parts[0], float(parts[1]), parts[2])
elif head_upper in ("MOVE", "MV"):
parts = rest.split()
if len(parts) != 3:
print("Usage: MOVE <name> <dx> <dy>")
return True
self.cmd_move(parts[0], float(parts[1]), float(parts[2]))
elif head_upper in ("MOVETO", "SETORIG"):
parts = rest.split()
if len(parts) != 3:
print("Usage: MOVETO <name> <x> <y>")
return True
self.cmd_moveto(parts[0], float(parts[1]), float(parts[2]))
elif head_upper in ("FLIPV", "VFLIP"):
self.cmd_flipv(rest.strip())
elif head_upper in ("FLIPH", "HFLIP"):
self.cmd_fliph(rest.strip())
elif head_upper in ("ROTATE", "ROT"):
parts = rest.split()
if len(parts) != 2:
print("Usage: ROTATE <name> <angle_deg>")
return True
self.cmd_rotate(parts[0], float(parts[1]))
elif head_upper == "REPORT":
parts = rest.split()
if len(parts) < 2:
print("Usage: REPORT <name> <freq_ghz> [<freq_ghz> ...]")
return True
self.cmd_report(parts[0], [float(p) for p in parts[1:]])
elif head_upper in ("2PORTGND", "2PG"):
parts = rest.split()
if len(parts) != 5:
print("Usage: 2PORTGND <name> <gnd> <f0> <f1> <step>")
return True
self.cmd_2port_gnd(
parts[0], parts[1],
float(parts[2]), float(parts[3]), float(parts[4]),
)
elif head_upper in ("2PORTPAD", "2PP"):
parts = rest.split()
if len(parts) != 6:
print("Usage: 2PORTPAD <name> <pad1> <pad2> <f0> <f1> <step>")
return True
self.cmd_2port_pad(
parts[0], parts[1], parts[2],
float(parts[3]), float(parts[4]), float(parts[5]),
)
elif head_upper in ("3PORT", "3P"):
parts = rest.split()
if len(parts) != 3:
print("Usage: 3PORT <name> <gnd> <freq_ghz>")
return True
self.cmd_3port(parts[0], parts[1], float(parts[2]))
elif head_upper in ("2PORTTRANS", "2PT"):
parts = rest.split()
if len(parts) != 5:
print("Usage: 2PORTTRANS <pri> <sec> <f0> <f1> <step>")
return True
self.cmd_2port_trans(
parts[0], parts[1],
float(parts[2]), float(parts[3]), float(parts[4]),
)
elif head_upper in ("2PZIN", "2PZ"):
parts = rest.split()
if len(parts) < 2:
print("Usage: 2PZIN <name> <freq_ghz> [Z_re Z_im]")
return True
zl_re = float(parts[2]) if len(parts) > 2 else 50.0
zl_im = float(parts[3]) if len(parts) > 3 else 0.0
self.cmd_2pzin(parts[0], float(parts[1]), zl_re, zl_im)
elif head_upper == "ERASE":
self.cmd_erase(rest.split())
elif head_upper == "RENAME":
parts = rest.split()
if len(parts) != 2:
print("Usage: RENAME <old> <new>")
return True
self.cmd_rename(parts[0], parts[1])
elif head_upper in ("COPY", "CP"):
parts = rest.split()
if len(parts) != 2:
print("Usage: COPY <src> <dst>")
return True
self.cmd_copy(parts[0], parts[1])
elif head_upper == "HIDE":
self.cmd_hide(rest.split())
elif head_upper in ("BEFRIEND", "FRIEND"):
parts = rest.split()
if len(parts) != 2:
print("Usage: BEFRIEND <s1> <s2>")
return True
self.cmd_befriend(parts[0], parts[1])
elif head_upper in ("UNFRIEND", "DEFRIEND"):
parts = rest.split()
if len(parts) != 2:
print("Usage: UNFRIEND <s1> <s2>")
return True
self.cmd_unfriend(parts[0], parts[1])
elif head_upper in ("INTERSECT", "FINDI"):
self.cmd_intersect(rest.strip())
# Builders
elif head_upper in ("TRANS", "T"):
self.cmd_trans(_parse_kv_args(rest))
elif head_upper in ("BALUN", "B"):
self.cmd_balun(_parse_kv_args(rest))
elif head_upper in ("CAPACITOR", "CCAP"):
self.cmd_capacitor(_parse_kv_args(rest))
elif head_upper in ("SYMSQ", "BALSQ", "CENTERSQ"):
self.cmd_symsq(_parse_kv_args(rest))
elif head_upper in ("SYMPOLY", "BALPOLY", "CENTERPOLY"):
self.cmd_sympoly(_parse_kv_args(rest))
elif head_upper in ("MMSQUARE", "MMSQ", "SQMM"):
self.cmd_mmsquare(_parse_kv_args(rest))
# Edit
elif head_upper in ("SPLIT", "UNJOIN", "BREAK"):
parts = rest.split()
if len(parts) != 3:
print("Usage: SPLIT <name> <segment_index> <new_name>")
return True
self.cmd_split(parts[0], int(float(parts[1])), parts[2])
elif head_upper in ("JOIN", "ADD", "MATE"):
self.cmd_join(rest.split())
elif head_upper in ("PHASE", "PH"):
parts = rest.split()
if len(parts) != 2:
print("Usage: PHASE <name> <+1|-1>")
return True
self.cmd_phase(parts[0], int(float(parts[1])))
# Tech edits
elif head_upper in ("MODIFYTECHLAYER", "TECHLAYER"):
parts = rest.split()
if len(parts) != 3:
print("Usage: MODIFYTECHLAYER <rho|t|eps> <layer> <value>")
return True
self.cmd_modify_tech_layer(
parts[0], int(float(parts[1])), float(parts[2])
)
elif head_upper in ("CELL", "CELLSIZE"):
parts = rest.split()
kw = {}
if len(parts) >= 1:
kw["max_l"] = float(parts[0])
if len(parts) >= 2:
kw["max_w"] = float(parts[1])
if len(parts) >= 3:
kw["max_t"] = float(parts[2])
self.cmd_cell(**kw)
elif head_upper in ("AUTOCELL", "ACELL"):
parts = rest.split()
alpha = float(parts[0]) if len(parts) >= 1 else 0.5
beta = float(parts[1]) if len(parts) >= 2 else 1.0
self.cmd_auto_cell(alpha, beta)
elif head_upper == "CHIP":
parts = rest.split()
x = float(parts[0]) if len(parts) >= 1 else None
y = float(parts[1]) if len(parts) >= 2 else None
self.cmd_chip(x, y)
elif head_upper in ("EDDY", "CALCEDDY"):
val = rest.strip().lower()
on = None
if val in ("on", "true", "1"):
on = True
elif val in ("off", "false", "0"):
on = False
self.cmd_eddy(on)
# View commands — track state where it's a single scalar
elif head_upper in ("SCALE", "ZOOM"):
parts = rest.split()
if parts:
try:
self.cmd_view_set("scale", float(parts[0]))
return True
except ValueError:
pass
self.cmd_no_op_view(head_upper)
elif head_upper == "PAN":
parts = rest.split()
if len(parts) >= 2:
try:
self.cmd_view_set("pan_x", float(parts[0]))
self.cmd_view_set("pan_y", float(parts[1]))
return True
except ValueError:
pass
self.cmd_no_op_view(head_upper)
elif head_upper in ("ORIGIN", "ORIG", "OR", "CENTER"):
parts = rest.split()
if len(parts) >= 2:
try:
self.cmd_view_set("origin_x", float(parts[0]))
self.cmd_view_set("origin_y", float(parts[1]))
return True
except ValueError:
pass
self.cmd_no_op_view(head_upper)
elif head_upper == "GRID":
parts = rest.split()
if parts:
try:
self.cmd_view_set("grid", float(parts[0]))
return True
except ValueError:
pass
self.cmd_no_op_view(head_upper)
elif head_upper == "SNAP":
parts = rest.split()
if parts:
try:
self.cmd_view_set("snap", float(parts[0]))
return True
except ValueError:
pass
self.cmd_no_op_view(head_upper)
elif head_upper in (
"VPAN", "HPAN", "PANOUT", "REFRESH",
"RULER", "SHOWPHASE", "VPC",
"BB", "BOUNDINGBOX", "FULLVIEW", "FV", "ORIGIN3D",
"SCALE3D", "ROTATE3D", "METAL", "OPENGL", "TEKIO",
"OPTIONS", "SETMAXNW", "PRINTTEKFILE",
):
self.cmd_no_op_view(head_upper)
# Pause / Input
elif head_upper in ("PAUSE", "WAIT"):
self.cmd_pause()
elif head_upper in ("INPUT", "REDIRECT"):
path = rest.split()[0] if rest.strip() else None
self.cmd_input(path)
elif head_upper == "VERBOSE":
self.cmd_verbose(rest.strip() or None)
elif head_upper in ("TIMER", "TIME"):
self.cmd_timer(rest.strip() or None)
elif head_upper == "SAVEMAT":
self.cmd_savemat(rest.strip() or None)
elif head_upper == "RECORD":
self.cmd_record(rest.strip() or None)
elif head_upper == "EXEC":
if not rest.strip():
print("Usage: EXEC <path>")
return True
self.cmd_exec_script(rest.strip())
elif head_upper == "CAT":
if not rest.strip():
print("Usage: CAT <path>")
return True
self.cmd_cat(rest.strip())
elif head_upper in ("VERSION", "VER"):
self.cmd_version()
elif head_upper == "LOG":
self.cmd_log(rest.strip() or None)
elif head_upper in ("HELP", "?"):
self.cmd_help(rest.strip() or None)
else:
print(f"Unknown command: {head!r}")
return True
def _print_status(repl: Repl) -> None:
"""Print a summary of the REPL state (loaded tech, shapes, viewport)."""
if repl.tech:
print(f"Tech: {repl.tech.chip.tech_file or '(in-memory)'}"
f" — {len(repl.tech.metals)} metals,"
f" {len(repl.tech.layers)} layers,"
f" {len(repl.tech.vias)} vias")
else:
print("Tech: (none)")
print(f"Shapes: {len(repl.shapes)}")
for name, sh in repl.shapes.items():
print(f" {name}: {len(sh.polygons)} polygons,"
f" {len(sh.segments())} segments")
print(
f"Viewport: scale={repl.viewport['scale']},"
f" pan=({repl.viewport['pan_x']}, {repl.viewport['pan_y']}),"
f" origin=({repl.viewport['origin_x']}, {repl.viewport['origin_y']})"
)
print(
f"Toggles: verbose={repl.verbose}, timer={repl.timer},"
f" save_mat={repl.save_mat}"
)
[docs]
def main(argv: list[str] | None = None) -> int:
"""Entry point for the ``reasitic`` console script."""
parser = argparse.ArgumentParser(prog="reasitic")
parser.add_argument("-t", "--tech", type=Path, help="tech file to load on startup")
parser.add_argument(
"-x", "--exec", dest="script", type=Path, help="run commands from a script file"
)
parser.add_argument("-c", "--command", help="run a single command and exit")
parser.add_argument(
"--version", action="store_true", help="print the version and exit"
)
parser.add_argument(
"--status", action="store_true",
help="print loaded-tech / shape summary and exit (use with -t and -x)",
)
args = parser.parse_args(argv)
if args.version:
from reasitic import __version__
print(f"reASITIC {__version__}")
return 0
repl = Repl()
if args.tech:
repl.cmd_load_tech(str(args.tech))
if args.command:
repl.execute(args.command)
if args.status:
_print_status(repl)
return 0
if args.script:
for line in args.script.read_text().splitlines():
if not repl.execute(line):
break
if args.status:
_print_status(repl)
return 0
if args.status:
_print_status(repl)
return 0
# Interactive
print("reASITIC — type 'help' for help, 'quit' to exit")
try:
while True:
try:
line = input("reASITIC> ")
except EOFError:
break
if not repl.execute(line):
break
except KeyboardInterrupt:
print()
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())