Chipify supports user plugins that extend the GUI and report output without modifying the core application. Plugins live as plain Python files in a designated directory and are discovered automatically at runtime.
By default Chipify looks for plugins in:
~/.chipify/plugins/
Override this by setting the environment variable CHIPIFY_PLUGINS to any directory path:
export CHIPIFY_PLUGINS=/path/to/my/plugins
chipify gui
Create the directory if it does not exist:
mkdir -p ~/.chipify/plugins
Every .py file in that directory is imported when the first plugin is requested. Each file may define multiple plugin classes. Subdirectories are not scanned.
PlotPlugin adds a custom entry to the plot-mode dropdown in the GUI.
Three plot modes ship as built-in PlotPlugins: "QQ Plot (Normality)", "ECDF + Spec Limits", and "Yield vs Spec Curve" (see chipify/plot_plugins/). They register through this same interface — a user plugin whose name matches a built-in overrides the built-in.
| Attribute | Type | Required | Description |
|---|---|---|---|
name |
str |
Yes | Label shown in the mode selector. Must be unique across all loaded plugins. A user plugin whose name matches a built-in mode overrides the built-in. |
api_version |
str |
No | Plugin API version. Default "1". |
def draw(self, fig, ax, valid_df, stim) -> None:
| Parameter | Type | Description |
|---|---|---|
fig |
matplotlib.figure.Figure |
Blank figure. Use when you need figure-level operations (colorbars, fig.clf()). |
ax |
matplotlib.axes.Axes |
The axes to draw onto. |
valid_df |
pd.DataFrame |
Simulation results filtered to successful runs only (sim_error == 'None'). |
stim |
chipify.util.Stimuli |
Parsed YAML test specification (see Data reference). |
The method must not return a value. Any exception raised inside draw() is caught, logged, and displayed as red text on the plot — it will never crash the application.
draw() is called each time the user switches to your plot mode or clicks Refresh. A fresh fig and ax are provided on every call; do not cache them.
# ~/.chipify/plugins/my_plot.py
import numpy as np
from chipify.plugin_loader import PlotPlugin
class GainHistogram(PlotPlugin):
name = "Gain Histogram"
def draw(self, fig, ax, valid_df, stim):
if "gain_db" not in valid_df.columns:
ax.text(0.5, 0.5, "Column 'gain_db' not found",
ha="center", va="center", transform=ax.transAxes)
return
ax.hist(valid_df["gain_db"].dropna(), bins=30, color="#3484F0", edgecolor="white")
ax.set_xlabel("Gain (dB)")
ax.set_ylabel("Count")
ax.set_title("Gain Distribution")
ReportPlugin appends a custom section to Markdown and/or PDF reports generated by Chipify.
| Attribute | Type | Required | Description |
|---|---|---|---|
name |
str |
Yes | Section identifier. Also used as the Markdown heading if you do not include one yourself. |
api_version |
str |
No | Plugin API version. Default "1". |
render_mddef render_md(self, valid_df, stim) -> str:
| Parameter | Type | Description |
|---|---|---|
valid_df |
pd.DataFrame |
Successful simulation rows only. |
stim |
chipify.util.Stimuli |
Parsed YAML test specification. |
Return a Markdown string. It is appended verbatim after the standard report body. Raise an exception to skip this section — the error is logged and the rest of the report continues normally.
render_pdf (optional)def render_pdf(self, pdf, valid_df, stim) -> None:
| Parameter | Type | Description |
|---|---|---|
pdf |
matplotlib.backends.backend_pdf.PdfPages |
Open PDF file. Call pdf.savefig(fig) to append pages. |
valid_df |
pd.DataFrame |
Successful simulation rows only. |
stim |
chipify.util.Stimuli |
Parsed YAML test specification. |
If not overridden, no PDF page is added.
# ~/.chipify/plugins/my_report.py
import pandas as pd
from chipify.plugin_loader import ReportPlugin
class YieldSummary(ReportPlugin):
name = "Extended Yield Summary"
def render_md(self, valid_df, stim) -> str:
total = len(valid_df)
if total == 0:
return "## Extended Yield Summary\n\nNo valid runs.\n"
pass_cols = [c for c in valid_df.columns if c.endswith("_overall_pass")]
lines = ["## Extended Yield Summary\n"]
for col in pass_cols:
tb = col.replace("_overall_pass", "")
pct = valid_df[col].mean() * 100
lines.append(f"- **{tb}**: {pct:.1f}% pass")
return "\n".join(lines) + "\n"
def render_pdf(self, pdf, valid_df, stim) -> None:
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
pass_cols = [c for c in valid_df.columns if c.endswith("_overall_pass")]
yields = [valid_df[c].mean() * 100 for c in pass_cols]
labels = [c.replace("_overall_pass", "") for c in pass_cols]
ax.bar(labels, yields)
ax.set_ylabel("Yield (%)")
ax.set_title("Per-Testbench Yield")
pdf.savefig(fig)
plt.close(fig)
ExpressionPlugin injects a named derived column into the simulation results, equivalent to entries in custom_equations in settings.json but distributable as code.
| Attribute | Type | Required | Description |
|---|---|---|---|
name |
str |
Yes | Output column name added to the result DataFrame. |
expression |
str |
Yes | Expression string evaluated via SafeEvaluator. |
api_version |
str |
No | Plugin API version. Default "1". |
Expressions are evaluated row-by-row against the result DataFrame using the same sandboxed SafeEvaluator that backs custom_equations. The evaluation context includes:
v(out) are automatically sanitised to v_out_).sin, cos, log, log10, log2, exp, abs, sqrt, pi, inf, nan, clip, where, etc.NaN or raises is left as NaN; it does not affect other rows.Expressions must not call import, open, exec, eval, or access dunder attributes. The sandbox blocks all of these.
Expression plugins are applied automatically at the end of apply_scalar_equations(), after all explicitly configured custom_equations. If a plugin column already exists in the DataFrame, it is overwritten.
# ~/.chipify/plugins/rf_metrics.py
from chipify.plugin_loader import ExpressionPlugin
class SNR(ExpressionPlugin):
name = "snr_db"
expression = "20 * log10(abs(signal / noise))"
class PowerEfficiency(ExpressionPlugin):
name = "efficiency_pct"
expression = "p_out / p_supply * 100"
ExporterPlugin adds a new file format to the “💾 Export” menu attached to every plot (histograms, advanced analytics, transient, and each cell in the Multi-Plot Dashboard).
PNG and SVG ship as built-in exporters. To support a new format — TIFF, EPS, WebP, JPG, PDF-per-plot, anything Matplotlib’s savefig() understands — drop one of these plugins into the plugin directory.
| Attribute | Type | Required | Description |
|---|---|---|---|
name |
str |
Yes | Label shown in the per-plot Export menu. Must be unique across all loaded exporters. A user plugin whose name matches a built-in ("PNG Image", "SVG Vector") overrides the built-in. |
extension |
str |
Yes | File extension without a leading dot, e.g. "png", "svg", "tiff". Used by the save dialog. |
description |
str |
No | Optional one-line description. Reserved for future tooltips. |
api_version |
str |
No | Plugin API version. Default "1". |
def export(self, fig, out_path, *, theme=None) -> str:
| Parameter | Type | Description |
|---|---|---|
fig |
matplotlib.figure.Figure |
The live figure the user wants to save. Do not call plt.close(fig) — the GUI is still showing it. |
out_path |
str |
Absolute path chosen via the file dialog. Write here. |
theme |
dict \| None |
Active plot palette (bg, fg, grid, spine, accent, …). Built-in exporters preserve fig.get_facecolor() and ignore it; custom exporters may consult it for re-theming on save. |
Return the path actually written (almost always the same as out_path).
export() is called once per click. The exporter class is instantiated fresh each time, so per-instance caches do not persist across saves. If your exporter raises, the error is caught and shown to the user via a message box — no crash.
# ~/.chipify/plugins/extra_exporters.py
from chipify.plugin_loader import ExporterPlugin
class WebPExporter(ExporterPlugin):
name = "WebP Image"
extension = "webp"
def export(self, fig, out_path, *, theme=None):
fig.savefig(out_path, format="webp", dpi=200, bbox_inches="tight")
return out_path
class TIFFExporter(ExporterPlugin):
name = "TIFF (300 DPI)"
extension = "tiff"
def export(self, fig, out_path, *, theme=None):
fig.savefig(out_path, format="tiff", dpi=300, bbox_inches="tight",
facecolor=fig.get_facecolor())
return out_path
After restarting Chipify both formats appear in the Export menu of every plot.
TabPlugin adds a whole tab to the Chipify main window — the most powerful plugin type. The plugin builds its own UI (customtkinter / tkinter widgets) into a frame the host provides, and accesses simulation data exclusively through a PluginContext facade: results, datasheet specs, rendered SPICE netlists, history runs, waveforms, and a thread bridge for calling external APIs without freezing the GUI.
[!WARNING]
TabPluginis legacy. The CustomTkinter GUI has been removed; the desktop GUI is now PySide6 (Qt) and loadsQtTabPlugin(Qt widgets) only — CustomTkinterTabPlugins are detected and skipped with a warning. The data-facing contract is identical: samePluginContext, same lifecycle methods. To port, change the base class toQtTabPluginand build Qt widgets intoparent(aQWidget). The other plugin types (PlotPlugin,ReportPlugin,ExpressionPlugin,ExporterPlugin) are pure-data and work unchanged. This section is kept for reference and porting — see QtTabPlugin.
| Attribute | Type | Required | Description |
|---|---|---|---|
name |
str |
Yes | Tab title shown in the main window. Must be unique and must not collide with a built-in tab name (Datasheet Editor, Measurements, Histograms, Advanced Analytics, Custom Equations, Transient). |
api_version |
str |
No | Plugin API version. Default "1". |
build (required)def build(self, parent, context) -> None:
| Parameter | Type | Description |
|---|---|---|
parent |
tk frame | The empty tab content area. Build your widgets into it (customtkinter recommended; plain tkinter works too). |
context |
PluginContext |
The data facade — see PluginContext reference. The same instance is passed to every lifecycle call, so you may keep a reference. |
Called exactly once at application startup. If build() raises, the host replaces the tab content with an error panel naming your plugin and the exception — the application keeps running.
on_data_changed (optional)def on_data_changed(self, context) -> None:
Called on the Tk main thread whenever new simulation results are loaded or the active datasheet changes (a sweep finished, a history run was selected, custom equations were applied). Refresh your widgets from context here.
on_show (optional)def on_show(self, context) -> None:
Called when the user switches to your tab. Useful for lazy refreshes.
on_close (optional)def on_close(self) -> None:
Called once at application shutdown. Release external resources here (network sessions, subprocesses, open files).
reload_plugins() notwithstanding).out/chipify.log) and skipped for that call; it never crashes the app.context.run_async(...).# ~/.chipify/plugins/run_counter_tab.py
import customtkinter as ctk
from chipify.plugin_loader import TabPlugin
class RunCounter(TabPlugin):
name = "Run Counter"
def build(self, parent, context):
theme = context.theme()
self._lbl = ctk.CTkLabel(parent, text="No data loaded yet.",
text_color=theme["text_muted"])
self._lbl.pack(pady=40)
self.on_data_changed(context)
def on_data_changed(self, context):
s = context.summary()
self._lbl.configure(
text=f"{s['total']} runs — {s['passed']} passing ({s['yield_pct']}%)")
A complete, runnable example ships with the repository at examples/plugins/run_info_tab.py — copy it into your plugin directory as a starting point.
QtTabPlugin is the supported tab-plugin base, loaded by the Qt GUI (chipify, or its chipify-qt alias). The contract is identical to the legacy TabPlugin except that build(parent, context) receives a QWidget instead of a Tk frame — lay your Qt widgets into it (e.g. with a QVBoxLayout).
Everything else is the same as TabPlugin:
name (required, unique) and optional api_version.build(parent, context) (required), and optional on_data_changed(context), on_show(context), on_close().context is the very same PluginContext — results(), specs(), netlists(), history_runs(), waveforms(), subscribe_data_changed(), and run_async() all behave exactly as documented. run_async’s completion callbacks are marshalled onto the Qt GUI thread, so it remains safe to update widgets from on_done.TabPlugin: built once at startup, all calls on the GUI thread and exception-guarded (a raising build() shows an error panel; other tabs are unaffected).[!NOTE] Do not block the GUI thread. Anything slower than ~50 ms (HTTP/LLM calls, large file reads) belongs in
context.run_async(...).
# ~/.chipify/plugins/run_counter_qt.py
from PySide6.QtWidgets import QLabel, QVBoxLayout
from chipify.plugin_loader import QtTabPlugin
class RunCounter(QtTabPlugin):
name = "Run Counter"
def build(self, parent, context):
self._lbl = QLabel("No data loaded yet.", parent)
QVBoxLayout(parent).addWidget(self._lbl)
self.on_data_changed(context)
def on_data_changed(self, context):
s = context.summary()
self._lbl.setText(
f"{s['total']} runs — {s['passed']} passing ({s['yield_pct']}%)")
Porting an existing TabPlugin: change the base class to QtTabPlugin, swap CustomTkinter widget construction for Qt (ctk.CTkLabel(parent, ...) → QLabel(..., parent) added to a layout), and leave all context.* calls untouched.
The context object handed to every TabPlugin call is the only supported way to read application data. Everything it returns is a defensive copy or plain serializable data — mutating it never affects the application — and its API is stable across chipify releases (context.api_version).
| Member | Returns |
|---|---|
results(valid_only=False) |
Copy of the loaded result DataFrame, or None. valid_only=True keeps only rows with sim_error == 'None'. |
summary() |
{"total", "crashes", "valid", "passed", "yield_pct"} for the loaded run. |
| Member | Returns |
|---|---|
datasheet_path |
Absolute path of the selected datasheet YAML, or None. |
datasheet_text() |
Raw YAML text (comments included); "" when none. |
specs() |
JSON-serializable dict of the parsed datasheet: {"datasheet", "parameters", "tests": {tb: {"measurements": {name: {min, typ, max}}, "signals", "measure"}}}. Designed to be dumped straight into an LLM prompt. |
| Member | Returns |
|---|---|
netlists() |
{tb_name: rendered SPICE netlist text}. Available after at least one simulation in the project; {} before. |
testbench_paths() |
{tb_name: absolute path to the tb/*.sch schematic} for files that exist. |
| Member | Returns |
|---|---|
history_runs() |
Run labels, newest first (same labels as the History dropdown). |
load_run(label) |
DataFrame for one history run, or None. |
run_meta(label) |
Sidecar metadata dict (yaml, duration_s, global_yield, …); {} when absent. |
analysis_kinds() |
Which of transient / dc / ac have waveform data for the loaded run. |
waveforms(kind, run_ids=None) |
Combined per-run waveform DataFrame (run_id + time/sweep/frequency + signals). Defaults to all valid runs — pass explicit run_ids to limit the load. |
| Member | Returns |
|---|---|
dirs |
{"in_dir", "out_dir", "tb_dir", "work_dir"} absolute paths. |
theme() |
Active palette: plot keys (bg, fg, grid, spine, legend_*, accent) plus widget tokens (window_bg, panel, card_bg, card_border, text_muted, danger). Use these so your tab matches the app theme. |
chipify_version / api_version |
Version strings. |
| Member | Behaviour |
|---|---|
subscribe_data_changed(callback) |
Calls callback() (no args, Tk main thread) on data changes. Exceptions in your callback are logged, never raised. Auto-disconnected at shutdown. Most plugins should just implement on_data_changed instead. |
run_async(work, on_done=None, on_error=None) |
Runs work() on a background daemon thread; delivers on_done(result) / on_error(exception) back on the Tk main thread. Threading rule: work must never touch widgets; mutate UI only inside on_done / on_error. This is the supported way to call external APIs. |
set_status(text, color="#3484F0") |
Shows a message in the application status bar. |
The intended flagship use of TabPlugin: a tab that sends the design’s specs, results, and SPICE netlists to an LLM and shows the analysis. The skeleton below uses the official anthropic Python SDK (pip install anthropic, key from the ANTHROPIC_API_KEY environment variable) — note how all data gathering happens through context, and the API call runs through run_async so the GUI never freezes.
# ~/.chipify/plugins/ai_review_tab.py
import json
import customtkinter as ctk
from chipify.plugin_loader import TabPlugin
class ClaudeReviewTab(TabPlugin):
name = "AI Review"
def build(self, parent, context):
theme = context.theme()
bar = ctk.CTkFrame(parent, fg_color="transparent")
bar.pack(fill="x", padx=10, pady=10)
self._btn = ctk.CTkButton(bar, text="Analyze results with Claude",
command=lambda: self._analyze(context))
self._btn.pack(side="left")
self._out = ctk.CTkTextbox(parent, wrap="word")
self._out.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self._out.insert("end", "Run a simulation, then click Analyze.")
def _analyze(self, context):
df = context.results(valid_only=True)
if df is None or df.empty:
context.set_status("AI Review: no results loaded", "#e74c3c")
return
# 1. Gather the payload on the main thread (cheap, copies only).
payload = {
"specs": context.specs(), # JSON-ready dict
"result_statistics": df.describe().to_string(),
"yield": context.summary(),
"netlists": context.netlists(), # {tb: spice text}
}
self._btn.configure(state="disabled")
self._out.delete("1.0", "end")
self._out.insert("end", "Asking Claude…\n")
# 2. Call the API off-thread; 3. render the answer back on the UI.
context.run_async(
lambda: self._ask_claude(payload),
on_done=self._show_answer,
on_error=lambda exc: self._show_answer(f"Request failed: {exc}"),
)
@staticmethod
def _ask_claude(payload) -> str:
# Runs on a worker thread — no widget access here!
import anthropic
client = anthropic.Anthropic() # key from ANTHROPIC_API_KEY
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
thinking={"type": "adaptive"},
system=("You are an analog IC design reviewer. You receive a "
"chipify datasheet spec, Monte-Carlo/corner results, and "
"SPICE netlists. Point out failing specs, marginal Cpk, "
"and likely design causes. Be concrete."),
messages=[{
"role": "user",
"content": json.dumps(payload, default=str),
}],
)
return next((b.text for b in response.content if b.type == "text"), "")
def _show_answer(self, text):
self._btn.configure(state="normal")
self._out.delete("1.0", "end")
self._out.insert("end", str(text))
Tips for production use:
run_async-style after() posts, or buffer and show the final message.context.specs() and summary() are small; netlists() and full DataFrames can be large — consider df.describe() / sampling before sending, and count tokens with client.messages.count_tokens() if needed.valid_df — result DataFrameEach row is one simulation run. Only successful runs are included (sim_error == 'None').
Common columns:
| Column pattern | Description |
|---|---|
| Sweep parameter names | One column per parameter defined in parameters: in the YAML (e.g. temp, vdd). |
| Measurement names | One column per values: entry in each testbench (e.g. gain_db, idd_ua). |
<tb_name>_overall_pass |
Boolean: whether all specs passed for that testbench. |
global_pass |
Boolean: all testbenches passed. |
sim_error |
Always "None" in valid_df. |
simulation_duration_s_total |
Wall-clock seconds for the full run (first row only). |
Custom equations (from settings.json or ExpressionPlugin) append additional columns after the simulation columns.
stim — parsed YAML specificationstim is a chipify.util.Stimuli object. Key attributes:
| Attribute | Type | Description |
|---|---|---|
stim.params |
dict[str, list] |
Parameter name → list of sweep values. |
stim.tests |
list[Test] |
One Test per testbench block in the YAML. |
stim.tests[i].tb_path |
str |
Testbench identifier (used as column prefix). |
stim.tests[i].value_lst |
list[Value] |
Measurement specs (Value.name, Value.vmin, Value.vtyp, Value.vmax). |
stim.tests[i].analyses |
list[Analysis] |
Declared signal captures (.kind ∈ transient/dc/ac, .signals). |
stim.tests[i].measure |
dict[str, str] |
measure: expressions from the YAML. |
TabPlugins should prefer context.specs() over walking stim directly — it returns the same information as a stable, JSON-serializable dict.
Every plugin class may declare:
api_version = "1"
This is currently informational. When a future Chipify release introduces breaking changes to the plugin interface it will increment the current API version. Plugins targeting an older version will log a warning on load but will continue to function as long as the old interface remains compatible.
There is no hard rejection based on api_version — warnings only.
| Situation | What happens |
|---|---|
| Syntax error in plugin file | File is skipped. Full traceback logged at WARNING level. Other plugins still load. |
draw() raises |
Exception caught. Error message rendered on the plot in red. |
render_md() raises |
Section skipped. Error logged. Rest of the report is unaffected. |
render_pdf() raises |
PDF page skipped. Error logged. |
ExpressionPlugin expression fails |
Column set to NaN for all rows. Warning logged. |
ExporterPlugin.export() raises |
Save is cancelled; error message shown in a dialog. App remains running. |
TabPlugin.build() raises |
The plugin’s tab shows an error panel with the exception; full traceback logged. Other tabs unaffected. |
TabPlugin.on_data_changed() / on_show() / on_close() raises |
Call skipped for that plugin; exception logged. |
A run_async worker or callback raises |
Logged; on_error invoked for worker failures. The GUI never sees the exception. |
TabPlugin.name collides with a built-in tab |
Plugin skipped. Warning logged. |
Two plugins with the same name |
Second plugin is skipped. Warning logged. For exporters, a user plugin overrides a built-in of the same name. |
Chipify never crashes due to a bad plugin.
from chipify.plugin_loader import list_plugins
import pprint
pprint.pprint(list_plugins())
Output example:
{
'expression': [{'api_version': '1', 'name': 'snr_db'},
{'api_version': '1', 'name': 'efficiency_pct'}],
'exporter': [{'api_version': '1', 'name': 'PNG Image'},
{'api_version': '1', 'name': 'SVG Vector'},
{'api_version': '1', 'name': 'WebP Image'}],
'plot': [{'api_version': '1', 'name': 'QQ Plot (Normality)'},
{'api_version': '1', 'name': 'ECDF + Spec Limits'},
{'api_version': '1', 'name': 'Yield vs Spec Curve'},
{'api_version': '1', 'name': 'Gain Histogram'}],
'report': [{'api_version': '1', 'name': 'Extended Yield Summary'}],
'tab': [{'api_version': '1', 'name': 'AI Review'}],
}
You can also call reload_plugins() to force re-discovery without restarting the application (useful during plugin development):
from chipify.plugin_loader import reload_plugins
reload_plugins()
To check the active plugin directory:
from chipify.plugin_loader import plugin_dir
print(plugin_dir())
Plugin files are executed as full Python with the same privileges as the Chipify process. Treat plugin files the same way you would treat shell scripts — only load plugins from sources you trust. The CHIPIFY_PLUGINS environment variable can point to any directory, so be careful when running Chipify in shared environments.
The ExpressionPlugin.expression string is evaluated inside the SafeEvaluator sandbox (asteval) which blocks import, open, exec, and dunder access. It is safe to load expressions from untrusted sources. The plugin file itself, however, is not sandboxed.