Browse Source

Add XSPICE code model build system and pre-built .cm plugins

- Add build_cmpp.py: builds cmpp preprocessor, preprocesses .ifs/.mod
  files, compiles .cm DLL plugins for runtime loading via codemodel command
- Ship 7 pre-built .cm plugins in pyngspice/codemodels/:
  analog (23 models), digital (32 models incl. d_cosim for Verilog
  co-simulation), spice2poly, table, tlines (5 transmission line models),
  xtradev (13 models incl. magnetic core, zener), xtraevt (4 models + UDNs)
- Add XSPICE_CODE_MODELS.md developer guide for writing custom components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pre-master-46
Joe DiPrima 2 hours ago
parent
commit
fa3fb1d8d3
  1. 193
      XSPICE_CODE_MODELS.md
  2. 242
      build_cmpp.py
  3. BIN
      pyngspice/codemodels/analog.cm
  4. BIN
      pyngspice/codemodels/digital.cm
  5. BIN
      pyngspice/codemodels/spice2poly.cm
  6. BIN
      pyngspice/codemodels/table.cm
  7. BIN
      pyngspice/codemodels/tlines.cm
  8. BIN
      pyngspice/codemodels/xtradev.cm
  9. BIN
      pyngspice/codemodels/xtraevt.cm

193
XSPICE_CODE_MODELS.md

