Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1028,3 +1028,18 @@ Consequences: no rate-change delay (latency = the oversampler's: 49 samples FIR
`harness/reports/M6/20261017_plugin_cpu.md`: default knobs 26.4 % (Half 18.9 %), every knob 10 50.2 % (33.2 %), knob automation
32–35 % (≈ 24 %); at the owner's NAM-matched settings 47.3 % (Half 31.5 %). Null test 26 / 26; automation (96 kHz, Full, exact J4,
1e-4 V) 0 non-finite, 20 rescued first-solve failures. The null test and the CPU tool default to Full.

## D-061 EL34: no plate current below the cathode (5 V softplus of the plate voltage; reference model change)
Date: 2026-10-17 · Status: accepted (owner: "approve the tubes.lib change")
Context: with the speaker load (D-059) the power amp at HOT had 37–56 first-solve Newton failures per DI clip at 384 kHz
(more at 768 kHz, 43 unrescued) and the b56 / b6 SPICE references stopped with "timestep too small". Diagnosis
(PROGRESS 2026-10-17): every failure at pair B's plate port during a flyback (pair A +1100 … +3400 V, pair B −700 …
−2600 V), where Koren's unclamped atan(Vpk/kvb) gives a reverse plate current and the screen knee switches at Vpk = 0;
not the iteration budget, tolerance, matrix precision, halvings or the half-step code (every-sample half-steps at fs
reproduce 2·fs to −67.6 dB).
Decision: in `tubes.lib` (EL34 Bp, Bs) and `devices::Pentode`, Vpk enters atan through P = max(Vpk, 0) + 5·ln(1 +
exp(−|Vpk|/5)): the plate current vanishes below the cathode, the cut-off current goes to the screen, and both are smooth
at 0 V. Above +20 V, P = Vpk within 0.1 V (DC operating point unchanged).
Consequences: engine diagnostic: HOT DI 0 power-amp failures at 8x and 16x; CRUNCH / DRIVE unchanged (−218 … −224 dB vs
the previous engine), HOT differs by −35 … −37 dB. tests/data EL34 sweeps regenerated; the pentode test asserts the new
below-cathode behaviour. SPICE references with the power amp are re-rendered.
16 changes: 16 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,22 @@ Notes:
- Owner listening 2026-10-17 (NAM-matched settings, same IR): Power Amp Rate Full vs Half, "can't hear any real audible
difference". The earlier NAM comparison ("pretty accurate") was made at Half (the restored instance setting).
- Owner 2026-10-17: keep Full as the default (D-060 confirmed after the listening test); Half stays selectable per instance.
- Owner 2026-10-17: fix the HOT convergence with the speaker load. Plan: (1) check the half-step handling of the speaker
inductors (every-sample half-steps at fs must reproduce 2·fs without half-steps, D-043); (2) characterise the failing
power-amp solves (ports, residuals, iterations); (3) fix (Newton step control / fallback / discretisation) and re-verify
HOT, the plugin model check and the acceptance.
- Diagnosis: every-sample half-steps at 8x reproduce 16x to −67.6 dB (half-step code correct); failures grow with the step
rate (384 kHz: 1, 768 kHz: 34–37) and are unchanged by maxIter 64, tolerances 1e-6 … 1e-8, double-double matrices or 4 / 8
halvings (8: stall). All at pair B's plate in flybacks below the cathode (−700 … −2600 V): Koren's unclamped
atan(Vpk/kvb) gives reverse plate current there. Hard clamp: mixed; 5 V softplus with a matching screen knee: 0 failures
at 8x and 16x. Owner approved the tubes.lib change (D-061).
- D-061 verification (new references): M4 full acceptance (768 kHz) 107 PASS; 384 kHz exact J4 107 PASS; HOT 20 PASS / 1 FAIL:
DI ESR −40.3 … −41.3 dB (D-059 alone −33.5 … −35.7), Newton failures 0 / 0 / 0 / 2 (rescued; the FAIL); b56 106 PASS; b6
96 PASS / 1 FAIL (the known 5 kHz 20 V Newton median, D-043); b5open 88 PASS. The b56 / b6 SWP-lo-0.1V references still stop
with "timestep too small" at RELTOL 1e-8 (node y / the recorded nScr source, same times as before D-061: not the tube
model) and are now rendered once more at RELTOL 1e-7 by `render_spice` (as the M3 cascade), noted in the reports. Plugin
model check: Full 2x HOT −24.2 dB (PASS), unseen −20.8 dB, 94 first-solve failures (2 unrescued) over 28 runs, nearly all in
the extreme validation settings (presets: 0 / 0 / 2); Half 2x HOT −18.9 dB. Presence knob jump −62.9 dB; null test 26 / 26.

