Metadata-Version: 2.4
Name: analogpy
Version: 0.2.15
Summary: Analog circuit IR (Intermediate Representation) and Spectre netlist generator
Author-email: Gaofeng Fan <circuitmuggle@gmaigmaill.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/circuitmuggle/analogpy
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy
Provides-Extra: visualization
Requires-Dist: schemdraw>=0.18; extra == "visualization"
Requires-Dist: reportlab>=4.0; extra == "visualization"
Requires-Dist: pypdf>=4.0; extra == "visualization"
Dynamic: license-file

License: Apache-2.0

# analogPy

### Glossary

- **DSL** (Domain-Specific Language): A mini-language focused on a specific problem domain. analogpy is a Python DSL for describing circuits — instead of writing raw Spectre netlists (`R1 vdd out 1k`), you write Python (`resistor("R1", p=vdd, n=out, r=1e3)`).

- **AST** (Abstract Syntax Tree): A tree representation of code structure. When you write `resistor("R1", p=vdd, n=out, r=1e3)`, analogpy builds an internal tree representing the circuit hierarchy before generating netlist text.

- **Codegen** (Code Generation): Taking the internal AST and converting it to output code (Spectre or ngspice netlist). `generate_spectre(tb)` walks the AST and outputs the netlist text.

```
Python code (your circuit)  →  analogpy builds AST  →  Codegen  →  Spectre netlist
```

Python DSL + AST + Codegen for Analog Circuit Design and Netlist Generation.

## Project Goals

**analogpy** is a Python library for generating circuit netlists. It bridges the gap between Python programming and analog circuit simulation.

### What analogpy DOES:

1. **Generate netlists** (Spectre & ngspice)
   - Circuit topology in Python
   - Hierarchical circuits
   - Testbench with analyses

