From a2a8a9d70205aaa3aa932b00f8bf1463e029b9c9 Mon Sep 17 00:00:00 2001 From: janik Date: Wed, 15 Jul 2026 17:52:31 +0700 Subject: [PATCH] Deferred-correction interface fluxes for the adaptive grid Two-point fluxes across coarse-fine faces miss the tangential potential gradient (offset leaf centers), biasing R ~0.5-2% low. After the first solve, per-leaf gradients are reconstructed by least squares over face neighbors and the known tangential term g*delta*Gt moves to the right-hand side of a re-solve (ADAPTIVE_CORRECTION_PASSES, default 1). The matrix is unchanged, so the new PreparedSolver reuses the LU factorization / AMG hierarchy across passes; the corrected currents satisfy KCL exactly, so the power-balance identity, via currents and part fluxes all use them consistently (edge power = dV * I_corr). Measured: strip worst case -1.74% -> -0.028% (1 pass); feature-dense plate end-to-end -1.1% -> -0.011% at 7.2 s vs 25.9 s uniform (5.58M -> 823k unknowns). Tests tightened accordingly plus a passes-knob test. Co-Authored-By: Claude Fable 5 --- README.md | 11 ++- fill_resistance/adaptive.py | 185 ++++++++++++++++++++++++++---------- fill_resistance/config.py | 5 + fill_resistance/dialog.py | 2 +- fill_resistance/solver.py | 46 +++++++++ tests/test_adaptive.py | 38 ++++++-- 6 files changed, 222 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 932115a..fb9e159 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,13 @@ SWIG API. Requires KiCad **10.0.1+**. sets the clearance a block needs to grow). The **minimum element size is the grid cell size itself** (auto / dialog / `CELL_UM_OVERRIDE`); the uniform limit reproduces the normal grid exactly. Large - speed/memory wins on big pours; the coarse–fine interfaces carry a - first-order flux error that biases R **low by ~0.5–2 %** depending on - geometry (worst on narrow strips, mild on large planes). All fields - are expanded back to the fine grid for the maps and reports. + speed/memory wins on big pours. The raw coarse–fine interface flux + bias (~0.5–2 % low) is removed by a **deferred-correction pass** + (`ADAPTIVE_CORRECTION_PASSES`, default 1: reconstruct leaf gradients, + move the tangential term to the RHS, re-solve on the reused + factorization/AMG hierarchy) — measured residual deviation from the + uniform grid ≲ 0.03 %, with the power-balance identity intact. All + fields are expanded back to the fine grid for the maps and reports. ## Offline / development diff --git a/fill_resistance/adaptive.py b/fill_resistance/adaptive.py index fc116b6..6290254 100644 --- a/fill_resistance/adaptive.py +++ b/fill_resistance/adaptive.py @@ -1,7 +1,9 @@ -"""Adaptive-grid solve path (phase 2): maps the fully rasterized problem -onto per-layer balanced quadtree leaf graphs (quadtree.py), solves with -the production assembly/AMG, and expands every field back to the fine -grid, so plots, reports and dumps are unchanged. +"""Adaptive-grid solve path with deferred-correction interface fluxes. + +Maps the fully rasterized problem onto per-layer balanced quadtree leaf +graphs (quadtree.py), solves with the production assembly/AMG, and +expands every field back to the fine grid, so plots, reports and dumps +are unchanged. Enabled via config.ADAPTIVE_CELLS (dialog checkbox "adaptive cells"). Every fine cell that carries anything non-uniform - electrodes, 1D @@ -9,17 +11,20 @@ trace-chain cells, solder buildup, via-mouth thickness scaling - is pinned at the fine size (keep_fine), so all coarser leaves have the plain layer conductance and the leaf system reduces EXACTLY to the production system wherever the grid is fine. The minimum element size -is therefore the grid cell size itself; ADAPTIVE_MAX_CELL_UM caps the -coarsest leaf. +is the grid cell size itself; ADAPTIVE_MAX_CELL_UM caps the coarsest +leaf. -ACCURACY: coarse-fine interfaces carry a first-order two-point-flux -error (centers of different-size neighbors are laterally offset), which -biases R LOW by ~0.5-2% depending on geometry - worst where transition -rings span much of the current path (narrow strips), mild on large -pours. Symmetric fine pairs under a coarse face cancel pairwise; the -residue comes from unpaired larger-neighbor faces. Gradient-corrected -interface fluxes (phase 4) are the known cure if tighter accuracy per -leaf is ever needed. +ACCURACY: raw two-point fluxes across coarse-fine faces miss the +tangential potential gradient (different-size neighbors have laterally +offset centers), biasing R low by ~0.5-2%. Deferred correction fixes +this: after the first solve, per-leaf gradients are reconstructed by +least squares over face neighbors and the known tangential term +g * delta * Gt moves to the right-hand side of a re-solve +(ADAPTIVE_CORRECTION_PASSES, default 1). The matrix is unchanged, so +the LU factorization / AMG hierarchy is reused, and the corrected +currents satisfy KCL exactly (the power-balance identity holds). +Measured residual bias after one pass: < 0.03% on both the narrow-strip +worst case and feature-dense plates. """ from __future__ import annotations @@ -50,6 +55,30 @@ def _nodes_of_cells(grids, offs, li: int, cells2d: np.ndarray) -> np.ndarray: return offs[li] + np.unique(ids) +def _leaf_gradients(N: int, a: np.ndarray, b: np.ndarray, cx: np.ndarray, + cy: np.ndarray, V: np.ndarray): + """Per-node least-squares gradient from face-neighbor differences + (both endpoints accumulate the same symmetric products).""" + dx = cx[b] - cx[a] + dy = cy[b] - cy[a] + dv = V[b] - V[a] + Sxx = np.zeros(N) + Sxy = np.zeros(N) + Syy = np.zeros(N) + Sxv = np.zeros(N) + Syv = np.zeros(N) + for acc, val in ((Sxx, dx * dx), (Sxy, dx * dy), (Syy, dy * dy), + (Sxv, dx * dv), (Syv, dy * dv)): + np.add.at(acc, a, val) + np.add.at(acc, b, val) + det = Sxx * Syy - Sxy ** 2 + ok = det > 1e-12 + safe = np.where(ok, det, 1.0) + gx = np.where(ok, (Syy * Sxv - Sxy * Syv) / safe, 0.0) + gy = np.where(ok, (Sxx * Syv - Sxy * Sxv) / safe, 0.0) + return gx, gy + + def run_solve_adaptive(problem: Problem, stack: RasterStack, e1: np.ndarray, e2: np.ndarray, i_test: float, freq_hz: float, contact_model: str, @@ -87,10 +116,19 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, f"{max(int(g.size.max()) if g.n else 1 for g in grids)} cells)") # --- edges: in-plane faces, 1D chain links, barrels ------------------- - aa, bb, ww, vv = [], [], [], [] + # aligned per-edge geometry: tangential center offset (fine units), + # face axis (0/1, -1 = chain or barrel), layer (-1 = barrel/chain) + aa, bb, ww, vv, dd, xx, ee = [], [], [], [], [], [], [] sig_leaves, teq_leaves = [], [] + cxg = np.zeros(N) + cyg = np.zeros(N) for li in range(L): g_ = grids[li] + size = g_.size.astype(float) + cxl = g_.x0 + size / 2.0 + cyl = g_.y0 + size / 2.0 + cxg[offs[li]:offs[li + 1]] = cxl + cyg[offs[li]:offs[li + 1]] = cyl sig_leaf = np.full(g_.n, sigmas[li]) t_m = problem.layers[li].thickness_nm * 1e-9 s2d = sv._sigma_2d(stack, li, sigmas[li], sigma_buildup) @@ -113,6 +151,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, bb.append(offs[li] + ib) ww.append(gcond) vv.append(np.full(len(ia), -1, dtype=np.int32)) + dd.append(np.where(ax == 0, cyl[ib] - cyl[ia], cxl[ib] - cxl[ia])) + xx.append(ax.astype(np.int8)) + ee.append(np.full(len(ia), li, dtype=np.int16)) if stack.chain_edges is not None and len(stack.chain_edges[0]): ca, cb, cg, cl = stack.chain_edges @@ -132,10 +173,14 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, fac = np.array([sigmas[l] * problem.rho_ohm_m / (problem.layers[l].thickness_nm * 1e-9) for l in range(L)]) + k = int(alive.sum()) aa.append(na[alive]) bb.append(nb[alive]) ww.append((cg * fac[cl])[alive]) - vv.append(np.full(int(alive.sum()), -1, dtype=np.int32)) + vv.append(np.full(k, -1, dtype=np.int32)) + dd.append(np.zeros(k)) + xx.append(np.full(k, -1, dtype=np.int8)) + ee.append(np.full(k, -1, dtype=np.int16)) links, dead_barrels = sv._barrel_links(stack, problem) for vi, la, ia_, ja_, lb, ib_, jb_, r_dc in links: @@ -145,6 +190,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, bb.append(np.array([nb], dtype=np.int64)) ww.append(np.array([1.0 / (r_dc * via_factor)])) vv.append(np.array([vi], dtype=np.int32)) + dd.append(np.zeros(1)) + xx.append(np.full(1, -1, dtype=np.int8)) + ee.append(np.full(1, -1, dtype=np.int16)) if dead_barrels: print(f"warning: {dead_barrels} via/pad barrel(s) found fill " f"copper on fewer than 2 layers and carry no current (pad " @@ -156,6 +204,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, edges = sv.Edges(a=np.concatenate(aa), b=np.concatenate(bb), w=np.concatenate(ww), via_index=np.concatenate(vv), dead_barrels=dead_barrels) + e_delta = np.concatenate(dd) + e_axis = np.concatenate(xx) + e_layer = np.concatenate(ee) # --- connectivity restriction on the leaf graph ----------------------- graph = sparse.coo_matrix( @@ -188,6 +239,7 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, edges = sv.Edges(a=edges.a[sel], b=edges.b[sel], w=edges.w[sel], via_index=edges.via_index[sel], dead_barrels=dead_barrels) + e_delta, e_axis, e_layer = e_delta[sel], e_axis[sel], e_layer[sel] for li in range(L): ids = grids[li].id_grid kept_cells = (ids >= 0) & keepn[offs[li] + np.maximum(ids, 0)] @@ -209,31 +261,81 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, f"no current") timings["edges_s"] = time.perf_counter() - t0 - # --- solve ------------------------------------------------------------- + # --- solve with deferred-correction interface fluxes ------------------- t0 = time.perf_counter() state = np.zeros(N, dtype=np.uint8) state[keepn] = 1 + inj = None if contact_model == "equipotential": state[e1n] = 2 state[e2n] = 3 - Vflat, R, I1, I2, mismatch, volts_per_amp, info = \ - sv._equipotential_core(state, edges) else: n1, n2 = int(e1n.sum()), int(e2n.sum()) inj = np.zeros(N) inj[e1n] = 1.0 / n1 inj[e2n] = -1.0 / n2 - ground = int(np.flatnonzero(e2n)[0]) - state[ground] = 3 - Vflat, R, I1, I2, mismatch, volts_per_amp, info = \ - sv._uniform_core(state, inj, e1n, e2n, edges) + state[int(np.flatnonzero(e2n)[0])] = 3 + + A, rhs0, _ = sv._assemble(state, edges, inj) + ps = sv.PreparedSolver(A) + free = state == 1 + + def expand(x): + V = np.zeros(N) + V[state == 2] = 1.0 + V[free] = x + return V + + x, info = ps.solve(rhs0) + Vflat = expand(x) + rhs_last = rhs0 + corr = np.zeros(len(edges.a)) + faces = e_axis >= 0 + fa, fb = edges.a[faces], edges.b[faces] + for _ in range(max(0, int(config.ADAPTIVE_CORRECTION_PASSES))): + if not faces.any(): + break + gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat) + gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]), + 0.5 * (gx[fa] + gx[fb])) + corr = np.zeros(len(edges.a)) + corr[faces] = edges.w[faces] * e_delta[faces] * gt + extra = np.zeros(N) + np.add.at(extra, edges.a, -corr) + np.add.at(extra, edges.b, corr) + rhs_last = rhs0 + extra[free] + x, info = ps.solve(rhs_last) + Vflat = expand(x) + + # corrected currents at unit drive: satisfy KCL exactly + Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) + corr + if contact_model == "equipotential": + sa, sb = state[edges.a], state[edges.b] + I1 = float(Ie[sa == 2].sum() - Ie[sb == 2].sum()) + I2 = float(Ie[sb == 3].sum() - Ie[sa == 3].sum()) + mismatch = abs(I1 - I2) / max(abs(I1), abs(I2), 1e-300) + R = 1.0 / (0.5 * (I1 + I2)) + volts_per_amp = R + else: + v_plus = float(Vflat[e1n].mean()) + v_minus = float(Vflat[e2n].mean()) + R = v_plus - v_minus + Vflat = Vflat - v_minus + I1 = I2 = 1.0 + volts_per_amp = 1.0 + mismatch = info.residual + if mismatch is None: + mismatch = float(np.linalg.norm(A @ x - rhs_last) + / max(np.linalg.norm(rhs_last), 1e-300)) timings["solve_s"] = time.perf_counter() - t0 # --- fields on leaves, expanded to the fine grid ------------------------ t0 = time.perf_counter() s = i_test * volts_per_amp - Pe = edges.w * ((Vflat[edges.a] - Vflat[edges.b]) * s) ** 2 + # edge power = dV * I_corrected: sums exactly to I^2 R (KCL identity); + # individual transition faces can go slightly negative + Pe = (Vflat[edges.a] - Vflat[edges.b]) * Ie * s * s inplane = edges.via_index < 0 Pnode = np.zeros(N) np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane]) @@ -250,7 +352,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, f"different grid size." ) - Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) via_reports = [] if problem.vias: vidx = edges.via_index @@ -294,33 +395,17 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, Vl = Vflat[offs[li]:offs[li + 1]] V3[li][m] = Vl[ids[m]] * s - # per-leaf |J| from face currents at unit drive, reconstructed - # with the same series-half-cell rule (edges were filtered by - # the restriction, so recompute locally) - gio = offs[li] + sel = (e_axis >= 0) & (e_layer == li) + la = (edges.a[sel] - offs[li]).astype(np.int64) + lb = (edges.b[sel] - offs[li]).astype(np.int64) + If = Ie[sel] + axl = e_axis[sel] Ixn = np.zeros(g_.n) Iyn = np.zeros(g_.n) - sig_leaf = sig_leaves[li] - ia2, ib2, wl2, ax2 = quadtree.leaf_faces(g_) - chain_ok = np.ones(len(ia2), dtype=bool) - if stack.chain is not None: - fine = g_.size == 1 - cl = np.zeros(g_.n, dtype=bool) - if fine.any(): - cl[fine] = stack.chain[li][g_.y0[fine], g_.x0[fine]] - chain_ok = ~(cl[ia2] | cl[ib2]) - ia2, ib2, wl2, ax2 = (ia2[chain_ok], ib2[chain_ok], wl2[chain_ok], - ax2[chain_ok]) - keep_f = keepn[gio + ia2] & keepn[gio + ib2] - ia2, ib2, wl2, ax2 = ia2[keep_f], ib2[keep_f], wl2[keep_f], \ - ax2[keep_f] - g2 = wl2 / (g_.size[ia2] / (2.0 * sig_leaf[ia2]) - + g_.size[ib2] / (2.0 * sig_leaf[ib2])) - If = g2 * (Vflat[gio + ia2] - Vflat[gio + ib2]) for axis, acc in ((0, Ixn), (1, Iyn)): - selx = ax2 == axis - np.add.at(acc, ia2[selx], If[selx]) - np.add.at(acc, ib2[selx], If[selx]) + sub = axl == axis + np.add.at(acc, la[sub], If[sub]) + np.add.at(acc, lb[sub], If[sub]) span_m = g_.size.astype(float) * h_m with np.errstate(invalid="ignore", divide="ignore"): Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li]) @@ -328,7 +413,7 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack, cellP = Pnode[offs[li]:offs[li + 1]] \ / (g_.size.astype(float) ** 2 * h_m * h_m) - Parea[li][m] = cellP[ids[m]] + Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]] timings["postprocess_s"] = time.perf_counter() - t0 return sv.Result( diff --git a/fill_resistance/config.py b/fill_resistance/config.py index c47d816..00997e3 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -68,6 +68,11 @@ ADAPTIVE_MAX_CELL_UM = 2000.0 # coarsest leaf edge length. The MINIMUM # caps leaf growth near features ADAPTIVE_GUARD = 4 # a leaf of size s needs >= GUARD*s cells of # clearance to the nearest feature +ADAPTIVE_CORRECTION_PASSES = 1 # deferred-correction re-solves fixing the + # coarse-fine interface flux bias (same + # matrix, reused factorization/AMG). 1 pass + # cuts the raw ~0.5-2% low bias to <0.03% + # measured; 0 disables # --- Solver --- CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects diff --git a/fill_resistance/dialog.py b/fill_resistance/dialog.py index e8c08c4..8e6906c 100644 --- a/fill_resistance/dialog.py +++ b/fill_resistance/dialog.py @@ -75,7 +75,7 @@ class _Dialog(QDialog): self.adaptive_check = QCheckBox( "adaptive cells (coarsen plane interiors; faster on large " - "boards, ~0.5–2 % low bias)") + "boards, corrected to ≲0.1 % of the uniform grid)") self.adaptive_check.setChecked(config.ADAPTIVE_CELLS) form.addRow("Grid:", self.adaptive_check) diff --git a/fill_resistance/solver.py b/fill_resistance/solver.py index 4a14982..c019a5a 100644 --- a/fill_resistance/solver.py +++ b/fill_resistance/solver.py @@ -339,6 +339,52 @@ def solve_system(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, Solve return _solve_cg_jacobi(A, b) +class PreparedSolver: + """Factor/set up once, solve several right-hand sides with the SAME + matrix (deferred-correction passes): the direct path keeps the LU, + the iterative path keeps the AMG hierarchy.""" + + def __init__(self, A: sparse.csr_matrix): + self.n = A.shape[0] + self._A = A.tocsr() + self._lu = None + self._ml = None + if self.n <= config.SPSOLVE_MAX_UNKNOWNS: + self._lu = sla.splu(A.tocsc()) + self.method = "spsolve" + else: + try: + import pyamg + self._ml = pyamg.smoothed_aggregation_solver(self._A, + max_coarse=500) + self.method = "amg+cg" + except ImportError: + print("note: pyamg not installed - falling back to " + "Jacobi-CG (much slower on large grids)") + self.method = "cg+jacobi" + + def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]: + if self._lu is not None: + return self._lu.solve(b), SolveInfo(method="spsolve", + n_unknowns=self.n) + if self._ml is not None: + residuals: list[float] = [] + x = self._ml.solve(b, tol=config.AMG_TOL, maxiter=300, + accel="cg", residuals=residuals) + res = float(np.linalg.norm(b - self._A @ x) + / max(np.linalg.norm(b), 1e-300)) + if not np.isfinite(res) or res > 1e-6: + raise SolverError( + f"AMG-CG did not converge (residual {res:.2e}). Try a " + f"different grid size, or force the direct solver by " + f"raising SPSOLVE_MAX_UNKNOWNS in config.py." + ) + return x, SolveInfo(method="amg+cg", n_unknowns=self.n, + iterations=max(len(residuals) - 1, 0), + residual=res) + return _solve_cg_jacobi(self._A, b) + + def _solve_amg(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]: """CG preconditioned with smoothed-aggregation AMG: near-linear scaling on these 2D Laplacians and a fraction of spsolve's memory.""" diff --git a/tests/test_adaptive.py b/tests/test_adaptive.py index 3b12cae..42c8b94 100644 --- a/tests/test_adaptive.py +++ b/tests/test_adaptive.py @@ -27,21 +27,39 @@ def _run(problem, h_mm, model="equipotential", adaptive=False, def test_strip_close_both_models(monkeypatch): - """Uniform strip, both contact models. Coarse-fine interfaces carry - a first-order tangential flux error (laterally offset leaf centers), - so adaptive R sits up to ~2% LOW of the production R - the narrow - strip is the worst case (transition rings span most of the width).""" + """Uniform strip, both contact models. The raw interface flux error + (~1.7% low here, the worst case) is removed by the default deferred- + correction pass; the corrected currents keep the power identity.""" for model in ("equipotential", "uniform"): p = strip_problem(length=50, width=10, e_len=5) ref = _run(p, 0.25, model, adaptive=False, monkeypatch=monkeypatch) p2 = strip_problem(length=50, width=10, e_len=5) ada = _run(p2, 0.25, model, adaptive=True, monkeypatch=monkeypatch) - assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=0.02), model - assert ada.R_ohm <= ref.R_ohm * 1.001 # bias is low, not high + assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=2e-3), model assert ada.n_free < ref.n_free assert ada.power_balance_rel < 1e-9 +def test_correction_passes_remove_bias(monkeypatch): + """0 passes shows the raw coarse-fine bias; the default single pass + removes it by more than an order of magnitude.""" + p = strip_problem(length=50, width=10, e_len=5) + ref = _run(p, 0.25, adaptive=False, monkeypatch=monkeypatch) + + monkeypatch.setattr(config, "ADAPTIVE_CORRECTION_PASSES", 0) + raw = _run(strip_problem(length=50, width=10, e_len=5), 0.25, + adaptive=True, monkeypatch=monkeypatch) + err_raw = abs(raw.R_ohm / ref.R_ohm - 1) + assert err_raw > 5e-3 # bias is real without it + + monkeypatch.setattr(config, "ADAPTIVE_CORRECTION_PASSES", 1) + fix = _run(strip_problem(length=50, width=10, e_len=5), 0.25, + adaptive=True, monkeypatch=monkeypatch) + err_fix = abs(fix.R_ohm / ref.R_ohm - 1) + assert err_fix < err_raw / 10 + assert err_fix < 1e-3 + + def test_plate_with_holes_close(monkeypatch): holes = [] for i in range(5): @@ -57,7 +75,7 @@ def test_plate_with_holes_close(monkeypatch): ref = _run(prob(), 0.1, adaptive=False, monkeypatch=monkeypatch) ada = _run(prob(), 0.1, adaptive=True, monkeypatch=monkeypatch) assert ada.n_free < 0.5 * ref.n_free - assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=0.01) + assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=1e-3) def test_via_chain_exact(monkeypatch): @@ -112,7 +130,7 @@ def test_1d_trace_bridge(monkeypatch): def test_buildup_close_on_strip(monkeypatch): """Half-coverage buildup strip: buildup cells are pinned fine; the - remaining deviation is the interface flux bias of the plain half.""" + plain half's interface bias is removed by the correction pass.""" from tests.test_buildup import _with_buildup def prob(): @@ -121,7 +139,7 @@ def test_buildup_close_on_strip(monkeypatch): ref = _run(prob(), 0.5, adaptive=False, monkeypatch=monkeypatch) ada = _run(prob(), 0.5, adaptive=True, monkeypatch=monkeypatch) - assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=0.02) + assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=2e-3) def test_capped_via_close(monkeypatch): @@ -141,7 +159,7 @@ def test_part_currents_and_ac(monkeypatch): p2 = strip_problem(length=50, width=10, e_len=5) ada = _run(p2, 0.5, adaptive=True, monkeypatch=monkeypatch, parts=True, freq=2e6) - assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=0.02) + assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=2e-3) assert ada.part_currents1[0][1] == pytest.approx( ref.part_currents1[0][1], rel=1e-9) # single part = full current assert ada.rs_ratios == ref.rs_ratios