@ -0,0 +1,193 @@
# Writing XSPICE Code Models for pyTesla
## What This Is
XSPICE code models let you write circuit components in C. During simulation,
ngspice calls your C function at each timestep with port voltages/states and
you compute the outputs. This is how we'll add custom parts (gate drivers,
controllers, digital logic) to the pyTesla part library.
## How a Code Model Looks in a Netlist
```spice
* Analog component using a code model
A1 in out mymodel
.model mymodel custom_model(gain=2.0 offset=0.1)
* Digital component with bridge to analog world
A2 [digital_in] [digital_out] my_gate
.model my_gate d_and(rise_delay=1n fall_delay=1n)
* Analog-to-digital bridge (connects analog node to digital code model)
A3 [analog_node] [digital_node] adc1
.model adc1 adc_bridge(in_low=0.8 in_high=2.0)
```
The `A` device letter tells ngspice this is an XSPICE code model instance.
## File Structure for a New Code Model
Each code model needs two files in `src/xspice/icm/<category>/<model_name>/`:
### 1. `ifspec.ifs` — Interface Specification
Declares the model's name, ports, and parameters. Not C — it's a simple
declarative format that the `cmpp` preprocessor converts to C.
```
NAME_TABLE:
C_Function_Name: cm_my_driver
Spice_Model_Name: my_driver
Description: "Half-bridge gate driver model"
PORT_TABLE:
Port_Name: hin lin ho lo
Description: "high in" "low in" "high out" "low out"
Direction: in in out out
Default_Type: d d v v
Allowed_Types: [d] [d] [v] [v]
Vector: no no no no
Vector_Bounds: - - - -
Null_Allowed: no no no no
PARAMETER_TABLE:
Parameter_Name: vcc dead_time
Description: "supply volts" "dead time seconds"
Data_Type: real real
Default_Value: 12.0 100e-9
Limits: [0.1 1000] [0 -]
Vector: no no
Vector_Bounds: - -
Null_Allowed: yes yes
```
### 2. `cfunc.mod` — C Implementation
Plain C with XSPICE macros for accessing ports/parameters. This is where
your component's behavior goes.
```c
#include <math.h>
void cm_my_driver(ARGS)
{
/* Read parameters */
double vcc = PARAM(vcc);
double dead_time = PARAM(dead_time);
/* Read digital input states */
Digital_State_t hin = INPUT_STATE(hin);
Digital_State_t lin = INPUT_STATE(lin);
/* Compute outputs */
if (INIT) {
/* First call — initialize state */
OUTPUT(ho) = 0.0;
OUTPUT(lo) = 0.0;
} else {
/* Normal operation */
if (hin == ONE)
OUTPUT(ho) = vcc;
else
OUTPUT(ho) = 0.0;
if (lin == ONE)
OUTPUT(lo) = vcc;
else
OUTPUT(lo) = 0.0;
}
/* Set output delay */
OUTPUT_DELAY(ho) = dead_time;
OUTPUT_DELAY(lo) = dead_time;
}
```
## Key XSPICE Macros
| Macro | Purpose |
|-------|---------|
| `ARGS` | Function signature placeholder (always use in the function declaration) |
| `INIT` | True on first call — use for initialization |
| `PARAM(name)` | Read a model parameter value |
| `INPUT(name)` | Read analog input voltage/current |
| `INPUT_STATE(name)` | Read digital input (ONE, ZERO, UNKNOWN) |
| `OUTPUT(name)` | Set analog output value |
| `OUTPUT_STATE(name)` | Set digital output state |
| `OUTPUT_DELAY(name)` | Set propagation delay for output |
| `OUTPUT_STRENGTH(name)` | Set digital output drive strength |
| `PORT_SIZE(name)` | Number of elements in a vector port |
| `PARTIAL(out, in)` | Set partial derivative (for analog convergence) |
| `STATIC_VAR(name)` | Access persistent state between timesteps |
| `T(n)` | Current time at call (n=0) or previous calls (n=1,2) |
| `cm_event_alloc(tag, size)` | Allocate event-driven state memory |
| `cm_event_get_ptr(tag, n)` | Get state pointer (n=0 current, n=1 previous) |
## Port Types
- **v** — analog voltage
- **i** — analog current
- **vd** — analog voltage differential
- **id** — analog current differential
- **d** — digital (ONE, ZERO, UNKNOWN)
## Analog-Digital Bridges
To connect analog and digital ports, use bridge models in the netlist:
```spice
* Analog voltage -> Digital state
A_bridge1 [v_node] [d_node] adc1
.model adc1 adc_bridge(in_low=0.8 in_high=2.0)
* Digital state -> Analog voltage
A_bridge2 [d_node] [v_node] dac1
.model dac1 dac_bridge(out_low=0.0 out_high=3.3)
```
## Verilog Co-Simulation (Future)
Once code models work, Verilog support follows:
1. Write your FPGA design in Verilog
2. Compile with Verilator: `verilator --cc design.v --exe`
3. Build the output into a shared library (.dll)
4. Use `d_cosim` in the netlist:
```spice
A1 [clk din] [dout pwm] cosim1
.model cosim1 d_cosim(simulation="my_fpga_design.dll")
```
ngspice loads the DLL and runs the compiled Verilog alongside the analog sim.
## Directory Layout for pyTesla Parts
```
src/xspice/icm/
├── analog/ # Built-in analog models (gain, limit, etc.)
├── digital/ # Built-in digital models (d_and, d_cosim, etc.)
├── pytesla/ # Our custom part library (new category)
│ ├── modpath.lst # List of model directories
│ ├── gate_driver/
│ │ ├── cfunc.mod
│ │ └── ifspec.ifs
│ ├── spark_gap/
│ │ ├── cfunc.mod
│ │ └── ifspec.ifs
│ └── ...
```
## Build Process
After adding a new code model:
1. Place `cfunc.mod` and `ifspec.ifs` in the appropriate directory
2. Add the model name to `modpath.lst`
3. Rebuild: `python build_mingw.py`
The CMake build will run `cmpp` to preprocess the files, compile
the generated C, and register the model with ngspice automatically.
(Note: the ICM build step in CMakeLists.txt is not yet implemented —
this document describes the target workflow once it is.)

242
build_cmpp.py

