diff --git a/.gitignore b/.gitignore index f4e2aba..dba83e6 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,9 @@ ExampleDesigns/Sandbox/* # would re-leak exactly what it removes. Run it before each commit. sanitize_sandbox.ps1 wiring.json -scripts/test-*.ps1 +# Local override for test-combined (team config is on team/local branch) +scripts/test-combined.json +scripts/py314.local.json +# Fork-local team config (team/test-combined.json is tracked on team/local only) +team/* +!team/test-combined.json diff --git a/README.md b/README.md index e641b4c..258a1fc 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,13 @@ define the power-delivery topology: | `SERIES` | `PDN_R`, `PDN_P_NET`\*, `PDN_N_NET`\* | Series resistance / fuse / ferrite / inductor DCR (rail bridge) | | `REGULATOR` | `PDN_V`, `PDN_REGULATOR_TYPE`, `PDN_REGULATOR_EFFICIENCY`, optional `PDN_QUIESCENT` — *or* `PDN_GAIN`, plus `PDN_OUT_*` / `PDN_IN_*` nets | On-board regulator (LDO / buck) — models BOTH input and output rails | +Optional two-terminal helpers (SOURCE / SINK): + +| Parameter | Purpose | +|-----------|---------| +| `PDN_P_PINS` / `PDN_N_PINS` | Restrict which pads on the host (or DES-listed parts) couple | +| `PDN_P_DES` / `PDN_N_DES` | Pull that terminal's pads from other designators only (host not auto-included); for multi-connector / banana-style sources | + \* `PDN_P_NET` and `PDN_N_NET` are optional for `SERIES` on a 2-pin part — the tool auto-infers them from the component's pad connectivity. diff --git a/docs/fork-workflow.md b/docs/fork-workflow.md new file mode 100644 index 0000000..ecdf437 --- /dev/null +++ b/docs/fork-workflow.md @@ -0,0 +1,80 @@ +# Fork workflow (team/local) + +This fork keeps upstream-ready work separate from local team tooling. + +## Branches + +| Branch | Purpose | +|--------|---------| +| `main` | Tracks upstream; use as the base for upstream pull requests | +| `team/local` | Shared fork config (combined-test branch list, GH Action for maintain) | +| `test/combined` | Published integration tip built from the JSON list (force-pushed) | + +Daily development can use `team/local` or feature branches. **Do not merge `team/local` into branches you open upstream.** + +## Combined test (`test/combined`) + +Feature branches listed in `team/test-combined.json` on `team/local` are merged into the shared `test/combined` branch and pushed to GitHub. Machines only fetch and check out that tip — they do not merge at Altium/FYPA start. + +Config on `team/local`: + +```json +{ + "baseBranch": "main", + "testBranch": "test/combined", + "deleteTestBranchFirst": true, + "extraFeatureBranches": ["feature/example-a", "fix/example-b"] +} +``` + +### Maintain (update + publish) + +Uses **origin tips only**. Prefer **incremental** updates (default): check out `origin/test/combined` and merge only extras not already in that tip. That keeps prior conflict resolutions. + +```powershell +pwsh scripts/maintain-test-combined.ps1 -Push # incremental +pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push # clean recreate from main +pwsh scripts/maintain-test-combined.ps1 -Abort # escape stuck merge +``` + +Omit `-Push` while resolving conflicts locally, then `-Push` when clean. + +**Conflicts:** only `.gitignore` / `FYPA.code-workspace` auto-resolve. On a real conflict the script stays on `test/combined` — do not `git switch` away. Either finish (`git add` + `git commit`, then `-Push`) or run `-Abort` to hard-reset to `origin/test/combined` and return to your previous branch. + +Use `-Rebuild` only when `main` moved a lot, extras were removed/reordered, or the tip is broken. Expect to resolve the same conflicts again. + +Config resolution: `scripts/test-combined.json` (gitignored) → `team/test-combined.json` → `team/local:team/test-combined.json` → example file. + +### GitHub Action (fork only) + +`.github/workflows/maintain-test-combined.yml` lives **only on `team/local`** — never commit it to `main` or upstream PR branches. + +Triggers: `workflow_dispatch` (`--ref team/local`) and pushes to `team/local` that touch `team/test-combined.json`. + +### Launch (no merge) + +```powershell +pwsh scripts/test-combined.ps1 +pwsh scripts/test-combined.ps1 -SkipTests -PrjPcb path\to\Board.PrjPcb +``` + +Checks out `origin/test/combined` only. `-Rebuild` is not supported — use maintain. + +Altium (`Run_FYPA.ps1`) calls `scripts/launch-combined-gui.ps1`. + +### Typical flow + +1. Push the feature branch to `origin`. +2. Add it to `team/test-combined.json` on `team/local` and push `team/local`. +3. `pwsh scripts/maintain-test-combined.ps1 -Push` (incremental). +4. On any machine: Altium / `test-combined.ps1` → `origin/test/combined`. + +Prefer clean feature branches in the JSON (not pre-merged `*-combined` stacks). + +## Upstream pull requests + +```powershell +git fetch upstream +git checkout -b feature/my-fix upstream/main +git cherry-pick +``` diff --git a/docs/user-guide/01-sources-and-sinks.md b/docs/user-guide/01-sources-and-sinks.md index c80d70b..2482fe3 100644 --- a/docs/user-guide/01-sources-and-sinks.md +++ b/docs/user-guide/01-sources-and-sinks.md @@ -173,6 +173,29 @@ To override the inferred pad set (e.g. to exclude a thermal pad), use the `PDN_P_PINS` / `PDN_N_PINS` parameters documented in the [main README](../../README.md). +### Multi-connector / banana-style sources + +Some bench setups feed power through one connector and return through +several others (banana jacks, sense returns, distributed ground posts). +Annotate the SOURCE on the **host** connector that carries the value / +role, then list the other designators on the terminal that should pull +pads from them: + +| Name | Value | +|--------------|--------------| +| `PDN_ROLE` | `SOURCE` | +| `PDN_V` | `5V` | +| `PDN_P_NET` | `VIN` | +| `PDN_N_NET` | `GND` | +| `PDN_N_DES` | `J3,J5,J7` | + +Here J2 is the host (P pads stay on J2). The N terminal uses **only** +pads on J3, J5, and J7 that sit on `GND` — the host is not +auto-included. `PDN_P_DES` works the same way on the P side. Optional +`PDN_P_PINS` / `PDN_N_PINS` still filter pad numbers across the listed +parts. Indexed channels use `PDNn_P_DES` / `PDNn_N_DES`. Without +`*_DES`, behaviour is unchanged (host pads only). + ### Several rails on one part (multi-channel) An IC that draws from more than one supply rail is a single part with diff --git a/fypa/altium/annotations.py b/fypa/altium/annotations.py index 3b54ec0..39aa653 100644 --- a/fypa/altium/annotations.py +++ b/fypa/altium/annotations.py @@ -16,10 +16,10 @@ ============ ============================= ================================================== Role Value params Net / pin params ============ ============================= ================================================== -SOURCE PDN_V PDN_P_NET, PDN_N_NET (overrides: *_PINS) - *or* PDN_NET (overrides: PDN_PINS) -SINK PDN_I PDN_P_NET, PDN_N_NET (overrides: *_PINS) - *or* PDN_NET (overrides: PDN_PINS) +SOURCE PDN_V PDN_P_NET, PDN_N_NET (overrides: *_PINS, *_DES) + *or* PDN_NET (overrides: PDN_PINS) +SINK PDN_I PDN_P_NET, PDN_N_NET (overrides: *_PINS, *_DES) + *or* PDN_NET (overrides: PDN_PINS) SERIES PDN_R PDN_P_NET, PDN_N_NET (optional) (overrides: *_PINS) REGULATOR PDN_V PDN_OUT_P_NET, PDN_OUT_N_NET, PDN_REGULATOR_TYPE PDN_IN_P_NET, PDN_IN_N_NET (overrides: *_PINS) @@ -27,6 +27,18 @@ PDN_QUIESCENT (optional) ============ ============================= ================================================== +Multi-connector P / N pads (``*_DES``) +-------------------------------------- +Two-terminal SOURCE / SINK channels may pull a terminal's pads from **other** +components via ``PDN_P_DES`` / ``PDN_N_DES`` (or ``PDNn_P_DES`` / +``PDNn_N_DES``): a comma-separated list of designators (e.g. ``J1,J2``). +Without ``*_DES``, pads come from the host component only (unchanged). +With ``*_DES``, pads come **only** from the listed designators on the named +net — the host is not auto-included. ``PDN_*_PINS`` still filters pads on +those chosen components. Single-net (``PDN_NET``) mode stays single-component; +SERIES / REGULATOR ignore ``*_DES``. ``SourceSpec.designator`` remains the +host. + Single-net (point-to-point) SOURCE / SINK ------------------------------------------ A SOURCE or SINK normally names a rail net (``PDN_P_NET``) and a return net @@ -180,8 +192,8 @@ "NET", "PINS", "P_NET", "N_NET", "P_PINS", "N_PINS", }) _KNOWN_SUFFIXES_BY_ROLE: dict[str, frozenset[str]] = { - "SOURCE": _COMMON_TERMINAL_SUFFIXES | frozenset({"V"}), - "SINK": _COMMON_TERMINAL_SUFFIXES | frozenset({"I", "MIN_V"}), + "SOURCE": _COMMON_TERMINAL_SUFFIXES | frozenset({"V", "P_DES", "N_DES"}), + "SINK": _COMMON_TERMINAL_SUFFIXES | frozenset({"I", "MIN_V", "P_DES", "N_DES"}), "REGULATOR": frozenset({ "V", "GAIN", "REGULATOR_TYPE", "REGULATOR_EFFICIENCY", "QUIESCENT", "OUT_P_NET", "OUT_N_NET", "OUT_P_PINS", "OUT_N_PINS", @@ -471,7 +483,7 @@ class TerminalPin: pad_polygon: shapely.geometry.Polygon | None = None # Owning PCB component when known. Used so P/N overlap arbitration does # not treat pad ``"1"`` on J2 and pad ``"1"`` on J3 as the same pin - # (multi-connector / banana-jack sources). + # (multi-connector / banana-jack sources via ``PDN_*_DES``). component_designator: str | None = None @@ -1382,6 +1394,7 @@ def _resolve_terminal( ) return None, errors, match_tier + comp_des = proj.pcb_components[pcb_index].designator pins = tuple( TerminalPin( pad_designator=p.designator, @@ -1398,6 +1411,132 @@ def _resolve_terminal( ), errors, match_tier +def _resolve_terminal_multi( + proj: ExtractedProject, + designators: list[str], + net_name: str | None, + override_pins: list[str] | None, + enabled_layers: list[int], + role_diagnostic: str, + warnings: list[str] | None = None, + net_remap: dict[int, int] | None = None, + schdoc_name: str | None = None, +) -> tuple[TerminalSpec | None, list[str]]: + """Resolve a terminal from pads on *other* components named by ``*_DES``. + + Each designator must exist on the PCB and contribute at least one matching + pad. The host component is not consulted — callers pass only the listed + designators. ``override_pins`` (from ``*_PINS``) filters pads across those + components; a pin name is satisfied if any listed component has it. + """ + errors: list[str] = [] + all_pins: list[TerminalPin] = [] + resolved_via_local = False + + if not designators: + errors.append(f"{role_diagnostic}: empty designator list") + return None, errors + + # Preserve author order; ignore duplicate names (case-insensitive). + seen_des: set[str] = set() + unique_des: list[str] = [] + for des in designators: + key = des.upper() + if key in seen_des: + continue + seen_des.add(key) + unique_des.append(des) + + for des in unique_des: + indices = _find_pcb_instances(proj, des) + if not indices: + errors.append( + f"{role_diagnostic}: designator {des!r} not found on the PCB" + ) + continue + + des_pins: list[TerminalPin] = [] + des_local = False + + if override_pins: + wanted = {pin.upper() for pin in override_pins} + for ix in indices: + comp_des = proj.pcb_components[ix].designator + component_pads = _pads_by_component_all(proj).get(ix, []) + matched = [ + p for p in component_pads + if p.designator.upper() in wanted + ] + for p in matched: + des_pins.append(TerminalPin( + pad_designator=p.designator, + layer_id=(_tl := _terminal_layer_for_pad( + p, enabled_layers, + )), + net_index=p.net_index, + point=p.center, + pad_polygon=_pad_polygon(p, _tl), + component_designator=comp_des, + )) + if not des_pins: + errors.append( + f"{role_diagnostic}: designator {des!r} has none of the " + f"override pins {sorted(wanted)}" + ) + else: + des_errs: list[str] = [] + for ix in indices: + pcb_comp = proj.pcb_components[ix] + sch_lookup = pcb_comp.source_designator or pcb_comp.designator + # ``_resolve_terminal`` returns ``(spec, errors)`` on main and + # ``(spec, errors, match_tier)`` on stacks that include pad + # arbitration (e.g. test/combined). Accept either shape. + resolved = _resolve_terminal( + proj, ix, net_name, None, enabled_layers, + f"{role_diagnostic} ({des})", + warnings=warnings, + net_remap=net_remap, + sch_lookup_designator=sch_lookup, + schdoc_name=schdoc_name, + ) + spec, err = resolved[0], resolved[1] + if spec is not None: + des_pins.extend(spec.pins) + des_local = des_local or spec.resolved_via_local + else: + des_errs.extend(err) + if not des_pins: + if des_errs: + errors.extend(des_errs) + else: + errors.append( + f"{role_diagnostic}: designator {des!r} has no pad " + f"on net {net_name!r}" + ) + + all_pins.extend(des_pins) + resolved_via_local = resolved_via_local or des_local + + if override_pins and all_pins: + found = {p.pad_designator.upper() for p in all_pins} + missing = {pin.upper() for pin in override_pins} - found + if missing: + errors.append( + f"{role_diagnostic}: pin overrides not found on listed " + f"designators: {sorted(missing)}" + ) + + if errors: + return None, errors + if not all_pins: + return None, [f"{role_diagnostic}: no pads resolved"] + return TerminalSpec( + pins=tuple(all_pins), + requested_net=net_name, + resolved_via_local=resolved_via_local, + ), [] + + def _find_pcb_instances(proj: ExtractedProject, sch_designator: str) -> list[int]: """Return the indices of every PCB component placed from a schematic part. @@ -1835,11 +1974,19 @@ def _resolve_two_terminal( net_remap: dict[int, int] | None = None, sch_lookup_designator: str | None = None, schdoc_name: str | None = None, + p_des_key: str | None = None, + n_des_key: str | None = None, ) -> tuple[TerminalSpec, TerminalSpec] | None: p_net = _ci_get(params, p_net_key) n_net = _ci_get(params, n_net_key) p_pins = _split_pin_list(_ci_get(params, p_pins_key)) n_pins = _split_pin_list(_ci_get(params, n_pins_key)) + p_des = ( + _split_pin_list(_ci_get(params, p_des_key)) if p_des_key else None + ) + n_des = ( + _split_pin_list(_ci_get(params, n_des_key)) if n_des_key else None + ) if p_net is None and p_pins is None: result.errors.append(f"{role_diag}: missing {p_net_key} (or {p_pins_key})") @@ -1848,22 +1995,31 @@ def _resolve_two_terminal( if p_net is None and p_pins is None or n_net is None and n_pins is None: return None - p_spec, p_err, p_tier = _resolve_terminal( - proj, pcb_index, p_net, p_pins, enabled_layers, - f"{role_diag} P-terminal", - warnings=result.warnings, - net_remap=net_remap, - sch_lookup_designator=sch_lookup_designator, - schdoc_name=schdoc_name, - ) - n_spec, n_err, n_tier = _resolve_terminal( - proj, pcb_index, n_net, n_pins, enabled_layers, - f"{role_diag} N-terminal", - warnings=result.warnings, - net_remap=net_remap, - sch_lookup_designator=sch_lookup_designator, - schdoc_name=schdoc_name, - ) + def _side( + net: str | None, + pins: list[str] | None, + des_list: list[str] | None, + side: str, + ) -> tuple[TerminalSpec | None, list[str], int]: + side_diag = f"{role_diag} {side}-terminal" + if des_list is not None: + spec, errs = _resolve_terminal_multi( + proj, des_list, net, pins, enabled_layers, side_diag, + warnings=result.warnings, + net_remap=net_remap, + schdoc_name=schdoc_name, + ) + return spec, errs, _LOCAL_NET_TIER_DIRECT + return _resolve_terminal( + proj, pcb_index, net, pins, enabled_layers, side_diag, + warnings=result.warnings, + net_remap=net_remap, + sch_lookup_designator=sch_lookup_designator, + schdoc_name=schdoc_name, + ) + + p_spec, p_err, p_tier = _side(p_net, p_pins, p_des, "P") + n_spec, n_err, n_tier = _side(n_net, n_pins, n_des, "N") result.errors.extend(p_err) result.errors.extend(n_err) if p_spec is None or n_spec is None: @@ -2013,9 +2169,10 @@ def _terminal_mode(params: dict[str, str], idx: int | None, """Decide whether a SOURCE/SINK channel is single-net or two-terminal. A single-net channel carries ``PDN_NET`` (or ``PDN_PINS``); a two-terminal - channel carries ``PDN_P_NET``/``PDN_N_NET`` (or their ``*_PINS``). The two - are mutually exclusive — see the module docstring. Returns ``"single"``, - ``"two"``, or ``None`` (a validation error has been appended to ``result``). + channel carries ``PDN_P_NET``/``PDN_N_NET`` (or their ``*_PINS`` / + ``*_DES``). The two are mutually exclusive — see the module docstring. + Returns ``"single"``, ``"two"``, or ``None`` (a validation error has been + appended to ``result``). """ net_key = _channel_key("NET", idx) pins_key = _channel_key("PINS", idx) @@ -2028,7 +2185,7 @@ def _terminal_mode(params: dict[str, str], idx: int | None, if _ci_get(params, pins_key) is not None: single_set.append(f"{pins_key} (single-net pin override)") two_set: list[str] = [] - for suffix in ("P_NET", "N_NET", "P_PINS", "N_PINS"): + for suffix in ("P_NET", "N_NET", "P_PINS", "N_PINS", "P_DES", "N_DES"): key = _channel_key(suffix, idx) if _ci_get(params, key) is not None: two_set.append(key) @@ -2181,6 +2338,8 @@ def _parse_source(comp, proj, enabled_layers, result, net_remap=net_remap, sch_lookup_designator=comp.lookup_designator, schdoc_name=comp.schdoc_name, + p_des_key=_channel_key("P_DES", idx), + n_des_key=_channel_key("N_DES", idx), ) if pair is None: continue @@ -2262,6 +2421,8 @@ def _parse_sink(comp, proj, enabled_layers, result, net_remap=net_remap, sch_lookup_designator=comp.lookup_designator, schdoc_name=comp.schdoc_name, + p_des_key=_channel_key("P_DES", idx), + n_des_key=_channel_key("N_DES", idx), ) if pair is None: continue @@ -3264,10 +3425,15 @@ def parse_annotations(proj: ExtractedProject, # --- self-check --------------------------------------------------------------- def _describe_terminal(label: str, term: TerminalSpec) -> str: - parts = [ - f"{p.pad_designator}@layer{p.layer_id}({p.point.x:.2f},{p.point.y:.2f})" - for p in term.pins - ] + parts = [] + for p in term.pins: + pad = ( + f"{p.component_designator}-{p.pad_designator}" + if p.component_designator else p.pad_designator + ) + parts.append( + f"{pad}@layer{p.layer_id}({p.point.x:.2f},{p.point.y:.2f})" + ) return f" {label:<8} pins: {', '.join(parts) if parts else '(none)'}" diff --git a/fypa/altium/loader.py b/fypa/altium/loader.py index 30f603d..532894d 100644 --- a/fypa/altium/loader.py +++ b/fypa/altium/loader.py @@ -2631,13 +2631,22 @@ def _terminal_summary(term, nets) -> dict: for pin in term.pins: net_name = (nets[pin.net_index].name if 0 <= pin.net_index < len(nets) else "(none)") - pins.append({ + # Keep ``pad`` as the raw pad designator so Unlock seeding of + # PDN_*_PINS fields stays resolvable (``"1"``, not ``"J2-1"``). + # ``component`` + ``pad_label`` cover multi-DES display. + entry = { "pad": pin.pad_designator, "layer_id": pin.layer_id, "net": net_name, "x_mm": pin.point.x, "y_mm": pin.point.y, - }) + } + if pin.component_designator: + entry["component"] = pin.component_designator + entry["pad_label"] = ( + f"{pin.component_designator}-{pin.pad_designator}" + ) + pins.append(entry) return { "pin_count": len(pins), "pins": pins, diff --git a/fypa/altium_viewer.py b/fypa/altium_viewer.py index f5f6033..e91d8e2 100644 --- a/fypa/altium_viewer.py +++ b/fypa/altium_viewer.py @@ -13310,7 +13310,7 @@ def _collect_candidates(role_filter: str | None continue out.append( (v_at, f"{d.get('label') or d.get('designator', '?')}" - f".{pin.get('pad', '?')}") + f".{self._pin_display_pad(pin) or '?'}") ) return out @@ -19513,11 +19513,74 @@ def _terminal_primary_net(term: dict | None) -> str | None: def _terminal_pin_pads(term: dict | None) -> list[str]: """Pad designators of a metadata directive terminal's pins — the PDN_PINS set the schematic resolved to. ``[]`` for an ideal return or - a terminal with no pins.""" + a terminal with no pins. + + Uses the raw ``pad`` field (not a compound ``J2-1`` label). Dedupes + case-insensitively so multi-DES terminals with the same pad number on + several connectors seed a single PDN_PINS entry. + """ + if not term or term.get("ideal_return"): + return [] + seen: set[str] = set() + out: list[str] = [] + for p in term.get("pins", []) or []: + pad = p.get("pad") + if pad in (None, ""): + continue + pad_s = str(pad) + # Legacy metadata prefixed pad as ``COMP-PAD``; strip when the + # component field matches the prefix so Unlock stays resolvable. + comp = p.get("component") + if (comp and pad_s.upper().startswith(str(comp).upper() + "-")): + pad_s = pad_s[len(str(comp)) + 1:] + key = pad_s.upper() + if key in seen: + continue + seen.add(key) + out.append(pad_s) + return out + + @staticmethod + def _terminal_des_list(term: dict | None, + host: str | None) -> list[str]: + """Unique component designators that contributed pins to ``term``. + + Used to seed P DES / N DES on Unlock. Host-only terminals return + ``[]`` (blank DES ⇒ host component). Multi-connector terminals return + every unique ``component`` that contributed a pin (order preserved). + """ if not term or term.get("ideal_return"): return [] - return [str(p.get("pad")) for p in term.get("pins", []) or [] - if p.get("pad") not in (None, "")] + seen: set[str] = set() + out: list[str] = [] + for p in term.get("pins", []) or []: + c = p.get("component") + if not c: + continue + key = str(c).upper() + if key in seen: + continue + seen.add(key) + out.append(str(c)) + if not out: + return [] + if host and len(out) == 1 and out[0].upper() == str(host).upper(): + return [] + return out + + @staticmethod + def _pin_display_pad(pin: dict | None) -> str: + """Display label for a metadata pin — prefer compound ``pad_label``.""" + if not pin: + return "" + label = pin.get("pad_label") + if label: + return str(label) + pad = pin.get("pad", "") + comp = pin.get("component") + if comp and pad: + return f"{comp}-{pad}" + return "" if pad is None else str(pad) @staticmethod def _parse_pin_field(text: str | None) -> list[str] | None: @@ -19915,10 +19978,33 @@ def _populate_editor_form(self) -> None: ) self._ef_npins_label = QLabel("N pins") form2.addRow(self._ef_npins_label, self._ef_npins) - # Pins apply to a real component's pads only. + # Multi-connector designator lists (PDN_P_DES / PDN_N_DES) — CSV of + # other component designators whose pads feed this terminal. Two-net + # SOURCE/SINK only; host is not auto-included when set. + self._ef_pdes = QLineEdit() + self._ef_pdes.setPlaceholderText("host only") + self._ef_pdes.setToolTip( + "Optional: comma-separated designators for the P terminal " + "(PDN_P_DES). Pads come only from those parts — the host is " + "not auto-included. Leave blank to use the host component." + ) + self._ef_pdes_label = QLabel("P DES") + form2.addRow(self._ef_pdes_label, self._ef_pdes) + self._ef_ndes = QLineEdit() + self._ef_ndes.setPlaceholderText("host only") + self._ef_ndes.setToolTip( + "Optional: comma-separated designators for the N terminal " + "(PDN_N_DES). Pads come only from those parts — the host is " + "not auto-included. Leave blank to use the host component." + ) + self._ef_ndes_label = QLabel("N DES") + form2.addRow(self._ef_ndes_label, self._ef_ndes) + # Pins / DES apply to a real component's pads only. self._ef_pins_apply = sel["kind"] == "component" for _w in (self._ef_pins, self._ef_pins_label, - self._ef_npins, self._ef_npins_label): + self._ef_npins, self._ef_npins_label, + self._ef_pdes, self._ef_pdes_label, + self._ef_ndes, self._ef_ndes_label): _w.setVisible(self._ef_pins_apply) lay.addLayout(form2) @@ -19955,6 +20041,10 @@ def _populate_editor_form(self) -> None: self._set_combo(self._ef_nnet, existing.n_net) self._ef_pins.setText(", ".join(existing.p_pins or [])) self._ef_npins.setText(", ".join(existing.n_pins or [])) + self._ef_pdes.setText(", ".join( + getattr(existing, "p_des", None) or [])) + self._ef_ndes.setText(", ".join( + getattr(existing, "n_des", None) or [])) self._ef_remove.setEnabled(True) if existing.overrides_designator: self._ef_status.setText( @@ -19993,6 +20083,13 @@ def _populate_editor_form(self) -> None: ", ".join(self._terminal_pin_pads(terms.get("P")))) self._ef_npins.setText( ", ".join(self._terminal_pin_pads(n_term))) + # Seed P/N DES from the components that actually contributed pins + # (multi-connector PDN_*_DES). Host-only → leave blank. + host_des = sel.get("designator") + self._ef_pdes.setText( + ", ".join(self._terminal_des_list(terms.get("P"), host_des))) + self._ef_ndes.setText( + ", ".join(self._terminal_des_list(n_term, host_des))) self._ef_remove.setEnabled(False) self._ef_status.setText( f"Unlocked — Apply " @@ -20041,12 +20138,22 @@ def _on_editor_model_changed(self, *_args) -> None: self._ef_pnet_label.setText("P net" if two else "Net") # The N-pin restriction only exists in two-net mode (single-net's N # terminal is an ideal return with no pads). Keep it in step with the - # N-net picker, and only for a component selection. + # N-net picker, and only for a component selection. P/N DES likewise + # apply only to two-net SOURCE/SINK (SERIES ignores them). if hasattr(self, "_ef_npins"): show_npins = two and getattr(self, "_ef_pins_apply", False) self._ef_npins.setVisible(show_npins) self._ef_npins_label.setVisible(show_npins) self._ef_pins_label.setText("P pins" if two else "Pins") + if hasattr(self, "_ef_pdes"): + role = (self._ef_role.currentText() + if hasattr(self, "_ef_role") else "") + show_des = (two and getattr(self, "_ef_pins_apply", False) + and role in ("SOURCE", "SINK")) + self._ef_pdes.setVisible(show_des) + self._ef_pdes_label.setVisible(show_des) + self._ef_ndes.setVisible(show_des) + self._ef_ndes_label.setVisible(show_des) def _on_editor_apply(self) -> None: """Commit the form into an :class:`EditorDirective` on the project, @@ -20124,6 +20231,13 @@ def _on_editor_apply(self) -> None: d.p_pins = self._parse_pin_field(self._ef_pins.text()) d.n_pins = (None if single else self._parse_pin_field(self._ef_npins.text())) + # Multi-connector DES lists (PDN_*_DES). Two-net SOURCE/SINK only. + if single or role not in ("SOURCE", "SINK"): + d.p_des = None + d.n_des = None + else: + d.p_des = self._parse_pin_field(self._ef_pdes.text()) + d.n_des = self._parse_pin_field(self._ef_ndes.text()) # If this component has a schematic directive, mark the editor # directive as its override so the re-solve drops the schematic # one instead of stamping both. @@ -20135,6 +20249,8 @@ def _on_editor_apply(self) -> None: d.kind = "free" d.p_pins = None d.n_pins = None + d.p_des = None + d.n_des = None self._editor_selection = {"kind": "free", "id": d.id} self._ensure_project().upsert_directive(d) @@ -24862,7 +24978,7 @@ def _get_v_pd_kdtree(phys_name: str, net_name: str): "designator": display_desig, "schdoc": schdoc, "terminal": term_name, - "pad": pin.get("pad", ""), + "pad": self._pin_display_pad(pin), "net": net, "layer_id": layer_id, "x_mm": x, @@ -29366,7 +29482,7 @@ def _format_setup_html(solution, metadata: dict | None, net_cell = f"{_esc(req_net or actual_net)}" parts.append("" f"{_esc(term_name) if i == 0 else ''}" - f"{_esc(pin.get('pad',''))}" + f"{_esc(PdnViewer._pin_display_pad(pin))}" f"{net_cell}" f"{pin.get('layer_id','')}" f"{pin.get('x_mm', 0):.3f}" diff --git a/fypa/editor_directives.py b/fypa/editor_directives.py index bfd961b..7a60d35 100644 --- a/fypa/editor_directives.py +++ b/fypa/editor_directives.py @@ -65,11 +65,32 @@ def apply_editor_directives(loaded, editor_directives) -> list[str]: if nm: net_index.setdefault(nm.upper(), i) - # physical PCB designator -> pcb_components index - comp_index: dict[str, int] = {} - for i, comp in enumerate(extracted.pcb_components): - if comp.designator: - comp_index.setdefault(comp.designator, i) + def _comp_indices(designator: str | None) -> list[int]: + """All PCB placements matching ``designator``. + + Mirrors schematic :func:`~fypa.altium.annotations._find_pcb_instances`: + prefer ``source_designator`` (multi-channel logical name), then fall + back to physical ``designator``. Returns every matching index so + multi-DES terminals merge pads from all channel placements. + """ + if not designator: + return [] + target = designator.upper() + hits = [ + i for i, c in enumerate(extracted.pcb_components) + if getattr(c, "source_designator", None) + and str(c.source_designator).upper() == target + ] + if hits: + return hits + return [ + i for i, c in enumerate(extracted.pcb_components) + if (getattr(c, "designator", None) or "").upper() == target + ] + + def _comp_idx(designator: str | None) -> int | None: + indices = _comp_indices(designator) + return indices[0] if indices else None # --- Per-rail return groups for single-net editor directives ---------- # Each electrically-connected rail needs its OWN ideal-0 V return node. @@ -132,7 +153,7 @@ def _return_group_for(net_name: str) -> int: return gid def _component_center(designator: str | None) -> tuple[float, float] | None: - ci = comp_index.get(designator) if designator else None + ci = _comp_idx(designator) if ci is None: return None pts = [p.center for p in extracted.pads if p.component_index == ci] @@ -141,6 +162,28 @@ def _component_center(designator: str | None) -> tuple[float, float] | None: return (sum(p.x for p in pts) / len(pts), sum(p.y for p in pts) / len(pts)) + def _pads_on_component(ci: int, nidx: int, wanted_pins): + """Pads of component ``ci`` on net ``nidx``, optionally pin-filtered.""" + out: list = [] + pcb_des = extracted.pcb_components[ci].designator or None + for p in extracted.pads: + if p.component_index != ci or p.net_index != nidx: + continue + if wanted_pins is not None and \ + (p.designator or "").upper() not in wanted_pins: + continue + through = getattr(p, "is_through_hole", False) + lid = top_layer if through else p.layer_id + out.append(TerminalPin( + pad_designator=p.designator or "(editor)", + layer_id=lid, + net_index=nidx, + point=p.center, + pad_polygon=None, + component_designator=pcb_des, + )) + return out + def _resolve_terminal(net_name, *, designator, fallback_xy, fallback_layer_id, pin_filter=None): """Build a TerminalSpec on ``net_name``. A component-bound directive @@ -160,23 +203,9 @@ def _resolve_terminal(net_name, *, designator, fallback_xy, wanted_pins = ({str(p).upper() for p in pin_filter} if pin_filter else None) pins: list = [] - ci = comp_index.get(designator) if designator else None + ci = _comp_idx(designator) if ci is not None: - for p in extracted.pads: - if p.component_index != ci or p.net_index != nidx: - continue - if wanted_pins is not None and \ - (p.designator or "").upper() not in wanted_pins: - continue - through = getattr(p, "is_through_hole", False) - lid = top_layer if through else p.layer_id - pins.append(TerminalPin( - pad_designator=p.designator or _ANCHOR_PAD, - layer_id=lid, - net_index=nidx, - point=p.center, - pad_polygon=None, - )) + pins = _pads_on_component(ci, nidx, wanted_pins) if not pins: # Free marker, or a component with no pad on this net — couple # at the supplied fallback point on the net's copper. @@ -187,9 +216,73 @@ def _resolve_terminal(net_name, *, designator, fallback_xy, net_index=nidx, point=Pt2D(float(fx), float(fy)), pad_polygon=None, + component_designator=designator or None, )) return TerminalSpec(pins=tuple(pins), requested_net=net_name) + def _resolve_terminal_multi_des(net_name, *, designators, pin_filter=None, + label="", side=""): + """Resolve a terminal from pads on the listed designators only. + + Mirrors schematic ``PDN_*_DES`` semantics: the host is not + auto-included. Returns ``(TerminalSpec | None, warning | None)``. + """ + if not net_name: + return None, f"{label}: {side} net is empty; skipped." + nidx = net_index.get(net_name.upper()) + if nidx is None: + return None, ( + f"{label}: {side} net {net_name!r} not found on the board; " + "skipped." + ) + if not designators: + return None, ( + f"{label}: {side}-DES list is empty; skipped." + ) + wanted_pins = ({str(p).upper() for p in pin_filter} + if pin_filter else None) + # Preserve author order; ignore duplicate names (case-insensitive). + seen: set[str] = set() + unique: list[str] = [] + for des in designators: + key = des.upper() + if key in seen: + continue + seen.add(key) + unique.append(des) + + all_pins: list = [] + for des in unique: + indices = _comp_indices(des) + if not indices: + return None, ( + f"{label}: designator {des!r} not found on the board; " + "skipped." + ) + des_pins: list = [] + for ci in indices: + des_pins.extend(_pads_on_component(ci, nidx, wanted_pins)) + if not des_pins: + return None, ( + f"{label}: designator {des!r} has no pad on net " + f"{net_name!r}; skipped." + ) + all_pins.extend(des_pins) + # Mirror schematic ``_resolve_terminal_multi``: every *_PINS entry + # must appear on at least one listed designator. + if wanted_pins and all_pins: + found = {p.pad_designator.upper() for p in all_pins} + missing = wanted_pins - found + if missing: + return None, ( + f"{label}: pin overrides not found on listed " + f"designators: {sorted(missing)}; skipped." + ) + return ( + TerminalSpec(pins=tuple(all_pins), requested_net=net_name), + None, + ) + warnings: list[str] = [] # Drop schematic directives that an unlocked editor directive overrides, @@ -237,21 +330,41 @@ def _resolve_terminal(net_name, *, designator, fallback_xy, # Pin restrictions only apply to a component-bound terminal that # actually has pads to pick from; a free marker couples at its anchor. + # Multi-DES lists (PDN_*_DES) likewise apply only to component-bound + # two-net SOURCE/SINK — listed designators only, host not included. p_pins = getattr(ed, "p_pins", None) if ed.kind != "free" else None n_pins = getattr(ed, "n_pins", None) if ed.kind != "free" else None - p_term = _resolve_terminal( - ed.p_net, designator=ed.designator, - fallback_xy=fallback_xy, fallback_layer_id=fallback_lid, - pin_filter=p_pins, - ) - if p_term is None: - warnings.append( - f"{label}: P net {ed.p_net!r} not found on the board; skipped." - ) - continue + p_des = getattr(ed, "p_des", None) if ed.kind != "free" else None + n_des = getattr(ed, "n_des", None) if ed.kind != "free" else None # SERIES always bridges two real nets; SOURCE / SINK honour the # directive's single-net flag. two_net = (not ed.single_net) or ed.role == "SERIES" + # *_DES is SOURCE/SINK two-net only (mirrors schematic). + use_des = two_net and ed.role in ("SOURCE", "SINK") + if not use_des: + p_des = None + n_des = None + + if p_des is not None: + p_term, des_warn = _resolve_terminal_multi_des( + ed.p_net, designators=p_des, pin_filter=p_pins, + label=label, side="P", + ) + if des_warn: + warnings.append(des_warn) + continue + else: + p_term = _resolve_terminal( + ed.p_net, designator=ed.designator, + fallback_xy=fallback_xy, fallback_layer_id=fallback_lid, + pin_filter=p_pins, + ) + if p_term is None: + warnings.append( + f"{label}: P net {ed.p_net!r} not found on the board; " + "skipped." + ) + continue n_term = None if two_net: if ed.role == "SERIES" and not ed.n_net: @@ -260,17 +373,26 @@ def _resolve_terminal(net_name, *, designator, fallback_xy, "skipped." ) continue - n_term = _resolve_terminal( - ed.n_net, designator=ed.designator, - fallback_xy=fallback_xy, fallback_layer_id=fallback_lid, - pin_filter=n_pins, - ) - if n_term is None: - warnings.append( - f"{label}: N net {ed.n_net!r} not found on the board; " - "skipped." + if n_des is not None: + n_term, des_warn = _resolve_terminal_multi_des( + ed.n_net, designators=n_des, pin_filter=n_pins, + label=label, side="N", ) - continue + if des_warn: + warnings.append(des_warn) + continue + else: + n_term = _resolve_terminal( + ed.n_net, designator=ed.designator, + fallback_xy=fallback_xy, fallback_layer_id=fallback_lid, + pin_filter=n_pins, + ) + if n_term is None: + warnings.append( + f"{label}: N net {ed.n_net!r} not found on the " + "board; skipped." + ) + continue # The same short the annotation path arbitrates, which this path # never called: a lumped element with both terminals on one node. # Two shapes reach it — both terminals on one real pad (overlapping diff --git a/fypa/project_file.py b/fypa/project_file.py index 67e33fd..c85b581 100644 --- a/fypa/project_file.py +++ b/fypa/project_file.py @@ -80,6 +80,13 @@ class EditorDirective: # with ``p_net`` (PDN_PINS in single-net mode), ``n_pins`` with ``n_net``. p_pins: list[str] | None = None n_pins: list[str] | None = None + # Optional multi-connector designator lists (schematic ``PDN_P_DES`` / + # ``PDN_N_DES``). When set on a two-net SOURCE/SINK, that terminal's pads + # come only from the listed designators — the host is not auto-included. + # ``None`` keeps host-only resolution (backward compatible). Ignored for + # free markers and single-net / SERIES directives. + p_des: list[str] | None = None + n_des: list[str] | None = None voltage: float | None = None current: float | None = None resistance: float | None = None # SERIES only, ohms @@ -101,11 +108,13 @@ def to_dict(self) -> dict[str, Any]: d["anchor_xy"] = [float(self.anchor_xy[0]), float(self.anchor_xy[1])] d["p_pins"] = list(self.p_pins) if self.p_pins is not None else None d["n_pins"] = list(self.n_pins) if self.n_pins is not None else None + d["p_des"] = list(self.p_des) if self.p_des is not None else None + d["n_des"] = list(self.n_des) if self.n_des is not None else None return d @staticmethod def _coerce_pins(raw: Any) -> list[str] | None: - """Normalise a stored pin list to ``list[str]`` (or ``None``). + """Normalise a stored pin / designator list to ``list[str]`` (or ``None``). Drops blanks / whitespace; an empty result collapses to ``None`` so "no restriction" and "explicitly empty" are the same thing.""" @@ -131,6 +140,8 @@ def from_dict(cls, d: dict[str, Any]) -> EditorDirective: n_net=d.get("n_net"), p_pins=cls._coerce_pins(d.get("p_pins")), n_pins=cls._coerce_pins(d.get("n_pins")), + p_des=cls._coerce_pins(d.get("p_des")), + n_des=cls._coerce_pins(d.get("n_des")), voltage=(None if d.get("voltage") is None else float(d["voltage"])), current=(None if d.get("current") is None else float(d["current"])), resistance=(None if d.get("resistance") is None diff --git a/fypa/topology/metadata/specs.py b/fypa/topology/metadata/specs.py index fae0836..c80b9a0 100644 --- a/fypa/topology/metadata/specs.py +++ b/fypa/topology/metadata/specs.py @@ -199,11 +199,15 @@ def jump_row_for_directive(directive: DirectiveDict) -> JumpRowDict | None: for term_name, term in terms.items(): for pin in term.get("pins") or []: if pin.get("x_mm") is not None and pin.get("y_mm") is not None: + pad = (pin.get("pad_label") + or (f"{pin['component']}-{pin['pad']}" + if pin.get("component") and pin.get("pad") + else pin.get("pad", ""))) return { "designator": str(directive.get("designator") or label), "role": directive.get("role", ""), "terminal": term_name, - "pad": pin.get("pad", ""), + "pad": pad, "net": pin.get("net", ""), "layer_id": pin.get("layer_id"), "x_mm": pin.get("x_mm"), diff --git a/fypa/topology/metadata_schema.py b/fypa/topology/metadata_schema.py index 21b5a37..df3ee41 100644 --- a/fypa/topology/metadata_schema.py +++ b/fypa/topology/metadata_schema.py @@ -10,6 +10,7 @@ class TerminalPinDict(TypedDict, total=False): pad: str + component: str net: str layer_id: int x_mm: float diff --git a/scripts/launch-combined-gui.ps1 b/scripts/launch-combined-gui.ps1 new file mode 100644 index 0000000..70678b9 --- /dev/null +++ b/scripts/launch-combined-gui.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Check out origin/test/combined and launch the GUI (Altium bootstrap path). + +.DESCRIPTION + Used by Run_FYPA.ps1 after clone/uv sync. Fetches the shared combined branch, + hard-resets to the remote tip, and runs Launch_GUI.py (or FYPA.py gui). + + Does not merge feature branches. Maintain the shared branch with: + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push + +.PARAMETER PrjPcb + Path to the focused .PrjPcb. + +.PARAMETER LaunchGui + Absolute path to Launch_GUI.py (outside the disposable clone). Optional. + +.PARAMETER Remote + Remote name. Default: origin + +.PARAMETER TestBranch + Combined branch name. Default: test/combined + +.PARAMETER RepoRoot + FYPA repo root. Default: parent of scripts/. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PrjPcb, + + [string] $LaunchGui, + + [string] $Remote = "origin", + + [string] $TestBranch = "test/combined", + + [string] $RepoRoot +) + +$ErrorActionPreference = "Stop" + +if (-not $RepoRoot) { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} +else { + $RepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path +} +Set-Location $RepoRoot + +if (-not (Test-Path -LiteralPath (Join-Path $RepoRoot 'FYPA.py'))) { + throw "FYPA.py not found in $RepoRoot" +} + +if (-not (Test-Path -LiteralPath $PrjPcb)) { + throw "PrjPcb not found: $PrjPcb" +} +$PrjPcbPath = (Resolve-Path -LiteralPath $PrjPcb).Path + +function Invoke-GitLogged { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + Write-Host (">> git {0}" -f ($GitArgs -join ' ')) -ForegroundColor DarkGray + & git.exe @GitArgs + if ($LASTEXITCODE -ne 0) { + throw "git $($GitArgs -join ' ') failed (exit $LASTEXITCODE)" + } +} + +$RemoteRef = "$Remote/$TestBranch" +Write-Host "==> Fetch $Remote $TestBranch" +& git.exe fetch $Remote $TestBranch 2>&1 | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Warning "git fetch $Remote $TestBranch failed; trying existing $RemoteRef" +} + +& git.exe rev-parse --verify "$RemoteRef^{commit}" 2>$null | Out-Null +if ($LASTEXITCODE -ne 0) { + throw @" +$RemoteRef not found. +Publish the shared branch first (on a maintainer machine or via the team/local Action): + + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push +"@ +} + +$Tip = ([string](& git.exe rev-parse --verify "$RemoteRef^{commit}")).Trim() +Write-Host "==> Checkout $TestBranch @ $Tip" +Invoke-GitLogged @('checkout', '-B', $TestBranch, $RemoteRef) +Invoke-GitLogged @('reset', '--hard', $RemoteRef) + +Write-Host "==> uv sync (on $TestBranch)" +& uv sync +if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { + $venvPython = Join-Path $RepoRoot '.venv\Scripts\python.exe' + if (Test-Path -LiteralPath $venvPython) { + Write-Warning "uv sync failed; reusing existing .venv" + } + else { + throw "uv sync failed (exit $LASTEXITCODE)" + } +} + +Write-Host "==> Launch GUI" +$env:PYTHONUNBUFFERED = '1' +if ($LaunchGui -and (Test-Path -LiteralPath $LaunchGui)) { + Write-Host " Using Launch_GUI.py (File > Import style, GUI first)" + & uv run --extra spacemouse python $LaunchGui $PrjPcbPath +} +else { + if ($LaunchGui) { + Write-Warning "Launch_GUI.py missing at $LaunchGui — falling back to FYPA.py gui" + } + & uv run --extra spacemouse FYPA.py gui $PrjPcbPath +} + +if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/scripts/maintain-test-combined.ps1 b/scripts/maintain-test-combined.ps1 new file mode 100644 index 0000000..519f8c2 --- /dev/null +++ b/scripts/maintain-test-combined.ps1 @@ -0,0 +1,760 @@ +<# +.SYNOPSIS + Rebuild origin/test/combined from team/local config and optionally push. + +.DESCRIPTION + Reads team/test-combined.json (team/local by default), fetches base + extras + from origin, updates the disposable test branch using remote tips only, + and optionally force-pushes with lease. + + Default (no -Rebuild): if origin/test/combined exists, check it out and + merge only extras whose tips are not already ancestors of HEAD + (incremental — keeps prior conflict resolutions). Pass -Rebuild for a + clean recreate from baseBranch (expect conflicts again). + + Local unpushed commits are never merged — tips are always origin/. + Missing remote extras abort the run. + + Config resolution (first match wins): + scripts/test-combined.json local override (gitignored) + team/test-combined.json working tree + team/local:team/test-combined.json from team/local via git show + scripts/test-combined.example.json fallback + +.PARAMETER ConfigPath + Path or ref:path to a JSON config. Overrides the default search order. + +.PARAMETER TeamConfigRef + Git ref for team/test-combined.json via git show. Default: team/local + +.PARAMETER Remote + Remote name. Default: origin + +.PARAMETER Rebuild + Delete/recreate from baseBranch even when a published tip exists. + Prefer incremental updates without this switch. + +.PARAMETER Abort + Abort a stuck merge: hard-reset local test/combined to origin/test/combined + (if present), clear merge/rebase state, and check out the starting branch. + Does not push. + +.PARAMETER Push + After a successful update (or reuse), push --force-with-lease to Remote. + +.PARAMETER BaseBranch / TestBranch / ExtraFeatureBranches / DeleteTestBranchFirst + Override individual config fields. + +.EXAMPLE + pwsh scripts/maintain-test-combined.ps1 -Push + + Incremental update from origin/test/combined, then publish. + +.EXAMPLE + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push + + Clean recreate from main (resolves conflicts from scratch). + +.EXAMPLE + pwsh scripts/maintain-test-combined.ps1 -Abort + + Escape a mid-merge worktree and return to the previous branch. +#> + +[CmdletBinding()] +param( + [string] $ConfigPath, + [string] $TeamConfigRef = "team/local", + [string] $Remote = "origin", + [switch] $Rebuild, + [switch] $Abort, + [switch] $Push, + [string] $BaseBranch, + [string] $TestBranch, + [string[]] $ExtraFeatureBranches, + [bool] $DeleteTestBranchFirst +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $RepoRoot + +function Invoke-GitCore { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs, + [switch] $Quiet + ) + if ($GitArgs.Count -eq 0) { + throw "Invoke-GitCore: no arguments" + } + + $Output = @(& git.exe @GitArgs 2>&1) + $ExitCode = $LASTEXITCODE + + if (-not $Quiet) { + foreach ($Line in $Output) { + if ($Line -is [System.Management.Automation.ErrorRecord]) { + Write-Warning $Line.ToString() + } + else { + Write-Host $Line + } + } + } + + $Stdout = @( + $Output | + Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | + ForEach-Object { [string] $_ } + ) + + return @{ + ExitCode = $ExitCode + Output = $Stdout + } +} + +function Invoke-Git { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + $Result = Invoke-GitCore @GitArgs + if ($Result.ExitCode -ne 0) { + throw "git $($GitArgs -join ' ') failed (exit $($Result.ExitCode))" + } + return $Result.Output +} + +function Invoke-GitSoft { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + return (Invoke-GitCore @GitArgs).ExitCode +} + +function Test-GitRef { + param([string] $Ref) + & git show-ref --verify --quiet $Ref + return $LASTEXITCODE -eq 0 +} + +function Test-GitAncestor { + param( + [string] $Ancestor, + [string] $Descendant + ) + & git.exe merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + +function Get-RefSha { + param([string] $Ref) + $Sha = ([string] (& git.exe rev-parse --verify "$Ref^{commit}" 2>$null)).Trim() + if ($LASTEXITCODE -ne 0 -or -not $Sha) { + return $null + } + return $Sha +} + +function Sync-RemoteBranches { + param( + [string] $RemoteName, + [string[]] $Branches + ) + + $UniqueBranches = @($Branches | Where-Object { $_ } | Select-Object -Unique) + if ($UniqueBranches.Count -eq 0) { + return + } + + Write-Host "==> Fetch $RemoteName $($UniqueBranches -join ', ')" + $Result = Invoke-GitCore -Quiet @(@('fetch', $RemoteName) + $UniqueBranches) + if ($Result.ExitCode -ne 0) { + $Detail = ($Result.Output -join "`n").Trim() + if ($Detail) { + throw "git fetch $RemoteName failed (exit $($Result.ExitCode)): $Detail" + } + throw "git fetch $RemoteName failed (exit $($Result.ExitCode))" + } +} + +function Resolve-RemoteTip { + param( + [string] $Branch, + [string] $RemoteName + ) + + $RemoteRef = "$RemoteName/$Branch" + if (-not (Test-GitRef "refs/remotes/$RemoteName/$Branch")) { + return $null + } + $Sha = Get-RefSha -Ref $RemoteRef + if (-not $Sha) { return $null } + return @{ + Branch = $Branch + MergeRef = $RemoteRef + Sha = $Sha + Source = 'remote' + } +} + +function Get-InputStamp { + param( + [string] $ConfigIdentity, + [string] $BaseName, + [string] $BaseSha, + [string[]] $ExtraPairs + ) + + $Parts = [System.Collections.Generic.List[string]]::new() + $Parts.Add("config=$ConfigIdentity") + $Parts.Add("base=$BaseName=$BaseSha") + foreach ($Pair in $ExtraPairs) { + if ($Pair) { $Parts.Add("extra=$Pair") } + } + return ($Parts -join '|') +} + +function Get-TestCombinedStamp { + param([string] $Commit) + if (-not $Commit) { return $null } + $Lines = @(& git.exe notes --ref=test-combined show $Commit 2>$null) + if ($LASTEXITCODE -ne 0) { + return $null + } + return (($Lines -join "`n").Trim()) +} + +function Set-TestCombinedStamp { + param( + [string] $Commit, + [string] $Stamp + ) + $ExitCode = Invoke-GitSoft @( + 'notes', '--ref=test-combined', 'add', '-f', '-m', $Stamp, $Commit + ) + if ($ExitCode -ne 0) { + Write-Warning "Could not write test-combined stamp note on $Commit" + } +} + +function ConvertTo-NormalizedStamp { + param([string] $Stamp) + if (-not $Stamp) { return $null } + $Normalized = $Stamp.Trim() -replace "`r`n", "`n" -replace "`r", "`n" + if ($Normalized.Contains("`n")) { + $Normalized = (($Normalized -split "`n") | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join '|' + } + return $Normalized +} + +function Test-MergeInProgress { + $MergeHead = & git.exe rev-parse -q --verify MERGE_HEAD 2>$null + return [bool] $MergeHead +} + +function Get-UnmergedPaths { + $Output = & git.exe diff --name-only --diff-filter=U 2>$null + if ($LASTEXITCODE -ne 0) { + return @() + } + return @($Output | Where-Object { $_ }) +} + +function Resolve-IgnoredMergeConflicts { + param( + [string[]] $IgnoredPaths, + [ValidateSet('ours', 'theirs')] + [string] $Prefer = 'ours' + ) + + foreach ($Path in (Get-UnmergedPaths)) { + if ($Path -in $IgnoredPaths) { + Write-Host "==> Auto-resolve merge conflict in $Path ($Prefer)" + Invoke-Git @('checkout', "--$Prefer", '--', $Path) + Invoke-Git @('add', '--', $Path) + } + } + + return @(Get-UnmergedPaths) +} + +function Merge-FeatureBranch { + param( + [string] $MergeRef, + [string] $ExtraBranch, + [string[]] $IgnoredPaths + ) + + $MergeMessage = "test: merge $ExtraBranch for combined testing" + $ExitCode = Invoke-GitSoft @( + 'merge', $MergeRef, '--no-edit', '-m', $MergeMessage + ) + if ($ExitCode -eq 0) { + return + } + + if (-not (Test-MergeInProgress)) { + throw "git merge $MergeRef failed (exit $ExitCode)" + } + + $Remaining = Resolve-IgnoredMergeConflicts -IgnoredPaths $IgnoredPaths -Prefer 'ours' + if ($Remaining.Count -gt 0) { + throw "Merge conflict in: $($Remaining -join ', ')" + } + + Invoke-Git @('commit', '--no-edit') +} + +function Get-CurrentBranch { + return ([string] (Invoke-Git @('branch', '--show-current') | Select-Object -First 1)).Trim() +} + +function Restore-DevBranch { + param([string] $Branch) + if ($Branch) { + Invoke-Git @('checkout', $Branch) + } +} + +function Get-GitConfigJson { + param( + [string[]] $Refs, + [string] $RepoPath = "team/test-combined.json" + ) + + foreach ($Ref in $Refs) { + if (-not $Ref) { continue } + $Spec = "${Ref}:${RepoPath}" + $Json = & git show $Spec 2>$null + if ($LASTEXITCODE -eq 0 -and $Json) { + return @{ Source = $Spec; Json = [string] $Json } + } + } + + return $null +} + +function Resolve-ConfigSource { + param( + [string] $ExplicitPath, + [string] $TeamRef + ) + + if ($ExplicitPath) { + if (Test-Path $ExplicitPath) { + return @{ + Source = (Resolve-Path $ExplicitPath).Path + Json = $null + } + } + if ($ExplicitPath -match ':') { + $Json = & git show $ExplicitPath 2>$null + if ($LASTEXITCODE -eq 0 -and $Json) { + return @{ Source = $ExplicitPath; Json = [string] $Json } + } + } + throw "Config file not found: $ExplicitPath" + } + + $LocalCandidates = @( + (Join-Path $RepoRoot "scripts/test-combined.json"), + (Join-Path $RepoRoot "team/test-combined.json") + ) + + foreach ($Candidate in $LocalCandidates) { + if (Test-Path $Candidate) { + return @{ + Source = (Resolve-Path $Candidate).Path + Json = $null + } + } + } + + $GitRefs = @( + $TeamRef, + "origin/$TeamRef" + ) + $FromGit = Get-GitConfigJson -Refs $GitRefs + if ($FromGit) { + return $FromGit + } + + $Example = Join-Path $RepoRoot "scripts/test-combined.example.json" + if (Test-Path $Example) { + Write-Warning "Using example config ($Example). Copy to scripts/test-combined.json or update team/local." + return @{ + Source = (Resolve-Path $Example).Path + Json = $null + } + } + + throw @" +No test-combined config found. +Fetch team/local (git fetch origin team/local) or create scripts/test-combined.json from scripts/test-combined.example.json. +"@ +} + +function Read-TestCombinedConfig { + param( + [string] $Source, + [string] $Json + ) + + try { + if ($Json) { + $Config = $Json | ConvertFrom-Json + } + else { + $Config = Get-Content -Raw -Path $Source | ConvertFrom-Json + } + } + catch { + throw "Failed to parse config JSON at '$Source': $_" + } + + foreach ($Required in @("baseBranch", "testBranch", "extraFeatureBranches")) { + if (-not ($Config.PSObject.Properties.Name -contains $Required)) { + throw "Config '$Source' is missing required field '$Required'." + } + } + + return $Config +} + +function Clear-TestCombinedUpstream { + param([string] $Branch) + # Creating from origin/main sets upstream to main — confusing ("ahead of main"). + & git.exe branch --unset-upstream $Branch 2>$null | Out-Null +} + +if (-not (Test-Path "FYPA.py")) { + throw "FYPA.py not found in $RepoRoot — run this script from the FYPA repo." +} + +$ReturnBranch = Get-CurrentBranch +if (-not $ReturnBranch) { + throw "Could not determine the current branch (detached HEAD?). Check out a branch first." +} + +if ($Abort) { + $AbortTestBranch = if ($PSBoundParameters.ContainsKey("TestBranch") -and $TestBranch) { + $TestBranch + } + else { + "test/combined" + } + Write-Host "==> Abort: clear merge/rebase state and reset $AbortTestBranch" + $onAbortBranch = ((Get-CurrentBranch) -eq $AbortTestBranch) + if ($onAbortBranch) { + & git merge --abort 2>$null | Out-Null + & git rebase --abort 2>$null | Out-Null + & git reset --merge 2>$null | Out-Null + } + try { + Sync-RemoteBranches -RemoteName $Remote -Branches @($AbortTestBranch) + } + catch { + Write-Warning "Fetch $Remote/$AbortTestBranch failed — using existing refs if present." + } + $RemoteAbortRef = "$Remote/$AbortTestBranch" + if (Test-GitRef "refs/remotes/$Remote/$AbortTestBranch") { + if ($onAbortBranch) { + Invoke-Git @('reset', '--hard', $RemoteAbortRef) + Clear-TestCombinedUpstream -Branch $AbortTestBranch + Write-Host "==> $AbortTestBranch reset to $RemoteAbortRef" + if ($ReturnBranch -ne $AbortTestBranch) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch + } + } + else { + # Update the local branch tip without checking it out (worktree may be dirty). + if (Test-GitRef "refs/heads/$AbortTestBranch") { + Invoke-Git @('branch', '-f', $AbortTestBranch, $RemoteAbortRef) + } + else { + Invoke-Git @('branch', $AbortTestBranch, $RemoteAbortRef) + } + Clear-TestCombinedUpstream -Branch $AbortTestBranch + Write-Host "==> Local $AbortTestBranch forced to $RemoteAbortRef (no checkout)" + } + } + elseif ($onAbortBranch) { + Write-Host "==> No $RemoteAbortRef — checking out $ReturnBranch and deleting local tip" + Restore-DevBranch -Branch $ReturnBranch + if (Test-GitRef "refs/heads/$AbortTestBranch") { + Invoke-Git @('branch', '-D', $AbortTestBranch) + } + } + else { + Write-Host "==> No local/remote $AbortTestBranch to reset" + } + Write-Host "==> Abort done — on $(Get-CurrentBranch)" + exit 0 +} + +# Soft-fetch team config ref so git show origin/team/local:... works. +if (-not $ConfigPath) { + try { + Sync-RemoteBranches -RemoteName $Remote -Branches @($TeamConfigRef) + } + catch { + Write-Warning "Fetch $Remote $TeamConfigRef failed; using existing refs if present." + Write-Warning "$_" + } +} + +$ConfigSource = Resolve-ConfigSource -ExplicitPath $ConfigPath -TeamRef $TeamConfigRef +Write-Host "==> Config: $($ConfigSource.Source)" +$Config = Read-TestCombinedConfig -Source $ConfigSource.Source -Json $ConfigSource.Json + +$BaseBranch = if ($PSBoundParameters.ContainsKey("BaseBranch")) { $BaseBranch } else { [string] $Config.baseBranch } +$TestBranch = if ($PSBoundParameters.ContainsKey("TestBranch")) { $TestBranch } else { [string] $Config.testBranch } +$ExtraFeatureBranches = if ($PSBoundParameters.ContainsKey("ExtraFeatureBranches")) { + $ExtraFeatureBranches +} +else { + @($Config.extraFeatureBranches | ForEach-Object { [string] $_ }) +} +$DeleteTestBranchFirst = if ($PSBoundParameters.ContainsKey("DeleteTestBranchFirst")) { + $DeleteTestBranchFirst +} +elseif ($Config.PSObject.Properties.Name -contains "deleteTestBranchFirst") { + [bool] $Config.deleteTestBranchFirst +} +else { + $false +} + +if (-not $BaseBranch) { throw "baseBranch is empty." } +if (-not $TestBranch) { throw "testBranch is empty." } + +Write-Host "==> Branch source: $Remote tips only (no local-ahead merge)" + +Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches) +# test/combined may not exist yet on first publish — soft-fetch only. +try { + Sync-RemoteBranches -RemoteName $Remote -Branches @($TestBranch) +} +catch { + Write-Host "==> $Remote/$TestBranch not fetched yet (ok on first publish)" +} + +$BaseTarget = Resolve-RemoteTip -Branch $BaseBranch -RemoteName $Remote +if (-not $BaseTarget) { + throw "Base branch '$BaseBranch' not found as $Remote/$BaseBranch. Push it first." +} + +$BaseRef = $BaseTarget.MergeRef +$BaseSha = $BaseTarget.Sha +Write-Host "==> Base: $BaseRef" + +$ExtraStampPairs = [System.Collections.Generic.List[string]]::new() +$ResolvedExtras = [System.Collections.Generic.List[hashtable]]::new() +$MissingExtras = [System.Collections.Generic.List[string]]::new() +foreach ($ExtraBranch in $ExtraFeatureBranches) { + if (-not $ExtraBranch) { continue } + $ExtraTarget = Resolve-RemoteTip -Branch $ExtraBranch -RemoteName $Remote + if (-not $ExtraTarget) { + $MissingExtras.Add($ExtraBranch) + continue + } + Write-Host "==> Extra: $($ExtraTarget.MergeRef)" + $ExtraStampPairs.Add("$ExtraBranch=$($ExtraTarget.Sha)") + $ResolvedExtras.Add(@{ + Branch = $ExtraTarget.Branch + MergeRef = $ExtraTarget.MergeRef + Sha = $ExtraTarget.Sha + }) +} + +if ($MissingExtras.Count -gt 0) { + throw @" +Missing on ${Remote}: $($MissingExtras -join ', '). +Push each feature branch before maintaining $TestBranch. +"@ +} + +$ConfigIdentity = @( + "base=$BaseBranch", + "test=$TestBranch", + "deleteFirst=$DeleteTestBranchFirst", + "extras=$($ExtraFeatureBranches -join ',')" +) -join ';' + +$DesiredStamp = ConvertTo-NormalizedStamp (Get-InputStamp ` + -ConfigIdentity $ConfigIdentity ` + -BaseName $BaseBranch ` + -BaseSha $BaseSha ` + -ExtraPairs @($ExtraStampPairs)) + +$RemoteTestRef = "$Remote/$TestBranch" +$HasRemoteTest = Test-GitRef "refs/remotes/$Remote/$TestBranch" +$RemoteTestSha = if ($HasRemoteTest) { Get-RefSha -Ref $RemoteTestRef } else { $null } +$RemoteStamp = ConvertTo-NormalizedStamp (Get-TestCombinedStamp -Commit $RemoteTestSha) + +$CanReuse = ( + -not $Rebuild -and + $RemoteStamp -and + ($RemoteStamp -eq $DesiredStamp) +) + +$UseIncremental = ( + -not $Rebuild -and + -not $CanReuse -and + $HasRemoteTest +) + +if ($CanReuse) { + Write-Host "==> Stamp matches $RemoteTestRef — reuse" +} +elseif ($UseIncremental) { + Write-Host "==> Incremental update from $RemoteTestRef (pass -Rebuild for clean recreate)" +} +elseif ($Rebuild) { + Write-Host "==> -Rebuild: clean recreate from $BaseRef" +} +else { + Write-Host "==> No $RemoteTestRef yet — first create from $BaseRef" +} + +$IgnoredPaths = @('.gitignore', 'FYPA.code-workspace') +$Status = @(Invoke-Git @('status', '--porcelain')) +$BlockingStatus = @($Status | Where-Object { + $path = $_.Substring(3).Trim() + if ($path -match ' -> ') { $path = ($path -split ' -> ', 2)[-1].Trim() } + elseif ($path -match "`t") { $path = ($path -split "`t", 2)[-1].Trim() } + $path -notin $IgnoredPaths +}) +if ($BlockingStatus.Count -gt 0) { + throw @" +Uncommitted changes detected on '$ReturnBranch'. +Commit or stash them before running maintain-test-combined. +To escape a stuck merge: pwsh scripts/maintain-test-combined.ps1 -Abort +"@ +} + +$Returned = $false +$LeaveOnConflict = $false +try { + if ($CanReuse) { + Write-Host "==> Checkout $TestBranch @ $RemoteTestRef" + Invoke-Git @('checkout', '-B', $TestBranch, $RemoteTestRef) + Invoke-Git @('reset', '--hard', $RemoteTestRef) + Clear-TestCombinedUpstream -Branch $TestBranch + } + elseif ($UseIncremental) { + Write-Host "==> Checkout $TestBranch @ $RemoteTestRef" + Invoke-Git @('checkout', '-B', $TestBranch, $RemoteTestRef) + Invoke-Git @('reset', '--hard', $RemoteTestRef) + Clear-TestCombinedUpstream -Branch $TestBranch + + $HeadSha = Get-RefSha -Ref 'HEAD' + foreach ($Extra in $ResolvedExtras) { + if (Test-GitAncestor -Ancestor $Extra.Sha -Descendant $HeadSha) { + Write-Host "==> Skip $($Extra.Branch) (already in $TestBranch)" + continue + } + Write-Host "==> Merge $($Extra.MergeRef) into $TestBranch" + Merge-FeatureBranch -MergeRef $Extra.MergeRef -ExtraBranch $Extra.Branch -IgnoredPaths $IgnoredPaths + $HeadSha = Get-RefSha -Ref 'HEAD' + } + + $NewTip = Get-RefSha -Ref 'HEAD' + if ($NewTip) { + Set-TestCombinedStamp -Commit $NewTip -Stamp $DesiredStamp + Write-Host "==> Stamp written for $TestBranch" + } + } + else { + # Full recreate from base (first publish or -Rebuild). + if ($DeleteTestBranchFirst -and (Test-GitRef "refs/heads/$TestBranch")) { + Write-Host "==> Delete $TestBranch" + if ((Get-CurrentBranch) -eq $TestBranch) { + Invoke-Git @('checkout', $ReturnBranch) + } + Invoke-Git @('branch', '-D', $TestBranch) + } + + if (Test-GitRef "refs/heads/$TestBranch") { + Write-Host "==> Recreate $TestBranch from $BaseRef" + Invoke-Git @('branch', '-f', $TestBranch, $BaseRef) + Invoke-Git @('checkout', $TestBranch) + } + else { + Write-Host "==> Create $TestBranch from $BaseRef" + Invoke-Git @('checkout', '-b', $TestBranch, $BaseRef) + } + Clear-TestCombinedUpstream -Branch $TestBranch + + foreach ($Extra in $ResolvedExtras) { + Write-Host "==> Merge $($Extra.MergeRef) into $TestBranch" + Merge-FeatureBranch -MergeRef $Extra.MergeRef -ExtraBranch $Extra.Branch -IgnoredPaths $IgnoredPaths + } + + $NewTip = Get-RefSha -Ref 'HEAD' + if ($NewTip) { + Set-TestCombinedStamp -Commit $NewTip -Stamp $DesiredStamp + Write-Host "==> Stamp written for $TestBranch" + } + } + + $Tip = Get-RefSha -Ref 'HEAD' + Write-Host "==> $TestBranch tip: $Tip" + + if ($Push) { + Write-Host "==> Push --force-with-lease $Remote $TestBranch" + Invoke-Git @('push', '--force-with-lease', $Remote, "HEAD:refs/heads/$TestBranch") + Write-Host "==> Pushed $Remote/$TestBranch" + } + else { + Write-Host "==> Local only (pass -Push to update $Remote/$TestBranch)" + } +} +catch { + $msg = "$_" + if ($msg -match 'Merge conflict' -and ( + (Test-MergeInProgress) -or ((Get-UnmergedPaths).Count -gt 0) + )) { + $LeaveOnConflict = $true + Write-Host @" + +==> Merge conflict — still on $TestBranch (do not git switch away). + +Finish: + git add -u + git commit --no-edit + pwsh scripts/maintain-test-combined.ps1 -Push + +Abort back to published tip + previous branch: + pwsh scripts/maintain-test-combined.ps1 -Abort +"@ + } + elseif ((Get-CurrentBranch) -ne $ReturnBranch) { + & git merge --abort 2>$null | Out-Null + & git rebase --abort 2>$null | Out-Null + } + throw +} +finally { + if (-not $LeaveOnConflict) { + $Current = Get-CurrentBranch + if ($Current -ne $ReturnBranch) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch + $Returned = $true + } + } +} + +if (-not $LeaveOnConflict) { + if (-not $Returned) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch + } +} diff --git a/scripts/test-combined.example.json b/scripts/test-combined.example.json new file mode 100644 index 0000000..3f21d67 --- /dev/null +++ b/scripts/test-combined.example.json @@ -0,0 +1,9 @@ +{ + "baseBranch": "main", + "testBranch": "test/combined", + "deleteTestBranchFirst": true, + "extraFeatureBranches": [ + "feature/example-a", + "fix/example-b" + ] +} diff --git a/scripts/test-combined.ps1 b/scripts/test-combined.ps1 new file mode 100644 index 0000000..3ce0138 --- /dev/null +++ b/scripts/test-combined.ps1 @@ -0,0 +1,219 @@ +<# +.SYNOPSIS + Check out origin/test/combined, optionally run tests/FYPA, then switch back. + +.DESCRIPTION + The combined branch is maintained centrally (see scripts/maintain-test-combined.ps1). + This script only fetches and checks out the remote tip — it does not merge + feature branches. + + To rebuild and push test/combined: + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push + +.PARAMETER Remote + Remote name. Default: origin + +.PARAMETER TestBranch + Combined branch name. Default: test/combined + +.PARAMETER Rebuild + Not supported here — prints how to run maintain-test-combined.ps1 and exits 1. + +.PARAMETER SkipTests + Skip the pytest topology suite; still runs FYPA.py unless the script exits earlier. + +.PARAMETER PrjPcb + Path to a .PrjPcb passed through to FYPA.py. + +.EXAMPLE + pwsh scripts/test-combined.ps1 + +.EXAMPLE + pwsh scripts/test-combined.ps1 -SkipTests -PrjPcb path\to\Board.PrjPcb +#> + +[CmdletBinding()] +param( + [string] $Remote = "origin", + [string] $TestBranch = "test/combined", + [switch] $Rebuild, + [switch] $SkipTests, + [string] $PrjPcb +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $RepoRoot + +if ($Rebuild) { + Write-Error @" +-Rebuild is no longer supported by test-combined.ps1. +Rebuild and publish the shared branch with: + + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push + +Then re-run this script to check out $Remote/$TestBranch. +"@ + exit 1 +} + +function Invoke-GitCore { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs, + [switch] $Quiet + ) + if ($GitArgs.Count -eq 0) { + throw "Invoke-GitCore: no arguments" + } + + $Output = @(& git.exe @GitArgs 2>&1) + $ExitCode = $LASTEXITCODE + + if (-not $Quiet) { + foreach ($Line in $Output) { + if ($Line -is [System.Management.Automation.ErrorRecord]) { + Write-Warning $Line.ToString() + } + else { + Write-Host $Line + } + } + } + + $Stdout = @( + $Output | + Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | + ForEach-Object { [string] $_ } + ) + + return @{ + ExitCode = $ExitCode + Output = $Stdout + } +} + +function Invoke-Git { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + $Result = Invoke-GitCore @GitArgs + if ($Result.ExitCode -ne 0) { + throw "git $($GitArgs -join ' ') failed (exit $($Result.ExitCode))" + } + return $Result.Output +} + +function Get-CurrentBranch { + return ([string] (Invoke-Git @('branch', '--show-current') | Select-Object -First 1)).Trim() +} + +function Test-GitRef { + param([string] $Ref) + & git show-ref --verify --quiet $Ref + return $LASTEXITCODE -eq 0 +} + +if (-not (Test-Path "FYPA.py")) { + throw "FYPA.py not found in $RepoRoot — run this script from the FYPA repo." +} + +$PrjPcbPath = $null +if ($PrjPcb) { + if (-not (Test-Path -LiteralPath $PrjPcb)) { + throw "PrjPcb not found: $PrjPcb" + } + $PrjPcbPath = (Resolve-Path -LiteralPath $PrjPcb).Path +} + +$ReturnBranch = Get-CurrentBranch +if (-not $ReturnBranch) { + throw "Could not determine the current branch." +} + +$IgnoredPaths = @('.gitignore', 'FYPA.code-workspace') +$Status = @(Invoke-Git @('status', '--porcelain')) +$BlockingStatus = @($Status | Where-Object { + $path = $_.Substring(3).Trim() + if ($path -match ' -> ') { $path = ($path -split ' -> ', 2)[-1].Trim() } + elseif ($path -match "`t") { $path = ($path -split "`t", 2)[-1].Trim() } + $path -notin $IgnoredPaths +}) +if ($BlockingStatus.Count -gt 0) { + throw @" +Uncommitted changes detected on '$ReturnBranch'. +Commit or stash them before running the test script. +"@ +} + +$RemoteRef = "$Remote/$TestBranch" +Write-Host "==> Fetch $Remote $TestBranch" +$FetchResult = Invoke-GitCore -Quiet @('fetch', $Remote, $TestBranch) +if ($FetchResult.ExitCode -ne 0) { + Write-Warning "Fetch failed; using existing $RemoteRef if present." +} + +if (-not (Test-GitRef "refs/remotes/$Remote/$TestBranch")) { + throw @" +$RemoteRef not found. +Publish the shared branch first: + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push +"@ +} + +$Returned = $false +$FypaExit = 0 +try { + Write-Host "==> Checkout $TestBranch from $RemoteRef" + Invoke-Git @('checkout', '-B', $TestBranch, $RemoteRef) + Invoke-Git @('reset', '--hard', $RemoteRef) + + if ($SkipTests) { + Write-Host "==> Skip pytest (-SkipTests)" + } + else { + Write-Host "==> pytest topology tests" + & uv run python -m pytest ` + tests/test_topology_invariants.py ` + tests/test_topology_regressions.py ` + tests/test_topology_layout.py ` + tests/test_topology_geometry.py ` + tests/test_topology_labels.py ` + tests/test_pdn_topology.py -q + if ($LASTEXITCODE -ne 0) { + throw "pytest failed (exit $LASTEXITCODE)" + } + } + + Write-Host "==> uv run FYPA.py" + if ($PrjPcbPath) { + Write-Host " Project: $PrjPcbPath" + & uv run --extra spacemouse FYPA.py gui $PrjPcbPath + } + else { + & uv run --extra spacemouse FYPA.py + } + $FypaExit = $LASTEXITCODE +} +catch { + throw +} +finally { + $Current = Get-CurrentBranch + if ($Current -ne $ReturnBranch) { + Write-Host "==> Return to $ReturnBranch" + Invoke-Git @('checkout', $ReturnBranch) + $Returned = $true + } +} + +if (-not $Returned) { + Write-Host "==> Return to $ReturnBranch" + Invoke-Git @('checkout', $ReturnBranch) +} + +if ($FypaExit -and $FypaExit -ne 0) { + exit $FypaExit +} diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 850264b..ec79bb0 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -3256,7 +3256,224 @@ def test_is_solveable_still_false_without_a_source(): assert not loaded.is_solveable +# --- Multi-connector PDN_*_DES ------------------------------------------------ +def _banana_source_proj(*, n_des: str | None = "J3,J5,J7", + extra_params: dict | None = None, + omit_j7_gnd: bool = False, + include_sink: bool = True): + """SOURCE on J2 (VIN/GND) with optional PDN_N_DES listing return connectors. + + Nets: 0=GND, 1=VIN. Each connector has pin 1 on VIN and pin 2 on GND + unless ``omit_j7_gnd`` drops J7's GND pad. + """ + src_params = { + "PDN_ROLE": "SOURCE", + "PDN_V": "5", + "PDN_P_NET": "VIN", + "PDN_N_NET": "GND", + } + if n_des is not None: + src_params["PDN_N_DES"] = n_des + if extra_params: + src_params.update(extra_params) + + sch = [ + RawSchComponent( + designator="J2", schdoc_name="Pwr.SchDoc", + parameters=src_params, + pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J3", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J5", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J7", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + ] + pcb = [ + RawPcbComponent( + designator="J2", center=Pt2D(0, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J2", + ), + RawPcbComponent( + designator="J3", center=Pt2D(5, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J3", + ), + RawPcbComponent( + designator="J5", center=Pt2D(10, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J5", + ), + RawPcbComponent( + designator="J7", center=Pt2D(15, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J7", + ), + ] + pads = [ + _pad(0, "1", 1, 0), # J2 VIN + _pad(0, "2", 0, 1), # J2 GND + _pad(1, "1", 1, 5), # J3 VIN + _pad(1, "2", 0, 6), # J3 GND + _pad(2, "1", 1, 10), # J5 VIN + _pad(2, "2", 0, 11), # J5 GND + _pad(3, "1", 1, 15), # J7 VIN + ] + if not omit_j7_gnd: + pads.append(_pad(3, "2", 0, 16)) # J7 GND + + if include_sink: + sch.append(RawSchComponent( + designator="U1", schdoc_name="Pwr.SchDoc", + parameters={ + "PDN_ROLE": "SINK", + "PDN_I": "1A", + "PDN_P_NET": "VIN", + "PDN_N_NET": "GND", + }, + pin_designators=("1", "2"), + )) + pcb.append(RawPcbComponent( + designator="U1", center=Pt2D(20, 0), rotation_deg=0.0, + layer_name="TOP", footprint="QFN", source_designator="U1", + )) + pads.extend([_pad(4, "1", 1, 20), _pad(4, "2", 0, 21)]) + + return _minimal_proj( + nets=(RawNet("GND"), RawNet("VIN")), + sch_components=tuple(sch), + pcb_components=tuple(pcb), + pads=tuple(pads), + ) + + +def test_source_n_des_multi_connector(): + """SOURCE on J2 with PDN_N_DES=J3,J5,J7 → P on J2, N from three connectors.""" + proj = _banana_source_proj() + result = parse_annotations(proj, enabled_layers=[1]) + assert result.ok, result.errors + sources = [d for d in result.directives if isinstance(d, SourceSpec)] + assert len(sources) == 1 + src = sources[0] + assert src.designator == "J2" + assert {p.pad_designator for p in src.p.pins} == {"1"} + assert {p.component_designator for p in src.p.pins} == {"J2"} + assert len(src.n.pins) == 3 + assert {p.component_designator for p in src.n.pins} == {"J3", "J5", "J7"} + assert {p.pad_designator for p in src.n.pins} == {"2"} + # Host J2 GND pad is NOT auto-included when N_DES is set. + assert "J2" not in {p.component_designator for p in src.n.pins} + + +def test_source_n_des_missing_designator_errors(): + proj = _banana_source_proj(n_des="J3,J99,J5") + result = parse_annotations(proj, enabled_layers=[1]) + assert not result.ok + assert any( + "J99" in e and "not found" in e for e in result.errors + ), result.errors + + +def test_source_n_des_no_pad_on_net_errors(): + proj = _banana_source_proj(n_des="J3,J5,J7", omit_j7_gnd=True) + result = parse_annotations(proj, enabled_layers=[1]) + assert not result.ok + assert any( + "J7" in e and "no pad" in e and "GND" in e for e in result.errors + ), result.errors + + +def test_source_without_des_backward_compat(): + """Without *_DES, P and N pads come from the host only.""" + proj = _banana_source_proj(n_des=None) + result = parse_annotations(proj, enabled_layers=[1]) + assert result.ok, result.errors + src = next(d for d in result.directives if isinstance(d, SourceSpec)) + assert {p.component_designator for p in src.p.pins} == {"J2"} + assert {p.component_designator for p in src.n.pins} == {"J2"} + assert {p.pad_designator for p in src.p.pins} == {"1"} + assert {p.pad_designator for p in src.n.pins} == {"2"} + + +def test_sink_p_des_multi_connector(): + """Analogous SINK: host J1 draws from +5V; P pads from J2,J3.""" + proj = _minimal_proj( + nets=(RawNet("GND"), RawNet("+5V")), + sch_components=( + RawSchComponent( + designator="J1", schdoc_name="Pwr.SchDoc", + parameters={ + "PDN_ROLE": "SINK", + "PDN_I": "2A", + "PDN_P_NET": "+5V", + "PDN_N_NET": "GND", + "PDN_P_DES": "J2,J3", + }, + pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J2", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J3", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J5", schdoc_name="Pwr.SchDoc", + parameters={ + "PDN_ROLE": "SOURCE", + "PDN_V": "5", + "PDN_P_NET": "+5V", + "PDN_N_NET": "GND", + }, + pin_designators=("1", "2"), + ), + ), + pcb_components=( + RawPcbComponent( + designator="J1", center=Pt2D(0, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J1", + ), + RawPcbComponent( + designator="J2", center=Pt2D(5, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J2", + ), + RawPcbComponent( + designator="J3", center=Pt2D(10, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J3", + ), + RawPcbComponent( + designator="J5", center=Pt2D(15, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J5", + ), + ), + pads=( + _pad(0, "1", 1, 0), # J1 +5V (host — NOT in P when P_DES set) + _pad(0, "2", 0, 1), # J1 GND + _pad(1, "1", 1, 5), # J2 +5V + _pad(1, "2", 0, 6), + _pad(2, "1", 1, 10), # J3 +5V + _pad(2, "2", 0, 11), + _pad(3, "1", 1, 15), # J5 SOURCE +5V + _pad(3, "2", 0, 16), + ), + ) + result = parse_annotations(proj, enabled_layers=[1]) + assert result.ok, result.errors + sinks = [d for d in result.directives if isinstance(d, SinkSpec)] + assert len(sinks) == 1 + snk = sinks[0] + assert snk.designator == "J1" + assert len(snk.p.pins) == 2 + assert {p.component_designator for p in snk.p.pins} == {"J2", "J3"} + assert {p.component_designator for p in snk.n.pins} == {"J1"} + assert "J1" not in {p.component_designator for p in snk.p.pins} def test_format_solve_blockers_lists_errors(): diff --git a/tests/test_editor_directives.py b/tests/test_editor_directives.py index c485665..5b0152d 100644 --- a/tests/test_editor_directives.py +++ b/tests/test_editor_directives.py @@ -164,3 +164,236 @@ def test_editor_series_on_two_nets_is_still_accepted(): resistance=0.05)] assert apply_editor_directives(loaded, eds) == [] assert len(loaded.annotations.directives) == 1 + + +# --- Multi-connector p_des / n_des ------------------------------------------- + +def _loaded_with_connectors(): + """Board stand-in: J2 (VIN+GND), J3/J5/J7 (GND return bananas), U1 load.""" + from fypa.altium.extract import Pt2D + + nets = [SimpleNamespace(name="GND"), SimpleNamespace(name="VIN")] + comps = [ + SimpleNamespace(designator="J2"), + SimpleNamespace(designator="J3"), + SimpleNamespace(designator="J5"), + SimpleNamespace(designator="J7"), + SimpleNamespace(designator="U1"), + ] + pads = [ + SimpleNamespace(component_index=0, net_index=1, designator="1", + center=Pt2D(0, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=0, net_index=0, designator="2", + center=Pt2D(1, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=1, net_index=0, designator="2", + center=Pt2D(5, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=2, net_index=0, designator="2", + center=Pt2D(10, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=3, net_index=0, designator="2", + center=Pt2D(15, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=4, net_index=1, designator="1", + center=Pt2D(20, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=4, net_index=0, designator="2", + center=Pt2D(21, 0), layer_id=1, is_through_hole=False), + ] + extracted = SimpleNamespace( + nets=nets, pcb_components=comps, pads=pads, + enabled_copper_layer_ids=lambda: [1], + ) + return SimpleNamespace(extracted=extracted, annotations=AnnotationResult()) + + +def test_editor_source_n_des_multi_connector(): + loaded = _loaded_with_connectors() + eds = [ + EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3", "J5", "J7"], voltage=5.0, + ), + EditorDirective( + kind="component", role="SINK", designator="U1", + single_net=False, p_net="VIN", n_net="GND", current=1.0, + ), + ] + warnings = apply_editor_directives(loaded, eds) + assert warnings == [], warnings + src = next(s for s in loaded.annotations.directives + if isinstance(s, SourceSpec)) + assert src.designator == "J2" + assert {p.component_designator for p in src.p.pins} == {"J2"} + assert {p.component_designator for p in src.n.pins} == {"J3", "J5", "J7"} + assert "J2" not in {p.component_designator for p in src.n.pins} + + +def test_editor_n_des_missing_designator_skipped(): + loaded = _loaded_with_connectors() + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3", "J99"], voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert loaded.annotations.directives == [] + assert any("J99" in w and "not found" in w for w in warnings) + + +def test_editor_n_des_no_pad_on_net_skipped(): + loaded = _loaded_with_connectors() + # J7 has only a GND pad in the fixture; point N_DES at a designator + # whose pads are all on VIN instead — drop J3's GND by renaming net. + loaded.extracted.pads[2].net_index = 1 # J3 pad now on VIN, not GND + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3"], voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert loaded.annotations.directives == [] + assert any("J3" in w and "no pad" in w for w in warnings) + + +def test_editor_without_des_backward_compat(): + loaded = _loaded_with_connectors() + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert warnings == [] + src = loaded.annotations.directives[0] + assert isinstance(src, SourceSpec) + assert {p.component_designator for p in src.p.pins} == {"J2"} + assert {p.component_designator for p in src.n.pins} == {"J2"} + + +def test_editor_directive_p_des_n_des_round_trip(): + d = EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + p_des=["J1"], n_des=["J3", "J5"], voltage=5.0, + ) + restored = EditorDirective.from_dict(d.to_dict()) + assert restored.p_des == ["J1"] + assert restored.n_des == ["J3", "J5"] + # Absent keys stay None (backward compatible .fypa files). + bare = EditorDirective.from_dict({"role": "SINK", "p_net": "+5V"}) + assert bare.p_des is None + assert bare.n_des is None + + +def test_editor_n_des_missing_pin_override_skipped(): + """Every *_PINS entry must appear on at least one listed designator.""" + loaded = _loaded_with_connectors() + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3", "J5"], n_pins=["2", "99"], voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert loaded.annotations.directives == [] + assert any( + "pin overrides not found" in w and "99" in w for w in warnings + ), warnings + + +def test_editor_n_des_multi_instance_merges_pads(): + """Multi-channel: one logical DES matches several physical placements.""" + from fypa.altium.extract import Pt2D + + nets = [SimpleNamespace(name="GND"), SimpleNamespace(name="VIN")] + comps = [ + SimpleNamespace(designator="J2", source_designator="J2"), + SimpleNamespace(designator="J3_CH1", source_designator="J3"), + SimpleNamespace(designator="J3_CH2", source_designator="J3"), + ] + pads = [ + SimpleNamespace(component_index=0, net_index=1, designator="1", + center=Pt2D(0, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=0, net_index=0, designator="2", + center=Pt2D(1, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=1, net_index=0, designator="2", + center=Pt2D(5, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=2, net_index=0, designator="2", + center=Pt2D(10, 0), layer_id=1, is_through_hole=False), + ] + extracted = SimpleNamespace( + nets=nets, pcb_components=comps, pads=pads, + enabled_copper_layer_ids=lambda: [1], + ) + loaded = SimpleNamespace( + extracted=extracted, annotations=AnnotationResult(), + ) + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3"], voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert warnings == [], warnings + src = loaded.annotations.directives[0] + assert isinstance(src, SourceSpec) + assert {p.component_designator for p in src.n.pins} == { + "J3_CH1", "J3_CH2", + } + assert len(src.n.pins) == 2 + + +# --- Unlock seeding helpers (no GUI) ----------------------------------------- + +def test_terminal_summary_pad_is_raw_not_compound(): + from fypa.altium.annotations import TerminalPin, TerminalSpec + from fypa.altium.extract import Pt2D + from fypa.altium.loader import _terminal_summary + + nets = [SimpleNamespace(name="GND"), SimpleNamespace(name="VIN")] + term = TerminalSpec(pins=( + TerminalPin( + pad_designator="1", layer_id=1, net_index=1, + point=Pt2D(0, 0), component_designator="J2", + ), + TerminalPin( + pad_designator="2", layer_id=1, net_index=0, + point=Pt2D(1, 0), component_designator="J3", + ), + ), requested_net="VIN") + summary = _terminal_summary(term, nets) + pads = [p["pad"] for p in summary["pins"]] + assert pads == ["1", "2"] + assert summary["pins"][0]["component"] == "J2" + assert summary["pins"][0]["pad_label"] == "J2-1" + assert summary["pins"][1]["pad_label"] == "J3-2" + + +def test_unlock_seeds_raw_pads_and_des_lists(): + """Unlock helpers: raw pads + DES from pin components (not host-only).""" + from fypa.altium_viewer import PdnViewer + + host = "J2" + p_term = { + "pins": [ + {"pad": "1", "component": "J2", "pad_label": "J2-1", "net": "VIN"}, + ], + } + n_term = { + "pins": [ + {"pad": "2", "component": "J3", "pad_label": "J3-2", "net": "GND"}, + {"pad": "2", "component": "J5", "pad_label": "J5-2", "net": "GND"}, + {"pad": "2", "component": "J7", "pad_label": "J7-2", "net": "GND"}, + ], + } + assert PdnViewer._terminal_pin_pads(p_term) == ["1"] + assert PdnViewer._terminal_pin_pads(n_term) == ["2"] + assert PdnViewer._terminal_des_list(p_term, host) == [] # host-only + assert PdnViewer._terminal_des_list(n_term, host) == ["J3", "J5", "J7"] + + +def test_unlock_strips_legacy_compound_pad(): + from fypa.altium_viewer import PdnViewer + + term = { + "pins": [ + {"pad": "J2-1", "component": "J2", "net": "VIN"}, + ], + } + assert PdnViewer._terminal_pin_pads(term) == ["1"] \ No newline at end of file