From 9b967707a000a1178b349572b7500bc0dbea74c2 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 21:12:55 +1300 Subject: [PATCH 1/2] qet-mcp: qet_edit names a conductor by its uuid set_conductor, move_conductor_segment and delete_conductor named a conductor by one of its terminals, which had to carry exactly one conductor: where two meet at a terminal, neither could be named from it. Each now also takes "conductor": "{uuid}" (as qet_conductors reports it) in place of element + terminal. The generated script asks qet.conductorEnds() for the conductor's two ends and passes the one whose terminal carries only that conductor. Where both ends are shared, or no conductor has that uuid, the op fails and its "note" says which. The lookup is required only when a uuid is used; giving both forms is an error. Tests: the script generated for each form and the argument errors; on a folio where one terminal carries two conductors, deleting either by uuid leaves exactly the other; an unknown uuid is reported. With the end chosen without checking its terminal carries only that conductor, the terminal test fails. 248/248 with a build carrying qet.conductorEnds(). Stacked on the conductorUuids()/conductorEnds() scripting change. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 7 ++++ misc/qet-mcp/qet_mcp.py | 64 ++++++++++++++++++++++++++++++++++-- misc/qet-mcp/test_qet_mcp.py | 54 ++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index e5136fb88..08317a00c 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -290,6 +290,13 @@ Python, plus the hang guard on `addConductor` and the database refresh in `index` also takes that uuid, which does not shift the way an index does. `qet_element_build` gives every part of a symbol a uuid as well, returned in `part_uuids`; `qet_element_info` lists them in `part_list`. +- **A conductor can be named by its uuid** (`qet_conductors` reports it): + `set_conductor`, `move_conductor_segment` and `delete_conductor` take + `"conductor": "{uuid}"` in place of `element` + `terminal`, which works + where two conductors meet at a terminal. It is turned at run time into an + end whose terminal carries only that conductor; where both of its ends + are shared the op fails and its `note` says why. Needs + `qet.conductorEnds()` in the build. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 7703c4c0e..0bf06a875 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1354,6 +1354,10 @@ SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"] FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach", "indexrev", "folio", "template"] +# The ops that address one conductor by element + terminal, and so also +# take "conductor": "{uuid}" (qet_conductors reports each one's uuid). +CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor") + # Accepted by set_conductor. The names are the project file's own, so what # a script sets is what qet_conductors reports back. CONDUCTOR_PROPERTIES = ["num", "formula", "function", "bus", "cable", @@ -1405,6 +1409,34 @@ def _build_script(operations: list, output: str) -> str: f"qet.log({_js(_MARKER)} + JSON.stringify(" "{kind: 'capabilities', missing: missing}));", "var stop = false;", + # A conductor named by uuid is turned into one of its ends, as the + # conductor calls take it: an end whose terminal carries no other + # conductor, so the call cannot pick the wrong one. null, with the + # reason logged, if it is not on the folio or both ends are shared. + "function qetMcpConductorEnd(index, folio, uuid) {", + " var ends = qet.conductorEnds(folio, uuid);", + " var why = 'no conductor ' + uuid + ' on folio ' + folio;", + " if (ends && ends.length === 2) {", + " var lines = qet.conductors(folio);", + " for (var k = 0; k < 2; k++) {", + " var n = 0;", + " for (var j = 0; j < lines.length; j++) {", + " var p = lines[j].split(' : ')[0].split(' -- ');", + " if (p[0] === ends[k] || p[1] === ends[k]) n++;", + " }", + " if (n === 1) {", + " var m = ends[k].split(' terminal ');", + " return {element: m[0], terminal: parseInt(m[1], 10)};", + " }", + " }", + " why = 'conductor ' + uuid + ' shares both of its terminals with other '", + " + 'conductors; the conductor calls address one by a terminal carrying '", + " + 'only it';", + " }", + f" qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'op_note', index: index, note: why}));", + " return null;", + "}", "if (missing.length === 0) {", ] @@ -1561,6 +1593,20 @@ def _build_script(operations: list, output: str) -> str: if name == "add_shape" and op.get("shape") not in SHAPES: raise ValueError(f"operation {i}: unknown shape {op.get('shape')!r}; " f"expected one of {', '.join(SHAPES)}") + # The conductor ops take "conductor": "{uuid}" in place of element + + # terminal: a uuid names one conductor for good, where a terminal + # can carry several. + conductor_js = None + if name in CONDUCTOR_UUID_OPS and "conductor" in op: + if "element" in op or "terminal" in op: + raise ValueError(f"operation {i} ({name}): give either \"conductor\" " + f"(its uuid) or \"element\" + \"terminal\", not both") + if not isinstance(op["conductor"], str) or not _UUID_RE.fullmatch(op["conductor"]): + raise ValueError(f"operation {i}: \"conductor\" must be a conductor uuid, " + f"got {op['conductor']!r}") + conductor_js = _js(op["conductor"]) + uuid_methods.add("conductorEnds") + op = {**op, "element": "{00000000-0000-0000-0000-000000000000}", "terminal": 0} args = [] for key, kind in spec: if key not in op: @@ -1568,6 +1614,8 @@ def _build_script(operations: list, output: str) -> str: folio_js = args[0] if args else "0" element_js = args[1] if len(args) > 1 else None args.append(ref_or(op[key], kind, i, key)) + if conductor_js is not None: + args[1], args[2] = f"e{i}.element", f"e{i}.terminal" ident = op.get("id") if ident is not None: @@ -1579,6 +1627,9 @@ def _build_script(operations: list, output: str) -> str: call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})" lines.append(" if (!stop) {") + if conductor_js is not None: + lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});") + call = f"(e{i} ? {call} : false)" lines.append(f" var v{i} = {call};") if ident is not None: lines.append(f" R[{_js(ident)}] = v{i};") @@ -1624,7 +1675,7 @@ def _parse_script_output(text: str) -> dict: the cost is nothing and the failure it prevents is silent (an edit that worked, reported as having run no operations at all, which is what the first version of this tool did).""" - caps, ops, saved, stopped = None, [], None, False + caps, ops, saved, stopped, notes = None, [], None, False, {} for line in text.splitlines(): idx = line.find(_MARKER) if idx < 0: @@ -1643,9 +1694,14 @@ def _parse_script_output(text: str) -> dict: rec["succeeded"] = not (r is None or r is False or r == "" or r == [] or r == {} or (isinstance(r, int) and not isinstance(r, bool) and r == -1)) ops.append(rec) + elif rec.get("kind") == "op_note": + notes[rec.get("index")] = rec.get("note") elif rec.get("kind") == "save": saved = bool(rec.get("result")) stopped = bool(rec.get("stopped_early")) + for rec in ops: + if rec.get("index") in notes: + rec["note"] = notes[rec["index"]] return {"missing_methods": caps, "operations": ops, "saved": saved, "stopped_early": stopped} @@ -2491,7 +2547,11 @@ TOOLS = [ "index in the element definition; qet_element_info lists them. " "set_conductor addresses a conductor as the one on a given " "terminal and applies the change to its whole electrical " - "potential, so name a terminal carrying exactly one conductor; " + "potential, so name a terminal carrying exactly one conductor, " + "or give \"conductor\": its uuid from qet_conductors in place " + "of \"element\" + \"terminal\" (set_conductor, " + "move_conductor_segment and delete_conductor all accept it; it " + "needs one of the conductor's two terminals to carry only it); " "its \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) + ". move_conductor_segment reroutes the drawn path itself rather " "than a property of the potential -- addressed the same way (a " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index d9310e6c2..592dbd9b7 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -194,6 +194,26 @@ class EditValidation(unittest.TestCase): with self.assertRaisesRegex(ValueError, "index or its uuid"): self.build([op]) + def test_conductor_by_uuid(self): + """A conductor named by uuid is turned into one of its ends at run + time; the lookup is required only then, and "conductor" cannot be + given alongside element + terminal.""" + U = "{11111111-2222-4333-8444-555555555555}" + s = self.build([{"op": "delete_conductor", "folio": 1, "conductor": U}]) + self.assertIn(f'var e0 = qetMcpConductorEnd(0, 1, "{U}");', s) + self.assertIn("(e0 ? qet.deleteConductor(1, e0.element, e0.terminal) : false)", s) + self.assertIn('"conductorEnds"', s) + s = self.build([{"op": "set_conductor", "folio": 0, "conductor": U, + "property": "num", "value": "W1"}]) + self.assertIn('qet.setConductorProperty(0, e0.element, e0.terminal, "num", "W1")', s) + self.assertNotIn('"conductorEnds"', self.build( + [{"op": "delete_conductor", "folio": 0, "element": U, "terminal": 0}])) + with self.assertRaisesRegex(ValueError, "not both"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": U, + "element": U, "terminal": 0}]) + with self.assertRaisesRegex(ValueError, "must be a conductor uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": "W1"}]) + def test_every_op_generates_a_script(self): # one minimal valid instance of every op f = {"op": "add_folio", "id": "f"} @@ -2379,6 +2399,40 @@ class Integration(unittest.TestCase): r = self.ok(self.sb.edit(base, ops)) self.assertEqual([c["num"] for c in m.tool_conductors(r["output"])["conductors"]], ["W7"]) + def _hub(self): + """e0's terminal 0 carries two conductors, to e1 and to e2. Returns + the saved project and the two conductors' uuids.""" + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + *[{"op": "add_element", "id": f"e{i}", "folio": "$f", "path": COIL, "x": 100 + i * 200, "y": 100} + for i in range(3)], + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0}])) + uuids = [c["uuid"] for c in m.tool_conductors(r["output"])["conductors"]] + self.assertEqual(len(uuids), 2) + self.assertTrue(all(uuids), "new conductors carry a saved uuid") + return r["output"], uuids + + def test_conductor_by_uuid_where_two_meet_at_a_terminal(self): + """The terminal e0/0 carries both conductors, which element + terminal + cannot name; each uuid names one, whichever it is.""" + project, uuids = self._hub() + for gone, kept in ((uuids[0], uuids[1]), (uuids[1], uuids[0])): + with self.subTest(deleted=gone): + r = self.ok(self.sb.edit(project, [ + {"op": "delete_conductor", "folio": 1, "conductor": gone}], out="out2.qet")) + left = [c["uuid"] for c in m.tool_conductors(r["output"])["conductors"]] + self.assertEqual(left, [kept]) + + def test_unknown_conductor_uuid_is_reported(self): + project, _ = self._hub() + r = self.sb.edit(project, [{"op": "delete_conductor", "folio": 1, + "conductor": "{11111111-2222-4333-8444-555555555555}"}], + out="out2.qet") + self.assertFalse(r["ok"]) + self.assertIn("no conductor", r["operations"][0].get("note", "")) + # ---- cross references, strips, folios ---- def test_cross_reference_across_folios(self): From e7745afd18991ce949d04cae6fad08f981e22cc5 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 23:25:05 +1300 Subject: [PATCH 2/2] qet-mcp: answer review on naming a conductor by uuid - An end conductorEnds() reports as "?" is never picked. - An empty "conductor" says why: an older project's conductors have no saved uuid, so qet_conductors reports it empty. - The op description and README say set_conductor still changes the whole potential when given a uuid, and that older projects' conductors are named by element + terminal until #1103. - test_conductor_by_uuid covers move_conductor_segment. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 7 ++++++- misc/qet-mcp/qet_mcp.py | 13 +++++++++++-- misc/qet-mcp/test_qet_mcp.py | 8 ++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 08317a00c..486b85379 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -296,7 +296,12 @@ Python, plus the hang guard on `addConductor` and the database refresh in where two conductors meet at a terminal. It is turned at run time into an end whose terminal carries only that conductor; where both of its ends are shared the op fails and its `note` says why. Needs - `qet.conductorEnds()` in the build. + `qet.conductorEnds()` in the build. A uuid names one wire, but + `set_conductor` still changes the whole potential, the same as by + terminal. A project saved before conductors carried a uuid has none in + the file: QElectroTech makes a new one on every load and does not save + it, so `qet_conductors` reports `uuid` as empty and those conductors are + named by `element` + `terminal` until wire uuids last (#1103). - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 0bf06a875..402cb8be1 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1419,6 +1419,7 @@ def _build_script(operations: list, output: str) -> str: " if (ends && ends.length === 2) {", " var lines = qet.conductors(folio);", " for (var k = 0; k < 2; k++) {", + " if (ends[k] === '?') continue;", " var n = 0;", " for (var j = 0; j < lines.length; j++) {", " var p = lines[j].split(' : ')[0].split(' -- ');", @@ -1601,6 +1602,11 @@ def _build_script(operations: list, output: str) -> str: if "element" in op or "terminal" in op: raise ValueError(f"operation {i} ({name}): give either \"conductor\" " f"(its uuid) or \"element\" + \"terminal\", not both") + if op["conductor"] == "": + raise ValueError(f"operation {i}: \"conductor\" is empty -- a conductor " + f"from a project saved before conductors carried a uuid " + f"has none in the file; name it by \"element\" + " + f"\"terminal\" instead") if not isinstance(op["conductor"], str) or not _UUID_RE.fullmatch(op["conductor"]): raise ValueError(f"operation {i}: \"conductor\" must be a conductor uuid, " f"got {op['conductor']!r}") @@ -2551,8 +2557,11 @@ TOOLS = [ "or give \"conductor\": its uuid from qet_conductors in place " "of \"element\" + \"terminal\" (set_conductor, " "move_conductor_segment and delete_conductor all accept it; it " - "needs one of the conductor's two terminals to carry only it); " - "its \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) + + "needs one of the conductor's two terminals to carry only it, " + "and a conductor qet_conductors lists with an empty uuid has " + "none to give). A uuid names one wire, but set_conductor still " + "changes its whole potential, as it does by terminal. " + "set_conductor's \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) + ". move_conductor_segment reroutes the drawn path itself rather " "than a property of the potential -- addressed the same way (a " "terminal carrying exactly one conductor), plus a segment index " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 592dbd9b7..a7a44d76e 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -206,6 +206,12 @@ class EditValidation(unittest.TestCase): s = self.build([{"op": "set_conductor", "folio": 0, "conductor": U, "property": "num", "value": "W1"}]) self.assertIn('qet.setConductorProperty(0, e0.element, e0.terminal, "num", "W1")', s) + s = self.build([{"op": "move_conductor_segment", "folio": 2, "conductor": U, + "segment": 1, "dx": 10, "dy": 0}]) + self.assertIn(f'var e0 = qetMcpConductorEnd(0, 2, "{U}");', s) + self.assertIn("(e0 ? qet.moveConductorSegment(2, e0.element, e0.terminal, 1, 10, 0) : false)", s) + # an end whose terminal or element is missing is never picked + self.assertIn("if (ends[k] === '?') continue;", s) self.assertNotIn('"conductorEnds"', self.build( [{"op": "delete_conductor", "folio": 0, "element": U, "terminal": 0}])) with self.assertRaisesRegex(ValueError, "not both"): @@ -213,6 +219,8 @@ class EditValidation(unittest.TestCase): "element": U, "terminal": 0}]) with self.assertRaisesRegex(ValueError, "must be a conductor uuid"): self.build([{"op": "delete_conductor", "folio": 0, "conductor": "W1"}]) + with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}]) def test_every_op_generates_a_script(self): # one minimal valid instance of every op