## Questions for the owner
_(Claude Code: add questions here and also ask them in the session.)_
Expand Down
14 changes: 9 additions & 5 deletions docs/WDF_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,21 @@ Vs' = max(Vsk, 1e−3)
u = kp · (1/mu + Vgk / Vs')
E1 = Vs' · softplus(u) / kp
F = E1 > 0 ? 2 · E1^ex : 0
Ip = (F / kg1) · atan(Vpk / kvb)
Is = F · ( 1/kg2 + (1.5707963 − atan(max(Vpk, 0) / kvb)) / kg1 ) (tubes.lib's literal for π/2)
P = max(Vpk, 0) + 5 · ln(1 + exp(−|Vpk| / 5)) (5 V softplus of Vpk, D-061)
Ip = (F / kg1) · atan(P / kvb)
Is = F · ( 1/kg2 + (1.5707963 − atan(P / kvb)) / kg1 ) (tubes.lib's literal for π/2)
Ig = diode as in 3.1 with Rs = 1000 Ω + 1 Ω

∂E1/∂Vgk = sigmoid(u)
∂E1/∂Vsk = softplus(u)/kp − sigmoid(u) · Vgk / Vs' (0 when Vsk < 1e−3)
∂F/∂x = 2 · ex · E1^(ex−1) · ∂E1/∂x
∂atan(Vpk/kvb)/∂Vpk = (1/kvb) / (1 + (Vpk/kvb)²)
∂P/∂Vpk = logistic(Vpk / 5)
∂atan(P/kvb)/∂Vpk = (∂P/∂Vpk / kvb) / (1 + (P/kvb)²)
```
Note: as in SPICE, `atan(Vpk/kvb)` is not clamped in the plate equation, so Ip would go negative if the
plate fell below the cathode. Replicate SPICE exactly; log it if it ever happens in the harness.
Note (D-061): Koren's form used atan(Vpk/kvb) unclamped, so Ip went negative below the cathode, and the screen knee
switched at Vpk = 0. With the speaker load (D-059) the plates fly far below the cathode at high Master and the power-amp
Newton failed there; the 5 V softplus gives no plate current below the cathode, smooth through 0 V, and is equal to
Vpk within 0.1 V above +20 V. SPICE (tubes.lib) uses the same expression.

Matched-pair equivalent (B6): multiply Ip, Is, Ig and all their derivatives by 2.

Expand Down
19 changes: 12 additions & 7 deletions engine/src/devices/Pentode.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ struct PentodeCurrents {
/// Koren pentode with this repository's screen-current modification, as in reference/spice/tubes.lib
/// (subckt EL34, WDF_DESIGN §3.2):
/// Vs' = max(Vsk, 1 mV), u = kp·(1/mu + Vgk/Vs'), E1 = Vs'·softplus(u)/kp, F = 2·E1^ex,
/// Ip = (F/kg1)·atan(Vpk/kvb), Is = F·(1/kg2 + (1.5707963 − atan(max(Vpk, 0)/kvb))/kg1),
/// P = s(Vpk) = max(Vpk, 0) + 5·ln(1 + exp(−|Vpk|/5)) (5 V softplus, D-061),
/// Ip = (F/kg1)·atan(P/kvb), Is = F·(1/kg2 + (1.5707963 − atan(P/kvb))/kg1),
/// Ig = grid diode (Rs = rgi + 1 Ω).
/// As in SPICE, atan(Vpk/kvb) in Ip is not clamped (Ip < 0 below the cathode). The 1 GΩ Re1 / Rcp
/// No plate current below the cathode (it goes to the screen), smooth at Vpk = 0 (D-061). The 1 GΩ Re1 / Rcp
/// helpers, the diode's CJO / TT and the inter-electrode capacitances are not part of the device.
/// `count` scales every current and derivative: 2 = a matched pair with identical drive (SPEC §4.2 B6).
struct Pentode {
static constexpr double kPlateSoft = 5.0; // V, plate softplus scale (D-061)
double mu = 14.77, ex = 1.046, kg1 = 194.3, kg2 = 2243.0, kp = 53.82, kvb = 40.43; // D-058 (was Koren 11 / 1.35 / 650 / 4200 / 60 / 24)
GridDiode grid{1e-9, 1.0, 1000.0 + 1.0};
double count = 1.0;
Expand All @@ -45,20 +47,23 @@ struct Pentode {
const double df_dvgk = df_de1 * sg;
const double df_dvsk = clamped ? 0.0 : df_de1 * (sp / kp - sg * vgk / vs);

const double x = vpk / kvb;
// P = 5 V softplus of Vpk (overflow-free form), dP/dVpk = logistic(Vpk/5) (D-061)
const double e = std::exp(-std::fabs(vpk) / kPlateSoft);
const double pv = std::fmax(vpk, 0.0) + kPlateSoft * std::log1p(e);
const double dpv = vpk >= 0.0 ? 1.0 / (1.0 + e) : e / (1.0 + e);
const double x = pv / kvb;
const double a = std::atan(x);
const double da = 1.0 / (kvb * (1.0 + x * x));
const double da = dpv / (kvb * (1.0 + x * x));
c.ip = f / kg1 * a;
c.dip_dvgk = df_dvgk / kg1 * a;
c.dip_dvsk = df_dvsk / kg1 * a;
c.dip_dvpk = f / kg1 * da;

const double knee = vpk > 0.0 ? a : 0.0;
const double sfac = 1.0 / kg2 + (1.5707963 - knee) / kg1;
const double sfac = 1.0 / kg2 + (1.5707963 - a) / kg1;
c.is = f * sfac;
c.dis_dvgk = df_dvgk * sfac;
c.dis_dvsk = df_dvsk * sfac;
c.dis_dvpk = vpk > 0.0 ? -f * da / kg1 : 0.0;
c.dis_dvpk = -f * da / kg1;
}
if (count != 1.0) {
for (double* p : {&c.ig, &c.is, &c.ip, &c.dig_dvgk, &c.dis_dvgk, &c.dis_dvsk, &c.dis_dvpk, &c.dip_dvgk,
Expand Down
11 changes: 10 additions & 1 deletion harness/render_spice.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ def select_signals(block, preset, pattern=None):
return [s for s in sigs if pattern is None or pattern in s.id]


RETRY_RELTOL = 1e-7 # one retry after "timestep too small" (the M3 cascade's P56_RETRY_RELTOL)


def render(block, preset, signals, jobs=None, log=print):
"""Returns {'op': {...}, 'ac': (f, h) or None, 'tran': {signal id: {node: array}}, 'recorded': {signal id:
{name: array}}, 'notes': [...]}. Blocks driven by recordings (b5open, b6) get them from their source runs
Expand All @@ -46,7 +49,13 @@ def one(sig):
t0 = time.time()
rec, notes = recordings.recordings(block, knobs, sig)
x = block_input(block, sig) if block.input_node else None
res = spice.tran(text, knobs, x, nodes, recorded=rec or None)
try:
res = spice.tran(text, knobs, x, nodes, recorded=rec or None)
except spice.SpiceError as e: # as the M3 cascade (render_p56): one retry at RELTOL 1e-7, noted in the report
if 'Timestep too small' not in str(e):
raise
res = spice.tran(text, knobs, x, nodes, recorded=rec or None, reltol=RETRY_RELTOL)
notes = list(notes) + [f'rendered at RELTOL {RETRY_RELTOL:g} (ngspice: timestep too small at {config.SPICE_RELTOL:g})']
if block.engine_supplies: # the engine block runs on this run's own supply waveforms (b56)
rec = {n: res[n] for n in block.engine_supplies}
log(f' spice {block.id}/{preset.id}/{sig.id}: {len(sig.x) / config.FS:.3f} s signal, {time.time() - t0:.1f} s')
Expand Down
Loading
Loading