app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Restore 25 files to their state before PR #61: the wiki's pr revert left them truncated to the diff's context
4f3a0f4
8d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
//! SPICE for kicad_export_spice and kicad_simulate (wiki #89), the pure half: the netlist KiCad exports
//! (`kicad-cli sch export netlist --format spice`) made ready to run with the analysis asked for, and
//! ngspice's vectors summarised for an AI (each probe downsampled, with its first, last, min and max; AC as
//! magnitude in dB and phase in degrees). The run itself is kicad_platform::ngspice, in a child process.
use serde_json::{json, Value};
/// The dot-commands that start an analysis.
const ANALYSES: &[&str] = &[".tran", ".ac", ".dc", ".op", ".noise", ".tf", ".disto", ".pz", ".sp"];
fn is_analysis(line: &str) -> bool {
let l = line.trim_start().to_lowercase();
ANALYSES.iter().any(|a| l == *a || l.starts_with(&format!("{a} ")))
}
/// What the exported netlist holds: its title, the analyses the schematic carries, its .include/.lib lines
/// and how many element lines.
pub fn describe(netlist: &str) -> Value {
let lines: Vec<&str> = netlist.lines().collect();
let title = lines.iter().find_map(|l| l.trim().strip_prefix(".title").map(|t| t.trim().to_string()));
let analyses: Vec<&str> = lines.iter().map(|l| l.trim()).filter(|l| is_analysis(l)).collect();
let includes: Vec<&str> = lines.iter().map(|l| l.trim()).filter(|l| { let x = l.to_lowercase(); x.starts_with(".include") || x.starts_with(".lib") }).collect();
let elements = lines.iter().filter(|l| { let t = l.trim_start(); !t.is_empty() && !t.starts_with('.') && !t.starts_with('*') && !t.starts_with('+') }).count();
json!({"title": title, "analyses": analyses, "includes": includes, "elements": elements, "lines": lines.len()})
}
/// A number given as JSON or as SPICE text ("100p", "4k7" is not SPICE: "4.7k", "1meg").
pub fn spice_num(v: &Value) -> Option<f64> {
if let Some(f) = v.as_f64() { return Some(f); }
let t = v.as_str()?.trim().to_ascii_lowercase();
let split = t.find(|c: char| c.is_ascii_alphabetic() && c != 'e').unwrap_or(t.len());
// "1meg" / "1e-9": the mantissa stops at the first letter that is not an exponent
let (num, suf) = match t[..split].parse::<f64>() {
Ok(n) => (n, &t[split..]),
Err(_) => return None,
};
let m = if suf.starts_with("meg") { 1e6 } else {
match suf.chars().next() { None => 1.0, Some('t') => 1e12, Some('g') => 1e9, Some('k') => 1e3, Some('m') => 1e-3, Some('u') => 1e-6, Some('n') => 1e-9, Some('p') => 1e-12, Some('f') => 1e-15, _ => return None }
};
Some(num * m)
}
/// A value the way a person reads it: 300, 4.7k, 100p, 1meg.
pub fn spice_label(v: f64) -> String {
let a = v.abs();
let (d, s) = if a == 0.0 { (1.0, "") } else if a >= 1e12 { (1e12, "T") } else if a >= 1e9 { (1e9, "G") } else if a >= 1e6 { (1e6, "meg") } else if a >= 1e3 { (1e3, "k") } else if a >= 1.0 { (1.0, "") }
else if a >= 1e-3 { (1e-3, "m") } else if a >= 1e-6 { (1e-6, "u") } else if a >= 1e-9 { (1e-9, "n") } else if a >= 1e-12 { (1e-12, "p") } else { (1e-15, "f") };
let x = ((v / d) * 1e4).round() / 1e4;
format!("{x}{s}")
}
/// The values of a sweep {start, stop, step, scale?}: lin steps by `step`; dec and oct take `step` points per
/// decade or octave. At most `cap` values.
pub fn sweep_values(start: f64, stop: f64, step: f64, scale: &str, cap: usize) -> Result<Vec<f64>, String> {
let mut out = Vec::new();
match scale {
"lin" => {
if step == 0.0 || (stop - start) * step < 0.0 { return Err("step must be non-zero and run from start toward stop".into()); }
let n = ((stop - start) / step + 1e-9).floor() as i64;
if n + 1 > cap as i64 { return Err(format!("{} runs; at most {cap}", n + 1)); }
for k in 0..=n { out.push(start + k as f64 * step); }
}
"dec" | "oct" => {
if start <= 0.0 || stop < start || step < 1.0 { return Err(format!("{scale} needs 0 < start <= stop and step >= 1 point per {}", if scale == "dec" { "decade" } else { "octave" })); }
let base: f64 = if scale == "dec" { 10.0 } else { 2.0 };
let n = ((stop / start).log(base) * step + 1e-9).floor() as i64;
if n + 1 > cap as i64 { return Err(format!("{} runs; at most {cap}", n + 1)); }
for k in 0..=n { out.push(start * base.powf(k as f64 / step)); }
}
o => return Err(format!("scale is lin, dec or oct, not {o}")),
}
Ok(out)
}
/// The element KiCad named for a designator: R4 itself, or with its SPICE letter in front (XU1).
fn element<'a>(netlist: &'a str, reference: &str) -> Option<&'a str> {
let r = reference.to_ascii_uppercase();
netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+'])).find(|t| {
let n = t.split_whitespace().next().unwrap_or("").to_ascii_uppercase();
n == r || (n.len() == r.len() + 1 && n.ends_with(r.as_str()))
})
}
/// The ngspice command that sets one sweep value: a part's value ("R4") with alter, a model parameter ("Q1.BF")
/// with altermod on the model the part uses. `models` are the .model names the netlist and its includes define.
pub fn sweep_command(netlist: &str, parameter: &str, value: f64, models: &[String]) -> Result<String, String> {
let (part, param) = match parameter.split_once('.') { Some((p, m)) => (p, Some(m)), None => (parameter, None) };
let line = element(netlist, part).ok_or_else(|| format!("{part} is not in the netlist (a part without a model, or no such designator)"))?;
let name = line.split_whitespace().next().unwrap_or(part);
match param {
None => Ok(format!("alter {name} = {value:e}")),
Some(p) => {
let model = line.split_whitespace().skip(1).find(|t| models.iter().any(|m| m.eq_ignore_ascii_case(t)))
.ok_or_else(|| format!("{part}'s model is not one the netlist defines (.model), so {p} cannot be stepped"))?;
Ok(format!("altermod {model} {p} = {value:e}"))
}
}
}
/// The .model names in netlist text.
pub fn model_names(text: &str) -> Vec<String> {
text.lines().filter_map(|l| { let t: Vec<&str> = l.split_whitespace().collect(); (t.len() >= 2 && t[0].eq_ignore_ascii_case(".model")).then(|| t[1].to_string()) }).collect()
}
/// The netlist with `.temp t` before its .end.
pub fn with_temp(lines: &[String], t: f64) -> Vec<String> {
let mut out: Vec<String> = lines.iter().filter(|l| !l.trim().to_ascii_lowercase().starts_with(".temp")).cloned().collect();
let at = out.iter().rposition(|l| l.trim().eq_ignore_ascii_case(".end")).unwrap_or(out.len());
out.insert(at, format!(".temp {t}"));
out
}
/// A noise run's integrated totals {onoise, inoise} in V (null when the run has none).
pub fn noise_totals(run: &Value) -> Value {
let get = |k: &str| run["vectors"].as_array().into_iter().flatten().find(|v| v["name"].as_str().map(|n| n.eq_ignore_ascii_case(k)).unwrap_or(false))
.and_then(|v| v["real"].as_array().and_then(|a| a.first()).and_then(Value::as_f64));
match (get("onoise_total"), get("inoise_total")) {
(None, None) => Value::Null,
(o, i) => json!({"onoise": o, "inoise": i}),
}
}
/// A net as the netlist names its node: KiCad writes a labelled net on the root sheet as /OUT, ground as GND.
pub fn resolve_node(netlist: &str, net: &str) -> String {
let nodes: std::collections::BTreeSet<&str> = netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+']))
.flat_map(|t| t.split_whitespace().skip(1)).collect();
let n = net.trim();
if nodes.contains(n) { return n.to_string(); }
let slash = format!("/{}", n.trim_start_matches('/'));
if nodes.contains(slash.as_str()) { return slash; }
nodes.iter().find(|x| x.eq_ignore_ascii_case(n) || x.eq_ignore_ascii_case(&slash)).map(|x| x.to_string()).unwrap_or_else(|| n.to_string())
}
/// The parts a Monte Carlo run can draw, from the netlist: resistors, capacitors and inductors by their value, DC
/// sources by their DC value. (element name, kind, nominal)
pub fn mc_parts(netlist: &str) -> Vec<(String, &'static str, f64)> {
let mut out = Vec::new();
for l in netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+'])) {
let t: Vec<&str> = l.split_whitespace().collect();
let Some(first) = t.first().and_then(|n| n.chars().next()) else { continue };
let kind = match first.to_ascii_uppercase() { 'R' => "resistor", 'C' => "capacitor", 'L' => "inductor", 'V' => "dcSource", _ => continue };
let value = if kind == "dcSource" {
t.iter().position(|x| x.eq_ignore_ascii_case("dc")).and_then(|i| t.get(i + 1)).or_else(|| t.get(3)).and_then(|v| spice_num(&json!(v)))
} else {
t.get(3).and_then(|v| spice_num(&json!(v)))
};
if let Some(v) = value.filter(|v| *v != 0.0) {
out.push((t[0].to_string(), kind, v));
}
}
out
}
/// splitmix64: the same seed gives the same draws on every machine.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64
}
}
/// A value to six significant digits, the way a run's step lists what was drawn.
pub fn sig6(v: f64) -> String {
if v == 0.0 { return "0".into(); }
let e = v.abs().log10().floor() as i32;
if (-3..6).contains(&e) { let d = (5 - e).max(0) as usize; let s = format!("{v:.d$}"); if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } } else { format!("{v:.5e}") }
}
/// Each run's drawn values for the parts whose kind has a tolerance (%): uniform within +/-tol, or gaussian with
/// sigma tol/3 clipped to +/-tol.
pub fn mc_draws(parts: &[(String, &'static str, f64)], tolerances: &Value, runs: usize, gaussian: bool, seed: u64) -> Vec<Vec<(String, f64)>> {
let mut rng = Rng(seed);
(0..runs).map(|_| parts.iter().filter_map(|(name, kind, nominal)| {
let tol = tolerances[*kind].as_f64().filter(|t| *t > 0.0)? / 100.0;
let d = if gaussian {
let (u1, u2) = (rng.next().max(1e-12), rng.next());
((-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos() * tol / 3.0).clamp(-tol, tol)
} else {
(rng.next() * 2.0 - 1.0) * tol
};
Some((name.clone(), nominal * (1.0 + d)))
}).collect()).collect()
}
/// KiCad writes a part it has no model for as a stub with no nodes (`J1 __J1`), which ngspice cannot parse: the
/// netlist without those lines, and the stubs' element names.
pub fn strip_unmodelled(netlist: &str) -> (String, Vec<String>) {
let mut dropped = Vec::new();
let kept: Vec<&str> = netlist.lines().filter(|l| {
let t: Vec<&str> = l.split_whitespace().collect();
let stub = t.len() == 2 && !t[0].starts_with(['.', '*', '+']) && t[1].starts_with("__");
if stub { dropped.push(t[0].to_string()); }
!stub
}).collect();
(kept.join("\n") + "\n", dropped)
}
/// The parts on the sheet that did not make it into KiCad's netlist (`refs` are the sheet's designators): KiCad
/// names each element by its designator, with the SPICE letter put in front when the designator does not start
/// with it (U1 as a subcircuit is XU1), so a designator no element ends with was left out for want of a model.
pub fn not_simulated(netlist: &str, refs: &[String]) -> Vec<String> {
let names: Vec<String> = netlist.lines().map(str::trim_start)
.filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+']))
.filter_map(|t| t.split_whitespace().next()).map(str::to_ascii_uppercase).collect();
refs.iter().filter(|r| {
let r = r.to_ascii_uppercase();
!names.iter().any(|n| *n == r || (n.len() == r.len() + 1 && n.ends_with(r.as_str())))
}).cloned().collect()
}
/// The circuit to run: the netlist without its `.end`, the schematic's own analyses commented out when
/// `analyses` are given (theirs win), then those analyses and `.end`.
pub fn prepare(netlist: &str, analyses: &[String]) -> Result<Vec<String>, String> {
let mut out: Vec<String> = Vec::new();
for l in netlist.lines() {
let t = l.trim();
if t.eq_ignore_ascii_case(".end") {
continue;
}
if !analyses.is_empty() && is_analysis(t) {
out.push(format!("* replaced by the call: {t}"));
continue;
}
out.push(l.to_string());
}
for a in analyses {
let a = a.trim();
if !is_analysis(a) {
return Err(format!("{a:?} is not an analysis: give one of {}", ANALYSES.join(", ")));
}
out.push(a.to_string());
}
if analyses.is_empty() && !out.iter().any(|l| is_analysis(l)) {
return Err("the schematic carries no analysis and none was given: pass analysis, e.g. \".tran 10u 5m\" or \".ac dec 20 10 1meg\"".into());
}
out.push(".end".into());
Ok(out)
}
/// A probe's name as ngspice names its vector: `V(/OUT)` and `/out` both mean the node vector `/out`;
/// `I(R1)` is `r1#branch`.
pub fn vector_name(probe: &str) -> String {
let p = probe.trim().to_lowercase();
if let Some(inner) = p.strip_prefix("v(").and_then(|s| s.strip_suffix(')')) {
return inner.to_string();
}
if let Some(inner) = p.strip_prefix("i(").and_then(|s| s.strip_suffix(')')) {
return format!("{inner}#branch");
}
p
}
fn r(v: f64) -> f64 {
if v == 0.0 || !v.is_finite() {
return v;
}
let mag = 10f64.powi(5 - v.abs().log10().ceil() as i32);
(v * mag).round() / mag
}
fn pick(data: &[f64], idx: &[usize]) -> Vec<f64> {
idx.iter().map(|&i| r(data[i])).collect()
}
/// The run's vectors as the reply gives them: the sweep (time, frequency or a DC sweep) and each probe
/// (every node and branch vector when none are named, at most 24), downsampled to `points`.
pub fn summarize(run: &Value, probes: &[String], points: usize) -> Result<Value, String> {
let vecs = run["vectors"].as_array().cloned().unwrap_or_default();
let name = |v: &Value| v["name"].as_str().unwrap_or("").to_lowercase();
let sweep = vecs.iter().find(|v| ["time", "frequency", "v-sweep", "i-sweep", "temp-sweep"].contains(&name(v).as_str()) || name(v).ends_with("-sweep"));
let len = vecs.iter().map(|v| v["real"].as_array().or(v["re"].as_array()).map(|a| a.len()).unwrap_or(0)).max().unwrap_or(0);
let points = points.clamp(2, 5000);
let idx: Vec<usize> = if len <= points { (0..len).collect() } else { (0..points).map(|k| k * (len - 1) / (points - 1)).collect() };
let nums = |v: &Value, k: &str| -> Vec<f64> { v[k].as_array().map(|a| a.iter().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default() };
let wanted: Vec<String> = probes.iter().map(|p| vector_name(p)).collect();
let mut missing = Vec::new();
let chosen: Vec<&Value> = if wanted.is_empty() {
vecs.iter().filter(|v| Some(*v) != sweep).take(24).collect()
} else {
let mut c = Vec::new();
for w in &wanted {
match vecs.iter().find(|v| name(v) == *w || name(v).trim_start_matches('/') == w.trim_start_matches('/')) {
Some(v) => c.push(v),
None => missing.push(w.clone()),
}
}
c
};
if chosen.is_empty() {
return Err(format!("none of the probes is a vector of this run ({}); it has: {}", wanted.join(", "), vecs.iter().map(name).take(40).collect::<Vec<_>>().join(", ")));
}
let mut out = Vec::new();
for v in chosen {
let n = v["name"].as_str().unwrap_or("");
if v["real"].is_array() {
let d = nums(v, "real");
if d.is_empty() {
continue;
}
let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(*x), m.1.max(*x)));
out.push(json!({"name": n, "first": r(d[0]), "last": r(*d.last().unwrap()), "min": r(lo), "max": r(hi), "values": pick(&d, &idx.iter().copied().filter(|&i| i < d.len()).collect::<Vec<_>>())}));
} else {
let (re, im) = (nums(v, "re"), nums(v, "im"));
let db: Vec<f64> = re.iter().zip(&im).map(|(a, b)| 20.0 * (a.hypot(*b)).log10()).collect();
let ph: Vec<f64> = re.iter().zip(&im).map(|(a, b)| b.atan2(*a).to_degrees()).collect();
if db.is_empty() {
continue;
}
let ii: Vec<usize> = idx.iter().copied().filter(|&i| i < db.len()).collect();
let (lo, hi) = db.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(*x), m.1.max(*x)));
out.push(json!({"name": n, "complex": true, "firstDb": r(db[0]), "lastDb": r(*db.last().unwrap()), "minDb": r(lo), "maxDb": r(hi), "db": pick(&db, &ii), "phaseDeg": pick(&ph, &ii)}));
}
}
let x = sweep.map(|s| {
let d = if s["real"].is_array() { nums(s, "real") } else { nums(s, "re") };
json!({"name": s["name"], "first": d.first().map(|v| r(*v)), "last": d.last().map(|v| r(*v)), "values": pick(&d, &idx.iter().copied().filter(|&i| i < d.len()).collect::<Vec<_>>())})
});
Ok(json!({"plot": run["plot"], "points": idx.len(), "of": len, "sweep": x, "probes": out, "missing": missing,
"available": vecs.iter().map(name).take(60).collect::<Vec<_>>()}))
}
/// One analysis in the standard's form (#23) as SPICE text: `{type: op}`, `{type: dc, source, start, stop, step}`,
/// `{type: ac, sweep: dec|oct|lin, points, start, stop}`, `{type: tran, step, stop, start?}` or `{type: raw, text}`;
/// a string is taken as SPICE text.
pub fn analysis_text(a: &Value) -> Result<String, String> {
if let Some(t) = a.as_str() {
return Ok(t.trim().to_string());
}
let num = |k: &str| a.get(k).and_then(Value::as_f64).ok_or(format!("analysis {} needs {k}", a["type"].as_str().unwrap_or("?")));
let g = |v: f64| format!("{v}");
match a["type"].as_str().unwrap_or("") {
"op" => Ok(".op".into()),
"raw" => a["text"].as_str().map(|t| t.trim().to_string()).ok_or("a raw analysis carries its SPICE text in text".into()),
"dc" => Ok(format!(".dc {} {} {} {}", a["source"].as_str().ok_or("a dc analysis names its source")?, g(num("start")?), g(num("stop")?), g(num("step")?))),
"ac" => {
let sweep = a["sweep"].as_str().unwrap_or("dec");
if !["dec", "oct", "lin"].contains(&sweep) {
return Err("an ac sweep is dec, oct or lin".into());
}
Ok(format!(".ac {sweep} {} {} {}", num("points")? as u64, g(num("start")?), g(num("stop")?)))
}
"tran" => Ok(match a.get("start").and_then(Value::as_f64) {
Some(st) => format!(".tran {} {} {}", g(num("step")?), g(num("stop")?), g(st)),
None => format!(".tran {} {}", g(num("step")?), g(num("stop")?)),
}),
// output and reference are node names as the netlist writes them (resolve_node)
"noise" => {
let sweep = a["sweep"].as_str().unwrap_or("dec");
if !["dec", "oct", "lin"].contains(&sweep) {
return Err("a noise sweep is dec, oct or lin".into());
}
let out = a["output"].as_str().ok_or("a noise analysis names its output net")?;
let node = match a["reference"].as_str().filter(|r| !r.is_empty() && *r != "0") { Some(r) => format!("v({out},{r})"), None => format!("v({out})") };
let points = a.get("points").and_then(Value::as_f64).unwrap_or(10.0) as u64;
Ok(format!(".noise {node} {} {sweep} {points} {} {}", a["source"].as_str().ok_or("a noise analysis names its input source")?, g(num("start")?), g(num("stop")?)))
}
t => Err(format!("analysis type {t:?} is not op, dc, ac, tran, noise or raw")),
}
}
/// A probe in the standard's form, `{net: "OUT"}` or `{current: "V1"}`, or a string (`V(/OUT)`, `I(V1)`), as
/// the vector name ngspice gives it.
pub fn probe_vector(p: &Value) -> Option<String> {
if let Some(s) = p.as_str() {
return Some(vector_name(s));
}
if let Some(n) = p["net"].as_str() {
return Some(n.trim().to_lowercase());
}
p["current"].as_str().map(|c| format!("{}#branch", c.trim().to_lowercase()))
}
/// Indices to keep from `y` within `points` (a ceiling): the first and last points and, per bucket, the lowest
/// and highest, so no peak is decimated away (#23).
pub fn keep_extrema(y: &[f64], points: usize) -> Vec<usize> {
let n = y.len();
if n <= points.max(2) {
return (0..n).collect();
}
let buckets = ((points.max(4) - 2) / 2).max(1);
let mut idx = vec![0, n - 1];
let inner = n - 2;
for b in 0..buckets {
let (lo, hi) = (1 + b * inner / buckets, 1 + (b + 1) * inner / buckets);
if lo >= hi {
continue;
}
let (mut imin, mut imax) = (lo, lo);
for i in lo..hi {
if y[i] < y[imin] {
imin = i;
}
if y[i] > y[imax] {
imax = i;
}
}
idx.push(imin);
idx.push(imax);
}
idx.sort_unstable();
idx.dedup();
idx
}
/// Phase in degrees with its 360-degree jumps removed.
pub fn unwrap_deg(p: &[f64]) -> Vec<f64> {
let mut out: Vec<f64> = Vec::with_capacity(p.len());
let mut offset = 0.0f64;
for (i, v) in p.iter().enumerate() {
if i > 0 {
let d = v + offset - out[i - 1];
if d > 180.0 {
offset -= 360.0 * ((d + 180.0) / 360.0).floor();
} else if d < -180.0 {
offset += 360.0 * ((-d + 180.0) / 360.0).floor();
}
}
out.push(v + offset);
}
out
}
/// The run's signals in the standard's shape (#23): `[{signal, x, y, xUnit, yUnit, phase?}]`, each decimated on its
/// own within `points`, keeping its first, last and extreme points; AC as dB with unwrapped phase in degrees.
/// `wanted` are vector names (probe_vector); empty means every node and branch (24 at most).
pub fn signals(run: &Value, wanted: &[String], points: usize) -> (Vec<Value>, Vec<String>) {
let vecs = run["vectors"].as_array().cloned().unwrap_or_default();
let name = |v: &Value| v["name"].as_str().unwrap_or("").to_lowercase();
let is_sweep = |n: &str| ["time", "frequency"].contains(&n) || n.ends_with("-sweep");
let sweep = vecs.iter().find(|v| is_sweep(&name(v)));
let sn = sweep.map(name).unwrap_or_default();
let x_unit = match sn.as_str() { "time" => "s", "frequency" => "Hz", "i-sweep" => "A", "temp-sweep" => "C", "" => "", _ => "V" };
let nums = |v: &Value, k: &str| -> Vec<f64> { v[k].as_array().map(|a| a.iter().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default() };
let x_all: Vec<f64> = sweep.map(|s| if s["real"].is_array() { nums(s, "real") } else { nums(s, "re") }).unwrap_or_default();
let mut missing = Vec::new();
// a noise run's integrated totals are single values, answered as totals (noise_totals), not as signals
let vecs: Vec<Value> = vecs.into_iter().filter(|v| !v["name"].as_str().unwrap_or("").to_lowercase().ends_with("noise_total")).collect();
let chosen: Vec<&Value> = if wanted.is_empty() {
vecs.iter().filter(|v| !is_sweep(&name(v))).take(24).collect()
} else {
let mut c = Vec::new();
for w in wanted {
match vecs.iter().find(|v| name(v) == *w || name(v).trim_start_matches('/') == w.trim_start_matches('/')) {
Some(v) => c.push(v),
None => missing.push(w.clone()),
}
}
c
};
let label = |n: &str| if let Some(k) = n.strip_suffix("_spectrum") { k.to_string() } else if n.ends_with("#branch") { format!("I({})", n.trim_end_matches("#branch").to_uppercase()) } else { format!("V({})", n.trim_start_matches('/').to_uppercase()) };
let mut out = Vec::new();
for v in chosen {
let n = name(v);
let current = n.ends_with("#branch");
if v["real"].is_array() {
let y = nums(v, "real");
let idx = keep_extrema(&y, points);
let x: Vec<f64> = if x_all.len() == y.len() { idx.iter().map(|&i| r(x_all[i])).collect() } else { idx.iter().map(|&i| i as f64).collect() };
let y_unit = if n.ends_with("noise_spectrum") { "V/rtHz" } else if current { "A" } else { "V" };
out.push(json!({"signal": label(&n), "x": x, "y": idx.iter().map(|&i| r(y[i])).collect::<Vec<_>>(), "xUnit": x_unit, "yUnit": y_unit}));
} else {
let (re, im) = (nums(v, "re"), nums(v, "im"));
let db: Vec<f64> = re.iter().zip(&im).map(|(a, b)| 20.0 * a.hypot(*b).log10()).collect();
let ph = unwrap_deg(&re.iter().zip(&im).map(|(a, b)| b.atan2(*a).to_degrees()).collect::<Vec<_>>());
let idx = keep_extrema(&db, points);
let x: Vec<f64> = if x_all.len() == db.len() { idx.iter().map(|&i| r(x_all[i])).collect() } else { idx.iter().map(|&i| i as f64).collect() };
out.push(json!({"signal": label(&n), "x": x, "y": idx.iter().map(|&i| r(db[i])).collect::<Vec<_>>(), "phase": idx.iter().map(|&i| r(ph[i])).collect::<Vec<_>>(), "xUnit": x_unit, "yUnit": "dB"}));
}
}
(out, missing)
}
/// The summary as CSV: the sweep, then a column per probe (dB and phase for a complex one).
pub fn csv(summary: &Value) -> String {
let sweep: Vec<f64> = summary["sweep"]["values"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let probes = summary["probes"].as_array().cloned().unwrap_or_default();
let mut head = vec![summary["sweep"]["name"].as_str().unwrap_or("index").to_string()];
let mut cols: Vec<Vec<f64>> = Vec::new();
for p in &probes {
let n = p["name"].as_str().unwrap_or("");
if p["complex"] == json!(true) {
head.push(format!("{n} dB"));
head.push(format!("{n} deg"));
cols.push(p["db"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());
cols.push(p["phaseDeg"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());
} else {
head.push(n.to_string());
cols.push(p["values"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());
}
}
let rows = cols.iter().map(Vec::len).chain(std::iter::once(sweep.len())).max().unwrap_or(0);
let mut s = head.join(",") + "\n";
for i in 0..rows {
let mut row = vec![sweep.get(i).map(|v| v.to_string()).unwrap_or_else(|| i.to_string())];
for c in &cols {
row.push(c.get(i).map(|v| v.to_string()).unwrap_or_default());
}
s += &(row.join(",") + "\n");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_standard_shape_of_analyses_probes_and_signals() {
assert_eq!(analysis_text(&json!({"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 1000000})).unwrap(), ".ac dec 20 10 1000000");
assert_eq!(analysis_text(&json!({"type": "tran", "step": 1e-5, "stop": 0.005})).unwrap(), ".tran 0.00001 0.005");
assert_eq!(analysis_text(&json!({"type": "dc", "source": "V1", "start": 0, "stop": 5, "step": 0.1})).unwrap(), ".dc V1 0 5 0.1");
assert_eq!(analysis_text(&json!({"type": "op"})).unwrap(), ".op");
assert_eq!(analysis_text(&json!({"type": "raw", "text": ".noise v(out) V1 dec 10 1 1meg"})).unwrap(), ".noise v(out) V1 dec 10 1 1meg");
assert!(analysis_text(&json!({"type": "ac", "points": 20})).is_err());
assert_eq!(probe_vector(&json!({"net": "OUT"})).unwrap(), "out");
assert_eq!(probe_vector(&json!({"current": "V1"})).unwrap(), "v1#branch");
// a spike survives decimation
let mut y = vec![0.0; 1000];
y[517] = 5.0;
let k = keep_extrema(&y, 20);
assert!(k.len() <= 20 && k.contains(&517) && k.contains(&0) && k.contains(&999), "{k:?}");
assert_eq!(unwrap_deg(&[170.0, -170.0, -150.0]), vec![170.0, 190.0, 210.0]);
let run = json!({"vectors": [{"name": "frequency", "re": [10.0, 100.0], "im": [0.0, 0.0]}, {"name": "/out", "re": [1.0, 0.0], "im": [0.0, -0.1]}, {"name": "v1#branch", "re": [0.001, 0.001], "im": [0.0, 0.0]}]});
let (sig, missing) = signals(&run, &["out".into(), "nope".into()], 200);
assert_eq!(sig[0]["signal"], "V(OUT)");
assert_eq!((sig[0]["xUnit"].as_str(), sig[0]["yUnit"].as_str()), (Some("Hz"), Some("dB")));
assert_eq!(sig[0]["y"], json!([0.0, -20.0]));
assert_eq!(sig[0]["phase"], json!([0.0, -90.0]));
assert_eq!(missing, vec!["nope".to_string()]);
let (all, _) = signals(&run, &[], 200);
assert!(all.iter().any(|s| s["signal"] == "I(V1)" && s["yUnit"] == "dB"));
}
const NET: &str = ".title KiCad schematic\nV1 /IN 0 DC 0 SIN( 0 1 1k ) AC 1\nR1 /IN /OUT 1k\nC1 /OUT 0 100n\n.tran 1u 2m\n.end\n";
#[test]
fn the_call_s_analysis_replaces_the_schematic_s() {
let d = describe(NET);
assert_eq!(d["elements"], 3);
assert_eq!(d["analyses"], json!([".tran 1u 2m"]));
let p = prepare(NET, &[".ac dec 10 10 100k".into()]).unwrap();
assert!(p.contains(&"* replaced by the call: .tran 1u 2m".to_string()));
assert_eq!(p[p.len() - 2], ".ac dec 10 10 100k");
assert_eq!(p.last().unwrap(), ".end");
assert_eq!(prepare(NET, &[]).unwrap().iter().filter(|l| *l == ".tran 1u 2m").count(), 1, "the schematic's own analysis runs");
assert!(prepare("R1 a 0 1k\n.end\n", &[]).is_err(), "no analysis anywhere");
assert!(prepare(NET, &["R2 a b 1".into()]).is_err());
}
#[test]
fn probes_are_named_the_way_ngspice_names_vectors() {
assert_eq!(vector_name("V(/OUT)"), "/out");
assert_eq!(vector_name("I(V1)"), "v1#branch");
let run = json!({"plot": "tran1", "vectors": [
{"name": "time", "real": [0.0, 1.0, 2.0, 3.0, 4.0]},
{"name": "/out", "real": [0.0, 0.5, 0.75, 0.875, 0.9375]},
{"name": "v1#branch", "real": [0.0, -1.0, -0.5, -0.25, -0.125]}]});
let s = summarize(&run, &["V(/OUT)".into(), "v(/nope)".into()], 3).unwrap();
assert_eq!(s["probes"][0]["name"], "/out");
assert_eq!(s["probes"][0]["values"], json!([0.0, 0.75, 0.9375]));
assert_eq!(s["probes"][0]["max"], 0.9375);
assert_eq!(s["sweep"]["values"], json!([0.0, 2.0, 4.0]));
assert_eq!(s["missing"], json!(["/nope"]));
assert!(csv(&s).starts_with("time,/out\n0,0\n2,0.75\n"));
// AC: magnitude in dB and phase
let ac = json!({"plot": "ac1", "vectors": [{"name": "frequency", "re": [10.0, 100.0], "im": [0.0, 0.0]}, {"name": "/out", "re": [1.0, 0.0], "im": [0.0, -0.1]}]});
let a = summarize(&ac, &["/out".into()], 10).unwrap();
assert_eq!(a["probes"][0]["db"], json!([0.0, -20.0]));
assert_eq!(a["probes"][0]["phaseDeg"], json!([0.0, -90.0]));
assert!(summarize(&run, &["v(/x)".into()], 3).is_err());
}
#[test]
fn kicads_stubs_for_parts_without_a_model_are_dropped() {
// exported by kicad-cli 10.0.7 for an RC with a connector on its output
let net = ".title KiCad schematic\nV1 /IN GND DC 0 SIN( 0 1 1k 0 0 0 ) AC 1\nJ1 __J1\nC1 /OUT GND 100n\nR1 /IN /OUT 1k\n.end\n";
let (kept, dropped) = strip_unmodelled(net);
assert_eq!(dropped, vec!["J1".to_string()]);
assert!(!kept.contains("__J1") && kept.contains("R1 /IN /OUT 1k") && kept.ends_with(".end\n"));
let refs: Vec<String> = ["V1", "J1", "C1", "R1"].iter().map(|s| s.to_string()).collect();
assert_eq!(not_simulated(&kept, &refs), vec!["J1".to_string()]);
}
#[test]
fn parts_kicad_left_out_of_the_netlist_are_named() {
let net = ".title RC\n* U2 has no model\nR1 IN OUT 1k\nC1 OUT 0 100n\nXU1 OUT 0 VCC opamp\nVV1 IN 0 AC 1\n.end\n";
let refs: Vec<String> = ["R1", "C1", "U1", "V1", "U2", "J1"].iter().map(|s| s.to_string()).collect();
assert_eq!(not_simulated(net, &refs), vec!["U2".to_string(), "J1".to_string()]);
assert_eq!(not_simulated(net, &refs[..4]), Vec::<String>::new());
}
#[test]
fn sweeps_step_values_the_way_spice_does() {
assert_eq!(sweep_values(300.0, 900.0, 300.0, "lin", 50).unwrap(), vec![300.0, 600.0, 900.0]);
let d = sweep_values(100e-12, 100e-6, 1.0, "dec", 50).unwrap();
assert_eq!(d.len(), 7);
assert!((d[6] - 100e-6).abs() < 1e-15);
assert_eq!(sweep_values(1.0, 8.0, 1.0, "oct", 50).unwrap().len(), 4);
assert!(sweep_values(0.0, 100.0, 1.0, "lin", 50).unwrap_err().contains("at most 50"));
assert!(sweep_values(1.0, 2.0, 1.0, "log", 50).is_err());
assert_eq!((spice_num(&json!("100p")), spice_num(&json!("1meg")), spice_num(&json!("4.7k")), spice_num(&json!(2.5))), (Some(100e-12), Some(1e6), Some(4700.0), Some(2.5)));
assert_eq!((spice_label(300.0), spice_label(4700.0), spice_label(1e-10), spice_label(-10.0)), ("300".to_string(), "4.7k".to_string(), "100p".to_string(), "-10".to_string()));
}
#[test]
fn a_sweep_value_is_an_alter_on_the_element_kicad_named() {
let net = ".title amp\n.model QNPN NPN(BF=100)\nR4 /A /B 1k\nQ1 /C /B /E QNPN\nXU1 /A /B /C opamp\n.end\n";
let m = model_names(net);
assert_eq!(sweep_command(net, "R4", 300.0, &m).unwrap(), "alter R4 = 3e2");
assert_eq!(sweep_command(net, "Q1.BF", 50.0, &m).unwrap(), "altermod QNPN BF = 5e1");
assert!(sweep_command(net, "R9", 1.0, &m).unwrap_err().contains("R9"));
assert!(sweep_command(net, "U1.GAIN", 1.0, &m).is_err());
let t = with_temp(&["R1 a 0 1k".to_string(), ".temp 27".to_string(), ".end".to_string()], 85.0);
assert_eq!(t, vec!["R1 a 0 1k".to_string(), ".temp 85".to_string(), ".end".to_string()]);
}
#[test]
fn noise_is_an_analysis_on_the_netlists_node_names() {
let net = ".title amp\nV1 /IN GND DC 0 AC 1\nR1 /IN /OUT 1k\nC1 /OUT GND 100n\nV2 /VP GND DC 12\n.end\n";
assert_eq!(resolve_node(net, "OUT"), "/OUT");
assert_eq!(resolve_node(net, "GND"), "GND");
let a = json!({"type": "noise", "source": "V1", "output": "/OUT", "sweep": "dec", "points": 10, "start": 10, "stop": 1e6});
assert_eq!(analysis_text(&a).unwrap(), ".noise v(/OUT) V1 dec 10 10 1000000");
let a = json!({"type": "noise", "source": "V1", "output": "/OUT", "reference": "/IN", "start": 10, "stop": 100});
assert_eq!(analysis_text(&a).unwrap(), ".noise v(/OUT,/IN) V1 dec 10 10 100");
let run = json!({"vectors": [{"name": "frequency", "real": [10.0, 100.0]}, {"name": "onoise_spectrum", "real": [4e-9, 4e-9]},
{"name": "inoise_spectrum", "real": [4e-9, 4e-9]}, {"name": "onoise_total", "real": [1.2e-6]}, {"name": "inoise_total", "real": [1.3e-6]}]});
let (sig, _) = signals(&run, &[], 200);
assert_eq!(sig.iter().map(|s| (s["signal"].as_str().unwrap(), s["yUnit"].as_str().unwrap())).collect::<Vec<_>>(), vec![("onoise", "V/rtHz"), ("inoise", "V/rtHz")]);
assert_eq!(noise_totals(&run), json!({"onoise": 1.2e-6, "inoise": 1.3e-6}));
}
#[test]
fn monte_carlo_draws_within_tolerance_and_repeats_with_its_seed() {
let net = ".title amp\nV1 /IN GND DC 0 SIN( 0 1 1k 0 0 0 ) AC 1\nV2 /VP GND DC 12\nR1 /IN /OUT 1k\nC1 /OUT GND 100n\nXU1 a b c opamp\n.end\n";
let parts = mc_parts(net);
assert_eq!(parts.iter().map(|p| (p.0.as_str(), p.1)).collect::<Vec<_>>(), vec![("V2", "dcSource"), ("R1", "resistor"), ("C1", "capacitor")]);
let tol = json!({"resistor": 5, "capacitor": 10});
let a = mc_draws(&parts, &tol, 50, false, 7);
assert_eq!(a, mc_draws(&parts, &tol, 50, false, 7));
assert_ne!(a, mc_draws(&parts, &tol, 50, false, 8));
for run in &a {
assert_eq!(run.len(), 2, "V2 has no tolerance");
assert!((run[0].1 / 1000.0 - 1.0).abs() <= 0.05 + 1e-12 && (run[1].1 / 100e-9 - 1.0).abs() <= 0.10 + 1e-12);
}
for run in mc_draws(&parts, &tol, 50, true, 7) {
assert!((run[0].1 / 1000.0 - 1.0).abs() <= 0.05 + 1e-12);
}
assert_eq!((sig6(9038.123), sig6(1.0004e-10), sig6(12.4331)), ("9038.12".to_string(), "1.00040e-10".to_string(), "12.4331".to_string()));
}
}