@ -0,0 +1,242 @@
"""Build the cmpp preprocessor and compile XSPICE .cm code model libraries."""
import subprocess
import os
import sys
import shutil
MINGW_BIN = r"C:\mingw64\bin"
NGSPICE_ROOT = r"C:\git\ngspice"
CMPP_SRC = os.path.join(NGSPICE_ROOT, "src", "xspice", "cmpp")
ICM_DIR = os.path.join(NGSPICE_ROOT, "src", "xspice", "icm")
INCLUDE_DIR = os.path.join(NGSPICE_ROOT, "src", "include")
CONFIG_H_DIR = os.path.join(NGSPICE_ROOT, "visualc", "src", "include")
BUILD_DIR = os.path.join(NGSPICE_ROOT, "build", "cmpp_build")
CM_OUTPUT_DIR = os.path.join(NGSPICE_ROOT, "pyngspice", "codemodels")
CMPP_EXE = os.path.join(BUILD_DIR, "cmpp.exe")
def get_env():
env = os.environ.copy()
env["PATH"] = MINGW_BIN + ";" + env["PATH"]
return env
def run(cmd, cwd=None):
print(f" > {' '.join(cmd)}")
r = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd, env=get_env())
if r.stdout.strip():
for line in r.stdout.strip().split('\n')[:20]:
print(f" {line}")
if r.stderr.strip():
lines = r.stderr.strip().split('\n')
# Show first 10 and last 30 lines of errors
if len(lines) > 40:
for line in lines[:5]:
print(f" {line}")
print(f" ... ({len(lines) - 35} lines omitted) ...")
for line in lines[-30:]:
print(f" {line}")
else:
for line in lines:
print(f" {line}")
if r.returncode != 0:
print(f" FAILED (rc={r.returncode})")
return False
return True
def build_cmpp():
"""Build the cmpp preprocessor tool."""
print("=== Step 1: Building cmpp preprocessor ===")
os.makedirs(BUILD_DIR, exist_ok=True)
if os.path.exists(CMPP_EXE):
print(f" cmpp.exe already exists, skipping")
return True
cmpp_sources = [
"main.c", "file_buffer.c", "pp_ifs.c", "pp_lst.c", "pp_mod.c",
"read_ifs.c", "util.c", "writ_ifs.c", "ifs_yacc.c", "mod_yacc.c",
"ifs_lex.c", "mod_lex.c"
]
cmd = [
"gcc", "-o", CMPP_EXE,
] + [os.path.join(CMPP_SRC, f) for f in cmpp_sources] + [
f"-I{CMPP_SRC}", f"-I{INCLUDE_DIR}",
"-lshlwapi"
]
if not run(cmd):
return False
print(f" OK: {CMPP_EXE}")
return True
def preprocess_category(category):
"""Run cmpp on a code model category (analog, digital, etc.)."""
print(f"\n=== Step 2: Preprocessing '{category}' code models ===")
src_dir = os.path.join(ICM_DIR, category)
out_dir = os.path.join(BUILD_DIR, category)
# Copy the category directory to build dir for cmpp to work in
if os.path.exists(out_dir):
shutil.rmtree(out_dir)
shutil.copytree(src_dir, out_dir)
# Step 2a: Generate registration headers from modpath.lst
print(f" Generating registration headers...")
env = get_env()
env["CMPP_IDIR"] = src_dir
env["CMPP_ODIR"] = out_dir
r = subprocess.run([CMPP_EXE, "-lst"], capture_output=True, text=True,
cwd=out_dir, env=env)
if r.returncode != 0:
print(f" cmpp -lst FAILED: {r.stderr}")
return None
print(f" OK: cminfo.h, cmextrn.h generated")
# Step 2b: Read modpath.lst to get model names
modpath = os.path.join(src_dir, "modpath.lst")
with open(modpath) as f:
models = [line.strip() for line in f if line.strip()]
# Step 2c: Preprocess each model's .ifs and .mod files
for model in models:
model_src = os.path.join(src_dir, model)
model_out = os.path.join(out_dir, model)
if not os.path.isdir(model_src):
print(f" SKIP: {model} (directory not found)")
continue
# Preprocess ifspec.ifs -> ifspec.c
env_mod = get_env()
env_mod["CMPP_IDIR"] = model_src
env_mod["CMPP_ODIR"] = model_out
r = subprocess.run([CMPP_EXE, "-ifs"], capture_output=True, text=True,
cwd=model_out, env=env_mod)
if r.returncode != 0:
print(f" FAIL: {model}/ifspec.ifs: {r.stderr}")
return None
# Preprocess cfunc.mod -> cfunc.c
r = subprocess.run([CMPP_EXE, "-mod"], capture_output=True, text=True,
cwd=model_out, env=env_mod)
if r.returncode != 0:
print(f" FAIL: {model}/cfunc.mod: {r.stderr}")
return None
print(f" OK: {len(models)} models preprocessed")
return models
# Category-specific extra source files and include paths
CATEGORY_EXTRAS = {
"tlines": {
"extra_sources": [
os.path.join(NGSPICE_ROOT, "src", "xspice", "tlines", "tline_common.c"),
os.path.join(NGSPICE_ROOT, "src", "xspice", "tlines", "msline_common.c"),
],
"extra_includes": [
os.path.join(NGSPICE_ROOT, "src", "xspice", "tlines"),
],
},
}
def compile_cm(category, models):
"""Compile preprocessed code models into a .cm DLL."""
print(f"\n=== Step 3: Compiling '{category}.cm' DLL ===")
src_dir = os.path.join(ICM_DIR, category)
out_dir = os.path.join(BUILD_DIR, category)
os.makedirs(CM_OUTPUT_DIR, exist_ok=True)
# Collect all .c files to compile
c_files = []
# dlmain.c (the DLL entry point with exports)
dlmain = os.path.join(ICM_DIR, "dlmain.c")
c_files.append(dlmain)
# Each model's cfunc.c and ifspec.c
for model in models:
model_dir = os.path.join(out_dir, model)
cfunc_c = os.path.join(model_dir, "cfunc.c")
ifspec_c = os.path.join(model_dir, "ifspec.c")
if os.path.exists(cfunc_c) and os.path.exists(ifspec_c):
c_files.append(cfunc_c)
c_files.append(ifspec_c)
else:
print(f" SKIP: {model} (generated .c files missing)")
# UDN (user-defined node) types — udnfunc.c files from udnpath.lst
udnpath = os.path.join(src_dir, "udnpath.lst")
if os.path.exists(udnpath):
with open(udnpath) as f:
udns = [line.strip() for line in f if line.strip()]
for udn in udns:
udnfunc = os.path.join(src_dir, udn, "udnfunc.c")
if os.path.exists(udnfunc):
c_files.append(udnfunc)
print(f" UDN: {udn}/udnfunc.c")
# Also need dstring.c for some models that use DS_CREATE
dstring_c = os.path.join(NGSPICE_ROOT, "src", "misc", "dstring.c")
if os.path.exists(dstring_c):
c_files.append(dstring_c)
# Category-specific extra sources
extras = CATEGORY_EXTRAS.get(category, {})
for src in extras.get("extra_sources", []):
if os.path.exists(src):
c_files.append(src)
print(f" EXTRA: {os.path.basename(src)}")
cm_file = os.path.join(CM_OUTPUT_DIR, f"{category}.cm")
cmd = [
"gcc", "-shared", "-o", cm_file,
] + c_files + [
f"-I{INCLUDE_DIR}",
f"-I{CONFIG_H_DIR}",
f"-I{out_dir}", # For cminfo.h, cmextrn.h
f"-I{ICM_DIR}", # For dlmain.c includes
]
# Category-specific extra include paths
for inc in extras.get("extra_includes", []):
cmd.append(f"-I{inc}")
cmd += [
"-DHAS_PROGREP",
"-DXSPICE",
"-DSIMULATOR",
"-DNG_SHARED_BUILD",
"-DNGSPICEDLL",
"-Wall", "-Wno-unused-variable", "-Wno-unused-function",
"-Wno-sign-compare", "-Wno-maybe-uninitialized",
"-Wno-implicit-function-declaration", # GCC 15 makes this an error; some models missing stdlib.h
]
if not run(cmd):
return False
size_kb = os.path.getsize(cm_file) // 1024
print(f" OK: {cm_file} ({size_kb} KB)")
return True
def build_category(category):
"""Full pipeline: preprocess + compile a code model category."""
models = preprocess_category(category)
if models is None:
return False
return compile_cm(category, models)
if __name__ == "__main__":
categories = sys.argv[1:] if len(sys.argv) > 1 else ["analog"]
if not build_cmpp():
sys.exit(1)
for cat in categories:
if not build_category(cat):
print(f"\nFailed to build {cat}.cm")
sys.exit(1)
print(f"\n=== Done! Code models in: {CM_OUTPUT_DIR} ===")

BIN
pyngspice/codemodels/analog.cm

BIN
pyngspice/codemodels/digital.cm

BIN
pyngspice/codemodels/spice2poly.cm

BIN
pyngspice/codemodels/table.cm

BIN
pyngspice/codemodels/tlines.cm

BIN
pyngspice/codemodels/xtradev.cm

BIN
pyngspice/codemodels/xtraevt.cm

Loading…
Cancel
Save