2. **Build simulation commands** (not execute)
   - SpectreCommand builder with configurable options
   - User executes via shell or [tmux4ssh](https://github.com/circuitmuggle/tmux4ssh)
   - this tool doesn't provide any simulator access, a demo with ngspice is available at [analogpy website](https://analogpy-website.vercel.app/)

3. **Make Python loop design easy**
   - PVT corners: Python loop generates N netlists
   - Monte Carlo: Python loop with different seeds
   - Parameter sweeps: Python variables directly in netlist

### What analogpy does NOT do:

- **Job submission**: Use shell or [tmux4ssh](https://github.com/circuitmuggle/tmux4ssh)
- **Result parsing**: Use [psf-utils](https://pypi.org/project/psf-utils/) for PSF ASCII files
- **Heavy analysis**: Use numpy, scipy (FFT, filtering, etc.)
- **Visualization**: Use matplotlib, plotly (analogpy provides helpers)
- **Replace Cadence ADE**: analogpy is CLI/script-first, not GUI

### Design Philosophy

```
┌─────────────────────────────────────────────────────────────┐
│                      Python Script                          │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  analogpy   │  │  psf-utils  │  │    matplotlib       │  │
│  │  (netlist)  │  │   numpy     │  │    plotly           │  │
│  │  (command)  │  │   scipy     │  │    (visualization)  │  │
│  │             │  │  (analysis) │  │                     │  │
│  └──────┬──────┘  └──────┬──────┘  └─────────┬──────────-┘  │
└─────────┼────────────────┼───────────────────┼──────────────┘
          │                │                   │
          ▼                ▼                   ▼
    ┌──────────┐    ┌──────────────┐    ┌───────────┐
    │ Spectre  │    │ Post-process │    │  Plots    │
    │ Netlist  │    │ (FFT, etc.)  │    │ PNG/HTML  │
    └──────────┘    └──────────────┘    └───────────┘
```

## Roadmap

- 0.1.x AST + netlist generation ✅
- 0.2.x Optimization / AI hooks
- 1.0.0 Stable IR

## Installation
```
pip install analogpy
```

or git clone this repo and then
```bash
pip install -e .
```

## Quick Start

```python
from analogpy import Circuit, nmos, pmos, vsource, generate_spectre


class Inverter(Circuit):
    """CMOS Inverter cell."""

    def __init__(self, w_n: float = 1e-6, w_p: float = 2e-6,
                 l_n: float = 180e-9, l_p: float = 180e-9):
        # Ports with optional direction using colon syntax:
        # "inp:input" = input, "out:output" = output, default = inout
        super().__init__(name="inverter", ports=["inp:input", "out:output", "vdd", "vss"])

        # Short port names (d, g, s, b) or full names (drain, gate, source, bulk)
        self.add_instance(pmos, "MP", d=self.net("out"), g=self.net("inp"),
                          s=self.net("vdd"), b=self.net("vdd"), w=w_p, l=l_p,
                          schematic_position={'relative_to': "MN", 'x_shift': 0, 'y_shift': 2.1})
        # Equivalent using full names:
        self.add_instance(nmos, "MN", drain=self.net("out"), gate=self.net("inp"),
                           source=self.net("vss"), bulk=self.net("vss"), w=w_n, l=l_n)

        # Draw explicit wires between ports sharing the same net
        self.draw_wires('out')
        self.draw_wires('inp')
        self.draw_wires('vdd', only=[('MP', 's'), ('MP', 'b')])
        self.draw_wires('vss', only=[('MN', 's'), ('MN', 'b')])

# Customize transistor sizes directly in the constructor:
inv_large = Inverter(w_n=2e-6, w_p=4e-6, l_n=90e-9, l_p=90e-9)

# Create top-level circuit 
top = Circuit("tb_inverter", ports=[])

# add_net() and net() are equivalent — use whichever you prefer
vin_net  = top.net("vin")       # short form
vout_net = top.add_net("vout")  # explicit form
vdd_net  = top.add_net("vdd")
gnd      = top.add_gnd()        # Global ground "0"; top.gnd() also works

# add_instance() and instance() are equivalent
vdd_inst = top.add_instance(vsource, "I_Vdd", p=vdd_net, n=gnd, dc=1.8)
x1_inst  = top.instance(inv_large, "X1", inp=vin_net, out=vout_net, vdd=vdd_net, vss=gnd)

# Generate Spectre netlist
netlist = generate_spectre(top)
print(netlist)
```

**Spectre netlist output:**
```spectre
subckt tb_inverter inp out vdd vss
I_Vdd (vdd 0) vsource dc=1.8
X1 (inp out vdd vss) inverter
ends tb_inverter
```

#### AST (Abstract Syntax Tree)

Inspect the circuit hierarchy as a tree:

```python
print(Inverter().tree())
```

```
inverter  [Circuit]  ports=['inp', 'out', 'vdd', 'vss']
├── MP  (pmos)  [primitive]  {w=2u, l=180n}  d=out, g=inp, s=vdd, b=vdd
└── MN  (nmos)  [primitive]  {w=1u, l=180n}  d=out, g=inp, s=vss, b=vss
```

#### Schematic Visualization

Generate a schematic SVG and cell symbol:

```python
from analogpy.visualization import render_schematic_svg, create_cell_symbol_standalone, drawing_to_svg

inverter = Inverter()

# Generate schematic view (with explicit wire routing)
svg = render_schematic_svg(cell=inverter)
with open("inverter.svg", "w") as f:
    f.write(svg)

# Generate cell symbol view (port directions auto-extracted from cell)
drawing = create_cell_symbol_standalone(cell=inverter)
svg_symbol = drawing_to_svg(drawing)
with open("inverter_symbol.svg", "w") as f:
    f.write(svg_symbol)

# Or render PDF (schematic only)
top.render_schematic("tb_inverter.pdf")
```

**Inverter schematic and symbol:**

<table>
<tr>
<td><img src="docs/inverter.svg" width="400"/></td>
<td><img src="docs/inverter_symbol.svg" width="250"/></td>
</tr>
<tr>
<td align="center"><em>Schematic view</em></td>
<td align="center"><em>Symbol view</em></td>
</tr>
</table>

Note: Spectre uses a different syntax than traditional SPICE:
- All instances start with `I` prefix (not R, C, L)
- Nodes are in parentheses: `(node1 node2)`
- Parameters use `key=value` syntax: `dc=1.8`, `w=1u l=180n`

> **Note on port naming:** Avoid using Python reserved keywords (`in`, `for`, `class`, etc.)
> as port names. For example, `add_instance(inv, "X1", in=vin)` is a syntax error because
> `in` is a reserved word. Use `inp` instead. If you must match an existing netlist that
> uses `in` as a port name, use dict unpacking as a workaround:
> `add_instance(inv, "X1", **{"in": vin, "out": vout, "vdd": vdd, "vss": gnd})`

## Examples

See the `examples/` folder for complete workflows:

- `examples/01_inverter_basic.py` - Simple inverter netlist
- `examples/02_ota_testbench.py` - OTA with DC/AC analysis
- `examples/03_pvt_sweep.py` - PVT corner sweep with Python loop
- `examples/04_monte_carlo.py` - Monte Carlo with Python loop
- `examples/06_oled_dc.py` - OLED DC simulation with Verilog-A LUTs (includes SpectreCommand reference)
- `examples/07_oled2.py` - Series OLED testbench using function-built cells

## Features

### Phase 1: Core Hierarchy (Implemented)

- **Circuit**: Reusable circuit blocks with defined ports (maps to Spectre `subckt`)
- **Aliases**: `Subcircuit` and `Subckt` are aliases for `Circuit`
- **Instantiation**: Hierarchical design with `circuit.add_instance()`
- **Nested hierarchy**: Circuits can contain other circuits
- **Top-level**: Use `Circuit("name", ports=[])` or `Testbench` for simulation top

### Phase 2: Testbench & Analysis (Implemented)

- **Testbench**: Test environment extending Circuit with simulation setup
- **Analysis classes**: DC, AC, Transient, Noise, STB
- **Simulator options**: Temperature, tolerances, convergence settings
- **Behavioral models**: Verilog-A include support

```python
from analogpy import Testbench, DC, AC, Transient, vsource

tb = Testbench("tb_amp")
vdd = tb.add_net("vdd")   # preferred; tb.net("vdd") is an alias
gnd = tb.add_gnd()        # preferred; tb.gnd() is an alias
vdd_inst = tb.add_instance(vsource, instance_name="I_Vdd", p=vdd, n=gnd, dc=1.8)
tb.set_temp(27)
tb.add_analysis(DC())
tb.add_analysis(AC(start=1, stop=1e9, points=100))
tb.add_analysis(Transient(stop=1e-6))
```

#### Analysis extras and SimulatorOptions

All analysis classes and `SimulatorOptions` support an `extras` dict for arbitrary Spectre parameters not covered by named fields:

```python
from analogpy import Transient, DC

# cmin is a named field on Transient (minimum capacitance per node for convergence)
tran = Transient(stop=1e-6, cmin=1e-18)

# Use extras for any other Spectre analysis parameter
tran = Transient(stop=1e-6, extras={"errpreset": "conservative", "method": "euler"})
dc = DC(extras={"homotopy": "all"})
```

#### Supported Analysis Types

analogpy supports the following analyses (verified against official documentation):

- **Spectre**: `/org/ees/tools/vendor/cadence/spectre/spectre-23.10.802/doc/spectreref/`
- **ngspice**: `https://ngspice.sourceforge.io/docs/ngspice-html-manual/manual.xhtml`

⚠️ **WARNING**: Support status (✅/❌) is verified against official docs. The generated netlist syntax has NOT been verified with actual simulation runs. Some generated outputs may need correction.

| Spectre | analogpy Class | Spectre | ngspice | Notes |
|---------|----------------|---------|---------|-------|
| `dc` | `DC` | ✅ | ✅ | DC operating point / sweep |
| `ac` | `AC` | ✅ | ✅ | AC small-signal |
| `tran` | `Transient` | ✅ | ✅ | Transient time-domain |
| `noise` | `Noise` | ✅ | ✅ | Noise analysis |
| `stb` | `STB` | ✅ | ❌ | Stability/phase margin |
| `pss` | `PSS` | ✅ | ✅ | Periodic steady-state (ngspice: experimental) |
| `pac` | `PAC` | ✅ | ✅ | Periodic AC (ngspice: experimental) |
| `pnoise` | `PNoise` | ✅ | ✅ | Periodic noise (ngspice: experimental) |
| `pxf` | `PXF` | ✅ | ❌ | Periodic transfer function |
| `hb` | `HB` | ✅ | ❌ | Harmonic balance |
| `sp` | `SP` | ✅ | ❌ | S-parameter |
| `xf` | `XF` | ✅ | ✅ | Transfer function |
| `pz` | `PZ` | ✅ | ✅ | Pole-zero |
| `sweep` | `Sweep` | ✅ | ❌ | Parameter sweep |
| `dcmatch` | `DCMatch` | ✅ | ❌ | DC mismatch |
| `acmatch` | `ACMatch` | ✅ | ❌ | AC mismatch |
| `sens` | — | ✅ | ✅ | Sensitivity analysis |
| `disto` | — | ❌ | ✅ | Distortion analysis (ngspice-only) |
| `fft` | `FFT` | ❌ | ✅ | FFT analysis |

#### Two Syntax Options

Each analysis supports two equivalent syntaxes:

| Analysis | Explicit (add_analysis) | Convenience Method |
|----------|------------------------|-------------------|
| DC | `tb.add_analysis(DC())` | `tb.op()` or `tb.dc()` |
| AC | `tb.add_analysis(AC(start=1, stop=1e9))` | `tb.ac(start=1, stop=1e9)` |
| Transient | `tb.add_analysis(Transient(stop=1e-6))` | `tb.tran(stop=1e-6)` |
| Noise | `tb.add_analysis(Noise(output="v(out)", input="V1"))` | `tb.noise(output="v(out)", input="V1")` |
| STB | `tb.add_analysis(STB(probe="v(out)"))` | `tb.stb(probe="v(out)")` |
| PSS | `tb.add_analysis(PSS(fund=1e9))` | `tb.pss(fund=1e9)` |
| PAC | `tb.add_analysis(PAC(stop=1e9))` | `tb.pac(stop=1e9)` |
| PNoise | `tb.add_analysis(PNoise(stop=1e9))` | `tb.pnoise(stop=1e9)` |
| PXF | `tb.add_analysis(PXF(stop=1e9))` | `tb.pxf(stop=1e9)` |
| SP | `tb.add_analysis(SP(start=1e6, stop=1e9))` | `tb.sp(start=1e6, stop=1e9)` |
| XF | `tb.add_analysis(XF(stop=1e9))` | `tb.xf(stop=1e9)` |
| PZ | `tb.add_analysis(PZ())` | `tb.pz()` |
| Sweep | `tb.add_analysis(Sweep(param="R1", ...))` | `tb.sweep(param="R1", ...)` |
| HB | `tb.add_analysis(HB(funds=[1e9]))` | `tb.hb(funds=[1e9])` |
| DCMatch | `tb.add_analysis(DCMatch())` | `tb.dcmatch()` |
| ACMatch | `tb.add_analysis(ACMatch(start=1, stop=1e9))` | `tb.acmatch(start=1, stop=1e9)` |

**SimulatorOptions** — tolerance fields (`reltol`, `vabstol`, `iabstol`, `gmin`) default to `None` and are not emitted, letting the command-line accuracy mode (`++aps`, `+aps`) control them. Set explicitly only when you need to override:

```python
tb = Testbench("tb_amp")
tb.simulator_options.reltol = 1e-6       # Override tolerance
tb.simulator_options.gmin = 1e-15        # Tighter gmin
tb.simulator_options.extras = {"rforce": 1, "pivotdc": "yes"}  # Convergence helpers
```

**temp vs tnom:**
- `temp` — circuit simulation temperature (varies in PVT sweeps)
- `tnom` — temperature at which device model parameters were measured/extracted (usually fixed to match PDK characterization, e.g. 27 or 25)

### Phase 3: SaveConfig (Implemented)

- **Hierarchical saves**: Define saves at block level, apply with prefix
- **Tagged signals**: Filter saves by category
- **Testbench control**: Override, include, exclude saves

```python
from analogpy import SaveConfig

# Define saves for OTA block
ota_saves = (SaveConfig("ota")
    .voltage("out", "tail", tag="essential")
    .op("M1:gm", "M2:gm", tag="op_params"))

# In testbench, apply with hierarchy prefix
tb.save(ota_saves.with_prefix("X_LDO.X_OTA"))
```

### Phase 4: Device Primitives (Implemented)

- **MOSFETs**: `nmos()`, `pmos()` with nf support
- **BJT/JFET**: `bjt()`, `jfet()` for bipolar and junction FETs
- **Passives**: `resistor()`, `capacitor()`, `inductor()`, `mutual_inductor()`
- **Sources**: `vsource()`, `isource()`
- **Controlled sources**: `vcvs()`, `vccs()`, `ccvs()`, `cccs()`
- **Other**: `diode()`, `iprobe()`, `port()` (for S-parameter)

### Phase 5: SpectreCommand (Implemented)

- **Command builder**: Generate spectre commands without execution
- **Minimal defaults**: Only emits flags you explicitly set
- **Configurable**: Accuracy, threads, output format, include paths
- **Presets**: Liberal (fast), conservative (robust), moderate

```python
from analogpy import SpectreCommand

cmd = (SpectreCommand("input.scs")
    .accuracy("liberal")
    .threads(16)
    .include_path("/path/to/models")
    .build())

# User executes via shell or tmux4ssh
```

#### SpectreCommand Options Reference

| Method | Spectre Flag | Description |
|--------|-------------|-------------|
| `.output_format(fmt)` | `-format` | Raw data format: `"psfascii"` (default), `"psfbin"`, `"psfxl"`, `"psfbinf"`, `"nutbin"`, `"nutascii"`, `"sst2"`, `"fsdb"`, `"fsdb5"`, `"wdf"`, `"uwi"`, `"tr0ascii"`. PSF ASCII files can be read with [psf-utils](https://pypi.org/project/psf-utils/) |
| `.accuracy(level, mode)` | `++aps`, `+aps`, `+errpreset` | Error tolerance and acceleration (see below) |
| `.threads(n)` | `+mt=N` | Number of parallel threads (max 64) |
| `.include_path(*paths)` | `-I` | Add include paths for model files |
| `.log_file(path)` | `+log` | Log file path (default: Spectre writes `<netlist>.log`) |
| `.raw_dir(path)` | `-raw` | Raw output directory (default: Spectre writes in current dir) |
| `.ahdl_libdir(path)` | `-ahdllibdir` | Compiled Verilog-A model cache directory (default: raw output dir) |
| `.timeout(seconds)` | `+lqtimeout` | License queue timeout — abort if license not acquired in time |
| `.max_warnings(n)` | `-maxw` | Max warnings before Spectre aborts |
| `.max_notes(n)` | `-maxn` | Max informational notes before suppression |
| `.logstatus()` | `+logstatus` | Enable status logging for monitoring simulation progress |
| `.flag("+escchars")` | `+escchars` | Allow backslash-escaped characters in paths/strings |

**Accuracy modes** — `.accuracy(level, mode)`:

- `level`: `"liberal"` (fast), `"moderate"`, `"conservative"` (accurate)
- `mode` (optional, default `"++aps"`):
  - `"++aps"` — Uses a different time-step control algorithm for improved performance while satisfying error tolerances. Emits `++aps=<level>`
  - `"+aps"` — Spectre APS mode, a different simulator engine from base Spectre. Emits `+aps=<level>`
  - `"errpreset"` — Base Spectre error preset only, no APS acceleration. Emits `+errpreset=<level>`

```python
# Examples
.accuracy("liberal")              # ++aps=liberal (default mode)
.accuracy("liberal", "+aps")      # +aps=liberal
.accuracy("moderate", "errpreset") # +errpreset=moderate
```

**Note**: Only `.output_format()` is emitted by default (`-format psfascii`). All other flags are opt-in — if not called, they are not included in the generated command, letting Spectre use its own defaults.

### Phase 6: SimulationBatch (Implemented)

- **PVT sweeps**: Process/Voltage/Temperature corners
- **Monte Carlo**: Generate N runs with different seeds
- **Runner scripts**: Python scripts with CLI configuration

```python
from analogpy import SimulationBatch

# Python loop generates multiple netlists
batch = SimulationBatch("ldo_pvt", "/sim/ldo_pvt")
batch.pvt_sweep(make_tb_ldo, corners=[
    {"process": "tt", "voltage": 1.8, "temp": 27},
    {"process": "ff", "voltage": 1.98, "temp": -40},
    {"process": "ss", "voltage": 1.62, "temp": 125},
])
batch.command_options(accuracy="liberal", threads=16)
batch.generate()
batch.write_runner("run_pvt.py")

# User runs: python run_pvt.py commands | parallel tmux4ssh {}
```

### Phase 7: PDK Infrastructure (Implemented, not tested)

- **PDK loader**: Load PDK configuration by name
- **Multi-source config**: Project, user, environment variables
- **NDA-safe**: PDK files never included in package

```python
from analogpy.pdk import PDK

pdk = PDK.load("tsmc28")  # Loads from config
mn1 = pdk.nmos("M1", d=vout, g=vin, s=gnd, b=gnd, w=1e-6, l=28e-9, nf=4)
```

### Visualization Module (Experimental)

Generate schematic symbols and block diagrams for circuit documentation.

```bash
pip install analogpy[visualization]  # Requires schemdraw, reportlab, pypdf
```

#### Port Type Inference

The visualization module automatically infers port placement on symbols based on naming conventions:

| Port Type | Position | Pattern Examples |
|-----------|----------|------------------|
| **POWER** | Top | `vdd`, `avdd`, `vcc`, `pwr`, `anode`, `*_vdd` |
| **GROUND** | Bottom | `vss`, `gnd`, `elvss`, `cathode`, `*_gnd` |
| **INPUT** | Left | `in`, `clk`, `en`, `rst`, `din`, `sel`, `*_in` |
| **OUTPUT** | Right | `out`, `q`, `y`, `dout`, `*_out` |
| **INOUT** | Left (below inputs) | `io`, `sda`, `scl`, `data`, `bus` |
| **UNKNOWN** | Right (below outputs) | All other names |

#### Customizing Port Locations

Override the auto-inference using `port_overrides`:

```python
from analogpy.visualization import draw_cell_symbol, PortType
import schemdraw

# Define your custom port types
port_overrides = {
    "BIAS": PortType.INPUT,      # Force BIAS to left side
    "MONITOR": PortType.OUTPUT,   # Force MONITOR to right side
}

# Draw symbol with overrides
with schemdraw.Drawing() as d:
    d.config(unit=1, fontsize=10)
    positions = draw_cell_symbol(
        d, "my_cell",
        ports=["VDD", "VSS", "IN", "OUT", "BIAS", "MONITOR"],
        port_overrides=port_overrides
    )
    d.save("my_cell.png")
```

#### Standalone Symbol Generation

```python
from analogpy.visualization import create_cell_symbol_standalone, drawing_to_svg

oled = Circuit(name="oled_cell", ports=["ANODE", "ELVSS"])
d = create_cell_symbol_standalone(oled)
svg = drawing_to_svg(d)
```

**Note**: This module is experimental. Block diagram connection routing still needs work.

## Architecture

```
analogpy/
├── circuit.py      # Circuit (Subcircuit, Subckt are aliases), Net, Instance
├── devices.py      # nmos, pmos, resistor, capacitor, etc.
├── spectre.py      # Spectre netlist generation
├── testbench.py    # Testbench class
├── analysis.py     # DC, AC, Transient, Noise, STB
├── save.py         # SaveConfig for probe management
├── command.py      # SpectreCommand builder
├── batch.py        # SimulationBatch for PVT/MC
└── pdk/            # PDK loader infrastructure
```

## Design Principles

1. **Netlist-focused**: Generate netlists - that's it
2. **Python-native**: Use Python variables, loops, data structures
3. **Don't reinvent**: FFT? Use scipy. Plots? Use matplotlib.
4. **CLI-first**: No GUI, scripts and commands
5. **AI-friendly**: Simple patterns for LLM generation

### Naming Conventions

Following PEP 8:
- **Files/modules**: `snake_case` — `oled_1rc.py`, `circuit.py`
- **Classes**: `PascalCase` — `Oled1RC`, `Circuit`, `Testbench`
- **Functions/variables**: `snake_case` — `add_instance()`, `generate_spectre()`
- **Device primitives**: `lowercase` — `nmos`, `pmos`, `resistor`, `cccs`

Example: `from cells.oled_1rc import Oled1RC` — file is snake_case, class is PascalCase.

### Object Model: Python vs EDA Naming

In analogpy, a **Python object** represents an **EDA cell definition** (not an instance):

| Python | EDA Concept | Example |
|---|---|---|
| Class (`Inverter`) | Cell **template** — defines structure, parameters not yet set | `class Inverter(Circuit):` |
| Object (`inverter = Inverter(w_n=1u)`) | **Parameterized cell** — fully describes the netlist | `nmos = Circuit(name="nmos", ...)` |
| `add_instance(cell, "X0", ...)` | **Instance** — a placed copy with pin connections | `self.add_instance(nmos, "MN", d=..., g=...)` |

Why not Python class = cell, Python object = instance? Because in analog design, the same
cell with different W/L values produces a different netlist. `Inverter(w_n=1u)` and
`Inverter(w_n=2u)` are **different cells**. The Python object captures that parameterization.

Device primitives like `nmos` and `pmos` are pre-created objects (not classes) because they
have no user-configurable structure — their parameters (W, L) are passed at instantiation time
via `add_instance()`.

## Testing

```bash
pytest tests/ -v
```

### Simulator Integration Tests

Some tests require a working Spectre simulator. These are marked with `@pytest.mark.simulator` and will be **automatically skipped** if no simulator is available.

**Test levels:**
1. **Syntax checks** - Always run, use Python-based validation
2. **Basic simulation** - Requires simulator, runs actual simulations
3. **Result validation** - Compares results against expected values

**Setting up simulator access:**

Option 1: **Config file** (recommended for remote simulation)
```bash
# Copy template to ~/.analogpy/
mkdir -p ~/.analogpy
cp config.yaml.template ~/.analogpy/config.yaml

# Edit the config file to set remote spectre path
# Uncomment and modify the settings you need
```

Example `~/.analogpy/config.yaml`:
```yaml
simulator:
  mode: remote
  remote:
    spectre_path: /tools/cadence/SPECTRE231/bin/spectre
    workdir: /tmp/analogpy
```

Option 2: **Local Spectre** (if installed on your machine)
```bash
# Spectre in PATH
which spectre  # Should return path

# Or set explicit path
export SPECTRE_PATH=/path/to/spectre
```

Option 3: **Remote via tmux4ssh** (auto-detected if config exists)
```bash
# Install tmux4ssh
pip install tmux4ssh

# Configure once (credentials are saved to ~/.tmux4ssh_config)
tmux4ssh user@your-spectre-server.com

# Now pytest will automatically use remote execution
pytest tests/test_simulation.py -v
```

**Configuration precedence:**
1. `~/.analogpy/config.yaml` (user config file)
2. Environment variables (override config file)
3. Local Spectre (PATH or SPECTRE_PATH)
4. Remote via tmux4ssh (reads ~/.tmux4ssh_config)
5. Skip with helpful message

**Environment variables:**
| Variable | Description | Default |
|----------|-------------|---------|
| `SPECTRE_PATH` | Path to local spectre binary | Auto-detect from PATH |
| `ANALOGPY_WORKDIR` | Working directory for simulation files | `/tmp/analogpy` |
| `ANALOGPY_SKIP_SIMULATION` | Set to "1" to skip all simulation tests | Disabled |

## License

Apache-2.0
