Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Multiple Thevenin supplies and prescribed-current loads on one net, solved in absolute volts with the Tellegen power balance verified per run; a source-sink pair table (effective copper resistance per supply x load pair plus an exactly-summing proportional-sharing loss attribution), in summary.txt and as its own figure. Bonded terminals short a package's contacts into one lug so the per-pin split becomes a solve outcome. Geometry dumps carry the terminal set (schema v8). The dialog gained a Classic/PDN mode selector and a full PDN editor: per-role supply/load tables built from the marker rectangles (or a config's terminal set, which never pins mode or net), with Component hints, per-terminal Layer scopes, Active checkboxes, comments, a per-net row filter, resizable tables and a scrolling, screen-sized dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere. fill_res_config.json fully specifies a run (classic or PDN) with validation, comments, named side-by-side configs (the one called default auto-loads), Load/Save buttons with an editable file name, and saves that never drop anything drawn on the board. 347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,17 @@ solved as coupled finite-difference sheets linked by the net's **via
|
|||||||
and through-hole-pad barrels** (18 µm plating, configurable). Shows
|
and through-hole-pad barrels** (18 µm plating, configurable). Shows
|
||||||
per-layer rasterized maps, potential, current density, and **power
|
per-layer rasterized maps, potential, current density, and **power
|
||||||
density**, and reports **per-via currents** (via ampacity!) and total
|
density**, and reports **per-via currents** (via ampacity!) and total
|
||||||
dissipation at a **selectable test current**. PNGs + a text summary are
|
dissipation at a **selectable test current**.
|
||||||
|
|
||||||
|
**[PDN mode](#pdn-mode)** replaces the single driven pair with a whole
|
||||||
|
power rail: any number of **supplies** (Thévenin sources with
|
||||||
|
configurable output resistance and open-circuit voltage) and **loads**
|
||||||
|
with prescribed current draws on one net — set up from marker
|
||||||
|
rectangles in an editable dialog or a [JSON config](#configuration-file)
|
||||||
|
— reporting the IR-drop map, per-supply **current sharing** and
|
||||||
|
per-load **contact voltages** in absolute volts.
|
||||||
|
|
||||||
|
PNGs + a text summary are
|
||||||
saved per run. An optional **skin-effect correction** (exact 1D
|
saved per run. An optional **skin-effect correction** (exact 1D
|
||||||
foil/barrel solution at a user-set frequency) estimates the resistive
|
foil/barrel solution at a user-set frequency) estimates the resistive
|
||||||
skin rise only — it is **not** an AC impedance simulation (no proximity
|
skin rise only — it is **not** an AC impedance simulation (no proximity
|
||||||
@@ -151,7 +161,11 @@ spelled out per step and in *Platform notes* below.
|
|||||||
check the **layers** to include, set each contact's layer scope
|
check the **layers** to include, set each contact's layer scope
|
||||||
("All selected layers" = bolted-lug/through contact), the **test
|
("All selected layers" = bolted-lug/through contact), the **test
|
||||||
current**, and optionally a grid cell size. Multiple layers are coupled
|
current**, and optionally a grid cell size. Multiple layers are coupled
|
||||||
through the net's via/pad barrels automatically.
|
through the net's via/pad barrels automatically. The **Mode**
|
||||||
|
selector at the top switches to [PDN mode](#pdn-mode) — per-terminal
|
||||||
|
currents instead of one driven pair — when rectangles exist on both
|
||||||
|
marker layers or a [config file](#configuration-file) provides the
|
||||||
|
terminal set.
|
||||||
4. Wait for the solve. Depending on board size, included layers, cell
|
4. Wait for the solve. Depending on board size, included layers, cell
|
||||||
size and your hardware it can take **considerable time** — large
|
size and your hardware it can take **considerable time** — large
|
||||||
multi-layer pours at fine cell sizes may run for minutes (on our
|
multi-layer pours at fine cell sizes may run for minutes (on our
|
||||||
@@ -162,7 +176,8 @@ spelled out per step and in *Platform notes* below.
|
|||||||
straight from the mounted installer image — a temp directory is used
|
straight from the mounted installer image — a temp directory is used
|
||||||
instead and its path printed to the Messages panel):
|
instead and its path printed to the Messages panel):
|
||||||
per-layer `1_raster_map` / `2_potential` / `3_current_density` /
|
per-layer `1_raster_map` / `2_potential` / `3_current_density` /
|
||||||
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
|
`4_power_density` PNGs (PDN runs add `5_source_sink_pairs`, the
|
||||||
|
pair table as a figure), `summary.txt` (incl. the busiest vias with
|
||||||
per-via current and dissipation, and the **current through each
|
per-via current and dissipation, and the **current through each
|
||||||
injection area** — computed flux with the equipotential model,
|
injection area** — computed flux with the equipotential model,
|
||||||
prescribed area share with the uniform model), `geometry_dump.json`.
|
prescribed area share with the uniform model), `geometry_dump.json`.
|
||||||
@@ -201,13 +216,277 @@ spelled out per step and in *Platform notes* below.
|
|||||||
EMI return paths, or plane capacitance, which this DC analysis does
|
EMI return paths, or plane capacitance, which this DC analysis does
|
||||||
not see.
|
not see.
|
||||||
|
|
||||||
|
## Configuration file
|
||||||
|
|
||||||
|
The plugin optionally reads a JSON config next to the board file. It
|
||||||
|
can fully specify a run — the shared run parameters, the classic setup
|
||||||
|
(optionally including the terminals themselves, by board reference),
|
||||||
|
or the [PDN terminal set](#pdn-mode).
|
||||||
|
|
||||||
|
**Named configs**: several configs can live side by side as
|
||||||
|
`fill_res_config.<name>.json`; **the config named `default` loads
|
||||||
|
automatically**. Search order on launch:
|
||||||
|
`<board stem>.fill_res_config.json` (board-specific — several boards
|
||||||
|
can share a directory), then `fill_res_config.default.json`, then
|
||||||
|
plain `fill_res_config.json` (the legacy spelling of "default"). Any
|
||||||
|
other config is pulled in per run with the dialog's **Load config…**
|
||||||
|
button (a file picker starting in the board directory): the dialog
|
||||||
|
re-opens with everything — mode, net, terminals, values — taken from
|
||||||
|
the picked file. **Save config…** asks for the target file name each
|
||||||
|
time (pre-filled with the loaded config), so writing back and saving
|
||||||
|
a variant under a new `fill_res_config.<name>.json` are both one
|
||||||
|
click — a name outside the auto-load set prints a reminder that it
|
||||||
|
needs Load config….
|
||||||
|
|
||||||
|
**Precedence**: `config.py` constants < config file < dialog edits.
|
||||||
|
The file pre-fills the dialog; what the dialog shows is what runs. A
|
||||||
|
missing file changes nothing; a present-but-invalid file stops the run
|
||||||
|
with a readable error (never a silent fallback). The **Save config…**
|
||||||
|
button in the dialog writes the current dialog values back to the file
|
||||||
|
(creating it if needed) — in classic mode the run parameters and
|
||||||
|
terminals-by-reference, in PDN mode the whole terminal set — so "run
|
||||||
|
once, tweak, save" is the whole authoring workflow.
|
||||||
|
|
||||||
|
Format notes: plain JSON, but **full-line comments** starting with
|
||||||
|
`//` are allowed, and keys starting with `_` are ignored everywhere
|
||||||
|
(`"_comment": "..."`). Unknown keys print a warning (typo guard) but
|
||||||
|
don't stop the run. Units are plain SI floats (A, Ω, V, Hz), `_mm`
|
||||||
|
keys are board millimetres, `_um` metal micrometres. Any number may
|
||||||
|
also be written as a **string with an SI suffix** — `"50m"` = 0.05,
|
||||||
|
`"4.7k"` = 4700, case separating m (milli) from M (mega) — and the
|
||||||
|
dialog's number fields accept the same suffixes typed directly
|
||||||
|
(`50m` for a 50 mΩ `R_out`). `freq_hz` keeps the frequency grammar
|
||||||
|
(`"142k"`, `"1.5M"`; a lone `m` means MHz there).
|
||||||
|
|
||||||
|
A classic example ([docs/fill_res_config.example.json](docs/fill_res_config.example.json)):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"mode": "classic",
|
||||||
|
"run": {
|
||||||
|
"net": "VOUT+",
|
||||||
|
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
|
||||||
|
"freq_hz": "142k",
|
||||||
|
"adaptive": true,
|
||||||
|
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
|
||||||
|
},
|
||||||
|
"classic": {
|
||||||
|
"current_a": 40.0,
|
||||||
|
"pos": ["J1.1"],
|
||||||
|
"neg": ["J2.1", "J2.2"]
|
||||||
|
},
|
||||||
|
"physics": {"via_plating_um": 25.0}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every `run` key is optional and mirrors a dialog field
|
||||||
|
(`include_tracks`, `vias_capped`, `cap_max_drill_mm`, `adaptive`,
|
||||||
|
`cell_um`, `freq_hz`, `contact_model`, `include_buildup`,
|
||||||
|
`extra_cu_um`, `push_overlays`, `v_nominal`, `trim`). `classic.pos` /
|
||||||
|
`classic.neg` define the terminals **by reference** — when present,
|
||||||
|
the board selection / marker-rectangle scan is skipped entirely and
|
||||||
|
`run.net` is required. The reference grammar (shared with PDN
|
||||||
|
terminals):
|
||||||
|
|
||||||
|
| form | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `"U7"` | every pad of footprint U7 that is on the run net |
|
||||||
|
| `"U7.3"` | pad "3" of U7 (split at the **first** dot — pad numbers are strings and may contain dots) |
|
||||||
|
| `"rect:NAME"` | rectangle on `User.3` named by a text item placed inside it |
|
||||||
|
| `{"rect_mm": [x0, y0, x1, y1], "contact": "F.Cu"}` | explicit rectangle, board mm |
|
||||||
|
| `{"via_mm": [x, y]}` | the net's via nearest to (x, y), within 1 mm |
|
||||||
|
|
||||||
|
`physics` overrides the constants you'd otherwise hand-edit in
|
||||||
|
`config.py` (`rho_cu_ohm_m`, `copper_thickness_um`, `via_plating_um`);
|
||||||
|
`markers` renames the marker layers (`pos_layer`, `neg_layer`,
|
||||||
|
`pdn_layer`). The full schema is documented in
|
||||||
|
`fill_resistance/configfile.py`.
|
||||||
|
|
||||||
|
## PDN mode
|
||||||
|
|
||||||
|
For power-distribution studies the plugin can replace the single driven
|
||||||
|
terminal pair with **multiple supplies and loads on one net**: each
|
||||||
|
supply is a Thévenin source (open-circuit voltage `v_oc` behind
|
||||||
|
`r_out_ohm`), each load draws a prescribed current `i_draw_a`. The
|
||||||
|
solve then runs in **absolute volts**: supply currents fall out of the
|
||||||
|
Thevenin split, and you get the IR-drop map, per-supply delivered
|
||||||
|
current and per-load voltage (mean and worst-case) in `summary.txt`
|
||||||
|
and the figures.
|
||||||
|
|
||||||
|
The summary also carries a **source-sink pair table**: for every
|
||||||
|
supply × load pair, the **effective copper resistance** between the
|
||||||
|
two contacts (an operating-point-independent board property — source
|
||||||
|
`R_out` excluded, contact patterns as in the solve, one extra linear
|
||||||
|
solve per terminal, deferred-corrected on the adaptive grid too) and
|
||||||
|
the **copper loss attributed to the pair** by proportional sharing
|
||||||
|
(`f_ij = I_i·I_j / I_loads`, per copper island). The attribution is a
|
||||||
|
convention — the pairwise flow split is not unique physics — but it
|
||||||
|
sums *exactly* to the total copper dissipation, never crosses a
|
||||||
|
copper gap, and pairs without a common copper path report "no path".
|
||||||
|
The same table is also rendered as its own figure
|
||||||
|
(`5_source_sink_pairs.png`) next to the field maps.
|
||||||
|
|
||||||
|
There are two ways to set a PDN run up: the **dialog editor** (marker
|
||||||
|
rectangles, no JSON needed) or the config file.
|
||||||
|
|
||||||
|
### The dialog editor
|
||||||
|
|
||||||
|
Draw **supply rectangles on `User.1`** and **load rectangles on
|
||||||
|
`User.2`** — the same marker layers as classic mode, but in PDN mode
|
||||||
|
**each rectangle is its own terminal** (classic merges each layer into
|
||||||
|
one V+/V−). Optionally place a **text item inside a rectangle** to
|
||||||
|
name it; unnamed rectangles get automatic names (S1, S2… / L1, L2…).
|
||||||
|
Launch the plugin and switch the **Mode** selector at the top of the
|
||||||
|
dialog to *PDN*: **two tables** — *Supplies* and *Loads*, each titled
|
||||||
|
with the marker layer its rectangles come from — list one row per
|
||||||
|
rectangle with its role taken from the layer. **Only rectangles whose
|
||||||
|
copper belongs to the selected net are shown**: switching the net
|
||||||
|
swaps the visible set, a count of hidden rows appears under the
|
||||||
|
tables, and hidden rows take no part in the run (not validated, not
|
||||||
|
solved). They are still **saved**, though: Save config… writes every
|
||||||
|
rectangle to the file — off-net ones as `"active": false`, values and
|
||||||
|
comments included — so nothing set up in the dialog is ever lost by a
|
||||||
|
save. Config-backed runs apply the same per-net filter, so such
|
||||||
|
archived terminals are skipped per run, never an error. A read-only
|
||||||
|
**Component** column identifies each row by the footprint whose pad
|
||||||
|
intersects the contact rectangle (e.g. `U5`); when no pad touches it,
|
||||||
|
the nearest component is shown as `near U5`. You type each load's
|
||||||
|
current draw and each supply's output resistance (plus an optional
|
||||||
|
per-supply `V_oc`; empty = the *V nominal* field below the tables).
|
||||||
|
Every row also has a **Layer** combo picking the copper the terminal
|
||||||
|
contacts — *All selected layers* (a rectangle's natural scope: a
|
||||||
|
bolted-lug/through contact) or one specific layer, exactly like the
|
||||||
|
classic contact scopes. The tables size themselves to their
|
||||||
|
rows and are **height-resizable** (drag the divider between them, or
|
||||||
|
enlarge the dialog); in PDN mode the dialog opens at ~60% of the
|
||||||
|
screen height so the tables start with room, and when the form
|
||||||
|
outgrows the screen the dialog scrolls, with the buttons always in
|
||||||
|
view. An **Active** checkbox per
|
||||||
|
row disregards a
|
||||||
|
terminal for what-if runs without deleting anything: an unchecked row
|
||||||
|
takes no part in the solve, may leave its value cells blank, and is
|
||||||
|
still saved (as `"active": false`) so it can be re-enabled later — the
|
||||||
|
totals line counts disabled rows. A free-text **Comment** column
|
||||||
|
annotates each terminal and is saved along with it. Rectangles that **share one name
|
||||||
|
become a single *bonded* terminal** — one table row, one total
|
||||||
|
current, and the per-rectangle split is a solve outcome (a multi-pin
|
||||||
|
package whose pins are joined by internal metal: the total draw is
|
||||||
|
known, which pin carries how much is exactly what the solve
|
||||||
|
determines). OK solves;
|
||||||
|
**Save config…** writes the whole setup to a config file whose name
|
||||||
|
you pick per save (pre-filled with the loaded config, else
|
||||||
|
`fill_res_config.json`) so the values survive between runs — named rectangles are saved as live `rect:NAME`
|
||||||
|
references (they follow the rectangle wherever it moves), unnamed ones
|
||||||
|
are frozen as `rect_mm` coordinates, so **label your rectangles** if
|
||||||
|
the layout is still moving. A saved config *provides* the terminal
|
||||||
|
set: the next launch opens in PDN mode (the saved mode is only the
|
||||||
|
**starting** mode — nothing is pinned, the Mode selector and the net
|
||||||
|
stay switchable) with the geometry read-only, everything else
|
||||||
|
editable. **Newly drawn rectangles still show up**: any marker
|
||||||
|
rectangle the config doesn't reference yet appears as a fresh
|
||||||
|
terminal row (a note under the tables counts them), and Save config…
|
||||||
|
appends it to the file; a rectangle *named after* an existing
|
||||||
|
`rect:NAME` terminal instead joins that terminal as another contact
|
||||||
|
part (add `"bonded": true` in the file if those parts are internally
|
||||||
|
joined). Delete the config's `terminals` section (or the file) to
|
||||||
|
return to the pure live scan. In a directory where
|
||||||
|
several boards share `fill_res_config.json`, save to the
|
||||||
|
board-specific `<stem>.fill_res_config.json` instead. Rectangle names
|
||||||
|
must be unique across `User.1`/`User.2`/`User.3`. The board selection
|
||||||
|
is ignored in PDN mode (terminals are the rectangles; pads/footprints
|
||||||
|
as terminals need the config file).
|
||||||
|
|
||||||
|
### The config file
|
||||||
|
|
||||||
|
The config file can express everything the editor can, plus terminals
|
||||||
|
made of **pads and footprints** (reference grammar above). A PDN
|
||||||
|
example
|
||||||
|
([docs/fill_res_config.pdn.example.json](docs/fill_res_config.pdn.example.json)):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"mode": "pdn",
|
||||||
|
"run": {"net": "VCC_3V3", "v_nominal": 3.30, "adaptive": true},
|
||||||
|
"terminals": [
|
||||||
|
{"name": "buck", "role": "supply",
|
||||||
|
"parts": ["U1.SW2", "U1.SW3"], "r_out_ohm": 0.004},
|
||||||
|
{"name": "ldo", "role": "supply",
|
||||||
|
"parts": ["U2.OUT"], "r_out_ohm": 0.050, "v_oc": 3.28},
|
||||||
|
{"name": "mcu", "role": "load", "parts": ["U7"],
|
||||||
|
"i_draw_a": 1.8, "bonded": true},
|
||||||
|
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
|
||||||
|
"i_draw_a": 0.35, "contact": "F.Cu"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `run.net` is **required**; every terminal needs a unique `name`, a
|
||||||
|
`role` (`supply` / `load`) and non-empty `parts` (reference grammar
|
||||||
|
above). Active loads need `i_draw_a` (≥ 0; 0 = voltage probe),
|
||||||
|
active supplies need `r_out_ohm` (0 = ideal source) and optionally
|
||||||
|
`v_oc` (default: `run.v_nominal`, default 3.3 V). With one supply —
|
||||||
|
or all supplies at the same `v_oc` — that voltage is only the
|
||||||
|
absolute reference (drops and currents don't depend on it); with
|
||||||
|
several supplies at *different* `v_oc`, the **differences** drive
|
||||||
|
the current sharing between them, so per-supply setpoints matter.
|
||||||
|
`"active": false` keeps a terminal in the file (and in the dialog,
|
||||||
|
unchecked) without it taking part in the run — its value may then be
|
||||||
|
omitted; `"comment"` is a free-text note shown in the dialog's
|
||||||
|
Comment column.
|
||||||
|
- **`"bonded": true`** shorts all of a terminal's contact cells into
|
||||||
|
one lug (a package with internal metal): the total value stays
|
||||||
|
prescribed, but the per-part/per-cell split becomes a **solve
|
||||||
|
outcome** and the contact face is equipotential. Without it (the
|
||||||
|
default), a multi-part load splits its draw by **area share** and a
|
||||||
|
multi-part supply attaches its `r_out_ohm` per cell. Use bonded for
|
||||||
|
"all pads of U7 draw 1.8 A total, per-pin unknown"; use the default
|
||||||
|
for genuinely distributed draws (a heater area on a plane). A
|
||||||
|
bonded load may even span disconnected copper sheets — the bond is
|
||||||
|
the connection.
|
||||||
|
- **Area terminals** (a plane region rather than pads): draw a
|
||||||
|
rectangle on `User.3` (`markers.pdn_layer`) and place a **text
|
||||||
|
item** inside it — the text names the rectangle for `rect:NAME`
|
||||||
|
(rectangles have no name of their own in the IPC API). One layer
|
||||||
|
serves supplies and loads alike; the role comes from the config.
|
||||||
|
- On launch the plugin resolves every reference against the board and
|
||||||
|
shows the terminal tables in the dialog with the geometry read-only
|
||||||
|
(a config file is authoritative for *which* terminals exist) but
|
||||||
|
**everything else editable** — the I / R_out / V_oc columns, the
|
||||||
|
Layer scope, the Active checkbox, the Comment, the net, and the
|
||||||
|
Mode selector itself (the file's `mode` is only where the dialog
|
||||||
|
starts; switching to classic and saving keeps the terminals section
|
||||||
|
intact). Tweak a value, OK runs with it, Save config… writes it
|
||||||
|
back.
|
||||||
|
The Layer combo edits the terminal-level `contact` key (part-level
|
||||||
|
contacts inside `parts` keep winning, per the schema); structural
|
||||||
|
edits (adding terminals, changing parts) happen in the file.
|
||||||
|
- Contact models are **fixed** in PDN mode: supplies attach through
|
||||||
|
their output resistance (per-cell equal conductances), loads inject
|
||||||
|
uniformly (the classic "uniform" model). The contact-model setting
|
||||||
|
is ignored with a note.
|
||||||
|
- Current must be able to flow: every load must sit on copper
|
||||||
|
connected to at least one supply (through vias counts), and a load
|
||||||
|
spanning disconnected sheets is an error. Two supplies with unequal
|
||||||
|
`v_oc` on the same copper exchange a circulating current — that is
|
||||||
|
physics, not a bug.
|
||||||
|
- `power balance` in `summary.txt` is the Tellegen check: source power
|
||||||
|
= copper loss + `r_out` loss + load power. At `freq_hz > 0` all
|
||||||
|
draws are assumed **in phase** (worst case), same skin-only caveats
|
||||||
|
as classic AC.
|
||||||
|
- The `geometry_dump.json` of a PDN run embeds the full terminal set
|
||||||
|
(schema v8), so `standalone.py` re-solves it offline with no extra
|
||||||
|
flags; older dumps load unchanged.
|
||||||
|
|
||||||
## Model & limits
|
## Model & limits
|
||||||
|
|
||||||
- Sheet model per layer: R□ = ρ/t, ρ = 1.68e-8 Ωm (20 °C), t from the
|
- Sheet model per layer: R□ = ρ/t, ρ = 1.68e-8 Ωm (20 °C), t from the
|
||||||
board's physical stackup. Layer z-positions from the stackup drive the
|
board's physical stackup. Layer z-positions from the stackup drive the
|
||||||
barrel lengths.
|
barrel lengths.
|
||||||
- Via/pad barrels: thin-wall annulus, R = ρ·L/(π·d·t_plating),
|
- Via/pad barrels: thin-wall annulus, R = ρ·L/(π·d·t_plating),
|
||||||
`VIA_PLATING_UM = 18` in `fill_resistance/config.py`. Vias are always
|
`VIA_PLATING_UM = 18` in `fill_resistance/config.py` (or
|
||||||
|
`physics.via_plating_um` in the config file). Vias are always
|
||||||
plated. Each via also contributes its **ring/pad copper** (a
|
plated. Each via also contributes its **ring/pad copper** (a
|
||||||
full-thickness disc of the pad diameter on every spanned layer) and
|
full-thickness disc of the pad diameter on every spanned layer) and
|
||||||
its **drill mouth**, area-weighted per cell: with the **"vias filled +
|
its **drill mouth**, area-weighted per cell: with the **"vias filled +
|
||||||
@@ -392,9 +671,16 @@ Every run writes `geometry_dump.json`; re-solve without KiCad:
|
|||||||
```sh
|
```sh
|
||||||
uv run python -m fill_resistance.standalone dump.json
|
uv run python -m fill_resistance.standalone dump.json
|
||||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
|
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
|
||||||
[--out DIR] [--force-iterative]
|
[--out DIR] [--force-iterative] [--config fill_res_config.json]
|
||||||
|
[--v-nominal 3.3]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--config` applies a config file's `run` section as defaults under the
|
||||||
|
explicit flags (CLI wins; the dump already bakes geometry and
|
||||||
|
physics). PDN dumps (schema v8) re-solve their embedded terminal set
|
||||||
|
automatically; `--v-nominal` overrides the default supply open-circuit
|
||||||
|
voltage there.
|
||||||
|
|
||||||
Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
|
Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
|
||||||
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
|
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
|
||||||
stays: KiCad builds the plugin's runtime venv from it):
|
stays: KiCad builds the plugin's runtime venv from it):
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Fill Resistance run configuration - classic mode.
|
||||||
|
// Copy next to your board as "fill_res_config.json" (or
|
||||||
|
// "fill_res_config.<name>.json" - the config named "default" loads
|
||||||
|
// automatically, others via the dialog's Load config... button; or
|
||||||
|
// "<board stem>.fill_res_config.json" when several boards share the
|
||||||
|
// directory). Full-line comments like these are allowed; keys starting
|
||||||
|
// with "_" are ignored everywhere. Every key except "version" is
|
||||||
|
// optional - the dialog's Save config... button writes this file too.
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"mode": "classic",
|
||||||
|
"run": {
|
||||||
|
"net": "VOUT+",
|
||||||
|
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
|
||||||
|
"include_tracks": true,
|
||||||
|
"vias_capped": true,
|
||||||
|
"cap_max_drill_mm": 0.5,
|
||||||
|
"adaptive": true,
|
||||||
|
"cell_um": null,
|
||||||
|
"freq_hz": "142k",
|
||||||
|
"contact_model": "uniform",
|
||||||
|
"include_buildup": false,
|
||||||
|
"extra_cu_um": 0.0,
|
||||||
|
"push_overlays": false,
|
||||||
|
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
|
||||||
|
},
|
||||||
|
"classic": {
|
||||||
|
"current_a": 40.0,
|
||||||
|
"contact1": "auto",
|
||||||
|
"contact2": "auto",
|
||||||
|
"_comment": "pos/neg omitted -> terminals come from the board as usual (selection / User.1+User.2 rectangles). With both present the file fully specifies the terminals:",
|
||||||
|
"pos": ["J1.1"],
|
||||||
|
"neg": ["J2.1", "J2.2"]
|
||||||
|
},
|
||||||
|
"physics": {
|
||||||
|
"via_plating_um": 25.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// PDN study of the 3.3 V rail: one buck output + a backup LDO feeding
|
||||||
|
// three loads. Loads sink fixed currents; supplies are Thevenin
|
||||||
|
// sources (v_oc behind r_out_ohm; v_oc defaults to run.v_nominal).
|
||||||
|
// "rect:CAM_ZONE" names a rectangle drawn on User.3 with a text item
|
||||||
|
// "CAM_ZONE" placed inside it. Part references: "U7" = all pads of U7
|
||||||
|
// on the run net, "U1.SW2" = one pad, {"rect_mm": ...} = explicit
|
||||||
|
// area, {"via_mm": ...} = nearest via.
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"mode": "pdn",
|
||||||
|
"run": {
|
||||||
|
"net": "VCC_3V3",
|
||||||
|
"v_nominal": 3.30,
|
||||||
|
"freq_hz": 0,
|
||||||
|
"adaptive": true
|
||||||
|
},
|
||||||
|
"terminals": [
|
||||||
|
{"name": "buck", "role": "supply",
|
||||||
|
"parts": ["U1.SW2", "U1.SW3"],
|
||||||
|
"r_out_ohm": 0.004},
|
||||||
|
{"name": "ldo_backup", "role": "supply",
|
||||||
|
"parts": ["U2.OUT"],
|
||||||
|
"r_out_ohm": 0.050, "v_oc": 3.28},
|
||||||
|
// bonded: all of U7's pins are one internally-joined lug -
|
||||||
|
// the 1.8 A total is prescribed, the per-pin split is a solve
|
||||||
|
// outcome (default false = per-cell area share instead)
|
||||||
|
{"name": "mcu_core", "role": "load",
|
||||||
|
"parts": ["U7"],
|
||||||
|
"i_draw_a": 1.8, "bonded": true,
|
||||||
|
"comment": "worst-case core draw, DS table 5-2"},
|
||||||
|
{"name": "camera_module", "role": "load",
|
||||||
|
"parts": ["rect:CAM_ZONE"],
|
||||||
|
"i_draw_a": 0.35, "contact": "F.Cu"},
|
||||||
|
{"name": "heater", "role": "load",
|
||||||
|
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
|
||||||
|
"contact": "B.Cu"},
|
||||||
|
{"via_mm": [115.2, 42.1]}],
|
||||||
|
"i_draw_a": 2.5}
|
||||||
|
],
|
||||||
|
"markers": {"pdn_layer": "User.3"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
PDN mode, a full configuration-file workflow and a reworked dialog -
|
||||||
|
all opt-in: classic runs behave exactly as in 1.3.0 (the analytic test
|
||||||
|
suite runs unchanged against the same solver paths).
|
||||||
|
|
||||||
|
PDN mode - multiple supplies and loads on one net:
|
||||||
|
|
||||||
|
- Instead of one driven terminal pair, a run can now model a power
|
||||||
|
rail: any number of supply points, each a Thevenin source with a
|
||||||
|
configurable output resistance (and optionally its own open-circuit
|
||||||
|
voltage), plus any number of loads, each drawing its own prescribed
|
||||||
|
current. The solve runs in absolute volts and reports the IR-drop
|
||||||
|
map, each supply's delivered current (the Thevenin split - an
|
||||||
|
outcome, not an input), and each load's mean and worst-case contact
|
||||||
|
voltage, alongside the usual per-layer dissipation, |J| maps and
|
||||||
|
per-via currents.
|
||||||
|
- The summary carries a source-sink pair table: for every supply x
|
||||||
|
load pair, the effective COPPER resistance between the two contacts
|
||||||
|
(operating-point independent, source R_out excluded; one extra
|
||||||
|
linear solve per terminal, deferred-corrected on the adaptive grid
|
||||||
|
too) plus the copper loss attributed to the pair by proportional
|
||||||
|
sharing - an attribution convention, but it sums exactly to the
|
||||||
|
total copper dissipation, never crosses a copper gap, and pairs
|
||||||
|
without a common copper path report "no path". The same table is
|
||||||
|
also rendered as a figure (5_source_sink_pairs.png) alongside the
|
||||||
|
field maps; terminals are keyed by their unique labels (no
|
||||||
|
positional tags), and both outputs note each terminal's component
|
||||||
|
hint and comment - the summary in the supplies/loads tables, the
|
||||||
|
figure in a terminals legend underneath.
|
||||||
|
- The consistency check generalizes: source power = copper loss +
|
||||||
|
output-resistance loss + load power (Tellegen), verified on every
|
||||||
|
run. Both the uniform-reference and the adaptive grid support PDN
|
||||||
|
mode; geometry dumps embed the terminal set (schema v8) and re-solve
|
||||||
|
offline via standalone.py with no extra flags. Older dumps load
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
The dialog editor - PDN runs need no JSON at all:
|
||||||
|
|
||||||
|
- The dialog gained a Mode selector: Classic (unchanged - and simply
|
||||||
|
called that; a "two-terminal" label would read like a 2-contact cap,
|
||||||
|
but classic terminals can bundle many contact parts) or PDN. In PDN
|
||||||
|
mode, rectangles on User.1 are supply terminals and rectangles on
|
||||||
|
User.2 are load terminals - the same marker layers as classic, but
|
||||||
|
each rectangle is its OWN terminal instead of being merged into one
|
||||||
|
V+/V- contact. Two editable tables - one for supplies, one for
|
||||||
|
loads, each titled with the marker layer its rectangles come from -
|
||||||
|
assign each load its current draw and each supply its output
|
||||||
|
resistance (plus an optional open-circuit voltage; empty = the
|
||||||
|
V-nominal field). PDN is selectable whenever both layers carry at
|
||||||
|
least one rectangle; otherwise the radio is disabled with the reason
|
||||||
|
shown, and a classic-mode failure (nothing selected, no marker pair)
|
||||||
|
no longer kills the launch when PDN rectangles exist - the dialog
|
||||||
|
opens in the mode that works.
|
||||||
|
- A text item placed inside a rectangle names the terminal; unnamed
|
||||||
|
rectangles get automatic names (S1../L1.., stable reading order).
|
||||||
|
- A read-only Component column identifies each row: the footprint
|
||||||
|
whose pad intersects the contact area (e.g. "U5"), or "near U5"
|
||||||
|
when nothing touches it. Purely spatial - it names where the
|
||||||
|
terminal sits, it plays no electrical role.
|
||||||
|
- Every terminal row picks its contacted copper in a Layer combo,
|
||||||
|
like the classic contact scopes: "All selected layers" (a
|
||||||
|
rectangle's natural bolted-lug scope) or one specific layer. The
|
||||||
|
choice is saved as the terminal-level "contact" key.
|
||||||
|
- Every row has an Active checkbox: unchecking it disregards the
|
||||||
|
terminal (no solve, value cells may stay blank) WITHOUT deleting it
|
||||||
|
- the row is saved as "active": false and can be re-enabled later.
|
||||||
|
A free-text Comment column is saved with each terminal ("comment"
|
||||||
|
key). Both are editable in config-backed setups too.
|
||||||
|
- The tables show only the rectangles that actually sit on the
|
||||||
|
selected net: switching the net swaps the visible set, the totals
|
||||||
|
line counts the hidden rows, and hidden rows take no part in the
|
||||||
|
run - not validated, not solved. Saving keeps them anyway: every
|
||||||
|
row lands in the config file, off-net ones as "active": false with
|
||||||
|
their values and comments intact, and config-backed runs apply the
|
||||||
|
same per-net filter so archived terminals are skipped, never fatal.
|
||||||
|
- The terminal tables are height-resizable: each sizes itself to its
|
||||||
|
rows, a drag handle between the two redistributes space, and
|
||||||
|
enlarging the dialog grows them. In PDN mode the dialog opens at
|
||||||
|
~60% of the screen height (capped at 85%/90% of the screen; the
|
||||||
|
KiCad window itself is not reachable through the IPC API, so the
|
||||||
|
screen is the reference). The whole form scrolls when it outgrows
|
||||||
|
the screen (the error line and the buttons always stay visible at
|
||||||
|
the bottom).
|
||||||
|
- Numbers understand SI suffixes: 50m = 0.05, 4.7k = 4700, 2M = 2e6
|
||||||
|
(case separates milli from mega). This works in every dialog number
|
||||||
|
field (R_out, V_oc, I draw, V nominal, test current, cell size,
|
||||||
|
thresholds), in the config file (any number may be a string:
|
||||||
|
"r_out_ohm": "50m"), and in the CLI's --current / --cell-um /
|
||||||
|
--v-nominal. The frequency field keeps its own grammar (142k, 1.5M -
|
||||||
|
a lone m means MHz there, as before).
|
||||||
|
|
||||||
|
Bonded terminals - a package's total current with a free per-pin split:
|
||||||
|
|
||||||
|
- A terminal (config key "bonded": true; in the editor, simply give
|
||||||
|
several rectangles the same name) shorts all its contacts into one
|
||||||
|
lug, the way a multi-pin package joins its pins with internal metal:
|
||||||
|
the TOTAL current stays prescribed, but which contact carries how
|
||||||
|
much becomes a solve outcome instead of the default per-cell area
|
||||||
|
share. Works for loads and supplies (a bonded supply is an
|
||||||
|
equipotential lug with its whole output resistance in series), on
|
||||||
|
both grids, and the reported per-part currents are the computed
|
||||||
|
boundary fluxes. A bonded load may even span disconnected copper
|
||||||
|
sheets - the bond is the connection.
|
||||||
|
|
||||||
|
Configuration file - the run, fully specified next to the board:
|
||||||
|
|
||||||
|
- A JSON config next to the board file can specify a complete run:
|
||||||
|
every dialog field, the classic terminals by board reference
|
||||||
|
(skipping the selection / marker-rectangle step), the select
|
||||||
|
physics constants that previously required editing config.py
|
||||||
|
(resistivity, copper thickness, via plating), the marker layer
|
||||||
|
names - or the whole PDN terminal set. Precedence is simple:
|
||||||
|
config.py defaults < config file < dialog edits; the file pre-fills
|
||||||
|
the dialog, what the dialog shows is what runs.
|
||||||
|
- Terminals are written by board reference: "U7" (all pads of a
|
||||||
|
footprint on the net), "U7.3" (one pad), "rect:NAME" (a rectangle
|
||||||
|
named by a text item placed inside it - searched on User.3, User.1
|
||||||
|
and User.2, so names must be unique across the marker layers),
|
||||||
|
explicit rectangles or nearest-via coordinates.
|
||||||
|
- Configs can be kept side by side as "fill_res_config.<name>.json":
|
||||||
|
the config named "default" loads automatically (plain
|
||||||
|
"fill_res_config.json" is its legacy spelling, and a board-specific
|
||||||
|
"<stem>.fill_res_config.json" wins over both), and the Load
|
||||||
|
config... button in the dialog pulls in any other config for this
|
||||||
|
run - the dialog re-opens seeded entirely from the picked file.
|
||||||
|
Save config... asks for the target file name each time (pre-filled
|
||||||
|
with the loaded config; ".json" appended when omitted), so writing
|
||||||
|
back and branching a variant are both one click; a name outside the
|
||||||
|
auto-load set prints a Load-config reminder.
|
||||||
|
- Save config... works in both modes: classic saves write the run
|
||||||
|
parameters and contact scopes, PDN saves write the whole terminal
|
||||||
|
set - named rectangles as live "rect:NAME" references (they follow
|
||||||
|
the rectangle wherever it moves), unnamed ones as frozen rect_mm
|
||||||
|
coordinates, so label your rectangles if the layout is still
|
||||||
|
moving. Full-line // comments and "_"-prefixed keys are allowed;
|
||||||
|
invalid files stop the run with the offending key path instead of
|
||||||
|
silently running defaults; unknown keys warn (typo guard).
|
||||||
|
- A config never pins anything. Its mode is only the STARTING mode
|
||||||
|
(a classic-mode file may carry a terminals section and vice versa),
|
||||||
|
the net stays switchable, values / layer scopes / active flags /
|
||||||
|
comments are editable per run and written back on save (part
|
||||||
|
references are preserved verbatim; part-level contacts keep winning
|
||||||
|
over the terminal scope), and a classic save over a PDN config
|
||||||
|
keeps the whole terminals section - it just flips the starting
|
||||||
|
mode. A broken terminal reference disables PDN mode with the reason
|
||||||
|
shown instead of killing the launch.
|
||||||
|
- A config-backed set is open-ended: any marker rectangle the file
|
||||||
|
does not reference yet appears as a NEW terminal row (a note under
|
||||||
|
the tables counts them) and Save config... appends it to the file.
|
||||||
|
A rectangle named after an existing rect:NAME terminal instead
|
||||||
|
joins that terminal as another contact part at resolve time. A
|
||||||
|
label colliding with an unrelated terminal name is skipped with a
|
||||||
|
note; colliding auto names are renumbered.
|
||||||
|
- standalone.py gained --config (run-parameter defaults under the
|
||||||
|
explicit flags) and --v-nominal.
|
||||||
@@ -4,4 +4,4 @@ __version__ is the runtime source of truth (metadata.json and
|
|||||||
pyproject.toml are not deployed with the plugin); a test keeps the
|
pyproject.toml are not deployed with the plugin); a test keeps the
|
||||||
three in sync.
|
three in sync.
|
||||||
"""
|
"""
|
||||||
__version__ = "1.3.0"
|
__version__ = "1.4.0"
|
||||||
|
|||||||
+359
-95
@@ -79,23 +79,22 @@ def _leaf_gradients(N: int, a: np.ndarray, b: np.ndarray, cx: np.ndarray,
|
|||||||
return gx, gy
|
return gx, gy
|
||||||
|
|
||||||
|
|
||||||
def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
def _leaf_graph(problem: Problem, stack: RasterStack, sigmas: list,
|
||||||
e1: np.ndarray, e2: np.ndarray, i_test: float,
|
via_factor: float, sigma_buildup: float,
|
||||||
freq_hz: float, contact_model: str,
|
keep_extra: np.ndarray):
|
||||||
parts1: list | None,
|
"""Per-layer quadtree leaf graphs + their edge set, shared by the
|
||||||
parts2: list | None) -> sv.Result:
|
classic and PDN adaptive solves (pure code motion out of
|
||||||
timings = {}
|
run_solve_adaptive). keep_extra: feature cells the caller pins at
|
||||||
|
the fine size (classic: e1|e2; PDN: the OR of every terminal's
|
||||||
|
contact mask, so contact nodes stay 1:1 with cells and per-node
|
||||||
|
injection equals per-cell); chain / buildup / thickness-scaled /
|
||||||
|
barrel-attachment cells are pinned here on top. Returns (grids,
|
||||||
|
offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg, teq_leaves);
|
||||||
|
the dead-barrel count travels in edges.dead_barrels."""
|
||||||
L, ny, nx = stack.masks.shape
|
L, ny, nx = stack.masks.shape
|
||||||
h_m = stack.h_nm * 1e-9
|
|
||||||
plane = ny * nx
|
plane = ny * nx
|
||||||
|
|
||||||
sigmas, rs_ratios, via_factor, sigma_buildup = \
|
|
||||||
sv._conductance_params(problem, stack, freq_hz)
|
|
||||||
|
|
||||||
# --- leaves per layer -------------------------------------------------
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
links, dead_barrels = sv._barrel_links(stack, problem)
|
links, dead_barrels = sv._barrel_links(stack, problem)
|
||||||
keep = e1 | e2
|
keep = keep_extra.copy()
|
||||||
if stack.chain is not None:
|
if stack.chain is not None:
|
||||||
keep |= stack.chain
|
keep |= stack.chain
|
||||||
if stack.buildup is not None:
|
if stack.buildup is not None:
|
||||||
@@ -213,6 +212,128 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
|||||||
e_delta = np.concatenate(dd)
|
e_delta = np.concatenate(dd)
|
||||||
e_axis = np.concatenate(xx)
|
e_axis = np.concatenate(xx)
|
||||||
e_layer = np.concatenate(ee)
|
e_layer = np.concatenate(ee)
|
||||||
|
return (grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
|
||||||
|
teq_leaves)
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_fields(problem: Problem, stack: RasterStack, grids: list,
|
||||||
|
offs: np.ndarray, N: int, edges: sv.Edges,
|
||||||
|
e_axis: np.ndarray, e_layer: np.ndarray,
|
||||||
|
cxg: np.ndarray, cyg: np.ndarray, teq_leaves: list,
|
||||||
|
Vflat: np.ndarray, Ie: np.ndarray, s: float):
|
||||||
|
"""Leaf-space powers, via reports, mesh overlay and the fine-grid
|
||||||
|
V/J/P expansion - shared by the classic and PDN adaptive solves
|
||||||
|
(pure code motion out of run_solve_adaptive). PDN appends virtual
|
||||||
|
supply nodes after N and tags its attachment edges PDN_EDGE: the
|
||||||
|
in-plane selection (via_index == -1) and the via selection (>= 0)
|
||||||
|
keep them out of every copper field and report here. Returns
|
||||||
|
(Pe, P_layers, P_vias, via_reports, V3, J3, Parea)."""
|
||||||
|
L, ny, nx = stack.masks.shape
|
||||||
|
h_m = stack.h_nm * 1e-9
|
||||||
|
|
||||||
|
# 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 == -1
|
||||||
|
Pnode = np.zeros(N)
|
||||||
|
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
|
||||||
|
np.add.at(Pnode, edges.b[inplane], 0.5 * Pe[inplane])
|
||||||
|
P_layers = [float(Pnode[offs[li]:offs[li + 1]].sum()) for li in range(L)]
|
||||||
|
P_vias = float(Pe[edges.via_index >= 0].sum())
|
||||||
|
|
||||||
|
via_reports = []
|
||||||
|
if problem.vias:
|
||||||
|
vidx = edges.via_index
|
||||||
|
for vi in np.unique(vidx[vidx >= 0]):
|
||||||
|
sel = vidx == vi
|
||||||
|
via = problem.vias[vi]
|
||||||
|
via_reports.append(sv.ViaReport(
|
||||||
|
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
|
||||||
|
drill_mm=via.drill_nm * 1e-6,
|
||||||
|
current_a=float(np.abs(Ie[sel]).max()) * s,
|
||||||
|
power_w=float(Pe[sel].sum()),
|
||||||
|
))
|
||||||
|
via_reports.sort(key=lambda v: v.current_a, reverse=True)
|
||||||
|
|
||||||
|
# leaf boundaries for the raster map: draw the coarse mesh structure
|
||||||
|
# (fine regions stay plain copper = fully resolved)
|
||||||
|
stack.mesh = np.zeros_like(stack.masks)
|
||||||
|
for li in range(L):
|
||||||
|
if grids[li].n == 0:
|
||||||
|
continue
|
||||||
|
ids = grids[li].id_grid
|
||||||
|
b = np.zeros_like(stack.masks[li])
|
||||||
|
b[:, 1:] |= ids[:, 1:] != ids[:, :-1]
|
||||||
|
b[1:, :] |= ids[1:, :] != ids[:-1, :]
|
||||||
|
coarse = grids[li].size[np.maximum(ids, 0)] >= 2
|
||||||
|
stack.mesh[li] = b & coarse & stack.masks[li]
|
||||||
|
|
||||||
|
# piecewise-LINEAR potential expansion from the leaf gradients of the
|
||||||
|
# final solution: constant-per-leaf expansion shows leaf-sized
|
||||||
|
# staircase corners in the equipotential contours on coarse interiors
|
||||||
|
faces = e_axis >= 0
|
||||||
|
fa, fb = edges.a[faces], edges.b[faces]
|
||||||
|
if faces.any():
|
||||||
|
dgx, dgy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
|
||||||
|
else:
|
||||||
|
dgx = dgy = np.zeros(N)
|
||||||
|
|
||||||
|
V3 = np.full((L, ny, nx), np.nan)
|
||||||
|
J3 = np.full((L, ny, nx), np.nan)
|
||||||
|
Parea = np.full((L, ny, nx), np.nan)
|
||||||
|
for li in range(L):
|
||||||
|
g_ = grids[li]
|
||||||
|
ids = g_.id_grid
|
||||||
|
m = stack.masks[li]
|
||||||
|
ii, jj = np.nonzero(m)
|
||||||
|
gid = offs[li] + ids[ii, jj]
|
||||||
|
V3[li][ii, jj] = (Vflat[gid]
|
||||||
|
+ dgx[gid] * (jj + 0.5 - cxg[gid])
|
||||||
|
+ dgy[gid] * (ii + 0.5 - cyg[gid])) * s
|
||||||
|
|
||||||
|
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)
|
||||||
|
for axis, acc in ((0, Ixn), (1, Iyn)):
|
||||||
|
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])
|
||||||
|
J3[li][m] = Jl[ids[m]] * s
|
||||||
|
|
||||||
|
cellP = Pnode[offs[li]:offs[li + 1]] \
|
||||||
|
/ (g_.size.astype(float) ** 2 * h_m * h_m)
|
||||||
|
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
|
||||||
|
# chain cells accumulate no leaf-face currents (their links carry
|
||||||
|
# axis -1): overlay the true 1D link density
|
||||||
|
sv.overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
|
||||||
|
return Pe, P_layers, P_vias, via_reports, V3, J3, Parea
|
||||||
|
|
||||||
|
|
||||||
|
def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||||
|
e1: np.ndarray, e2: np.ndarray, i_test: float,
|
||||||
|
freq_hz: float, contact_model: str,
|
||||||
|
parts1: list | None,
|
||||||
|
parts2: list | None) -> sv.Result:
|
||||||
|
timings = {}
|
||||||
|
L, ny, nx = stack.masks.shape
|
||||||
|
h_m = stack.h_nm * 1e-9
|
||||||
|
|
||||||
|
sigmas, rs_ratios, via_factor, sigma_buildup = \
|
||||||
|
sv._conductance_params(problem, stack, freq_hz)
|
||||||
|
|
||||||
|
# --- leaves per layer -------------------------------------------------
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
(grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
|
||||||
|
teq_leaves) = _leaf_graph(problem, stack, sigmas, via_factor,
|
||||||
|
sigma_buildup, e1 | e2)
|
||||||
|
dead_barrels = edges.dead_barrels
|
||||||
|
|
||||||
# --- connectivity restriction on the leaf graph -----------------------
|
# --- connectivity restriction on the leaf graph -----------------------
|
||||||
graph = sparse.coo_matrix(
|
graph = sparse.coo_matrix(
|
||||||
@@ -343,15 +464,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
|||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
s = i_test * volts_per_amp
|
s = i_test * volts_per_amp
|
||||||
|
|
||||||
# edge power = dV * I_corrected: sums exactly to I^2 R (KCL identity);
|
Pe, P_layers, P_vias, via_reports, V3, J3, Parea = _expand_fields(
|
||||||
# individual transition faces can go slightly negative
|
problem, stack, grids, offs, N, edges, e_axis, e_layer, cxg, cyg,
|
||||||
Pe = (Vflat[edges.a] - Vflat[edges.b]) * Ie * s * s
|
teq_leaves, Vflat, Ie, s)
|
||||||
inplane = edges.via_index < 0
|
|
||||||
Pnode = np.zeros(N)
|
|
||||||
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
|
|
||||||
np.add.at(Pnode, edges.b[inplane], 0.5 * Pe[inplane])
|
|
||||||
P_layers = [float(Pnode[offs[li]:offs[li + 1]].sum()) for li in range(L)]
|
|
||||||
P_vias = float(Pe[~inplane].sum())
|
|
||||||
P_total = i_test ** 2 * R
|
P_total = i_test ** 2 * R
|
||||||
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
|
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
|
||||||
if not np.isfinite(balance) or balance > 1e-3:
|
if not np.isfinite(balance) or balance > 1e-3:
|
||||||
@@ -362,20 +477,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
|||||||
f"different grid size."
|
f"different grid size."
|
||||||
)
|
)
|
||||||
|
|
||||||
via_reports = []
|
|
||||||
if problem.vias:
|
|
||||||
vidx = edges.via_index
|
|
||||||
for vi in np.unique(vidx[vidx >= 0]):
|
|
||||||
sel = vidx == vi
|
|
||||||
via = problem.vias[vi]
|
|
||||||
via_reports.append(sv.ViaReport(
|
|
||||||
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
|
|
||||||
drill_mm=via.drill_nm * 1e-6,
|
|
||||||
current_a=float(np.abs(Ie[sel]).max()) * s,
|
|
||||||
power_w=float(Pe[sel].sum()),
|
|
||||||
))
|
|
||||||
via_reports.sort(key=lambda v: v.current_a, reverse=True)
|
|
||||||
|
|
||||||
def part_currents(parts, e_nodes, n_total_cells):
|
def part_currents(parts, e_nodes, n_total_cells):
|
||||||
out = []
|
out = []
|
||||||
for label, mask3 in (parts or []):
|
for label, mask3 in (parts or []):
|
||||||
@@ -394,64 +495,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
|||||||
|
|
||||||
part_currents1 = part_currents(parts1, e1n, int(e1.sum()))
|
part_currents1 = part_currents(parts1, e1n, int(e1.sum()))
|
||||||
part_currents2 = part_currents(parts2, e2n, int(e2.sum()))
|
part_currents2 = part_currents(parts2, e2n, int(e2.sum()))
|
||||||
|
|
||||||
# leaf boundaries for the raster map: draw the coarse mesh structure
|
|
||||||
# (fine regions stay plain copper = fully resolved)
|
|
||||||
stack.mesh = np.zeros_like(stack.masks)
|
|
||||||
for li in range(L):
|
|
||||||
if grids[li].n == 0:
|
|
||||||
continue
|
|
||||||
ids = grids[li].id_grid
|
|
||||||
b = np.zeros_like(stack.masks[li])
|
|
||||||
b[:, 1:] |= ids[:, 1:] != ids[:, :-1]
|
|
||||||
b[1:, :] |= ids[1:, :] != ids[:-1, :]
|
|
||||||
coarse = grids[li].size[np.maximum(ids, 0)] >= 2
|
|
||||||
stack.mesh[li] = b & coarse & stack.masks[li]
|
|
||||||
|
|
||||||
# piecewise-LINEAR potential expansion from the leaf gradients of the
|
|
||||||
# final solution: constant-per-leaf expansion shows leaf-sized
|
|
||||||
# staircase corners in the equipotential contours on coarse interiors
|
|
||||||
if faces.any():
|
|
||||||
dgx, dgy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
|
|
||||||
else:
|
|
||||||
dgx = dgy = np.zeros(N)
|
|
||||||
|
|
||||||
V3 = np.full((L, ny, nx), np.nan)
|
|
||||||
J3 = np.full((L, ny, nx), np.nan)
|
|
||||||
Parea = np.full((L, ny, nx), np.nan)
|
|
||||||
for li in range(L):
|
|
||||||
g_ = grids[li]
|
|
||||||
ids = g_.id_grid
|
|
||||||
m = stack.masks[li]
|
|
||||||
Vl = Vflat[offs[li]:offs[li + 1]]
|
|
||||||
ii, jj = np.nonzero(m)
|
|
||||||
gid = offs[li] + ids[ii, jj]
|
|
||||||
V3[li][ii, jj] = (Vflat[gid]
|
|
||||||
+ dgx[gid] * (jj + 0.5 - cxg[gid])
|
|
||||||
+ dgy[gid] * (ii + 0.5 - cyg[gid])) * s
|
|
||||||
|
|
||||||
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)
|
|
||||||
for axis, acc in ((0, Ixn), (1, Iyn)):
|
|
||||||
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])
|
|
||||||
J3[li][m] = Jl[ids[m]] * s
|
|
||||||
|
|
||||||
cellP = Pnode[offs[li]:offs[li + 1]] \
|
|
||||||
/ (g_.size.astype(float) ** 2 * h_m * h_m)
|
|
||||||
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
|
|
||||||
# chain cells accumulate no leaf-face currents (their links carry
|
|
||||||
# axis -1): overlay the true 1D link density
|
|
||||||
sv.overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
|
|
||||||
timings["postprocess_s"] = time.perf_counter() - t0
|
timings["postprocess_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
return sv.Result(
|
return sv.Result(
|
||||||
@@ -469,3 +512,224 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
|||||||
rs_ratios=rs_ratios,
|
rs_ratios=rs_ratios,
|
||||||
timings=timings,
|
timings=timings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_solve_adaptive_pdn(problem: Problem, stack: RasterStack,
|
||||||
|
term_masks: list, term_parts: list,
|
||||||
|
freq_hz: float, v_nominal: float) -> sv.Result:
|
||||||
|
"""PDN solve on the leaf graph (dispatched from solver.run_solve_pdn,
|
||||||
|
which already labeled and validated the terminals). Same electrical
|
||||||
|
model as the uniform-grid path: Thevenin supplies as virtual
|
||||||
|
Dirichlet nodes appended after the leaf id space, loads as uniform
|
||||||
|
per-cell injection. Contact cells are pinned fine by _leaf_graph, so
|
||||||
|
leaf nodes and contact cells are 1:1 and the per-node quantities
|
||||||
|
match the uniform grid exactly there. The deferred-correction loop
|
||||||
|
is unchanged: supply attachment edges carry e_axis = -1 / e_delta =
|
||||||
|
0, so they are excluded from the gradient reconstruction and get
|
||||||
|
zero correction (their currents stay exactly w * dV)."""
|
||||||
|
timings = {}
|
||||||
|
L, ny, nx = stack.masks.shape
|
||||||
|
terminals = problem.terminals
|
||||||
|
|
||||||
|
sigmas, rs_ratios, via_factor, sigma_buildup = \
|
||||||
|
sv._conductance_params(problem, stack, freq_hz)
|
||||||
|
|
||||||
|
# --- leaves per layer -------------------------------------------------
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
keep_extra = np.zeros_like(stack.masks)
|
||||||
|
for m in term_masks:
|
||||||
|
keep_extra |= m
|
||||||
|
(grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
|
||||||
|
teq_leaves) = _leaf_graph(problem, stack, sigmas, via_factor,
|
||||||
|
sigma_buildup, keep_extra)
|
||||||
|
dead_barrels = edges.dead_barrels
|
||||||
|
|
||||||
|
# --- connectivity restriction on the leaf graph (PDN keep rule) -------
|
||||||
|
graph = sparse.coo_matrix(
|
||||||
|
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(N, N))
|
||||||
|
_, labels = csgraph.connected_components(graph, directed=False)
|
||||||
|
term_nodes_all = []
|
||||||
|
for m in term_masks:
|
||||||
|
tn = np.zeros(N, dtype=bool)
|
||||||
|
for li in range(L):
|
||||||
|
tn[_nodes_of_cells(grids, offs, li, m[li])] = True
|
||||||
|
term_nodes_all.append(tn)
|
||||||
|
per_term = [set(np.unique(labels[tn]).tolist()) if tn.any() else set()
|
||||||
|
for tn in term_nodes_all]
|
||||||
|
kept = sv._pdn_keep_components(terminals, per_term)
|
||||||
|
keepn = np.isin(labels, sorted(kept))
|
||||||
|
if not keepn.all():
|
||||||
|
sel = keepn[edges.a] & keepn[edges.b]
|
||||||
|
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):
|
||||||
|
if grids[li].n == 0:
|
||||||
|
continue
|
||||||
|
ids = grids[li].id_grid
|
||||||
|
kept_cells = (ids >= 0) & keepn[offs[li] + np.maximum(ids, 0)]
|
||||||
|
stack.masks[li] &= kept_cells
|
||||||
|
for m in term_masks:
|
||||||
|
m[li] &= kept_cells
|
||||||
|
if stack.buildup is not None:
|
||||||
|
stack.buildup &= stack.masks
|
||||||
|
if stack.chain is not None:
|
||||||
|
stack.chain &= stack.masks
|
||||||
|
for tn in term_nodes_all:
|
||||||
|
tn &= keepn
|
||||||
|
for t, m in zip(terminals, term_masks):
|
||||||
|
if t.role == "supply" and not m.any():
|
||||||
|
print(f"warning: supply '{t.label}' only touches copper "
|
||||||
|
f"not connected to any load - it delivers 0 A")
|
||||||
|
for t, parts in zip(terminals, term_parts):
|
||||||
|
for label, m in parts:
|
||||||
|
had = bool(m.any())
|
||||||
|
m &= stack.masks
|
||||||
|
if had and not m.any():
|
||||||
|
print(f"warning: contact part '{label}' of {t.role} "
|
||||||
|
f"'{t.label}' only touches disconnected copper - "
|
||||||
|
f"it carries no current")
|
||||||
|
timings["edges_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
# --- solve with deferred-correction interface fluxes -------------------
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
state = np.zeros(N, dtype=np.uint8)
|
||||||
|
state[keepn] = 1
|
||||||
|
term_nodes = [np.flatnonzero(tn) for tn in term_nodes_all]
|
||||||
|
state_base = state.copy() # copper-only state for _pdn_pairs
|
||||||
|
state, dirichlet_v, inj, edges_ext, attaches, merge = sv._pdn_attach(
|
||||||
|
terminals, term_nodes, state, edges, v_nominal)
|
||||||
|
n_pdn = len(edges_ext.a) - len(edges.a)
|
||||||
|
e_delta = np.concatenate([e_delta, np.zeros(n_pdn)])
|
||||||
|
e_axis = np.concatenate([e_axis, np.full(n_pdn, -1, dtype=np.int8)])
|
||||||
|
e_layer = np.concatenate([e_layer, np.full(n_pdn, -1, dtype=np.int16)])
|
||||||
|
# bonded terminals: solve on the merge-relabeled edges; contact
|
||||||
|
# cells are pinned fine, so every face touching a member is a
|
||||||
|
# fine-fine face with zero tangential offset - the deferred
|
||||||
|
# correction never fires there and the lug's mixed-position
|
||||||
|
# gradient can do no harm (it only ever multiplies delta = 0)
|
||||||
|
edges_solve = sv._pdn_solve_edges(edges_ext, merge)
|
||||||
|
|
||||||
|
A, rhs0, _ = sv._assemble(state, edges_solve, inj, dirichlet_v)
|
||||||
|
ps = sv.PreparedSolver(A)
|
||||||
|
free = state == 1
|
||||||
|
|
||||||
|
def expand(x):
|
||||||
|
V = np.where(state >= 2, dirichlet_v, 0.0)
|
||||||
|
V[free] = x
|
||||||
|
if merge is not None:
|
||||||
|
V = V[merge] # bonded members read their lug
|
||||||
|
return V
|
||||||
|
|
||||||
|
x, info = ps.solve(rhs0)
|
||||||
|
Vflat = expand(x)
|
||||||
|
corr = np.zeros(len(edges_ext.a))
|
||||||
|
faces = e_axis >= 0
|
||||||
|
fa, fb = edges_ext.a[faces], edges_ext.b[faces]
|
||||||
|
passes = max(0, int(config.ADAPTIVE_CORRECTION_PASSES))
|
||||||
|
for p in range(passes):
|
||||||
|
if not faces.any():
|
||||||
|
break
|
||||||
|
progress.stage(f"correction pass {p + 1}/{passes} ...")
|
||||||
|
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_ext.a))
|
||||||
|
corr[faces] = edges_ext.w[faces] * e_delta[faces] * gt
|
||||||
|
extra = np.zeros(state.size)
|
||||||
|
np.add.at(extra, edges_solve.a, -corr)
|
||||||
|
np.add.at(extra, edges_solve.b, corr)
|
||||||
|
x, info = ps.solve(rhs0 + extra[free])
|
||||||
|
Vflat = expand(x)
|
||||||
|
|
||||||
|
# corrected currents in absolute volts: satisfy KCL exactly
|
||||||
|
Ie = edges_ext.w * (Vflat[edges_ext.a] - Vflat[edges_ext.b]) + corr
|
||||||
|
timings["solve_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
# --- fields on leaves, expanded to the fine grid ------------------------
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
term_part_nodes = []
|
||||||
|
for parts in term_parts:
|
||||||
|
pn = []
|
||||||
|
for pl, m3 in parts:
|
||||||
|
nodes = np.zeros(N, dtype=bool)
|
||||||
|
for li in range(L):
|
||||||
|
nodes[_nodes_of_cells(grids, offs, li, m3[li])] = True
|
||||||
|
pn.append((pl, np.flatnonzero(nodes)))
|
||||||
|
term_part_nodes.append(pn)
|
||||||
|
supplies, loads = sv._pdn_extract(terminals, term_nodes, attaches,
|
||||||
|
Vflat, Ie, edges_ext, term_part_nodes)
|
||||||
|
Pe, P_layers, P_vias, via_reports, V3, J3, Parea = _expand_fields(
|
||||||
|
problem, stack, grids, offs, N, edges_ext, e_axis, e_layer,
|
||||||
|
cxg, cyg, teq_leaves, Vflat, Ie, 1.0)
|
||||||
|
balance, mismatch, i_sup, i_loads, p_loads = sv._pdn_balance(
|
||||||
|
supplies, loads, P_layers, P_vias)
|
||||||
|
timings["postprocess_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
# --- source-sink pair matrix on the copper-only leaf graph -------------
|
||||||
|
# same deferred-correction loop per pattern solve, so the pair
|
||||||
|
# resistances match the uniform grid to the usual adaptive accuracy
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
ebase = len(edges.a)
|
||||||
|
axb = e_axis[:ebase]
|
||||||
|
dlb = e_delta[:ebase]
|
||||||
|
facb = axb >= 0
|
||||||
|
|
||||||
|
def _pair_solver(state_g, dv, edges_pm, pmerge):
|
||||||
|
A2, rhs0p, _ = sv._assemble(state_g, edges_pm, None, dv)
|
||||||
|
ps2 = sv.PreparedSolver(A2)
|
||||||
|
freeg = state_g == 1
|
||||||
|
fa3, fb3 = edges.a[facb], edges.b[facb]
|
||||||
|
|
||||||
|
def expand_g(x2):
|
||||||
|
V = np.where(state_g >= 2, dv, 0.0)
|
||||||
|
V[freeg] = x2
|
||||||
|
if pmerge is not None:
|
||||||
|
V = V[pmerge] # members read their super-node
|
||||||
|
return V
|
||||||
|
|
||||||
|
def slv(inj_p):
|
||||||
|
x2, _ = ps2.solve(rhs0p + inj_p[freeg])
|
||||||
|
V = expand_g(x2)
|
||||||
|
for _p in range(passes):
|
||||||
|
if not facb.any():
|
||||||
|
break
|
||||||
|
gx, gy = _leaf_gradients(N, fa3, fb3, cxg, cyg, V)
|
||||||
|
gt = np.where(axb[facb] == 0,
|
||||||
|
0.5 * (gy[fa3] + gy[fb3]),
|
||||||
|
0.5 * (gx[fa3] + gx[fb3]))
|
||||||
|
corrp = np.zeros(ebase)
|
||||||
|
corrp[facb] = edges.w[facb] * dlb[facb] * gt
|
||||||
|
extra = np.zeros(state_g.size)
|
||||||
|
np.add.at(extra, edges_pm.a, -corrp)
|
||||||
|
np.add.at(extra, edges_pm.b, corrp)
|
||||||
|
x2, _ = ps2.solve(rhs0p + inj_p[freeg] + extra[freeg])
|
||||||
|
V = expand_g(x2)
|
||||||
|
return V
|
||||||
|
return slv
|
||||||
|
|
||||||
|
pairs = sv._pdn_pairs(terminals, term_nodes, attaches, merge,
|
||||||
|
state_base, edges, supplies, loads,
|
||||||
|
_pair_solver)
|
||||||
|
timings["pairs_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
return sv.Result(
|
||||||
|
R_ohm=float("nan"), i_test=i_loads, V=V3, Jmag=J3, Parea=Parea,
|
||||||
|
layer_names=list(stack.layer_names),
|
||||||
|
P_total=float(sum(P_layers) + P_vias),
|
||||||
|
P_layers=P_layers, P_vias=P_vias,
|
||||||
|
power_balance_rel=balance, via_reports=via_reports,
|
||||||
|
I1_a=i_sup, I2_a=i_loads, mismatch_rel=mismatch,
|
||||||
|
n_free=info.n_unknowns, solve_info=info,
|
||||||
|
contact_model="pdn",
|
||||||
|
freq_hz=freq_hz,
|
||||||
|
skin_depth_um=(skin.skin_depth_m(freq_hz, problem.rho_ohm_m) * 1e6
|
||||||
|
if freq_hz > 0 else None),
|
||||||
|
rs_ratios=rs_ratios,
|
||||||
|
timings=timings,
|
||||||
|
mode="pdn", supplies=supplies, loads=loads,
|
||||||
|
P_loads=p_loads,
|
||||||
|
P_supply_internal=sum(s_.p_internal_w for s_ in supplies),
|
||||||
|
v_nominal=v_nominal, pairs=pairs,
|
||||||
|
)
|
||||||
|
|||||||
+531
-16
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from kipy import KiCad
|
from kipy import KiCad
|
||||||
from kipy.board import Board
|
from kipy.board import Board
|
||||||
from kipy.board_types import ArcTrack, BoardRectangle, Pad, Via
|
from kipy.board_types import ArcTrack, BoardRectangle, BoardText, Pad, Via
|
||||||
from kipy.proto.board.board_pb2 import BoardStackupLayerType
|
from kipy.proto.board.board_pb2 import BoardStackupLayerType
|
||||||
from kipy.proto.board.board_types_pb2 import ZoneType
|
from kipy.proto.board.board_types_pb2 import ZoneType
|
||||||
from kipy.util.board_layer import (canonical_name, is_copper_layer,
|
from kipy.util.board_layer import (canonical_name, is_copper_layer,
|
||||||
@@ -21,9 +21,10 @@ from kipy.util.board_layer import (canonical_name, is_copper_layer,
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .errors import ApiVersionError, CandidateError, SelectionError
|
from .errors import (ApiVersionError, CandidateError, ConfigError,
|
||||||
|
SelectionError)
|
||||||
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
|
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
|
||||||
SurfaceBuildup, TrackSeg, ViaLink,
|
SurfaceBuildup, Terminal, TrackSeg, ViaLink,
|
||||||
contact_solder_buildups, linearize_ring,
|
contact_solder_buildups, linearize_ring,
|
||||||
tht_joint_buildups)
|
tht_joint_buildups)
|
||||||
|
|
||||||
@@ -391,6 +392,479 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- config-file terminal resolution -----------------------------------------
|
||||||
|
|
||||||
|
def _pair_rect_labels(board: Board, layer: str) -> list:
|
||||||
|
"""[(BoardRectangle, name_or_None), ...] for one marker layer. A
|
||||||
|
rectangle is named by a text item on the same layer whose anchor
|
||||||
|
lies inside it (BoardRectangle itself has no name in the IPC API);
|
||||||
|
a rectangle containing several text items is ambiguous and errors.
|
||||||
|
Duplicate-name policy is the CALLER's (config lookup warns/skips
|
||||||
|
unnamed rects; the PDN editor scan needs them too)."""
|
||||||
|
rects = [s for s in board.get_shapes()
|
||||||
|
if isinstance(s, BoardRectangle)
|
||||||
|
and canonical_name(s.layer) == layer]
|
||||||
|
texts = [t for t in board.get_text()
|
||||||
|
if isinstance(t, BoardText)
|
||||||
|
and canonical_name(t.layer) == layer]
|
||||||
|
out = []
|
||||||
|
for r in rects:
|
||||||
|
tl, br = r.top_left, r.bottom_right
|
||||||
|
x0, x1 = min(tl.x, br.x), max(tl.x, br.x)
|
||||||
|
y0, y1 = min(tl.y, br.y), max(tl.y, br.y)
|
||||||
|
inside = [t for t in texts
|
||||||
|
if x0 <= t.position.x <= x1
|
||||||
|
and y0 <= t.position.y <= y1]
|
||||||
|
if len(inside) > 1:
|
||||||
|
names = ", ".join(repr(t.value) for t in inside[:4])
|
||||||
|
raise ConfigError(
|
||||||
|
f"the rectangle on {layer} at "
|
||||||
|
f"({x0 / 1e6:.1f}, {y0 / 1e6:.1f}) mm contains "
|
||||||
|
f"{len(inside)} text items ({names}) - keep "
|
||||||
|
f"exactly one so its name is unambiguous."
|
||||||
|
)
|
||||||
|
out.append((r, inside[0].value.strip() if inside else None))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MarkerTerminal:
|
||||||
|
"""One PDN-editor terminal candidate: one or several marker
|
||||||
|
rectangles with the role taken from the layer they sit on
|
||||||
|
(ELECTRODE_POS_LAYER = supply, ELECTRODE_NEG_LAYER = load). In PDN
|
||||||
|
mode every rectangle is its own terminal - unlike classic mode,
|
||||||
|
which merges each layer into one V+/V- contact - EXCEPT that
|
||||||
|
rectangles sharing one text-item name group into a single BONDED
|
||||||
|
terminal (a multi-pin package: total current known, per-contact
|
||||||
|
split solved through the internal bond)."""
|
||||||
|
name: str
|
||||||
|
role: str # "supply" | "load"
|
||||||
|
labeled: bool # named by a text item: saves as a
|
||||||
|
# live rect:NAME ref; unnamed rects
|
||||||
|
# save as frozen rect_mm coordinates
|
||||||
|
electrodes: list # [Electrode]; > 1 only when labeled
|
||||||
|
bonded: bool = False # grouped rects are bonded into one lug
|
||||||
|
|
||||||
|
|
||||||
|
def scan_marker_terminals(board: Board,
|
||||||
|
require_both: bool = True
|
||||||
|
) -> list[MarkerTerminal]:
|
||||||
|
"""Board-wide marker-rectangle scan for the PDN dialog editor (the
|
||||||
|
selection is deliberately ignored: PDN terminals are the drawn
|
||||||
|
rectangles, nothing else). Order is stable reading order - supplies
|
||||||
|
first, each group by the (y, x) of its first rectangle - because
|
||||||
|
the dialog's row identity is POSITIONAL; auto names S1../L1.. skip
|
||||||
|
names already taken by a label. Raises SelectionError when either
|
||||||
|
layer has no rectangle (require_both False skips that check: the
|
||||||
|
merge with a config's terminal set treats empty layers as simply
|
||||||
|
'nothing new') and ConfigError on naming problems (both just
|
||||||
|
disable the editor upstream; classic mode still runs)."""
|
||||||
|
pos_l = config.ELECTRODE_POS_LAYER
|
||||||
|
neg_l = config.ELECTRODE_NEG_LAYER
|
||||||
|
pairs = {l: _pair_rect_labels(board, l) for l in (pos_l, neg_l)}
|
||||||
|
if require_both and (not pairs[pos_l] or not pairs[neg_l]):
|
||||||
|
raise SelectionError(
|
||||||
|
f"PDN mode needs marker rectangles on both layers; found "
|
||||||
|
f"{len(pairs[pos_l])} on {pos_l} (supplies) and "
|
||||||
|
f"{len(pairs[neg_l])} on {neg_l} (loads). Draw supply "
|
||||||
|
f"rectangle(s) on {pos_l} and load rectangle(s) on {neg_l} "
|
||||||
|
f"(axis-aligned); a text item inside a rectangle names it."
|
||||||
|
)
|
||||||
|
# name uniqueness ACROSS marker layers: a name is a terminal name
|
||||||
|
# here and becomes a rect:NAME ref on save - both need exactly one
|
||||||
|
# owning layer (User.3 labels count: rect:NAME searches there too).
|
||||||
|
# WITHIN a layer a repeated name is the grouping mechanism, not an
|
||||||
|
# error: those rectangles form one bonded terminal
|
||||||
|
check_layers = []
|
||||||
|
for l in (pos_l, neg_l, config.ELECTRODE_PDN_LAYER):
|
||||||
|
if l not in check_layers:
|
||||||
|
check_layers.append(l)
|
||||||
|
seen: dict = {}
|
||||||
|
for l in check_layers:
|
||||||
|
prs = pairs[l] if l in pairs else _pair_rect_labels(board, l)
|
||||||
|
for _r, n in prs:
|
||||||
|
if n is None:
|
||||||
|
continue
|
||||||
|
if n in seen and seen[n] != l:
|
||||||
|
raise ConfigError(
|
||||||
|
f"rectangle name '{n}' exists on {seen[n]} and {l} "
|
||||||
|
f"- marker rectangle names must be unique across "
|
||||||
|
f"the marker layers."
|
||||||
|
)
|
||||||
|
seen[n] = l
|
||||||
|
|
||||||
|
def reading_order(pair):
|
||||||
|
tl, br = pair[0].top_left, pair[0].bottom_right
|
||||||
|
return (min(tl.y, br.y), min(tl.x, br.x))
|
||||||
|
|
||||||
|
taken = set(seen)
|
||||||
|
out: list = []
|
||||||
|
counter = {"supply": 0, "load": 0}
|
||||||
|
prefix = {"supply": "S", "load": "L"}
|
||||||
|
for layer, role in ((pos_l, "supply"), (neg_l, "load")):
|
||||||
|
groups: dict = {} # name -> MarkerTerminal, in reading
|
||||||
|
for r, name in sorted(pairs[layer], key=reading_order):
|
||||||
|
labeled = name is not None
|
||||||
|
if not labeled:
|
||||||
|
while True:
|
||||||
|
counter[role] += 1
|
||||||
|
name = f"{prefix[role]}{counter[role]}"
|
||||||
|
if name not in taken:
|
||||||
|
break
|
||||||
|
taken.add(name)
|
||||||
|
e = _to_electrode(board, r)
|
||||||
|
e.label = name
|
||||||
|
if labeled and name in groups:
|
||||||
|
mt = groups[name]
|
||||||
|
mt.electrodes.append(e)
|
||||||
|
mt.bonded = True # grouped = one externally bonded lug
|
||||||
|
continue
|
||||||
|
mt = MarkerTerminal(name=name, role=role, labeled=labeled,
|
||||||
|
electrodes=[e])
|
||||||
|
groups[name] = mt
|
||||||
|
out.append(mt)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
RECT_MATCH_TOL_MM = 1e-3 # frozen rect_mm coords are written with
|
||||||
|
# 1e-6 rounding; 1 um absorbs both that
|
||||||
|
# and the nm->mm float trip
|
||||||
|
|
||||||
|
|
||||||
|
def new_marker_terminals(specs: list, marker_terms: list
|
||||||
|
) -> list[MarkerTerminal]:
|
||||||
|
"""The scanned marker terminals NOT already referenced by the
|
||||||
|
config's TerminalSpec list: drawing a new rectangle on a marker
|
||||||
|
layer creates a new terminal even while a config provides the set.
|
||||||
|
A scanned rectangle is covered when its label appears as a
|
||||||
|
rect:NAME part (drawing MORE rects with that name extends that
|
||||||
|
very terminal at resolve time, so the scan group is not new
|
||||||
|
either) or when its geometry matches a frozen rect_mm part. A
|
||||||
|
label colliding with an unrelated config terminal name is skipped
|
||||||
|
with a printed note (rename one of the two); colliding auto names
|
||||||
|
are simply renumbered."""
|
||||||
|
covered_labels = set()
|
||||||
|
covered_rects = []
|
||||||
|
names = set()
|
||||||
|
for spec in specs:
|
||||||
|
names.add(spec.name)
|
||||||
|
for part in spec.parts:
|
||||||
|
if part.kind == "rect_label":
|
||||||
|
covered_labels.add(part.label)
|
||||||
|
elif part.kind == "rect_mm":
|
||||||
|
x0, y0, x1, y1 = part.rect_mm
|
||||||
|
covered_rects.append((min(x0, x1), min(y0, y1),
|
||||||
|
max(x0, x1), max(y0, y1)))
|
||||||
|
|
||||||
|
def frozen(e) -> bool:
|
||||||
|
r = e.rect
|
||||||
|
mm = (r.x0 / 1e6, r.y0 / 1e6, r.x1 / 1e6, r.y1 / 1e6)
|
||||||
|
return any(all(abs(a - b) <= RECT_MATCH_TOL_MM
|
||||||
|
for a, b in zip(mm, c)) for c in covered_rects)
|
||||||
|
|
||||||
|
taken = names | {mt.name for mt in marker_terms}
|
||||||
|
out = []
|
||||||
|
for mt in marker_terms:
|
||||||
|
if mt.labeled and mt.name in covered_labels:
|
||||||
|
continue
|
||||||
|
if all(frozen(e) for e in mt.electrodes):
|
||||||
|
continue
|
||||||
|
if mt.name in names:
|
||||||
|
if mt.labeled:
|
||||||
|
print(f"note: rectangle '{mt.name}' collides with the "
|
||||||
|
f"config terminal '{mt.name}' (which does not "
|
||||||
|
f"reference it) - rename one of the two to add "
|
||||||
|
f"the rectangle as a terminal")
|
||||||
|
continue
|
||||||
|
prefix = "S" if mt.role == "supply" else "L"
|
||||||
|
i = 1
|
||||||
|
while f"{prefix}{i}" in taken:
|
||||||
|
i += 1
|
||||||
|
mt.name = f"{prefix}{i}"
|
||||||
|
taken.add(mt.name)
|
||||||
|
for e in mt.electrodes:
|
||||||
|
e.label = mt.name
|
||||||
|
out.append(mt)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def component_hints(board: Board, electrode_groups: list) -> list:
|
||||||
|
"""One row-identification string per electrode group for the PDN
|
||||||
|
dialog's Component column: the reference designators of footprints
|
||||||
|
with a pad intersecting any of the group's contact rectangles,
|
||||||
|
else "near <ref>" for the footprint whose pad center is closest.
|
||||||
|
Purely spatial - no net or layer filter: this identifies WHERE a
|
||||||
|
terminal sits, it plays no electrical role. Pads are approximated
|
||||||
|
by squares of their largest copper diameter (exact enough for
|
||||||
|
naming the owner). Empty string for a group when the board has no
|
||||||
|
usable footprints."""
|
||||||
|
fps = []
|
||||||
|
for fp in board.get_footprints():
|
||||||
|
try:
|
||||||
|
ref = fp.reference_field.text.value
|
||||||
|
pads = [(int(p.position.x), int(p.position.y),
|
||||||
|
_padstack_pad_nm(p) // 2)
|
||||||
|
for p in fp.definition.pads]
|
||||||
|
except Exception:
|
||||||
|
continue # identification only: skip odd
|
||||||
|
if ref and pads: # footprints, never fail the run
|
||||||
|
fps.append((ref, pads))
|
||||||
|
out = []
|
||||||
|
for electrodes in electrode_groups:
|
||||||
|
hits = []
|
||||||
|
near = None # (distance_nm, ref)
|
||||||
|
for ref, pads in fps:
|
||||||
|
best = None
|
||||||
|
for x, y, r in pads:
|
||||||
|
for e in electrodes:
|
||||||
|
rc = e.rect
|
||||||
|
# center-to-rectangle axis distances; both within
|
||||||
|
# the pad half-size = the square pad overlaps
|
||||||
|
dx = max(rc.x0 - x, x - rc.x1, 0)
|
||||||
|
dy = max(rc.y0 - y, y - rc.y1, 0)
|
||||||
|
d = 0.0 if (dx <= r and dy <= r) \
|
||||||
|
else float(dx * dx + dy * dy) ** 0.5
|
||||||
|
if best is None or d < best:
|
||||||
|
best = d
|
||||||
|
if best == 0.0:
|
||||||
|
hits.append(ref)
|
||||||
|
elif best is not None and (near is None or best < near[0]):
|
||||||
|
near = (best, ref)
|
||||||
|
if hits:
|
||||||
|
out.append(", ".join(hits[:3])
|
||||||
|
+ (f" +{len(hits) - 3}" if len(hits) > 3 else ""))
|
||||||
|
elif near is not None:
|
||||||
|
out.append(f"near {near[1]}")
|
||||||
|
else:
|
||||||
|
out.append("")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class _RefContext:
|
||||||
|
"""Resolves configfile.PartRef entries against a live board. Board
|
||||||
|
queries (footprints, pads, shapes, texts, vias) are fetched once,
|
||||||
|
lazily - every map is built from the SAME get_footprints() call so
|
||||||
|
ownership comparisons stay identity-safe."""
|
||||||
|
|
||||||
|
def __init__(self, board: Board, stackup: StackupInfo | None, net: str):
|
||||||
|
self.board = board
|
||||||
|
self.stackup = stackup
|
||||||
|
self.net = net
|
||||||
|
self._by_ref: dict | None = None
|
||||||
|
self._pad_map: dict | None = None
|
||||||
|
self._pads: list | None = None
|
||||||
|
self._rects: dict = {} # layer -> {name: BoardRectangle}
|
||||||
|
self._vias: list | None = None
|
||||||
|
|
||||||
|
def _footprints(self) -> dict:
|
||||||
|
if self._by_ref is None:
|
||||||
|
fps = list(self.board.get_footprints())
|
||||||
|
self._by_ref = {}
|
||||||
|
for fp in fps:
|
||||||
|
try:
|
||||||
|
ref = fp.reference_field.text.value
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if ref:
|
||||||
|
self._by_ref.setdefault(ref, []).append(fp)
|
||||||
|
self._pad_map = _footprint_pad_map(fps)
|
||||||
|
return self._by_ref
|
||||||
|
|
||||||
|
def _board_pads(self) -> list:
|
||||||
|
if self._pads is None:
|
||||||
|
self._pads = list(self.board.get_pads())
|
||||||
|
return self._pads
|
||||||
|
|
||||||
|
def _fp_of(self, ref: str, where: str):
|
||||||
|
by_ref = self._footprints()
|
||||||
|
fps = by_ref.get(ref)
|
||||||
|
if not fps:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: footprint '{ref}' not found on the board."
|
||||||
|
)
|
||||||
|
if len(fps) > 1:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: reference '{ref}' is ambiguous - "
|
||||||
|
f"{len(fps)} footprints share it."
|
||||||
|
)
|
||||||
|
return fps[0]
|
||||||
|
|
||||||
|
def _pads_of_fp(self, fp) -> list:
|
||||||
|
self._footprints()
|
||||||
|
return [p for p in self._board_pads()
|
||||||
|
if _pad_owner(p, self._pad_map) is fp]
|
||||||
|
|
||||||
|
def _labeled_rects(self, layer: str) -> dict:
|
||||||
|
"""name -> [BoardRectangle, ...] on one marker layer (see
|
||||||
|
_pair_rect_labels). Cached per layer; unnamed rectangles are
|
||||||
|
skipped with a warning. Several rectangles sharing one name are
|
||||||
|
ONE multi-part reference (the grouping mechanism for bonded
|
||||||
|
multi-contact terminals), not an error."""
|
||||||
|
if layer not in self._rects:
|
||||||
|
named: dict = {}
|
||||||
|
for r, name in _pair_rect_labels(self.board, layer):
|
||||||
|
if name is None:
|
||||||
|
tl, br = r.top_left, r.bottom_right
|
||||||
|
print(f"config warning: unnamed rectangle on {layer} "
|
||||||
|
f"at ({min(tl.x, br.x) / 1e6:.1f}, "
|
||||||
|
f"{min(tl.y, br.y) / 1e6:.1f}) mm - place a "
|
||||||
|
f"text item inside it to use it as rect:NAME")
|
||||||
|
continue
|
||||||
|
named.setdefault(name, []).append(r)
|
||||||
|
self._rects[layer] = named
|
||||||
|
return self._rects[layer]
|
||||||
|
|
||||||
|
def _net_vias(self) -> list:
|
||||||
|
if self._vias is None:
|
||||||
|
self._vias = [v for v in self.board.get_vias()
|
||||||
|
if v.net is not None and v.net.name == self.net]
|
||||||
|
return self._vias
|
||||||
|
|
||||||
|
def resolve(self, part, where: str) -> list[Electrode]:
|
||||||
|
"""PartRef -> Electrode list (footprints can span several pads).
|
||||||
|
All errors are ConfigError with the terminal context in
|
||||||
|
`where`."""
|
||||||
|
if part.kind == "footprint":
|
||||||
|
fp = self._fp_of(part.ref, where)
|
||||||
|
pads = self._pads_of_fp(fp)
|
||||||
|
on_net = [p for p in pads
|
||||||
|
if p.net is not None and p.net.name == self.net]
|
||||||
|
if not on_net:
|
||||||
|
nets = sorted({p.net.name for p in pads
|
||||||
|
if p.net is not None})
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: footprint '{part.ref}' has no pads on net "
|
||||||
|
f"'{self.net}'"
|
||||||
|
+ (f" (its nets: {', '.join(nets)})." if nets
|
||||||
|
else " (it has no connected pads).")
|
||||||
|
)
|
||||||
|
return [_to_electrode(self.board, p, self.stackup,
|
||||||
|
self._pad_map) for p in on_net]
|
||||||
|
if part.kind == "pad":
|
||||||
|
fp = self._fp_of(part.ref, where)
|
||||||
|
pads = self._pads_of_fp(fp)
|
||||||
|
matches = [p for p in pads if p.number == part.pad]
|
||||||
|
if not matches:
|
||||||
|
nums = ", ".join(sorted({p.number for p in pads})[:16])
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: '{part.ref}' has no pad '{part.pad}'"
|
||||||
|
+ (f" (its pads: {nums})." if nums else ".")
|
||||||
|
)
|
||||||
|
for p in matches:
|
||||||
|
pnet = p.net.name if p.net is not None else "no net"
|
||||||
|
if pnet != self.net:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: pad '{part.ref}.{part.pad}' is on "
|
||||||
|
f"'{pnet}', not '{self.net}'."
|
||||||
|
)
|
||||||
|
return [_to_electrode(self.board, p, self.stackup,
|
||||||
|
self._pad_map) for p in matches]
|
||||||
|
if part.kind == "rect_label":
|
||||||
|
# rect:NAME searches every marker layer, so labeled
|
||||||
|
# PDN-editor rectangles (User.1/User.2) resolve too; a name
|
||||||
|
# existing on several layers is ambiguous and errors
|
||||||
|
layers = []
|
||||||
|
for l in (config.ELECTRODE_PDN_LAYER,
|
||||||
|
config.ELECTRODE_POS_LAYER,
|
||||||
|
config.ELECTRODE_NEG_LAYER):
|
||||||
|
if l not in layers:
|
||||||
|
layers.append(l)
|
||||||
|
hits = [(l, self._labeled_rects(l)[part.label])
|
||||||
|
for l in layers
|
||||||
|
if part.label in self._labeled_rects(l)]
|
||||||
|
if not hits:
|
||||||
|
names = ", ".join(sorted(
|
||||||
|
{n for l in layers for n in self._labeled_rects(l)}
|
||||||
|
)[:16])
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: no rectangle named '{part.label}' on "
|
||||||
|
f"{', '.join(layers)}"
|
||||||
|
+ (f" (found: {names})." if names else
|
||||||
|
" (no named rectangles found there).")
|
||||||
|
)
|
||||||
|
if len(hits) > 1:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: rectangle name '{part.label}' exists on "
|
||||||
|
f"{' and '.join(l for l, _ in hits)} - marker "
|
||||||
|
f"rectangle names must be unique across layers."
|
||||||
|
)
|
||||||
|
# every same-named rectangle on the owning layer is one
|
||||||
|
# part of the reference (multi-contact terminals)
|
||||||
|
return [_to_electrode(self.board, r) for r in hits[0][1]]
|
||||||
|
if part.kind == "rect_mm":
|
||||||
|
x0, y0, x1, y1 = part.rect_mm
|
||||||
|
rect = Rect.normalized(int(x0 * 1e6), int(y0 * 1e6),
|
||||||
|
int(x1 * 1e6), int(y1 * 1e6),
|
||||||
|
"config")
|
||||||
|
return [Electrode(rect=rect,
|
||||||
|
contact=part.contact or "all",
|
||||||
|
label=f"rect({x0:g},{y0:g})")]
|
||||||
|
# via_mm
|
||||||
|
x = int(part.via_mm[0] * 1e6)
|
||||||
|
y = int(part.via_mm[1] * 1e6)
|
||||||
|
best, bd = None, 0.0
|
||||||
|
for v in self._net_vias():
|
||||||
|
d = math.hypot(v.position.x - x, v.position.y - y)
|
||||||
|
if best is None or d < bd:
|
||||||
|
best, bd = v, d
|
||||||
|
if best is None:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: net '{self.net}' has no vias "
|
||||||
|
f"({part.describe()})."
|
||||||
|
)
|
||||||
|
if bd > 1e6:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{where}: {part.describe()} - the nearest via of "
|
||||||
|
f"'{self.net}' is {bd / 1e6:.2f} mm away (limit 1 mm)."
|
||||||
|
)
|
||||||
|
return [_to_electrode(self.board, best, self.stackup)]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_parts(ctx: _RefContext, spec_contact: str, parts: list,
|
||||||
|
where: str) -> list[Electrode]:
|
||||||
|
"""Resolve a part list and apply the contact-scope precedence: an
|
||||||
|
explicit part-level contact wins, else the terminal-level scope
|
||||||
|
(unless 'auto' = keep what resolution decided)."""
|
||||||
|
out = []
|
||||||
|
for part in parts:
|
||||||
|
els = ctx.resolve(part, where)
|
||||||
|
for e in els:
|
||||||
|
if part.contact:
|
||||||
|
e.contact = part.contact
|
||||||
|
elif spec_contact and spec_contact != "auto":
|
||||||
|
e.contact = spec_contact
|
||||||
|
out.extend(els)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_terminal_specs(board: Board, stackup: StackupInfo | None,
|
||||||
|
specs: list, net: str) -> list[Terminal]:
|
||||||
|
"""configfile.TerminalSpec list -> geometry.Terminal list, resolved
|
||||||
|
against the live board. Raises ConfigError naming the terminal and
|
||||||
|
the offending reference."""
|
||||||
|
ctx = _RefContext(board, stackup, net)
|
||||||
|
terminals = []
|
||||||
|
for spec in specs:
|
||||||
|
where = f"{spec.role} '{spec.name}'"
|
||||||
|
electrodes = _resolve_parts(ctx, spec.contact, spec.parts, where)
|
||||||
|
terminals.append(Terminal(
|
||||||
|
role=spec.role, electrodes=electrodes, label=spec.name,
|
||||||
|
i_draw_a=spec.i_draw_a, r_out_ohm=spec.r_out_ohm,
|
||||||
|
v_oc=spec.v_oc, bonded=spec.bonded,
|
||||||
|
comment=getattr(spec, "comment", "")))
|
||||||
|
return terminals
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_classic_parts(board: Board, stackup: StackupInfo | None,
|
||||||
|
pos: list, neg: list, net: str
|
||||||
|
) -> tuple[list[Electrode], list[Electrode]]:
|
||||||
|
"""classic.pos / classic.neg part references -> V+/V- electrode
|
||||||
|
lists (the config file then fully replaces the board selection)."""
|
||||||
|
ctx = _RefContext(board, stackup, net)
|
||||||
|
return (_resolve_parts(ctx, "", pos, "classic.pos"),
|
||||||
|
_resolve_parts(ctx, "", neg, "classic.neg"))
|
||||||
|
|
||||||
|
|
||||||
# --- fills -------------------------------------------------------------------
|
# --- fills -------------------------------------------------------------------
|
||||||
|
|
||||||
def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
|
def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
|
||||||
@@ -481,6 +955,23 @@ def nets_overlapping(fills: dict, es1: list[Electrode],
|
|||||||
return sorted(out)
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def group_nets(copper: dict, electrode_groups: list) -> list:
|
||||||
|
"""Per electrode group: the frozenset of nets whose copper overlaps
|
||||||
|
any of the group's contact rectangles (any layer - the connection
|
||||||
|
may go through vias; same permissive bbox prefilter as
|
||||||
|
nets_overlapping). The PDN editor uses this to show only the
|
||||||
|
rectangles that actually sit on the selected net."""
|
||||||
|
out = []
|
||||||
|
for electrodes in electrode_groups:
|
||||||
|
nets = set()
|
||||||
|
for net, per_layer in copper.items():
|
||||||
|
if any(_rect_overlaps(e.rect, polys) for e in electrodes
|
||||||
|
for polys in per_layer.values()):
|
||||||
|
nets.add(net)
|
||||||
|
out.append(frozenset(nets))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def gather_mask_buildups(board: Board) -> dict[str, list[Polygon]]:
|
def gather_mask_buildups(board: Board) -> dict[str, list[Polygon]]:
|
||||||
"""Zones on F.Mask/B.Mask (mask openings) -> fill polygons keyed by
|
"""Zones on F.Mask/B.Mask (mask openings) -> fill polygons keyed by
|
||||||
the outer copper layer they expose."""
|
the outer copper layer they expose."""
|
||||||
@@ -870,7 +1361,8 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
extra_cu_um: float | None = None,
|
extra_cu_um: float | None = None,
|
||||||
tracks: dict | None = None,
|
tracks: dict | None = None,
|
||||||
vias_capped: bool | None = None,
|
vias_capped: bool | None = None,
|
||||||
cap_max_drill_mm: float | None = None) -> Problem:
|
cap_max_drill_mm: float | None = None,
|
||||||
|
terminals: list[Terminal] | None = None) -> Problem:
|
||||||
per_layer = fills.get(net, {})
|
per_layer = fills.get(net, {})
|
||||||
per_layer_tracks = (tracks or {}).get(net, {})
|
per_layer_tracks = (tracks or {}).get(net, {})
|
||||||
layers = []
|
layers = []
|
||||||
@@ -941,6 +1433,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
vias=vias,
|
vias=vias,
|
||||||
electrodes1=es1,
|
electrodes1=es1,
|
||||||
electrodes2=es2,
|
electrodes2=es2,
|
||||||
|
terminals=terminals or [],
|
||||||
thickness_source=("override" if config.COPPER_THICKNESS_UM is not None
|
thickness_source=("override" if config.COPPER_THICKNESS_UM is not None
|
||||||
else "stackup"),
|
else "stackup"),
|
||||||
buildups=buildup_list,
|
buildups=buildup_list,
|
||||||
@@ -961,7 +1454,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
solder_layers = contact_solder_buildups(problem)
|
solder_layers = contact_solder_buildups(problem)
|
||||||
if solder_layers:
|
if solder_layers:
|
||||||
sides = sorted({e.protrusion_side
|
sides = sorted({e.protrusion_side
|
||||||
for e in problem.electrodes1 + problem.electrodes2
|
for e in problem.contact_electrodes()
|
||||||
if e.solder and e.protrusion_side})
|
if e.solder and e.protrusion_side})
|
||||||
cone = (f", {config.THT_LEAD_PROTRUSION_MM:g} mm lead + solder cone "
|
cone = (f", {config.THT_LEAD_PROTRUSION_MM:g} mm lead + solder cone "
|
||||||
f"on {', '.join(sides)}"
|
f"on {', '.join(sides)}"
|
||||||
@@ -985,28 +1478,50 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from . import configfile
|
||||||
from .geometry import save_problem
|
from .geometry import save_problem
|
||||||
|
|
||||||
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json")
|
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json")
|
||||||
_, board = connect()
|
_, board = connect()
|
||||||
stackup = get_stackup_info(board)
|
stackup = get_stackup_info(board)
|
||||||
es1, es2, net_hint = get_electrodes(board, stackup)
|
cfg_path = configfile.find_config(board_dir(board),
|
||||||
|
getattr(board, "name", "") or "")
|
||||||
|
cfg = configfile.load_config(cfg_path) if cfg_path else None
|
||||||
|
pdn = cfg is not None and cfg.mode == "pdn"
|
||||||
|
terminals = None
|
||||||
|
if cfg is not None:
|
||||||
|
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
|
||||||
|
configfile.apply_physics(cfg)
|
||||||
|
if pdn:
|
||||||
|
es1, es2, net_hint = [], [], cfg.net
|
||||||
|
elif cfg is not None and cfg.pos_parts is not None:
|
||||||
|
es1, es2 = resolve_classic_parts(board, stackup, cfg.pos_parts,
|
||||||
|
cfg.neg_parts, cfg.net)
|
||||||
|
net_hint = cfg.net
|
||||||
|
else:
|
||||||
|
es1, es2, net_hint = get_electrodes(board, stackup)
|
||||||
if any_zone_unfilled(board):
|
if any_zone_unfilled(board):
|
||||||
refill(board)
|
refill(board)
|
||||||
fills = gather_net_fills(board)
|
fills = gather_net_fills(board)
|
||||||
tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
|
tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
|
||||||
copper = merge_copper(fills, tracks_as_polygons(tracks))
|
copper = merge_copper(fills, tracks_as_polygons(tracks))
|
||||||
nets = nets_overlapping(copper, es1, es2)
|
if pdn:
|
||||||
if len(sys.argv) > 2:
|
net = cfg.net
|
||||||
net = sys.argv[2]
|
terminals = resolve_terminal_specs(board, stackup, cfg.terminals,
|
||||||
elif net_hint in nets:
|
net)
|
||||||
net = net_hint
|
|
||||||
elif len(nets) == 1:
|
|
||||||
net = nets[0]
|
|
||||||
else:
|
else:
|
||||||
print(f"candidate nets: {nets}; pass one as second argument")
|
nets = nets_overlapping(copper, es1, es2)
|
||||||
sys.exit(1)
|
if len(sys.argv) > 2:
|
||||||
|
net = sys.argv[2]
|
||||||
|
elif net_hint in nets:
|
||||||
|
net = net_hint
|
||||||
|
elif len(nets) == 1:
|
||||||
|
net = nets[0]
|
||||||
|
else:
|
||||||
|
print(f"candidate nets: {nets}; pass one as second argument")
|
||||||
|
sys.exit(1)
|
||||||
problem = build_problem(board, net, list(copper.get(net, {})), es1, es2,
|
problem = build_problem(board, net, list(copper.get(net, {})), es1, es2,
|
||||||
stackup, fills, tracks=tracks)
|
stackup, fills, tracks=tracks,
|
||||||
|
terminals=terminals)
|
||||||
save_problem(problem, out)
|
save_problem(problem, out)
|
||||||
print(f"wrote {out}")
|
print(f"wrote {out}")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""All tunable constants. v1 has no GUI dialog: edit here, re-run.
|
"""All tunable constants; the dialog exposes the common ones per run.
|
||||||
|
|
||||||
A future version may read overrides from <project>/fill_res_config.json.
|
A <board dir>/fill_res_config.json (see configfile.py) can override the
|
||||||
|
run parameters, select physics constants and the marker layers, and in
|
||||||
|
PDN mode defines the supply/load terminals. Values here remain the
|
||||||
|
defaults when no config file is present.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations # KiCad's macOS Python is 3.9: without
|
from __future__ import annotations # KiCad's macOS Python is 3.9: without
|
||||||
# this, `float | None` annotations are
|
# this, `float | None` annotations are
|
||||||
@@ -79,8 +82,19 @@ TRACK_1D_FACTOR = 3.0 # traces narrower than this many grid cells
|
|||||||
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
|
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
|
||||||
ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
|
ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
|
||||||
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
|
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
|
||||||
|
ELECTRODE_PDN_LAYER = "User.3" # PDN mode: rectangles referenced from the
|
||||||
|
# config file as "rect:NAME" live here, named
|
||||||
|
# by a text item placed inside them (role
|
||||||
|
# comes from the config entry, so one layer
|
||||||
|
# serves supplies and loads alike)
|
||||||
ALWAYS_REFILL = False # refill zones even if KiCad says they are filled
|
ALWAYS_REFILL = False # refill zones even if KiCad says they are filled
|
||||||
|
|
||||||
|
# --- Configuration file ---
|
||||||
|
CONFIG_FILENAME = "fill_res_config.json"
|
||||||
|
# searched next to the board file, after the
|
||||||
|
# board-specific "<stem>.fill_res_config.json"
|
||||||
|
# (several boards can share a directory)
|
||||||
|
|
||||||
# --- In-KiCad result overlays (EXPERIMENTAL) ---
|
# --- In-KiCad result overlays (EXPERIMENTAL) ---
|
||||||
PUSH_OVERLAYS = False # after solving, push the per-layer |J|
|
PUSH_OVERLAYS = False # after solving, push the per-layer |J|
|
||||||
# heatmaps into the open board as unlocked
|
# heatmaps into the open board as unlocked
|
||||||
@@ -146,6 +160,17 @@ ADAPTIVE_CORRECTION_PASSES = 1 # deferred-correction re-solves fixing the
|
|||||||
# cuts the raw ~0.5-2% low bias to <0.03%
|
# cuts the raw ~0.5-2% low bias to <0.03%
|
||||||
# measured; 0 disables
|
# measured; 0 disables
|
||||||
|
|
||||||
|
# --- PDN mode ---
|
||||||
|
PDN_V_NOMINAL = 3.3 # default supply open-circuit voltage [V];
|
||||||
|
# per-supply v_oc and the config file's
|
||||||
|
# run.v_nominal override it. Only shifts the
|
||||||
|
# absolute-volt reporting reference - drops
|
||||||
|
# and currents are independent of it
|
||||||
|
PDN_R_OUT_EPS = 1e-12 # supplies with r_out_ohm at or below this
|
||||||
|
# become ideal (Dirichlet) contacts: the
|
||||||
|
# exact R_out -> 0 limit, avoiding a huge
|
||||||
|
# attachment conductance in the matrix
|
||||||
|
|
||||||
# --- Solver ---
|
# --- Solver ---
|
||||||
CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
|
CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
|
||||||
# orthogonally with uniform surface density
|
# orthogonally with uniform surface density
|
||||||
|
|||||||
@@ -0,0 +1,878 @@
|
|||||||
|
"""fill_res_config.json: load / validate / save, no kipy or Qt here.
|
||||||
|
|
||||||
|
The config file fully specifies a run: the shared run parameters, the
|
||||||
|
classic setup (optionally including the terminals themselves, by board
|
||||||
|
reference), or the PDN terminal set (supplies with output resistance,
|
||||||
|
loads with prescribed draws). Several configs can be kept side by side
|
||||||
|
as "fill_res_config.<name>.json"; the one named "default" loads
|
||||||
|
automatically. Search order next to the board file:
|
||||||
|
"<board stem>.fill_res_config.json" first (several boards can share a
|
||||||
|
directory), then "fill_res_config.default.json", then plain
|
||||||
|
"fill_res_config.json" (the legacy spelling of "default"). Any other
|
||||||
|
config is pulled in per run with the dialog's "Load config..." button.
|
||||||
|
Precedence: config.py constants < config file < dialog edits - the file
|
||||||
|
pre-fills the dialog, what the dialog shows is what runs. A missing
|
||||||
|
file changes nothing; a present-but-invalid file is a fatal ConfigError.
|
||||||
|
|
||||||
|
Comments: full lines whose first non-blank characters are "//" are
|
||||||
|
stripped (replaced by blank lines, so JSON error line numbers stay
|
||||||
|
correct); keys starting with "_" are ignored everywhere ("_comment").
|
||||||
|
|
||||||
|
Schema (version 1) - every key optional unless stated:
|
||||||
|
|
||||||
|
version int, REQUIRED (currently 1)
|
||||||
|
mode "classic" | "pdn": the mode the dialog STARTS in;
|
||||||
|
inferred from `terminals` when absent. Nothing is
|
||||||
|
pinned - the dialog can always switch modes, nets and
|
||||||
|
values; the file is authoritative only for WHICH PDN
|
||||||
|
terminals exist (while it has a `terminals` section)
|
||||||
|
run
|
||||||
|
net str; PDN: REQUIRED run on this net
|
||||||
|
layers [str] subset of copper layers
|
||||||
|
include_tracks bool
|
||||||
|
vias_capped bool
|
||||||
|
cap_max_drill_mm number > 0
|
||||||
|
adaptive bool
|
||||||
|
cell_um number > 0 | null null = auto
|
||||||
|
freq_hz number >= 0 | str "142k", "1.5M", 0 = DC
|
||||||
|
contact_model "uniform" | "equipotential" (classic only)
|
||||||
|
include_buildup bool
|
||||||
|
extra_cu_um number >= 0
|
||||||
|
push_overlays bool
|
||||||
|
v_nominal number > 0 PDN: default supply v_oc
|
||||||
|
trim {enabled: bool, mode: "pct"|"abs", value: number}
|
||||||
|
classic
|
||||||
|
current_a number > 0
|
||||||
|
contact1 "auto" | "all" | layer name
|
||||||
|
contact2 "auto" | "all" | layer name
|
||||||
|
pos [partref] V+ terminal parts by board reference
|
||||||
|
neg [partref] V- parts; pos/neg only together -
|
||||||
|
when present the board selection /
|
||||||
|
marker-rectangle scan is skipped
|
||||||
|
terminals [terminal] REQUIRED in pdn mode; may also sit
|
||||||
|
in a classic-mode config - the
|
||||||
|
dialog's PDN mode then offers them,
|
||||||
|
and classic saves preserve them
|
||||||
|
name str, REQUIRED, unique
|
||||||
|
role "supply" | "load", REQUIRED
|
||||||
|
parts [partref], REQUIRED, non-empty
|
||||||
|
active bool, default true; false = the terminal stays
|
||||||
|
in the file (and in the dialog, with its
|
||||||
|
checkbox cleared) but takes no part in the run.
|
||||||
|
The editor also archives rows whose copper is
|
||||||
|
not on run.net this way - a save never drops a
|
||||||
|
drawn rectangle
|
||||||
|
i_draw_a number >= 0 active loads: REQUIRED (0 =
|
||||||
|
voltage probe); forbidden on
|
||||||
|
supplies
|
||||||
|
r_out_ohm number >= 0 active supplies: REQUIRED;
|
||||||
|
forbidden on loads
|
||||||
|
v_oc number > 0 supplies only; default run.v_nominal
|
||||||
|
contact "auto" | "all" | layer name (applied to parts
|
||||||
|
without their own contact)
|
||||||
|
comment str free-text note, shown and editable
|
||||||
|
in the dialog's Comment column
|
||||||
|
bonded bool short ALL the terminal's contact
|
||||||
|
cells into one lug (a multi-pin
|
||||||
|
package with internal metal): the
|
||||||
|
total current stays prescribed but
|
||||||
|
the per-part/per-cell split becomes
|
||||||
|
a solve outcome. Default false =
|
||||||
|
per-cell area share (loads) /
|
||||||
|
per-cell Thevenin attach (supplies)
|
||||||
|
physics config.py overrides (the hand-edit set)
|
||||||
|
rho_cu_ohm_m, copper_thickness_um, via_plating_um
|
||||||
|
markers marker layer names
|
||||||
|
pos_layer, neg_layer, pdn_layer default User.1 / User.2 / User.3
|
||||||
|
|
||||||
|
partref - a string for the common cases, an object for the rest:
|
||||||
|
|
||||||
|
"U7" every pad of footprint U7 on the run net
|
||||||
|
"U7.3" pad "3" of U7 (split at the FIRST dot; pad numbers
|
||||||
|
are strings and may contain dots themselves)
|
||||||
|
"rect:NAME" rectangle on markers.pdn_layer named NAME by a text
|
||||||
|
item placed inside it (same layer)
|
||||||
|
{"rect_mm": [x0, y0, x1, y1], "contact": "F.Cu"}
|
||||||
|
explicit rectangle, board mm; contact optional
|
||||||
|
{"via_mm": [x, y]}
|
||||||
|
the net's via nearest to (x, y), within 1 mm
|
||||||
|
|
||||||
|
Units are plain SI floats (A, ohm, V, Hz), mm for board coordinates
|
||||||
|
(_mm), um for metal thickness and cell size (_um). Any number may also
|
||||||
|
be written as a STRING with an SI suffix - "50m" = 0.05, "4.7k" =
|
||||||
|
4700, case decides m (milli) vs M (mega) - except freq_hz, which keeps
|
||||||
|
the frequency grammar ("142k", "1.5M", a lone m means MHz there).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import config, skin
|
||||||
|
from .errors import ConfigError
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- parsed model -----------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PartRef:
|
||||||
|
"""One terminal part by board reference (see the partref grammar)."""
|
||||||
|
kind: str # "footprint" | "pad" | "rect_label" |
|
||||||
|
# "rect_mm" | "via_mm"
|
||||||
|
ref: str = "" # footprint reference designator
|
||||||
|
pad: str = "" # pad number (kind "pad")
|
||||||
|
label: str = "" # rectangle name (kind "rect_label")
|
||||||
|
rect_mm: tuple | None = None # (x0, y0, x1, y1) board mm
|
||||||
|
via_mm: tuple | None = None # (x, y) board mm
|
||||||
|
contact: str = "" # part-level layer scope; "" = decide
|
||||||
|
# at resolution (terminal-level scope,
|
||||||
|
# else the part's natural layers)
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
if self.kind == "footprint":
|
||||||
|
return self.ref
|
||||||
|
if self.kind == "pad":
|
||||||
|
return f"{self.ref}.{self.pad}"
|
||||||
|
if self.kind == "rect_label":
|
||||||
|
return f"rect:{self.label}"
|
||||||
|
if self.kind == "rect_mm":
|
||||||
|
x0, y0, x1, y1 = self.rect_mm
|
||||||
|
return f"rect ({x0:g}, {y0:g})..({x1:g}, {y1:g}) mm"
|
||||||
|
return f"via near ({self.via_mm[0]:g}, {self.via_mm[1]:g}) mm"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TerminalSpec:
|
||||||
|
"""One PDN terminal as written in the config (geometry unresolved)."""
|
||||||
|
name: str
|
||||||
|
role: str # "supply" | "load"
|
||||||
|
parts: list # [PartRef]
|
||||||
|
i_draw_a: float | None = None # None: not given (inactive load)
|
||||||
|
r_out_ohm: float | None = None # None: not given (inactive supply)
|
||||||
|
v_oc: float | None = None
|
||||||
|
contact: str = "auto"
|
||||||
|
bonded: bool = False # one lug: split is a solve outcome
|
||||||
|
active: bool = True # false: kept but not part of the run
|
||||||
|
comment: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunConfig:
|
||||||
|
"""A loaded, validated config file. None = key not present (the
|
||||||
|
config.py default applies); `raw` keeps the parsed JSON so saving
|
||||||
|
can preserve sections this dataclass does not model."""
|
||||||
|
mode: str = "classic"
|
||||||
|
path: Path | None = None
|
||||||
|
raw: dict = field(default_factory=dict)
|
||||||
|
# run
|
||||||
|
net: str | None = None
|
||||||
|
layers: list | None = None
|
||||||
|
include_tracks: bool | None = None
|
||||||
|
vias_capped: bool | None = None
|
||||||
|
cap_max_drill_mm: float | None = None
|
||||||
|
adaptive: bool | None = None
|
||||||
|
cell_um: float | None = None
|
||||||
|
cell_um_given: bool = False # "cell_um": null explicitly means auto
|
||||||
|
freq_hz: float | None = None
|
||||||
|
contact_model: str | None = None
|
||||||
|
include_buildup: bool | None = None
|
||||||
|
extra_cu_um: float | None = None
|
||||||
|
push_overlays: bool | None = None
|
||||||
|
v_nominal: float | None = None
|
||||||
|
trim_enabled: bool | None = None
|
||||||
|
trim_mode: str | None = None
|
||||||
|
trim_value: float | None = None
|
||||||
|
# classic
|
||||||
|
current_a: float | None = None
|
||||||
|
contact1: str | None = None
|
||||||
|
contact2: str | None = None
|
||||||
|
pos_parts: list | None = None # [PartRef]
|
||||||
|
neg_parts: list | None = None
|
||||||
|
# pdn
|
||||||
|
terminals: list = field(default_factory=list) # [TerminalSpec]
|
||||||
|
# overrides
|
||||||
|
physics: dict = field(default_factory=dict)
|
||||||
|
markers: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DialogDefaults:
|
||||||
|
"""Everything the dialog seeds its widgets from. Built by
|
||||||
|
dialog_defaults(): config.py constants, overlaid with the config
|
||||||
|
file's values - the single precedence point."""
|
||||||
|
net: str | None = None
|
||||||
|
layers: list | None = None
|
||||||
|
include_tracks: bool = True
|
||||||
|
vias_capped: bool = True
|
||||||
|
cap_max_drill_mm: float = 0.5
|
||||||
|
adaptive: bool = True
|
||||||
|
contact_model: str = "uniform"
|
||||||
|
current_a: float = 1.0
|
||||||
|
freq_hz: float = 0.0
|
||||||
|
cell_um: float | None = None
|
||||||
|
include_buildup: bool = False
|
||||||
|
extra_cu_um: float = 0.0
|
||||||
|
push_overlays: bool = False
|
||||||
|
trim_enabled: bool = False
|
||||||
|
trim_mode: str = "pct"
|
||||||
|
trim_value: float | None = None # None = the mode's default
|
||||||
|
contact1: str | None = None # None = derived from the board
|
||||||
|
contact2: str | None = None
|
||||||
|
v_nominal: float | None = None # None = config.PDN_V_NOMINAL
|
||||||
|
|
||||||
|
|
||||||
|
# --- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def named_config_filename(name: str) -> str:
|
||||||
|
"""The named-config scheme: "fill_res_config.<name>.json". Plain
|
||||||
|
"fill_res_config.json" is the legacy spelling of the config named
|
||||||
|
"default"."""
|
||||||
|
stem, suffix = config.CONFIG_FILENAME.rsplit(".", 1)
|
||||||
|
return f"{stem}.{name}.{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_config(board_dir: Path, board_filename: str) -> Path | None:
|
||||||
|
"""Board-specific name first, then the config named "default" (its
|
||||||
|
plain legacy filename last); None when none exists."""
|
||||||
|
board_dir = Path(board_dir)
|
||||||
|
stem = Path(board_filename).stem
|
||||||
|
candidates = []
|
||||||
|
if stem:
|
||||||
|
candidates.append(board_dir / f"{stem}.{config.CONFIG_FILENAME}")
|
||||||
|
candidates.append(board_dir / named_config_filename("default"))
|
||||||
|
candidates.append(board_dir / config.CONFIG_FILENAME)
|
||||||
|
for c in candidates:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comment_lines(text: str) -> str:
|
||||||
|
"""Remove full-line // comments. Stripped lines become empty lines
|
||||||
|
so json.JSONDecodeError line numbers still point into the user's
|
||||||
|
file; inline // is NOT supported (it could sit inside a string)."""
|
||||||
|
return "\n".join("" if line.lstrip().startswith("//") else line
|
||||||
|
for line in text.split("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def _err(path: Path, keypath: str, msg: str) -> ConfigError:
|
||||||
|
return ConfigError(f"{path.name}: {keypath} {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unknown(path: Path, keypath: str, d: dict, known: tuple) -> None:
|
||||||
|
for k in d:
|
||||||
|
if isinstance(k, str) and not k.startswith("_") and k not in known:
|
||||||
|
print(f"config warning: unknown key '{keypath}{k}' in "
|
||||||
|
f"{path.name} (ignored)")
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(v, path, keypath) -> bool:
|
||||||
|
if not isinstance(v, bool):
|
||||||
|
raise _err(path, keypath, f"must be true or false (got {v!r})")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _str(v, path, keypath) -> str:
|
||||||
|
if not isinstance(v, str) or not v.strip():
|
||||||
|
raise _err(path, keypath, f"must be a non-empty string (got {v!r})")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v, path, keypath, minimum=None, exclusive=False) -> float:
|
||||||
|
if isinstance(v, str):
|
||||||
|
# every number may also be a string with an SI suffix ("50m",
|
||||||
|
# "4.7k") - the same grammar the dialog fields accept
|
||||||
|
try:
|
||||||
|
v = skin.parse_engineering(v)
|
||||||
|
except ValueError as e:
|
||||||
|
raise _err(path, keypath, f"cannot parse number {v!r} "
|
||||||
|
f"({e}; examples: 0.05, \"50m\", "
|
||||||
|
f"\"4.7k\")")
|
||||||
|
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||||
|
raise _err(path, keypath, f"must be a number (got {v!r})")
|
||||||
|
v = float(v)
|
||||||
|
if minimum is not None:
|
||||||
|
if exclusive and v <= minimum:
|
||||||
|
raise _err(path, keypath, f"must be > {minimum:g} (got {v:g})")
|
||||||
|
if not exclusive and v < minimum:
|
||||||
|
raise _err(path, keypath, f"must be >= {minimum:g} (got {v:g})")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _freq(v, path, keypath) -> float:
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return skin.parse_frequency(v)
|
||||||
|
except ValueError as e:
|
||||||
|
raise _err(path, keypath, f"cannot parse frequency {v!r} "
|
||||||
|
f"({e}; examples: 0, \"142k\", "
|
||||||
|
f"\"1.5M\")")
|
||||||
|
return _num(v, path, keypath, minimum=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _scope(v, path, keypath) -> str:
|
||||||
|
s = _str(v, path, keypath)
|
||||||
|
return s # "auto" / "all" / a layer name (checked on the board)
|
||||||
|
|
||||||
|
|
||||||
|
def _partref(v, path, keypath) -> PartRef:
|
||||||
|
if isinstance(v, str):
|
||||||
|
s = v.strip()
|
||||||
|
if s.startswith("rect:"):
|
||||||
|
label = s[len("rect:"):].strip()
|
||||||
|
if not label:
|
||||||
|
raise _err(path, keypath, "has an empty rectangle name "
|
||||||
|
"('rect:NAME')")
|
||||||
|
return PartRef(kind="rect_label", label=label)
|
||||||
|
if "." in s:
|
||||||
|
# first dot: pad numbers are strings and may contain dots,
|
||||||
|
# reference designators never do
|
||||||
|
ref, pad = s.split(".", 1)
|
||||||
|
if not ref or not pad:
|
||||||
|
raise _err(path, keypath, f"is not a valid reference "
|
||||||
|
f"({s!r}; expected \"U7\" or "
|
||||||
|
f"\"U7.3\")")
|
||||||
|
return PartRef(kind="pad", ref=ref, pad=pad)
|
||||||
|
if not s:
|
||||||
|
raise _err(path, keypath, "is an empty reference")
|
||||||
|
return PartRef(kind="footprint", ref=s)
|
||||||
|
if isinstance(v, dict):
|
||||||
|
_warn_unknown(path, keypath + ".", v, ("rect_mm", "via_mm",
|
||||||
|
"contact"))
|
||||||
|
contact = ""
|
||||||
|
if "contact" in v:
|
||||||
|
contact = _str(v["contact"], path, keypath + ".contact")
|
||||||
|
if "rect_mm" in v:
|
||||||
|
r = v["rect_mm"]
|
||||||
|
if (not isinstance(r, list) or len(r) != 4
|
||||||
|
or any(isinstance(x, bool)
|
||||||
|
or not isinstance(x, (int, float)) for x in r)):
|
||||||
|
raise _err(path, keypath + ".rect_mm",
|
||||||
|
"must be [x0, y0, x1, y1] in mm")
|
||||||
|
return PartRef(kind="rect_mm", rect_mm=tuple(float(x) for x in r),
|
||||||
|
contact=contact)
|
||||||
|
if "via_mm" in v:
|
||||||
|
r = v["via_mm"]
|
||||||
|
if (not isinstance(r, list) or len(r) != 2
|
||||||
|
or any(isinstance(x, bool)
|
||||||
|
or not isinstance(x, (int, float)) for x in r)):
|
||||||
|
raise _err(path, keypath + ".via_mm", "must be [x, y] in mm")
|
||||||
|
return PartRef(kind="via_mm", via_mm=tuple(float(x) for x in r),
|
||||||
|
contact=contact)
|
||||||
|
raise _err(path, keypath, "needs \"rect_mm\" or \"via_mm\"")
|
||||||
|
raise _err(path, keypath, f"must be a reference string or an object "
|
||||||
|
f"(got {v!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _partref_list(v, path, keypath) -> list:
|
||||||
|
if not isinstance(v, list) or not v:
|
||||||
|
raise _err(path, keypath, "must be a non-empty list of part "
|
||||||
|
"references")
|
||||||
|
return [_partref(x, path, f"{keypath}[{i}]") for i, x in enumerate(v)]
|
||||||
|
|
||||||
|
|
||||||
|
# --- load -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_config(path: Path) -> RunConfig:
|
||||||
|
path = Path(path)
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as e:
|
||||||
|
raise ConfigError(f"cannot read {path.name}: {e}")
|
||||||
|
try:
|
||||||
|
raw = json.loads(strip_comment_lines(text))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ConfigError(f"{path.name} is not valid JSON: {e.msg} at "
|
||||||
|
f"line {e.lineno}, column {e.colno}")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ConfigError(f"{path.name}: the top level must be an object")
|
||||||
|
return _validate(raw, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(raw: dict, path: Path) -> RunConfig:
|
||||||
|
_warn_unknown(path, "", raw, ("version", "mode", "run", "classic",
|
||||||
|
"terminals", "physics", "markers"))
|
||||||
|
if "version" not in raw:
|
||||||
|
raise _err(path, "version", "is required (currently 1)")
|
||||||
|
version = raw["version"]
|
||||||
|
if isinstance(version, bool) or not isinstance(version, int):
|
||||||
|
raise _err(path, "version", f"must be an integer (got {version!r})")
|
||||||
|
if version > SCHEMA_VERSION:
|
||||||
|
raise _err(path, "version", f"{version} is newer than this plugin "
|
||||||
|
f"understands (<= {SCHEMA_VERSION}) - "
|
||||||
|
f"update the plugin")
|
||||||
|
if version < 1:
|
||||||
|
raise _err(path, "version", f"must be >= 1 (got {version})")
|
||||||
|
|
||||||
|
cfg = RunConfig(path=path, raw=raw)
|
||||||
|
|
||||||
|
terminals_raw = raw.get("terminals")
|
||||||
|
if terminals_raw is not None and not isinstance(terminals_raw, list):
|
||||||
|
raise _err(path, "terminals", "must be a list")
|
||||||
|
has_terminals = bool(terminals_raw)
|
||||||
|
mode = raw.get("mode")
|
||||||
|
if mode is not None:
|
||||||
|
if mode not in ("classic", "pdn"):
|
||||||
|
raise _err(path, "mode", f"must be \"classic\" or \"pdn\" "
|
||||||
|
f"(got {mode!r})")
|
||||||
|
# mode only picks the STARTING mode; a classic config may
|
||||||
|
# carry a terminals section (the dialog switches freely, and
|
||||||
|
# classic saves preserve it) - but "pdn" with nothing to run
|
||||||
|
# is still a contradiction
|
||||||
|
if mode == "pdn" and not has_terminals:
|
||||||
|
raise _err(path, "mode", "is \"pdn\" but there are no "
|
||||||
|
"terminals")
|
||||||
|
cfg.mode = mode
|
||||||
|
else:
|
||||||
|
cfg.mode = "pdn" if has_terminals else "classic"
|
||||||
|
|
||||||
|
_validate_run(raw.get("run"), cfg, path)
|
||||||
|
_validate_classic(raw.get("classic"), cfg, path)
|
||||||
|
if cfg.pos_parts is not None and not cfg.net:
|
||||||
|
raise _err(path, "run.net", "is required when classic.pos/neg "
|
||||||
|
"define the terminals by reference")
|
||||||
|
if has_terminals:
|
||||||
|
_validate_terminals(terminals_raw, cfg, path)
|
||||||
|
if cfg.mode == "pdn" and not cfg.net:
|
||||||
|
raise _err(path, "run.net", "is required in PDN mode (the "
|
||||||
|
"net the terminals live on)")
|
||||||
|
_validate_physics(raw.get("physics"), cfg, path)
|
||||||
|
_validate_markers(raw.get("markers"), cfg, path)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
_RUN_KEYS = ("net", "layers", "include_tracks", "vias_capped",
|
||||||
|
"cap_max_drill_mm", "adaptive", "cell_um", "freq_hz",
|
||||||
|
"contact_model", "include_buildup", "extra_cu_um",
|
||||||
|
"push_overlays", "v_nominal", "trim")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_run(run, cfg: RunConfig, path: Path) -> None:
|
||||||
|
if run is None:
|
||||||
|
return
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
raise _err(path, "run", "must be an object")
|
||||||
|
_warn_unknown(path, "run.", run, _RUN_KEYS)
|
||||||
|
if "net" in run:
|
||||||
|
cfg.net = _str(run["net"], path, "run.net")
|
||||||
|
if "layers" in run:
|
||||||
|
v = run["layers"]
|
||||||
|
if not isinstance(v, list) or not v:
|
||||||
|
raise _err(path, "run.layers", "must be a non-empty list of "
|
||||||
|
"layer names")
|
||||||
|
cfg.layers = [_str(x, path, f"run.layers[{i}]")
|
||||||
|
for i, x in enumerate(v)]
|
||||||
|
for key in ("include_tracks", "vias_capped", "adaptive",
|
||||||
|
"include_buildup", "push_overlays"):
|
||||||
|
if key in run:
|
||||||
|
setattr(cfg, key, _bool(run[key], path, f"run.{key}"))
|
||||||
|
if "cap_max_drill_mm" in run:
|
||||||
|
cfg.cap_max_drill_mm = _num(run["cap_max_drill_mm"], path,
|
||||||
|
"run.cap_max_drill_mm", 0.0,
|
||||||
|
exclusive=True)
|
||||||
|
if "cell_um" in run:
|
||||||
|
cfg.cell_um_given = True
|
||||||
|
if run["cell_um"] is not None:
|
||||||
|
cfg.cell_um = _num(run["cell_um"], path, "run.cell_um", 0.0,
|
||||||
|
exclusive=True)
|
||||||
|
if "freq_hz" in run:
|
||||||
|
cfg.freq_hz = _freq(run["freq_hz"], path, "run.freq_hz")
|
||||||
|
if "contact_model" in run:
|
||||||
|
v = run["contact_model"]
|
||||||
|
if v not in ("uniform", "equipotential"):
|
||||||
|
raise _err(path, "run.contact_model",
|
||||||
|
f"must be \"uniform\" or \"equipotential\" "
|
||||||
|
f"(got {v!r})")
|
||||||
|
cfg.contact_model = v
|
||||||
|
if "extra_cu_um" in run:
|
||||||
|
cfg.extra_cu_um = _num(run["extra_cu_um"], path,
|
||||||
|
"run.extra_cu_um", 0.0)
|
||||||
|
if "v_nominal" in run:
|
||||||
|
cfg.v_nominal = _num(run["v_nominal"], path, "run.v_nominal", 0.0,
|
||||||
|
exclusive=True)
|
||||||
|
if "trim" in run:
|
||||||
|
t = run["trim"]
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
raise _err(path, "run.trim", "must be an object "
|
||||||
|
"{enabled, mode, value}")
|
||||||
|
_warn_unknown(path, "run.trim.", t, ("enabled", "mode", "value"))
|
||||||
|
if "enabled" in t:
|
||||||
|
cfg.trim_enabled = _bool(t["enabled"], path, "run.trim.enabled")
|
||||||
|
if "mode" in t:
|
||||||
|
if t["mode"] not in ("pct", "abs"):
|
||||||
|
raise _err(path, "run.trim.mode",
|
||||||
|
f"must be \"pct\" or \"abs\" (got {t['mode']!r})")
|
||||||
|
cfg.trim_mode = t["mode"]
|
||||||
|
if "value" in t:
|
||||||
|
v = _num(t["value"], path, "run.trim.value", 0.0,
|
||||||
|
exclusive=True)
|
||||||
|
if (cfg.trim_mode or config.TRIM_MODE) == "pct" and v >= 100:
|
||||||
|
raise _err(path, "run.trim.value",
|
||||||
|
"must be between 0 and 100 (% of the mean |J|)")
|
||||||
|
cfg.trim_value = v
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_classic(cl, cfg: RunConfig, path: Path) -> None:
|
||||||
|
if cl is None:
|
||||||
|
return
|
||||||
|
if not isinstance(cl, dict):
|
||||||
|
raise _err(path, "classic", "must be an object")
|
||||||
|
_warn_unknown(path, "classic.", cl, ("current_a", "contact1",
|
||||||
|
"contact2", "pos", "neg"))
|
||||||
|
if "current_a" in cl:
|
||||||
|
cfg.current_a = _num(cl["current_a"], path, "classic.current_a",
|
||||||
|
0.0, exclusive=True)
|
||||||
|
if "contact1" in cl:
|
||||||
|
cfg.contact1 = _scope(cl["contact1"], path, "classic.contact1")
|
||||||
|
if "contact2" in cl:
|
||||||
|
cfg.contact2 = _scope(cl["contact2"], path, "classic.contact2")
|
||||||
|
if ("pos" in cl) != ("neg" in cl):
|
||||||
|
raise _err(path, "classic", "needs pos and neg together (or "
|
||||||
|
"neither - terminals then come from "
|
||||||
|
"the board)")
|
||||||
|
if "pos" in cl:
|
||||||
|
cfg.pos_parts = _partref_list(cl["pos"], path, "classic.pos")
|
||||||
|
cfg.neg_parts = _partref_list(cl["neg"], path, "classic.neg")
|
||||||
|
|
||||||
|
|
||||||
|
_TERMINAL_KEYS = ("name", "role", "parts", "active", "i_draw_a",
|
||||||
|
"r_out_ohm", "v_oc", "contact", "bonded", "comment")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_terminals(terms, cfg: RunConfig, path: Path) -> None:
|
||||||
|
if not terms:
|
||||||
|
raise _err(path, "terminals", "must be a non-empty list in PDN "
|
||||||
|
"mode")
|
||||||
|
names = set()
|
||||||
|
n_sup = n_load = 0
|
||||||
|
for i, t in enumerate(terms):
|
||||||
|
kp = f"terminals[{i}]"
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
raise _err(path, kp, "must be an object")
|
||||||
|
_warn_unknown(path, kp + ".", t, _TERMINAL_KEYS)
|
||||||
|
if "name" not in t:
|
||||||
|
raise _err(path, kp + ".name", "is required")
|
||||||
|
name = _str(t["name"], path, kp + ".name")
|
||||||
|
if name in names:
|
||||||
|
raise _err(path, kp + ".name", f"duplicates terminal "
|
||||||
|
f"'{name}'")
|
||||||
|
names.add(name)
|
||||||
|
role = t.get("role")
|
||||||
|
if role not in ("supply", "load"):
|
||||||
|
raise _err(path, kp + ".role", f"must be \"supply\" or "
|
||||||
|
f"\"load\" (got {role!r})")
|
||||||
|
if "parts" not in t:
|
||||||
|
raise _err(path, kp + ".parts", "is required")
|
||||||
|
parts = _partref_list(t["parts"], path, kp + ".parts")
|
||||||
|
spec = TerminalSpec(name=name, role=role, parts=parts)
|
||||||
|
if "active" in t:
|
||||||
|
spec.active = _bool(t["active"], path, kp + ".active")
|
||||||
|
if "comment" in t:
|
||||||
|
# empty string allowed (unlike _str): "" simply means none
|
||||||
|
if not isinstance(t["comment"], str):
|
||||||
|
raise _err(path, kp + ".comment",
|
||||||
|
f"must be a string (got {t['comment']!r})")
|
||||||
|
spec.comment = t["comment"]
|
||||||
|
# a value is REQUIRED only while the terminal is active; an
|
||||||
|
# inactive one may stay blank (it takes no part in the run) -
|
||||||
|
# but a value that IS given must be valid either way
|
||||||
|
if role == "load":
|
||||||
|
n_load += spec.active
|
||||||
|
if "r_out_ohm" in t or "v_oc" in t:
|
||||||
|
raise _err(path, kp, "is a load: r_out_ohm/v_oc belong "
|
||||||
|
"on supplies (did you mean role "
|
||||||
|
"\"supply\"?)")
|
||||||
|
if "i_draw_a" in t:
|
||||||
|
spec.i_draw_a = _num(t["i_draw_a"], path,
|
||||||
|
kp + ".i_draw_a", 0.0)
|
||||||
|
elif spec.active:
|
||||||
|
raise _err(path, kp + ".i_draw_a", "is required for an "
|
||||||
|
"active load")
|
||||||
|
else:
|
||||||
|
n_sup += spec.active
|
||||||
|
if "i_draw_a" in t:
|
||||||
|
raise _err(path, kp, "is a supply: i_draw_a belongs on "
|
||||||
|
"loads (did you mean role "
|
||||||
|
"\"load\"?)")
|
||||||
|
if "r_out_ohm" in t:
|
||||||
|
spec.r_out_ohm = _num(t["r_out_ohm"], path,
|
||||||
|
kp + ".r_out_ohm", 0.0)
|
||||||
|
elif spec.active:
|
||||||
|
raise _err(path, kp + ".r_out_ohm", "is required for an "
|
||||||
|
"active supply")
|
||||||
|
if "v_oc" in t:
|
||||||
|
spec.v_oc = _num(t["v_oc"], path, kp + ".v_oc", 0.0,
|
||||||
|
exclusive=True)
|
||||||
|
if "contact" in t:
|
||||||
|
spec.contact = _scope(t["contact"], path, kp + ".contact")
|
||||||
|
if "bonded" in t:
|
||||||
|
spec.bonded = _bool(t["bonded"], path, kp + ".bonded")
|
||||||
|
cfg.terminals.append(spec)
|
||||||
|
if n_sup == 0:
|
||||||
|
raise _err(path, "terminals", "needs at least one active supply")
|
||||||
|
if n_load == 0:
|
||||||
|
raise _err(path, "terminals", "needs at least one active load")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_physics(ph, cfg: RunConfig, path: Path) -> None:
|
||||||
|
if ph is None:
|
||||||
|
return
|
||||||
|
if not isinstance(ph, dict):
|
||||||
|
raise _err(path, "physics", "must be an object")
|
||||||
|
_warn_unknown(path, "physics.", ph, ("rho_cu_ohm_m",
|
||||||
|
"copper_thickness_um",
|
||||||
|
"via_plating_um"))
|
||||||
|
for key in ("rho_cu_ohm_m", "copper_thickness_um", "via_plating_um"):
|
||||||
|
if key in ph:
|
||||||
|
cfg.physics[key] = _num(ph[key], path, f"physics.{key}", 0.0,
|
||||||
|
exclusive=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_markers(mk, cfg: RunConfig, path: Path) -> None:
|
||||||
|
if mk is None:
|
||||||
|
return
|
||||||
|
if not isinstance(mk, dict):
|
||||||
|
raise _err(path, "markers", "must be an object")
|
||||||
|
_warn_unknown(path, "markers.", mk, ("pos_layer", "neg_layer",
|
||||||
|
"pdn_layer"))
|
||||||
|
for key in ("pos_layer", "neg_layer", "pdn_layer"):
|
||||||
|
if key in mk:
|
||||||
|
cfg.markers[key] = _str(mk[key], path, f"markers.{key}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- precedence / application -----------------------------------------------
|
||||||
|
|
||||||
|
def dialog_defaults(cfg: RunConfig | None = None) -> DialogDefaults:
|
||||||
|
"""The single precedence point below the dialog: config.py
|
||||||
|
constants, overlaid with the config file's values. Reads the
|
||||||
|
constants at call time (they are mutable globals)."""
|
||||||
|
d = DialogDefaults(
|
||||||
|
include_tracks=config.INCLUDE_TRACKS,
|
||||||
|
vias_capped=config.VIAS_CAPPED,
|
||||||
|
cap_max_drill_mm=config.CAP_MAX_DRILL_MM,
|
||||||
|
adaptive=config.ADAPTIVE_CELLS,
|
||||||
|
contact_model=config.CONTACT_MODEL,
|
||||||
|
current_a=config.TEST_CURRENT_A,
|
||||||
|
include_buildup=config.INCLUDE_MASK_BUILDUP,
|
||||||
|
extra_cu_um=config.BUILDUP_EXTRA_CU_UM,
|
||||||
|
push_overlays=config.PUSH_OVERLAYS,
|
||||||
|
trim_enabled=config.TRIM_ENABLED,
|
||||||
|
trim_mode=config.TRIM_MODE,
|
||||||
|
)
|
||||||
|
if cfg is None:
|
||||||
|
return d
|
||||||
|
for name in ("net", "layers", "include_tracks", "vias_capped",
|
||||||
|
"cap_max_drill_mm", "adaptive", "contact_model",
|
||||||
|
"current_a", "freq_hz", "include_buildup", "extra_cu_um",
|
||||||
|
"push_overlays", "trim_enabled", "trim_mode",
|
||||||
|
"trim_value", "contact1", "contact2", "v_nominal"):
|
||||||
|
v = getattr(cfg, name)
|
||||||
|
if v is not None:
|
||||||
|
setattr(d, name, v)
|
||||||
|
if cfg.cell_um_given:
|
||||||
|
d.cell_um = cfg.cell_um
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def apply_physics(cfg: RunConfig | None) -> None:
|
||||||
|
"""Push the physics/markers overrides into the config module - the
|
||||||
|
same global-mutation mechanism main() already uses for cell size
|
||||||
|
and the adaptive flag. Call before any board geometry is gathered
|
||||||
|
(the marker layers steer get_electrodes)."""
|
||||||
|
if cfg is None:
|
||||||
|
return
|
||||||
|
ph = cfg.physics
|
||||||
|
if "rho_cu_ohm_m" in ph:
|
||||||
|
config.RHO_CU_OHM_M = ph["rho_cu_ohm_m"]
|
||||||
|
if "copper_thickness_um" in ph:
|
||||||
|
config.COPPER_THICKNESS_UM = ph["copper_thickness_um"]
|
||||||
|
if "via_plating_um" in ph:
|
||||||
|
config.VIA_PLATING_UM = ph["via_plating_um"]
|
||||||
|
mk = cfg.markers
|
||||||
|
if "pos_layer" in mk:
|
||||||
|
config.ELECTRODE_POS_LAYER = mk["pos_layer"]
|
||||||
|
if "neg_layer" in mk:
|
||||||
|
config.ELECTRODE_NEG_LAYER = mk["neg_layer"]
|
||||||
|
if "pdn_layer" in mk:
|
||||||
|
config.ELECTRODE_PDN_LAYER = mk["pdn_layer"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- save -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run_section(selection) -> dict:
|
||||||
|
"""The `run` block serialized from a dialog Selection - shared by
|
||||||
|
the classic and PDN savers. v_nominal is written only when the
|
||||||
|
Selection carries one (PDN mode), so classic saves stay exactly as
|
||||||
|
before."""
|
||||||
|
run = {
|
||||||
|
"net": selection.net,
|
||||||
|
"layers": selection.layers,
|
||||||
|
"include_tracks": selection.include_tracks,
|
||||||
|
"vias_capped": selection.vias_capped,
|
||||||
|
"cap_max_drill_mm": selection.cap_max_drill_mm,
|
||||||
|
"adaptive": selection.adaptive,
|
||||||
|
"cell_um": selection.cell_um,
|
||||||
|
"freq_hz": selection.freq_hz,
|
||||||
|
"contact_model": selection.contact_model,
|
||||||
|
"include_buildup": selection.include_buildup,
|
||||||
|
"extra_cu_um": selection.extra_cu_um,
|
||||||
|
"push_overlays": selection.push_overlays,
|
||||||
|
"trim": {"enabled": selection.trim_enabled,
|
||||||
|
"mode": selection.trim_mode,
|
||||||
|
"value": selection.trim_value},
|
||||||
|
}
|
||||||
|
v_nom = getattr(selection, "v_nominal", None)
|
||||||
|
if v_nom is not None:
|
||||||
|
run["v_nominal"] = v_nom
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def save_classic_config(path: Path, selection) -> None:
|
||||||
|
"""Serialize the dialog's current values ("Save config...") with
|
||||||
|
mode "classic". An existing file's physics / markers / terminals /
|
||||||
|
classic.pos / classic.neg sections are preserved (load-merge-
|
||||||
|
write) - saving classic values over a PDN config keeps its whole
|
||||||
|
terminal set and only flips the STARTING mode; // comments are NOT
|
||||||
|
preserved - the file is rewritten. Refuses a file it cannot parse
|
||||||
|
(never destroy user edits); the assembled data passes the loader's
|
||||||
|
own validation before anything touches disk."""
|
||||||
|
path = Path(path)
|
||||||
|
old_raw: dict = {}
|
||||||
|
if path.exists():
|
||||||
|
old = load_config(path) # ConfigError propagates: fix first
|
||||||
|
old_raw = old.raw
|
||||||
|
data = {
|
||||||
|
"version": SCHEMA_VERSION,
|
||||||
|
"mode": "classic",
|
||||||
|
"run": _run_section(selection),
|
||||||
|
"classic": {
|
||||||
|
"current_a": selection.current_a,
|
||||||
|
"contact1": selection.contact1,
|
||||||
|
"contact2": selection.contact2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
old_classic = old_raw.get("classic") or {}
|
||||||
|
for key in ("pos", "neg"):
|
||||||
|
if key in old_classic:
|
||||||
|
data["classic"][key] = old_classic[key]
|
||||||
|
for section in ("terminals", "physics", "markers"):
|
||||||
|
if section in old_raw:
|
||||||
|
data[section] = old_raw[section]
|
||||||
|
_validate(data, path) # self-check before writing
|
||||||
|
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def updated_terminals_json(raw_terminals: list, rows: list) -> list:
|
||||||
|
"""Config-backed PDN save: each raw terminal object is deep-copied
|
||||||
|
verbatim (parts, "_"-prefixed keys preserved) and only the
|
||||||
|
dialog-editable values - I / R_out / V_oc and the terminal-level
|
||||||
|
contact layer - are written back POSITIONALLY: the dialog never
|
||||||
|
reorders its tables, so index i is the same terminal in both lists.
|
||||||
|
A supply row's v_oc of None REMOVES the key (restoring the
|
||||||
|
defaults-to-v_nominal semantics); a contact of "auto" removes the
|
||||||
|
key too (auto is the schema default). Part-level contacts inside
|
||||||
|
`parts` stay untouched and keep winning over the terminal scope."""
|
||||||
|
out = []
|
||||||
|
for raw, row in zip(raw_terminals, rows):
|
||||||
|
t = copy.deepcopy(raw)
|
||||||
|
# value cells may be blank on an INACTIVE row - None then
|
||||||
|
# removes the key (an active row always carries a value)
|
||||||
|
if row.role == "load":
|
||||||
|
if row.i_draw_a is None:
|
||||||
|
t.pop("i_draw_a", None)
|
||||||
|
else:
|
||||||
|
t["i_draw_a"] = row.i_draw_a
|
||||||
|
else:
|
||||||
|
if row.r_out_ohm is None:
|
||||||
|
t.pop("r_out_ohm", None)
|
||||||
|
else:
|
||||||
|
t["r_out_ohm"] = row.r_out_ohm
|
||||||
|
if row.v_oc is None:
|
||||||
|
t.pop("v_oc", None)
|
||||||
|
else:
|
||||||
|
t["v_oc"] = row.v_oc
|
||||||
|
contact = getattr(row, "contact", "auto")
|
||||||
|
if contact and contact != "auto":
|
||||||
|
t["contact"] = contact
|
||||||
|
else:
|
||||||
|
t.pop("contact", None)
|
||||||
|
if getattr(row, "active", True):
|
||||||
|
t.pop("active", None) # true is the schema default
|
||||||
|
else:
|
||||||
|
t["active"] = False
|
||||||
|
comment = getattr(row, "comment", "")
|
||||||
|
if comment:
|
||||||
|
t["comment"] = comment
|
||||||
|
else:
|
||||||
|
t.pop("comment", None)
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rect_terminals_json(rows: list, rect_infos: list) -> list:
|
||||||
|
"""PDN-editor save: rect_infos[i] = (labeled: bool, (x0, y0, x1,
|
||||||
|
y1) board mm), parallel to rows. Labeled rectangles save as live
|
||||||
|
"rect:NAME" refs (they follow the rectangle wherever it moves and
|
||||||
|
resizes); unnamed ones freeze as rect_mm coordinates. A row's
|
||||||
|
contact layer is written as the terminal-level "contact" key; "all"
|
||||||
|
is omitted (a marker rectangle's natural scope already contacts
|
||||||
|
every selected layer)."""
|
||||||
|
out = []
|
||||||
|
for row, (labeled, rect_mm) in zip(rows, rect_infos):
|
||||||
|
if labeled:
|
||||||
|
parts: list = [f"rect:{row.name}"]
|
||||||
|
else:
|
||||||
|
parts = [{"rect_mm": [round(float(v), 6) for v in rect_mm]}]
|
||||||
|
t: dict = {"name": row.name, "role": row.role, "parts": parts}
|
||||||
|
if not getattr(row, "active", True):
|
||||||
|
t["active"] = False # true is the schema default
|
||||||
|
contact = getattr(row, "contact", "all")
|
||||||
|
if contact not in ("", "auto", "all"):
|
||||||
|
t["contact"] = contact
|
||||||
|
if getattr(row, "bonded", False):
|
||||||
|
t["bonded"] = True
|
||||||
|
# value cells may be blank on an inactive row (None: no key)
|
||||||
|
if row.role == "load":
|
||||||
|
if row.i_draw_a is not None:
|
||||||
|
t["i_draw_a"] = row.i_draw_a
|
||||||
|
else:
|
||||||
|
if row.r_out_ohm is not None:
|
||||||
|
t["r_out_ohm"] = row.r_out_ohm
|
||||||
|
if row.v_oc is not None:
|
||||||
|
t["v_oc"] = row.v_oc
|
||||||
|
comment = getattr(row, "comment", "")
|
||||||
|
if comment:
|
||||||
|
t["comment"] = comment
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def save_pdn_config(path: Path, selection, terminals: list) -> None:
|
||||||
|
"""Serialize a PDN dialog run ("Save config..." in PDN mode).
|
||||||
|
`terminals` is the schema-shaped list from updated_terminals_json /
|
||||||
|
rect_terminals_json. Preserves an existing file's physics / markers
|
||||||
|
and its WHOLE classic section (a later hand-edit of mode back to
|
||||||
|
"classic" finds it intact); refuses a file it cannot parse. The
|
||||||
|
assembled data passes the loader's own validation before anything
|
||||||
|
touches disk, so a save can never produce a config the next launch
|
||||||
|
rejects."""
|
||||||
|
path = Path(path)
|
||||||
|
old_raw: dict = {}
|
||||||
|
if path.exists():
|
||||||
|
old = load_config(path) # ConfigError propagates: fix first
|
||||||
|
old_raw = old.raw
|
||||||
|
data = {
|
||||||
|
"version": SCHEMA_VERSION,
|
||||||
|
"mode": "pdn",
|
||||||
|
"run": _run_section(selection),
|
||||||
|
"terminals": terminals,
|
||||||
|
}
|
||||||
|
for section in ("classic", "physics", "markers"):
|
||||||
|
if section in old_raw:
|
||||||
|
data[section] = old_raw[section]
|
||||||
|
_validate(data, path) # self-check before writing
|
||||||
|
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
|
||||||
+753
-79
@@ -2,18 +2,55 @@
|
|||||||
contact, test current, optional cell size. PySide6 is already a plugin
|
contact, test current, optional cell size. PySide6 is already a plugin
|
||||||
dependency (matplotlib QtAgg backend); the QApplication created here is
|
dependency (matplotlib QtAgg backend); the QApplication created here is
|
||||||
reused by matplotlib afterwards.
|
reused by matplotlib afterwards.
|
||||||
|
|
||||||
|
Widget defaults come from a configfile.DialogDefaults (config.py
|
||||||
|
constants overlaid with the optional fill_res_config.json) - the dialog
|
||||||
|
never reads config.* seeds directly, so the file's precedence lives in
|
||||||
|
one place.
|
||||||
|
|
||||||
|
Two run modes share the dialog, chosen by a radio at the top:
|
||||||
|
|
||||||
|
- Classic: one V+ and one V- terminal (each may bundle several contact
|
||||||
|
parts), contact scopes, contact model, one test current;
|
||||||
|
- PDN: two editable terminal tables instead - one for supplies, one
|
||||||
|
for loads, each titled with its marker layer. A load row takes a
|
||||||
|
current draw, a supply row an output resistance and an optional
|
||||||
|
open-circuit voltage; every row also picks the contacted copper
|
||||||
|
layer and shows the component it belongs to (read-only, from
|
||||||
|
board_io.component_hints). The terminal set comes either from the
|
||||||
|
live marker-rectangle scan (PdnSetup.from_config False: User.1
|
||||||
|
rects are supplies, User.2 rects are loads) or from a config file's
|
||||||
|
terminals section (from_config True: the file says WHICH terminals
|
||||||
|
exist, everything else - mode, net, values, layers, active,
|
||||||
|
comments - stays editable; nothing is pinned).
|
||||||
|
|
||||||
|
"Load config…" swaps the whole setup for another config file: ask()
|
||||||
|
then returns a LoadRequest instead of a Selection and main re-derives
|
||||||
|
everything from that file and reopens the dialog.
|
||||||
|
|
||||||
|
Row identity is POSITIONAL: the tables never sort or reorder, so
|
||||||
|
main.py zips Selection.pdn_rows with its own parallel terminal list.
|
||||||
|
Rows carry the nets their contacts overlap; rows not on the active net
|
||||||
|
are hidden, and come back (like unchecked rows) with active=False -
|
||||||
|
still saved to the config, just not part of the run.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog,
|
from PySide6.QtWidgets import (QAbstractScrollArea, QApplication, QCheckBox,
|
||||||
QDialogButtonBox, QFormLayout, QHBoxLayout,
|
QComboBox, QDialog, QDialogButtonBox,
|
||||||
QLabel, QLineEdit, QListWidget,
|
QFileDialog, QFormLayout, QFrame,
|
||||||
QListWidgetItem, QVBoxLayout, QWidget)
|
QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
||||||
|
QListWidgetItem, QRadioButton, QScrollArea,
|
||||||
|
QSplitter, QTableWidget, QTableWidgetItem,
|
||||||
|
QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from . import config, skin
|
from . import config, configfile, skin
|
||||||
|
from .configfile import DialogDefaults, dialog_defaults
|
||||||
|
from .errors import ConfigError
|
||||||
|
|
||||||
ALL_LAYERS = "All selected layers"
|
ALL_LAYERS = "All selected layers"
|
||||||
AUTO_CONTACT = "(auto: per contact part)"
|
AUTO_CONTACT = "(auto: per contact part)"
|
||||||
@@ -23,6 +60,19 @@ MODEL_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_number(text: str, name: str) -> float:
|
||||||
|
"""Shared by QLineEdits and table cells: float with SI suffixes
|
||||||
|
(50m = 0.05, 4.7k = 4700) and the decimal-comma normalization,
|
||||||
|
ValueError with a user-readable message."""
|
||||||
|
try:
|
||||||
|
return skin.parse_engineering(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
if "separator" in str(exc):
|
||||||
|
raise ValueError(f"{name}: {exc}")
|
||||||
|
raise ValueError(f"{name}: '{text}' is not a number "
|
||||||
|
f"(SI suffixes work: 50m, 4.7k, 2M).")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Selection:
|
class Selection:
|
||||||
net: str
|
net: str
|
||||||
@@ -43,112 +93,282 @@ class Selection:
|
|||||||
trim_enabled: bool = False # EXPERIMENTAL low-current copper marking
|
trim_enabled: bool = False # EXPERIMENTAL low-current copper marking
|
||||||
trim_mode: str = "pct" # "pct" (% of the mean |J|) or "abs" (A/mm2)
|
trim_mode: str = "pct" # "pct" (% of the mean |J|) or "abs" (A/mm2)
|
||||||
trim_value: float = 10.0 # threshold in the unit trim_mode names
|
trim_value: float = 10.0 # threshold in the unit trim_mode names
|
||||||
|
mode: str = "classic" # "classic" | "pdn" (current_a then
|
||||||
|
# carries the summed load draw)
|
||||||
|
pdn_rows: list | None = None # PDN: validated PdnTerminalRow list,
|
||||||
|
# same order and LENGTH as the
|
||||||
|
# PdnSetup given in; a row's `active`
|
||||||
|
# is False when unchecked OR hidden
|
||||||
|
# by the net filter (not in the run,
|
||||||
|
# but still saved)
|
||||||
|
v_nominal: float | None = None # PDN: default supply v_oc [V]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PdnTerminalRow:
|
||||||
|
"""One terminal in the PDN tables. Identity is positional (the
|
||||||
|
dialog never reorders rows), so main.py zips the returned list
|
||||||
|
against its own parallel terminal list - no key needed."""
|
||||||
|
name: str
|
||||||
|
role: str # "supply" | "load"
|
||||||
|
resolved: str # read-only geometry description
|
||||||
|
component: str = "" # read-only owner hint ("U5" /
|
||||||
|
# "near U5", board_io.component_hints)
|
||||||
|
active: bool = True # checkbox: false = the terminal is
|
||||||
|
# kept (and saved) but takes no part
|
||||||
|
# in the run; its value cells may
|
||||||
|
# then stay blank
|
||||||
|
comment: str = "" # free-text note, saved to the config
|
||||||
|
i_draw_a: float | None = None # loads; None = not entered yet
|
||||||
|
r_out_ohm: float | None = None # supplies; None = not entered yet
|
||||||
|
v_oc: float | None = None # supplies; None = v_nominal
|
||||||
|
bonded: bool = False # multi-contact lug: the TOTAL value
|
||||||
|
# applies, the per-contact split is a
|
||||||
|
# solve outcome (display/data only -
|
||||||
|
# not editable in the table)
|
||||||
|
contact: str = "all" # terminal-level layer scope: "auto"
|
||||||
|
# (per contact part - config-backed
|
||||||
|
# rows only), "all", or a layer name
|
||||||
|
from_config: bool = False # this ROW's geometry comes from the
|
||||||
|
# config file (a setup may mix file
|
||||||
|
# terminals with newly drawn
|
||||||
|
# rectangles)
|
||||||
|
nets: frozenset | None = None # nets whose copper the contacts
|
||||||
|
# overlap; the row is HIDDEN while
|
||||||
|
# the active net is not in the set
|
||||||
|
# (skipped by validation and solve,
|
||||||
|
# SAVED as active: false). None =
|
||||||
|
# always shown (no net info)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LoadRequest:
|
||||||
|
"""Returned by ask() instead of a Selection when the user picked a
|
||||||
|
file with "Load config…" - main re-derives everything from that
|
||||||
|
config and reopens the dialog."""
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PdnSetup:
|
||||||
|
"""The PDN side of the dialog - plain data so main.py builds it
|
||||||
|
without the dialog importing board_io."""
|
||||||
|
rows: list # [PdnTerminalRow] in display order
|
||||||
|
source: str # header: config file name, or
|
||||||
|
# "marker rectangles on User.1/User.2"
|
||||||
|
from_config: bool = False # rows come from a config file: the
|
||||||
|
# file is authoritative for WHICH
|
||||||
|
# terminals exist (structural edits
|
||||||
|
# happen there), everything else is
|
||||||
|
# editable - nothing is pinned
|
||||||
|
note: str = "" # extra hint ("selection ignored")
|
||||||
|
|
||||||
|
|
||||||
class _Dialog(QDialog):
|
class _Dialog(QDialog):
|
||||||
def __init__(self, candidates: dict[str, list[str]], layer_order: list[str],
|
def __init__(self, candidates: dict[str, list[str]], layer_order: list[str],
|
||||||
default_net: str, e1_label: str, e2_label: str,
|
default_net: str, e1_label: str, e2_label: str,
|
||||||
contact1: str, contact2: str, buildup_layers: list[str]):
|
contact1: str, contact2: str, buildup_layers: list[str],
|
||||||
|
defaults: DialogDefaults | None = None,
|
||||||
|
pdn: PdnSetup | None = None,
|
||||||
|
pdn_candidates: dict | None = None,
|
||||||
|
classic_reason: str | None = None,
|
||||||
|
pdn_reason: str | None = None,
|
||||||
|
save_callback=None, save_target=None, load_dir=None,
|
||||||
|
start_mode: str = "classic"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
d = defaults if defaults is not None else dialog_defaults(None)
|
||||||
self.setWindowTitle("Fill Resistance")
|
self.setWindowTitle("Fill Resistance")
|
||||||
self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
|
self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
|
||||||
self._candidates = candidates
|
|
||||||
self._layer_order = layer_order
|
self._layer_order = layer_order
|
||||||
|
self._pdn = pdn
|
||||||
|
self._save_callback = save_callback
|
||||||
|
self._save_target = save_target # picker seed; last save wins
|
||||||
|
self._load_dir = load_dir
|
||||||
|
self._load_request: Path | None = None
|
||||||
|
self._classic_ok = classic_reason is None
|
||||||
|
self._pdn_ok = pdn is not None
|
||||||
|
self._candidates_classic = candidates
|
||||||
|
self._candidates_pdn = pdn_candidates or {}
|
||||||
|
self._candidates: dict = {}
|
||||||
|
# config-provided layer subset: applied while the dialog shows
|
||||||
|
# the net it was written for; switching nets re-checks all
|
||||||
|
self._preset_layers = d.layers
|
||||||
|
self._preset_net = default_net
|
||||||
|
|
||||||
form = QFormLayout()
|
# --- mode selector ---------------------------------------------
|
||||||
|
# "Classic", not "two-terminal": classic terminals can bundle
|
||||||
|
# many contact parts - the old label read like a 2-contact cap
|
||||||
|
self.mode_classic = QRadioButton("Classic")
|
||||||
|
self.mode_pdn = QRadioButton("PDN")
|
||||||
|
self.mode_classic.setEnabled(self._classic_ok)
|
||||||
|
self.mode_pdn.setEnabled(self._pdn_ok)
|
||||||
|
start_pdn = self._pdn_ok and (not self._classic_ok
|
||||||
|
or start_mode == "pdn")
|
||||||
|
(self.mode_pdn if start_pdn else self.mode_classic).setChecked(True)
|
||||||
|
reason = None
|
||||||
|
if not self._classic_ok and classic_reason:
|
||||||
|
reason = f"Classic unavailable: {classic_reason}"
|
||||||
|
self.mode_classic.setToolTip(classic_reason)
|
||||||
|
elif not self._pdn_ok and pdn_reason:
|
||||||
|
reason = f"PDN unavailable: {pdn_reason}"
|
||||||
|
self.mode_pdn.setToolTip(pdn_reason)
|
||||||
|
|
||||||
|
# --- shared form #1 --------------------------------------------
|
||||||
|
form1 = QFormLayout()
|
||||||
|
|
||||||
self.net_box = QComboBox()
|
self.net_box = QComboBox()
|
||||||
for net in sorted(candidates):
|
form1.addRow("Signal (net):", self.net_box)
|
||||||
self.net_box.addItem(net)
|
|
||||||
self.net_box.setCurrentText(default_net)
|
|
||||||
form.addRow("Signal (net):", self.net_box)
|
|
||||||
|
|
||||||
self.layer_list = QListWidget()
|
self.layer_list = QListWidget()
|
||||||
self.layer_list.setMaximumHeight(120)
|
self.layer_list.setMaximumHeight(120)
|
||||||
form.addRow("Layers:", self.layer_list)
|
form1.addRow("Layers:", self.layer_list)
|
||||||
|
|
||||||
self.tracks_check = QCheckBox("include the net's traces "
|
self.tracks_check = QCheckBox("include the net's traces "
|
||||||
"(tracks + arcs)")
|
"(tracks + arcs)")
|
||||||
self.tracks_check.setChecked(config.INCLUDE_TRACKS)
|
self.tracks_check.setChecked(d.include_tracks)
|
||||||
form.addRow("Conductors:", self.tracks_check)
|
form1.addRow("Conductors:", self.tracks_check)
|
||||||
|
|
||||||
self.capped_check = QCheckBox(
|
self.capped_check = QCheckBox(
|
||||||
f"vias filled + capped ({config.CAP_PLATING_UM:g} µm cap; "
|
f"vias filled + capped ({config.CAP_PLATING_UM:g} µm cap; "
|
||||||
f"off = open mouths)")
|
f"off = open mouths)")
|
||||||
self.capped_check.setChecked(config.VIAS_CAPPED)
|
self.capped_check.setChecked(d.vias_capped)
|
||||||
form.addRow("Vias:", self.capped_check)
|
form1.addRow("Vias:", self.capped_check)
|
||||||
|
|
||||||
self.cap_drill_edit = QLineEdit(f"{config.CAP_MAX_DRILL_MM:g}")
|
self.cap_drill_edit = QLineEdit(f"{d.cap_max_drill_mm:g}")
|
||||||
self.cap_drill_edit.setEnabled(config.VIAS_CAPPED)
|
self.cap_drill_edit.setEnabled(d.vias_capped)
|
||||||
self.capped_check.toggled.connect(self.cap_drill_edit.setEnabled)
|
self.capped_check.toggled.connect(self.cap_drill_edit.setEnabled)
|
||||||
form.addRow("Capped up to drill [mm]:", self.cap_drill_edit)
|
form1.addRow("Capped up to drill [mm]:", self.cap_drill_edit)
|
||||||
|
|
||||||
self.adaptive_check = QCheckBox(
|
self.adaptive_check = QCheckBox(
|
||||||
"adaptive cells (coarsen plane interiors; faster on large "
|
"adaptive cells (coarsen plane interiors; faster on large "
|
||||||
"boards, corrected to ≲0.03 % of the uniform grid)")
|
"boards, corrected to ≲0.03 % of the uniform grid)")
|
||||||
self.adaptive_check.setChecked(config.ADAPTIVE_CELLS)
|
self.adaptive_check.setChecked(d.adaptive)
|
||||||
form.addRow("Grid:", self.adaptive_check)
|
form1.addRow("Grid:", self.adaptive_check)
|
||||||
|
|
||||||
self.contact1_box = QComboBox()
|
# --- classic section (only when classic mode is available) -----
|
||||||
self.contact2_box = QComboBox()
|
# rows are CREATED conditionally, never shown-but-ignored; the
|
||||||
form.addRow(f"V+ ({e1_label}):", self.contact1_box)
|
# switchable case toggles the whole section widget instead
|
||||||
form.addRow(f"V− ({e2_label}):", self.contact2_box)
|
# (portable to old Qt - QFormLayout.setRowVisible is 6.4+)
|
||||||
|
self.classic_section = None
|
||||||
|
self.contact1_box = None
|
||||||
|
self.contact2_box = None
|
||||||
|
self.model_box = None
|
||||||
|
self.current_edit = None
|
||||||
|
if self._classic_ok:
|
||||||
|
self.classic_section = QWidget()
|
||||||
|
cform = QFormLayout(self.classic_section)
|
||||||
|
cform.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.contact1_box = QComboBox()
|
||||||
|
self.contact2_box = QComboBox()
|
||||||
|
cform.addRow(f"V+ ({e1_label}):", self.contact1_box)
|
||||||
|
cform.addRow(f"V− ({e2_label}):", self.contact2_box)
|
||||||
|
|
||||||
self.model_box = QComboBox()
|
self.model_box = QComboBox()
|
||||||
for key in ("uniform", "equipotential"):
|
for key in ("uniform", "equipotential"):
|
||||||
self.model_box.addItem(MODEL_LABELS[key], key)
|
self.model_box.addItem(MODEL_LABELS[key], key)
|
||||||
default_index = 0 if config.CONTACT_MODEL == "uniform" else 1
|
default_index = 0 if d.contact_model == "uniform" else 1
|
||||||
self.model_box.setCurrentIndex(default_index)
|
self.model_box.setCurrentIndex(default_index)
|
||||||
form.addRow("Contact model:", self.model_box)
|
cform.addRow("Contact model:", self.model_box)
|
||||||
|
|
||||||
self.current_edit = QLineEdit(f"{config.TEST_CURRENT_A:g}")
|
self.current_edit = QLineEdit(f"{d.current_a:g}")
|
||||||
form.addRow("Test current [A]:", self.current_edit)
|
cform.addRow("Test current [A]:", self.current_edit)
|
||||||
|
|
||||||
self.freq_edit = QLineEdit("")
|
# --- PDN section (only when a PdnSetup is given) ----------------
|
||||||
|
self.pdn_section = None
|
||||||
|
self.pdn_sup_table = None
|
||||||
|
self.pdn_load_table = None
|
||||||
|
self.pdn_splitter = None
|
||||||
|
self.pdn_totals = None
|
||||||
|
self.vnominal_edit = None
|
||||||
|
self._pdn_map: list = [] # rows[i] -> (table, table row)
|
||||||
|
self._pdn_layer_combos: list = []
|
||||||
|
self._pdn_layer_desired: list = []
|
||||||
|
self._pdn_hidden: list = [] # rows[i] not on the active net
|
||||||
|
if pdn is not None:
|
||||||
|
self.pdn_section = QWidget()
|
||||||
|
pv = QVBoxLayout(self.pdn_section)
|
||||||
|
pv.setContentsMargins(0, 0, 0, 0)
|
||||||
|
hdr = QLabel(f"PDN terminals — {pdn.source}")
|
||||||
|
hdr.setStyleSheet("font-weight: bold;")
|
||||||
|
pv.addWidget(hdr)
|
||||||
|
self._build_pdn_tables(pdn, pv)
|
||||||
|
self.pdn_totals = QLabel("")
|
||||||
|
pv.addWidget(self.pdn_totals)
|
||||||
|
hints = []
|
||||||
|
if pdn.note:
|
||||||
|
hints.append(pdn.note)
|
||||||
|
if pdn.from_config:
|
||||||
|
hints.append(f"geometry from {pdn.source}; edit the "
|
||||||
|
f"file to change terminals")
|
||||||
|
else:
|
||||||
|
# the role/layer mapping lives in the table titles now
|
||||||
|
hints.append("name from a text item inside the "
|
||||||
|
"rectangle; empty V_oc = V nominal")
|
||||||
|
hints.append("Layer = the copper the terminal contacts; "
|
||||||
|
"values take SI suffixes (50m = 0.05)")
|
||||||
|
hint = QLabel(" — ".join(hints))
|
||||||
|
hint.setWordWrap(True)
|
||||||
|
hint.setStyleSheet("color: gray; font-size: 10px;")
|
||||||
|
pv.addWidget(hint)
|
||||||
|
pform = QFormLayout()
|
||||||
|
self.vnominal_edit = QLineEdit(
|
||||||
|
f"{d.v_nominal:g}" if d.v_nominal is not None
|
||||||
|
else f"{config.PDN_V_NOMINAL:g}")
|
||||||
|
pform.addRow("V nominal [V]:", self.vnominal_edit)
|
||||||
|
pv.addLayout(pform)
|
||||||
|
for t in (self.pdn_sup_table, self.pdn_load_table):
|
||||||
|
t.cellChanged.connect(lambda *_: self._update_totals())
|
||||||
|
self._update_totals()
|
||||||
|
|
||||||
|
# --- shared form #2 --------------------------------------------
|
||||||
|
form2 = QFormLayout()
|
||||||
|
|
||||||
|
self.freq_edit = QLineEdit(f"{d.freq_hz:g}" if d.freq_hz else "")
|
||||||
self.freq_edit.setPlaceholderText("0 = DC (e.g. 142k, 1.5M)")
|
self.freq_edit.setPlaceholderText("0 = DC (e.g. 142k, 1.5M)")
|
||||||
form.addRow("Frequency [Hz]:", self.freq_edit)
|
form2.addRow("Frequency [Hz]:", self.freq_edit)
|
||||||
|
|
||||||
self.cell_edit = QLineEdit("")
|
self.cell_edit = QLineEdit(f"{d.cell_um:g}" if d.cell_um else "")
|
||||||
self.cell_edit.setPlaceholderText("auto")
|
self.cell_edit.setPlaceholderText("auto")
|
||||||
form.addRow("Cell size [µm]:", self.cell_edit)
|
form2.addRow("Cell size [µm]:", self.cell_edit)
|
||||||
|
|
||||||
self.buildup_check = QCheckBox(
|
self.buildup_check = QCheckBox(
|
||||||
f"{config.SOLDER_THICKNESS_UM:g} µm solder on mask openings"
|
f"{config.SOLDER_THICKNESS_UM:g} µm solder on mask openings"
|
||||||
+ (f" ({', '.join(buildup_layers)})" if buildup_layers
|
+ (f" ({', '.join(buildup_layers)})" if buildup_layers
|
||||||
else " (none found)"))
|
else " (none found)"))
|
||||||
self.buildup_check.setChecked(bool(buildup_layers)
|
self.buildup_check.setChecked(bool(buildup_layers)
|
||||||
and config.INCLUDE_MASK_BUILDUP)
|
and d.include_buildup)
|
||||||
self.buildup_check.setEnabled(bool(buildup_layers))
|
self.buildup_check.setEnabled(bool(buildup_layers))
|
||||||
form.addRow("Buildup:", self.buildup_check)
|
form2.addRow("Buildup:", self.buildup_check)
|
||||||
|
|
||||||
self.extracu_edit = QLineEdit(f"{config.BUILDUP_EXTRA_CU_UM:g}")
|
self.extracu_edit = QLineEdit(f"{d.extra_cu_um:g}")
|
||||||
self.extracu_edit.setEnabled(bool(buildup_layers))
|
self.extracu_edit.setEnabled(bool(buildup_layers))
|
||||||
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
|
form2.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
|
||||||
|
|
||||||
first, last = config.OVERLAY_LAYERS[0], config.OVERLAY_LAYERS[-1]
|
first, last = config.OVERLAY_LAYERS[0], config.OVERLAY_LAYERS[-1]
|
||||||
self.overlay_check = QCheckBox(
|
self.overlay_check = QCheckBox(
|
||||||
f"experimental: push per-layer |J| heatmaps into the board as "
|
f"experimental: push per-layer |J| heatmaps into the board as "
|
||||||
f"reference images on {first}..{last} (replaces images there; "
|
f"reference images on {first}..{last} (replaces images there; "
|
||||||
f"layers must be enabled in Board Setup)")
|
f"layers must be enabled in Board Setup)")
|
||||||
self.overlay_check.setChecked(config.PUSH_OVERLAYS)
|
self.overlay_check.setChecked(d.push_overlays)
|
||||||
form.addRow("Overlays:", self.overlay_check)
|
form2.addRow("Overlays:", self.overlay_check)
|
||||||
|
|
||||||
tfirst, tlast = config.TRIM_LAYERS[0], config.TRIM_LAYERS[-1]
|
tfirst, tlast = config.TRIM_LAYERS[0], config.TRIM_LAYERS[-1]
|
||||||
self.trim_check = QCheckBox(
|
self.trim_check = QCheckBox(
|
||||||
f"experimental: mark copper below the threshold as polygons "
|
f"experimental: mark copper below the threshold as polygons "
|
||||||
f"on {tfirst}..{tlast} (replaces polygons there; a suggestion "
|
f"on {tfirst}..{tlast} (replaces polygons there; a suggestion "
|
||||||
f"only - removing copper shifts current elsewhere)")
|
f"only - removing copper shifts current elsewhere)")
|
||||||
self.trim_check.setChecked(config.TRIM_ENABLED)
|
self.trim_check.setChecked(d.trim_enabled)
|
||||||
form.addRow("Low-current copper:", self.trim_check)
|
form2.addRow("Low-current copper:", self.trim_check)
|
||||||
|
|
||||||
self.trim_mode_box = QComboBox()
|
self.trim_mode_box = QComboBox()
|
||||||
self.trim_mode_box.addItem("% of mean |J|", "pct")
|
self.trim_mode_box.addItem("% of mean |J|", "pct")
|
||||||
self.trim_mode_box.addItem("A/mm²", "abs")
|
self.trim_mode_box.addItem("A/mm²", "abs")
|
||||||
self.trim_mode_box.setCurrentIndex(1 if config.TRIM_MODE == "abs"
|
self.trim_mode_box.setCurrentIndex(1 if d.trim_mode == "abs" else 0)
|
||||||
else 0)
|
self.trim_edit = QLineEdit(f"{d.trim_value:g}"
|
||||||
self.trim_edit = QLineEdit(self._trim_default())
|
if d.trim_value is not None
|
||||||
|
else self._trim_default())
|
||||||
for w in (self.trim_edit, self.trim_mode_box):
|
for w in (self.trim_edit, self.trim_mode_box):
|
||||||
w.setEnabled(config.TRIM_ENABLED)
|
w.setEnabled(d.trim_enabled)
|
||||||
self.trim_check.toggled.connect(w.setEnabled)
|
self.trim_check.toggled.connect(w.setEnabled)
|
||||||
self.trim_mode_box.currentIndexChanged.connect(
|
self.trim_mode_box.currentIndexChanged.connect(
|
||||||
self._trim_mode_changed)
|
self._trim_mode_changed)
|
||||||
@@ -157,14 +377,42 @@ class _Dialog(QDialog):
|
|||||||
trim_lay.setContentsMargins(0, 0, 0, 0)
|
trim_lay.setContentsMargins(0, 0, 0, 0)
|
||||||
trim_lay.addWidget(self.trim_edit, 1)
|
trim_lay.addWidget(self.trim_edit, 1)
|
||||||
trim_lay.addWidget(self.trim_mode_box)
|
trim_lay.addWidget(self.trim_mode_box)
|
||||||
form.addRow("Threshold:", trim_row)
|
form2.addRow("Threshold:", trim_row)
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
buttons.accepted.connect(self._try_accept)
|
buttons.accepted.connect(self._try_accept)
|
||||||
buttons.rejected.connect(self.reject)
|
buttons.rejected.connect(self.reject)
|
||||||
|
if load_dir is not None:
|
||||||
|
load_btn = buttons.addButton("Load config…",
|
||||||
|
QDialogButtonBox.ActionRole)
|
||||||
|
load_btn.clicked.connect(self._load_config)
|
||||||
|
if save_callback is not None:
|
||||||
|
save_btn = buttons.addButton("Save config…",
|
||||||
|
QDialogButtonBox.ActionRole)
|
||||||
|
save_btn.clicked.connect(self._save_config)
|
||||||
|
|
||||||
lay = QVBoxLayout(self)
|
content = QWidget()
|
||||||
lay.addLayout(form)
|
lay = QVBoxLayout(content)
|
||||||
|
lay.setContentsMargins(0, 0, 0, 0)
|
||||||
|
mode_row = QHBoxLayout()
|
||||||
|
mode_row.addWidget(QLabel("Mode:"))
|
||||||
|
mode_row.addWidget(self.mode_classic)
|
||||||
|
mode_row.addWidget(self.mode_pdn)
|
||||||
|
mode_row.addStretch(1)
|
||||||
|
lay.addLayout(mode_row)
|
||||||
|
if reason is not None:
|
||||||
|
rl = QLabel(reason)
|
||||||
|
rl.setWordWrap(True)
|
||||||
|
rl.setStyleSheet("color: gray; font-size: 10px;")
|
||||||
|
lay.addWidget(rl)
|
||||||
|
lay.addLayout(form1)
|
||||||
|
if self.classic_section is not None:
|
||||||
|
lay.addWidget(self.classic_section)
|
||||||
|
if self.pdn_section is not None:
|
||||||
|
# stretch 1: enlarging the dialog grows the tables, not
|
||||||
|
# the form spacing
|
||||||
|
lay.addWidget(self.pdn_section, 1)
|
||||||
|
lay.addLayout(form2)
|
||||||
note = QLabel("Multiple layers are coupled through the net's "
|
note = QLabel("Multiple layers are coupled through the net's "
|
||||||
"via/through-pad barrels. f > 0 applies only the "
|
"via/through-pad barrels. f > 0 applies only the "
|
||||||
"foil-thickness skin effect (a lower bound on the "
|
"foil-thickness skin effect (a lower bound on the "
|
||||||
@@ -173,18 +421,343 @@ class _Dialog(QDialog):
|
|||||||
note.setWordWrap(True)
|
note.setWordWrap(True)
|
||||||
note.setStyleSheet("color: gray; font-size: 10px;")
|
note.setStyleSheet("color: gray; font-size: 10px;")
|
||||||
lay.addWidget(note)
|
lay.addWidget(note)
|
||||||
|
# zero-stretch spacer: pools surplus height below the form
|
||||||
|
# when no table is there to absorb it (classic mode in an
|
||||||
|
# enlarged dialog) - the visible PDN section's stretch 1
|
||||||
|
# otherwise wins all of it
|
||||||
|
lay.addStretch()
|
||||||
|
# everything above scrolls when the content outgrows the
|
||||||
|
# screen-capped dialog; the error line and the buttons stay
|
||||||
|
# outside the scroll area so they are always visible
|
||||||
|
self._scroll = QScrollArea()
|
||||||
|
self._scroll.setWidgetResizable(True)
|
||||||
|
self._scroll.setFrameShape(QFrame.NoFrame)
|
||||||
|
# sizeHint tracks the content, so adjustSize() opens the
|
||||||
|
# dialog content-sized (clamped to the screen by Qt)
|
||||||
|
self._scroll.setSizeAdjustPolicy(
|
||||||
|
QAbstractScrollArea.AdjustToContents)
|
||||||
|
self._scroll.setWidget(content)
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
outer.addWidget(self._scroll, 1)
|
||||||
self.error_label = QLabel("")
|
self.error_label = QLabel("")
|
||||||
self.error_label.setWordWrap(True)
|
self.error_label.setWordWrap(True)
|
||||||
self.error_label.setStyleSheet("color: #b02a2a;")
|
|
||||||
self.error_label.setVisible(False)
|
self.error_label.setVisible(False)
|
||||||
lay.addWidget(self.error_label)
|
outer.addWidget(self.error_label)
|
||||||
lay.addWidget(buttons)
|
outer.addWidget(buttons)
|
||||||
|
|
||||||
self._selection: Selection | None = None
|
self._selection: Selection | None = None
|
||||||
|
|
||||||
self._desired1, self._desired2 = contact1, contact2
|
self._desired1, self._desired2 = contact1, contact2
|
||||||
self.net_box.currentTextChanged.connect(self._refresh)
|
self._apply_mode()
|
||||||
|
if default_net:
|
||||||
|
self.net_box.setCurrentText(default_net)
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
self.net_box.currentTextChanged.connect(self._refresh)
|
||||||
|
# one toggled signal fires for any radio switch (auto-exclusive)
|
||||||
|
self.mode_classic.toggled.connect(lambda _c: self._apply_mode())
|
||||||
|
|
||||||
|
# --- mode handling ----------------------------------------------------
|
||||||
|
|
||||||
|
def _active_mode(self) -> str:
|
||||||
|
return "pdn" if self.mode_pdn.isChecked() else "classic"
|
||||||
|
|
||||||
|
def _apply_mode(self) -> None:
|
||||||
|
"""Toggle the mode sections and swap the net combo between the
|
||||||
|
classic and PDN candidate sets (a net present in both stays
|
||||||
|
selected across the switch)."""
|
||||||
|
pdn_mode = self._active_mode() == "pdn"
|
||||||
|
if self.classic_section is not None:
|
||||||
|
self.classic_section.setVisible(not pdn_mode)
|
||||||
|
if self.pdn_section is not None:
|
||||||
|
self.pdn_section.setVisible(pdn_mode)
|
||||||
|
cands = (self._candidates_pdn if pdn_mode
|
||||||
|
else self._candidates_classic)
|
||||||
|
if cands is not self._candidates:
|
||||||
|
current = self.net_box.currentText()
|
||||||
|
self._candidates = cands
|
||||||
|
self.net_box.blockSignals(True)
|
||||||
|
self.net_box.clear()
|
||||||
|
for net in sorted(cands):
|
||||||
|
self.net_box.addItem(net)
|
||||||
|
if current in cands:
|
||||||
|
self.net_box.setCurrentText(current)
|
||||||
|
self.net_box.blockSignals(False)
|
||||||
|
self._refresh()
|
||||||
|
self._fit_size()
|
||||||
|
|
||||||
|
def _fit_size(self) -> None:
|
||||||
|
"""Default dialog size for the active mode: content-sized, but
|
||||||
|
in PDN mode at least ~60% of the available screen height so
|
||||||
|
the tables open with real room (extra height flows into them
|
||||||
|
via the stretch; the scroll area covers whatever still does
|
||||||
|
not fit). The KiCad window itself is not reachable through the
|
||||||
|
IPC API, so the screen is the reference. Everything stays
|
||||||
|
user-resizable afterwards."""
|
||||||
|
hint = self.sizeHint()
|
||||||
|
w, h = hint.width(), hint.height()
|
||||||
|
screen = self.screen() or QApplication.primaryScreen()
|
||||||
|
if screen is not None:
|
||||||
|
avail = screen.availableGeometry()
|
||||||
|
if self._active_mode() == "pdn":
|
||||||
|
h = max(h, int(avail.height() * 0.6))
|
||||||
|
w = min(w, int(avail.width() * 0.9))
|
||||||
|
h = min(h, int(avail.height() * 0.85))
|
||||||
|
self.resize(w, h)
|
||||||
|
|
||||||
|
# --- PDN tables -------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_pdn_tables(self, pdn: PdnSetup, layout: QVBoxLayout) -> None:
|
||||||
|
"""One table per role - supplies and loads carry different value
|
||||||
|
columns, so mixing them forced grayed-out cells. Each title
|
||||||
|
names its role's marker layer: that is where a NEW rectangle
|
||||||
|
becomes a new terminal, whatever the current rows' source. Row
|
||||||
|
identity stays POSITIONAL: _pdn_map[i] is (table, table row)
|
||||||
|
for PdnSetup.rows[i], and neither table ever sorts (Qt
|
||||||
|
default). The Layer combos start empty; _refresh populates
|
||||||
|
them with the active net's layers."""
|
||||||
|
titles = {
|
||||||
|
"supply": (f"Supplies — rectangles on "
|
||||||
|
f"{config.ELECTRODE_POS_LAYER}"),
|
||||||
|
"load": (f"Loads — rectangles on "
|
||||||
|
f"{config.ELECTRODE_NEG_LAYER}"),
|
||||||
|
}
|
||||||
|
# both tables live in a vertical splitter: each sizes itself
|
||||||
|
# to its rows (no fixed cap), the drag handle redistributes
|
||||||
|
# height between them, and growing the dialog grows the
|
||||||
|
# splitter (the section has stretch 1); past the screen the
|
||||||
|
# dialog's scroll area takes over
|
||||||
|
splitter = QSplitter(Qt.Vertical)
|
||||||
|
splitter.setChildrenCollapsible(False)
|
||||||
|
tables = {}
|
||||||
|
labels = {}
|
||||||
|
for role, cols in (
|
||||||
|
("supply",
|
||||||
|
["Active", "Name", "Component", "R_out [Ω]",
|
||||||
|
"V_oc [V]", "Layer", "Contact parts", "Comment"]),
|
||||||
|
("load",
|
||||||
|
["Active", "Name", "Component", "I draw [A]", "Layer",
|
||||||
|
"Contact parts", "Comment"])):
|
||||||
|
n = sum(1 for r in pdn.rows if r.role == role)
|
||||||
|
panel = QWidget()
|
||||||
|
pv = QVBoxLayout(panel)
|
||||||
|
pv.setContentsMargins(0, 0, 0, 0)
|
||||||
|
lab = QLabel(titles[role])
|
||||||
|
lab.setStyleSheet("font-weight: bold;")
|
||||||
|
pv.addWidget(lab)
|
||||||
|
labels[role] = lab
|
||||||
|
t = QTableWidget(n, len(cols))
|
||||||
|
t.setHorizontalHeaderLabels(cols)
|
||||||
|
t.verticalHeader().setVisible(False)
|
||||||
|
t.setMinimumHeight(84) # header + ~2 rows floor
|
||||||
|
t.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents)
|
||||||
|
pv.addWidget(t)
|
||||||
|
splitter.addWidget(panel)
|
||||||
|
tables[role] = t
|
||||||
|
layout.addWidget(splitter, 1)
|
||||||
|
self.pdn_splitter = splitter
|
||||||
|
fill = {"supply": 0, "load": 0}
|
||||||
|
for row in pdn.rows:
|
||||||
|
t = tables[row.role]
|
||||||
|
i = fill[row.role]
|
||||||
|
fill[row.role] += 1
|
||||||
|
self._pdn_map.append((t, i))
|
||||||
|
values = ([row.r_out_ohm, row.v_oc] if row.role == "supply"
|
||||||
|
else [row.i_draw_a])
|
||||||
|
last = t.columnCount() - 1 # ... | Layer | parts | Comment
|
||||||
|
cells = [(1, row.name, False), (2, row.component, False)]
|
||||||
|
for col, value in enumerate(values, start=3):
|
||||||
|
cells.append((col, "" if value is None else f"{value:g}",
|
||||||
|
True))
|
||||||
|
cells.append((last - 1, row.resolved, False))
|
||||||
|
cells.append((last, row.comment, True))
|
||||||
|
for col, text, editable in cells:
|
||||||
|
it = QTableWidgetItem(str(text))
|
||||||
|
it.setFlags((Qt.ItemIsEnabled | Qt.ItemIsSelectable
|
||||||
|
| Qt.ItemIsEditable) if editable
|
||||||
|
else Qt.ItemIsEnabled)
|
||||||
|
t.setItem(i, col, it)
|
||||||
|
box = QTableWidgetItem("")
|
||||||
|
box.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
|
||||||
|
box.setCheckState(Qt.Checked if row.active else Qt.Unchecked)
|
||||||
|
t.setItem(i, 0, box)
|
||||||
|
combo = QComboBox()
|
||||||
|
idx = len(self._pdn_layer_combos)
|
||||||
|
self._pdn_layer_combos.append(combo)
|
||||||
|
self._pdn_layer_desired.append(row.contact)
|
||||||
|
# activated fires only on a USER pick: the sticky desired
|
||||||
|
# value survives repopulation on net/mode switches
|
||||||
|
combo.activated.connect(
|
||||||
|
lambda _i, idx=idx: self._layer_picked(idx))
|
||||||
|
t.setCellWidget(i, last - 2, combo)
|
||||||
|
for t in tables.values():
|
||||||
|
t.resizeColumnsToContents()
|
||||||
|
self.pdn_sup_table = tables["supply"]
|
||||||
|
self.pdn_load_table = tables["load"]
|
||||||
|
self.pdn_sup_label = labels["supply"]
|
||||||
|
self.pdn_load_label = labels["load"]
|
||||||
|
|
||||||
|
def _layer_picked(self, idx: int) -> None:
|
||||||
|
self._pdn_layer_desired[idx] = (
|
||||||
|
self._pdn_layer_combos[idx].currentData())
|
||||||
|
|
||||||
|
def _row_hidden(self, i: int) -> bool:
|
||||||
|
return bool(self._pdn_hidden) and self._pdn_hidden[i]
|
||||||
|
|
||||||
|
def _apply_net_filter(self, net: str) -> None:
|
||||||
|
"""Hide the rows whose contacts carry no copper of the active
|
||||||
|
net: they are not part of this run (skipped by validation,
|
||||||
|
totals and the solve) but they stay in the returned row list,
|
||||||
|
so a save keeps them - as "active": false, since a saved PDN
|
||||||
|
config pins this very net. Rows with nets=None always show."""
|
||||||
|
self._pdn_hidden = []
|
||||||
|
for i, row in enumerate(self._pdn.rows):
|
||||||
|
hidden = row.nets is not None and net not in row.nets
|
||||||
|
t, r = self._pdn_map[i]
|
||||||
|
t.setRowHidden(r, hidden)
|
||||||
|
self._pdn_hidden.append(hidden)
|
||||||
|
self._update_totals()
|
||||||
|
|
||||||
|
def _refresh_layer_combos(self, layers: list) -> None:
|
||||||
|
"""Repopulate the per-terminal Layer combos for the active
|
||||||
|
net's layers; the desired value is re-selected when available,
|
||||||
|
else the combo falls back to its first entry. Config-backed
|
||||||
|
ROWS also offer "auto" - per contact part, the schema
|
||||||
|
default - which a live rectangle does not need (a rectangle's
|
||||||
|
natural scope IS all layers)."""
|
||||||
|
for idx, combo in enumerate(self._pdn_layer_combos):
|
||||||
|
combo.blockSignals(True)
|
||||||
|
combo.clear()
|
||||||
|
if self._pdn.rows[idx].from_config:
|
||||||
|
combo.addItem(AUTO_CONTACT, "auto")
|
||||||
|
combo.addItem(ALL_LAYERS, "all")
|
||||||
|
for name in layers:
|
||||||
|
combo.addItem(name, name)
|
||||||
|
i = combo.findData(self._pdn_layer_desired[idx])
|
||||||
|
combo.setCurrentIndex(i if i >= 0 else 0)
|
||||||
|
combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _update_totals(self) -> None:
|
||||||
|
"""Best-effort live sum of the load draws under the tables;
|
||||||
|
unparseable cells are simply skipped (OK validates properly).
|
||||||
|
Counts only the checked rows on the active net; unchecked and
|
||||||
|
hidden rows are called out so a missing terminal is
|
||||||
|
explainable."""
|
||||||
|
total = 0.0
|
||||||
|
ns = nl = off = 0
|
||||||
|
for i, row in enumerate(self._pdn.rows):
|
||||||
|
if self._row_hidden(i):
|
||||||
|
continue
|
||||||
|
t, r = self._pdn_map[i]
|
||||||
|
box = t.item(r, 0)
|
||||||
|
if box is not None and box.checkState() != Qt.Checked:
|
||||||
|
off += 1
|
||||||
|
continue
|
||||||
|
if row.role != "load":
|
||||||
|
ns += 1
|
||||||
|
continue
|
||||||
|
nl += 1
|
||||||
|
it = t.item(r, 3)
|
||||||
|
text = it.text().strip() if it is not None else ""
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
total += skin.parse_engineering(text)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
text = f"{ns} supplies, {nl} loads, {total:g} A total draw"
|
||||||
|
notes = []
|
||||||
|
if off:
|
||||||
|
notes.append(f"{off} disabled")
|
||||||
|
hidden = sum(self._pdn_hidden)
|
||||||
|
if hidden:
|
||||||
|
notes.append(f"{hidden} not on this net: hidden")
|
||||||
|
if notes:
|
||||||
|
text += f" ({'; '.join(notes)})"
|
||||||
|
self.pdn_totals.setText(text)
|
||||||
|
|
||||||
|
def _read_pdn_rows(self) -> list:
|
||||||
|
"""Read + validate the tables into fresh PdnTerminalRow objects
|
||||||
|
(same order and length as PdnSetup.rows - EVERY row comes back,
|
||||||
|
so nothing in the dialog is ever lost on save); ValueError
|
||||||
|
names the offending terminal. The returned `active` records
|
||||||
|
in-run status: the checkbox AND the net filter. An off-net row
|
||||||
|
can never run under this net - and a saved PDN config pins its
|
||||||
|
net - so it is saved as "active": false while its geometry,
|
||||||
|
values and comment are all kept. Any row not in the run may
|
||||||
|
leave its value cells blank, but anything entered must still be
|
||||||
|
valid (a typo is never silently dropped on save)."""
|
||||||
|
out = []
|
||||||
|
for i, row in enumerate(self._pdn.rows):
|
||||||
|
t, r = self._pdn_map[i]
|
||||||
|
|
||||||
|
def cell(col):
|
||||||
|
it = t.item(r, col)
|
||||||
|
return it.text().strip() if it is not None else ""
|
||||||
|
new = PdnTerminalRow(name=row.name, role=row.role,
|
||||||
|
resolved=row.resolved,
|
||||||
|
component=row.component,
|
||||||
|
bonded=row.bonded,
|
||||||
|
from_config=row.from_config)
|
||||||
|
box = t.item(r, 0)
|
||||||
|
checked = (box is None
|
||||||
|
or box.checkState() == Qt.Checked)
|
||||||
|
new.active = checked and not self._row_hidden(i)
|
||||||
|
new.comment = cell(t.columnCount() - 1)
|
||||||
|
combo = self._pdn_layer_combos[i]
|
||||||
|
new.contact = (combo.currentData() if combo.count()
|
||||||
|
else row.contact)
|
||||||
|
if row.role == "load":
|
||||||
|
text = cell(3)
|
||||||
|
if not text:
|
||||||
|
if new.active:
|
||||||
|
raise ValueError(
|
||||||
|
f"Terminal '{row.name}': I draw is required "
|
||||||
|
f"(0 = voltage probe).")
|
||||||
|
else:
|
||||||
|
v = _parse_number(text,
|
||||||
|
f"Terminal '{row.name}': I draw")
|
||||||
|
if v < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Terminal '{row.name}': I draw must be "
|
||||||
|
f"≥ 0 A.")
|
||||||
|
new.i_draw_a = v
|
||||||
|
else:
|
||||||
|
text = cell(3)
|
||||||
|
if not text:
|
||||||
|
if new.active:
|
||||||
|
raise ValueError(
|
||||||
|
f"Terminal '{row.name}': R_out is required "
|
||||||
|
f"(0 = ideal source).")
|
||||||
|
else:
|
||||||
|
v = _parse_number(text,
|
||||||
|
f"Terminal '{row.name}': R_out")
|
||||||
|
if v < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Terminal '{row.name}': R_out must be "
|
||||||
|
f"≥ 0 Ω.")
|
||||||
|
new.r_out_ohm = v
|
||||||
|
vtext = cell(4)
|
||||||
|
if vtext:
|
||||||
|
vv = _parse_number(
|
||||||
|
vtext, f"Terminal '{row.name}': V_oc")
|
||||||
|
if vv <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Terminal '{row.name}': V_oc must be > 0 V "
|
||||||
|
f"(leave empty for V nominal).")
|
||||||
|
new.v_oc = vv
|
||||||
|
out.append(new)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- shared helpers ---------------------------------------------------
|
||||||
|
|
||||||
|
def _show_error(self, msg: str) -> None:
|
||||||
|
self.error_label.setStyleSheet("color: #b02a2a;")
|
||||||
|
self.error_label.setText(msg)
|
||||||
|
self.error_label.setVisible(True)
|
||||||
|
|
||||||
|
def _show_info(self, msg: str) -> None:
|
||||||
|
self.error_label.setStyleSheet("color: #2a7a2a;")
|
||||||
|
self.error_label.setText(msg)
|
||||||
|
self.error_label.setVisible(True)
|
||||||
|
|
||||||
def _trim_default(self, mode: str | None = None) -> str:
|
def _trim_default(self, mode: str | None = None) -> str:
|
||||||
mode = mode or self.trim_mode_box.currentData()
|
mode = mode or self.trim_mode_box.currentData()
|
||||||
@@ -208,10 +781,19 @@ class _Dialog(QDialog):
|
|||||||
for name in layers:
|
for name in layers:
|
||||||
item = QListWidgetItem(name)
|
item = QListWidgetItem(name)
|
||||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||||
item.setCheckState(Qt.Checked)
|
checked = True
|
||||||
|
if self._preset_layers is not None and net == self._preset_net:
|
||||||
|
checked = name in self._preset_layers
|
||||||
|
item.setCheckState(Qt.Checked if checked else Qt.Unchecked)
|
||||||
self.layer_list.addItem(item)
|
self.layer_list.addItem(item)
|
||||||
for box, desired in ((self.contact1_box, self._desired1),
|
if self._pdn is not None:
|
||||||
(self.contact2_box, self._desired2)):
|
self._refresh_layer_combos(layers)
|
||||||
|
self._apply_net_filter(net)
|
||||||
|
boxes = []
|
||||||
|
if self.contact1_box is not None:
|
||||||
|
boxes = [(self.contact1_box, self._desired1),
|
||||||
|
(self.contact2_box, self._desired2)]
|
||||||
|
for box, desired in boxes:
|
||||||
box.clear()
|
box.clear()
|
||||||
box.addItem(AUTO_CONTACT)
|
box.addItem(AUTO_CONTACT)
|
||||||
box.addItem(ALL_LAYERS)
|
box.addItem(ALL_LAYERS)
|
||||||
@@ -238,17 +820,32 @@ class _Dialog(QDialog):
|
|||||||
raise ValueError("Check at least one layer.")
|
raise ValueError("Check at least one layer.")
|
||||||
|
|
||||||
def number(edit: QLineEdit, name: str) -> float:
|
def number(edit: QLineEdit, name: str) -> float:
|
||||||
text = edit.text().strip()
|
return _parse_number(edit.text().strip(), name)
|
||||||
try:
|
|
||||||
return float(skin.normalize_decimal(text))
|
|
||||||
except ValueError as exc:
|
|
||||||
if "separator" in str(exc):
|
|
||||||
raise ValueError(f"{name}: {exc}")
|
|
||||||
raise ValueError(f"{name}: '{text}' is not a number.")
|
|
||||||
|
|
||||||
current = number(self.current_edit, "Test current")
|
pdn_mode = self._active_mode() == "pdn"
|
||||||
if current <= 0:
|
pdn_rows = None
|
||||||
raise ValueError("Test current must be > 0 A.")
|
v_nominal = None
|
||||||
|
if pdn_mode:
|
||||||
|
pdn_rows = self._read_pdn_rows()
|
||||||
|
live = [r for r in pdn_rows if r.active]
|
||||||
|
for role in ("supply", "load"):
|
||||||
|
if not any(r.role == role for r in live):
|
||||||
|
raise ValueError(
|
||||||
|
f"At least one active {role} is needed - check "
|
||||||
|
f"an Active box in the {role} table.")
|
||||||
|
vtext = self.vnominal_edit.text().strip()
|
||||||
|
if not vtext:
|
||||||
|
raise ValueError("V nominal is required in PDN mode "
|
||||||
|
"(the default supply open-circuit "
|
||||||
|
"voltage).")
|
||||||
|
v_nominal = _parse_number(vtext, "V nominal")
|
||||||
|
if v_nominal <= 0:
|
||||||
|
raise ValueError("V nominal must be > 0 V.")
|
||||||
|
current = sum(r.i_draw_a for r in live if r.role == "load")
|
||||||
|
else:
|
||||||
|
current = number(self.current_edit, "Test current")
|
||||||
|
if current <= 0:
|
||||||
|
raise ValueError("Test current must be > 0 A.")
|
||||||
cell = None
|
cell = None
|
||||||
if self.cell_edit.text().strip():
|
if self.cell_edit.text().strip():
|
||||||
cell = number(self.cell_edit, "Cell size")
|
cell = number(self.cell_edit, "Cell size")
|
||||||
@@ -284,7 +881,9 @@ class _Dialog(QDialog):
|
|||||||
if cap_max_drill <= 0:
|
if cap_max_drill <= 0:
|
||||||
raise ValueError("Capped-up-to drill must be > 0 mm.")
|
raise ValueError("Capped-up-to drill must be > 0 mm.")
|
||||||
|
|
||||||
def contact(box: QComboBox) -> str:
|
def contact(box: QComboBox | None) -> str:
|
||||||
|
if box is None or pdn_mode:
|
||||||
|
return "auto" # PDN: scopes live per terminal
|
||||||
t = box.currentText()
|
t = box.currentText()
|
||||||
if t == AUTO_CONTACT:
|
if t == AUTO_CONTACT:
|
||||||
return "auto"
|
return "auto"
|
||||||
@@ -295,7 +894,9 @@ class _Dialog(QDialog):
|
|||||||
contact2=contact(self.contact2_box),
|
contact2=contact(self.contact2_box),
|
||||||
current_a=current, cell_um=cell,
|
current_a=current, cell_um=cell,
|
||||||
freq_hz=freq,
|
freq_hz=freq,
|
||||||
contact_model=self.model_box.currentData(),
|
contact_model=(self.model_box.currentData()
|
||||||
|
if self.model_box is not None
|
||||||
|
and not pdn_mode else "uniform"),
|
||||||
include_buildup=self.buildup_check.isChecked(),
|
include_buildup=self.buildup_check.isChecked(),
|
||||||
extra_cu_um=extra_cu,
|
extra_cu_um=extra_cu,
|
||||||
include_tracks=self.tracks_check.isChecked(),
|
include_tracks=self.tracks_check.isChecked(),
|
||||||
@@ -304,14 +905,63 @@ class _Dialog(QDialog):
|
|||||||
adaptive=self.adaptive_check.isChecked(),
|
adaptive=self.adaptive_check.isChecked(),
|
||||||
push_overlays=self.overlay_check.isChecked(),
|
push_overlays=self.overlay_check.isChecked(),
|
||||||
trim_enabled=self.trim_check.isChecked(),
|
trim_enabled=self.trim_check.isChecked(),
|
||||||
trim_mode=trim_mode, trim_value=trim_value)
|
trim_mode=trim_mode, trim_value=trim_value,
|
||||||
|
mode="pdn" if pdn_mode else "classic",
|
||||||
|
pdn_rows=pdn_rows, v_nominal=v_nominal)
|
||||||
|
|
||||||
|
def _load_config(self) -> None:
|
||||||
|
"""'Load config…': pick a config file - a VALID pick closes the
|
||||||
|
dialog with a LoadRequest (main re-derives everything from that
|
||||||
|
file and reopens the dialog), an invalid one shows the loader's
|
||||||
|
error and stays open. Nothing in the current form is validated:
|
||||||
|
loading replaces it wholesale."""
|
||||||
|
path, _filter = QFileDialog.getOpenFileName(
|
||||||
|
self, "Load config", str(self._load_dir),
|
||||||
|
"Config files (*.json)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
configfile.load_config(Path(path))
|
||||||
|
except ConfigError as e:
|
||||||
|
self._show_error(str(e))
|
||||||
|
return
|
||||||
|
self._load_request = Path(path)
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _save_config(self) -> None:
|
||||||
|
"""'Save config…': validate like OK, ask for the target file
|
||||||
|
(name editable - seeded with the loaded config, or the default
|
||||||
|
name; the next save re-seeds with whatever was chosen), then
|
||||||
|
hand Selection + path to the callback (which writes the file
|
||||||
|
and returns its name). The dialog stays open."""
|
||||||
|
try:
|
||||||
|
sel = self._build_selection()
|
||||||
|
except ValueError as e:
|
||||||
|
self._show_error(str(e))
|
||||||
|
return
|
||||||
|
path, _filter = QFileDialog.getSaveFileName(
|
||||||
|
self, "Save config",
|
||||||
|
str(self._save_target) if self._save_target else "",
|
||||||
|
"Config files (*.json)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
target = Path(path)
|
||||||
|
if target.suffix.lower() != ".json":
|
||||||
|
# non-native pickers do not append the filter's suffix
|
||||||
|
target = target.with_name(target.name + ".json")
|
||||||
|
try:
|
||||||
|
saved_to = self._save_callback(sel, target)
|
||||||
|
except Exception as e:
|
||||||
|
self._show_error(str(e))
|
||||||
|
return
|
||||||
|
self._save_target = target
|
||||||
|
self._show_info(f"saved to {saved_to}")
|
||||||
|
|
||||||
def _try_accept(self) -> None:
|
def _try_accept(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._selection = self._build_selection()
|
self._selection = self._build_selection()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
self.error_label.setText(str(e))
|
self._show_error(str(e))
|
||||||
self.error_label.setVisible(True)
|
|
||||||
return
|
return
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
@@ -319,13 +969,37 @@ class _Dialog(QDialog):
|
|||||||
def ask(candidates: dict[str, list[str]], layer_order: list[str],
|
def ask(candidates: dict[str, list[str]], layer_order: list[str],
|
||||||
default_net: str, e1_label: str, e2_label: str,
|
default_net: str, e1_label: str, e2_label: str,
|
||||||
contact1: str, contact2: str,
|
contact1: str, contact2: str,
|
||||||
buildup_layers: list[str] | None = None) -> Selection | None:
|
buildup_layers: list[str] | None = None,
|
||||||
"""Show the dialog; returns None on cancel."""
|
defaults: DialogDefaults | None = None,
|
||||||
|
pdn: PdnSetup | None = None,
|
||||||
|
pdn_candidates: dict | None = None,
|
||||||
|
classic_reason: str | None = None,
|
||||||
|
pdn_reason: str | None = None,
|
||||||
|
save_callback=None, save_target=None, load_dir=None,
|
||||||
|
start_mode: str = "classic"):
|
||||||
|
"""Show the dialog; returns a Selection, a LoadRequest (the user
|
||||||
|
picked another config with "Load config…" - re-derive and call ask
|
||||||
|
again), or None on cancel. defaults: widget seeds (config.py +
|
||||||
|
config file); pdn: the PDN terminal setup (editable tables);
|
||||||
|
pdn_candidates: net candidates for PDN mode (classic uses
|
||||||
|
`candidates`); classic_reason / pdn_reason: why a mode is
|
||||||
|
unavailable (its radio is disabled with the reason shown);
|
||||||
|
save_callback(selection, target_path) -> saved name string enables
|
||||||
|
the "Save config…" button in both modes (the file name is asked
|
||||||
|
per save, seeded with save_target); load_dir (the board directory)
|
||||||
|
enables the "Load config…" button; start_mode ("classic"/"pdn") is
|
||||||
|
only the STARTING radio - both stay switchable while available."""
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
dlg = _Dialog(candidates, layer_order, default_net, e1_label, e2_label,
|
dlg = _Dialog(candidates, layer_order, default_net, e1_label, e2_label,
|
||||||
contact1, contact2, buildup_layers or [])
|
contact1, contact2, buildup_layers or [],
|
||||||
|
defaults=defaults, pdn=pdn, pdn_candidates=pdn_candidates,
|
||||||
|
classic_reason=classic_reason, pdn_reason=pdn_reason,
|
||||||
|
save_callback=save_callback, save_target=save_target,
|
||||||
|
load_dir=load_dir, start_mode=start_mode)
|
||||||
dlg.raise_()
|
dlg.raise_()
|
||||||
dlg.activateWindow()
|
dlg.activateWindow()
|
||||||
if dlg.exec() != QDialog.Accepted:
|
if dlg.exec() != QDialog.Accepted:
|
||||||
return None
|
return None
|
||||||
|
if dlg._load_request is not None:
|
||||||
|
return LoadRequest(dlg._load_request)
|
||||||
return dlg._selection
|
return dlg._selection
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ class SelectionError(UserFacingError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(UserFacingError):
|
||||||
|
"""fill_res_config.json is present but unreadable or invalid. Always
|
||||||
|
fatal - silently ignoring a config (and running a default setup the
|
||||||
|
user did not ask for) would be worse than stopping."""
|
||||||
|
|
||||||
|
|
||||||
class CandidateError(UserFacingError):
|
class CandidateError(UserFacingError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ so the whole pipeline downstream of board_io runs without KiCad.
|
|||||||
|
|
||||||
Schema v2 is multi-layer: per-layer fills at stackup depths, linked by
|
Schema v2 is multi-layer: per-layer fills at stackup depths, linked by
|
||||||
via/through-pad barrels. v1 dumps (single layer, no vias) still load.
|
via/through-pad barrels. v1 dumps (single layer, no vias) still load.
|
||||||
|
Schema v7 adds PDN terminals (supplies/loads); dumps <= v6 load with
|
||||||
|
terminals=[] and run the classic two-terminal solve unchanged. v8 adds
|
||||||
|
the per-terminal `bonded` flag (v7 dumps load with bonded=False).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,7 +20,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
JSON_SCHEMA_VERSION = 6
|
JSON_SCHEMA_VERSION = 8
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -133,6 +136,39 @@ class Electrode:
|
|||||||
# Problem.tht_protrusion_nm
|
# Problem.tht_protrusion_nm
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Terminal:
|
||||||
|
"""One PDN-mode terminal: a supply (Thevenin source: open-circuit
|
||||||
|
volts v_oc behind r_out_ohm) or a load (prescribed current draw
|
||||||
|
i_draw_a). Contact geometry is a list of Electrode parts. In PDN
|
||||||
|
mode Problem.terminals replaces electrodes1/electrodes2; supply
|
||||||
|
currents are solve OUTCOMES, load draws are prescribed.
|
||||||
|
|
||||||
|
bonded: all the terminal's contact cells are shorted into one
|
||||||
|
super-node (an externally bonded lug - a multi-pin package with
|
||||||
|
internal metal). The TOTAL current is prescribed as usual, but the
|
||||||
|
per-part/per-cell split becomes a solve outcome instead of the
|
||||||
|
default per-cell area share (loads) / per-cell Thevenin attachment
|
||||||
|
(supplies). The contact face is then equipotential."""
|
||||||
|
role: str # "supply" | "load"
|
||||||
|
electrodes: list[Electrode]
|
||||||
|
label: str = "" # display name; "" gets an
|
||||||
|
# S1/L1 tag at solve time
|
||||||
|
i_draw_a: float = 0.0 # loads: prescribed draw [A]
|
||||||
|
r_out_ohm: float = 0.0 # supplies: Thevenin output
|
||||||
|
# resistance [ohm]
|
||||||
|
v_oc: float | None = None # supplies: open-circuit
|
||||||
|
# volts; None -> the run's
|
||||||
|
# v_nominal at solve time
|
||||||
|
bonded: bool = False # short all contact cells
|
||||||
|
# into one lug (see above)
|
||||||
|
component: str = "" # display only: the owner
|
||||||
|
# hint ("U5" / "near U5",
|
||||||
|
# board_io.component_hints)
|
||||||
|
comment: str = "" # display only: the user's
|
||||||
|
# free-text note
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ViaLink:
|
class ViaLink:
|
||||||
"""A conductive barrel (via or plated through-hole pad) linking copper
|
"""A conductive barrel (via or plated through-hole pad) linking copper
|
||||||
@@ -201,6 +237,10 @@ class Problem:
|
|||||||
electrodes1: list[Electrode] # V+ terminal parts (merged)
|
electrodes1: list[Electrode] # V+ terminal parts (merged)
|
||||||
electrodes2: list[Electrode] # V- terminal parts (merged)
|
electrodes2: list[Electrode] # V- terminal parts (merged)
|
||||||
thickness_source: str = "stackup"
|
thickness_source: str = "stackup"
|
||||||
|
# PDN mode: non-empty replaces electrodes1/2 entirely (the pipeline
|
||||||
|
# rejects a problem carrying both) - N supplies + M loads instead of
|
||||||
|
# one driven terminal pair
|
||||||
|
terminals: list[Terminal] = field(default_factory=list)
|
||||||
buildups: list[SurfaceBuildup] = field(default_factory=list)
|
buildups: list[SurfaceBuildup] = field(default_factory=list)
|
||||||
solder_thickness_nm: int = 50_000
|
solder_thickness_nm: int = 50_000
|
||||||
solder_rho_ohm_m: float = 1.32e-7
|
solder_rho_ohm_m: float = 1.32e-7
|
||||||
@@ -231,6 +271,15 @@ class Problem:
|
|||||||
def layer_names(self) -> list[str]:
|
def layer_names(self) -> list[str]:
|
||||||
return [l.layer_name for l in self.layers]
|
return [l.layer_name for l in self.layers]
|
||||||
|
|
||||||
|
def contact_electrodes(self) -> list[Electrode]:
|
||||||
|
"""Every contact part regardless of mode: classic V+/V- lists
|
||||||
|
plus all PDN terminal parts (exactly one group is non-empty in a
|
||||||
|
valid problem). Use this wherever per-contact geometry features
|
||||||
|
(solder coats, lead cones) are collected, so PDN terminals get
|
||||||
|
the same treatment as classic ones."""
|
||||||
|
return (self.electrodes1 + self.electrodes2
|
||||||
|
+ [e for t in self.terminals for e in t.electrodes])
|
||||||
|
|
||||||
def sigma_s(self, layer_index: int) -> float:
|
def sigma_s(self, layer_index: int) -> float:
|
||||||
"""Sheet conductance of one layer [S per square]."""
|
"""Sheet conductance of one layer [S per square]."""
|
||||||
return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m
|
return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m
|
||||||
@@ -262,7 +311,7 @@ def contact_solder_buildups(problem: Problem) -> list[str]:
|
|||||||
names. Called once when the problem is built."""
|
names. Called once when the problem is built."""
|
||||||
included = {l.layer_name for l in problem.layers}
|
included = {l.layer_name for l in problem.layers}
|
||||||
touched = []
|
touched = []
|
||||||
for e in problem.electrodes1 + problem.electrodes2:
|
for e in problem.contact_electrodes():
|
||||||
if not e.solder or not e.polygons \
|
if not e.solder or not e.polygons \
|
||||||
or e.protrusion_side not in included:
|
or e.protrusion_side not in included:
|
||||||
continue
|
continue
|
||||||
@@ -318,7 +367,7 @@ def tht_joint_buildups(problem: Problem,
|
|||||||
coats them with the exact pad shape. Returns the affected layer
|
coats them with the exact pad shape. Returns the affected layer
|
||||||
names."""
|
names."""
|
||||||
included = {l.layer_name for l in problem.layers}
|
included = {l.layer_name for l in problem.layers}
|
||||||
contacts = {e.center for e in problem.electrodes1 + problem.electrodes2
|
contacts = {e.center for e in problem.contact_electrodes()
|
||||||
if e.drill_nm > 0 and e.center is not None}
|
if e.drill_nm > 0 and e.center is not None}
|
||||||
touched = []
|
touched = []
|
||||||
for v in problem.vias:
|
for v in problem.vias:
|
||||||
@@ -528,6 +577,39 @@ def _electrode_from_json(d: dict) -> Electrode:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_to_json(t: Terminal) -> dict:
|
||||||
|
d = {
|
||||||
|
"role": t.role,
|
||||||
|
"label": t.label,
|
||||||
|
"i_draw_a": t.i_draw_a,
|
||||||
|
"r_out_ohm": t.r_out_ohm,
|
||||||
|
"v_oc": t.v_oc,
|
||||||
|
"bonded": t.bonded,
|
||||||
|
"electrodes": [_electrode_to_json(e) for e in t.electrodes],
|
||||||
|
}
|
||||||
|
# display-only metadata, written when present (still schema v8:
|
||||||
|
# optional keys, older loaders simply ignore them)
|
||||||
|
if t.component:
|
||||||
|
d["component"] = t.component
|
||||||
|
if t.comment:
|
||||||
|
d["comment"] = t.comment
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_from_json(d: dict) -> Terminal:
|
||||||
|
return Terminal(
|
||||||
|
role=d["role"],
|
||||||
|
electrodes=[_electrode_from_json(ed) for ed in d["electrodes"]],
|
||||||
|
label=d.get("label", ""),
|
||||||
|
i_draw_a=float(d.get("i_draw_a", 0.0)),
|
||||||
|
r_out_ohm=float(d.get("r_out_ohm", 0.0)),
|
||||||
|
v_oc=(None if d.get("v_oc") is None else float(d["v_oc"])),
|
||||||
|
bonded=bool(d.get("bonded", False)), # <= v7: not bonded
|
||||||
|
component=str(d.get("component", "")),
|
||||||
|
comment=str(d.get("comment", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def problem_to_json(p: Problem) -> dict:
|
def problem_to_json(p: Problem) -> dict:
|
||||||
return {
|
return {
|
||||||
"schema_version": JSON_SCHEMA_VERSION,
|
"schema_version": JSON_SCHEMA_VERSION,
|
||||||
@@ -538,6 +620,7 @@ def problem_to_json(p: Problem) -> dict:
|
|||||||
"thickness_source": p.thickness_source,
|
"thickness_source": p.thickness_source,
|
||||||
"electrodes1": [_electrode_to_json(e) for e in p.electrodes1],
|
"electrodes1": [_electrode_to_json(e) for e in p.electrodes1],
|
||||||
"electrodes2": [_electrode_to_json(e) for e in p.electrodes2],
|
"electrodes2": [_electrode_to_json(e) for e in p.electrodes2],
|
||||||
|
"terminals": [_terminal_to_json(t) for t in p.terminals],
|
||||||
"layers": [
|
"layers": [
|
||||||
{
|
{
|
||||||
"layer_name": l.layer_name,
|
"layer_name": l.layer_name,
|
||||||
@@ -629,6 +712,8 @@ def problem_from_json(d: dict) -> Problem:
|
|||||||
electrodes2=(
|
electrodes2=(
|
||||||
[_electrode_from_json(ed) for ed in d["electrodes2"]]
|
[_electrode_from_json(ed) for ed in d["electrodes2"]]
|
||||||
if version >= 3 else [_electrode_from_json(d["electrode2"])]),
|
if version >= 3 else [_electrode_from_json(d["electrode2"])]),
|
||||||
|
# v7: PDN terminals; dumps <= v6 predate them and load classic
|
||||||
|
terminals=[_terminal_from_json(td) for td in d.get("terminals", [])],
|
||||||
thickness_source=d.get("thickness_source", "unknown"),
|
thickness_source=d.get("thickness_source", "unknown"),
|
||||||
buildups=[
|
buildups=[
|
||||||
SurfaceBuildup(
|
SurfaceBuildup(
|
||||||
|
|||||||
+410
-51
@@ -1,8 +1,13 @@
|
|||||||
"""Top-level orchestration for the KiCad-launched action.
|
"""Top-level orchestration for the KiCad-launched action.
|
||||||
|
|
||||||
Flow: connect -> read the two selected contacts (rectangles/pads) ->
|
Flow: connect -> load the config named "default" (or the board-specific
|
||||||
gather fills -> selection dialog (net, layers, contacts, current, cell)
|
one) -> derive BOTH modes' terminals (classic: selection / marker
|
||||||
-> extract vias -> solve -> figures + report.
|
rectangles / config refs; PDN: per-rectangle marker scan, or the
|
||||||
|
config's terminal set) -> gather fills -> dialog with a Classic/PDN
|
||||||
|
mode selector (classic: the two-contact form; PDN: editable per-role
|
||||||
|
terminal tables) -> extract vias -> solve -> figures + report. The
|
||||||
|
dialog's "Load config…" button loops back to the derivation with the
|
||||||
|
picked file, so a run can be set up from any saved config.
|
||||||
|
|
||||||
Every failure is reported twice: on stdout (lands in the KiCad status-bar
|
Every failure is reported twice: on stdout (lands in the KiCad status-bar
|
||||||
warning list) and as a matplotlib error figure, so it cannot be missed.
|
warning list) and as a matplotlib error figure, so it cannot be missed.
|
||||||
@@ -11,9 +16,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from . import config, pipeline, progress, report
|
from . import config, pipeline, progress, report
|
||||||
from .errors import CandidateError, UserFacingError
|
from .errors import ConfigError, SelectionError, UserFacingError
|
||||||
|
from .geometry import Terminal
|
||||||
|
|
||||||
|
|
||||||
def _fail(message: str, outdir) -> None:
|
def _fail(message: str, outdir) -> None:
|
||||||
@@ -36,13 +43,21 @@ def _fail(message: str, outdir) -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# config globals a config file may override; "Load config…" re-derives
|
||||||
|
# from a fresh baseline so one file's physics/marker layers never leak
|
||||||
|
# into the next
|
||||||
|
_CFG_GLOBALS = ("RHO_CU_OHM_M", "COPPER_THICKNESS_UM", "VIA_PLATING_UM",
|
||||||
|
"ELECTRODE_POS_LAYER", "ELECTRODE_NEG_LAYER",
|
||||||
|
"ELECTRODE_PDN_LAYER")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
outdir = None
|
outdir = None
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
from kipy.errors import ApiError
|
from kipy.errors import ApiError
|
||||||
|
|
||||||
from . import board_io, dialog
|
from . import board_io, configfile, dialog
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
if "cannot open shared object file" not in str(e):
|
if "cannot open shared object file" not in str(e):
|
||||||
raise
|
raise
|
||||||
@@ -60,47 +75,327 @@ def main() -> None:
|
|||||||
try:
|
try:
|
||||||
kicad, board = board_io.connect()
|
kicad, board = board_io.connect()
|
||||||
stackup = board_io.get_stackup_info(board)
|
stackup = board_io.get_stackup_info(board)
|
||||||
es1, es2, net_hint = board_io.get_electrodes(board, stackup)
|
|
||||||
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
|
|
||||||
board_io.refill(board)
|
|
||||||
fills = board_io.gather_net_fills(board)
|
|
||||||
tracks = board_io.gather_net_tracks(board)
|
|
||||||
copper = board_io.merge_copper(
|
|
||||||
fills, board_io.tracks_as_polygons(tracks))
|
|
||||||
candidate_nets = board_io.nets_overlapping(copper, es1, es2)
|
|
||||||
buildups = board_io.gather_mask_buildups(board)
|
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
raise UserFacingError(
|
raise UserFacingError(
|
||||||
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
|
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
|
||||||
f"it and run again."
|
f"it and run again."
|
||||||
)
|
)
|
||||||
|
cfg_path = configfile.find_config(
|
||||||
|
board_io.board_dir(board),
|
||||||
|
getattr(board, "name", "") or "")
|
||||||
|
base_globals = {name: getattr(config, name)
|
||||||
|
for name in _CFG_GLOBALS}
|
||||||
|
while True:
|
||||||
|
for name, value in base_globals.items():
|
||||||
|
setattr(config, name, value)
|
||||||
|
try:
|
||||||
|
cfg = configfile.load_config(cfg_path) if cfg_path else None
|
||||||
|
# a config with a terminals section is the PDN source;
|
||||||
|
# cfg.mode is only the STARTING mode - nothing is
|
||||||
|
# pinned, the dialog switches modes and nets freely
|
||||||
|
pdn_cfg = cfg is not None and bool(cfg.terminals)
|
||||||
|
if cfg is not None:
|
||||||
|
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
|
||||||
|
# before any geometry: the marker layers steer
|
||||||
|
# get_electrodes, the physics steers build_problem
|
||||||
|
configfile.apply_physics(cfg)
|
||||||
|
|
||||||
if not candidate_nets:
|
# BOTH terminal derivations always run; a failure only
|
||||||
raise CandidateError(
|
# disables that mode's radio (with the reason shown) - the
|
||||||
"No copper (zone fill or trace) overlaps both contacts. "
|
# launch dies only when neither mode is possible
|
||||||
"Check that both sit over copper of the same net and that "
|
classic_reason = pdn_reason = None
|
||||||
"the fills are up to date (press B in the board editor)."
|
es1: list = []
|
||||||
|
es2: list = []
|
||||||
|
net_hint = None
|
||||||
|
terminals: list = [] # resolved config terminals
|
||||||
|
marker_terms = None # live-scan MarkerTerminal list
|
||||||
|
new_terms: list = [] # rects not in the config yet
|
||||||
|
merge_note = ""
|
||||||
|
pdn_groups: list = [] # electrode groups, either way
|
||||||
|
pdn_hints: list = [] # per-terminal Component text
|
||||||
|
term_nets: list = [] # per-terminal overlapped nets
|
||||||
|
has_selection = bool(list(board.get_selection()))
|
||||||
|
try:
|
||||||
|
if cfg is not None and cfg.pos_parts is not None:
|
||||||
|
es1, es2 = board_io.resolve_classic_parts(
|
||||||
|
board, stackup, cfg.pos_parts,
|
||||||
|
cfg.neg_parts, cfg.net)
|
||||||
|
net_hint = cfg.net
|
||||||
|
else:
|
||||||
|
es1, es2, net_hint = board_io.get_electrodes(
|
||||||
|
board, stackup)
|
||||||
|
except SelectionError as e:
|
||||||
|
classic_reason = str(e)
|
||||||
|
try:
|
||||||
|
if pdn_cfg:
|
||||||
|
# config refs resolve against run.net; a broken
|
||||||
|
# ref disables PDN mode instead of killing the
|
||||||
|
# launch (classic may still work)
|
||||||
|
if not cfg.net:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{cfg_path.name}: run.net is required "
|
||||||
|
f"to resolve the config terminals")
|
||||||
|
terminals = board_io.resolve_terminal_specs(
|
||||||
|
board, stackup, cfg.terminals, cfg.net)
|
||||||
|
# rectangles drawn since the save become NEW
|
||||||
|
# terminals - the file freezes nothing. A scan
|
||||||
|
# problem only forfeits the new ones, never
|
||||||
|
# the config set
|
||||||
|
try:
|
||||||
|
new_terms = board_io.new_marker_terminals(
|
||||||
|
cfg.terminals,
|
||||||
|
board_io.scan_marker_terminals(
|
||||||
|
board, require_both=False))
|
||||||
|
except (SelectionError, ConfigError) as e:
|
||||||
|
merge_note = (f"rectangle scan failed ({e})"
|
||||||
|
f" - new rectangles not "
|
||||||
|
f"offered")
|
||||||
|
print(f"note: {merge_note}")
|
||||||
|
pdn_groups = (
|
||||||
|
[t.electrodes for t in terminals]
|
||||||
|
+ [mt.electrodes for mt in new_terms])
|
||||||
|
else:
|
||||||
|
marker_terms = board_io.scan_marker_terminals(
|
||||||
|
board)
|
||||||
|
pdn_groups = [mt.electrodes
|
||||||
|
for mt in marker_terms]
|
||||||
|
pdn_hints = board_io.component_hints(board,
|
||||||
|
pdn_groups)
|
||||||
|
except (SelectionError, ConfigError) as e:
|
||||||
|
pdn_reason = str(e)
|
||||||
|
|
||||||
|
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
|
||||||
|
board_io.refill(board)
|
||||||
|
fills = board_io.gather_net_fills(board)
|
||||||
|
tracks = board_io.gather_net_tracks(board)
|
||||||
|
copper = board_io.merge_copper(
|
||||||
|
fills, board_io.tracks_as_polygons(tracks))
|
||||||
|
|
||||||
|
classic_nets: list = []
|
||||||
|
pdn_nets: list = []
|
||||||
|
if classic_reason is None:
|
||||||
|
classic_nets = board_io.nets_overlapping(
|
||||||
|
copper, es1, es2)
|
||||||
|
if not classic_nets:
|
||||||
|
classic_reason = (
|
||||||
|
"No copper (zone fill or trace) overlaps "
|
||||||
|
"both contacts. Check that both sit over "
|
||||||
|
"copper of the same net and that the fills "
|
||||||
|
"are up to date (press B in the board "
|
||||||
|
"editor).")
|
||||||
|
if pdn_reason is None:
|
||||||
|
# per-terminal net sets drive BOTH the candidate
|
||||||
|
# list (a net qualifies with >= 1 supply and >= 1
|
||||||
|
# load terminal on it) and the dialog's row filter
|
||||||
|
# (only terminals on the selected net are shown
|
||||||
|
# and solved) - config and live sources alike
|
||||||
|
term_nets = board_io.group_nets(copper, pdn_groups)
|
||||||
|
roles = (([t.role for t in terminals]
|
||||||
|
+ [mt.role for mt in new_terms]) if pdn_cfg
|
||||||
|
else [mt.role for mt in marker_terms])
|
||||||
|
sup_nets: set = set()
|
||||||
|
load_nets: set = set()
|
||||||
|
for role, tn in zip(roles, term_nets):
|
||||||
|
(sup_nets if role == "supply"
|
||||||
|
else load_nets).update(tn)
|
||||||
|
pdn_nets = sorted(sup_nets & load_nets)
|
||||||
|
if not pdn_nets:
|
||||||
|
pdn_reason = (
|
||||||
|
"no net's copper overlaps at least one "
|
||||||
|
"supply and one load "
|
||||||
|
+ (f"terminal of {cfg_path.name}"
|
||||||
|
if pdn_cfg else "rectangle"))
|
||||||
|
elif pdn_cfg and cfg.net not in pdn_nets:
|
||||||
|
print(f"note: run.net '{cfg.net}' has no "
|
||||||
|
f"workable supply+load copper; PDN "
|
||||||
|
f"candidates: {', '.join(pdn_nets)}")
|
||||||
|
if classic_reason is not None and pdn_reason is not None:
|
||||||
|
raise SelectionError(
|
||||||
|
f"{classic_reason}\n(PDN mode is also "
|
||||||
|
f"unavailable: {pdn_reason})")
|
||||||
|
buildups = board_io.gather_mask_buildups(board)
|
||||||
|
except ApiError as e:
|
||||||
|
raise UserFacingError(
|
||||||
|
f"KiCad API error: {e}\nIf KiCad is showing a dialog, "
|
||||||
|
f"close it and run again."
|
||||||
|
)
|
||||||
|
|
||||||
|
def group_label(parts):
|
||||||
|
names = [p.label for p in parts[:3]]
|
||||||
|
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
|
||||||
|
return f"{len(parts)}× " + ", ".join(names) + more
|
||||||
|
|
||||||
|
def group_contact(parts):
|
||||||
|
contacts = {p.contact for p in parts}
|
||||||
|
return contacts.pop() if len(contacts) == 1 else "auto"
|
||||||
|
|
||||||
|
def rect_desc(e):
|
||||||
|
r = e.rect
|
||||||
|
return (f"rect ({r.x0 / 1e6:.1f}, {r.y0 / 1e6:.1f}).."
|
||||||
|
f"({r.x1 / 1e6:.1f}, {r.y1 / 1e6:.1f}) mm")
|
||||||
|
|
||||||
|
def marker_desc(mt):
|
||||||
|
if len(mt.electrodes) == 1:
|
||||||
|
return rect_desc(mt.electrodes[0])
|
||||||
|
# same-named rectangles grouped into one bonded lug
|
||||||
|
return (f"{len(mt.electrodes)}× "
|
||||||
|
f"{rect_desc(mt.electrodes[0])} … — bonded")
|
||||||
|
|
||||||
|
def live_row(mt, hint, tn):
|
||||||
|
return dialog.PdnTerminalRow(
|
||||||
|
name=mt.name, role=mt.role,
|
||||||
|
resolved=marker_desc(mt), component=hint,
|
||||||
|
bonded=mt.bonded, nets=tn)
|
||||||
|
|
||||||
|
defaults = configfile.dialog_defaults(cfg)
|
||||||
|
pdn_setup = None
|
||||||
|
if pdn_cfg and pdn_reason is None:
|
||||||
|
n_cfg = len(terminals)
|
||||||
|
rows = [dialog.PdnTerminalRow(
|
||||||
|
name=t.label, role=t.role,
|
||||||
|
resolved=(group_label(t.electrodes)
|
||||||
|
+ (" — bonded" if t.bonded else "")),
|
||||||
|
component=hint,
|
||||||
|
i_draw_a=(t.i_draw_a if t.role == "load"
|
||||||
|
else None),
|
||||||
|
r_out_ohm=(t.r_out_ohm if t.role == "supply"
|
||||||
|
else None),
|
||||||
|
v_oc=t.v_oc, bonded=t.bonded,
|
||||||
|
contact=spec.contact or "auto",
|
||||||
|
active=spec.active, comment=spec.comment,
|
||||||
|
nets=tn, from_config=True)
|
||||||
|
for t, spec, hint, tn in zip(
|
||||||
|
terminals, cfg.terminals, pdn_hints[:n_cfg],
|
||||||
|
term_nets[:n_cfg])]
|
||||||
|
# newly drawn rectangles append as live rows: a save
|
||||||
|
# writes them into the config alongside the file's set
|
||||||
|
rows += [live_row(mt, hint, tn)
|
||||||
|
for mt, hint, tn in zip(new_terms,
|
||||||
|
pdn_hints[n_cfg:],
|
||||||
|
term_nets[n_cfg:])]
|
||||||
|
notes = []
|
||||||
|
if new_terms:
|
||||||
|
notes.append(f"{len(new_terms)} new rectangle(s) "
|
||||||
|
f"not in {cfg_path.name} yet - "
|
||||||
|
f"Save config… adds them")
|
||||||
|
if merge_note:
|
||||||
|
notes.append(merge_note)
|
||||||
|
pdn_setup = dialog.PdnSetup(
|
||||||
|
rows=rows, source=cfg_path.name, from_config=True,
|
||||||
|
note="; ".join(notes))
|
||||||
|
elif pdn_reason is None:
|
||||||
|
note = ""
|
||||||
|
if has_selection:
|
||||||
|
note = ("board selection ignored in PDN mode - "
|
||||||
|
"terminals are the marker rectangles")
|
||||||
|
print(f"note: {note}")
|
||||||
|
pdn_setup = dialog.PdnSetup(
|
||||||
|
rows=[live_row(mt, hint, tn)
|
||||||
|
for mt, hint, tn in zip(marker_terms,
|
||||||
|
pdn_hints,
|
||||||
|
term_nets)],
|
||||||
|
source=(f"marker rectangles on "
|
||||||
|
f"{config.ELECTRODE_POS_LAYER}/"
|
||||||
|
f"{config.ELECTRODE_NEG_LAYER}"),
|
||||||
|
note=note)
|
||||||
|
|
||||||
|
# cfg.mode is only the starting radio - never a pin
|
||||||
|
start_pdn = ((cfg is not None and cfg.mode == "pdn"
|
||||||
|
and pdn_reason is None)
|
||||||
|
or classic_reason is not None)
|
||||||
|
if start_pdn:
|
||||||
|
default_net = (defaults.net if defaults.net in pdn_nets
|
||||||
|
else (pdn_nets[0] if pdn_nets else ""))
|
||||||
|
else:
|
||||||
|
default_net = (defaults.net if defaults.net in classic_nets
|
||||||
|
else net_hint if net_hint in classic_nets
|
||||||
|
else classic_nets[0])
|
||||||
|
|
||||||
|
def rect_infos(terms):
|
||||||
|
# unlabeled terminals are always single rectangles, so
|
||||||
|
# freezing the first rect's coordinates is exact
|
||||||
|
return [(mt.labeled,
|
||||||
|
(mt.electrodes[0].rect.x0 / 1e6,
|
||||||
|
mt.electrodes[0].rect.y0 / 1e6,
|
||||||
|
mt.electrodes[0].rect.x1 / 1e6,
|
||||||
|
mt.electrodes[0].rect.y1 / 1e6))
|
||||||
|
for mt in terms]
|
||||||
|
|
||||||
|
def save_cb(sel, target, cfg=cfg, cfg_path=cfg_path,
|
||||||
|
pdn_cfg=pdn_cfg, marker_terms=marker_terms,
|
||||||
|
new_terms=new_terms):
|
||||||
|
if sel.mode == "classic":
|
||||||
|
configfile.save_classic_config(target, sel)
|
||||||
|
elif pdn_cfg:
|
||||||
|
# the config rows update positionally; newly drawn
|
||||||
|
# rectangles append as fresh terminal entries
|
||||||
|
n = len(cfg.raw["terminals"])
|
||||||
|
tj = configfile.updated_terminals_json(
|
||||||
|
cfg.raw["terminals"], sel.pdn_rows[:n])
|
||||||
|
if sel.pdn_rows[n:]:
|
||||||
|
tj += configfile.rect_terminals_json(
|
||||||
|
sel.pdn_rows[n:], rect_infos(new_terms))
|
||||||
|
print(f"note: {len(sel.pdn_rows[n:])} new "
|
||||||
|
f"terminal(s) added to the config")
|
||||||
|
configfile.save_pdn_config(target, sel, tj)
|
||||||
|
else:
|
||||||
|
# EVERY row is saved - off-net ones arrive from the
|
||||||
|
# dialog as active: false (nothing drawn on the
|
||||||
|
# board is lost by a save); labeled (possibly
|
||||||
|
# grouped) rectangles save as rect:NAME
|
||||||
|
configfile.save_pdn_config(
|
||||||
|
target, sel,
|
||||||
|
configfile.rect_terminals_json(
|
||||||
|
sel.pdn_rows, rect_infos(marker_terms)))
|
||||||
|
print("note: the saved config now provides the "
|
||||||
|
"terminal set on later launches - labeled "
|
||||||
|
"rectangles stay live (rect:NAME), unlabeled "
|
||||||
|
"ones were frozen as coordinates; remove the "
|
||||||
|
"terminals section (or the file) to return "
|
||||||
|
"to the live rectangle scan")
|
||||||
|
print(f"config saved to {target}")
|
||||||
|
# only "default" (or its legacy plain spelling) and the
|
||||||
|
# board-stem name load on launch; other names need the
|
||||||
|
# Load config… button - say so before it surprises
|
||||||
|
auto = {config.CONFIG_FILENAME,
|
||||||
|
configfile.named_config_filename("default")}
|
||||||
|
stem = Path(getattr(board, "name", "") or "").stem
|
||||||
|
if stem:
|
||||||
|
auto.add(f"{stem}.{config.CONFIG_FILENAME}")
|
||||||
|
if target.name not in auto:
|
||||||
|
print("note: this name does not load automatically "
|
||||||
|
"- pull it in with Load config…")
|
||||||
|
return target.name
|
||||||
|
|
||||||
|
selection = dialog.ask(
|
||||||
|
candidates={n: list(copper[n].keys())
|
||||||
|
for n in classic_nets},
|
||||||
|
layer_order=stackup.names,
|
||||||
|
default_net=default_net,
|
||||||
|
e1_label=(group_label(es1) if es1 else ""),
|
||||||
|
e2_label=(group_label(es2) if es2 else ""),
|
||||||
|
contact1=((defaults.contact1 or group_contact(es1))
|
||||||
|
if es1 else "auto"),
|
||||||
|
contact2=((defaults.contact2 or group_contact(es2))
|
||||||
|
if es2 else "auto"),
|
||||||
|
buildup_layers=sorted(buildups.keys()),
|
||||||
|
defaults=defaults, pdn=pdn_setup,
|
||||||
|
pdn_candidates={n: list(copper[n].keys())
|
||||||
|
for n in pdn_nets},
|
||||||
|
classic_reason=classic_reason, pdn_reason=pdn_reason,
|
||||||
|
save_callback=save_cb,
|
||||||
|
save_target=(cfg_path if cfg_path is not None else
|
||||||
|
board_io.board_dir(board)
|
||||||
|
/ config.CONFIG_FILENAME),
|
||||||
|
load_dir=board_io.board_dir(board),
|
||||||
|
start_mode=("pdn" if start_pdn else "classic"),
|
||||||
)
|
)
|
||||||
|
if isinstance(selection, dialog.LoadRequest):
|
||||||
def group_label(parts):
|
# re-derive everything from the picked file; its validity
|
||||||
names = [p.label for p in parts[:3]]
|
# was already checked by the dialog before it closed
|
||||||
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
|
cfg_path = selection.path
|
||||||
return f"{len(parts)}× " + ", ".join(names) + more
|
continue
|
||||||
|
break
|
||||||
def group_contact(parts):
|
|
||||||
contacts = {p.contact for p in parts}
|
|
||||||
return contacts.pop() if len(contacts) == 1 else "auto"
|
|
||||||
|
|
||||||
default_net = (net_hint if net_hint in candidate_nets
|
|
||||||
else candidate_nets[0])
|
|
||||||
selection = dialog.ask(
|
|
||||||
candidates={n: list(copper[n].keys()) for n in candidate_nets},
|
|
||||||
layer_order=stackup.names,
|
|
||||||
default_net=default_net,
|
|
||||||
e1_label=group_label(es1), e2_label=group_label(es2),
|
|
||||||
contact1=group_contact(es1), contact2=group_contact(es2),
|
|
||||||
buildup_layers=sorted(buildups.keys()),
|
|
||||||
)
|
|
||||||
if selection is None:
|
if selection is None:
|
||||||
print("cancelled")
|
print("cancelled")
|
||||||
return
|
return
|
||||||
@@ -108,25 +403,86 @@ def main() -> None:
|
|||||||
# looks like it did nothing until the figures appear
|
# looks like it did nothing until the figures appear
|
||||||
progress.start()
|
progress.start()
|
||||||
|
|
||||||
if selection.contact1 != "auto":
|
run_pdn = selection.mode == "pdn"
|
||||||
for e in es1:
|
if run_pdn:
|
||||||
e.contact = selection.contact1
|
def live_terminal(mt, row):
|
||||||
if selection.contact2 != "auto":
|
if row.contact not in ("", "all", "auto"):
|
||||||
for e in es2:
|
# dialog Layer pick: this terminal's rectangles
|
||||||
e.contact = selection.contact2
|
# contact only that copper layer
|
||||||
|
for e in mt.electrodes:
|
||||||
|
e.contact = row.contact
|
||||||
|
return Terminal(
|
||||||
|
role=row.role, electrodes=mt.electrodes,
|
||||||
|
label=row.name,
|
||||||
|
i_draw_a=(row.i_draw_a
|
||||||
|
if row.i_draw_a is not None else 0.0),
|
||||||
|
r_out_ohm=(row.r_out_ohm
|
||||||
|
if row.r_out_ohm is not None else 0.0),
|
||||||
|
v_oc=row.v_oc, bonded=mt.bonded,
|
||||||
|
component=row.component, comment=row.comment)
|
||||||
|
if pdn_cfg:
|
||||||
|
cfg_rows = selection.pdn_rows[:len(terminals)]
|
||||||
|
new_rows = selection.pdn_rows[len(terminals):]
|
||||||
|
# a changed Layer scope is geometry: push it onto the
|
||||||
|
# specs and re-resolve (part-level contacts inside the
|
||||||
|
# file still win, exactly as the schema promises)
|
||||||
|
changed = False
|
||||||
|
for spec, row in zip(cfg.terminals, cfg_rows):
|
||||||
|
if (row.contact or "auto") != (spec.contact or "auto"):
|
||||||
|
spec.contact = row.contact
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
try:
|
||||||
|
terminals = board_io.resolve_terminal_specs(
|
||||||
|
board, stackup, cfg.terminals, cfg.net)
|
||||||
|
except ApiError as e:
|
||||||
|
raise UserFacingError(f"KiCad API error: {e}")
|
||||||
|
# dialog value edits win for the run: write them back
|
||||||
|
# onto the resolved terminals (positional, same order),
|
||||||
|
# then drop the unchecked ones - they stay in the file
|
||||||
|
# but take no part in the solve; newly drawn
|
||||||
|
# rectangles run as live terminals
|
||||||
|
for t, row in zip(terminals, cfg_rows):
|
||||||
|
if t.role == "load":
|
||||||
|
t.i_draw_a = row.i_draw_a
|
||||||
|
else:
|
||||||
|
t.r_out_ohm = row.r_out_ohm
|
||||||
|
t.v_oc = row.v_oc
|
||||||
|
t.component = row.component
|
||||||
|
t.comment = row.comment
|
||||||
|
terminals = (
|
||||||
|
[t for t, row in zip(terminals, cfg_rows)
|
||||||
|
if row.active]
|
||||||
|
+ [live_terminal(mt, row)
|
||||||
|
for mt, row in zip(new_terms, new_rows)
|
||||||
|
if row.active])
|
||||||
|
else:
|
||||||
|
terminals = [live_terminal(mt, row)
|
||||||
|
for mt, row in zip(marker_terms,
|
||||||
|
selection.pdn_rows)
|
||||||
|
if row.active]
|
||||||
|
else:
|
||||||
|
if selection.contact1 != "auto":
|
||||||
|
for e in es1:
|
||||||
|
e.contact = selection.contact1
|
||||||
|
if selection.contact2 != "auto":
|
||||||
|
for e in es2:
|
||||||
|
e.contact = selection.contact2
|
||||||
if selection.cell_um is not None:
|
if selection.cell_um is not None:
|
||||||
config.CELL_UM_OVERRIDE = selection.cell_um
|
config.CELL_UM_OVERRIDE = selection.cell_um
|
||||||
config.ADAPTIVE_CELLS = selection.adaptive
|
config.ADAPTIVE_CELLS = selection.adaptive
|
||||||
|
|
||||||
try:
|
try:
|
||||||
problem = board_io.build_problem(
|
problem = board_io.build_problem(
|
||||||
board, selection.net, selection.layers, es1, es2, stackup,
|
board, selection.net, selection.layers,
|
||||||
fills,
|
([] if run_pdn else es1), ([] if run_pdn else es2),
|
||||||
|
stackup, fills,
|
||||||
buildups=(buildups if selection.include_buildup else None),
|
buildups=(buildups if selection.include_buildup else None),
|
||||||
extra_cu_um=selection.extra_cu_um,
|
extra_cu_um=selection.extra_cu_um,
|
||||||
tracks=(tracks if selection.include_tracks else None),
|
tracks=(tracks if selection.include_tracks else None),
|
||||||
vias_capped=selection.vias_capped,
|
vias_capped=selection.vias_capped,
|
||||||
cap_max_drill_mm=selection.cap_max_drill_mm)
|
cap_max_drill_mm=selection.cap_max_drill_mm,
|
||||||
|
terminals=(terminals if run_pdn else None))
|
||||||
outdir = report.make_output_dir(board_io.board_dir(board))
|
outdir = report.make_output_dir(board_io.board_dir(board))
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
raise UserFacingError(f"KiCad API error: {e}")
|
raise UserFacingError(f"KiCad API error: {e}")
|
||||||
@@ -145,12 +501,15 @@ def main() -> None:
|
|||||||
trim_abs = selection.trim_value
|
trim_abs = selection.trim_value
|
||||||
else:
|
else:
|
||||||
trim_pct = selection.trim_value
|
trim_pct = selection.trim_value
|
||||||
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
|
pipeline.run(problem, outdir, show=True,
|
||||||
|
i_test=(None if run_pdn else selection.current_a),
|
||||||
freq_hz=selection.freq_hz,
|
freq_hz=selection.freq_hz,
|
||||||
contact_model=selection.contact_model,
|
contact_model=(None if run_pdn
|
||||||
|
else selection.contact_model),
|
||||||
overlay=overlay_cb,
|
overlay=overlay_cb,
|
||||||
trim_pct=trim_pct, trim_abs=trim_abs,
|
trim_pct=trim_pct, trim_abs=trim_abs,
|
||||||
trim_push=trim_cb)
|
trim_push=trim_cb,
|
||||||
|
v_nominal=(selection.v_nominal if run_pdn else None))
|
||||||
except progress.Cancelled:
|
except progress.Cancelled:
|
||||||
print("cancelled") # user's own doing: no error figure
|
print("cancelled") # user's own doing: no error figure
|
||||||
except UserFacingError as e:
|
except UserFacingError as e:
|
||||||
|
|||||||
+64
-18
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from . import config, plots, progress, raster, report, solver, trim
|
from . import config, plots, progress, raster, report, solver, trim
|
||||||
from .errors import UserFacingError
|
from .errors import ElectrodeError, UserFacingError
|
||||||
from .geometry import Problem
|
from .geometry import Problem
|
||||||
from .solver import Result
|
from .solver import Result
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
|||||||
i_test: float | None = None, freq_hz: float = 0.0,
|
i_test: float | None = None, freq_hz: float = 0.0,
|
||||||
contact_model: str | None = None, overlay=None,
|
contact_model: str | None = None, overlay=None,
|
||||||
trim_pct: float | None = None, trim_abs: float | None = None,
|
trim_pct: float | None = None, trim_abs: float | None = None,
|
||||||
trim_push=None) -> Result:
|
trim_push=None, v_nominal: float | None = None) -> Result:
|
||||||
"""overlay: optional callback(stack, result) run after the solve
|
"""overlay: optional callback(stack, result) run after the solve
|
||||||
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.
|
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.
|
||||||
trim_pct / trim_abs: mark copper below this threshold (% of the
|
trim_pct / trim_abs: mark copper below this threshold (% of the
|
||||||
@@ -22,11 +24,22 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
|||||||
areas are printed, polygons saved to
|
areas are printed, polygons saved to
|
||||||
<outdir>/low_current_copper.json and handed to trim_push, an
|
<outdir>/low_current_copper.json and handed to trim_push, an
|
||||||
optional callback(trim_result) that pushes them into the board
|
optional callback(trim_result) that pushes them into the board
|
||||||
(failures non-fatal)."""
|
(failures non-fatal). Problems with terminals run in PDN mode:
|
||||||
if i_test is None:
|
i_test/contact_model are ignored there (draws come from the
|
||||||
i_test = config.TEST_CURRENT_A
|
terminals, the contact models are fixed) and v_nominal is the
|
||||||
if i_test <= 0:
|
default supply open-circuit voltage."""
|
||||||
raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).")
|
pdn = bool(problem.terminals)
|
||||||
|
if pdn and (problem.electrodes1 or problem.electrodes2):
|
||||||
|
raise ElectrodeError(
|
||||||
|
"The problem carries both classic V+/V- electrodes and PDN "
|
||||||
|
"terminals - exactly one terminal scheme must be used."
|
||||||
|
)
|
||||||
|
if not pdn:
|
||||||
|
if i_test is None:
|
||||||
|
i_test = config.TEST_CURRENT_A
|
||||||
|
if i_test <= 0:
|
||||||
|
raise UserFacingError(
|
||||||
|
f"Test current must be > 0 A (got {i_test:g}).")
|
||||||
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
|
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
|
||||||
progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell "
|
progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell "
|
||||||
f"size {h / 1000:.1f} um ...")
|
f"size {h / 1000:.1f} um ...")
|
||||||
@@ -35,18 +48,49 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
|||||||
f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} "
|
f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} "
|
||||||
f"via/pad barrel(s)")
|
f"via/pad barrel(s)")
|
||||||
|
|
||||||
e1, e2 = raster.electrode_masks(stack, problem)
|
if pdn:
|
||||||
parts1, parts2 = raster.electrode_partition(stack, problem)
|
if contact_model is not None:
|
||||||
|
print("PDN mode: contact models are fixed (Thevenin supplies "
|
||||||
|
"/ uniform-injection loads) - ignoring the setting")
|
||||||
|
tmasks = raster.terminal_masks(stack, problem)
|
||||||
|
tparts = raster.terminal_partition(stack, problem)
|
||||||
|
draw = sum(t.i_draw_a for t in problem.terminals
|
||||||
|
if t.role == "load")
|
||||||
|
progress.stage(f"solving PDN, {draw:g} A total draw"
|
||||||
|
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC")
|
||||||
|
+ " ...")
|
||||||
|
result = solver.run_solve_pdn(problem, stack, tmasks, tparts,
|
||||||
|
freq_hz, v_nominal)
|
||||||
|
for s_ in result.supplies:
|
||||||
|
print(f" {s_.label}: {s_.i_a:.4g} A @ {s_.v_contact:.4g} V "
|
||||||
|
f"(v_oc {s_.v_oc:g} V, r_out {s_.r_out_ohm:g} ohm, "
|
||||||
|
f"P_int {s_.p_internal_w:.3g} W)")
|
||||||
|
for l_ in result.loads:
|
||||||
|
print(f" {l_.label}: {l_.i_a:.4g} A, V {l_.v_mean:.4g} V "
|
||||||
|
f"(min {l_.v_min:.4g}), P {l_.p_w:.4g} W")
|
||||||
|
# figures reuse the two-terminal color scheme: supplies as V+,
|
||||||
|
# loads as V- (masks already follow the solve's restriction)
|
||||||
|
e1 = np.zeros_like(stack.masks)
|
||||||
|
e2 = np.zeros_like(stack.masks)
|
||||||
|
for t, m in zip(problem.terminals, tmasks):
|
||||||
|
if t.role == "supply":
|
||||||
|
e1 |= m
|
||||||
|
else:
|
||||||
|
e2 |= m
|
||||||
|
else:
|
||||||
|
e1, e2 = raster.electrode_masks(stack, problem)
|
||||||
|
parts1, parts2 = raster.electrode_partition(stack, problem)
|
||||||
|
|
||||||
progress.stage(f"solving @ {i_test:g} A"
|
progress.stage(f"solving @ {i_test:g} A"
|
||||||
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
|
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC")
|
||||||
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
|
+ " ...")
|
||||||
contact_model, parts1, parts2)
|
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
|
||||||
for prefix, pcs in (("P", result.part_currents1),
|
contact_model, parts1, parts2)
|
||||||
("N", result.part_currents2)):
|
for prefix, pcs in (("P", result.part_currents1),
|
||||||
for i, (label, amps) in enumerate(pcs):
|
("N", result.part_currents2)):
|
||||||
print(f" {prefix}{i + 1} ({label}): {amps:.4g} A "
|
for i, (label, amps) in enumerate(pcs):
|
||||||
f"({100 * amps / i_test:.1f}%)")
|
print(f" {prefix}{i + 1} ({label}): {amps:.4g} A "
|
||||||
|
f"({100 * amps / i_test:.1f}%)")
|
||||||
|
|
||||||
if outdir is not None:
|
if outdir is not None:
|
||||||
outdir.mkdir(parents=True, exist_ok=True)
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -78,5 +122,7 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
|||||||
"3_current_density"),
|
"3_current_density"),
|
||||||
(plots.fig_power(result, stack, e1, e2, problem), "4_power_density"),
|
(plots.fig_power(result, stack, e1, e2, problem), "4_power_density"),
|
||||||
]
|
]
|
||||||
|
if result.mode == "pdn" and result.pairs:
|
||||||
|
figs.append((plots.fig_pdn_pairs(result), "5_source_sink_pairs"))
|
||||||
plots.save_and_show(figs, outdir, show=show) # closes the window itself
|
plots.save_and_show(figs, outdir, show=show) # closes the window itself
|
||||||
return result
|
return result
|
||||||
|
|||||||
+102
-6
@@ -83,7 +83,15 @@ def _fmt_si(value: float, unit: str) -> str:
|
|||||||
def _suptitle(problem, stack, result=None) -> str:
|
def _suptitle(problem, stack, result=None) -> str:
|
||||||
ny, nx = stack.shape2d
|
ny, nx = stack.shape2d
|
||||||
parts = []
|
parts = []
|
||||||
if result is not None:
|
if result is not None and result.mode == "pdn":
|
||||||
|
# no single two-terminal R in PDN mode (R_ohm is NaN)
|
||||||
|
parts.append(f"PDN {len(result.supplies)}S/{len(result.loads)}L, "
|
||||||
|
f"ΣI = {result.i_test:g} A")
|
||||||
|
parts.append(f"P_Cu = {_fmt_si(result.P_total, 'W')}")
|
||||||
|
if result.freq_hz > 0:
|
||||||
|
parts.append(f"f = {result.freq_hz / 1e3:g} kHz "
|
||||||
|
f"(δ={result.skin_depth_um:.0f} µm, lower bound)")
|
||||||
|
elif result is not None:
|
||||||
parts.append(f"R = {result.R_ohm * 1000:.4g} mΩ")
|
parts.append(f"R = {result.R_ohm * 1000:.4g} mΩ")
|
||||||
parts.append(f"P = {_fmt_si(result.P_total, 'W')} @ "
|
parts.append(f"P = {_fmt_si(result.P_total, 'W')} @ "
|
||||||
f"{result.i_test:g} A")
|
f"{result.i_test:g} A")
|
||||||
@@ -286,8 +294,22 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
|||||||
if has_plug:
|
if has_plug:
|
||||||
handles.append(Patch(
|
handles.append(Patch(
|
||||||
fc=_PLUG, label="solder-filled THT hole (lead + solder)"))
|
fc=_PLUG, label="solder-filled THT hole (lead + solder)"))
|
||||||
if result is not None and (result.part_currents1
|
if result is not None and result.mode == "pdn":
|
||||||
or result.part_currents2):
|
entries = ([(f"S{i + 1}", _E1_COLOR, s_.label, s_.i_a)
|
||||||
|
for i, s_ in enumerate(result.supplies)]
|
||||||
|
+ [(f"L{i + 1}", _E2_COLOR, l_.label, l_.i_a)
|
||||||
|
for i, l_ in enumerate(result.loads)])
|
||||||
|
shown = entries[:14]
|
||||||
|
for tag, color, label, amps in shown:
|
||||||
|
handles.append(Patch(
|
||||||
|
fc=color, label=f"{tag} {label}: {amps:.3g} A"))
|
||||||
|
if len(entries) > len(shown):
|
||||||
|
handles.append(Patch(
|
||||||
|
fc="#00000000",
|
||||||
|
label=f"... +{len(entries) - len(shown)} "
|
||||||
|
f"more in summary.txt"))
|
||||||
|
elif result is not None and (result.part_currents1
|
||||||
|
or result.part_currents2):
|
||||||
entries = ([("+", _E1_COLOR, i, amps)
|
entries = ([("+", _E1_COLOR, i, amps)
|
||||||
for i, (_, amps) in
|
for i, (_, amps) in
|
||||||
enumerate(result.part_currents1)]
|
enumerate(result.part_currents1)]
|
||||||
@@ -321,9 +343,14 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
|||||||
|
|
||||||
def fig_potential(result, stack, e1, e2, problem):
|
def fig_potential(result, stack, e1, e2, problem):
|
||||||
vmax = float(np.nanmax(result.V))
|
vmax = float(np.nanmax(result.V))
|
||||||
# uniform model: <V-> = 0 is the reference, individual V- cells can
|
if result.mode == "pdn":
|
||||||
# sit slightly below it - keep them in range instead of clipping
|
# absolute volts (e.g. 3.3 V nominal): anchoring the scale at
|
||||||
vmin = min(0.0, float(np.nanmin(result.V)))
|
# 0 V would flatten the map into one color - auto-range instead
|
||||||
|
vmin = float(np.nanmin(result.V))
|
||||||
|
else:
|
||||||
|
# uniform model: <V-> = 0 is the reference, individual V- cells
|
||||||
|
# can sit slightly below it - keep them in range, don't clip
|
||||||
|
vmin = min(0.0, float(np.nanmin(result.V)))
|
||||||
unit, scale = ("mV", 1e3) if vmax < 0.1 else ("V", 1.0)
|
unit, scale = ("mV", 1e3) if vmax < 0.1 else ("V", 1.0)
|
||||||
cmap = matplotlib.colormaps[config.CMAP_POTENTIAL].copy()
|
cmap = matplotlib.colormaps[config.CMAP_POTENTIAL].copy()
|
||||||
cmap.set_bad(_BG)
|
cmap.set_bad(_BG)
|
||||||
@@ -444,6 +471,75 @@ def fig_power(result, stack, e1, e2, problem):
|
|||||||
paint_extra=paint_extra)
|
paint_extra=paint_extra)
|
||||||
|
|
||||||
|
|
||||||
|
def _style_table(tbl):
|
||||||
|
tbl.auto_set_font_size(False)
|
||||||
|
tbl.set_fontsize(9)
|
||||||
|
tbl.scale(1.0, 1.5)
|
||||||
|
tbl.auto_set_column_width(col=sorted({c for _r, c
|
||||||
|
in tbl.get_celld()}))
|
||||||
|
for (r, _c), cell in tbl.get_celld().items():
|
||||||
|
cell.set_edgecolor("#cccccc")
|
||||||
|
if r == 0:
|
||||||
|
cell.set_text_props(fontweight="bold", color=_INK)
|
||||||
|
cell.set_facecolor("#eeeeee")
|
||||||
|
elif r % 2 == 0:
|
||||||
|
cell.set_facecolor("#f7f7f7")
|
||||||
|
|
||||||
|
|
||||||
|
def fig_pdn_pairs(result):
|
||||||
|
"""The PDN source-sink pair table as a figure: effective copper
|
||||||
|
resistance between every supply and every load plus the
|
||||||
|
proportional-sharing loss attribution - the same numbers and
|
||||||
|
conventions as the summary.txt table. Terminals are keyed by their
|
||||||
|
(unique) labels alone; a legend table underneath notes each
|
||||||
|
terminal's component hint and comment when there are any."""
|
||||||
|
header = ["supply", "load", "R (copper)", "I attributed",
|
||||||
|
"P attributed"]
|
||||||
|
rows = [[pr.supply, pr.load,
|
||||||
|
(_fmt_si(pr.r_ohm, "Ω") if pr.r_ohm is not None
|
||||||
|
else "no path"),
|
||||||
|
_fmt_si(pr.i_share_a, "A"),
|
||||||
|
_fmt_si(pr.p_w, "W")] for pr in result.pairs]
|
||||||
|
legend = [[t.label, role, t.component, t.comment]
|
||||||
|
for role, terms in (("supply", result.supplies),
|
||||||
|
("load", result.loads))
|
||||||
|
for t in terms if t.component or t.comment]
|
||||||
|
h1 = 1.8 + 0.32 * len(rows)
|
||||||
|
h2 = 0.9 + 0.30 * len(legend)
|
||||||
|
if legend:
|
||||||
|
fig, (ax, ax2) = plt.subplots(
|
||||||
|
2, 1, figsize=(9.0, h1 + h2), layout="constrained",
|
||||||
|
gridspec_kw={"height_ratios": [h1, h2]})
|
||||||
|
else:
|
||||||
|
fig, ax = plt.subplots(figsize=(9.0, h1), layout="constrained")
|
||||||
|
ax2 = None
|
||||||
|
ax.axis("off")
|
||||||
|
ax.set_title("Fill Resistance - source→sink pairs", fontsize=13,
|
||||||
|
color=_INK, loc="left")
|
||||||
|
_style_table(ax.table(cellText=rows, colLabels=header,
|
||||||
|
loc="upper center", cellLoc="left",
|
||||||
|
colLoc="left"))
|
||||||
|
p_attr = sum(pr.p_w for pr in result.pairs)
|
||||||
|
ax.text(0.0, 0.02,
|
||||||
|
f"attributed copper loss total: {_fmt_si(p_attr, 'W')} "
|
||||||
|
f"(copper loss {_fmt_si(result.P_total, 'W')})\n"
|
||||||
|
"R: effective copper resistance between the two contacts - "
|
||||||
|
"operating-point independent, source R_out excluded.\n"
|
||||||
|
"I/P attributed by proportional sharing per copper island: "
|
||||||
|
"a convention (the pair split is not unique physics), but "
|
||||||
|
"exact in total.",
|
||||||
|
transform=ax.transAxes, fontsize=8, color="#666666",
|
||||||
|
va="bottom", ha="left")
|
||||||
|
if ax2 is not None:
|
||||||
|
ax2.axis("off")
|
||||||
|
ax2.set_title("terminals", fontsize=10, color=_INK, loc="left")
|
||||||
|
_style_table(ax2.table(
|
||||||
|
cellText=legend,
|
||||||
|
colLabels=["terminal", "role", "component", "comment"],
|
||||||
|
loc="upper center", cellLoc="left", colLoc="left"))
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
def fig_error(message: str):
|
def fig_error(message: str):
|
||||||
fig, ax = plt.subplots(figsize=(9, 4.5), layout="constrained")
|
fig, ax = plt.subplots(figsize=(9, 4.5), layout="constrained")
|
||||||
ax.axis("off")
|
ax.axis("off")
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
|
|||||||
# net's populated stitching THT pads, skipping the contacts' barrels
|
# net's populated stitching THT pads, skipping the contacts' barrels
|
||||||
jobs = []
|
jobs = []
|
||||||
seen = set()
|
seen = set()
|
||||||
for e in problem.electrodes1 + problem.electrodes2:
|
for e in problem.contact_electrodes():
|
||||||
if e.drill_nm <= 0:
|
if e.drill_nm <= 0:
|
||||||
continue
|
continue
|
||||||
if e.center is not None:
|
if e.center is not None:
|
||||||
@@ -629,6 +629,58 @@ def electrode_masks(stack: RasterStack, problem: Problem
|
|||||||
return e1, e2
|
return e1, e2
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_masks(stack: RasterStack, problem: Problem) -> list:
|
||||||
|
"""PDN mode: one (L, ny, nx) contact mask per problem.terminals
|
||||||
|
entry, same order. Same part semantics as electrode_masks (every
|
||||||
|
part must land on copper); additionally NO two terminals may share
|
||||||
|
a cell - each cell's injection/attachment must belong to exactly one
|
||||||
|
terminal or the currents would be ill-defined."""
|
||||||
|
out = []
|
||||||
|
for t in problem.terminals:
|
||||||
|
m = np.zeros_like(stack.masks)
|
||||||
|
for el in t.electrodes:
|
||||||
|
part = _part_mask3d(stack, problem, el)
|
||||||
|
if not part.any():
|
||||||
|
where = ("near its barrel (drill-wall ring / pad footprint)"
|
||||||
|
if el.drill_nm > 0 else
|
||||||
|
"(or is smaller than one grid cell)")
|
||||||
|
raise ElectrodeError(
|
||||||
|
f"{t.role} '{t.label}': contact part ({el.label}) does "
|
||||||
|
f"not overlap any copper of the selected fill on "
|
||||||
|
f"contact layer(s) '{el.contact}' {where}."
|
||||||
|
)
|
||||||
|
m |= part
|
||||||
|
out.append(m)
|
||||||
|
for i, ti in enumerate(problem.terminals):
|
||||||
|
for j in range(i + 1, len(problem.terminals)):
|
||||||
|
if (out[i] & out[j]).any():
|
||||||
|
tj = problem.terminals[j]
|
||||||
|
raise ElectrodeError(
|
||||||
|
f"The contact areas of {ti.role} '{ti.label}' and "
|
||||||
|
f"{tj.role} '{tj.label}' overlap on the copper grid. "
|
||||||
|
f"Move them apart."
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_partition(stack: RasterStack, problem: Problem) -> list:
|
||||||
|
"""PDN mode: per-part cell masks for each terminal, as a list (one
|
||||||
|
entry per terminal) of [(label, mask3d), ...]. Within one terminal
|
||||||
|
overlapping parts keep the first-wins attribution of
|
||||||
|
electrode_partition, so part currents sum to the terminal current."""
|
||||||
|
out = []
|
||||||
|
for t in problem.terminals:
|
||||||
|
parts = []
|
||||||
|
claimed = np.zeros_like(stack.masks)
|
||||||
|
for el in t.electrodes:
|
||||||
|
m = _part_mask3d(stack, problem, el)
|
||||||
|
m &= ~claimed
|
||||||
|
claimed |= m
|
||||||
|
parts.append((el.label, m))
|
||||||
|
out.append(parts)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def electrode_partition(stack: RasterStack, problem: Problem
|
def electrode_partition(stack: RasterStack, problem: Problem
|
||||||
) -> tuple[list, list]:
|
) -> tuple[list, list]:
|
||||||
"""Per-part cell masks for both terminals, as [(label, mask3d), ...].
|
"""Per-part cell masks for both terminals, as [(label, mask3d), ...].
|
||||||
|
|||||||
+209
-57
@@ -42,6 +42,15 @@ def result_line(result: Result, problem: Problem, stack: RasterStack) -> str:
|
|||||||
ny, nx = stack.shape2d
|
ny, nx = stack.shape2d
|
||||||
ac = (f" @ {result.freq_hz / 1e3:g} kHz (lower bound)"
|
ac = (f" @ {result.freq_hz / 1e3:g} kHz (lower bound)"
|
||||||
if result.freq_hz > 0 else "")
|
if result.freq_hz > 0 else "")
|
||||||
|
if result.mode == "pdn":
|
||||||
|
vmin = min((l.v_min for l in result.loads), default=float("nan"))
|
||||||
|
return (f"PDN: {len(result.supplies)} supplies / "
|
||||||
|
f"{len(result.loads)} loads, {result.i_test:g} A total "
|
||||||
|
f"draw{ac}, worst load {vmin:.4g} V, "
|
||||||
|
f"P_copper = {result.P_total:.4g} W "
|
||||||
|
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
|
||||||
|
f"grid {nx}x{ny}x{stack.nlayers}, "
|
||||||
|
f"cell {stack.h_nm / 1000:.0f} um)")
|
||||||
return (f"R = {result.R_ohm * 1000:.4g} mOhm{ac}, "
|
return (f"R = {result.R_ohm * 1000:.4g} mOhm{ac}, "
|
||||||
f"P = {result.P_total:.4g} W @ {result.i_test:g} A "
|
f"P = {result.P_total:.4g} W @ {result.i_test:g} A "
|
||||||
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
|
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
|
||||||
@@ -55,11 +64,87 @@ def _electrode_line(e) -> str:
|
|||||||
f"y [{r.y0 / 1e6:.2f}, {r.y1 / 1e6:.2f}] mm")
|
f"y [{r.y0 / 1e6:.2f}, {r.y1 / 1e6:.2f}] mm")
|
||||||
|
|
||||||
|
|
||||||
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
def _buildup_line(problem: Problem, stack: RasterStack) -> str | None:
|
||||||
result: Result) -> Path:
|
if not (problem.buildups and stack.buildup is not None):
|
||||||
|
return None
|
||||||
|
eq_um = (problem.solder_thickness_nm / 1000
|
||||||
|
* problem.rho_ohm_m / problem.solder_rho_ohm_m
|
||||||
|
+ problem.extra_cu_nm / 1000)
|
||||||
|
cell_mm2 = (stack.h_nm * 1e-6) ** 2
|
||||||
|
per_layer = {name: float(stack.buildup[li].sum()) * cell_mm2
|
||||||
|
for li, name in enumerate(stack.layer_names)
|
||||||
|
if stack.buildup[li].any()}
|
||||||
|
areas = ", ".join(f"{n}: {a:.0f} mm^2" for n, a in per_layer.items())
|
||||||
|
return (f"solder buildup: "
|
||||||
|
f"{problem.solder_thickness_nm / 1000:.0f} um solder"
|
||||||
|
+ (f" + {problem.extra_cu_nm / 1000:.0f} um Cu"
|
||||||
|
if problem.extra_cu_nm else "")
|
||||||
|
+ f" = {eq_um:.1f} um equivalent Cu ({areas})")
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_lines(problem: Problem, result: Result) -> list:
|
||||||
|
out = []
|
||||||
|
for li, layer in enumerate(problem.layers):
|
||||||
|
ac = (f" Rs_AC/Rs_DC={result.rs_ratios[li]:.2f}"
|
||||||
|
if result.freq_hz > 0 else "")
|
||||||
|
out.append(
|
||||||
|
f" {layer.layer_name:8s} t={layer.thickness_nm / 1000:5.1f} um "
|
||||||
|
f"z={layer.z_nm / 1000:7.1f} um "
|
||||||
|
f"P={result.P_layers[li]:.4g} W "
|
||||||
|
f"maxJ={float(np.nanmax(result.Jmag[li])) * 1e-6 if np.isfinite(result.Jmag[li]).any() else 0:.4g} A/mm^2"
|
||||||
|
+ ac
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _solver_lines(stack: RasterStack, result: Result) -> list:
|
||||||
ny, nx = stack.shape2d
|
ny, nx = stack.shape2d
|
||||||
info = result.solve_info
|
info = result.solve_info
|
||||||
head = f"fill_resistance {__version__} summary"
|
if result.contact_model == "equipotential":
|
||||||
|
quality = (f"I1/I2 @ 1V: {result.I1_a:.9g} / "
|
||||||
|
f"{result.I2_a:.9g} A "
|
||||||
|
f"(mismatch {result.mismatch_rel:.2e})")
|
||||||
|
elif result.mode == "pdn":
|
||||||
|
quality = (f"KCL residual: {result.mismatch_rel:.2e} "
|
||||||
|
f"(supplies {result.I1_a:.6g} A vs loads "
|
||||||
|
f"{result.I2_a:.6g} A)")
|
||||||
|
else:
|
||||||
|
quality = (f"solve residual: {result.mismatch_rel:.2e} "
|
||||||
|
f"(KCL, prescribed injection)")
|
||||||
|
return [
|
||||||
|
f"grid: {nx} x {ny} x {stack.nlayers} cells @ "
|
||||||
|
f"{stack.h_nm / 1000:.1f} um",
|
||||||
|
f"copper cells: {int(stack.masks.sum())}",
|
||||||
|
f"free unknowns: {result.n_free}",
|
||||||
|
f"solver: {info.method}"
|
||||||
|
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
|
||||||
|
if info.iterations is not None else ""),
|
||||||
|
quality,
|
||||||
|
f"timings [s]: "
|
||||||
|
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _via_lines(result: Result) -> list:
|
||||||
|
if not result.via_reports:
|
||||||
|
return []
|
||||||
|
n_shown = min(10, len(result.via_reports))
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
f"vias/pads carrying current (top {n_shown} of "
|
||||||
|
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
|
||||||
|
" x [mm] y [mm] kind drill I [A] P [W]",
|
||||||
|
]
|
||||||
|
for v in result.via_reports[:n_shown]:
|
||||||
|
lines.append(
|
||||||
|
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
|
||||||
|
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_classic_lines(head: str, problem: Problem, stack: RasterStack,
|
||||||
|
result: Result) -> list:
|
||||||
lines = [
|
lines = [
|
||||||
head,
|
head,
|
||||||
"=" * len(head),
|
"=" * len(head),
|
||||||
@@ -82,48 +167,13 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
|||||||
f" in vias: {result.P_vias:.4g} W",
|
f" in vias: {result.P_vias:.4g} W",
|
||||||
f" power balance: {result.power_balance_rel:.2e} (consistency)",
|
f" power balance: {result.power_balance_rel:.2e} (consistency)",
|
||||||
"",
|
"",
|
||||||
"layers (top to bottom):",
|
|
||||||
]
|
]
|
||||||
if problem.buildups and stack.buildup is not None:
|
bl = _buildup_line(problem, stack)
|
||||||
eq_um = (problem.solder_thickness_nm / 1000
|
if bl:
|
||||||
* problem.rho_ohm_m / problem.solder_rho_ohm_m
|
lines.append(bl)
|
||||||
+ problem.extra_cu_nm / 1000)
|
lines.append("layers (top to bottom):")
|
||||||
cell_mm2 = (stack.h_nm * 1e-6) ** 2
|
lines += _layer_lines(problem, result)
|
||||||
per_layer = {name: float(stack.buildup[li].sum()) * cell_mm2
|
lines += [""] + _solver_lines(stack, result) + [
|
||||||
for li, name in enumerate(stack.layer_names)
|
|
||||||
if stack.buildup[li].any()}
|
|
||||||
areas = ", ".join(f"{n}: {a:.0f} mm^2" for n, a in per_layer.items())
|
|
||||||
lines.insert(-1, f"solder buildup: "
|
|
||||||
f"{problem.solder_thickness_nm / 1000:.0f} um solder"
|
|
||||||
+ (f" + {problem.extra_cu_nm / 1000:.0f} um Cu"
|
|
||||||
if problem.extra_cu_nm else "")
|
|
||||||
+ f" = {eq_um:.1f} um equivalent Cu ({areas})")
|
|
||||||
for li, layer in enumerate(problem.layers):
|
|
||||||
ac = (f" Rs_AC/Rs_DC={result.rs_ratios[li]:.2f}"
|
|
||||||
if result.freq_hz > 0 else "")
|
|
||||||
lines.append(
|
|
||||||
f" {layer.layer_name:8s} t={layer.thickness_nm / 1000:5.1f} um "
|
|
||||||
f"z={layer.z_nm / 1000:7.1f} um "
|
|
||||||
f"P={result.P_layers[li]:.4g} W "
|
|
||||||
f"maxJ={float(np.nanmax(result.Jmag[li])) * 1e-6 if np.isfinite(result.Jmag[li]).any() else 0:.4g} A/mm^2"
|
|
||||||
+ ac
|
|
||||||
)
|
|
||||||
lines += [
|
|
||||||
"",
|
|
||||||
f"grid: {nx} x {ny} x {stack.nlayers} cells @ "
|
|
||||||
f"{stack.h_nm / 1000:.1f} um",
|
|
||||||
f"copper cells: {int(stack.masks.sum())}",
|
|
||||||
f"free unknowns: {result.n_free}",
|
|
||||||
f"solver: {info.method}"
|
|
||||||
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
|
|
||||||
if info.iterations is not None else ""),
|
|
||||||
(f"I1/I2 @ 1V: {result.I1_a:.9g} / {result.I2_a:.9g} A "
|
|
||||||
f"(mismatch {result.mismatch_rel:.2e})"
|
|
||||||
if result.contact_model == "equipotential" else
|
|
||||||
f"solve residual: {result.mismatch_rel:.2e} "
|
|
||||||
f"(KCL, prescribed injection)"),
|
|
||||||
f"timings [s]: "
|
|
||||||
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
|
|
||||||
"",
|
"",
|
||||||
f"contact model: {result.contact_model}"
|
f"contact model: {result.contact_model}"
|
||||||
+ (" (uniform orthogonal injection; R is the upper contact bound)"
|
+ (" (uniform orthogonal injection; R is the upper contact bound)"
|
||||||
@@ -146,19 +196,121 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
|||||||
tag = f"{'P' if sign == '+' else 'N'}{i + 1}"
|
tag = f"{'P' if sign == '+' else 'N'}{i + 1}"
|
||||||
lines.append(f" {tag:4s} {label:24s} {amps:9.4g} A "
|
lines.append(f" {tag:4s} {label:24s} {amps:9.4g} A "
|
||||||
f"({100 * amps / result.i_test:5.1f}%)")
|
f"({100 * amps / result.i_test:5.1f}%)")
|
||||||
if result.via_reports:
|
return lines + _via_lines(result)
|
||||||
n_shown = min(10, len(result.via_reports))
|
|
||||||
lines += [
|
|
||||||
"",
|
def _summary_pdn_lines(head: str, problem: Problem, stack: RasterStack,
|
||||||
f"vias/pads carrying current (top {n_shown} of "
|
result: Result) -> list:
|
||||||
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
|
p_src = result.P_total + result.P_supply_internal + result.P_loads
|
||||||
" x [mm] y [mm] kind drill I [A] P [W]",
|
lines = [
|
||||||
]
|
head,
|
||||||
for v in result.via_reports[:n_shown]:
|
"=" * len(head),
|
||||||
lines.append(
|
f"board: {problem.board_path}",
|
||||||
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
|
f"net: {problem.net_name}",
|
||||||
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
|
f"mode: PDN ({len(result.supplies)} supplies / "
|
||||||
)
|
f"{len(result.loads)} loads)",
|
||||||
|
f"total load draw: {result.i_test:g} A",
|
||||||
|
f"nominal voltage: {result.v_nominal:g} V "
|
||||||
|
f"(default v_oc; per-supply v_oc overrides)",
|
||||||
|
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
|
||||||
|
f"via plating: {problem.plating_nm / 1000:.0f} um",
|
||||||
|
"",
|
||||||
|
("frequency: "
|
||||||
|
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
|
||||||
|
if result.freq_hz > 0 else "DC")),
|
||||||
|
f"COPPER LOSS: {result.P_total:.6g} W",
|
||||||
|
f" in vias: {result.P_vias:.4g} W",
|
||||||
|
f" in supply R_out: {result.P_supply_internal:.4g} W",
|
||||||
|
f" load power: {result.P_loads:.6g} W",
|
||||||
|
f" source power: {p_src:.6g} W",
|
||||||
|
f" power balance: {result.power_balance_rel:.2e} (consistency)",
|
||||||
|
]
|
||||||
|
if result.freq_hz > 0:
|
||||||
|
lines.append(
|
||||||
|
" NOTE: AC PDN assumes all load draws are IN PHASE (worst "
|
||||||
|
"case; skin resistance only - no proximity, no inductance)")
|
||||||
|
# terminal LABELS are unique (validated) and are the one key used
|
||||||
|
# everywhere - no extra positional tags, which would only collide
|
||||||
|
# with auto-names like "S1"/"L1"
|
||||||
|
def _term_note(component, comment):
|
||||||
|
parts = ([component] if component else []) \
|
||||||
|
+ ([f"# {comment}"] if comment else [])
|
||||||
|
return (" " + " ".join(parts)) if parts else ""
|
||||||
|
|
||||||
|
lines += ["", "supplies:",
|
||||||
|
" label v_oc [V] r_out [ohm]"
|
||||||
|
" I [A] V [V] P_int [W] component / # comment"]
|
||||||
|
for s_ in result.supplies:
|
||||||
|
lines.append(
|
||||||
|
f" {s_.label:28s} {s_.v_oc:8.4g} "
|
||||||
|
f"{s_.r_out_ohm:11.4g} {s_.i_a:8.4g} {s_.v_contact:8.5g} "
|
||||||
|
f"{s_.p_internal_w:9.4g}"
|
||||||
|
+ _term_note(s_.component, s_.comment))
|
||||||
|
if len(s_.part_currents) > 1:
|
||||||
|
for pl, amps in s_.part_currents:
|
||||||
|
lines.append(f" - {pl:24s} {amps:9.4g} A")
|
||||||
|
# drops are quoted against the highest open-circuit voltage: the
|
||||||
|
# reference a supply designer compares regulation against
|
||||||
|
v_ref = max((s_.v_oc for s_ in result.supplies),
|
||||||
|
default=result.v_nominal or 0.0)
|
||||||
|
lines += ["", "loads:",
|
||||||
|
" label I [A] V_mean [V]"
|
||||||
|
" V_min [V] drop [mV] P [W] component / # comment"]
|
||||||
|
for l_ in result.loads:
|
||||||
|
lines.append(
|
||||||
|
f" {l_.label:28s} {l_.i_a:7.4g} "
|
||||||
|
f"{l_.v_mean:9.5g} {l_.v_min:9.5g} "
|
||||||
|
f"{(v_ref - l_.v_mean) * 1000:9.4g} {l_.p_w:8.4g}"
|
||||||
|
+ _term_note(l_.component, l_.comment))
|
||||||
|
if len(l_.part_currents) > 1:
|
||||||
|
for pl, amps in l_.part_currents:
|
||||||
|
lines.append(f" - {pl:24s} {amps:9.4g} A")
|
||||||
|
if result.pairs:
|
||||||
|
lines += ["", "source-sink pairs (R: effective copper "
|
||||||
|
"resistance between the two contacts, "
|
||||||
|
"operating-point independent, source R_out "
|
||||||
|
"excluded; current/loss attributed by "
|
||||||
|
"proportional sharing - a convention, but exact "
|
||||||
|
"in total):",
|
||||||
|
" pair "
|
||||||
|
"R [ohm] I_attr [A] P_attr [W]"]
|
||||||
|
for pr in result.pairs:
|
||||||
|
name = f"{pr.supply} -> {pr.load}"
|
||||||
|
r_txt = (f"{pr.r_ohm:10.4g}" if pr.r_ohm is not None
|
||||||
|
else " no path")
|
||||||
|
lines.append(f" {name:38s} {r_txt} {pr.i_share_a:10.4g}"
|
||||||
|
f" {pr.p_w:10.4g}")
|
||||||
|
p_attr = sum(pr.p_w for pr in result.pairs)
|
||||||
|
lines.append(f" attributed copper loss total: {p_attr:.6g} W "
|
||||||
|
f"(copper loss {result.P_total:.6g} W)")
|
||||||
|
lines.append("")
|
||||||
|
bl = _buildup_line(problem, stack)
|
||||||
|
if bl:
|
||||||
|
lines.append(bl)
|
||||||
|
lines.append("layers (top to bottom):")
|
||||||
|
lines += _layer_lines(problem, result)
|
||||||
|
lines += [""] + _solver_lines(stack, result) + [
|
||||||
|
"",
|
||||||
|
"contact model: PDN (fixed: Thevenin supplies / "
|
||||||
|
"uniform-injection loads)",
|
||||||
|
"terminals:",
|
||||||
|
]
|
||||||
|
for t in problem.terminals:
|
||||||
|
lines.append(f" {t.role} '{t.label}' "
|
||||||
|
f"({len(t.electrodes)} contact part(s)"
|
||||||
|
+ (", bonded: per-part split is computed"
|
||||||
|
if t.bonded else "") + "):")
|
||||||
|
lines += [f" {_electrode_line(e)}" for e in t.electrodes]
|
||||||
|
return lines + _via_lines(result)
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
||||||
|
result: Result) -> Path:
|
||||||
|
head = f"fill_resistance {__version__} summary"
|
||||||
|
if result.mode == "pdn":
|
||||||
|
lines = _summary_pdn_lines(head, problem, stack, result)
|
||||||
|
else:
|
||||||
|
lines = _summary_classic_lines(head, problem, stack, result)
|
||||||
p = outdir / "summary.txt"
|
p = outdir / "summary.txt"
|
||||||
p.write_text("\n".join(lines), encoding="utf-8")
|
p.write_text("\n".join(lines), encoding="utf-8")
|
||||||
return p
|
return p
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ def normalize_decimal(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
_SI_SUFFIXES = {"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "μ": 1e-6,
|
||||||
|
"m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_engineering(text: str) -> float:
|
||||||
|
"""General value entry: '50m' -> 0.05, '4.7k' -> 4700, '2M' ->
|
||||||
|
2e6, '3,3' -> 3.3, '10' -> 10. A trailing SI suffix scales the
|
||||||
|
number - CASE decides between m (milli) and M (mega), unlike
|
||||||
|
parse_frequency, where a lone m can only mean MHz. Raises
|
||||||
|
ValueError on garbage or an ambiguous comma (normalize_decimal's
|
||||||
|
rules)."""
|
||||||
|
t = normalize_decimal(text.strip())
|
||||||
|
mult = 1.0
|
||||||
|
if t and t[-1] in _SI_SUFFIXES:
|
||||||
|
mult = _SI_SUFFIXES[t[-1]]
|
||||||
|
t = t[:-1].strip() # allow '50 m'
|
||||||
|
return float(t) * mult # ValueError on garbage
|
||||||
|
|
||||||
|
|
||||||
def parse_frequency(text: str) -> float:
|
def parse_frequency(text: str) -> float:
|
||||||
"""'0', '100k', '1.5M', '142500' -> Hz; empty -> 0 (DC).
|
"""'0', '100k', '1.5M', '142500' -> Hz; empty -> 0 (DC).
|
||||||
Raises ValueError on unparseable, ambiguous or negative input (a
|
Raises ValueError on unparseable, ambiguous or negative input (a
|
||||||
|
|||||||
+807
-60
@@ -59,12 +59,19 @@ class SolveInfo:
|
|||||||
residual: float | None = None
|
residual: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# via_index tag for PDN supply-attachment edges (virtual Thevenin node
|
||||||
|
# to contact cell): excluded from the in-plane fields (== -1) AND from
|
||||||
|
# the via power/reports (>= 0); their dissipation is P_supply_internal
|
||||||
|
PDN_EDGE = -2
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Edges:
|
class Edges:
|
||||||
a: np.ndarray # int64 flat cell ids
|
a: np.ndarray # int64 flat cell ids
|
||||||
b: np.ndarray
|
b: np.ndarray
|
||||||
w: np.ndarray # conductance [S]
|
w: np.ndarray # conductance [S]
|
||||||
via_index: np.ndarray # int32; -1 = in-plane edge
|
via_index: np.ndarray # int32; -1 = in-plane edge,
|
||||||
|
# PDN_EDGE = supply attachment
|
||||||
dead_barrels: int = 0 # barrels spanning >=2 layers that found
|
dead_barrels: int = 0 # barrels spanning >=2 layers that found
|
||||||
# fill copper on fewer than 2 of them
|
# fill copper on fewer than 2 of them
|
||||||
|
|
||||||
@@ -79,6 +86,59 @@ class ViaReport:
|
|||||||
power_w: float # total barrel dissipation @ I_test
|
power_w: float # total barrel dissipation @ I_test
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SupplyReport:
|
||||||
|
"""One PDN supply after the solve. The delivered current is an
|
||||||
|
OUTCOME (Thevenin split), not an input."""
|
||||||
|
label: str
|
||||||
|
v_oc: float # open-circuit volts used in the solve
|
||||||
|
r_out_ohm: float
|
||||||
|
i_a: float # delivered current [A]
|
||||||
|
v_contact: float # mean volts over the contact cells
|
||||||
|
p_internal_w: float # dissipated inside r_out
|
||||||
|
part_currents: list = field(default_factory=list) # [(label, amps)]
|
||||||
|
v_eff: float = 0.0 # current-weighted contact volts: the
|
||||||
|
# potential the delivered power sees
|
||||||
|
# (= v_contact for ideal and bonded
|
||||||
|
# contacts); makes the pair-loss
|
||||||
|
# allocation sum EXACTLY to the
|
||||||
|
# copper dissipation
|
||||||
|
component: str = "" # display: terminal's owner hint
|
||||||
|
comment: str = "" # display: terminal's free-text note
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LoadReport:
|
||||||
|
"""One PDN load after the solve. The draw is prescribed; the contact
|
||||||
|
voltage is the outcome of interest."""
|
||||||
|
label: str
|
||||||
|
i_a: float # prescribed draw [A]
|
||||||
|
v_mean: float # mean volts over the contact cells
|
||||||
|
v_min: float # worst-case contact cell
|
||||||
|
p_w: float # i_a * v_mean (exact: injection and
|
||||||
|
# averaging weights coincide)
|
||||||
|
part_currents: list = field(default_factory=list) # [(label, amps)]
|
||||||
|
component: str = "" # display: terminal's owner hint
|
||||||
|
comment: str = "" # display: terminal's free-text note
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PairReport:
|
||||||
|
"""One (supply, load) pair: the effective COPPER resistance between
|
||||||
|
the two contacts (source internals excluded; injection patterns as
|
||||||
|
in the solve - uniform per cell, or the bonded lug) and the copper
|
||||||
|
loss attributed to the pair by PROPORTIONAL SHARING
|
||||||
|
(f_ij = I_i * I_j / I_component, P_ij = f_ij * (v_eff_i - v_mean_j)).
|
||||||
|
The attribution is a convention, not unique physics - but it sums
|
||||||
|
exactly to the total copper dissipation, and R is an operating-
|
||||||
|
point-independent property of the board."""
|
||||||
|
supply: str
|
||||||
|
load: str
|
||||||
|
r_ohm: float | None # None: no common copper path
|
||||||
|
i_share_a: float # attributed current [A]
|
||||||
|
p_w: float # attributed copper loss [W]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Result:
|
class Result:
|
||||||
R_ohm: float
|
R_ohm: float
|
||||||
@@ -106,6 +166,18 @@ class Result:
|
|||||||
skin_depth_um: float | None = None
|
skin_depth_um: float | None = None
|
||||||
rs_ratios: list[float] = field(default_factory=list) # R_AC/R_DC per layer
|
rs_ratios: list[float] = field(default_factory=list) # R_AC/R_DC per layer
|
||||||
timings: dict = field(default_factory=dict)
|
timings: dict = field(default_factory=dict)
|
||||||
|
# --- PDN mode (mode == "pdn"; classic solves leave these empty) ---
|
||||||
|
# R_ohm is NaN there (no single two-terminal R); i_test carries the
|
||||||
|
# summed load draw so %-of-total displays keep working; fields V/
|
||||||
|
# Jmag/Parea are in ABSOLUTE volts / real operating current
|
||||||
|
mode: str = "classic" # "classic" | "pdn"
|
||||||
|
supplies: list = field(default_factory=list) # [SupplyReport]
|
||||||
|
loads: list = field(default_factory=list) # [LoadReport]
|
||||||
|
P_loads: float = 0.0 # sum of load powers [W]
|
||||||
|
P_supply_internal: float = 0.0 # sum of r_out dissipation
|
||||||
|
v_nominal: float | None = None # default supply v_oc used
|
||||||
|
pairs: list = field(default_factory=list) # [PairReport], every
|
||||||
|
# supply x load
|
||||||
|
|
||||||
|
|
||||||
def _shifts2d():
|
def _shifts2d():
|
||||||
@@ -288,20 +360,116 @@ def connected_restrict(stack: RasterStack, e1: np.ndarray, e2: np.ndarray,
|
|||||||
return changed, len(common)
|
return changed, len(common)
|
||||||
|
|
||||||
|
|
||||||
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
|
def _pdn_keep_components(terminals: list, per_term: list) -> set:
|
||||||
|
"""The PDN component keep rule + its diagnostics, on the label sets
|
||||||
|
each terminal's contact touches (shared by the cell graph here and
|
||||||
|
the adaptive leaf graph). Keep components holding >= 1 supply AND
|
||||||
|
(>= 1 load OR >= 2 supplies); errors for unreachable / sheet-
|
||||||
|
spanning loads, notes for sheet-spanning supplies."""
|
||||||
|
n_sup: dict = {}
|
||||||
|
has_load: set = set()
|
||||||
|
for t, labs in zip(terminals, per_term):
|
||||||
|
if t.role == "supply":
|
||||||
|
for l in labs:
|
||||||
|
n_sup[l] = n_sup.get(l, 0) + 1
|
||||||
|
else:
|
||||||
|
has_load |= labs
|
||||||
|
kept = {l for l, c in n_sup.items() if l in has_load or c >= 2}
|
||||||
|
if not kept:
|
||||||
|
raise ConnectivityError(
|
||||||
|
"No copper component connects a supply to a load (not even "
|
||||||
|
"through vias). Check the layer selection, the terminal "
|
||||||
|
"definitions and that the fills are up to date."
|
||||||
|
)
|
||||||
|
for t, labs in zip(terminals, per_term):
|
||||||
|
if t.role != "load":
|
||||||
|
continue
|
||||||
|
kl = labs & kept
|
||||||
|
if not kl:
|
||||||
|
raise ConnectivityError(
|
||||||
|
f"Load '{t.label}' sits on copper that is not connected "
|
||||||
|
f"to any supply (not even through vias)."
|
||||||
|
)
|
||||||
|
if len(kl) > 1:
|
||||||
|
if t.bonded:
|
||||||
|
# the external bond IS the connection: the split
|
||||||
|
# between the sheets is well-defined through the lug
|
||||||
|
print(f"note: bonded load '{t.label}' spans {len(kl)} "
|
||||||
|
f"disconnected copper sheets; the split between "
|
||||||
|
f"them is set by its external bond")
|
||||||
|
continue
|
||||||
|
raise ConnectivityError(
|
||||||
|
f"Load '{t.label}' spans {len(kl)} disconnected copper "
|
||||||
|
f"sheets - the current split between them is undefined "
|
||||||
|
f"with per-cell injection. Include the layers/vias that "
|
||||||
|
f"join them, mark the load as bonded, or split it into "
|
||||||
|
f"one terminal per sheet."
|
||||||
|
)
|
||||||
|
for t, labs in zip(terminals, per_term):
|
||||||
|
if t.role == "supply" and len(labs & kept) > 1:
|
||||||
|
print(f"note: supply '{t.label}' feeds {len(labs & kept)} "
|
||||||
|
f"disconnected copper sheets; the split between them "
|
||||||
|
f"is set by its output resistance (Thevenin)")
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def connected_restrict_multi(stack: RasterStack, term_masks: list,
|
||||||
|
terminals: list, edges: Edges
|
||||||
|
) -> tuple[bool, int]:
|
||||||
|
"""PDN connectivity restriction: keep copper components holding
|
||||||
|
>= 1 supply AND (>= 1 load OR >= 2 supplies) - the second clause
|
||||||
|
keeps circulating-current paths between paralleled supplies with
|
||||||
|
unequal v_oc. A load on copper reachable from no supply is an
|
||||||
|
error; so is a load spanning several kept components (its uniform
|
||||||
|
injection cannot decide the split between disconnected sheets). A
|
||||||
|
load merely LOSING cells to dropped copper is fine: those cells
|
||||||
|
could not carry current anyway, the draw renormalizes over the
|
||||||
|
rest. Mutates stack.masks and the term_masks. Returns (changed,
|
||||||
|
n_kept_components)."""
|
||||||
|
n = stack.masks.size
|
||||||
|
graph = sparse.coo_matrix(
|
||||||
|
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(n, n))
|
||||||
|
_, labels = csgraph.connected_components(graph, directed=False)
|
||||||
|
labels3 = labels.reshape(stack.masks.shape)
|
||||||
|
|
||||||
|
per_term = [set(np.unique(labels3[m]).tolist()) if m.any() else set()
|
||||||
|
for m in term_masks]
|
||||||
|
kept = _pdn_keep_components(terminals, per_term)
|
||||||
|
keep = np.isin(labels3, sorted(kept)) & stack.masks
|
||||||
|
changed = bool((stack.masks & ~keep).any())
|
||||||
|
stack.masks &= keep
|
||||||
|
for t, m in zip(terminals, term_masks):
|
||||||
|
had = bool(m.any())
|
||||||
|
m &= keep
|
||||||
|
if t.role == "supply" and had and not m.any():
|
||||||
|
print(f"warning: supply '{t.label}' only touches copper not "
|
||||||
|
f"connected to any load - it delivers 0 A")
|
||||||
|
return changed, len(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None,
|
||||||
|
dirichlet_v: np.ndarray | None = None):
|
||||||
"""Weighted-Laplacian assembly with Dirichlet elimination.
|
"""Weighted-Laplacian assembly with Dirichlet elimination.
|
||||||
state: 0 off, 1 free, 2 Dirichlet@1V, 3 Dirichlet@0V.
|
state: 0 off, 1 free, 2 Dirichlet@1V, 3 Dirichlet@0V.
|
||||||
rhs_extra: per-flat-cell current injection [A] added for free cells."""
|
rhs_extra: per-flat-cell current injection [A] added for free cells.
|
||||||
|
dirichlet_v (PDN mode): per-node Dirichlet volts - any state >= 2
|
||||||
|
is then held at dirichlet_v[node] instead of the fixed 1 V / 0 V
|
||||||
|
pair, and the direct-connection short check is skipped (edges
|
||||||
|
between Dirichlet nodes simply conduct; their currents come out of
|
||||||
|
the post-solve edge fluxes). None keeps the classic behavior
|
||||||
|
bit-for-bit."""
|
||||||
n = state.size
|
n = state.size
|
||||||
sa, sb = state[edges.a], state[edges.b]
|
sa, sb = state[edges.a], state[edges.b]
|
||||||
short = ((sa == 2) & (sb == 3)) | ((sa == 3) & (sb == 2))
|
if dirichlet_v is None:
|
||||||
if short.any():
|
short = ((sa == 2) & (sb == 3)) | ((sa == 3) & (sb == 2))
|
||||||
n_via = int((edges.via_index[short] >= 0).sum())
|
if short.any():
|
||||||
raise ElectrodeError(
|
n_via = int((edges.via_index[short] >= 0).sum())
|
||||||
f"The terminals are directly connected by {int(short.sum())} "
|
raise ElectrodeError(
|
||||||
f"conductance(s) ({n_via} via barrel(s)) without any free copper "
|
f"The terminals are directly connected by "
|
||||||
f"in between - move the contacts apart."
|
f"{int(short.sum())} conductance(s) ({n_via} via "
|
||||||
)
|
f"barrel(s)) without any free copper in between - move "
|
||||||
|
f"the contacts apart."
|
||||||
|
)
|
||||||
|
|
||||||
free = state == 1
|
free = state == 1
|
||||||
n_free = int(free.sum())
|
n_free = int(free.sum())
|
||||||
@@ -318,10 +486,18 @@ def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
|
|||||||
fa, fb = sa == 1, sb == 1
|
fa, fb = sa == 1, sb == 1
|
||||||
np.add.at(diag, idx[edges.a[fa]], edges.w[fa])
|
np.add.at(diag, idx[edges.a[fa]], edges.w[fa])
|
||||||
np.add.at(diag, idx[edges.b[fb]], edges.w[fb])
|
np.add.at(diag, idx[edges.b[fb]], edges.w[fb])
|
||||||
r1a = fa & (sb == 2)
|
if dirichlet_v is None:
|
||||||
r1b = fb & (sa == 2)
|
r1a = fa & (sb == 2)
|
||||||
np.add.at(rhs, idx[edges.a[r1a]], edges.w[r1a])
|
r1b = fb & (sa == 2)
|
||||||
np.add.at(rhs, idx[edges.b[r1b]], edges.w[r1b])
|
np.add.at(rhs, idx[edges.a[r1a]], edges.w[r1a])
|
||||||
|
np.add.at(rhs, idx[edges.b[r1b]], edges.w[r1b])
|
||||||
|
else:
|
||||||
|
r1a = fa & (sb >= 2)
|
||||||
|
r1b = fb & (sa >= 2)
|
||||||
|
np.add.at(rhs, idx[edges.a[r1a]],
|
||||||
|
edges.w[r1a] * dirichlet_v[edges.b[r1a]])
|
||||||
|
np.add.at(rhs, idx[edges.b[r1b]],
|
||||||
|
edges.w[r1b] * dirichlet_v[edges.a[r1b]])
|
||||||
if rhs_extra is not None:
|
if rhs_extra is not None:
|
||||||
rhs += rhs_extra[free]
|
rhs += rhs_extra[free]
|
||||||
|
|
||||||
@@ -605,6 +781,71 @@ def _part_currents(parts, Ie, edges, e_flat, scale,
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _postprocess_fields(problem: Problem, stack: RasterStack, edges: Edges,
|
||||||
|
Vflat: np.ndarray, s: float, sigmas: list[float],
|
||||||
|
sigma_buildup: float):
|
||||||
|
"""Edge powers, per-layer dissipation, via reports and the V/J/P
|
||||||
|
display fields on the uniform grid - shared verbatim by the classic
|
||||||
|
and PDN solves. PDN appends virtual supply nodes after the grid
|
||||||
|
ids: everything here slices Vflat back to the grid (a no-op view
|
||||||
|
for classic) and selects in-plane edges by via_index == -1 / via
|
||||||
|
barrels by >= 0, so PDN_EDGE attachment edges stay out of the
|
||||||
|
copper fields and the via reports. Returns (Pe, Ie, P_layers,
|
||||||
|
P_vias, Parea, via_reports, V3, J3): Pe is s^2-scaled, Ie is at the
|
||||||
|
drive of Vflat (unit drive classic, absolute volts PDN); via report
|
||||||
|
currents are s-scaled here."""
|
||||||
|
L, ny, nx = stack.masks.shape
|
||||||
|
h_m = stack.h_nm * 1e-9
|
||||||
|
n_grid = stack.masks.size
|
||||||
|
|
||||||
|
# per-edge power @ I_test; distribute in-plane power to endpoint cells
|
||||||
|
Pe = edges.w * ((Vflat[edges.a] - Vflat[edges.b]) * s) ** 2
|
||||||
|
inplane = edges.via_index == -1
|
||||||
|
Pflat = np.zeros(n_grid)
|
||||||
|
np.add.at(Pflat, edges.a[inplane], 0.5 * Pe[inplane])
|
||||||
|
np.add.at(Pflat, edges.b[inplane], 0.5 * Pe[inplane])
|
||||||
|
Parea = Pflat.reshape(L, ny, nx) / (h_m * h_m)
|
||||||
|
Parea[~stack.masks] = np.nan
|
||||||
|
plane = ny * nx
|
||||||
|
P_layers = [float(Pflat[li * plane:(li + 1) * plane].sum())
|
||||||
|
for li in range(L)]
|
||||||
|
P_vias = float(Pe[edges.via_index >= 0].sum())
|
||||||
|
|
||||||
|
# via reports: max segment current + total power per via
|
||||||
|
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b])
|
||||||
|
via_reports = []
|
||||||
|
if problem.vias:
|
||||||
|
vidx = edges.via_index
|
||||||
|
for vi in np.unique(vidx[vidx >= 0]):
|
||||||
|
sel = vidx == vi
|
||||||
|
via = problem.vias[vi]
|
||||||
|
via_reports.append(ViaReport(
|
||||||
|
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
|
||||||
|
drill_mm=via.drill_nm * 1e-6,
|
||||||
|
current_a=float(np.abs(Ie[sel]).max()) * s,
|
||||||
|
power_w=float(Pe[sel].sum()),
|
||||||
|
))
|
||||||
|
via_reports.sort(key=lambda v: v.current_a, reverse=True)
|
||||||
|
|
||||||
|
# embedded potential + per-layer current density @ I_test; chain
|
||||||
|
# cells have no sheet faces in the model, so keep them out of the
|
||||||
|
# face computation and overlay their true 1D link density instead
|
||||||
|
V3 = np.full((L, ny, nx), np.nan)
|
||||||
|
V3[stack.masks] = Vflat[:n_grid].reshape(L, ny, nx)[stack.masks] * s
|
||||||
|
sheet = stack.masks if stack.chain is None \
|
||||||
|
else stack.masks & ~stack.chain
|
||||||
|
J3 = np.stack([
|
||||||
|
_face_current_density(
|
||||||
|
np.nan_to_num(V3[li]), sheet[li], sigmas[li],
|
||||||
|
h_m, problem.layers[li].thickness_nm * 1e-9,
|
||||||
|
sig2d=_sigma_2d(stack, li, sigmas[li], sigma_buildup),
|
||||||
|
rho=problem.rho_ohm_m)
|
||||||
|
for li in range(L)
|
||||||
|
])
|
||||||
|
overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
|
||||||
|
return Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3
|
||||||
|
|
||||||
|
|
||||||
def _conductance_params(problem: Problem, stack: RasterStack,
|
def _conductance_params(problem: Problem, stack: RasterStack,
|
||||||
freq_hz: float):
|
freq_hz: float):
|
||||||
"""Effective (possibly AC) sheet conductances per layer, Rs ratios,
|
"""Effective (possibly AC) sheet conductances per layer, Rs ratios,
|
||||||
@@ -711,18 +952,9 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
|
|||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
s = i_test * volts_per_amp # unit-drive volts -> volts @ I_test
|
s = i_test * volts_per_amp # unit-drive volts -> volts @ I_test
|
||||||
|
|
||||||
# per-edge power @ I_test; distribute in-plane power to endpoint cells
|
Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3 = \
|
||||||
Pe = edges.w * ((Vflat[edges.a] - Vflat[edges.b]) * s) ** 2
|
_postprocess_fields(problem, stack, edges, Vflat, s, sigmas,
|
||||||
inplane = edges.via_index < 0
|
sigma_buildup)
|
||||||
Pflat = np.zeros(Vflat.size)
|
|
||||||
np.add.at(Pflat, edges.a[inplane], 0.5 * Pe[inplane])
|
|
||||||
np.add.at(Pflat, edges.b[inplane], 0.5 * Pe[inplane])
|
|
||||||
Parea = Pflat.reshape(L, ny, nx) / (h_m * h_m)
|
|
||||||
Parea[~stack.masks] = np.nan
|
|
||||||
plane = ny * nx
|
|
||||||
P_layers = [float(Pflat[li * plane:(li + 1) * plane].sum())
|
|
||||||
for li in range(L)]
|
|
||||||
P_vias = float(Pe[~inplane].sum())
|
|
||||||
P_total = i_test ** 2 * R
|
P_total = i_test ** 2 * R
|
||||||
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
|
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
|
||||||
if not np.isfinite(balance) or balance > 1e-3:
|
if not np.isfinite(balance) or balance > 1e-3:
|
||||||
@@ -733,22 +965,6 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
|
|||||||
f"different grid size."
|
f"different grid size."
|
||||||
)
|
)
|
||||||
|
|
||||||
# via reports: max segment current + total power per via
|
|
||||||
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) # amps at unit drive
|
|
||||||
via_reports = []
|
|
||||||
if problem.vias:
|
|
||||||
vidx = edges.via_index
|
|
||||||
for vi in np.unique(vidx[vidx >= 0]):
|
|
||||||
sel = vidx == vi
|
|
||||||
via = problem.vias[vi]
|
|
||||||
via_reports.append(ViaReport(
|
|
||||||
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
|
|
||||||
drill_mm=via.drill_nm * 1e-6,
|
|
||||||
current_a=float(np.abs(Ie[sel]).max()) * s,
|
|
||||||
power_w=float(Pe[sel].sum()),
|
|
||||||
))
|
|
||||||
via_reports.sort(key=lambda v: v.current_a, reverse=True)
|
|
||||||
|
|
||||||
# per-injection-area currents
|
# per-injection-area currents
|
||||||
part_currents1 = _part_currents(
|
part_currents1 = _part_currents(
|
||||||
parts1 or [], Ie, edges, e1.ravel(), s, i_test,
|
parts1 or [], Ie, edges, e1.ravel(), s, i_test,
|
||||||
@@ -756,23 +972,6 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
|
|||||||
part_currents2 = _part_currents(
|
part_currents2 = _part_currents(
|
||||||
parts2 or [], Ie, edges, e2.ravel(), s, i_test,
|
parts2 or [], Ie, edges, e2.ravel(), s, i_test,
|
||||||
contact_model, int(e2.sum()))
|
contact_model, int(e2.sum()))
|
||||||
|
|
||||||
# embedded potential + per-layer current density @ I_test; chain
|
|
||||||
# cells have no sheet faces in the model, so keep them out of the
|
|
||||||
# face computation and overlay their true 1D link density instead
|
|
||||||
V3 = np.full((L, ny, nx), np.nan)
|
|
||||||
V3[stack.masks] = Vflat.reshape(L, ny, nx)[stack.masks] * s
|
|
||||||
sheet = stack.masks if stack.chain is None \
|
|
||||||
else stack.masks & ~stack.chain
|
|
||||||
J3 = np.stack([
|
|
||||||
_face_current_density(
|
|
||||||
np.nan_to_num(V3[li]), sheet[li], sigmas[li],
|
|
||||||
h_m, problem.layers[li].thickness_nm * 1e-9,
|
|
||||||
sig2d=_sigma_2d(stack, li, sigmas[li], sigma_buildup),
|
|
||||||
rho=problem.rho_ohm_m)
|
|
||||||
for li in range(L)
|
|
||||||
])
|
|
||||||
overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
|
|
||||||
timings["postprocess_s"] = time.perf_counter() - t0
|
timings["postprocess_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
return Result(
|
return Result(
|
||||||
@@ -790,3 +989,551 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
|
|||||||
rs_ratios=rs_ratios,
|
rs_ratios=rs_ratios,
|
||||||
timings=timings,
|
timings=timings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- PDN mode ---------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# N supplies + M loads instead of one driven terminal pair, solved in
|
||||||
|
# ABSOLUTE volts (no unit-drive rescale). Each supply is a Thevenin
|
||||||
|
# source: a virtual node held Dirichlet at v_oc, attached to its
|
||||||
|
# contact cells through 1/(r_out * n_cells) each (sum = 1/r_out) -
|
||||||
|
# because Dirichlet nodes are eliminated, virtual nodes never enter the
|
||||||
|
# matrix, they only shift the diagonal/RHS of their contact cells and
|
||||||
|
# the system stays SPD. r_out <= PDN_R_OUT_EPS degrades to a direct
|
||||||
|
# Dirichlet contact (the exact limit). Each load draws its prescribed
|
||||||
|
# current with uniform orthogonal injection (-I/n per cell), the same
|
||||||
|
# semantics as the classic "uniform" contact model. Supply currents are
|
||||||
|
# OUTCOMES (the Thevenin split); KCL makes them sum to the load draws.
|
||||||
|
|
||||||
|
def _label_terminals(terminals: list) -> None:
|
||||||
|
"""Assign S1/L1-style display tags to unlabeled terminals (in
|
||||||
|
definition order, per role)."""
|
||||||
|
ns = nl = 0
|
||||||
|
for t in terminals:
|
||||||
|
if t.role == "supply":
|
||||||
|
ns += 1
|
||||||
|
if not t.label:
|
||||||
|
t.label = f"S{ns}"
|
||||||
|
else:
|
||||||
|
nl += 1
|
||||||
|
if not t.label:
|
||||||
|
t.label = f"L{nl}"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_terminals(terminals: list) -> None:
|
||||||
|
for t in terminals:
|
||||||
|
if t.role not in ("supply", "load"):
|
||||||
|
raise ElectrodeError(
|
||||||
|
f"Terminal '{t.label}': unknown role '{t.role}' "
|
||||||
|
f"(expected 'supply' or 'load')."
|
||||||
|
)
|
||||||
|
if t.role == "load" and t.i_draw_a < 0:
|
||||||
|
raise ElectrodeError(
|
||||||
|
f"Load '{t.label}': i_draw_a must be >= 0 "
|
||||||
|
f"(got {t.i_draw_a:g})."
|
||||||
|
)
|
||||||
|
if t.role == "supply" and t.r_out_ohm < 0:
|
||||||
|
raise ElectrodeError(
|
||||||
|
f"Supply '{t.label}': r_out_ohm must be >= 0 "
|
||||||
|
f"(got {t.r_out_ohm:g})."
|
||||||
|
)
|
||||||
|
if not any(t.role == "supply" for t in terminals):
|
||||||
|
raise ElectrodeError("PDN mode needs at least one supply terminal.")
|
||||||
|
if not any(t.role == "load" for t in terminals):
|
||||||
|
raise ElectrodeError("PDN mode needs at least one load terminal.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Attach:
|
||||||
|
"""How one supply is wired into the extended graph."""
|
||||||
|
v_oc: float
|
||||||
|
r_out: float
|
||||||
|
nodes: np.ndarray # contact node ids (base space)
|
||||||
|
ideal: bool # r_out below eps: direct Dirichlet
|
||||||
|
e0: int = 0 # its attachment edges in edges_ext
|
||||||
|
e1: int = 0 # (empty slice for ideal supplies)
|
||||||
|
|
||||||
|
|
||||||
|
def _pdn_attach(terminals: list, term_nodes: list, state: np.ndarray,
|
||||||
|
edges: Edges, v_nominal: float):
|
||||||
|
"""Extend the copper graph with the PDN boundary conditions. state
|
||||||
|
is uint8 over the base node space (1 = copper); term_nodes carries
|
||||||
|
each terminal's contact node ids in that space (uniform grid: flat
|
||||||
|
cell ids; adaptive: leaf ids - contact cells are pinned fine there,
|
||||||
|
so nodes and cells are 1:1 and per-node injection equals per-cell).
|
||||||
|
|
||||||
|
A BONDED terminal shorts all its contact cells into one super-node:
|
||||||
|
`merge` maps every node id to its representative (identity outside
|
||||||
|
bonded terminals; None when no terminal is bonded). The caller
|
||||||
|
solves on merge-relabeled edges (member cells leave the system,
|
||||||
|
state 0) and afterwards scatters the potentials back with
|
||||||
|
Vflat = Vflat[merge], so all extraction runs on the ORIGINAL edge
|
||||||
|
endpoints where internal member-member edges carry exactly zero.
|
||||||
|
|
||||||
|
Returns (state_ext, dirichlet_v, inj, edges_ext, attaches, merge)
|
||||||
|
with virtual supply nodes appended after state.size."""
|
||||||
|
n_base = state.size
|
||||||
|
n_virt = sum(1 for t, nd in zip(terminals, term_nodes)
|
||||||
|
if t.role == "supply" and len(nd)
|
||||||
|
and t.r_out_ohm > config.PDN_R_OUT_EPS)
|
||||||
|
n_ext = n_base + n_virt
|
||||||
|
state_ext = np.zeros(n_ext, dtype=np.uint8)
|
||||||
|
state_ext[:n_base] = state
|
||||||
|
dirichlet_v = np.zeros(n_ext)
|
||||||
|
inj = np.zeros(n_ext)
|
||||||
|
merge = None
|
||||||
|
|
||||||
|
def bond(nodes):
|
||||||
|
"""Short the cells to nodes[0]; members leave the system."""
|
||||||
|
nonlocal merge
|
||||||
|
if merge is None:
|
||||||
|
merge = np.arange(n_ext, dtype=np.int64)
|
||||||
|
rep = int(nodes[0])
|
||||||
|
merge[nodes] = rep
|
||||||
|
state_ext[nodes] = 0
|
||||||
|
return rep
|
||||||
|
|
||||||
|
aa = [edges.a]
|
||||||
|
bb = [edges.b]
|
||||||
|
ww = [edges.w]
|
||||||
|
vv = [edges.via_index]
|
||||||
|
attaches: list = []
|
||||||
|
nv = 0
|
||||||
|
e_next = len(edges.a)
|
||||||
|
for t, nodes in zip(terminals, term_nodes):
|
||||||
|
if t.role != "supply":
|
||||||
|
attaches.append(None)
|
||||||
|
if len(nodes):
|
||||||
|
if t.bonded:
|
||||||
|
rep = bond(nodes)
|
||||||
|
state_ext[rep] = 1
|
||||||
|
inj[rep] -= t.i_draw_a # whole draw at the lug
|
||||||
|
else:
|
||||||
|
inj[nodes] -= t.i_draw_a / len(nodes)
|
||||||
|
continue
|
||||||
|
v = v_nominal if t.v_oc is None else t.v_oc
|
||||||
|
at = _Attach(v_oc=v, r_out=t.r_out_ohm, nodes=nodes,
|
||||||
|
ideal=t.r_out_ohm <= config.PDN_R_OUT_EPS)
|
||||||
|
attaches.append(at)
|
||||||
|
if len(nodes) == 0:
|
||||||
|
continue # restricted away: reports 0 A
|
||||||
|
if t.bonded:
|
||||||
|
rep = bond(nodes)
|
||||||
|
if at.ideal:
|
||||||
|
state_ext[rep] = 2
|
||||||
|
dirichlet_v[rep] = v
|
||||||
|
else:
|
||||||
|
state_ext[rep] = 1
|
||||||
|
vid = n_base + nv
|
||||||
|
nv += 1
|
||||||
|
state_ext[vid] = 2
|
||||||
|
dirichlet_v[vid] = v
|
||||||
|
# the whole r_out in series with the equipotential lug
|
||||||
|
aa.append(np.array([vid], dtype=np.int64))
|
||||||
|
bb.append(np.array([rep], dtype=np.int64))
|
||||||
|
ww.append(np.array([1.0 / t.r_out_ohm]))
|
||||||
|
vv.append(np.array([PDN_EDGE], dtype=np.int32))
|
||||||
|
at.e0, at.e1 = e_next, e_next + 1
|
||||||
|
e_next += 1
|
||||||
|
elif at.ideal:
|
||||||
|
state_ext[nodes] = 2
|
||||||
|
dirichlet_v[nodes] = v
|
||||||
|
else:
|
||||||
|
vid = n_base + nv
|
||||||
|
nv += 1
|
||||||
|
state_ext[vid] = 2
|
||||||
|
dirichlet_v[vid] = v
|
||||||
|
k = len(nodes)
|
||||||
|
# oriented virtual -> cell so Ie = w * (v_oc - V_cell) is
|
||||||
|
# the delivered current, positive out of the supply
|
||||||
|
aa.append(np.full(k, vid, dtype=np.int64))
|
||||||
|
bb.append(nodes.astype(np.int64))
|
||||||
|
ww.append(np.full(k, 1.0 / (t.r_out_ohm * k)))
|
||||||
|
vv.append(np.full(k, PDN_EDGE, dtype=np.int32))
|
||||||
|
at.e0, at.e1 = e_next, e_next + k
|
||||||
|
e_next += k
|
||||||
|
edges_ext = Edges(a=np.concatenate(aa), b=np.concatenate(bb),
|
||||||
|
w=np.concatenate(ww), via_index=np.concatenate(vv),
|
||||||
|
dead_barrels=edges.dead_barrels)
|
||||||
|
return state_ext, dirichlet_v, inj, edges_ext, attaches, merge
|
||||||
|
|
||||||
|
|
||||||
|
def _pdn_solve_edges(edges_ext: Edges, merge) -> Edges:
|
||||||
|
"""The edge set the linear system is assembled from: bonded
|
||||||
|
terminals' member cells relabeled to their representative (edge
|
||||||
|
ORDER and COUNT are preserved - only endpoints move; internal
|
||||||
|
edges become self-loops, which cancel exactly in the COO
|
||||||
|
assembly). Identity when nothing is bonded."""
|
||||||
|
if merge is None:
|
||||||
|
return edges_ext
|
||||||
|
return Edges(a=merge[edges_ext.a], b=merge[edges_ext.b],
|
||||||
|
w=edges_ext.w, via_index=edges_ext.via_index,
|
||||||
|
dead_barrels=edges_ext.dead_barrels)
|
||||||
|
|
||||||
|
|
||||||
|
def _pdn_extract(terminals: list, term_nodes: list, attaches: list,
|
||||||
|
Vflat: np.ndarray, Ie: np.ndarray, edges_ext: Edges,
|
||||||
|
term_part_nodes: list):
|
||||||
|
"""Per-supply delivered currents and per-load voltages/powers from
|
||||||
|
the solved potentials, shared by the uniform-grid and adaptive PDN
|
||||||
|
paths (Ie must satisfy KCL - the corrected currents on the adaptive
|
||||||
|
path; bonded terminals' Vflat already scattered back, so their
|
||||||
|
internal edges carry exactly zero). Returns (supplies, loads)."""
|
||||||
|
n_ext = Vflat.size
|
||||||
|
copper = edges_ext.via_index != PDN_EDGE
|
||||||
|
|
||||||
|
def part_flux_out(pn):
|
||||||
|
"""Oriented copper-edge flux out of a part's cells: the part's
|
||||||
|
boundary current (same-terminal internal edges carry zero for
|
||||||
|
Dirichlet and bonded contacts; attachment edges excluded so a
|
||||||
|
bonded supply's lug edge is not double-counted)."""
|
||||||
|
pm = np.zeros(n_ext, dtype=bool)
|
||||||
|
pm[pn] = True
|
||||||
|
return float(Ie[pm[edges_ext.a] & copper].sum()
|
||||||
|
- Ie[pm[edges_ext.b] & copper].sum())
|
||||||
|
|
||||||
|
supplies: list = []
|
||||||
|
loads: list = []
|
||||||
|
for t, nodes, at, pnodes in zip(terminals, term_nodes, attaches,
|
||||||
|
term_part_nodes):
|
||||||
|
if t.role == "supply":
|
||||||
|
if len(at.nodes) == 0:
|
||||||
|
supplies.append(SupplyReport(
|
||||||
|
label=t.label, v_oc=at.v_oc, r_out_ohm=at.r_out,
|
||||||
|
i_a=0.0, v_contact=at.v_oc, p_internal_w=0.0,
|
||||||
|
part_currents=[(pl, 0.0) for pl, _ in pnodes],
|
||||||
|
v_eff=at.v_oc, component=t.component,
|
||||||
|
comment=t.comment))
|
||||||
|
continue
|
||||||
|
if at.ideal:
|
||||||
|
# exact discrete flux out of the Dirichlet contact
|
||||||
|
# (edges inside the contact cancel a-side vs b-side)
|
||||||
|
member = np.zeros(n_ext, dtype=bool)
|
||||||
|
member[at.nodes] = True
|
||||||
|
i_a = float(Ie[member[edges_ext.a] & copper].sum()
|
||||||
|
- Ie[member[edges_ext.b] & copper].sum())
|
||||||
|
v_contact = at.v_oc
|
||||||
|
v_eff = at.v_oc
|
||||||
|
p_int = 0.0
|
||||||
|
pcs = [(pl, part_flux_out(pn)) for pl, pn in pnodes]
|
||||||
|
else:
|
||||||
|
sl = slice(at.e0, at.e1)
|
||||||
|
i_a = float(Ie[sl].sum())
|
||||||
|
v_contact = float(Vflat[at.nodes].mean())
|
||||||
|
dv = Vflat[edges_ext.a[sl]] - Vflat[edges_ext.b[sl]]
|
||||||
|
p_int = float((edges_ext.w[sl] * dv * dv).sum())
|
||||||
|
if t.bonded:
|
||||||
|
# one lug edge carries the total; the per-part
|
||||||
|
# split is the copper boundary flux (an outcome)
|
||||||
|
v_eff = v_contact # equipotential lug
|
||||||
|
pcs = [(pl, part_flux_out(pn)) for pl, pn in pnodes]
|
||||||
|
else:
|
||||||
|
cells = edges_ext.b[sl]
|
||||||
|
# the potential the delivered power actually sees:
|
||||||
|
# per-cell currents weight their cell potentials
|
||||||
|
v_eff = (float((Ie[sl] * Vflat[cells]).sum() / i_a)
|
||||||
|
if abs(i_a) > 1e-300 else v_contact)
|
||||||
|
pcs = []
|
||||||
|
for pl, pn in pnodes:
|
||||||
|
pm = np.zeros(n_ext, dtype=bool)
|
||||||
|
pm[pn] = True
|
||||||
|
pcs.append((pl, float(Ie[sl][pm[cells]].sum())))
|
||||||
|
supplies.append(SupplyReport(
|
||||||
|
label=t.label, v_oc=at.v_oc, r_out_ohm=at.r_out,
|
||||||
|
i_a=i_a, v_contact=v_contact, p_internal_w=p_int,
|
||||||
|
part_currents=pcs, v_eff=v_eff,
|
||||||
|
component=t.component, comment=t.comment))
|
||||||
|
else:
|
||||||
|
v = Vflat[nodes]
|
||||||
|
n_k = len(nodes)
|
||||||
|
if t.bonded:
|
||||||
|
# split by the network through the external bond
|
||||||
|
pcs = [(pl, -part_flux_out(pn)) for pl, pn in pnodes]
|
||||||
|
else:
|
||||||
|
pcs = [(pl, t.i_draw_a * len(pn) / max(n_k, 1))
|
||||||
|
for pl, pn in pnodes]
|
||||||
|
loads.append(LoadReport(
|
||||||
|
label=t.label, i_a=t.i_draw_a,
|
||||||
|
v_mean=float(v.mean()), v_min=float(v.min()),
|
||||||
|
p_w=t.i_draw_a * float(v.mean()), part_currents=pcs,
|
||||||
|
component=t.component, comment=t.comment))
|
||||||
|
return supplies, loads
|
||||||
|
|
||||||
|
|
||||||
|
def _pdn_balance(supplies: list, loads: list, P_layers: list,
|
||||||
|
P_vias: float) -> tuple[float, float, float, float, float]:
|
||||||
|
"""Generalized power balance (Tellegen): source power = copper
|
||||||
|
dissipation + R_out dissipation + load power, exactly, independent
|
||||||
|
of the voltage reference (supply and load currents sum equal).
|
||||||
|
Returns (balance_rel, mismatch_rel, i_sup, i_loads, p_loads_rout)
|
||||||
|
or raises SolverError; a zero-power probe run (no draws, equal
|
||||||
|
v_oc) has nothing to balance and reports 0."""
|
||||||
|
i_loads = sum(l.i_a for l in loads)
|
||||||
|
i_sup = sum(s_.i_a for s_ in supplies)
|
||||||
|
p_rout = sum(s_.p_internal_w for s_ in supplies)
|
||||||
|
p_loads = sum(l.p_w for l in loads)
|
||||||
|
p_src = sum(s_.v_oc * s_.i_a for s_ in supplies)
|
||||||
|
# gross scale, not |net|: with circulating currents between supplies
|
||||||
|
# the net source power nearly cancels while watts really flow - the
|
||||||
|
# residual must be judged against what flows, or the check trips on
|
||||||
|
# pure floating-point cancellation
|
||||||
|
p_gross = sum(abs(s_.v_oc * s_.i_a) for s_ in supplies)
|
||||||
|
p_sink = sum(P_layers) + P_vias + p_rout + p_loads
|
||||||
|
vocs = [s_.v_oc for s_ in supplies]
|
||||||
|
if i_loads == 0.0 and max(vocs) == min(vocs):
|
||||||
|
balance = 0.0
|
||||||
|
else:
|
||||||
|
balance = abs(p_src - p_sink) / max(p_gross, 1e-300)
|
||||||
|
if not np.isfinite(balance) or balance > 1e-3:
|
||||||
|
raise SolverError(
|
||||||
|
f"Inconsistent PDN solve: power-balance error "
|
||||||
|
f"{balance:.2e} (sources {p_src:.6g} W vs copper + "
|
||||||
|
f"R_out + loads {p_sink:.6g} W). The result is not "
|
||||||
|
f"trustworthy - try a different grid size."
|
||||||
|
)
|
||||||
|
i_scale = max(abs(i_loads),
|
||||||
|
max((abs(s_.i_a) for s_ in supplies), default=0.0),
|
||||||
|
1e-300)
|
||||||
|
mismatch = abs(i_sup - i_loads) / i_scale
|
||||||
|
return balance, mismatch, i_sup, i_loads, p_loads
|
||||||
|
|
||||||
|
|
||||||
|
def _pdn_pairs(terminals: list, term_nodes: list, attaches: list,
|
||||||
|
merge, state_base: np.ndarray, edges: Edges,
|
||||||
|
supplies: list, loads: list, make_solver) -> list:
|
||||||
|
"""Effective copper resistance between every supply and every load,
|
||||||
|
plus the proportional-sharing loss allocation (see PairReport).
|
||||||
|
|
||||||
|
The pair network is the COPPER alone - attachment resistances and
|
||||||
|
Thevenin sources stripped. Contact patterns mirror the solve's
|
||||||
|
models: loads and resistive supplies inject uniformly per cell,
|
||||||
|
bonded terminals and ideal supplies are equipotential super-nodes.
|
||||||
|
R_ij = (p_i - p_j)^T L_g^{-1} (p_i - p_j) costs ONE extra solve per
|
||||||
|
terminal (same factorization; one node per connected component is
|
||||||
|
grounded - the balanced quadratic form is ground-independent).
|
||||||
|
Pairs without a common copper component get r_ohm None and no
|
||||||
|
allocation; the allocation splits each component's copper loss by
|
||||||
|
f_ij = I_i * I_j / I_loads_of_that_component, which sums exactly
|
||||||
|
to the total copper dissipation.
|
||||||
|
|
||||||
|
make_solver(state_g, dirichlet_v, edges_pm, pmerge) -> solve(inj)
|
||||||
|
-> V lets the adaptive path plug in its deferred-correction loop
|
||||||
|
(pmerge is the pair system's own node-merge map, or None)."""
|
||||||
|
n_base = state_base.size
|
||||||
|
idx0 = np.arange(n_base, dtype=np.int64)
|
||||||
|
# pair-system merge: the solve's bonded lugs plus every ideal
|
||||||
|
# supply contact (a Dirichlet region is equipotential too)
|
||||||
|
pmerge = idx0.copy() if merge is None else merge[:n_base].copy()
|
||||||
|
for t, at, nodes in zip(terminals, attaches, term_nodes):
|
||||||
|
if (t.role == "supply" and at is not None and at.ideal
|
||||||
|
and not t.bonded and len(nodes)):
|
||||||
|
pmerge[nodes] = pmerge[int(nodes[0])]
|
||||||
|
if bool((pmerge == idx0).all()):
|
||||||
|
pmerge = None
|
||||||
|
|
||||||
|
state_g = state_base.copy()
|
||||||
|
if pmerge is not None:
|
||||||
|
state_g[pmerge != idx0] = 0
|
||||||
|
edges_pm = Edges(a=pmerge[edges.a], b=pmerge[edges.b],
|
||||||
|
w=edges.w, via_index=edges.via_index,
|
||||||
|
dead_barrels=edges.dead_barrels)
|
||||||
|
else:
|
||||||
|
edges_pm = edges
|
||||||
|
|
||||||
|
pats = []
|
||||||
|
for t, at, nodes in zip(terminals, attaches, term_nodes):
|
||||||
|
if len(nodes) == 0:
|
||||||
|
pats.append(None)
|
||||||
|
continue
|
||||||
|
p = np.zeros(n_base)
|
||||||
|
if (t.bonded or (t.role == "supply" and at is not None
|
||||||
|
and at.ideal)):
|
||||||
|
rep = (int(pmerge[nodes[0]]) if pmerge is not None
|
||||||
|
else int(nodes[0]))
|
||||||
|
p[rep] = 1.0
|
||||||
|
else:
|
||||||
|
p[nodes] = 1.0 / len(nodes)
|
||||||
|
pats.append(p)
|
||||||
|
|
||||||
|
graph = sparse.coo_matrix(
|
||||||
|
(np.ones(len(edges_pm.a)), (edges_pm.a, edges_pm.b)),
|
||||||
|
shape=(n_base, n_base))
|
||||||
|
_, labels = csgraph.connected_components(graph, directed=False)
|
||||||
|
tcomp = []
|
||||||
|
for p in pats:
|
||||||
|
if p is None:
|
||||||
|
tcomp.append(None)
|
||||||
|
continue
|
||||||
|
cs = set(labels[np.flatnonzero(p)].tolist())
|
||||||
|
# a terminal spread over several components has no single
|
||||||
|
# pair resistance - its pairs report "no path"
|
||||||
|
tcomp.append(cs.pop() if len(cs) == 1 else None)
|
||||||
|
|
||||||
|
dv = np.zeros(n_base)
|
||||||
|
grounds: set = set()
|
||||||
|
for c, p in zip(tcomp, pats):
|
||||||
|
if c is not None and c not in grounds:
|
||||||
|
state_g[int(np.flatnonzero(p)[0])] = 2
|
||||||
|
grounds.add(c)
|
||||||
|
if not grounds:
|
||||||
|
return []
|
||||||
|
|
||||||
|
progress.stage("source-sink pair resistances ...")
|
||||||
|
solve = make_solver(state_g, dv, edges_pm, pmerge)
|
||||||
|
us = [solve(p) if p is not None and c is not None else None
|
||||||
|
for p, c in zip(pats, tcomp)]
|
||||||
|
|
||||||
|
# per-component load current: proportional sharing never crosses a
|
||||||
|
# copper gap (nothing flows between disconnected sheets)
|
||||||
|
comp_load_i: dict = {}
|
||||||
|
lidx = -1
|
||||||
|
for j, tj in enumerate(terminals):
|
||||||
|
if tj.role != "load":
|
||||||
|
continue
|
||||||
|
lidx += 1
|
||||||
|
if tcomp[j] is not None:
|
||||||
|
comp_load_i[tcomp[j]] = (comp_load_i.get(tcomp[j], 0.0)
|
||||||
|
+ loads[lidx].i_a)
|
||||||
|
|
||||||
|
rows: list = []
|
||||||
|
sidx = -1
|
||||||
|
for i, ti in enumerate(terminals):
|
||||||
|
if ti.role != "supply":
|
||||||
|
continue
|
||||||
|
sidx += 1
|
||||||
|
s = supplies[sidx]
|
||||||
|
lidx = -1
|
||||||
|
for j, tj in enumerate(terminals):
|
||||||
|
if tj.role != "load":
|
||||||
|
continue
|
||||||
|
lidx += 1
|
||||||
|
ld = loads[lidx]
|
||||||
|
same = tcomp[i] is not None and tcomp[i] == tcomp[j]
|
||||||
|
r = None
|
||||||
|
f = 0.0
|
||||||
|
if same:
|
||||||
|
r = float(pats[i] @ us[i] + pats[j] @ us[j]
|
||||||
|
- 2.0 * (pats[i] @ us[j]))
|
||||||
|
i_tot = comp_load_i.get(tcomp[i], 0.0)
|
||||||
|
if i_tot > 0.0:
|
||||||
|
f = s.i_a * ld.i_a / i_tot
|
||||||
|
rows.append(PairReport(
|
||||||
|
supply=s.label, load=ld.label, r_ohm=r,
|
||||||
|
i_share_a=f, p_w=f * (s.v_eff - ld.v_mean)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def run_solve_pdn(problem: Problem, stack: RasterStack, term_masks: list,
|
||||||
|
term_parts: list, freq_hz: float = 0.0,
|
||||||
|
v_nominal: float | None = None) -> Result:
|
||||||
|
"""PDN solve on the uniform grid (adaptive dispatch on top, like
|
||||||
|
run_solve). term_masks/term_parts come from raster.terminal_masks /
|
||||||
|
terminal_partition, aligned with problem.terminals. Contact models
|
||||||
|
are fixed: Thevenin supplies, uniform-injection loads."""
|
||||||
|
if v_nominal is None:
|
||||||
|
v_nominal = config.PDN_V_NOMINAL
|
||||||
|
terminals = problem.terminals
|
||||||
|
_label_terminals(terminals)
|
||||||
|
_validate_terminals(terminals)
|
||||||
|
if config.ADAPTIVE_CELLS:
|
||||||
|
from . import adaptive
|
||||||
|
return adaptive.run_solve_adaptive_pdn(problem, stack, term_masks,
|
||||||
|
term_parts, freq_hz,
|
||||||
|
v_nominal)
|
||||||
|
|
||||||
|
timings = {}
|
||||||
|
sigmas, rs_ratios, via_factor, sigma_buildup = \
|
||||||
|
_conductance_params(problem, stack, freq_hz)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
|
||||||
|
changed, _n_kept = connected_restrict_multi(stack, term_masks,
|
||||||
|
terminals, edges)
|
||||||
|
if changed:
|
||||||
|
edges = build_edges(stack, problem, sigmas, via_factor,
|
||||||
|
sigma_buildup)
|
||||||
|
if edges.dead_barrels:
|
||||||
|
print(f"warning: {edges.dead_barrels} via/pad barrel(s) found fill "
|
||||||
|
f"copper on fewer than 2 layers and carry no current (pad "
|
||||||
|
f"copper is not modeled; a finer grid may pick up thermal "
|
||||||
|
f"spokes)")
|
||||||
|
if stack.buildup is not None:
|
||||||
|
stack.buildup &= stack.masks
|
||||||
|
if stack.chain is not None:
|
||||||
|
stack.chain &= stack.masks
|
||||||
|
for t, parts in zip(terminals, term_parts):
|
||||||
|
for label, m in parts:
|
||||||
|
had = bool(m.any())
|
||||||
|
m &= stack.masks
|
||||||
|
if had and not m.any():
|
||||||
|
print(f"warning: contact part '{label}' of {t.role} "
|
||||||
|
f"'{t.label}' only touches disconnected copper - "
|
||||||
|
f"it carries no current")
|
||||||
|
timings["edges_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
state = np.zeros(stack.masks.size, dtype=np.uint8)
|
||||||
|
state[stack.masks.ravel()] = 1
|
||||||
|
term_nodes = [np.flatnonzero(m.ravel()) for m in term_masks]
|
||||||
|
state_base = state.copy() # copper-only state for _pdn_pairs
|
||||||
|
state, dirichlet_v, inj, edges_ext, attaches, merge = _pdn_attach(
|
||||||
|
terminals, term_nodes, state, edges, v_nominal)
|
||||||
|
A, rhs, _ = _assemble(state, _pdn_solve_edges(edges_ext, merge),
|
||||||
|
inj, dirichlet_v)
|
||||||
|
x, info = solve_system(A, rhs)
|
||||||
|
Vflat = np.where(state >= 2, dirichlet_v, 0.0)
|
||||||
|
Vflat[state == 1] = x
|
||||||
|
if merge is not None:
|
||||||
|
Vflat = Vflat[merge] # bonded members read their lug
|
||||||
|
timings["solve_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3 = \
|
||||||
|
_postprocess_fields(problem, stack, edges_ext, Vflat, 1.0, sigmas,
|
||||||
|
sigma_buildup)
|
||||||
|
term_part_nodes = [
|
||||||
|
[(pl, np.flatnonzero(m.ravel())) for pl, m in parts]
|
||||||
|
for parts in term_parts]
|
||||||
|
supplies, loads = _pdn_extract(terminals, term_nodes, attaches, Vflat,
|
||||||
|
Ie, edges_ext, term_part_nodes)
|
||||||
|
balance, mismatch, i_sup, i_loads, p_loads = _pdn_balance(
|
||||||
|
supplies, loads, P_layers, P_vias)
|
||||||
|
timings["postprocess_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
|
def _pair_solver(state_g, dv, edges_pm, pmerge):
|
||||||
|
A2, rhs0p, _ = _assemble(state_g, edges_pm, None, dv)
|
||||||
|
ps2 = PreparedSolver(A2)
|
||||||
|
freeg = state_g == 1
|
||||||
|
|
||||||
|
def slv(inj_p):
|
||||||
|
x2, _ = ps2.solve(rhs0p + inj_p[freeg])
|
||||||
|
V = np.where(state_g >= 2, dv, 0.0)
|
||||||
|
V[freeg] = x2
|
||||||
|
return V
|
||||||
|
return slv
|
||||||
|
|
||||||
|
pairs = _pdn_pairs(terminals, term_nodes, attaches, merge,
|
||||||
|
state_base, edges, supplies, loads, _pair_solver)
|
||||||
|
timings["pairs_s"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
return Result(
|
||||||
|
R_ohm=float("nan"), i_test=i_loads, V=V3, Jmag=J3, Parea=Parea,
|
||||||
|
layer_names=list(stack.layer_names),
|
||||||
|
P_total=float(sum(P_layers) + P_vias),
|
||||||
|
P_layers=P_layers, P_vias=P_vias,
|
||||||
|
power_balance_rel=balance, via_reports=via_reports,
|
||||||
|
I1_a=i_sup, I2_a=i_loads, mismatch_rel=mismatch,
|
||||||
|
n_free=info.n_unknowns, solve_info=info,
|
||||||
|
contact_model="pdn",
|
||||||
|
freq_hz=freq_hz,
|
||||||
|
skin_depth_um=(skin.skin_depth_m(freq_hz, problem.rho_ohm_m) * 1e6
|
||||||
|
if freq_hz > 0 else None),
|
||||||
|
rs_ratios=rs_ratios,
|
||||||
|
timings=timings,
|
||||||
|
mode="pdn", supplies=supplies, loads=loads,
|
||||||
|
P_loads=p_loads,
|
||||||
|
P_supply_internal=sum(s_.p_internal_w for s_ in supplies),
|
||||||
|
v_nominal=v_nominal, pairs=pairs,
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,19 +16,25 @@ from pathlib import Path
|
|||||||
from . import config, pipeline, progress
|
from . import config, pipeline, progress
|
||||||
from .errors import UserFacingError
|
from .errors import UserFacingError
|
||||||
from .geometry import load_problem
|
from .geometry import load_problem
|
||||||
from .skin import parse_frequency
|
from .skin import parse_engineering, parse_frequency
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument("dump", type=Path, help="geometry_dump.json from a plugin run")
|
ap.add_argument("dump", type=Path, help="geometry_dump.json from a plugin run")
|
||||||
ap.add_argument("--current", type=float, default=None,
|
ap.add_argument("--current", type=parse_engineering, default=None,
|
||||||
help="test current [A] (default: config TEST_CURRENT_A)")
|
help="test current [A], SI suffixes ok (500m = 0.5) "
|
||||||
ap.add_argument("--freq", type=parse_frequency, default=0.0,
|
"(default: config TEST_CURRENT_A)")
|
||||||
|
ap.add_argument("--config", type=Path, default=None, metavar="JSON",
|
||||||
|
help="fill_res_config.json whose run parameters act "
|
||||||
|
"as defaults under the explicit flags here. Only "
|
||||||
|
"re-solve parameters apply - the dump already "
|
||||||
|
"bakes the geometry and physics")
|
||||||
|
ap.add_argument("--freq", type=parse_frequency, default=None,
|
||||||
help="frequency, e.g. 142k or 1.5M (default: DC). "
|
help="frequency, e.g. 142k or 1.5M (default: DC). "
|
||||||
"Skin resistance only, a lower bound - not AC "
|
"Skin resistance only, a lower bound - not AC "
|
||||||
"impedance (no proximity, no inductance)")
|
"impedance (no proximity, no inductance)")
|
||||||
ap.add_argument("--cell-um", type=float, default=None,
|
ap.add_argument("--cell-um", type=parse_engineering, default=None,
|
||||||
help="force grid cell size [um]")
|
help="force grid cell size [um]")
|
||||||
ap.add_argument("--layers", type=str, default=None,
|
ap.add_argument("--layers", type=str, default=None,
|
||||||
help="comma-separated subset of layers to include")
|
help="comma-separated subset of layers to include")
|
||||||
@@ -37,7 +43,12 @@ def main(argv=None) -> int:
|
|||||||
ap.add_argument("--no-show", action="store_true",
|
ap.add_argument("--no-show", action="store_true",
|
||||||
help="save PNGs only, no windows")
|
help="save PNGs only, no windows")
|
||||||
ap.add_argument("--contact-model", choices=["uniform", "equipotential"],
|
ap.add_argument("--contact-model", choices=["uniform", "equipotential"],
|
||||||
default=None, help="contact model (default: config)")
|
default=None, help="contact model (default: config); "
|
||||||
|
"ignored for PDN dumps (models fixed there)")
|
||||||
|
ap.add_argument("--v-nominal", type=parse_engineering, default=None,
|
||||||
|
help="PDN dumps: default supply open-circuit voltage "
|
||||||
|
"[V] (default: config PDN_V_NOMINAL); supplies "
|
||||||
|
"with their own v_oc keep it")
|
||||||
ap.add_argument("--strip-buildup", action="store_true",
|
ap.add_argument("--strip-buildup", action="store_true",
|
||||||
help="ignore solder buildup stored in the dump")
|
help="ignore solder buildup stored in the dump")
|
||||||
ap.add_argument("--uncapped", action="store_true",
|
ap.add_argument("--uncapped", action="store_true",
|
||||||
@@ -62,6 +73,32 @@ def main(argv=None) -> int:
|
|||||||
"uniform reference grid")
|
"uniform reference grid")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
if args.config is not None:
|
||||||
|
from .configfile import load_config
|
||||||
|
try:
|
||||||
|
cfg = load_config(args.config)
|
||||||
|
except UserFacingError as e:
|
||||||
|
print(f"ERROR: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
# config file values fill in only where no explicit flag was
|
||||||
|
# given (CLI wins); geometry/physics stay as baked into the dump
|
||||||
|
if args.current is None and cfg.current_a is not None:
|
||||||
|
args.current = cfg.current_a
|
||||||
|
if args.freq is None and cfg.freq_hz is not None:
|
||||||
|
args.freq = cfg.freq_hz
|
||||||
|
if args.cell_um is None and cfg.cell_um_given:
|
||||||
|
args.cell_um = cfg.cell_um
|
||||||
|
if args.adaptive is None and cfg.adaptive is not None:
|
||||||
|
args.adaptive = cfg.adaptive
|
||||||
|
if args.contact_model is None and cfg.contact_model is not None:
|
||||||
|
args.contact_model = cfg.contact_model
|
||||||
|
if args.v_nominal is None and cfg.v_nominal is not None:
|
||||||
|
args.v_nominal = cfg.v_nominal
|
||||||
|
if args.layers is None and cfg.layers:
|
||||||
|
args.layers = ",".join(cfg.layers)
|
||||||
|
if args.freq is None:
|
||||||
|
args.freq = 0.0
|
||||||
|
|
||||||
if args.cell_um is not None:
|
if args.cell_um is not None:
|
||||||
config.CELL_UM_OVERRIDE = args.cell_um
|
config.CELL_UM_OVERRIDE = args.cell_um
|
||||||
if args.no_show:
|
if args.no_show:
|
||||||
@@ -93,9 +130,13 @@ def main(argv=None) -> int:
|
|||||||
if args.progress:
|
if args.progress:
|
||||||
progress.start()
|
progress.start()
|
||||||
try:
|
try:
|
||||||
|
if problem.terminals:
|
||||||
|
print(f"PDN dump: {len(problem.terminals)} terminals "
|
||||||
|
f"(supplies/loads from the dump; --current is ignored)")
|
||||||
pipeline.run(problem, outdir, show=not args.no_show,
|
pipeline.run(problem, outdir, show=not args.no_show,
|
||||||
i_test=args.current, freq_hz=args.freq,
|
i_test=args.current, freq_hz=args.freq,
|
||||||
contact_model=args.contact_model)
|
contact_model=args.contact_model,
|
||||||
|
v_nominal=args.v_nominal)
|
||||||
except progress.Cancelled:
|
except progress.Cancelled:
|
||||||
print("cancelled")
|
print("cancelled")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://go.kicad.org/pcm/schemas/v2",
|
"$schema": "https://go.kicad.org/pcm/schemas/v2",
|
||||||
"name": "Fill Resistance",
|
"name": "Fill Resistance",
|
||||||
"description": "DC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps.",
|
"description": "DC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps. PDN mode: multiple supplies and loads on one rail with current sharing and IR-drop.",
|
||||||
"description_full": "Computes the DC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad, conducting in-plane as its solder plug and lead on every layer it spans. Every net pad's exact copper shape is stamped on the layers it sits on, SMD as well as through-hole, and oblong (slotted) holes are modelled as their true stadium shape rather than an approximating circle. Traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. An optional skin-effect correction (exact 1D foil/barrel solution at a user-set frequency) estimates the resistive skin rise only - proximity redistribution and inductance are not modeled, so this is not an AC impedance simulation. PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
|
"description_full": "Computes the DC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad, conducting in-plane as its solder plug and lead on every layer it spans. Every net pad's exact copper shape is stamped on the layers it sits on, SMD as well as through-hole, and oblong (slotted) holes are modelled as their true stadium shape rather than an approximating circle. Traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nPDN mode replaces the single driven pair with a whole power rail: any number of supplies (Thevenin sources with configurable output resistance and open-circuit voltage) and loads with prescribed current draws on one net, set up from marker rectangles in an editable dialog or a JSON configuration file, reporting the IR-drop map, per-supply current sharing and per-load contact voltages in absolute volts. Multi-contact terminals can be bonded into one lug (a package's total current with the per-pin split solved).\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. An optional skin-effect correction (exact 1D foil/barrel solution at a user-set frequency) estimates the resistive skin rise only - proximity redistribution and inductance are not modeled, so this is not an AC impedance simulation. PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
|
||||||
"identifier": "th.co.b4l.fill-resistance",
|
"identifier": "th.co.b4l.fill-resistance",
|
||||||
"type": "plugin",
|
"type": "plugin",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"status": "stable",
|
"status": "stable",
|
||||||
"kicad_version": "10.0",
|
"kicad_version": "10.0",
|
||||||
"runtime": "ipc"
|
"runtime": "ipc"
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
# the dependency list there in sync with [project.dependencies].
|
# the dependency list there in sync with [project.dependencies].
|
||||||
[project]
|
[project]
|
||||||
name = "fill-resistance"
|
name = "fill-resistance"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"""fill_res_config.json loader: parsing, validation, precedence, save."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fill_resistance import config, configfile
|
||||||
|
from fill_resistance.configfile import (ConfigError, dialog_defaults,
|
||||||
|
load_config, rect_terminals_json,
|
||||||
|
save_classic_config,
|
||||||
|
save_pdn_config,
|
||||||
|
strip_comment_lines,
|
||||||
|
updated_terminals_json)
|
||||||
|
from fill_resistance.dialog import PdnTerminalRow, Selection
|
||||||
|
|
||||||
|
CLASSIC = """\
|
||||||
|
// Fill Resistance run configuration - classic mode.
|
||||||
|
// Full-line comments like this one are allowed; keys starting with "_"
|
||||||
|
// are ignored everywhere.
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"mode": "classic",
|
||||||
|
"run": {
|
||||||
|
"net": "VOUT+",
|
||||||
|
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
|
||||||
|
"include_tracks": true,
|
||||||
|
"vias_capped": true,
|
||||||
|
"cap_max_drill_mm": 0.5,
|
||||||
|
"adaptive": true,
|
||||||
|
"cell_um": null,
|
||||||
|
"freq_hz": "142k",
|
||||||
|
"contact_model": "uniform",
|
||||||
|
"include_buildup": false,
|
||||||
|
"extra_cu_um": 0.0,
|
||||||
|
"push_overlays": false,
|
||||||
|
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
|
||||||
|
},
|
||||||
|
"classic": {
|
||||||
|
"current_a": 40.0,
|
||||||
|
"contact1": "auto",
|
||||||
|
"contact2": "auto",
|
||||||
|
"_comment": "pos/neg: terminals fully specified by the file",
|
||||||
|
"pos": ["J1.1"],
|
||||||
|
"neg": ["J2.1", "J2.2"]
|
||||||
|
},
|
||||||
|
"physics": {
|
||||||
|
"via_plating_um": 25.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PDN = """\
|
||||||
|
// PDN study of the 3.3 V rail.
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"mode": "pdn",
|
||||||
|
"run": {"net": "VCC_3V3", "freq_hz": 0, "adaptive": true,
|
||||||
|
"v_nominal": 3.30},
|
||||||
|
"terminals": [
|
||||||
|
{"name": "buck", "role": "supply",
|
||||||
|
"parts": ["U1.SW2", "U1.SW3"],
|
||||||
|
"r_out_ohm": 0.004},
|
||||||
|
{"name": "ldo", "role": "supply",
|
||||||
|
"parts": ["U2.OUT"],
|
||||||
|
"r_out_ohm": 0.050, "v_oc": 3.28},
|
||||||
|
{"name": "mcu", "role": "load", "parts": ["U7"], "i_draw_a": 1.8},
|
||||||
|
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
|
||||||
|
"i_draw_a": 0.35, "contact": "F.Cu"},
|
||||||
|
{"name": "heater", "role": "load",
|
||||||
|
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
|
||||||
|
"contact": "B.Cu"},
|
||||||
|
{"via_mm": [115.2, 42.1]}],
|
||||||
|
"i_draw_a": 2.5}
|
||||||
|
],
|
||||||
|
"markers": {"pdn_layer": "User.3"}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path, text, name="fill_res_config.json"):
|
||||||
|
p = tmp_path / name
|
||||||
|
p.write_text(text, encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_example_loads(tmp_path):
|
||||||
|
cfg = load_config(_write(tmp_path, CLASSIC))
|
||||||
|
assert cfg.mode == "classic"
|
||||||
|
assert cfg.net == "VOUT+"
|
||||||
|
assert cfg.layers == ["F.Cu", "In1.Cu", "B.Cu"]
|
||||||
|
assert cfg.freq_hz == pytest.approx(142_000.0)
|
||||||
|
assert cfg.cell_um_given and cfg.cell_um is None
|
||||||
|
assert cfg.current_a == pytest.approx(40.0)
|
||||||
|
assert cfg.trim_enabled is False
|
||||||
|
assert cfg.trim_value == pytest.approx(10.0)
|
||||||
|
assert [p.kind for p in cfg.pos_parts] == ["pad"]
|
||||||
|
assert (cfg.neg_parts[0].ref, cfg.neg_parts[0].pad) == ("J2", "1")
|
||||||
|
assert cfg.physics == {"via_plating_um": 25.0}
|
||||||
|
assert cfg.terminals == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdn_example_loads(tmp_path):
|
||||||
|
cfg = load_config(_write(tmp_path, PDN))
|
||||||
|
assert cfg.mode == "pdn"
|
||||||
|
assert cfg.net == "VCC_3V3"
|
||||||
|
assert cfg.v_nominal == pytest.approx(3.30)
|
||||||
|
assert [t.name for t in cfg.terminals] == ["buck", "ldo", "mcu", "cam",
|
||||||
|
"heater"]
|
||||||
|
buck, ldo, mcu, cam, heater = cfg.terminals
|
||||||
|
assert buck.role == "supply" and buck.r_out_ohm == pytest.approx(0.004)
|
||||||
|
assert buck.v_oc is None
|
||||||
|
assert ldo.v_oc == pytest.approx(3.28)
|
||||||
|
assert mcu.parts[0].kind == "footprint" and mcu.parts[0].ref == "U7"
|
||||||
|
assert cam.parts[0].kind == "rect_label"
|
||||||
|
assert cam.parts[0].label == "CAM_ZONE"
|
||||||
|
assert cam.contact == "F.Cu"
|
||||||
|
assert heater.parts[0].kind == "rect_mm"
|
||||||
|
assert heater.parts[0].rect_mm == (112.0, 40.5, 118.0, 44.0)
|
||||||
|
assert heater.parts[0].contact == "B.Cu"
|
||||||
|
assert heater.parts[1].kind == "via_mm"
|
||||||
|
assert cfg.markers == {"pdn_layer": "User.3"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_stripping_keeps_line_numbers(tmp_path):
|
||||||
|
# the syntax error sits on line 4 of the file; the two stripped
|
||||||
|
# comment lines above it must not shift the reported position
|
||||||
|
text = "// one\n// two\n{\n \"version\": oops\n}\n"
|
||||||
|
with pytest.raises(ConfigError, match="line 4"):
|
||||||
|
load_config(_write(tmp_path, text))
|
||||||
|
assert strip_comment_lines(text).count("\n") == text.count("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_keys_warn_but_load(tmp_path, capsys):
|
||||||
|
text = json.dumps({"version": 1, "run": {"nett": "X", "net": "Y"}})
|
||||||
|
cfg = load_config(_write(tmp_path, text))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "run.nett" in out
|
||||||
|
assert cfg.net == "Y"
|
||||||
|
|
||||||
|
|
||||||
|
def test_underscore_keys_are_silent(tmp_path, capsys):
|
||||||
|
text = json.dumps({"version": 1, "_note": "hi",
|
||||||
|
"run": {"_x": 1, "net": "Y"}})
|
||||||
|
load_config(_write(tmp_path, text))
|
||||||
|
assert "warning" not in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutate, match", [
|
||||||
|
(lambda d: d.pop("version"), "version"),
|
||||||
|
(lambda d: d.update(version=2), "newer"),
|
||||||
|
(lambda d: d.update(mode="pdn"), "no.*terminals|terminals"),
|
||||||
|
(lambda d: d["run"].update(contact_model="bonded"), "contact_model"),
|
||||||
|
(lambda d: d["run"].update(freq_hz="-5"), "freq_hz"),
|
||||||
|
(lambda d: d["run"].update(cell_um=-1), "cell_um"),
|
||||||
|
(lambda d: d["classic"].update(current_a=0), "current_a"),
|
||||||
|
(lambda d: d["classic"].pop("neg"), "pos and neg together"),
|
||||||
|
])
|
||||||
|
def test_classic_validation_failures(tmp_path, mutate, match):
|
||||||
|
d = json.loads(strip_comment_lines(CLASSIC))
|
||||||
|
mutate(d)
|
||||||
|
with pytest.raises(ConfigError, match=match):
|
||||||
|
load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutate, match", [
|
||||||
|
(lambda d: d["terminals"][2].update(name="buck"), "duplicates"),
|
||||||
|
(lambda d: d["terminals"][2].pop("i_draw_a"), "i_draw_a.*required"),
|
||||||
|
(lambda d: d["terminals"][0].update(i_draw_a=1.0), "supply.*i_draw_a"),
|
||||||
|
(lambda d: d["terminals"][2].update(r_out_ohm=1.0), "load.*r_out_ohm"),
|
||||||
|
(lambda d: d["terminals"][0].update(r_out_ohm=-1), "r_out_ohm"),
|
||||||
|
(lambda d: d["terminals"][2].update(i_draw_a=-1), "i_draw_a"),
|
||||||
|
(lambda d: d["terminals"][0].update(role="source"), "role"),
|
||||||
|
(lambda d: d["terminals"][2].update(parts=["U7."]), "not a valid"),
|
||||||
|
(lambda d: d["terminals"][3].update(parts=["rect:"]), "empty rect"),
|
||||||
|
(lambda d: [d["terminals"].pop(0), d["terminals"].pop(0)],
|
||||||
|
"at least one active supply"),
|
||||||
|
(lambda d: [t.update(active=False) for t in d["terminals"]
|
||||||
|
if t["role"] == "load"], "at least one active load"),
|
||||||
|
(lambda d: d["terminals"][0].update(active="yes"), "active"),
|
||||||
|
(lambda d: d["terminals"][0].update(comment=3), "comment"),
|
||||||
|
(lambda d: d["run"].pop("net"), "run.net"),
|
||||||
|
])
|
||||||
|
def test_pdn_validation_failures(tmp_path, mutate, match):
|
||||||
|
d = json.loads(strip_comment_lines(PDN))
|
||||||
|
mutate(d)
|
||||||
|
with pytest.raises(ConfigError, match=match):
|
||||||
|
load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_inferred_from_terminals(tmp_path):
|
||||||
|
d = json.loads(strip_comment_lines(PDN))
|
||||||
|
del d["mode"]
|
||||||
|
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
assert cfg.mode == "pdn"
|
||||||
|
c = json.loads(strip_comment_lines(CLASSIC))
|
||||||
|
del c["mode"]
|
||||||
|
cfg = load_config(_write(tmp_path, json.dumps(c), name="c.json"))
|
||||||
|
assert cfg.mode == "classic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pad_number_with_dot_splits_on_first(tmp_path):
|
||||||
|
d = {"version": 1, "run": {"net": "V"},
|
||||||
|
"terminals": [
|
||||||
|
{"name": "s", "role": "supply", "parts": ["U1.A.1"],
|
||||||
|
"r_out_ohm": 0},
|
||||||
|
{"name": "l", "role": "load", "parts": ["U2.1"],
|
||||||
|
"i_draw_a": 1}]}
|
||||||
|
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
ref = cfg.terminals[0].parts[0]
|
||||||
|
assert (ref.ref, ref.pad) == ("U1", "A.1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_config_prefers_board_stem(tmp_path):
|
||||||
|
shared = _write(tmp_path, CLASSIC)
|
||||||
|
specific = _write(tmp_path, CLASSIC,
|
||||||
|
name="myboard.fill_res_config.json")
|
||||||
|
assert configfile.find_config(tmp_path, "myboard.kicad_pcb") == specific
|
||||||
|
assert configfile.find_config(tmp_path, "other.kicad_pcb") == shared
|
||||||
|
assert configfile.find_config(tmp_path / "empty", "x.kicad_pcb") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_config_loads_the_config_named_default(tmp_path):
|
||||||
|
plain = _write(tmp_path, CLASSIC)
|
||||||
|
named = _write(tmp_path, CLASSIC,
|
||||||
|
name="fill_res_config.default.json")
|
||||||
|
# "default" beats the plain legacy filename, board-specific beats both
|
||||||
|
assert configfile.find_config(tmp_path, "x.kicad_pcb") == named
|
||||||
|
specific = _write(tmp_path, CLASSIC, name="x.fill_res_config.json")
|
||||||
|
assert configfile.find_config(tmp_path, "x.kicad_pcb") == specific
|
||||||
|
named.unlink()
|
||||||
|
specific.unlink()
|
||||||
|
assert configfile.find_config(tmp_path, "x.kicad_pcb") == plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_config_filename_scheme():
|
||||||
|
assert (configfile.named_config_filename("pdn_test")
|
||||||
|
== "fill_res_config.pdn_test.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dialog_defaults_without_config_match_constants():
|
||||||
|
d = dialog_defaults(None)
|
||||||
|
assert d.include_tracks == config.INCLUDE_TRACKS
|
||||||
|
assert d.vias_capped == config.VIAS_CAPPED
|
||||||
|
assert d.adaptive == config.ADAPTIVE_CELLS
|
||||||
|
assert d.contact_model == config.CONTACT_MODEL
|
||||||
|
assert d.current_a == config.TEST_CURRENT_A
|
||||||
|
assert d.trim_mode == config.TRIM_MODE
|
||||||
|
assert d.net is None and d.layers is None
|
||||||
|
assert d.cell_um is None and d.trim_value is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dialog_defaults_overlay_config(tmp_path):
|
||||||
|
cfg = load_config(_write(tmp_path, CLASSIC))
|
||||||
|
d = dialog_defaults(cfg)
|
||||||
|
assert d.net == "VOUT+"
|
||||||
|
assert d.current_a == pytest.approx(40.0)
|
||||||
|
assert d.freq_hz == pytest.approx(142_000.0)
|
||||||
|
assert d.layers == ["F.Cu", "In1.Cu", "B.Cu"]
|
||||||
|
assert d.contact1 == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_physics_mutates_config_module(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "VIA_PLATING_UM", 18.0)
|
||||||
|
monkeypatch.setattr(config, "ELECTRODE_PDN_LAYER", "User.3")
|
||||||
|
d = {"version": 1, "physics": {"via_plating_um": 30.0},
|
||||||
|
"markers": {"pdn_layer": "User.4"}}
|
||||||
|
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
configfile.apply_physics(cfg)
|
||||||
|
assert config.VIA_PLATING_UM == 30.0
|
||||||
|
assert config.ELECTRODE_PDN_LAYER == "User.4"
|
||||||
|
|
||||||
|
|
||||||
|
def _selection():
|
||||||
|
return Selection(net="VOUT+", layers=["F.Cu", "B.Cu"], contact1="auto",
|
||||||
|
contact2="all", current_a=12.5, cell_um=80.0,
|
||||||
|
freq_hz=0.0, contact_model="equipotential",
|
||||||
|
include_buildup=True, extra_cu_um=100.0,
|
||||||
|
include_tracks=False, vias_capped=False,
|
||||||
|
cap_max_drill_mm=0.6, adaptive=False,
|
||||||
|
push_overlays=True, trim_enabled=True,
|
||||||
|
trim_mode="abs", trim_value=2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_then_load_roundtrip(tmp_path):
|
||||||
|
path = tmp_path / "fill_res_config.json"
|
||||||
|
save_classic_config(path, _selection())
|
||||||
|
cfg = load_config(path)
|
||||||
|
assert cfg.mode == "classic"
|
||||||
|
assert cfg.net == "VOUT+"
|
||||||
|
assert cfg.layers == ["F.Cu", "B.Cu"]
|
||||||
|
assert cfg.current_a == pytest.approx(12.5)
|
||||||
|
assert cfg.cell_um == pytest.approx(80.0)
|
||||||
|
assert cfg.contact_model == "equipotential"
|
||||||
|
assert cfg.contact2 == "all"
|
||||||
|
assert cfg.include_tracks is False
|
||||||
|
assert cfg.trim_enabled is True and cfg.trim_mode == "abs"
|
||||||
|
assert cfg.trim_value == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_preserves_foreign_sections(tmp_path):
|
||||||
|
path = _write(tmp_path, CLASSIC)
|
||||||
|
save_classic_config(path, _selection())
|
||||||
|
cfg = load_config(path)
|
||||||
|
assert cfg.physics == {"via_plating_um": 25.0} # kept from the old file
|
||||||
|
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
|
||||||
|
assert cfg.current_a == pytest.approx(12.5) # new dialog value
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_mode_may_carry_terminals(tmp_path):
|
||||||
|
# mode is only the STARTING mode: a classic config keeps a
|
||||||
|
# terminals section (the dialog's PDN mode offers it)
|
||||||
|
d = json.loads(strip_comment_lines(PDN))
|
||||||
|
d["mode"] = "classic"
|
||||||
|
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
assert cfg.mode == "classic"
|
||||||
|
assert len(cfg.terminals) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_classic_over_pdn_keeps_the_terminals(tmp_path):
|
||||||
|
path = _write(tmp_path, PDN)
|
||||||
|
old = load_config(path)
|
||||||
|
save_classic_config(path, _selection())
|
||||||
|
cfg = load_config(path)
|
||||||
|
assert cfg.mode == "classic" # starting mode flipped
|
||||||
|
assert cfg.current_a == pytest.approx(12.5)
|
||||||
|
assert [t.name for t in cfg.terminals] == [t.name for t in
|
||||||
|
old.terminals]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_refuses_broken_config(tmp_path):
|
||||||
|
path = _write(tmp_path, "{ not json")
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
save_classic_config(path, _selection())
|
||||||
|
assert path.read_text(encoding="utf-8") == "{ not json" # untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_examples_load():
|
||||||
|
"""The copyable examples in docs/ must always parse."""
|
||||||
|
from pathlib import Path
|
||||||
|
root = Path(__file__).resolve().parent.parent / "docs"
|
||||||
|
classic = load_config(root / "fill_res_config.example.json")
|
||||||
|
assert classic.mode == "classic"
|
||||||
|
pdn = load_config(root / "fill_res_config.pdn.example.json")
|
||||||
|
assert pdn.mode == "pdn" and len(pdn.terminals) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# --- PDN save (dialog editor) ------------------------------------------------
|
||||||
|
|
||||||
|
def _pdn_selection():
|
||||||
|
sel = _selection()
|
||||||
|
sel.mode = "pdn"
|
||||||
|
sel.net = "VCC_3V3"
|
||||||
|
sel.v_nominal = 3.3
|
||||||
|
return sel
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_terminals_json_shapes():
|
||||||
|
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.01),
|
||||||
|
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||||
|
i_draw_a=2.0)]
|
||||||
|
infos = [(True, (0.0, 0.0, 1.0, 1.0)),
|
||||||
|
(False, (10.0, 20.5, 12.25, 22.0))]
|
||||||
|
tj = rect_terminals_json(rows, infos)
|
||||||
|
assert tj[0]["parts"] == ["rect:VIN"] # labeled: live ref
|
||||||
|
assert tj[0]["r_out_ohm"] == pytest.approx(0.01)
|
||||||
|
assert "v_oc" not in tj[0] # None -> key omitted
|
||||||
|
assert tj[1]["parts"] == [{"rect_mm": [10.0, 20.5, 12.25, 22.0]}]
|
||||||
|
assert tj[1]["i_draw_a"] == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_updated_terminals_json_preserves_raw():
|
||||||
|
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
|
||||||
|
"r_out_ohm": 0.004, "v_oc": 3.3, "_comment": "keep me"},
|
||||||
|
{"name": "mcu", "role": "load", "parts": ["U7"],
|
||||||
|
"i_draw_a": 1.8}]
|
||||||
|
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.007, v_oc=None),
|
||||||
|
PdnTerminalRow(name="mcu", role="load", resolved="",
|
||||||
|
i_draw_a=2.2)]
|
||||||
|
tj = updated_terminals_json(raw, rows)
|
||||||
|
assert tj[0]["parts"] == ["U1.SW2"]
|
||||||
|
assert tj[0]["_comment"] == "keep me" # verbatim carry-over
|
||||||
|
assert tj[0]["r_out_ohm"] == pytest.approx(0.007)
|
||||||
|
assert "v_oc" not in tj[0] # None removes the key
|
||||||
|
assert tj[1]["i_draw_a"] == pytest.approx(2.2)
|
||||||
|
assert raw[0]["r_out_ohm"] == pytest.approx(0.004) # deep-copied
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_terminals_json_contact_layer():
|
||||||
|
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.01, contact="F.Cu"),
|
||||||
|
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||||
|
i_draw_a=2.0, contact="all")]
|
||||||
|
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
|
||||||
|
tj = rect_terminals_json(rows, infos)
|
||||||
|
assert tj[0]["contact"] == "F.Cu"
|
||||||
|
assert "contact" not in tj[1] # "all" is a rect's natural scope
|
||||||
|
|
||||||
|
|
||||||
|
def test_updated_terminals_json_moves_the_contact_scope():
|
||||||
|
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
|
||||||
|
"r_out_ohm": 0.004, "contact": "F.Cu"},
|
||||||
|
{"name": "mcu", "role": "load", "parts": ["U7"],
|
||||||
|
"i_draw_a": 1.8}]
|
||||||
|
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.004, contact="auto"),
|
||||||
|
PdnTerminalRow(name="mcu", role="load", resolved="",
|
||||||
|
i_draw_a=1.8, contact="B.Cu")]
|
||||||
|
tj = updated_terminals_json(raw, rows)
|
||||||
|
assert "contact" not in tj[0] # "auto" restores the default
|
||||||
|
assert tj[1]["contact"] == "B.Cu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbers_accept_si_suffix_strings(tmp_path):
|
||||||
|
d = {"version": 1, "mode": "pdn",
|
||||||
|
"run": {"net": "V", "v_nominal": "3300m"},
|
||||||
|
"terminals": [
|
||||||
|
{"name": "s", "role": "supply", "parts": ["U1"],
|
||||||
|
"r_out_ohm": "50m"},
|
||||||
|
{"name": "l", "role": "load", "parts": ["U2"],
|
||||||
|
"i_draw_a": "150m"}]}
|
||||||
|
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
assert cfg.v_nominal == pytest.approx(3.3)
|
||||||
|
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.05)
|
||||||
|
assert cfg.terminals[1].i_draw_a == pytest.approx(0.15)
|
||||||
|
|
||||||
|
d["terminals"][0]["r_out_ohm"] = "5k5" # RKM style: rejected
|
||||||
|
with pytest.raises(ConfigError, match="cannot parse number"):
|
||||||
|
load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactive_terminal_may_omit_its_value(tmp_path):
|
||||||
|
d = {"version": 1, "mode": "pdn", "run": {"net": "VCC"},
|
||||||
|
"terminals": [
|
||||||
|
{"name": "s", "role": "supply", "parts": ["U1"],
|
||||||
|
"r_out_ohm": 0.01},
|
||||||
|
{"name": "l1", "role": "load", "parts": ["U2"],
|
||||||
|
"i_draw_a": 1.0},
|
||||||
|
{"name": "l2", "role": "load", "parts": ["U3"],
|
||||||
|
"active": False, "comment": "not fitted"}]}
|
||||||
|
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
l2 = cfg.terminals[2]
|
||||||
|
assert l2.active is False and l2.i_draw_a is None
|
||||||
|
assert l2.comment == "not fitted"
|
||||||
|
assert cfg.terminals[0].active is True and cfg.terminals[0].comment == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_and_comment_in_the_save_builders():
|
||||||
|
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.01, comment="buck"),
|
||||||
|
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||||
|
active=False, i_draw_a=None)]
|
||||||
|
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
|
||||||
|
tj = rect_terminals_json(rows, infos)
|
||||||
|
assert "active" not in tj[0] and tj[0]["comment"] == "buck"
|
||||||
|
assert tj[1]["active"] is False
|
||||||
|
assert "i_draw_a" not in tj[1] and "comment" not in tj[1]
|
||||||
|
|
||||||
|
raw = [{"name": "VIN", "role": "supply", "parts": ["U1"],
|
||||||
|
"r_out_ohm": 0.01, "comment": "old"},
|
||||||
|
{"name": "L1", "role": "load", "parts": ["U2"],
|
||||||
|
"active": False}]
|
||||||
|
rows[0].comment = "" # cleared -> key removed
|
||||||
|
rows[1].active = True # re-enabled -> default again
|
||||||
|
rows[1].i_draw_a = 2.0
|
||||||
|
uj = updated_terminals_json(raw, rows)
|
||||||
|
assert "comment" not in uj[0]
|
||||||
|
assert "active" not in uj[1] and uj[1]["i_draw_a"] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactive_row_saves_and_reloads(tmp_path):
|
||||||
|
path = tmp_path / "fill_res_config.json"
|
||||||
|
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.01),
|
||||||
|
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||||
|
i_draw_a=2.0, comment="main draw"),
|
||||||
|
PdnTerminalRow(name="L2", role="load", resolved="",
|
||||||
|
active=False)] # blank value: still valid
|
||||||
|
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0)),
|
||||||
|
(True, (4.0, 0.0, 5.0, 1.0))]
|
||||||
|
save_pdn_config(path, _pdn_selection(),
|
||||||
|
rect_terminals_json(rows, infos))
|
||||||
|
cfg = load_config(path)
|
||||||
|
assert cfg.terminals[2].active is False
|
||||||
|
assert cfg.terminals[2].i_draw_a is None
|
||||||
|
assert cfg.terminals[1].comment == "main draw"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_pdn_config_roundtrip(tmp_path):
|
||||||
|
path = tmp_path / "fill_res_config.json"
|
||||||
|
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.01, v_oc=3.28),
|
||||||
|
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||||
|
i_draw_a=2.0)]
|
||||||
|
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (False, (5.0, 5.0, 6.0, 6.0))]
|
||||||
|
save_pdn_config(path, _pdn_selection(),
|
||||||
|
rect_terminals_json(rows, infos))
|
||||||
|
cfg = load_config(path)
|
||||||
|
assert cfg.mode == "pdn"
|
||||||
|
assert cfg.net == "VCC_3V3"
|
||||||
|
assert cfg.v_nominal == pytest.approx(3.3)
|
||||||
|
vin, l1 = cfg.terminals
|
||||||
|
assert vin.parts[0].kind == "rect_label"
|
||||||
|
assert vin.parts[0].label == "VIN"
|
||||||
|
assert vin.r_out_ohm == pytest.approx(0.01)
|
||||||
|
assert vin.v_oc == pytest.approx(3.28)
|
||||||
|
assert l1.parts[0].kind == "rect_mm"
|
||||||
|
assert l1.i_draw_a == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_pdn_into_classic_file_preserves_sections(tmp_path):
|
||||||
|
path = _write(tmp_path, CLASSIC)
|
||||||
|
rows = [PdnTerminalRow(name="S1", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.0),
|
||||||
|
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||||
|
i_draw_a=1.0)]
|
||||||
|
infos = [(False, (0.0, 0.0, 1.0, 1.0)), (False, (2.0, 0.0, 3.0, 1.0))]
|
||||||
|
save_pdn_config(path, _pdn_selection(),
|
||||||
|
rect_terminals_json(rows, infos))
|
||||||
|
cfg = load_config(path)
|
||||||
|
assert cfg.mode == "pdn" and len(cfg.terminals) == 2
|
||||||
|
# the classic section survived for a later hand-edit back
|
||||||
|
assert cfg.current_a == pytest.approx(40.0)
|
||||||
|
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
|
||||||
|
assert cfg.physics == {"via_plating_um": 25.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_pdn_into_pdn_file_updates_values(tmp_path):
|
||||||
|
path = _write(tmp_path, PDN)
|
||||||
|
old = load_config(path)
|
||||||
|
rows = []
|
||||||
|
for spec in old.terminals:
|
||||||
|
rows.append(PdnTerminalRow(
|
||||||
|
name=spec.name, role=spec.role, resolved="",
|
||||||
|
i_draw_a=(spec.i_draw_a + 1.0 if spec.role == "load"
|
||||||
|
else None),
|
||||||
|
r_out_ohm=(spec.r_out_ohm * 2 if spec.role == "supply"
|
||||||
|
else None),
|
||||||
|
v_oc=spec.v_oc))
|
||||||
|
save_pdn_config(path, _pdn_selection(),
|
||||||
|
updated_terminals_json(old.raw["terminals"], rows))
|
||||||
|
cfg = load_config(path)
|
||||||
|
assert [t.name for t in cfg.terminals] == [t.name for t in
|
||||||
|
old.terminals]
|
||||||
|
# partrefs are byte-identical carry-overs, only values moved
|
||||||
|
assert cfg.terminals[0].parts[0].describe() == "U1.SW2"
|
||||||
|
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.008)
|
||||||
|
assert cfg.terminals[2].i_draw_a == pytest.approx(2.8)
|
||||||
|
assert cfg.terminals[3].parts[0].label == "CAM_ZONE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_pdn_refuses_broken_file(tmp_path):
|
||||||
|
path = _write(tmp_path, "{ not json")
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
save_pdn_config(path, _pdn_selection(), [])
|
||||||
|
assert path.read_text(encoding="utf-8") == "{ not json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_pdn_self_validates_before_writing(tmp_path):
|
||||||
|
path = tmp_path / "fill_res_config.json"
|
||||||
|
# a load without a draw is invalid - the save must refuse instead
|
||||||
|
# of writing a config the next launch rejects
|
||||||
|
bad = [{"name": "L1", "role": "load", "parts": ["U1"]}]
|
||||||
|
with pytest.raises(ConfigError, match="i_draw_a"):
|
||||||
|
save_pdn_config(path, _pdn_selection(), bad)
|
||||||
|
assert not path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_save_still_omits_v_nominal(tmp_path):
|
||||||
|
path = tmp_path / "fill_res_config.json"
|
||||||
|
save_classic_config(path, _selection()) # v_nominal stays None
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
assert "v_nominal" not in raw["run"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonded_key_parses_and_validates(tmp_path):
|
||||||
|
d = {"version": 1, "run": {"net": "V"},
|
||||||
|
"terminals": [
|
||||||
|
{"name": "s", "role": "supply", "parts": ["U1"],
|
||||||
|
"r_out_ohm": 0},
|
||||||
|
{"name": "pkg", "role": "load", "parts": ["U2"],
|
||||||
|
"i_draw_a": 3.0, "bonded": True}]}
|
||||||
|
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||||
|
assert cfg.terminals[0].bonded is False
|
||||||
|
assert cfg.terminals[1].bonded is True
|
||||||
|
|
||||||
|
d["terminals"][1]["bonded"] = "yes"
|
||||||
|
with pytest.raises(ConfigError, match="bonded"):
|
||||||
|
load_config(_write(tmp_path, json.dumps(d), name="b.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonded_survives_the_save_roundtrip(tmp_path):
|
||||||
|
path = tmp_path / "fill_res_config.json"
|
||||||
|
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||||
|
r_out_ohm=0.01),
|
||||||
|
PdnTerminalRow(name="PKG", role="load", resolved="",
|
||||||
|
i_draw_a=2.0, bonded=True)]
|
||||||
|
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (5.0, 5.0, 6.0, 6.0))]
|
||||||
|
tj = rect_terminals_json(rows, infos)
|
||||||
|
assert "bonded" not in tj[0] # false -> key omitted
|
||||||
|
assert tj[1]["bonded"] is True
|
||||||
|
save_pdn_config(path, _pdn_selection(), tj)
|
||||||
|
cfg = load_config(path)
|
||||||
|
assert cfg.terminals[0].bonded is False
|
||||||
|
assert cfg.terminals[1].bonded is True
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
"""Dialog construction and validation (offscreen Qt): defaults
|
||||||
|
injection, the Classic/PDN mode selector, the editable supply/load
|
||||||
|
tables with their Layer combos, and the Load-/Save-config buttons."""
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QDialog
|
||||||
|
|
||||||
|
from fill_resistance import config
|
||||||
|
from fill_resistance import dialog as dialog_mod
|
||||||
|
from fill_resistance.configfile import DialogDefaults, dialog_defaults
|
||||||
|
from fill_resistance.dialog import PdnSetup, PdnTerminalRow, _Dialog
|
||||||
|
|
||||||
|
CANDIDATES = {"VCC": ["F.Cu", "In1.Cu", "B.Cu"], "GND": ["F.Cu", "B.Cu"]}
|
||||||
|
PDN_CANDS = {"VCC": ["F.Cu", "B.Cu"], "V5": ["F.Cu"]}
|
||||||
|
ORDER = ["F.Cu", "In1.Cu", "B.Cu"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(values=False):
|
||||||
|
return [PdnTerminalRow(name="src", role="supply", resolved="rect a",
|
||||||
|
component="J1",
|
||||||
|
r_out_ohm=0.004 if values else None,
|
||||||
|
v_oc=3.28 if values else None),
|
||||||
|
PdnTerminalRow(name="snk", role="load", resolved="rect b",
|
||||||
|
component="near U5",
|
||||||
|
i_draw_a=1.8 if values else None)]
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(from_config=False, values=False, note=""):
|
||||||
|
return PdnSetup(rows=_rows(values), source="markers",
|
||||||
|
from_config=from_config, note=note)
|
||||||
|
|
||||||
|
|
||||||
|
def _dlg(app, defaults=None, pdn=None, pdn_candidates=None,
|
||||||
|
classic_reason=None, pdn_reason=None, save_callback=None,
|
||||||
|
load_dir=None, start_mode="classic", net="VCC"):
|
||||||
|
return _Dialog(CANDIDATES, ORDER, net, "e1", "e2", "auto", "auto",
|
||||||
|
buildup_layers=["F.Cu"], defaults=defaults, pdn=pdn,
|
||||||
|
pdn_candidates=(pdn_candidates if pdn_candidates
|
||||||
|
is not None else PDN_CANDS),
|
||||||
|
classic_reason=classic_reason, pdn_reason=pdn_reason,
|
||||||
|
save_callback=save_callback, load_dir=load_dir,
|
||||||
|
start_mode=start_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_pdn(dlg, r_out="0.01", i_draw="2.0"):
|
||||||
|
"""Minimal valid PDN entries (supply table row 0, load table
|
||||||
|
row 0; the value columns start after Active | Name | Component)."""
|
||||||
|
dlg.pdn_sup_table.item(0, 3).setText(r_out)
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText(i_draw)
|
||||||
|
|
||||||
|
|
||||||
|
# --- classic mode (unchanged behavior) ---------------------------------------
|
||||||
|
|
||||||
|
def test_defaults_seed_the_widgets(app):
|
||||||
|
d = DialogDefaults(net="VCC", layers=["F.Cu", "B.Cu"],
|
||||||
|
include_tracks=False, vias_capped=False,
|
||||||
|
cap_max_drill_mm=0.7, adaptive=False,
|
||||||
|
contact_model="equipotential", current_a=42.0,
|
||||||
|
freq_hz=142_000.0, cell_um=80.0,
|
||||||
|
include_buildup=True, extra_cu_um=50.0,
|
||||||
|
push_overlays=True, trim_enabled=True,
|
||||||
|
trim_mode="abs", trim_value=2.5)
|
||||||
|
dlg = _dlg(app, defaults=d)
|
||||||
|
assert dlg.tracks_check.isChecked() is False
|
||||||
|
assert dlg.capped_check.isChecked() is False
|
||||||
|
assert dlg.cap_drill_edit.text() == "0.7"
|
||||||
|
assert dlg.adaptive_check.isChecked() is False
|
||||||
|
assert dlg.model_box.currentData() == "equipotential"
|
||||||
|
assert dlg.current_edit.text() == "42"
|
||||||
|
assert dlg.freq_edit.text() == "142000"
|
||||||
|
assert dlg.cell_edit.text() == "80"
|
||||||
|
assert dlg.buildup_check.isChecked() is True
|
||||||
|
assert dlg.extracu_edit.text() == "50"
|
||||||
|
assert dlg.overlay_check.isChecked() is True
|
||||||
|
assert dlg.trim_check.isChecked() is True
|
||||||
|
assert dlg.trim_mode_box.currentData() == "abs"
|
||||||
|
assert dlg.trim_edit.text() == "2.5"
|
||||||
|
# layer subset: In1.Cu was not in the config's list
|
||||||
|
assert dlg.checked_layers() == ["F.Cu", "B.Cu"]
|
||||||
|
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.mode == "classic"
|
||||||
|
assert sel.current_a == pytest.approx(42.0)
|
||||||
|
assert sel.trim_value == pytest.approx(2.5)
|
||||||
|
assert sel.layers == ["F.Cu", "B.Cu"]
|
||||||
|
assert sel.pdn_rows is None and sel.v_nominal is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_defaults_behaves_like_config_constants(app):
|
||||||
|
dlg = _dlg(app)
|
||||||
|
assert dlg.current_edit.text() == f"{config.TEST_CURRENT_A:g}"
|
||||||
|
assert dlg.checked_layers() == ORDER # everything checked
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.mode == "classic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_validation_still_rejects_bad_current(app):
|
||||||
|
dlg = _dlg(app)
|
||||||
|
dlg.current_edit.setText("-3")
|
||||||
|
with pytest.raises(ValueError, match="Test current"):
|
||||||
|
dlg._build_selection()
|
||||||
|
dlg.current_edit.setText("nope")
|
||||||
|
with pytest.raises(ValueError, match="not a number"):
|
||||||
|
dlg._build_selection()
|
||||||
|
|
||||||
|
|
||||||
|
# --- mode selector -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_mode_radio_labels(app):
|
||||||
|
# deliberately NOT "two-terminal": classic terminals may bundle
|
||||||
|
# many contact parts, the old label read like a 2-contact cap
|
||||||
|
dlg = _dlg(app, pdn=_setup())
|
||||||
|
assert dlg.mode_classic.text() == "Classic"
|
||||||
|
assert dlg.mode_pdn.text() == "PDN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_modes_available_starts_classic_and_switches(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup())
|
||||||
|
assert dlg.mode_classic.isChecked()
|
||||||
|
assert dlg.mode_classic.isEnabled() and dlg.mode_pdn.isEnabled()
|
||||||
|
assert dlg.pdn_section.isHidden()
|
||||||
|
assert not dlg.classic_section.isHidden()
|
||||||
|
|
||||||
|
dlg.mode_pdn.setChecked(True)
|
||||||
|
assert dlg.classic_section.isHidden()
|
||||||
|
assert not dlg.pdn_section.isHidden()
|
||||||
|
_fill_pdn(dlg)
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.mode == "pdn"
|
||||||
|
|
||||||
|
dlg.mode_classic.setChecked(True)
|
||||||
|
assert dlg._build_selection().mode == "classic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdn_radio_disabled_with_reason(app):
|
||||||
|
dlg = _dlg(app, pdn=None, pdn_reason="no rectangles found")
|
||||||
|
assert dlg.mode_pdn.isEnabled() is False
|
||||||
|
assert dlg.mode_classic.isChecked()
|
||||||
|
assert dlg.pdn_section is None
|
||||||
|
assert "no rectangles found" in dlg.mode_pdn.toolTip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_starts_in_pdn_when_classic_unavailable(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="two contacts needed")
|
||||||
|
assert dlg.mode_pdn.isChecked()
|
||||||
|
assert dlg.mode_classic.isEnabled() is False
|
||||||
|
assert dlg.classic_section is None
|
||||||
|
assert dlg.contact1_box is None and dlg.current_edit is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_combo_swaps_with_the_mode(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), net="VCC")
|
||||||
|
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
|
||||||
|
assert items == sorted(CANDIDATES)
|
||||||
|
dlg.mode_pdn.setChecked(True)
|
||||||
|
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
|
||||||
|
assert items == sorted(PDN_CANDS)
|
||||||
|
assert dlg.net_box.currentText() == "VCC" # shared net survives
|
||||||
|
assert dlg.net_box.isEnabled() # editor mode: free
|
||||||
|
dlg.mode_classic.setChecked(True)
|
||||||
|
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
|
||||||
|
assert items == sorted(CANDIDATES)
|
||||||
|
|
||||||
|
|
||||||
|
# --- config-backed PDN -------------------------------------------------------
|
||||||
|
|
||||||
|
def test_config_backed_rows_prefill_and_edit_values(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||||
|
classic_reason="nothing selected",
|
||||||
|
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
|
||||||
|
assert dlg.mode_pdn.isChecked() # classic unavailable
|
||||||
|
assert dlg.net_box.isEnabled() # net is NOT pinned
|
||||||
|
assert dlg.pdn_sup_table.item(0, 3).text() == "0.004"
|
||||||
|
assert dlg.pdn_sup_table.item(0, 4).text() == "3.28"
|
||||||
|
assert dlg.pdn_load_table.item(0, 3).text() == "1.8"
|
||||||
|
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText("2.5") # tweak the draw
|
||||||
|
dlg.pdn_sup_table.item(0, 4).setText("") # v_oc back to nominal
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.mode == "pdn"
|
||||||
|
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.5)
|
||||||
|
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
|
||||||
|
assert sel.pdn_rows[0].v_oc is None
|
||||||
|
assert sel.current_a == pytest.approx(2.5) # summed draw
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_never_pins_the_mode(app):
|
||||||
|
# a config-backed PDN setup with classic available: starts in PDN
|
||||||
|
# (start_mode from cfg.mode) but classic stays one click away
|
||||||
|
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||||
|
start_mode="pdn")
|
||||||
|
assert dlg.mode_pdn.isChecked()
|
||||||
|
assert dlg.mode_classic.isEnabled()
|
||||||
|
assert dlg.net_box.isEnabled()
|
||||||
|
dlg.mode_classic.setChecked(True)
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.mode == "classic"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the editable tables -----------------------------------------------------
|
||||||
|
|
||||||
|
def test_tables_split_by_role(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
assert dlg.pdn_sup_table.rowCount() == 1
|
||||||
|
assert dlg.pdn_load_table.rowCount() == 1
|
||||||
|
assert dlg.pdn_sup_table.item(0, 1).text() == "src"
|
||||||
|
assert dlg.pdn_load_table.item(0, 1).text() == "snk"
|
||||||
|
# supplies carry R_out + V_oc columns, loads only I draw
|
||||||
|
assert dlg.pdn_sup_table.columnCount() == 8
|
||||||
|
assert dlg.pdn_load_table.columnCount() == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_titles_name_the_marker_layers(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
assert config.ELECTRODE_POS_LAYER in dlg.pdn_sup_label.text()
|
||||||
|
assert config.ELECTRODE_NEG_LAYER in dlg.pdn_load_label.text()
|
||||||
|
# config-backed setups too: the titles say where a NEW rectangle
|
||||||
|
# becomes a new terminal, whatever the current rows' source
|
||||||
|
backed = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||||
|
classic_reason="nothing selected")
|
||||||
|
assert config.ELECTRODE_POS_LAYER in backed.pdn_sup_label.text()
|
||||||
|
assert config.ELECTRODE_NEG_LAYER in backed.pdn_load_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_component_column_identifies_the_row(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
assert dlg.pdn_sup_table.item(0, 2).text() == "J1"
|
||||||
|
assert dlg.pdn_load_table.item(0, 2).text() == "near U5"
|
||||||
|
assert not (dlg.pdn_sup_table.item(0, 2).flags() & Qt.ItemIsEditable)
|
||||||
|
_fill_pdn(dlg)
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.pdn_rows[1].component == "near U5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdn_mode_opens_with_a_roomy_default_size(app):
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
avail = QApplication.primaryScreen().availableGeometry()
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x") # starts in PDN
|
||||||
|
assert dlg.height() >= int(avail.height() * 0.6)
|
||||||
|
assert dlg.height() <= int(avail.height() * 0.85)
|
||||||
|
assert dlg.width() <= int(avail.width() * 0.9)
|
||||||
|
# switching to PDN grows a classic-sized dialog the same way
|
||||||
|
both = _dlg(app, pdn=_setup())
|
||||||
|
both.mode_pdn.setChecked(True)
|
||||||
|
assert both.height() >= int(avail.height() * 0.6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tables_resize_and_the_dialog_scrolls(app):
|
||||||
|
from PySide6.QtWidgets import QSplitter
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
# the form scrolls; the error line and buttons stay outside
|
||||||
|
assert dlg._scroll.widgetResizable()
|
||||||
|
assert dlg.error_label.parent() is dlg
|
||||||
|
# tables sit in a draggable vertical splitter, no fixed height cap
|
||||||
|
assert isinstance(dlg.pdn_splitter, QSplitter)
|
||||||
|
assert dlg.pdn_splitter.orientation() == Qt.Vertical
|
||||||
|
assert dlg.pdn_splitter.count() == 2
|
||||||
|
assert not dlg.pdn_splitter.childrenCollapsible()
|
||||||
|
assert dlg.pdn_sup_table.maximumHeight() > 100_000
|
||||||
|
assert dlg.pdn_load_table.maximumHeight() > 100_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_order_is_preserved_across_the_split(app):
|
||||||
|
rows = [PdnTerminalRow(name="l1", role="load", resolved="a"),
|
||||||
|
PdnTerminalRow(name="s1", role="supply", resolved="b"),
|
||||||
|
PdnTerminalRow(name="l2", role="load", resolved="c")]
|
||||||
|
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
|
||||||
|
classic_reason="x")
|
||||||
|
dlg.pdn_sup_table.item(0, 3).setText("0.01")
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText("1")
|
||||||
|
dlg.pdn_load_table.item(1, 3).setText("2")
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert [r.name for r in sel.pdn_rows] == ["l1", "s1", "l2"]
|
||||||
|
assert sel.pdn_rows[0].i_draw_a == pytest.approx(1.0)
|
||||||
|
assert sel.pdn_rows[2].i_draw_a == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_flags(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
for t, value_cols in ((dlg.pdn_sup_table, (3, 4)),
|
||||||
|
(dlg.pdn_load_table, (3,))):
|
||||||
|
last = t.columnCount() - 1
|
||||||
|
for col in value_cols + (last,): # values + Comment editable
|
||||||
|
assert t.item(0, col).flags() & Qt.ItemIsEditable
|
||||||
|
# name / component / contact parts: visible but read-only
|
||||||
|
for col in (1, 2, last - 1):
|
||||||
|
assert t.item(0, col).flags() & Qt.ItemIsEnabled
|
||||||
|
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
|
||||||
|
# the Active column is a checkbox, not an editable cell
|
||||||
|
assert t.item(0, 0).flags() & Qt.ItemIsUserCheckable
|
||||||
|
assert not (t.item(0, 0).flags() & Qt.ItemIsEditable)
|
||||||
|
assert t.item(0, 0).checkState() == Qt.Checked
|
||||||
|
# the Layer column holds a combo, not a text item
|
||||||
|
assert t.cellWidget(0, last - 2) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer_combos_default_to_all_layers(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
combos = dlg._pdn_layer_combos
|
||||||
|
assert [c.currentData() for c in combos] == ["all", "all"]
|
||||||
|
# live editor: no "auto" entry (a rectangle's natural scope IS all)
|
||||||
|
items = [combos[0].itemData(i) for i in range(combos[0].count())]
|
||||||
|
assert items == ["all", "F.Cu", "B.Cu"] # the PDN net's layers
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer_pick_reaches_the_selection(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
_fill_pdn(dlg)
|
||||||
|
combo = dlg._pdn_layer_combos[1] # the load row
|
||||||
|
combo.setCurrentIndex(combo.findData("B.Cu"))
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.pdn_rows[0].contact == "all"
|
||||||
|
assert sel.pdn_rows[1].contact == "B.Cu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_backed_combos_offer_auto_and_seed_the_scope(app):
|
||||||
|
rows = _rows(values=True)
|
||||||
|
rows[0].contact = "F.Cu"
|
||||||
|
rows[0].from_config = True
|
||||||
|
rows[1].contact = "auto"
|
||||||
|
rows[1].from_config = True
|
||||||
|
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
|
||||||
|
from_config=True),
|
||||||
|
classic_reason="nothing selected",
|
||||||
|
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
|
||||||
|
combos = dlg._pdn_layer_combos
|
||||||
|
assert combos[0].currentData() == "F.Cu"
|
||||||
|
assert combos[1].currentData() == "auto"
|
||||||
|
assert combos[1].itemData(0) == "auto" # schema default first
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_scope_is_per_row_in_a_mixed_setup(app):
|
||||||
|
# a config-backed setup may also carry NEWLY drawn rectangles:
|
||||||
|
# only the config rows offer the "auto" scope
|
||||||
|
rows = _rows(values=True)
|
||||||
|
rows[0].from_config = True # file terminal
|
||||||
|
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
|
||||||
|
from_config=True),
|
||||||
|
classic_reason="nothing selected",
|
||||||
|
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
|
||||||
|
combos = dlg._pdn_layer_combos
|
||||||
|
assert combos[0].findData("auto") >= 0 # config row
|
||||||
|
assert combos[1].findData("auto") == -1 # new rectangle
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer_combos_follow_the_net(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x", net="VCC")
|
||||||
|
combo = dlg._pdn_layer_combos[0]
|
||||||
|
assert combo.findData("B.Cu") >= 0
|
||||||
|
dlg.net_box.setCurrentText("V5") # V5 copper: F.Cu only
|
||||||
|
assert combo.findData("B.Cu") == -1
|
||||||
|
assert combo.findData("F.Cu") >= 0
|
||||||
|
assert combo.currentData() == "all"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_by_the_selected_net(app):
|
||||||
|
rows = [PdnTerminalRow(name="s1", role="supply", resolved="a",
|
||||||
|
nets=frozenset({"VCC", "V5"})),
|
||||||
|
PdnTerminalRow(name="l1", role="load", resolved="b",
|
||||||
|
nets=frozenset({"VCC"})),
|
||||||
|
PdnTerminalRow(name="l2", role="load", resolved="c",
|
||||||
|
nets=frozenset({"V5"}))]
|
||||||
|
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
|
||||||
|
classic_reason="x", net="VCC")
|
||||||
|
assert not dlg.pdn_sup_table.isRowHidden(0)
|
||||||
|
assert not dlg.pdn_load_table.isRowHidden(0) # l1 on VCC
|
||||||
|
assert dlg.pdn_load_table.isRowHidden(1) # l2 is not
|
||||||
|
assert "1 not on this net: hidden" in dlg.pdn_totals.text()
|
||||||
|
|
||||||
|
# the hidden row is exempt from validation and the summed draw,
|
||||||
|
# but it still comes back - with active False and everything it
|
||||||
|
# holds - so a save keeps it in the config file
|
||||||
|
dlg.pdn_sup_table.item(0, 3).setText("0.01")
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText("2")
|
||||||
|
dlg.pdn_load_table.item(1, 3).setText("7") # value on hidden l2
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.pdn_rows[2].active is False # off-net: not in run
|
||||||
|
assert sel.pdn_rows[2].i_draw_a == pytest.approx(7.0) # kept
|
||||||
|
assert sel.pdn_rows[1].active is True
|
||||||
|
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.0)
|
||||||
|
assert sel.current_a == pytest.approx(2.0)
|
||||||
|
|
||||||
|
# switching the net swaps the visible set
|
||||||
|
dlg.net_box.setCurrentText("V5")
|
||||||
|
assert dlg.pdn_load_table.isRowHidden(0)
|
||||||
|
assert not dlg.pdn_load_table.isRowHidden(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_without_net_info_always_show(app):
|
||||||
|
# rows without net info (nets=None) are never filtered
|
||||||
|
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||||
|
classic_reason="nothing selected")
|
||||||
|
assert not dlg.pdn_sup_table.isRowHidden(0)
|
||||||
|
assert not dlg.pdn_load_table.isRowHidden(0)
|
||||||
|
assert "hidden" not in dlg.pdn_totals.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchecking_a_row_takes_it_out_of_the_run(app):
|
||||||
|
rows = [PdnTerminalRow(name="src", role="supply", resolved="a"),
|
||||||
|
PdnTerminalRow(name="big", role="load", resolved="b"),
|
||||||
|
PdnTerminalRow(name="small", role="load", resolved="c")]
|
||||||
|
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
|
||||||
|
classic_reason="x")
|
||||||
|
dlg.pdn_sup_table.item(0, 3).setText("0.01")
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText("4")
|
||||||
|
# "small" stays BLANK and unchecked: no validation error, and it
|
||||||
|
# comes back inactive instead of being dropped (it is still saved)
|
||||||
|
dlg.pdn_load_table.item(1, 0).setCheckState(Qt.Unchecked)
|
||||||
|
assert "1 disabled" in dlg.pdn_totals.text()
|
||||||
|
assert "1 supplies, 1 loads, 4 A total draw" in dlg.pdn_totals.text()
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.pdn_rows[2].active is False
|
||||||
|
assert sel.pdn_rows[2].i_draw_a is None
|
||||||
|
assert sel.pdn_rows[1].active is True
|
||||||
|
assert sel.current_a == pytest.approx(4.0)
|
||||||
|
# a value typed into a disabled row must still be a number
|
||||||
|
dlg.pdn_load_table.item(1, 3).setText("junk")
|
||||||
|
with pytest.raises(ValueError, match="not a number"):
|
||||||
|
dlg._build_selection()
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_rows_of_a_role_unchecked_blocks_ok(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
_fill_pdn(dlg)
|
||||||
|
dlg.pdn_load_table.item(0, 0).setCheckState(Qt.Unchecked)
|
||||||
|
with pytest.raises(ValueError, match="At least one active load"):
|
||||||
|
dlg._build_selection()
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_column_roundtrip(app):
|
||||||
|
rows = _rows()
|
||||||
|
rows[1].comment = "camera burst draw"
|
||||||
|
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
|
||||||
|
classic_reason="x")
|
||||||
|
last = dlg.pdn_load_table.columnCount() - 1
|
||||||
|
assert dlg.pdn_load_table.item(0, last).text() == "camera burst draw"
|
||||||
|
_fill_pdn(dlg)
|
||||||
|
dlg.pdn_load_table.item(0, last).setText("worst case")
|
||||||
|
slast = dlg.pdn_sup_table.columnCount() - 1
|
||||||
|
dlg.pdn_sup_table.item(0, slast).setText("buck output")
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.pdn_rows[0].comment == "buck output"
|
||||||
|
assert sel.pdn_rows[1].comment == "worst case"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonded_flag_survives_the_table_roundtrip(app):
|
||||||
|
setup = PdnSetup(rows=[
|
||||||
|
PdnTerminalRow(name="src", role="supply", resolved="r"),
|
||||||
|
PdnTerminalRow(name="pkg", role="load", resolved="2× rects",
|
||||||
|
bonded=True)], source="markers")
|
||||||
|
dlg = _dlg(app, pdn=setup, classic_reason="x")
|
||||||
|
_fill_pdn(dlg)
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.pdn_rows[0].bonded is False
|
||||||
|
assert sel.pdn_rows[1].bonded is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdn_values_reach_the_selection(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
dlg.pdn_sup_table.item(0, 3).setText("50m") # SI suffix = 0.05
|
||||||
|
dlg.pdn_sup_table.item(0, 4).setText("3.28")
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText("1,5") # decimal comma ok
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
src, snk = sel.pdn_rows
|
||||||
|
assert src.r_out_ohm == pytest.approx(0.05)
|
||||||
|
assert src.v_oc == pytest.approx(3.28)
|
||||||
|
assert snk.i_draw_a == pytest.approx(1.5)
|
||||||
|
assert sel.v_nominal == pytest.approx(config.PDN_V_NOMINAL)
|
||||||
|
|
||||||
|
|
||||||
|
def test_si_suffixes_work_in_every_number_field(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
dlg.pdn_sup_table.item(0, 3).setText("4m")
|
||||||
|
dlg.pdn_sup_table.item(0, 4).setText("3300m")
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText("1k") # 1000 A: silly, legal
|
||||||
|
dlg.vnominal_edit.setText("5000m")
|
||||||
|
dlg.cell_edit.setText("0.1k")
|
||||||
|
sel = dlg._build_selection()
|
||||||
|
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
|
||||||
|
assert sel.pdn_rows[0].v_oc == pytest.approx(3.3)
|
||||||
|
assert sel.pdn_rows[1].i_draw_a == pytest.approx(1000.0)
|
||||||
|
assert sel.v_nominal == pytest.approx(5.0)
|
||||||
|
assert sel.cell_um == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prepare, match", [
|
||||||
|
(lambda d: _fill_pdn(d, i_draw=""), "'snk': I draw is required"),
|
||||||
|
(lambda d: _fill_pdn(d, i_draw="-1"), "'snk': I draw must be"),
|
||||||
|
(lambda d: _fill_pdn(d, i_draw="abc"), "not a number"),
|
||||||
|
(lambda d: _fill_pdn(d, r_out=""), "'src': R_out is required"),
|
||||||
|
(lambda d: _fill_pdn(d, r_out="-2"), "'src': R_out must be"),
|
||||||
|
(lambda d: (_fill_pdn(d), d.pdn_sup_table.item(0, 4).setText("0")),
|
||||||
|
"'src': V_oc must be > 0"),
|
||||||
|
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("")),
|
||||||
|
"V nominal is required"),
|
||||||
|
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("0")),
|
||||||
|
"V nominal must be > 0"),
|
||||||
|
])
|
||||||
|
def test_pdn_validation_errors(app, prepare, match):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
prepare(dlg)
|
||||||
|
with pytest.raises(ValueError, match=match):
|
||||||
|
dlg._build_selection()
|
||||||
|
|
||||||
|
|
||||||
|
def test_v_nominal_seeds_from_defaults(app):
|
||||||
|
d = dialog_defaults(None)
|
||||||
|
d.v_nominal = 5.0
|
||||||
|
dlg = _dlg(app, defaults=d, pdn=_setup(), classic_reason="x")
|
||||||
|
assert dlg.vnominal_edit.text() == "5"
|
||||||
|
_fill_pdn(dlg)
|
||||||
|
assert dlg._build_selection().v_nominal == pytest.approx(5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_totals_label_follows_edits(app):
|
||||||
|
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||||
|
assert "0 A total draw" in dlg.pdn_totals.text()
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText("2.5")
|
||||||
|
assert "1 supplies, 1 loads, 2.5 A total draw" in dlg.pdn_totals.text()
|
||||||
|
dlg.pdn_load_table.item(0, 3).setText("2500m") # suffix counted too
|
||||||
|
assert "2.5 A total draw" in dlg.pdn_totals.text()
|
||||||
|
|
||||||
|
|
||||||
|
# --- load button -------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FakePicker:
|
||||||
|
"""Stands in for QFileDialog (a native picker cannot run in the
|
||||||
|
offscreen test session)."""
|
||||||
|
result = ("", "")
|
||||||
|
save_result = ("", "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getOpenFileName(*_args, **_kwargs):
|
||||||
|
return _FakePicker.result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSaveFileName(*_args, **_kwargs):
|
||||||
|
return _FakePicker.save_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_button_returns_a_load_request(app, tmp_path, monkeypatch):
|
||||||
|
path = tmp_path / "fill_res_config.other.json"
|
||||||
|
path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||||
|
_FakePicker.result = (str(path), "json")
|
||||||
|
dlg = _dlg(app, load_dir=tmp_path)
|
||||||
|
dlg._load_config()
|
||||||
|
assert dlg._load_request == path
|
||||||
|
assert dlg.result() == QDialog.Accepted
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_button_rejects_an_invalid_config(app, tmp_path, monkeypatch):
|
||||||
|
path = tmp_path / "broken.json"
|
||||||
|
path.write_text('{"version": []}', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||||
|
_FakePicker.result = (str(path), "json")
|
||||||
|
dlg = _dlg(app, load_dir=tmp_path)
|
||||||
|
dlg._load_config()
|
||||||
|
assert dlg._load_request is None # stays open instead
|
||||||
|
assert "version" in dlg.error_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||||
|
_FakePicker.result = ("", "")
|
||||||
|
dlg = _dlg(app, load_dir=tmp_path)
|
||||||
|
dlg._load_config()
|
||||||
|
assert dlg._load_request is None
|
||||||
|
assert not dlg.error_label.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
# --- save button -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _patch_save(monkeypatch, name):
|
||||||
|
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||||
|
_FakePicker.save_result = (name, "json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_button_valid_selection_reaches_callback(app, monkeypatch):
|
||||||
|
got = {}
|
||||||
|
|
||||||
|
def cb(sel, target):
|
||||||
|
got["sel"], got["target"] = sel, target
|
||||||
|
return target.name
|
||||||
|
|
||||||
|
_patch_save(monkeypatch, "x.fill_res_config.json")
|
||||||
|
dlg = _dlg(app, save_callback=cb)
|
||||||
|
dlg._save_config()
|
||||||
|
assert got["sel"].mode == "classic"
|
||||||
|
assert got["target"].name == "x.fill_res_config.json"
|
||||||
|
assert "saved to x.fill_res_config.json" in dlg.error_label.text()
|
||||||
|
|
||||||
|
# an invalid field blocks the save BEFORE the file picker opens
|
||||||
|
got.clear()
|
||||||
|
dlg.current_edit.setText("bogus")
|
||||||
|
dlg._save_config()
|
||||||
|
assert not got
|
||||||
|
assert "not a number" in dlg.error_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_name_is_editable_and_sticky(app, monkeypatch, tmp_path):
|
||||||
|
got = {}
|
||||||
|
|
||||||
|
def cb(sel, target):
|
||||||
|
got["target"] = target
|
||||||
|
return target.name
|
||||||
|
|
||||||
|
_patch_save(monkeypatch, str(tmp_path / "fill_res_config.exp.json"))
|
||||||
|
dlg = _dlg(app, save_callback=cb)
|
||||||
|
assert dlg._save_target is None # seeded by main normally
|
||||||
|
dlg._save_config()
|
||||||
|
assert got["target"] == tmp_path / "fill_res_config.exp.json"
|
||||||
|
# the chosen name seeds the next save's picker
|
||||||
|
assert dlg._save_target == tmp_path / "fill_res_config.exp.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_appends_the_json_suffix(app, monkeypatch):
|
||||||
|
got = {}
|
||||||
|
|
||||||
|
def cb(sel, target):
|
||||||
|
got["target"] = target
|
||||||
|
return target.name
|
||||||
|
|
||||||
|
_patch_save(monkeypatch, "experiment") # typed without a suffix
|
||||||
|
dlg = _dlg(app, save_callback=cb)
|
||||||
|
dlg._save_config()
|
||||||
|
assert got["target"].name == "experiment.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_picker_cancel_does_nothing(app, monkeypatch):
|
||||||
|
def cb(sel, target):
|
||||||
|
raise AssertionError("must not be called")
|
||||||
|
|
||||||
|
_patch_save(monkeypatch, "")
|
||||||
|
dlg = _dlg(app, save_callback=cb)
|
||||||
|
dlg._save_config()
|
||||||
|
assert not dlg.error_label.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_button_works_in_config_backed_pdn_mode(app, monkeypatch):
|
||||||
|
got = {}
|
||||||
|
|
||||||
|
def cb(sel, target):
|
||||||
|
got["sel"] = sel
|
||||||
|
return "y.json"
|
||||||
|
|
||||||
|
_patch_save(monkeypatch, "y.json")
|
||||||
|
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||||
|
classic_reason="nothing selected", save_callback=cb)
|
||||||
|
dlg._save_config()
|
||||||
|
assert got["sel"].mode == "pdn"
|
||||||
|
assert got["sel"].pdn_rows[1].i_draw_a == pytest.approx(1.8)
|
||||||
|
assert "saved to y.json" in dlg.error_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_callback_failure_is_shown_not_raised(app, monkeypatch):
|
||||||
|
def cb(sel, target):
|
||||||
|
raise RuntimeError("disk full")
|
||||||
|
|
||||||
|
_patch_save(monkeypatch, "z.json")
|
||||||
|
dlg = _dlg(app, save_callback=cb)
|
||||||
|
dlg._save_config()
|
||||||
|
assert "disk full" in dlg.error_label.text()
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
"""PDN mode: multi-supply / multi-load solves against analytic and 1D
|
||||||
|
references, error paths, adaptive equivalence, JSON schema v7."""
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fill_resistance import config, pipeline, raster, solver
|
||||||
|
from fill_resistance.errors import ConnectivityError, ElectrodeError
|
||||||
|
from fill_resistance.geometry import (Electrode, Terminal, problem_from_json,
|
||||||
|
problem_to_json)
|
||||||
|
from tests.util import NM, make_multilayer, make_problem, rect_mm, sigma_s
|
||||||
|
|
||||||
|
H_MM = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _strip(length=50.0, width=10.0):
|
||||||
|
"""Bare uniform strip; terminals are attached by the caller."""
|
||||||
|
outline = [(0, 0), (length, 0), (length, width), (0, width)]
|
||||||
|
p = make_problem([(outline, [])], rect1_mm=(0, 0, 1, 1),
|
||||||
|
rect2_mm=(2, 2, 3, 3))
|
||||||
|
p.electrodes1 = []
|
||||||
|
p.electrodes2 = []
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _term(role, rect, label, contact="all", **kw):
|
||||||
|
return Terminal(role=role,
|
||||||
|
electrodes=[Electrode(rect=rect_mm(rect),
|
||||||
|
contact=contact, label=label)],
|
||||||
|
label=label, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_pdn(p, h_mm=H_MM, freq=0.0, v_nominal=None):
|
||||||
|
stack = raster.rasterize_stack(p, int(h_mm * NM))
|
||||||
|
tm = raster.terminal_masks(stack, p)
|
||||||
|
tp = raster.terminal_partition(stack, p)
|
||||||
|
return solver.run_solve_pdn(p, stack, tm, tp, freq, v_nominal), stack
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_classic(p, h_mm=H_MM):
|
||||||
|
stack = raster.rasterize_stack(p, int(h_mm * NM))
|
||||||
|
e1, e2 = raster.electrode_masks(stack, p)
|
||||||
|
return solver.run_solve(p, stack, e1, e2, 1.0, contact_model="uniform")
|
||||||
|
|
||||||
|
|
||||||
|
def _pdn_1d_reference(m_cols, rows, dirichlet, v_dir, inj):
|
||||||
|
"""Independent 1D chain reference: nodes = columns, face conductance
|
||||||
|
sigma_s * rows between neighbors, Dirichlet columns pinned at v_dir,
|
||||||
|
per-column injection [A]. Returns node volts."""
|
||||||
|
g = sigma_s() * rows
|
||||||
|
n = m_cols
|
||||||
|
A = np.zeros((n, n))
|
||||||
|
for k in range(n - 1):
|
||||||
|
A[k, k] += g
|
||||||
|
A[k + 1, k + 1] += g
|
||||||
|
A[k, k + 1] -= g
|
||||||
|
A[k + 1, k] -= g
|
||||||
|
free = ~np.asarray(dirichlet)
|
||||||
|
v = np.asarray(v_dir, dtype=float).copy()
|
||||||
|
b = np.asarray(inj, dtype=float)[free] \
|
||||||
|
- A[np.ix_(free, ~free)] @ v[~free]
|
||||||
|
v[free] = np.linalg.solve(A[np.ix_(free, free)], b)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_ideal_supplies_split_matches_1d():
|
||||||
|
"""Ideal supplies at both strip ends, an off-center load band: the
|
||||||
|
current split and the load voltage must match an independent 1D
|
||||||
|
computation exactly (all rows are identical chains)."""
|
||||||
|
draw = 10.0
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "left", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("supply", (45, 0, 50, 10), "right", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (25, 0, 30, 10), "band", i_draw_a=draw),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
|
||||||
|
dirichlet = np.zeros(100, dtype=bool)
|
||||||
|
dirichlet[:10] = dirichlet[90:] = True
|
||||||
|
v_dir = np.full(100, 3.3)
|
||||||
|
inj = np.zeros(100)
|
||||||
|
inj[50:60] = -draw / 10.0
|
||||||
|
v = _pdn_1d_reference(100, 20, dirichlet, v_dir, inj)
|
||||||
|
g = sigma_s() * 20
|
||||||
|
i_left = g * (v[9] - v[10])
|
||||||
|
i_right = g * (v[90] - v[89])
|
||||||
|
|
||||||
|
left, right = res.supplies
|
||||||
|
(band,) = res.loads
|
||||||
|
assert left.i_a == pytest.approx(i_left, rel=1e-9)
|
||||||
|
assert right.i_a == pytest.approx(i_right, rel=1e-9)
|
||||||
|
assert left.i_a + right.i_a == pytest.approx(draw, rel=1e-9)
|
||||||
|
assert band.v_mean == pytest.approx(float(v[50:60].mean()), rel=1e-9)
|
||||||
|
assert res.power_balance_rel < 1e-9
|
||||||
|
assert res.mismatch_rel < 1e-10
|
||||||
|
assert res.mode == "pdn"
|
||||||
|
assert np.isnan(res.R_ohm)
|
||||||
|
assert res.i_test == pytest.approx(draw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resistive_supply_thevenin_drop_exact():
|
||||||
|
"""Single-column end contacts (all contact cells equipotential by
|
||||||
|
symmetry, so every contact model coincides): the load voltage is
|
||||||
|
exactly v_oc - I * (r_out + R_classic)."""
|
||||||
|
draw, r_out, v_oc = 4.0, 0.007, 3.3
|
||||||
|
left = (0, 0, H_MM, 10)
|
||||||
|
right = (50 - H_MM, 0, 50, 10)
|
||||||
|
|
||||||
|
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
|
||||||
|
rect1_mm=left, rect2_mm=right)
|
||||||
|
r_classic = _solve_classic(classic).R_ohm
|
||||||
|
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", left, "src", r_out_ohm=r_out, v_oc=v_oc),
|
||||||
|
_term("load", right, "sink", i_draw_a=draw),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
(src,) = res.supplies
|
||||||
|
(sink,) = res.loads
|
||||||
|
assert src.i_a == pytest.approx(draw, rel=1e-9)
|
||||||
|
assert src.v_contact == pytest.approx(v_oc - draw * r_out, rel=1e-9)
|
||||||
|
assert src.p_internal_w == pytest.approx(draw ** 2 * r_out, rel=1e-9)
|
||||||
|
assert sink.v_mean == pytest.approx(
|
||||||
|
v_oc - draw * (r_out + r_classic), rel=1e-9)
|
||||||
|
assert sink.p_w == pytest.approx(draw * sink.v_mean, rel=1e-12)
|
||||||
|
assert res.power_balance_rel < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_unequal_voc_circulating_current():
|
||||||
|
"""Two resistive supplies with unequal v_oc and a zero-draw load:
|
||||||
|
the circulating current is dV / (r1 + r2 + R_strip)."""
|
||||||
|
r1, r2, v1, v2 = 0.010, 0.020, 3.30, 3.28
|
||||||
|
left = (0, 0, H_MM, 10)
|
||||||
|
right = (50 - H_MM, 0, 50, 10)
|
||||||
|
|
||||||
|
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
|
||||||
|
rect1_mm=left, rect2_mm=right)
|
||||||
|
r_strip = _solve_classic(classic).R_ohm
|
||||||
|
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", left, "hi", r_out_ohm=r1, v_oc=v1),
|
||||||
|
_term("supply", right, "lo", r_out_ohm=r2, v_oc=v2),
|
||||||
|
_term("load", (25, 0, 25.5, 10), "probe", i_draw_a=0.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
hi, lo = res.supplies
|
||||||
|
i_circ = (v1 - v2) / (r1 + r2 + r_strip)
|
||||||
|
assert hi.i_a == pytest.approx(i_circ, rel=1e-9)
|
||||||
|
assert lo.i_a == pytest.approx(-i_circ, rel=1e-9)
|
||||||
|
assert res.power_balance_rel < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_draw_probe_is_flat_at_voc():
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (40, 0, 45, 10), "probe", i_draw_a=0.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
v = res.V[np.isfinite(res.V)]
|
||||||
|
assert np.allclose(v, 3.3)
|
||||||
|
assert res.supplies[0].i_a == pytest.approx(0.0, abs=1e-6)
|
||||||
|
assert res.power_balance_rel == 0.0 # nothing to balance
|
||||||
|
|
||||||
|
|
||||||
|
def test_via_carries_the_full_load_draw():
|
||||||
|
"""Supply on L0, load on L1, one barrel: every ampere of the draw
|
||||||
|
crosses the via."""
|
||||||
|
draw = 4.0
|
||||||
|
square = [(0, 0), (20, 0), (20, 5), (0, 5)]
|
||||||
|
p = make_multilayer([[(square, [])], [(square, [])]],
|
||||||
|
rect1_mm=(0, 0, 1, 1), rect2_mm=(2, 2, 3, 3),
|
||||||
|
vias_mm=[(18.0, 2.5)])
|
||||||
|
p.electrodes1 = []
|
||||||
|
p.electrodes2 = []
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 2, 5), "src", contact="L0",
|
||||||
|
r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (4, 0, 6, 5), "sink", contact="L1", i_draw_a=draw),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p, h_mm=0.25)
|
||||||
|
assert len(res.via_reports) == 1
|
||||||
|
assert res.via_reports[0].current_a == pytest.approx(draw, rel=1e-9)
|
||||||
|
assert res.power_balance_rel < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_matches_uniform(monkeypatch):
|
||||||
|
"""Plate with a hole, 2 resistive supplies + 2 loads: the adaptive
|
||||||
|
leaf solve must match the uniform reference closely on supply
|
||||||
|
currents and load voltage DROPS (drops, not absolute volts - the
|
||||||
|
3.3 V offset would hide any error)."""
|
||||||
|
hole = [(12, 6), (18, 6), (18, 10), (12, 10)]
|
||||||
|
outline = [(0, 0), (30, 0), (30, 16), (0, 16)]
|
||||||
|
|
||||||
|
def build():
|
||||||
|
p = make_problem([(outline, [hole])], rect1_mm=(0, 0, 1, 1),
|
||||||
|
rect2_mm=(2, 2, 3, 3))
|
||||||
|
p.electrodes1 = []
|
||||||
|
p.electrodes2 = []
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 1, 16), "s_left",
|
||||||
|
r_out_ohm=0.003, v_oc=3.3),
|
||||||
|
_term("supply", (29, 0, 30, 16), "s_right",
|
||||||
|
r_out_ohm=0.010, v_oc=3.3),
|
||||||
|
_term("load", (8, 12, 12, 16), "l_top", i_draw_a=3.0),
|
||||||
|
_term("load", (20, 1, 26, 4), "l_bot", i_draw_a=1.5),
|
||||||
|
]
|
||||||
|
return p
|
||||||
|
|
||||||
|
ref, _ = _solve_pdn(build(), h_mm=0.25)
|
||||||
|
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
|
||||||
|
ada, _ = _solve_pdn(build(), h_mm=0.25)
|
||||||
|
|
||||||
|
for s_r, s_a in zip(ref.supplies, ada.supplies):
|
||||||
|
assert s_a.i_a == pytest.approx(s_r.i_a, rel=2e-3)
|
||||||
|
for l_r, l_a in zip(ref.loads, ada.loads):
|
||||||
|
assert (3.3 - l_a.v_mean) == pytest.approx(3.3 - l_r.v_mean,
|
||||||
|
rel=2e-3)
|
||||||
|
assert ada.power_balance_rel < 1e-9
|
||||||
|
assert ada.mismatch_rel < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac_pdn_drops_increase(monkeypatch):
|
||||||
|
p = _strip()
|
||||||
|
|
||||||
|
def terms():
|
||||||
|
return [
|
||||||
|
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.001,
|
||||||
|
v_oc=3.3),
|
||||||
|
_term("load", (45, 0, 50, 10), "sink", i_draw_a=5.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
p.terminals = terms()
|
||||||
|
dc, _ = _solve_pdn(p)
|
||||||
|
p2 = _strip()
|
||||||
|
p2.terminals = terms()
|
||||||
|
ac, _ = _solve_pdn(p2, freq=2e6)
|
||||||
|
drop_dc = 3.3 - dc.loads[0].v_mean
|
||||||
|
drop_ac = 3.3 - ac.loads[0].v_mean
|
||||||
|
assert drop_ac > drop_dc
|
||||||
|
assert ac.power_balance_rel < 1e-3
|
||||||
|
|
||||||
|
|
||||||
|
def test_v_nominal_default_applies():
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0), # no v_oc
|
||||||
|
_term("load", (45, 0, 50, 10), "sink", i_draw_a=1.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p, v_nominal=5.0)
|
||||||
|
assert res.supplies[0].v_oc == pytest.approx(5.0)
|
||||||
|
assert res.v_nominal == pytest.approx(5.0)
|
||||||
|
assert res.loads[0].v_mean < 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _two_part_load_strip(bonded):
|
||||||
|
"""Ideal supply at the left end; one load made of TWO narrow
|
||||||
|
full-width bands at different distances (a 'package' with two
|
||||||
|
contacts)."""
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
Terminal(role="load", label="pkg", i_draw_a=8.0, bonded=bonded,
|
||||||
|
electrodes=[
|
||||||
|
Electrode(rect=rect_mm((24.5, 0, 25, 10)),
|
||||||
|
label="p1"),
|
||||||
|
Electrode(rect=rect_mm((44.5, 0, 45, 10)),
|
||||||
|
label="p2"),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonded_load_split_follows_network():
|
||||||
|
"""Non-bonded: the draw splits by area share (half/half here).
|
||||||
|
Bonded: the parts are one lug, so the bond short-circuits the
|
||||||
|
copper between them - the near part takes (essentially) all the
|
||||||
|
current and the copper beyond it sits flat at the lug potential."""
|
||||||
|
res_u, _ = _solve_pdn(_two_part_load_strip(False))
|
||||||
|
assert dict(res_u.loads[0].part_currents) == pytest.approx(
|
||||||
|
{"p1": 4.0, "p2": 4.0})
|
||||||
|
|
||||||
|
res_b, _ = _solve_pdn(_two_part_load_strip(True))
|
||||||
|
pcs = dict(res_b.loads[0].part_currents)
|
||||||
|
assert pcs["p1"] == pytest.approx(8.0, abs=1e-6)
|
||||||
|
assert pcs["p2"] == pytest.approx(0.0, abs=1e-6)
|
||||||
|
assert pcs["p1"] + pcs["p2"] == pytest.approx(8.0, abs=1e-6)
|
||||||
|
assert res_b.power_balance_rel < 1e-9
|
||||||
|
# copper between the bonded parts: flat at the lug potential
|
||||||
|
lug = res_b.loads[0].v_mean
|
||||||
|
mid = res_b.V[0][10, 60:88]
|
||||||
|
assert float(np.nanmax(np.abs(mid - lug))) < 1e-8
|
||||||
|
# and the bonded load drops LESS than the area-share one (the lug
|
||||||
|
# takes the shorter path)
|
||||||
|
assert res_b.loads[0].v_mean > res_u.loads[0].v_mean
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonded_supply_is_a_lug_with_series_r():
|
||||||
|
"""Bonded resistive supply with parts at BOTH strip ends feeding a
|
||||||
|
center load: the contact face is one equipotential lug with the
|
||||||
|
whole r_out in series, and the split is symmetric."""
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
Terminal(role="supply", label="lug", r_out_ohm=0.01, v_oc=3.3,
|
||||||
|
bonded=True,
|
||||||
|
electrodes=[
|
||||||
|
Electrode(rect=rect_mm((0, 0, 0.5, 10)), label="a"),
|
||||||
|
Electrode(rect=rect_mm((49.5, 0, 50, 10)),
|
||||||
|
label="b"),
|
||||||
|
]),
|
||||||
|
_term("load", (24.5, 0, 25.5, 10), "mid", i_draw_a=6.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
(s,) = res.supplies
|
||||||
|
assert s.i_a == pytest.approx(6.0, abs=1e-6)
|
||||||
|
assert s.v_contact == pytest.approx(3.3 - 6.0 * 0.01, abs=1e-8)
|
||||||
|
assert s.p_internal_w == pytest.approx(36.0 * 0.01, rel=1e-6)
|
||||||
|
d = dict(s.part_currents)
|
||||||
|
assert d["a"] == pytest.approx(3.0, abs=1e-6)
|
||||||
|
assert d["b"] == pytest.approx(3.0, abs=1e-6)
|
||||||
|
assert res.power_balance_rel < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonded_load_adaptive_matches_uniform(monkeypatch):
|
||||||
|
ref, _ = _solve_pdn(_two_part_load_strip(True))
|
||||||
|
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
|
||||||
|
ada, _ = _solve_pdn(_two_part_load_strip(True))
|
||||||
|
for (pl_r, a_r), (pl_a, a_a) in zip(ref.loads[0].part_currents,
|
||||||
|
ada.loads[0].part_currents):
|
||||||
|
assert a_a == pytest.approx(a_r, abs=1e-6)
|
||||||
|
# grids differ by the documented deferred-correction bound: compare
|
||||||
|
# the DROPS (the 3.3 V offset would mask any real error)
|
||||||
|
assert (3.3 - ada.loads[0].v_mean) == pytest.approx(
|
||||||
|
3.3 - ref.loads[0].v_mean, rel=1e-3)
|
||||||
|
assert ada.power_balance_rel < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonded_load_may_span_sheets(capsys):
|
||||||
|
"""A bonded load bridging two disconnected sheets is fine - the
|
||||||
|
external bond IS the connection (symmetric islands: half each)."""
|
||||||
|
p = _two_islands()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
Terminal(role="load", label="pkg", i_draw_a=4.0, bonded=True,
|
||||||
|
electrodes=[
|
||||||
|
Electrode(rect=rect_mm((9, 0, 10, 10)), label="a"),
|
||||||
|
Electrode(rect=rect_mm((29, 0, 30, 10)), label="b"),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
assert "bonded load 'pkg' spans 2" in capsys.readouterr().out
|
||||||
|
d = dict(res.loads[0].part_currents)
|
||||||
|
assert d["a"] == pytest.approx(2.0, abs=1e-6) # symmetric split
|
||||||
|
assert d["b"] == pytest.approx(2.0, abs=1e-6)
|
||||||
|
assert res.power_balance_rel < 1e-8
|
||||||
|
|
||||||
|
|
||||||
|
# --- source-sink pair matrix -------------------------------------------------
|
||||||
|
|
||||||
|
def test_pair_r_matches_classic_uniform_model():
|
||||||
|
"""Resistive supply + load, both uniform-injection patterns: the
|
||||||
|
pair resistance IS the classic uniform-model R between the same
|
||||||
|
two rectangles - and r_out must not leak into it."""
|
||||||
|
left, right = (0, 0, 1, 10), (49, 0, 50, 10)
|
||||||
|
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
|
||||||
|
rect1_mm=left, rect2_mm=right)
|
||||||
|
r_ref = _solve_classic(classic).R_ohm
|
||||||
|
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", left, "src", r_out_ohm=0.005, v_oc=3.3),
|
||||||
|
_term("load", right, "sink", i_draw_a=4.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
(pr,) = res.pairs
|
||||||
|
assert (pr.supply, pr.load) == ("src", "sink")
|
||||||
|
assert pr.r_ohm == pytest.approx(r_ref, rel=1e-9)
|
||||||
|
assert pr.i_share_a == pytest.approx(4.0, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_r_single_column_contacts_every_model_coincides():
|
||||||
|
"""Single-column end contacts are equipotential by symmetry, so
|
||||||
|
the ideal supply's equipotential pattern and the resistive
|
||||||
|
supply's uniform pattern give the SAME pair R - the classic
|
||||||
|
end-to-end resistance, exactly."""
|
||||||
|
left = (0, 0, H_MM, 10)
|
||||||
|
right = (50 - H_MM, 0, 50, 10)
|
||||||
|
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
|
||||||
|
rect1_mm=left, rect2_mm=right)
|
||||||
|
r_ref = _solve_classic(classic).R_ohm
|
||||||
|
for r_out in (0.0, 0.02):
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", left, "src", r_out_ohm=r_out, v_oc=3.3),
|
||||||
|
_term("load", right, "sink", i_draw_a=2.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
assert res.pairs[0].r_ohm == pytest.approx(r_ref, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_loss_allocation_sums_to_copper_loss():
|
||||||
|
"""Two ideal supplies + one load: each pair's attributed current is
|
||||||
|
that supply's delivered current, and the attributed losses sum
|
||||||
|
EXACTLY to the copper dissipation (Tellegen)."""
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "left", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("supply", (45, 0, 50, 10), "right", r_out_ohm=0.0,
|
||||||
|
v_oc=3.3),
|
||||||
|
_term("load", (25, 0, 30, 10), "band", i_draw_a=10.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
assert len(res.pairs) == 2
|
||||||
|
for pr, s_ in zip(res.pairs, res.supplies):
|
||||||
|
assert pr.r_ohm > 0
|
||||||
|
assert pr.i_share_a == pytest.approx(s_.i_a, rel=1e-9)
|
||||||
|
assert sum(pr.i_share_a for pr in res.pairs) == pytest.approx(
|
||||||
|
10.0, rel=1e-9)
|
||||||
|
assert sum(pr.p_w for pr in res.pairs) == pytest.approx(
|
||||||
|
res.P_total, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_no_common_path_between_islands():
|
||||||
|
"""Cross-island pairs report no path and get no allocation; the
|
||||||
|
per-island allocation carries the island's full draw and the total
|
||||||
|
still matches the copper loss exactly."""
|
||||||
|
p = _two_islands()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (9, 0, 10, 10), "la", i_draw_a=2.0),
|
||||||
|
_term("load", (29, 0, 30, 10), "lb", i_draw_a=3.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
d = {(pr.supply, pr.load): pr for pr in res.pairs}
|
||||||
|
assert len(d) == 4
|
||||||
|
assert d[("sa", "la")].r_ohm > 0
|
||||||
|
assert d[("sb", "lb")].r_ohm > 0
|
||||||
|
assert d[("sa", "lb")].r_ohm is None
|
||||||
|
assert d[("sb", "la")].r_ohm is None
|
||||||
|
assert d[("sa", "lb")].i_share_a == 0.0
|
||||||
|
assert d[("sa", "la")].i_share_a == pytest.approx(2.0, rel=1e-9)
|
||||||
|
assert d[("sb", "lb")].i_share_a == pytest.approx(3.0, rel=1e-9)
|
||||||
|
assert sum(pr.p_w for pr in res.pairs) == pytest.approx(
|
||||||
|
res.P_total, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_matrix_adaptive_matches_uniform(monkeypatch):
|
||||||
|
"""The pair solves reuse the deferred-correction loop, so adaptive
|
||||||
|
pair resistances track the uniform grid at the usual accuracy, and
|
||||||
|
the allocation identity stays exact on the adaptive grid too."""
|
||||||
|
hole = [(12, 6), (18, 6), (18, 10), (12, 10)]
|
||||||
|
outline = [(0, 0), (30, 0), (30, 16), (0, 16)]
|
||||||
|
|
||||||
|
def build():
|
||||||
|
p = make_problem([(outline, [hole])], rect1_mm=(0, 0, 1, 1),
|
||||||
|
rect2_mm=(2, 2, 3, 3))
|
||||||
|
p.electrodes1 = []
|
||||||
|
p.electrodes2 = []
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 1, 16), "s_left",
|
||||||
|
r_out_ohm=0.003, v_oc=3.3),
|
||||||
|
_term("supply", (29, 0, 30, 16), "s_right",
|
||||||
|
r_out_ohm=0.010, v_oc=3.3),
|
||||||
|
_term("load", (8, 12, 12, 16), "l_top", i_draw_a=3.0),
|
||||||
|
_term("load", (20, 1, 26, 4), "l_bot", i_draw_a=1.5),
|
||||||
|
]
|
||||||
|
return p
|
||||||
|
|
||||||
|
ref, _ = _solve_pdn(build(), h_mm=0.25)
|
||||||
|
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
|
||||||
|
ada, _ = _solve_pdn(build(), h_mm=0.25)
|
||||||
|
assert len(ada.pairs) == len(ref.pairs) == 4
|
||||||
|
for pr, pa in zip(ref.pairs, ada.pairs):
|
||||||
|
assert pa.r_ohm == pytest.approx(pr.r_ohm, rel=2e-3)
|
||||||
|
assert pa.p_w == pytest.approx(pr.p_w, rel=2e-2)
|
||||||
|
assert sum(p_.p_w for p_ in ada.pairs) == pytest.approx(
|
||||||
|
ada.P_total, rel=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_table_renders_as_a_figure(tmp_path):
|
||||||
|
from matplotlib.table import Table
|
||||||
|
|
||||||
|
from fill_resistance import plots
|
||||||
|
p = _two_islands()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (9, 0, 10, 10), "la", i_draw_a=2.0),
|
||||||
|
_term("load", (29, 0, 30, 10), "lb", i_draw_a=3.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
fig = plots.fig_pdn_pairs(res)
|
||||||
|
try:
|
||||||
|
(tbl,) = [c for c in fig.axes[0].get_children()
|
||||||
|
if isinstance(c, Table)]
|
||||||
|
texts = {c.get_text().get_text()
|
||||||
|
for c in tbl.get_celld().values()}
|
||||||
|
# terminals are keyed by their labels alone - no S#/L# tags
|
||||||
|
# (they would collide with auto-names like "S1")
|
||||||
|
assert "sa" in texts and "lb" in texts
|
||||||
|
assert not any(t.startswith("S1 ") for t in texts)
|
||||||
|
assert "no path" in texts # the cross-island pairs
|
||||||
|
assert len(fig.axes) == 1 # no component/comment: no legend
|
||||||
|
fig.savefig(tmp_path / "pairs.png") # renders without error
|
||||||
|
finally:
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_component_and_comment_reach_summary_and_figure(tmp_path):
|
||||||
|
from matplotlib.table import Table
|
||||||
|
|
||||||
|
from fill_resistance import plots, report
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "S9", r_out_ohm=0.001, v_oc=3.3),
|
||||||
|
_term("load", (45, 0, 50, 10), "L1", i_draw_a=5.0),
|
||||||
|
]
|
||||||
|
p.terminals[0].component = "near U5"
|
||||||
|
p.terminals[0].comment = "buck output"
|
||||||
|
p.terminals[1].component = "U7"
|
||||||
|
res, stack = _solve_pdn(p)
|
||||||
|
assert res.supplies[0].comment == "buck output"
|
||||||
|
assert res.loads[0].component == "U7"
|
||||||
|
|
||||||
|
text = report.write_summary(tmp_path, p, stack,
|
||||||
|
res).read_text(encoding="utf-8")
|
||||||
|
assert "near U5 # buck output" in text
|
||||||
|
assert "S9 -> L1" in text # labels only, no positional tags
|
||||||
|
|
||||||
|
fig = plots.fig_pdn_pairs(res)
|
||||||
|
try:
|
||||||
|
assert len(fig.axes) == 2 # pair table + terminal legend
|
||||||
|
(leg,) = [c for c in fig.axes[1].get_children()
|
||||||
|
if isinstance(c, Table)]
|
||||||
|
texts = {c.get_text().get_text()
|
||||||
|
for c in leg.get_celld().values()}
|
||||||
|
assert {"S9", "near U5", "buck output", "U7"} <= texts
|
||||||
|
fig.savefig(tmp_path / "pairs2.png")
|
||||||
|
finally:
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_table_lands_in_the_summary(tmp_path):
|
||||||
|
from fill_resistance import report
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.001,
|
||||||
|
v_oc=3.3),
|
||||||
|
_term("load", (45, 0, 50, 10), "sink", i_draw_a=5.0),
|
||||||
|
]
|
||||||
|
res, stack = _solve_pdn(p)
|
||||||
|
text = report.write_summary(tmp_path, p, stack,
|
||||||
|
res).read_text(encoding="utf-8")
|
||||||
|
assert "source-sink pairs" in text
|
||||||
|
assert "src -> sink" in text # labels only, no S#/L# tags
|
||||||
|
assert "attributed copper loss total" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- error paths -------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_no_supply_or_no_load_is_an_error():
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [_term("load", (0, 0, 5, 10), "l", i_draw_a=1.0)]
|
||||||
|
with pytest.raises(ElectrodeError, match="at least one supply"):
|
||||||
|
_solve_pdn(p)
|
||||||
|
p2 = _strip()
|
||||||
|
p2.terminals = [_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0)]
|
||||||
|
with pytest.raises(ElectrodeError, match="at least one load"):
|
||||||
|
_solve_pdn(p2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_values_are_errors():
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=-1.0),
|
||||||
|
_term("load", (45, 0, 50, 10), "l", i_draw_a=1.0),
|
||||||
|
]
|
||||||
|
with pytest.raises(ElectrodeError, match="r_out_ohm"):
|
||||||
|
_solve_pdn(p)
|
||||||
|
p2 = _strip()
|
||||||
|
p2.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0),
|
||||||
|
_term("load", (45, 0, 50, 10), "l", i_draw_a=-2.0),
|
||||||
|
]
|
||||||
|
with pytest.raises(ElectrodeError, match="i_draw_a"):
|
||||||
|
_solve_pdn(p2)
|
||||||
|
|
||||||
|
|
||||||
|
def _two_islands():
|
||||||
|
"""Two disjoint copper squares on one layer."""
|
||||||
|
a = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
||||||
|
b = [(20, 0), (30, 0), (30, 10), (20, 10)]
|
||||||
|
p = make_problem([(a, []), (b, [])], rect1_mm=(0, 0, 1, 1),
|
||||||
|
rect2_mm=(2, 2, 3, 3))
|
||||||
|
# make_problem puts each polygon set on its own layer; rebuild as
|
||||||
|
# ONE layer holding both islands
|
||||||
|
from fill_resistance.geometry import LayerFill, Polygon
|
||||||
|
from tests.util import ring_mm
|
||||||
|
p.layers = [LayerFill(layer_name="F.Cu", thickness_nm=70_000, z_nm=0,
|
||||||
|
polygons=[Polygon(outline=ring_mm(a)),
|
||||||
|
Polygon(outline=ring_mm(b))])]
|
||||||
|
p.electrodes1 = []
|
||||||
|
p.electrodes2 = []
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_on_unreachable_island():
|
||||||
|
p = _two_islands()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 1, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (5, 0, 6, 10), "near", i_draw_a=1.0),
|
||||||
|
_term("load", (25, 0, 26, 10), "far", i_draw_a=1.0),
|
||||||
|
]
|
||||||
|
with pytest.raises(ConnectivityError, match="far"):
|
||||||
|
_solve_pdn(p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_connects_supply_to_load():
|
||||||
|
p = _two_islands()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 1, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (25, 0, 26, 10), "far", i_draw_a=1.0),
|
||||||
|
]
|
||||||
|
with pytest.raises(ConnectivityError, match="No copper component"):
|
||||||
|
_solve_pdn(p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_spanning_two_sheets():
|
||||||
|
p = _two_islands()
|
||||||
|
p.terminals = [
|
||||||
|
Terminal(role="supply", label="src", r_out_ohm=0.01, v_oc=3.3,
|
||||||
|
electrodes=[Electrode(rect=rect_mm((0, 0, 1, 10)),
|
||||||
|
label="a"),
|
||||||
|
Electrode(rect=rect_mm((29, 0, 30, 10)),
|
||||||
|
label="b")]),
|
||||||
|
Terminal(role="load", label="split", i_draw_a=2.0,
|
||||||
|
electrodes=[Electrode(rect=rect_mm((5, 0, 6, 10)),
|
||||||
|
label="a"),
|
||||||
|
Electrode(rect=rect_mm((24, 0, 25, 10)),
|
||||||
|
label="b")]),
|
||||||
|
]
|
||||||
|
with pytest.raises(ConnectivityError, match="split.*disconnected"):
|
||||||
|
_solve_pdn(p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_supply_only_island_warns_and_reports_zero(capsys):
|
||||||
|
p = _two_islands()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 1, 10), "main", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("supply", (25, 0, 26, 10), "orphan", r_out_ohm=0.01,
|
||||||
|
v_oc=3.3),
|
||||||
|
_term("load", (5, 0, 6, 10), "l", i_draw_a=1.0),
|
||||||
|
]
|
||||||
|
res, _ = _solve_pdn(p)
|
||||||
|
assert "orphan" in capsys.readouterr().out
|
||||||
|
by_label = {s.label: s for s in res.supplies}
|
||||||
|
assert by_label["orphan"].i_a == 0.0
|
||||||
|
assert by_label["main"].i_a == pytest.approx(1.0, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlapping_terminals_error_names_both():
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (3, 0, 8, 10), "clash", i_draw_a=1.0),
|
||||||
|
]
|
||||||
|
stack = raster.rasterize_stack(p, int(H_MM * NM))
|
||||||
|
with pytest.raises(ElectrodeError, match="src.*clash|clash.*src"):
|
||||||
|
raster.terminal_masks(stack, p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_rejects_mixed_terminal_schemes(tmp_path):
|
||||||
|
from tests.util import strip_problem
|
||||||
|
p = strip_problem()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0, v_oc=3.3),
|
||||||
|
_term("load", (45, 0, 50, 10), "l", i_draw_a=1.0),
|
||||||
|
]
|
||||||
|
with pytest.raises(ElectrodeError, match="both classic"):
|
||||||
|
pipeline.run(p, None, show=False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- JSON schema v7 ----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_json_v8_roundtrip_with_terminals():
|
||||||
|
p = _strip()
|
||||||
|
p.terminals = [
|
||||||
|
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.004, v_oc=3.28),
|
||||||
|
_term("load", (45, 0, 50, 10), "sink", i_draw_a=2.5, bonded=True),
|
||||||
|
]
|
||||||
|
d = problem_to_json(p)
|
||||||
|
assert d["schema_version"] == 8
|
||||||
|
q = problem_from_json(d)
|
||||||
|
assert len(q.terminals) == 2
|
||||||
|
src, sink = q.terminals
|
||||||
|
assert src.role == "supply"
|
||||||
|
assert src.label == "src"
|
||||||
|
assert src.r_out_ohm == pytest.approx(0.004)
|
||||||
|
assert src.v_oc == pytest.approx(3.28)
|
||||||
|
assert src.bonded is False
|
||||||
|
assert sink.role == "load"
|
||||||
|
assert sink.i_draw_a == pytest.approx(2.5)
|
||||||
|
assert sink.v_oc is None
|
||||||
|
assert sink.bonded is True
|
||||||
|
assert sink.electrodes[0].rect == p.terminals[1].electrodes[0].rect
|
||||||
|
# a v7 dump (no bonded keys) loads with bonded=False
|
||||||
|
for td in d["terminals"]:
|
||||||
|
del td["bonded"]
|
||||||
|
d["schema_version"] = 7
|
||||||
|
assert all(t.bonded is False for t in problem_from_json(d).terminals)
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_v6_dumps_load_classic():
|
||||||
|
from tests.util import strip_problem
|
||||||
|
p = strip_problem()
|
||||||
|
d = problem_to_json(p)
|
||||||
|
d["schema_version"] = 6
|
||||||
|
del d["terminals"]
|
||||||
|
q = problem_from_json(d)
|
||||||
|
assert q.terminals == []
|
||||||
|
assert len(q.electrodes1) == 1 and len(q.electrodes2) == 1
|
||||||
@@ -122,3 +122,27 @@ def test_dc_default_unchanged():
|
|||||||
assert res.freq_hz == 0.0
|
assert res.freq_hz == 0.0
|
||||||
assert res.skin_depth_um is None
|
assert res.skin_depth_um is None
|
||||||
assert all(r == 1.0 for r in res.rs_ratios)
|
assert all(r == 1.0 for r in res.rs_ratios)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text, value", [
|
||||||
|
("50m", 0.05), ("4.7k", 4700.0), ("2M", 2e6), ("10", 10.0),
|
||||||
|
("3,3", 3.3), ("1,5k", 1500.0), ("500u", 5e-4), ("2µ", 2e-6),
|
||||||
|
("100n", 1e-7), ("1p", 1e-12), ("1G", 1e9), ("4K", 4000.0),
|
||||||
|
("50 m", 0.05), ("-2m", -0.002), ("1e3", 1000.0), ("0", 0.0),
|
||||||
|
])
|
||||||
|
def test_parse_engineering_values(text, value):
|
||||||
|
assert skin.parse_engineering(text) == pytest.approx(value, rel=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["", "m", "junk", "5k5", "1,500k",
|
||||||
|
"1.2.3", "50 m m"])
|
||||||
|
def test_parse_engineering_rejects_garbage(bad):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
skin.parse_engineering(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_engineering_case_separates_milli_from_mega():
|
||||||
|
# exactly the trap parse_frequency sidesteps by lowercasing: for
|
||||||
|
# general values 50m and 50M are 9 orders of magnitude apart
|
||||||
|
assert skin.parse_engineering("50m") == pytest.approx(0.05)
|
||||||
|
assert skin.parse_engineering("50M") == pytest.approx(5e7)
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"""Config-file part references resolved against a fake board.
|
||||||
|
|
||||||
|
Real kipy objects (Pad/Via/BoardRectangle/BoardText - _to_electrode and
|
||||||
|
the labeled-rectangle scan dispatch on isinstance), a duck-typed board.
|
||||||
|
"""
|
||||||
|
from types import SimpleNamespace as NS
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from kipy.board_types import BoardRectangle, BoardText, Net, Pad, Via
|
||||||
|
from kipy.geometry import Vector2
|
||||||
|
from kipy.util.board_layer import layer_from_canonical_name
|
||||||
|
|
||||||
|
from fill_resistance import board_io, config
|
||||||
|
from fill_resistance.configfile import PartRef, TerminalSpec
|
||||||
|
from fill_resistance.errors import ConfigError, SelectionError
|
||||||
|
from fill_resistance.geometry import Polygon
|
||||||
|
|
||||||
|
MM = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _pad(x_mm, y_mm, number, net):
|
||||||
|
p = Pad()
|
||||||
|
p.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
|
||||||
|
p.number = number
|
||||||
|
p.net = Net(name=net)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _fp(ref, pads):
|
||||||
|
return NS(reference_field=NS(text=NS(value=ref)),
|
||||||
|
definition=NS(pads=pads))
|
||||||
|
|
||||||
|
|
||||||
|
def _rect(x0_mm, y0_mm, x1_mm, y1_mm, layer="User.3"):
|
||||||
|
r = BoardRectangle()
|
||||||
|
r.layer = layer_from_canonical_name(layer)
|
||||||
|
r.top_left = Vector2.from_xy(int(x0_mm * MM), int(y0_mm * MM))
|
||||||
|
r.bottom_right = Vector2.from_xy(int(x1_mm * MM), int(y1_mm * MM))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _text(x_mm, y_mm, value, layer="User.3"):
|
||||||
|
t = BoardText()
|
||||||
|
t.layer = layer_from_canonical_name(layer)
|
||||||
|
t.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
|
||||||
|
t.value = value
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _via(x_mm, y_mm, net, drill_mm=0.3):
|
||||||
|
v = Via()
|
||||||
|
v.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
|
||||||
|
v.net = Net(name=net)
|
||||||
|
v.drill_diameter = int(drill_mm * MM)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBoard:
|
||||||
|
def __init__(self, footprints=(), shapes=(), texts=(), vias=()):
|
||||||
|
self._fps = list(footprints)
|
||||||
|
self._shapes = list(shapes)
|
||||||
|
self._texts = list(texts)
|
||||||
|
self._vias = list(vias)
|
||||||
|
|
||||||
|
def get_footprints(self):
|
||||||
|
return list(self._fps)
|
||||||
|
|
||||||
|
def get_pads(self):
|
||||||
|
return [p for fp in self._fps for p in fp.definition.pads]
|
||||||
|
|
||||||
|
def get_shapes(self):
|
||||||
|
return list(self._shapes)
|
||||||
|
|
||||||
|
def get_text(self):
|
||||||
|
return list(self._texts)
|
||||||
|
|
||||||
|
def get_vias(self):
|
||||||
|
return list(self._vias)
|
||||||
|
|
||||||
|
def get_item_bounding_box(self, item):
|
||||||
|
p = item.position
|
||||||
|
return NS(pos=NS(x=p.x - 500_000, y=p.y - 500_000),
|
||||||
|
size=NS(x=1_000_000, y=1_000_000))
|
||||||
|
|
||||||
|
def get_pad_shapes_as_polygons(self, pad, layer):
|
||||||
|
return None # rect fallback is fine here
|
||||||
|
|
||||||
|
|
||||||
|
def _board():
|
||||||
|
u7 = _fp("U7", [_pad(10, 10, "1", "VCC"), _pad(12, 10, "2", "GND"),
|
||||||
|
_pad(14, 10, "3", "VCC")])
|
||||||
|
j1 = _fp("J1", [_pad(0, 0, "1", "VCC")])
|
||||||
|
return _FakeBoard(
|
||||||
|
footprints=[u7, j1],
|
||||||
|
shapes=[_rect(20, 20, 30, 26), _rect(40, 20, 46, 26),
|
||||||
|
_rect(50, 50, 52, 52)], # the last one unnamed
|
||||||
|
texts=[_text(25, 23, "ZONE_A"), _text(43, 23, "ZONE_B"),
|
||||||
|
_text(90, 90, "ELSEWHERE")],
|
||||||
|
vias=[_via(5, 5, "VCC"), _via(8, 5, "GND")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(board=None, net="VCC"):
|
||||||
|
return board_io._RefContext(board or _board(), None, net)
|
||||||
|
|
||||||
|
|
||||||
|
def test_footprint_resolves_only_net_pads():
|
||||||
|
els = _ctx().resolve(PartRef(kind="footprint", ref="U7"), "t")
|
||||||
|
assert len(els) == 2 # pads 1 and 3, not the GND one
|
||||||
|
assert all("VCC" in e.label for e in els)
|
||||||
|
|
||||||
|
|
||||||
|
def test_footprint_not_found():
|
||||||
|
with pytest.raises(ConfigError, match="'U9' not found"):
|
||||||
|
_ctx().resolve(PartRef(kind="footprint", ref="U9"), "t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_footprint_without_net_pads_lists_its_nets():
|
||||||
|
with pytest.raises(ConfigError, match="GND|VCC"):
|
||||||
|
_ctx(net="V5").resolve(PartRef(kind="footprint", ref="U7"), "t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pad_by_number():
|
||||||
|
els = _ctx().resolve(PartRef(kind="pad", ref="U7", pad="3"), "t")
|
||||||
|
assert len(els) == 1
|
||||||
|
assert els[0].label == "pad 3@VCC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pad_number_missing_lists_pads():
|
||||||
|
with pytest.raises(ConfigError, match="no pad '9'.*1.*2.*3"):
|
||||||
|
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="9"), "t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pad_net_mismatch():
|
||||||
|
with pytest.raises(ConfigError, match="'U7.2' is on 'GND', not 'VCC'"):
|
||||||
|
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="2"), "t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_refdes_is_ambiguous():
|
||||||
|
board = _board()
|
||||||
|
board._fps.append(_fp("U7", [_pad(50, 50, "1", "VCC")]))
|
||||||
|
with pytest.raises(ConfigError, match="ambiguous"):
|
||||||
|
_ctx(board).resolve(PartRef(kind="footprint", ref="U7"), "t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_labeled_rect_resolves():
|
||||||
|
els = _ctx().resolve(PartRef(kind="rect_label", label="ZONE_A"), "t")
|
||||||
|
assert len(els) == 1
|
||||||
|
r = els[0].rect
|
||||||
|
assert (r.x0, r.y0, r.x1, r.y1) == (20 * MM, 20 * MM, 30 * MM, 26 * MM)
|
||||||
|
assert els[0].contact == "all"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_label_lists_found_names():
|
||||||
|
with pytest.raises(ConfigError, match="ZONE_A.*ZONE_B"):
|
||||||
|
_ctx().resolve(PartRef(kind="rect_label", label="NOPE"), "t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unnamed_rect_warns_and_is_skipped(capsys):
|
||||||
|
ctx = _ctx()
|
||||||
|
ctx._labeled_rects(config.ELECTRODE_PDN_LAYER)
|
||||||
|
assert "unnamed rectangle" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_texts_in_one_rect_is_an_error():
|
||||||
|
board = _board()
|
||||||
|
board._texts.append(_text(26, 24, "ZONE_A2")) # also inside rect 1
|
||||||
|
with pytest.raises(ConfigError, match="2 text items"):
|
||||||
|
_ctx(board)._labeled_rects(config.ELECTRODE_PDN_LAYER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_name_rects_form_a_multipart_ref():
|
||||||
|
"""Two rectangles sharing one name are ONE multi-part reference
|
||||||
|
(the grouping mechanism for bonded multi-contact terminals)."""
|
||||||
|
board = _board()
|
||||||
|
board._texts.append(_text(43, 24, "ZONE_A")) # names rect 2 too
|
||||||
|
board._texts.remove(board._texts[1]) # drop ZONE_B
|
||||||
|
els = _ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
|
||||||
|
"t")
|
||||||
|
assert len(els) == 2
|
||||||
|
assert {e.rect.x0 for e in els} == {20 * MM, 40 * MM}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_mm_converts_and_scopes():
|
||||||
|
els = _ctx().resolve(PartRef(kind="rect_mm",
|
||||||
|
rect_mm=(1.0, 2.0, 3.0, 4.0),
|
||||||
|
contact="B.Cu"), "t")
|
||||||
|
r = els[0].rect
|
||||||
|
assert (r.x0, r.y0, r.x1, r.y1) == (1 * MM, 2 * MM, 3 * MM, 4 * MM)
|
||||||
|
assert els[0].contact == "B.Cu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_via_mm_nearest_of_the_net():
|
||||||
|
els = _ctx().resolve(PartRef(kind="via_mm", via_mm=(5.2, 5.0)), "t")
|
||||||
|
assert len(els) == 1
|
||||||
|
assert els[0].drill_nm == 300_000
|
||||||
|
assert els[0].center == (5 * MM, 5 * MM) # not the closer GND via
|
||||||
|
|
||||||
|
|
||||||
|
def test_via_mm_too_far():
|
||||||
|
with pytest.raises(ConfigError, match="mm away"):
|
||||||
|
_ctx().resolve(PartRef(kind="via_mm", via_mm=(5.0, 30.0)), "t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_terminal_specs_maps_names_and_scopes():
|
||||||
|
specs = [
|
||||||
|
TerminalSpec(name="src", role="supply",
|
||||||
|
parts=[PartRef(kind="pad", ref="J1", pad="1")],
|
||||||
|
r_out_ohm=0.01, v_oc=3.28),
|
||||||
|
TerminalSpec(name="zone", role="load",
|
||||||
|
parts=[PartRef(kind="rect_label", label="ZONE_A"),
|
||||||
|
PartRef(kind="rect_mm",
|
||||||
|
rect_mm=(0, 0, 1, 1),
|
||||||
|
contact="In1.Cu")],
|
||||||
|
i_draw_a=2.0, contact="F.Cu"),
|
||||||
|
]
|
||||||
|
terms = board_io.resolve_terminal_specs(_board(), None, specs, "VCC")
|
||||||
|
src, zone = terms
|
||||||
|
assert src.label == "src" and src.role == "supply"
|
||||||
|
assert src.r_out_ohm == pytest.approx(0.01)
|
||||||
|
assert src.v_oc == pytest.approx(3.28)
|
||||||
|
assert zone.i_draw_a == pytest.approx(2.0)
|
||||||
|
# terminal-level scope applies where the part has none...
|
||||||
|
assert zone.electrodes[0].contact == "F.Cu"
|
||||||
|
# ...but an explicit part-level contact wins
|
||||||
|
assert zone.electrodes[1].contact == "In1.Cu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_classic_parts():
|
||||||
|
es1, es2 = board_io.resolve_classic_parts(
|
||||||
|
_board(), None,
|
||||||
|
[PartRef(kind="pad", ref="J1", pad="1")],
|
||||||
|
[PartRef(kind="footprint", ref="U7")], "VCC")
|
||||||
|
assert len(es1) == 1 and len(es2) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- labeled rects across marker layers / the PDN editor scan ----------------
|
||||||
|
|
||||||
|
def test_labeled_rects_cached_per_layer():
|
||||||
|
ctx = _ctx()
|
||||||
|
a = ctx._labeled_rects("User.3")
|
||||||
|
assert ctx._labeled_rects("User.3") is a # keyed cache
|
||||||
|
assert ctx._labeled_rects("User.1") is not a
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_label_resolves_on_editor_marker_layers():
|
||||||
|
board = _board()
|
||||||
|
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
|
||||||
|
board._texts.append(_text(63, 12, "VIN", layer="User.1"))
|
||||||
|
els = _ctx(board).resolve(PartRef(kind="rect_label", label="VIN"), "t")
|
||||||
|
r = els[0].rect
|
||||||
|
assert (r.x0, r.y0) == (60 * MM, 10 * MM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rect_label_cross_layer_collision_errors():
|
||||||
|
board = _board()
|
||||||
|
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
|
||||||
|
board._texts.append(_text(63, 12, "ZONE_A", layer="User.1"))
|
||||||
|
with pytest.raises(ConfigError, match="User.3 and User.1"):
|
||||||
|
_ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
|
||||||
|
"t")
|
||||||
|
|
||||||
|
|
||||||
|
def _marker_board(pos=(), neg=(), extra_shapes=(), extra_texts=()):
|
||||||
|
"""pos/neg: iterables of (x0, y0, x1, y1, name_or_None)."""
|
||||||
|
shapes, texts = list(extra_shapes), list(extra_texts)
|
||||||
|
for layer, group in (("User.1", pos), ("User.2", neg)):
|
||||||
|
for x0, y0, x1, y1, name in group:
|
||||||
|
shapes.append(_rect(x0, y0, x1, y1, layer=layer))
|
||||||
|
if name is not None:
|
||||||
|
texts.append(_text((x0 + x1) / 2, (y0 + y1) / 2, name,
|
||||||
|
layer=layer))
|
||||||
|
return _FakeBoard(shapes=shapes, texts=texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_roles_names_and_reading_order():
|
||||||
|
board = _marker_board(
|
||||||
|
pos=[(0, 10, 2, 12, "VIN"), (0, 2, 2, 4, None)],
|
||||||
|
neg=[(20, 0, 22, 2, None), (10, 0, 12, 2, "CPU")])
|
||||||
|
terms = board_io.scan_marker_terminals(board)
|
||||||
|
# supplies first, each group in (y, x) reading order
|
||||||
|
assert [(t.name, t.role, t.labeled) for t in terms] == [
|
||||||
|
("S1", "supply", False), # y=2 before the labeled y=10 one
|
||||||
|
("VIN", "supply", True),
|
||||||
|
("CPU", "load", True), # same y: x=10 before x=20
|
||||||
|
("L1", "load", False),
|
||||||
|
]
|
||||||
|
assert terms[0].electrodes[0].label == "S1"
|
||||||
|
r = terms[1].electrodes[0].rect
|
||||||
|
assert (r.x0, r.y0, r.x1, r.y1) == (0, 10 * MM, 2 * MM, 12 * MM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_auto_names_skip_taken_labels():
|
||||||
|
board = _marker_board(pos=[(0, 0, 2, 2, "S1"), (0, 4, 2, 6, None)],
|
||||||
|
neg=[(10, 0, 12, 2, None)])
|
||||||
|
terms = board_io.scan_marker_terminals(board)
|
||||||
|
assert [t.name for t in terms] == ["S1", "S2", "L1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_requires_rects_on_both_layers():
|
||||||
|
board = _marker_board(pos=[(0, 0, 2, 2, None)], neg=[])
|
||||||
|
with pytest.raises(SelectionError, match="1 on User.1.*0 on User.2"):
|
||||||
|
board_io.scan_marker_terminals(board)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_duplicate_name_across_pos_and_neg():
|
||||||
|
board = _marker_board(pos=[(0, 0, 2, 2, "X")],
|
||||||
|
neg=[(10, 0, 12, 2, "X")])
|
||||||
|
with pytest.raises(ConfigError, match="User.1 and User.2"):
|
||||||
|
board_io.scan_marker_terminals(board)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_name_collision_with_pdn_layer_labels():
|
||||||
|
board = _marker_board(
|
||||||
|
pos=[(0, 0, 2, 2, "ZONE")], neg=[(10, 0, 12, 2, None)],
|
||||||
|
extra_shapes=[_rect(50, 50, 56, 54, layer="User.3")],
|
||||||
|
extra_texts=[_text(53, 52, "ZONE", layer="User.3")])
|
||||||
|
with pytest.raises(ConfigError, match="unique across"):
|
||||||
|
board_io.scan_marker_terminals(board)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_groups_same_label_rects_into_one_bonded_terminal():
|
||||||
|
"""Two rectangles labeled identically on one layer = one bonded
|
||||||
|
terminal (a multi-pin package: total known, split solved)."""
|
||||||
|
board = _marker_board(
|
||||||
|
pos=[(0, 0, 2, 2, "VIN")],
|
||||||
|
neg=[(10, 0, 12, 2, "PKG"), (20, 0, 22, 2, "PKG"),
|
||||||
|
(30, 0, 32, 2, None)])
|
||||||
|
terms = board_io.scan_marker_terminals(board)
|
||||||
|
assert [(t.name, t.role, t.bonded, len(t.electrodes))
|
||||||
|
for t in terms] == [
|
||||||
|
("VIN", "supply", False, 1),
|
||||||
|
("PKG", "load", True, 2),
|
||||||
|
("L1", "load", False, 1),
|
||||||
|
]
|
||||||
|
xs = sorted(e.rect.x0 for e in terms[1].electrodes)
|
||||||
|
assert xs == [10 * MM, 20 * MM]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_two_texts_in_one_rect_propagates():
|
||||||
|
board = _marker_board(pos=[(0, 0, 4, 4, "A")],
|
||||||
|
neg=[(10, 0, 12, 2, None)],
|
||||||
|
extra_texts=[_text(1, 1, "B", layer="User.1")])
|
||||||
|
with pytest.raises(ConfigError, match="2 text items"):
|
||||||
|
board_io.scan_marker_terminals(board)
|
||||||
|
|
||||||
|
|
||||||
|
# --- merging newly drawn rectangles into a config-backed set -----------------
|
||||||
|
|
||||||
|
def test_scan_without_require_both_allows_empty_layers():
|
||||||
|
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")], neg=[])
|
||||||
|
with pytest.raises(SelectionError):
|
||||||
|
board_io.scan_marker_terminals(board)
|
||||||
|
terms = board_io.scan_marker_terminals(board, require_both=False)
|
||||||
|
assert [(t.name, t.role) for t in terms] == [("VIN", "supply")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_marker_terminals_filters_covered_rects():
|
||||||
|
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")],
|
||||||
|
neg=[(10, 0, 12, 2, "CPU"),
|
||||||
|
(20, 0, 22, 2, None),
|
||||||
|
(30, 0, 32, 2, "FAN")])
|
||||||
|
scanned = board_io.scan_marker_terminals(board)
|
||||||
|
specs = [
|
||||||
|
TerminalSpec(name="VIN", role="supply",
|
||||||
|
parts=[PartRef(kind="rect_label", label="VIN")]),
|
||||||
|
TerminalSpec(name="CPU", role="load",
|
||||||
|
parts=[PartRef(kind="rect_label", label="CPU")]),
|
||||||
|
# the unnamed rect was frozen as coordinates by an earlier save
|
||||||
|
TerminalSpec(name="L_old", role="load",
|
||||||
|
parts=[PartRef(kind="rect_mm",
|
||||||
|
rect_mm=(20.0, 0.0, 22.0, 2.0))]),
|
||||||
|
]
|
||||||
|
new = board_io.new_marker_terminals(specs, scanned)
|
||||||
|
assert [mt.name for mt in new] == ["FAN"] # only the new one
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_marker_terminals_handles_name_collisions(capsys):
|
||||||
|
board = _marker_board(pos=[(0, 0, 4, 4, None)],
|
||||||
|
neg=[(10, 0, 12, 2, "mcu")])
|
||||||
|
scanned = board_io.scan_marker_terminals(board)
|
||||||
|
specs = [
|
||||||
|
TerminalSpec(name="S1", role="supply",
|
||||||
|
parts=[PartRef(kind="footprint", ref="U1")]),
|
||||||
|
TerminalSpec(name="mcu", role="load",
|
||||||
|
parts=[PartRef(kind="footprint", ref="U7")]),
|
||||||
|
]
|
||||||
|
new = board_io.new_marker_terminals(specs, scanned)
|
||||||
|
# the labeled collision is skipped with a note (ambiguous - the
|
||||||
|
# config's "mcu" does not reference the rectangle); the colliding
|
||||||
|
# auto name is simply renumbered
|
||||||
|
assert [mt.name for mt in new] == ["S2"]
|
||||||
|
assert new[0].electrodes[0].label == "S2"
|
||||||
|
assert "collides" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
# --- component hints (the dialog's Component column) -------------------------
|
||||||
|
|
||||||
|
def test_component_hints_direct_and_nearest():
|
||||||
|
u5 = _fp("U5", [_pad(21, 21, "1", "VCC")]) # inside the first rect
|
||||||
|
j2 = _fp("J2", [_pad(60, 24, "1", "GND")]) # nearest to the second
|
||||||
|
board = _FakeBoard(footprints=[u5, j2],
|
||||||
|
shapes=[_rect(20, 20, 30, 26),
|
||||||
|
_rect(40, 20, 46, 26)])
|
||||||
|
groups = [[board_io._to_electrode(board, r)]
|
||||||
|
for r in board.get_shapes()]
|
||||||
|
hints = board_io.component_hints(board, groups)
|
||||||
|
assert hints[0] == "U5"
|
||||||
|
# no intersection: J2's pad (14 mm away) beats U5's (19 mm); the
|
||||||
|
# net does not matter - this is spatial identification only
|
||||||
|
assert hints[1] == "near J2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_component_hints_multiple_hits_and_empty_board():
|
||||||
|
a = _fp("U1", [_pad(21, 21, "1", "VCC")])
|
||||||
|
b = _fp("R5", [_pad(29, 25, "1", "VCC")])
|
||||||
|
board = _FakeBoard(footprints=[a, b],
|
||||||
|
shapes=[_rect(20, 20, 30, 26)])
|
||||||
|
e = board_io._to_electrode(board, board.get_shapes()[0])
|
||||||
|
assert board_io.component_hints(board, [[e]]) == ["U1, R5"]
|
||||||
|
bare = _FakeBoard(shapes=[_rect(0, 0, 1, 1)])
|
||||||
|
e2 = board_io._to_electrode(bare, bare.get_shapes()[0])
|
||||||
|
assert board_io.component_hints(bare, [[e2]]) == [""]
|
||||||
|
|
||||||
|
|
||||||
|
def test_component_hints_check_every_rect_of_a_group():
|
||||||
|
# bonded group: the pad sits in the SECOND rectangle - still a hit
|
||||||
|
u9 = _fp("U9", [_pad(45, 23, "1", "VCC")])
|
||||||
|
board = _FakeBoard(footprints=[u9],
|
||||||
|
shapes=[_rect(0, 0, 2, 2), _rect(44, 22, 46, 24)])
|
||||||
|
es = [board_io._to_electrode(board, r) for r in board.get_shapes()]
|
||||||
|
assert board_io.component_hints(board, [es]) == ["U9"]
|
||||||
|
|
||||||
|
|
||||||
|
def _poly(x0_mm, y0_mm, x1_mm, y1_mm):
|
||||||
|
return Polygon(outline=np.array(
|
||||||
|
[[x0_mm, y0_mm], [x1_mm, y0_mm], [x1_mm, y1_mm], [x0_mm, y1_mm]],
|
||||||
|
dtype=np.int64) * MM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_nets_by_copper_overlap():
|
||||||
|
board = _FakeBoard(shapes=[_rect(0, 0, 4, 4), _rect(10, 0, 14, 4)])
|
||||||
|
groups = [[board_io._to_electrode(board, r)]
|
||||||
|
for r in board.get_shapes()]
|
||||||
|
copper = {"VCC": {"F.Cu": [_poly(0, 0, 6, 6)]},
|
||||||
|
"GND": {"B.Cu": [_poly(8, 0, 20, 6)]}}
|
||||||
|
assert board_io.group_nets(copper, groups) == [
|
||||||
|
frozenset({"VCC"}), frozenset({"GND"})]
|
||||||
|
# a rectangle over bare board overlaps nothing
|
||||||
|
bare = _FakeBoard(shapes=[_rect(40, 40, 42, 42)])
|
||||||
|
e = board_io._to_electrode(bare, bare.get_shapes()[0])
|
||||||
|
assert board_io.group_nets(copper, [[e]]) == [frozenset()]
|
||||||
@@ -216,7 +216,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fill-resistance"
|
name = "fill-resistance"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "kicad-python" },
|
{ name = "kicad-python" },
|
||||||
|
|||||||
Reference in New Issue
Block a user