analogpy 0.1.4
Analog circuit IR (Intermediate Representation) and Spectre netlist generator
Requires Python: >=3.8

License: Apache-2.0

# analog-py

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** (MVP: Spectre, future: 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 [tmux-ssh](https://github.com/circuitmuggle/tmux-ssh)

3. **Parse simulation results** (planned)
   - Read PSF/nutbin files
   - Expose data as numpy arrays / pandas DataFrames
   - Enable Python-native post-processing

4. **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 [tmux-ssh](https://github.com/circuitmuggle/tmux-ssh)
- **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   │  │   numpy     │  │    matplotlib       │  │
│  │  (netlist)  │  │   scipy     │  │    plotly           │  │
│  │  (parse)    │  │   pandas    │  │    (visualization)  │  │
│  │  (expose)   │  │  (analysis) │  │                     │  │
│  └──────┬──────┘  └──────┬──────┘  └─────────┬──────────-┘  │
└─────────┼────────────────┼───────────────────┼──────────────┘
          │                │                   │
          ▼                ▼                   ▼
    ┌──────────┐    ┌──────────────┐    ┌───────────┐
    │ Spectre  │    │ Post-process │    │  Plots    │
    │ Netlist  │    │ (FFT, etc.)  │    │ PNG/HTML  │
    └──────────┘    └──────────────┘    └───────────┘
```

## Roadmap

- 0.1.x AST + netlist generation ✅
- 0.2.x Result parser + data exposure
- 0.3.x Optimization / AI hooks
- 1.0.0 Stable IR

## Installation

```bash
pip install -e .
```

## Quick Start

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


class Inverter(Circuit):
    """CMOS Inverter cell - parameters visible in signature."""

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

        self.add_instance(nmos, "MN", d=self.net("out"), g=self.net("inp"),
                          s=self.net("vss"), b=self.net("vss"), w=w_n, l=l)
        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)


# Create inverter with default sizing - parameters visible in signature
inv = Inverter()

# Create top-level circuit (no ports = top level)
top = Circuit("tb_inverter", ports=[])
vin = top.net("vin")
vout = top.net("vout")
vdd = top.net("vdd")
gnd = top.gnd()  # Global ground "0" at testbench level

# Add voltage source
top.add_instance(vsource, instance_name="I_Vdd", p=vdd, n=gnd, dc=1.8)

# Instantiate inverter
top.add_instance(inv, "X1", inp=vin, out=vout, vdd=vdd, vss=gnd)

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

> **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/05_result_processing.py` - Parse results and plot (planned)
- `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
from analogpy.devices import vsource

tb = Testbench("tb_amp")
vdd = tb.net("vdd")
gnd = tb.gnd()  # Global ground "0"
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"})
```

**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 tmux-ssh
```

#### 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 tmux-ssh {}
```

### Phase 7: PDK Infrastructure (Implemented)

- **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** | Left (default) | 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

# Quick way to generate a symbol image
d = create_cell_symbol_standalone("oled_cell", ["ANODE", "ELVSS"])
d.save("oled_symbol.png")
```

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

### Phase 8: Result Parsing (Planned)

- **Parse PSF/nutbin**: Read Spectre output files
- **Expose as Python data**: numpy arrays, pandas DataFrames
- **Display config**: Separate from save config
- **Validation**: Warn if display signal not in saved signals

```python
# Planned API
from analogpy.results import load_results

results = load_results("/sim/ldo_pvt/tt_v1.8_t27/psf")

# Point query
vout_dc = results.dc["X_OTA.vout"]

# Waveform as numpy array
vout_tran = results.tran["vout"]  # Returns (time, values) arrays

# At specific time
vgs_at_10ns = results.tran["M1:vgs"].at(10e-9)

# Use Python for analysis
import numpy as np
from scipy.fft import fft

spectrum = fft(vout_tran.values)  # numpy/scipy does the 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
└── results/        # Result parsing (planned)
```

## Design Principles

1. **Netlist-focused**: Generate netlists, expose results - 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

## 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 tmux-ssh** (auto-detected if config exists)
```bash
# Install tmux-ssh
pip install tmux-ssh

# Configure once (credentials are saved to ~/.tmux_ssh_config)
tmux-ssh 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 tmux-ssh (reads ~/.tmux_ssh_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