From 6a1898d6e74cf9cd94dbec93abe77c4ff661f437 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 4 Aug 2026 15:58:56 +1000 Subject: [PATCH 01/91] add ladder diagram renderer for PLCopen XML exports Graphical POUs currently export as native CODESYS xml, which git can store but nobody can review. This renders Ladder bodies from PLCopen XML as ASCII rungs, as a derived read-only artifact: the native xml stays the thing Import From Files reads. Layout is derived from connection topology and the x/y coordinates are discarded at the parse boundary, so moving a contact in the CODESYS editor produces no diff. There is a test asserting exactly that. Written in the Python 2/3 common subset so the eventual move into src/ is a file move rather than a rewrite, and covered by CI under both Python 3 and the IronPython 2.7 that CODESYS embeds. Fixtures include a real CODESYS V3.5 SP11 export, which is what caught the two dialect differences a plain schema reading misses: typeName and instanceName are attributes rather than child elements, and CODESYS writes edge="none" instead of omitting the attribute. Not yet wired into the export path. FBD and SFC bodies are skipped. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 14 + tools/ladder/ascii_render.py | 253 ++++++++++ tools/ladder/model.py | 197 ++++++++ tools/ladder/parse.py | 417 ++++++++++++++++ tools/ladder/render_ld.py | 39 ++ .../tests/fixtures/codesys/FbTesting.xml | 325 +++++++++++++ .../fixtures/codesys/LDTesting.expected.txt | 25 + .../tests/fixtures/codesys/LDTesting.xml | 271 +++++++++++ .../tests/fixtures/codesys/SFCTesting.xml | 454 ++++++++++++++++++ .../tests/fixtures/motor_control.expected.txt | 26 + .../tests/fixtures/motor_control.plcopen.xml | 177 +++++++ tools/ladder/tests/test_ladder.py | 189 ++++++++ 12 files changed, 2387 insertions(+) create mode 100644 tools/ladder/ascii_render.py create mode 100644 tools/ladder/model.py create mode 100644 tools/ladder/parse.py create mode 100644 tools/ladder/render_ld.py create mode 100644 tools/ladder/tests/fixtures/codesys/FbTesting.xml create mode 100644 tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt create mode 100644 tools/ladder/tests/fixtures/codesys/LDTesting.xml create mode 100644 tools/ladder/tests/fixtures/codesys/SFCTesting.xml create mode 100644 tools/ladder/tests/fixtures/motor_control.expected.txt create mode 100644 tools/ladder/tests/fixtures/motor_control.plcopen.xml create mode 100644 tools/ladder/tests/test_ladder.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5994bb5..a6d860a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,17 @@ jobs: - name: Import smoke test with stubbed scriptengine shell: pwsh run: .\ipy\net45\ipy.exe tools\ci\import_smoke.py + + # The ladder renderer is destined for src/, so it has to pass under the + # same interpreter CODESYS embeds - not just under CI's Python 3. + - name: Ladder renderer tests under IronPython 2.7 + shell: pwsh + run: .\ipy\net45\ipy.exe tools\ladder\tests\test_ladder.py + + ladder: + name: ladder + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Ladder renderer tests under Python 3 + run: python3 tools/ladder/tests/test_ladder.py diff --git a/tools/ladder/ascii_render.py b/tools/ladder/ascii_render.py new file mode 100644 index 0000000..deef858 --- /dev/null +++ b/tools/ladder/ascii_render.py @@ -0,0 +1,253 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Render a parsed Ladder Diagram as ASCII rungs. + +Layout comes from the expression tree only - the x/y coordinates in the source +XML are deliberately ignored. Dragging a contact sideways in CODESYS must not +show up as a diff. + +Composition works on Blocks: a rectangle of text plus the row index its wire +enters and leaves on. Series concatenates Blocks horizontally aligned on that +row; Parallel stacks them and threads a junction column down each side. +""" + +from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series + +POU_TYPE_KEYWORDS = { + "program": "PROGRAM", + "functionBlock": "FUNCTION_BLOCK", + "function": "FUNCTION", +} + + +class Block(object): + def __init__(self, lines, connect_row): + self.lines = lines + self.connect_row = connect_row + + @property + def width(self): + if not self.lines: + return 0 + return max(len(line) for line in self.lines) + + +def _symbol_and_label(element): + """The drawn symbol, and the caption sitting above it.""" + kind = element.kind + + if kind == CONTACT: + if element.edge == "rising": + symbol = "|P|" + elif element.edge == "falling": + symbol = "|N|" + elif element.negated: + symbol = "|/|" + else: + symbol = "| |" + return symbol, element.label or "" + + if kind == COIL: + if element.storage == "set": + symbol = "(S)" + elif element.storage == "reset": + symbol = "(R)" + elif element.negated: + symbol = "(/)" + else: + symbol = "( )" + return symbol, element.label or "" + + if kind == "jump": + return ">>" + (element.label or "?"), "" + + if kind == "return": + return "", "" + + # In/out variables and anything unrecognised draw as a named box so + # unhandled logic is visible rather than silently dropped. + return "[" + (element.label or "?") + "]", "" + + +def _render_block(element): + """Draw a function block as a pin box. + + TON_0 : TON + +-----------+ + ---|IN Q|--- + |PT := T#5S | + +-----------+ + + The power pin sorts first, so the wire enters and leaves on the same row. + """ + left = [] + for pin, label in element.input_pins: + text = pin or "?" + # A label of None is the power pin - it is wired, not parameterised. + if label is not None: + text += " := " + label if label else "" + left.append(text) + + right = [] + for pin, assigned in element.output_pins: + text = pin or "?" + if assigned: + text += " => " + assigned + right.append(text) + + rows = max(len(left), len(right), 1) + left += [""] * (rows - len(left)) + right += [""] * (rows - len(right)) + + title = element.title + inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)]) + + lines = [title.center(inner + 2)] + lines.append("+" + "-" * inner + "+") + for index in range(rows): + gap = inner - len(left[index]) - len(right[index]) + lines.append("|" + left[index] + " " * gap + right[index] + "|") + lines.append("+" + "-" * inner + "+") + + # Row 0 is the title and row 1 the top border, so the first pin is row 2. + connect_row = 2 + + # A lead-in and lead-out stub, so back-to-back boxes do not fuse into one + # unreadable run of border characters. + stubbed = [] + for index, line in enumerate(lines): + stub = "-" if index == connect_row else " " + stubbed.append(stub + line + stub) + + return Block(stubbed, connect_row) + + +def _render_element(element): + if element.kind == BLOCK: + return _render_block(element) + + symbol, label = _symbol_and_label(element) + width = max(len(label) + 2, len(symbol) + 4) + + lead = (width - len(symbol)) // 2 + symbol_line = "-" * lead + symbol + "-" * (width - len(symbol) - lead) + + lead = (width - len(label)) // 2 + label_line = " " * lead + label + " " * (width - len(label) - lead) + + return Block([label_line, symbol_line], 1) + + +def _render_series(items): + blocks = [_render(item) for item in items] + connect_row = max(block.connect_row for block in blocks) + height = max(connect_row - block.connect_row + len(block.lines) for block in blocks) + + columns = [] + for block in blocks: + width = block.width + above = connect_row - block.connect_row + lines = [" " * width] * above + lines += [line.ljust(width) for line in block.lines] + lines += [" " * width] * (height - len(lines)) + columns.append(lines) + + joined = [] + for row in range(height): + joined.append("".join(column[row] for column in columns)) + return Block(joined, connect_row) + + +def _render_parallel(branches): + blocks = [_render(branch) for branch in branches] + width = max(block.width for block in blocks) + + stacked = [] + connect_rows = [] + for block in blocks: + connect_rows.append(len(stacked) + block.connect_row) + for index, line in enumerate(block.lines): + # The wire itself extends with dashes; everything else with spaces, + # so short branches still reach the junction on the right. + fill = "-" if index == block.connect_row else " " + stacked.append(line + fill * (width - len(line))) + + junctions = set(connect_rows) + first, last = connect_rows[0], connect_rows[-1] + + lines = [] + for row, line in enumerate(stacked): + if row in junctions: + edge = "+" + elif first < row < last: + edge = "|" + else: + edge = " " + lines.append(edge + line + edge) + + return Block(lines, first) + + +def _render(expr): + if isinstance(expr, Empty): + return Block([" ", "---"], 1) + if isinstance(expr, Element): + return _render_element(expr) + if isinstance(expr, Series): + return _render_series(expr.items) + if isinstance(expr, Parallel): + return _render_parallel(expr.branches) + raise TypeError("cannot render %r" % (expr,)) + + +def render_rung(expr): + """Render one rung, bounded by the left power rail.""" + block = _render(expr) + lines = [] + for row, line in enumerate(block.lines): + if row == block.connect_row: + lines.append("|--" + line + "--|") + else: + lines.append("| " + line) + return lines + + +def render_declaration(pou): + keyword = POU_TYPE_KEYWORDS.get(pou.pou_type, "PROGRAM") + lines = [keyword + " " + pou.name] + + scope = None + for variable in pou.variables: + if variable.scope != scope: + if scope is not None: + lines.append("END_VAR") + lines.append(variable.scope) + scope = variable.scope + entry = " " + variable.name + " : " + variable.type_name + if variable.initial_value is not None: + entry += " := " + variable.initial_value + lines.append(entry + ";") + if scope is not None: + lines.append("END_VAR") + + return lines + + +def render_pou(pou): + """Render a whole POU: declaration, then one block per rung.""" + lines = render_declaration(pou) + lines.append("") + + if not pou.rungs: + lines.append("(* no rungs *)") + + for index, rung in enumerate(pou.rungs): + lines.append("(* Network " + str(index + 1) + " *)") + lines.extend(render_rung(rung)) + lines.append("") + + while lines and lines[-1] == "": + lines.pop() + + # Trailing whitespace is an artefact of grid composition, and the repo's + # pre-commit hooks would strip it anyway. + return [line.rstrip() for line in lines] diff --git a/tools/ladder/model.py b/tools/ladder/model.py new file mode 100644 index 0000000..938e238 --- /dev/null +++ b/tools/ladder/model.py @@ -0,0 +1,197 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Data model for a Ladder Diagram network. + +Two layers live here: + +* ``Node`` - a single graphical element exactly as it appears in the PLCopen + body, still wired by ``localId``. This is a faithful, dumb transcription of + the XML. +* The ``Expr`` classes - the same logic rearranged into the series/parallel + tree that a renderer can actually draw. Coordinates are deliberately dropped + at this point: layout is derived from topology so that nudging a block in the + CODESYS editor does not churn the diff. +""" + +# Element kinds we understand. Anything else is carried through as an opaque +# element so unknown logic is visibly wrong rather than silently missing. +LEFT_RAIL = "leftPowerRail" +RIGHT_RAIL = "rightPowerRail" +CONTACT = "contact" +COIL = "coil" +BLOCK = "block" +IN_VARIABLE = "inVariable" +OUT_VARIABLE = "outVariable" +JUMP = "jump" +RETURN = "return" + +RAILS = (LEFT_RAIL, RIGHT_RAIL) + + +class Connection(object): + """One wire arriving at an element. + + ``source_pin`` is the formalParameter on the *upstream* element's output + (CODESYS writes ``formalParameter="Q"`` on the connection itself), while + ``target_pin`` is the input pin on *this* element. Only blocks have named + pins; for contacts and coils both are None. + """ + + def __init__(self, ref_id, source_pin=None, target_pin=None): + self.ref_id = ref_id + self.source_pin = source_pin + self.target_pin = target_pin + + def __repr__(self): + return "Connection(%s, source_pin=%r, target_pin=%r)" % (self.ref_id, self.source_pin, self.target_pin) + + +class Node(object): + """One graphical element from an LD body, still wired by localId.""" + + def __init__( + self, + local_id, + kind, + label=None, + negated=False, + edge=None, + storage=None, + inputs=None, + type_name=None, + instance_name=None, + outputs=None, + ): + self.local_id = local_id + self.kind = kind + self.label = label + self.negated = negated + self.edge = edge # "rising" | "falling" | None + self.storage = storage # "set" | "reset" | None + self.inputs = inputs if inputs is not None else [] + self.type_name = type_name # blocks only + self.instance_name = instance_name # blocks only, absent for operators + self.outputs = outputs if outputs is not None else [] # blocks only: (pin, assigned_var) + + def __repr__(self): + return "Node(%s, %s, %r, inputs=%r)" % (self.local_id, self.kind, self.label, self.inputs) + + +class Variable(object): + """One entry from the POU interface, for rendering the declaration block.""" + + def __init__(self, name, type_name, initial_value=None, scope="VAR"): + self.name = name + self.type_name = type_name + self.initial_value = initial_value + self.scope = scope + + +class Pou(object): + def __init__(self, name, pou_type, variables=None, rungs=None): + self.name = name + self.pou_type = pou_type + self.variables = variables if variables is not None else [] + self.rungs = rungs if rungs is not None else [] + + +# --- expression tree ------------------------------------------------------- + + +class Empty(object): + """A wire with nothing on it - an unconditional rung, or a bare rail.""" + + def __eq__(self, other): + return isinstance(other, Empty) + + def __repr__(self): + return "Empty()" + + +class Element(object): + """A drawable leaf: contact, coil, block call, jump. + + For blocks, ``input_pins`` and ``output_pins`` are lists of + ``(pin_name, label)``. A label of None marks the pin carrying power flow - + the one wired into the rung rather than fed from a literal or a side + branch. That pin is always sorted first so the wire runs straight through. + """ + + def __init__( + self, + kind, + label=None, + negated=False, + edge=None, + storage=None, + type_name=None, + instance_name=None, + input_pins=None, + output_pins=None, + active_output=None, + ): + self.kind = kind + self.label = label + self.negated = negated + self.edge = edge + self.storage = storage + self.type_name = type_name + self.instance_name = instance_name + self.input_pins = input_pins if input_pins is not None else [] + self.output_pins = output_pins if output_pins is not None else [] + self.active_output = active_output + + @property + def title(self): + """Caption drawn above a block: 'TON_0 : TON', or just 'GT'.""" + if self.instance_name: + return self.instance_name + " : " + (self.type_name or "?") + return self.type_name or self.label or "?" + + def __repr__(self): + return "Element(%s, %r)" % (self.kind, self.label) + + +class Series(object): + """Elements wired left to right - logical AND.""" + + def __init__(self, items): + self.items = items + + def __repr__(self): + return "Series(%r)" % (self.items,) + + +class Parallel(object): + """Branches wired top to bottom - logical OR.""" + + def __init__(self, branches): + self.branches = branches + + def __repr__(self): + return "Parallel(%r)" % (self.branches,) + + +def series(items): + """Build a Series, flattening nested ones and dropping Empty legs.""" + flat = [] + for item in items: + if isinstance(item, Empty): + continue + if isinstance(item, Series): + flat.extend(item.items) + else: + flat.append(item) + if not flat: + return Empty() + if len(flat) == 1: + return flat[0] + return Series(flat) + + +def parallel(branches): + """Build a Parallel, collapsing the single-branch case.""" + if not branches: + return Empty() + if len(branches) == 1: + return branches[0] + return Parallel(branches) diff --git a/tools/ladder/parse.py b/tools/ladder/parse.py new file mode 100644 index 0000000..a48fcbd --- /dev/null +++ b/tools/ladder/parse.py @@ -0,0 +1,417 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Parse Ladder Diagram bodies out of PLCopen XML. + +Namespaces are stripped rather than matched, because the exact namespace URI +varies between PLCopen schema revisions (tc6_0200 vs tc6_0201) and CODESYS +adds proprietary extensions of its own. Matching on local tag names keeps this +working across dialects. +""" + +import xml.etree.ElementTree as ET + +from model import ( + BLOCK, + COIL, + CONTACT, + IN_VARIABLE, + LEFT_RAIL, + RAILS, + RIGHT_RAIL, + Connection, + Element, + Empty, + Node, + Parallel, + Pou, + Series, + Variable, + parallel, + series, +) + +# Elements that carry logic. Rails are structural: they anchor a rung but draw +# nothing themselves. +KNOWN_KINDS = ( + LEFT_RAIL, + RIGHT_RAIL, + CONTACT, + COIL, + BLOCK, + "inVariable", + "outVariable", + "jump", + "return", +) + +TRUTHY = ("true", "1") + + +def _tag(elem): + """Local tag name, with any namespace stripped.""" + return elem.tag.split("}")[-1] + + +def _find_child(elem, name): + for child in elem: + if _tag(child) == name: + return child + return None + + +def _child_text(elem, name): + child = _find_child(elem, name) + if child is None or child.text is None: + return None + return child.text.strip() + + +def _is_true(elem, attr): + return (elem.get(attr) or "").lower() in TRUTHY + + +def _attr(elem, name): + """An attribute, treating CODESYS's literal "none" as absent.""" + value = elem.get(name) + if value in (None, "", "none"): + return None + return value + + +def _direct_connections(elem): + """Wires arriving at this element's own connectionPointIn children. + + Several under a single connectionPointIn is how PLCopen + spells a parallel branch (a wired OR), so order and multiplicity matter. + This deliberately does not recurse: a block's pins hang off + and are collected separately, with their pin names. + """ + connections = [] + for point in elem: + if _tag(point) != "connectionPointIn": + continue + for child in point: + if _tag(child) != "connection": + continue + ref = child.get("refLocalId") + if ref is not None: + connections.append(Connection(ref, source_pin=_attr(child, "formalParameter"))) + return connections + + +def _block_connections(block_elem): + """Wires arriving at a block, tagged with the pin they land on.""" + connections = [] + for group_name in ("inputVariables", "inOutVariables"): + group = _find_child(block_elem, group_name) + if group is None: + continue + for var in group: + if _tag(var) != "variable": + continue + pin = var.get("formalParameter") + for connection in _direct_connections(var): + connection.target_pin = pin + connections.append(connection) + return connections + + +def _block_outputs(block_elem): + """(pin, assigned variable) for each block output. + + CODESYS writes an assignment straight onto the output pin as + uiCurrSupplyVolt. + """ + outputs = [] + group = _find_child(block_elem, "outputVariables") + if group is None: + return outputs + for var in group: + if _tag(var) != "variable": + continue + assigned = None + point = _find_child(var, "connectionPointOut") + if point is not None: + expression = _find_child(point, "expression") + if expression is not None and expression.text: + assigned = expression.text.strip() + outputs.append((var.get("formalParameter"), assigned)) + return outputs + + +def _node_label(elem, kind): + if kind == BLOCK: + # typeName and instanceName are attributes in CODESYS's output, not + # the child elements a literal schema reading would suggest. + return elem.get("instanceName") or elem.get("typeName") + return _child_text(elem, "variable") or _child_text(elem, "expression") + + +def parse_ld_body(body_elem): + """Return an ordered list of Nodes from an body element.""" + nodes = [] + for child in body_elem: + kind = _tag(child) + if kind not in KNOWN_KINDS: + continue + local_id = child.get("localId") + if local_id is None: + continue + is_block = kind == BLOCK + nodes.append( + Node( + local_id=local_id, + kind=kind, + label=_node_label(child, kind), + negated=_is_true(child, "negated"), + edge=_attr(child, "edge"), + storage=_attr(child, "storage"), + inputs=_block_connections(child) if is_block else _direct_connections(child), + type_name=child.get("typeName") if is_block else None, + instance_name=child.get("instanceName") if is_block else None, + outputs=_block_outputs(child) if is_block else None, + ) + ) + return nodes + + +def _type_name(var_elem): + type_elem = _find_child(var_elem, "type") + if type_elem is None: + return "BOOL" + for child in type_elem: + name = _tag(child) + if name == "derived": + return child.get("name") or "UNKNOWN" + return name + return "BOOL" + + +def _initial_value(var_elem): + value_elem = _find_child(var_elem, "initialValue") + if value_elem is None: + return None + simple = _find_child(value_elem, "simpleValue") + if simple is None: + return None + return simple.get("value") + + +SCOPE_TAGS = { + "localVars": "VAR", + "inputVars": "VAR_INPUT", + "outputVars": "VAR_OUTPUT", + "inOutVars": "VAR_IN_OUT", + "tempVars": "VAR_TEMP", + "globalVars": "VAR_GLOBAL", +} + + +def parse_interface(interface_elem): + variables = [] + if interface_elem is None: + return variables + for group in interface_elem: + scope = SCOPE_TAGS.get(_tag(group)) + if scope is None: + continue + if _is_true(group, "constant"): + scope += " CONSTANT" + for var_elem in group: + if _tag(var_elem) != "variable": + continue + variables.append( + Variable( + name=var_elem.get("name") or "", + type_name=_type_name(var_elem), + initial_value=_initial_value(var_elem), + scope=scope, + ) + ) + return variables + + +# --- graph to expression tree ---------------------------------------------- + + +def _to_element(node): + return Element( + kind=node.kind, + label=node.label, + negated=node.negated, + edge=node.edge, + storage=node.storage, + ) + + +def expr_to_text(expr): + """Flatten an expression to one line of ST-ish text. + + Used for a block's side inputs: a RESET pin fed by its own contact chain + cannot be drawn as a second horizontal wire without a genuine 2-D layout, + so it is written into the pin as "RESET := PowerOff" instead. + """ + if isinstance(expr, Empty): + return "" + if isinstance(expr, Series): + parts = [part for part in (expr_to_text(item) for item in expr.items) if part] + return " AND ".join(parts) + if isinstance(expr, Parallel): + parts = [part for part in (expr_to_text(branch) for branch in expr.branches) if part] + return "(" + " OR ".join(parts) + ")" + if isinstance(expr, Element): + if expr.kind == BLOCK: + base = expr.instance_name or expr.type_name or "?" + return base + "." + expr.active_output if expr.active_output else base + label = expr.label or "" + if expr.edge == "rising": + return "R(" + label + ")" + if expr.edge == "falling": + return "F(" + label + ")" + if expr.negated: + return "NOT " + label + return label + return "?" + + +def _build_block(node, by_id, visiting, via_pin): + """Build a block call, separating power flow from parameter inputs. + + Exactly one input carries the rung's power flow. Pins fed by a literal or + an inVariable are parameters, not power, so the first genuinely wired pin + wins and the rest become captions inside the box. + """ + power_expr = Empty() + power_pin = None + side_pins = [] + + for connection in node.inputs: + upstream = by_id.get(connection.ref_id) + if upstream is None: + side_pins.append((connection.target_pin, "?")) + continue + sub_expr = _build_expr(upstream, by_id, visiting, connection.source_pin) + if upstream.kind == IN_VARIABLE: + side_pins.append((connection.target_pin, upstream.label or "")) + elif power_pin is None: + power_pin = connection.target_pin + power_expr = sub_expr + else: + side_pins.append((connection.target_pin, expr_to_text(sub_expr))) + + input_pins = [] + if power_pin is not None: + # None marks the power pin, and it sorts first so the wire runs + # straight through the box instead of jogging to another row. + input_pins.append((power_pin, None)) + input_pins.extend(side_pins) + + active = via_pin + if active is None and node.outputs: + active = node.outputs[0][0] + output_pins = [out for out in node.outputs if out[0] == active] + output_pins += [out for out in node.outputs if out[0] != active] + + element = Element( + kind=BLOCK, + label=node.label, + type_name=node.type_name, + instance_name=node.instance_name, + input_pins=input_pins, + output_pins=output_pins, + active_output=active, + ) + return series([power_expr, element]) + + +def _build_expr(node, by_id, visiting, via_pin=None): + """Walk backwards from a node to the power rail, building series/parallel. + + A node's expression is everything feeding it (OR'd together if there is + more than one input) followed by the node itself. + """ + if node.local_id in visiting: + # Feedback loops are not legal in a rung, but a malformed export should + # produce a visible marker rather than blow the stack. + return Element(kind="cycle", label="" % node.local_id) + + visiting = visiting | set([node.local_id]) + + if node.kind == BLOCK: + return _build_block(node, by_id, visiting, via_pin) + + branches = [] + for connection in node.inputs: + upstream = by_id.get(connection.ref_id) + if upstream is None: + continue + branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin)) + + incoming = parallel(branches) if branches else Empty() + + if node.kind in RAILS: + # Rails are anchors, not symbols - they contribute nothing to draw. + return incoming + + return series([incoming, _to_element(node)]) + + +def build_rungs(nodes): + """Split a flat node list into one expression tree per rung. + + A rung is identified by its terminal: an element nothing else consumes. + That is the right power rail where one exists, and the coil itself where + the export omits it. + """ + by_id = {} + for node in nodes: + by_id[node.local_id] = node + + consumed = set() + for node in nodes: + for connection in node.inputs: + consumed.add(connection.ref_id) + + rungs = [] + for node in nodes: + if node.local_id in consumed: + continue + if node.kind == LEFT_RAIL: + # An unconnected left rail is an empty rung, not a terminal. + continue + expr = _build_expr(node, by_id, set()) + if isinstance(expr, Empty): + continue + rungs.append(expr) + return rungs + + +# --- top level ------------------------------------------------------------- + + +def parse_pous(source): + """Parse every LD POU in a PLCopen file. Non-LD POUs are skipped. + + ``source`` is a path or a file object, as accepted by ElementTree. + """ + tree = ET.parse(source) + root = tree.getroot() + + pous = [] + for elem in root.iter(): + if _tag(elem) != "pou": + continue + body = _find_child(elem, "body") + if body is None: + continue + ld_body = _find_child(body, "LD") + if ld_body is None: + continue + pous.append( + Pou( + name=elem.get("name") or "", + pou_type=elem.get("pouType") or "program", + variables=parse_interface(_find_child(elem, "interface")), + rungs=build_rungs(parse_ld_body(ld_body)), + ) + ) + return pous diff --git a/tools/ladder/render_ld.py b/tools/ladder/render_ld.py new file mode 100644 index 0000000..08961a2 --- /dev/null +++ b/tools/ladder/render_ld.py @@ -0,0 +1,39 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Render the Ladder POUs in a PLCopen XML file as ASCII. + + python tools/ladder/render_ld.py [more.xml ...] + +Prototype only - not yet wired into the CODESYS export path. +""" + +from __future__ import print_function + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ascii_render import render_pou # noqa: E402 +from parse import parse_pous # noqa: E402 + + +def render_file(path): + lines = [] + for pou in parse_pous(path): + lines.extend(render_pou(pou)) + lines.append("") + return lines + + +def main(argv): + if not argv: + print(__doc__) + return 2 + for path in argv: + for line in render_file(path): + print(line) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.xml b/tools/ladder/tests/fixtures/codesys/FbTesting.xml new file mode 100644 index 0000000..1dec322 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.xml @@ -0,0 +1,325 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + Minimum Voltage in mV + + + + + + + + + + Function Block to monitor supply voltage on VBB15 (from ignition) + + + + + + + + Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + + + + + + + + Operating Voltage in mV + + + + + + + + + + + + + + + FBD Implementation Attributes + + + + + + + + + + + + + // Function Block to monitor supply voltage on VBB15 (from ignition) + + + + + + ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15 + + + + + ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + uiCurrSupplyVolt + + + + + + functionblock + + + SYS_VOLTAGE_CHANNEL MODE_SYSTEM_SUPPLY FILTER_INPUT + + + BOOL ifmTypes.DIAG_INFO BOOL UINT + + + + + + + //Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge + + + + + + ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH + + + + + uiCurrSupplyVolt + + + + + uiMinVoltage + + + + + + + + + + + + + + + + + + + + + + + + operator + + + + + + BOOL + + + + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + BOOL TIME + + + BOOL TIME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + MODE_SUPPLY_SWITCH BOOL + + + BOOL ifmTypes.DIAG_INFO BOOL + + + + + + + + cedc2742-8922-46db-927d-5f652c9943c9 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt new file mode 100644 index 0000000..2a61220 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt @@ -0,0 +1,25 @@ +PROGRAM LD_TEST +VAR + Sensor1 : BOOL; + Sensor2 : BOOL; + sensor3 : BOOL; + PowerOn : BOOL; + TON_0 : TON; + CTU_0 : CTU; + PowerOff : BOOL; +END_VAR + +(* Network 1 *) +| Sensor1 Sensor2 PowerOn +|--+---| |---+---|/|------(S)-----| +| | sensor3 | +| +---| |---+ + +(* Network 2 *) +| TON_0 : TON CTU_0 : CTU +| PowerOn +---------------+ +----------------------+ PowerOff +|-----| |----|IN Q|--|CU Q|----(R)------| +| |PT := T#5S ET| |RESET := PowerOff CV| +| +---------------+ |PV := 10 | +| +----------------------+ + diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.xml b/tools/ladder/tests/fixtures/codesys/LDTesting.xml new file mode 100644 index 0000000..45c882d --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.xml @@ -0,0 +1,271 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + Sensor1 + + + + + + + + sensor3 + + + + + + + + + Sensor2 + + + + + + + + PowerOn + + + + + + + + + + + + + + + networktitle + + + + + + + + + + PowerOn + + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + + + + + + 4f4ad042-bbb9-4292-adb4-f91543e47fce + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/fixtures/codesys/SFCTesting.xml b/tools/ladder/tests/fixtures/codesys/SFCTesting.xml new file mode 100644 index 0000000..6f45495 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/SFCTesting.xml @@ -0,0 +1,454 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WaitingForTrain + FALSE + 0 + TRUE + FALSE + + + + + + + + + + + + + + + + + CmdBarriers + FALSE + 0 + R + + + + + + + + + + + + + + + + Branch0 + FALSE + FALSE + + + + + + + + SensorA + + + + + + + + + + + + + + + SensorA + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + + TrainFromAboveA + FALSE + 0 + FALSE + FALSE + + + + + + + + + + + + + + + + + CmdBarriers + FALSE + 0 + S + + + + + + + + + SensorB + + + + + + + + + + + + + + + SensorB + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + TrainFromAboveB + FALSE + 0 + FALSE + FALSE + + + + + + + + Not SensorB + + + + + + + + + + + + + + + Not SensorB + FALSE + 0 + FALSE + 0 + + + + + + + + SensorB + + + + + + + + + + + + + + + SensorB + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + + TrainFromBelowB + FALSE + 0 + FALSE + FALSE + + + + + + + + + + + + + + + + + CmdBarriers + FALSE + 0 + S + + + + + + + + + SensorA + + + + + + + + + + + + + + + SensorA + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + TrainFromBelowA + FALSE + 0 + FALSE + FALSE + + + + + + + + Not SensorA + + + + + + + + + + + + + + + Not SensorA + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + + + + + + + + + + WaitingForTrain + FALSE + + + + + + + + + + IecSfc + System + 3.4.2.0 + IecSfc + false + false + + + + + + + + + + + + + + + + + + + + + d158652c-96fd-4ed4-aadb-28e74dfb74df + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/fixtures/motor_control.expected.txt b/tools/ladder/tests/fixtures/motor_control.expected.txt new file mode 100644 index 0000000..9c49316 --- /dev/null +++ b/tools/ladder/tests/fixtures/motor_control.expected.txt @@ -0,0 +1,26 @@ +PROGRAM Motor_Control +VAR + Start_PB : BOOL; + Stop_PB : BOOL; + Motor_Run : BOOL := FALSE; + Fault_In : BOOL; + Fault_Latch : BOOL; + Reset_PB : BOOL; + Ack : BOOL; + Run_Time : TON; +END_VAR + +(* Network 1 *) +| Start_PB Stop_PB Motor_Run +|--+---| |-----+---|/|-------( )------| +| | Motor_Run | +| +----| |----+ + +(* Network 2 *) +| Fault_In Fault_Latch +|-----|P|---------(S)-------| + +(* Network 3 *) +| Reset_PB Ack Fault_Latch +|-----| |------|/|-------(R)-------| + diff --git a/tools/ladder/tests/fixtures/motor_control.plcopen.xml b/tools/ladder/tests/fixtures/motor_control.plcopen.xml new file mode 100644 index 0000000..2108190 --- /dev/null +++ b/tools/ladder/tests/fixtures/motor_control.plcopen.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Start_PB + + + + + + + + + Motor_Run + + + + + + + + + + + Stop_PB + + + + + + + + + Motor_Run + + + + + + + + + + + + + + + + + + + + + + Fault_In + + + + + + + + + Fault_Latch + + + + + + + + + + + + + + + + + + + + + + Reset_PB + + + + + + + + + Ack + + + + + + + + Fault_Latch + + + + + + + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py new file mode 100644 index 0000000..ebed1ef --- /dev/null +++ b/tools/ladder/tests/test_ladder.py @@ -0,0 +1,189 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Tests for the Ladder Diagram renderer. + +Written as a plain script rather than pytest, matching tools/ci/, so it runs +under both Python 3 and the IronPython 2.7 that CODESYS embeds. The renderer +is destined for src/ once it is proven, and it has to pass there too. + + python tools/ladder/tests/test_ladder.py +""" + +from __future__ import print_function + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) + +from ascii_render import render_pou # noqa: E402 +from model import COIL, CONTACT, Element, Parallel, Series # noqa: E402 +from parse import parse_pous # noqa: E402 + +FIXTURES = os.path.join(HERE, "fixtures") +SOURCE = os.path.join(FIXTURES, "motor_control.plcopen.xml") +EXPECTED = os.path.join(FIXTURES, "motor_control.expected.txt") + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + else: + failures.append(name) + print("FAIL " + name + ((": " + detail) if detail else "")) + + +def check_equal(name, actual, expected): + check(name, actual == expected, "expected %r, got %r" % (expected, actual)) + + +# --- parsing --------------------------------------------------------------- + +pous = parse_pous(SOURCE) + +check_equal("one LD pou is found", len(pous), 1) + +pou = pous[0] +check_equal("pou name", pou.name, "Motor_Control") +check_equal("pou type", pou.pou_type, "program") +check_equal("interface variables", len(pou.variables), 8) +check_equal("derived type resolves to its name", pou.variables[-1].type_name, "TON") +check_equal("initial value is captured", pou.variables[2].initial_value, "FALSE") + +check_equal("three rungs", len(pou.rungs), 3) + +# Network 1: (Start_PB OR Motor_Run) AND NOT Stop_PB -> Motor_Run +rung1 = pou.rungs[0] +check("rung 1 is a series", isinstance(rung1, Series)) +check_equal("rung 1 has three stages", len(rung1.items), 3) +check("rung 1 opens with a parallel branch", isinstance(rung1.items[0], Parallel)) +check_equal("seal-in has two branches", len(rung1.items[0].branches), 2) +check_equal("first branch is Start_PB", rung1.items[0].branches[0].label, "Start_PB") +check_equal("second branch is Motor_Run", rung1.items[0].branches[1].label, "Motor_Run") +check("Stop_PB is negated", rung1.items[1].negated) +check_equal("Stop_PB is a contact", rung1.items[1].kind, CONTACT) +check_equal("rung 1 terminates in a coil", rung1.items[2].kind, COIL) +check_equal("coil drives Motor_Run", rung1.items[2].label, "Motor_Run") + +# The right power rail anchors the rung but must not become a drawn element. +check( + "right power rail is not drawn", + all(not (isinstance(i, Element) and i.kind.endswith("PowerRail")) for i in rung1.items), +) + +# Network 2: rising edge into a set coil +rung2 = pou.rungs[1] +check_equal("rising edge is captured", rung2.items[0].edge, "rising") +check_equal("set coil storage", rung2.items[1].storage, "set") + +# Network 3: terminal is the coil itself, with no right power rail +rung3 = pou.rungs[2] +check_equal("rung 3 has three stages", len(rung3.items), 3) +check_equal("reset coil storage", rung3.items[2].storage, "reset") + +# --- layout independence --------------------------------------------------- + +with open(SOURCE) as handle: + source_text = handle.read() + +# Shifting every element 500px right must not change a single character of +# output. This is the property that keeps diffs meaningful. +moved = source_text.replace(' Export > PLCopenXML. This is +# the dialect that actually matters; the hand-authored fixture above only +# covers what the spec says. +CODESYS_SOURCE = os.path.join(FIXTURES, "codesys", "LDTesting.xml") +CODESYS_EXPECTED = os.path.join(FIXTURES, "codesys", "LDTesting.expected.txt") + +codesys_pous = parse_pous(CODESYS_SOURCE) +check_equal("codesys: one LD pou", len(codesys_pous), 1) + +ld_test = codesys_pous[0] +check_equal("codesys: pou name", ld_test.name, "LD_TEST") +check_equal("codesys: two networks", len(ld_test.rungs), 2) + +# CODESYS writes edge="none"/storage="none" rather than omitting the attribute. +network1 = ld_test.rungs[0] +check_equal("codesys: literal 'none' edge is normalised away", network1.items[1].edge, None) +check("codesys: negated contact survives", network1.items[1].negated) +check_equal("codesys: set coil", network1.items[2].storage, "set") + +# typeName and instanceName are attributes in CODESYS's output. Reading them as +# child elements is what produced "[?]" boxes on the first run. +network2 = ld_test.rungs[1] +blocks = [item for item in network2.items if getattr(item, "kind", None) == "block"] +check_equal("codesys: two blocks in network 2", len(blocks), 2) +check_equal("codesys: block type name", blocks[0].type_name, "TON") +check_equal("codesys: block instance name", blocks[0].instance_name, "TON_0") +check_equal("codesys: block title", blocks[0].title, "TON_0 : TON") + +# The power pin sorts first and carries no caption; parameter pins carry one. +check_equal("codesys: TON power pin is IN", blocks[0].input_pins[0], ("IN", None)) +check_equal("codesys: TON PT is a parameter", blocks[0].input_pins[1], ("PT", "T#5S")) + +# A second wired input cannot be drawn as another horizontal wire, so it is +# flattened to text inside the pin. +check_equal("codesys: CTU power pin is CU", blocks[1].input_pins[0], ("CU", None)) +check_equal("codesys: CTU RESET is flattened to text", blocks[1].input_pins[1], ("RESET", "PowerOff")) +check_equal("codesys: CTU PV is a literal", blocks[1].input_pins[2], ("PV", "10")) + +# The consumer's connection names the output pin it draws from. +check_equal("codesys: active output follows the wire", blocks[0].active_output, "Q") +check_equal("codesys: active output sorts first", blocks[0].output_pins[0][0], "Q") + +codesys_rendered = render_pou(ld_test) +check("codesys: no trailing whitespace", all(line == line.rstrip() for line in codesys_rendered)) +check_golden("codesys: golden output matches", codesys_rendered, CODESYS_EXPECTED) + +print("") +if failures: + print("%d check(s) failed" % len(failures)) +else: + print("all checks passed") +sys.exit(1 if failures else 0) From 67d5e55e503425a81ff27c386eaa1bdf4e46c7ae Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 4 Aug 2026 16:09:29 +1000 Subject: [PATCH 02/91] add FBD renderer and Structured Text emitter Extends the graphical rendering to Function Block Diagram, and adds a second output format for both languages. FBD has no power rail, so a network is a tree of calls rather than a series/parallel chain. Each block's inputs are rendered to its left and stacked, and pin rows are placed at whatever row their source landed on, so every wire stays horizontal. The real export has x="0" y="0" on every element, which is a good reminder that coordinates were never usable for layout. The ST emitter is the more useful of the two formats for review: it diffs line by line and it greps, which ASCII art does not. A ladder rung becomes its condition and a coil assignment, and blocks in the chain become call statements threaded through their output pin: TON_0(IN := PowerOn, PT := T#5S); CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); IF CTU_0.Q THEN PowerOff := FALSE; END_IF It is a rendering, not a translation - the output is not guaranteed to compile and must not be fed back into CODESYS. The XML helpers both languages share moved to plcopen.py and the text-grid composition to layout.py. render_ld.py is replaced by render.py, which dispatches on body language and takes --format art|st|both. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 18 +- tools/ladder/ascii_render.py | 13 +- tools/ladder/fbd_render.py | 167 +++++++++++++ tools/ladder/layout.py | 44 ++++ tools/ladder/model.py | 64 ++++- tools/ladder/parse.py | 219 +++--------------- tools/ladder/parse_fbd.py | 151 ++++++++++++ tools/ladder/plcopen.py | 196 ++++++++++++++++ tools/ladder/render.py | 83 +++++++ tools/ladder/render_ld.py | 39 ---- tools/ladder/st_render.py | 132 +++++++++++ .../codesys/FbTesting.art.expected.txt | 32 +++ .../codesys/FbTesting.st.expected.txt | 19 ++ .../codesys/LDTesting.st.expected.txt | 19 ++ tools/ladder/tests/test_fbd.py | 169 ++++++++++++++ 15 files changed, 1121 insertions(+), 244 deletions(-) create mode 100644 tools/ladder/fbd_render.py create mode 100644 tools/ladder/layout.py create mode 100644 tools/ladder/parse_fbd.py create mode 100644 tools/ladder/plcopen.py create mode 100644 tools/ladder/render.py delete mode 100644 tools/ladder/render_ld.py create mode 100644 tools/ladder/st_render.py create mode 100644 tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt create mode 100644 tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt create mode 100644 tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt create mode 100644 tools/ladder/tests/test_fbd.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6d860a..005b0dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,16 +31,24 @@ jobs: shell: pwsh run: .\ipy\net45\ipy.exe tools\ci\import_smoke.py - # The ladder renderer is destined for src/, so it has to pass under the + # The renderers are destined for src/, so they have to pass under the # same interpreter CODESYS embeds - not just under CI's Python 3. - - name: Ladder renderer tests under IronPython 2.7 + - name: Renderer tests under IronPython 2.7 shell: pwsh - run: .\ipy\net45\ipy.exe tools\ladder\tests\test_ladder.py + run: | + # A native exe's exit code does not halt a pwsh script, so a failure + # in the first suite would otherwise be masked by the second passing. + .\ipy\net45\ipy.exe tools\ladder\tests\test_ladder.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + .\ipy\net45\ipy.exe tools\ladder\tests\test_fbd.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ladder: name: ladder runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Ladder renderer tests under Python 3 - run: python3 tools/ladder/tests/test_ladder.py + - name: Renderer tests under Python 3 + run: | + python3 tools/ladder/tests/test_ladder.py + python3 tools/ladder/tests/test_fbd.py diff --git a/tools/ladder/ascii_render.py b/tools/ladder/ascii_render.py index deef858..86fc97a 100644 --- a/tools/ladder/ascii_render.py +++ b/tools/ladder/ascii_render.py @@ -10,6 +10,7 @@ row; Parallel stacks them and threads a junction column down each side. """ +from layout import Block from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series POU_TYPE_KEYWORDS = { @@ -19,18 +20,6 @@ } -class Block(object): - def __init__(self, lines, connect_row): - self.lines = lines - self.connect_row = connect_row - - @property - def width(self): - if not self.lines: - return 0 - return max(len(line) for line in self.lines) - - def _symbol_and_label(element): """The drawn symbol, and the caption sitting above it.""" kind = element.kind diff --git a/tools/ladder/fbd_render.py b/tools/ladder/fbd_render.py new file mode 100644 index 0000000..1eda25f --- /dev/null +++ b/tools/ladder/fbd_render.py @@ -0,0 +1,167 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Render a parsed Function Block Diagram as ASCII boxes. + +Layout is derived from the call tree, not from the exported coordinates. Each +block's inputs are rendered to its left and stacked vertically, so a pin fed +by another block gets that block's whole box beside it. Pin rows are placed at +whatever row their source ended up on, which keeps every wire horizontal. +""" + +from ascii_render import render_declaration +from layout import Block, stack +from model import Assign, Call, Signal + + +def _render_signal(node): + return Block([node.label or ""], 0) + + +def _render_assign(node): + source = _render(node.source) if node.source is not None else Block([""], 0) + lines = source.padded(source.width) + tail = "---> " + (node.label or "?") + out = [] + for index, line in enumerate(lines): + out.append(line + tail if index == source.connect_row else line) + return Block(out, source.connect_row) + + +def _is_wired(source): + """False for a pin CODESYS exported with no source, or an empty expression. + + Those must not be drawn with a wire running off to the left, because there + is nothing out there feeding them. + """ + if source is None: + return False + return not (isinstance(source, Signal) and not source.label) + + +def _render_call(call): + input_blocks = [] + for _pin, source in call.inputs: + input_blocks.append(_render(source) if source is not None else Block([""], 0)) + + left_lines, pin_rows = stack(input_blocks) + # A minimum lead-in, so a source exactly as wide as the column still shows + # a wire and back-to-back boxes do not fuse into one run of borders. + left_width = (max([len(line) for line in left_lines]) + 2) if left_lines else 0 + + # Only the rows where a source hands off to a pin get their wire extended; + # a nested box's own internal wires already end at that box's edge. + handoff = set() + for index, pin_and_source in enumerate(call.inputs): + if _is_wired(pin_and_source[1]): + handoff.add(pin_rows[index]) + + left = [] + for index, line in enumerate(left_lines): + fill = "-" if index in handoff else " " + left.append(line + fill * (left_width - len(line))) + + input_rows = list(pin_rows) + output_rows = [] + for index in range(len(call.outputs)): + if index < len(input_rows): + output_rows.append(input_rows[index]) + else: + # More outputs than inputs: the surplus hangs below the last pin. + base = input_rows[-1] if input_rows else -1 + output_rows.append(base + index - len(input_rows) + 1) + + all_rows = (input_rows + output_rows) or [0] + box_first, box_last = min(all_rows), max(all_rows) + + # The title and top border sit two rows above the first pin, so everything + # shifts down if the first pin would land at the very top of the grid. + shift = max(0, 2 - box_first) + if shift: + left = [" " * left_width] * shift + left + input_rows = [row + shift for row in input_rows] + output_rows = [row + shift for row in output_rows] + box_first += shift + box_last += shift + + in_at = {} + for index, pin_and_source in enumerate(call.inputs): + in_at[input_rows[index]] = pin_and_source[0] or "?" + + out_at = {} + for index, pin_and_assignment in enumerate(call.outputs): + pin, assigned = pin_and_assignment + text = pin or "?" + if assigned: + text += " => " + assigned + out_at[output_rows[index]] = text + + title = call.title + widths = [len(title)] + for row in range(box_first, box_last + 1): + widths.append(len(in_at.get(row, "")) + 3 + len(out_at.get(row, ""))) + inner = max(widths) + + height = max(len(left), box_last + 2) + left += [" " * left_width] * (height - len(left)) + + lines = [] + for row in range(height): + if row == box_first - 2: + box = title.center(inner + 2) + elif row == box_first - 1 or row == box_last + 1: + box = "+" + "-" * inner + "+" + elif box_first <= row <= box_last: + left_pin = in_at.get(row, "") + right_pin = out_at.get(row, "") + box = "|" + left_pin + " " * (inner - len(left_pin) - len(right_pin)) + right_pin + "|" + else: + box = " " * (inner + 2) + lines.append(left[row] + box) + + # The wire leaves on whichever output pin the consumer asked for. + connect_row = box_first + pins = [pin for pin, _assigned in call.outputs] + if call.active_output in pins: + connect_row = output_rows[pins.index(call.active_output)] + elif output_rows: + connect_row = output_rows[0] + + return Block(lines, connect_row) + + +def _render(node): + if isinstance(node, Call): + return _render_call(node) + if isinstance(node, Assign): + return _render_assign(node) + if isinstance(node, Signal): + return _render_signal(node) + raise TypeError("cannot render %r" % (node,)) + + +def render_network(tree): + return _render(tree).lines + + +def render_pou(pou): + """Render a whole FBD POU: declaration, then one box tree per network.""" + lines = render_declaration(pou) + lines.append("") + + if not pou.networks: + lines.append("(* no networks *)") + + for index, network in enumerate(pou.networks): + comment, tree = network + header = "(* Network " + str(index + 1) + if comment: + # CODESYS comments usually already start with //, which would read + # oddly nested inside an ST block comment. + header += ": " + comment.lstrip("/").strip() + lines.append(header + " *)") + lines.extend(render_network(tree)) + lines.append("") + + while lines and lines[-1] == "": + lines.pop() + + return [line.rstrip() for line in lines] diff --git a/tools/ladder/layout.py b/tools/ladder/layout.py new file mode 100644 index 0000000..c49f313 --- /dev/null +++ b/tools/ladder/layout.py @@ -0,0 +1,44 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Text-grid composition shared by the graphical renderers. + +A Block is a rectangle of text plus the row its wire enters and leaves on. +Renderers build small Blocks for leaves and compose them; nothing else needs +to know about absolute coordinates. +""" + + +class Block(object): + def __init__(self, lines, connect_row): + self.lines = lines + self.connect_row = connect_row + + @property + def width(self): + if not self.lines: + return 0 + return max(len(line) for line in self.lines) + + def padded(self, width, wire_rows=None): + """Lines padded to ``width``, extending wires with dashes. + + Rows listed in ``wire_rows`` (defaulting to this Block's own connect + row) are filled with dashes so a short branch still reaches the + junction on its right. Every other row is filled with spaces. + """ + if wire_rows is None: + wire_rows = set([self.connect_row]) + out = [] + for index, line in enumerate(self.lines): + fill = "-" if index in wire_rows else " " + out.append(line + fill * (width - len(line))) + return out + + +def stack(blocks): + """Stack Blocks vertically. Returns (lines, absolute connect rows).""" + lines = [] + connect_rows = [] + for block in blocks: + connect_rows.append(len(lines) + block.connect_row) + lines.extend(block.lines) + return lines, connect_rows diff --git a/tools/ladder/model.py b/tools/ladder/model.py index 938e238..0573226 100644 --- a/tools/ladder/model.py +++ b/tools/ladder/model.py @@ -87,11 +87,73 @@ def __init__(self, name, type_name, initial_value=None, scope="VAR"): class Pou(object): - def __init__(self, name, pou_type, variables=None, rungs=None): + """A parsed POU. ``rungs`` is populated for LD, ``networks`` for FBD.""" + + def __init__(self, name, pou_type, variables=None, rungs=None, networks=None, language=None): self.name = name self.pou_type = pou_type + self.language = language self.variables = variables if variables is not None else [] self.rungs = rungs if rungs is not None else [] + self.networks = networks if networks is not None else [] + + +# --- FBD tree -------------------------------------------------------------- +# +# FBD has no power rail, so there is no single wire to hang a series/parallel +# tree off. A network is instead a tree of calls: each block pin is fed either +# by a named value or by another block's output. + + +class Signal(object): + """A named value entering a network: a variable, a literal, or nothing.""" + + def __init__(self, label): + self.label = label + + def __repr__(self): + return "Signal(%r)" % (self.label,) + + +class Call(object): + """An FBD block call - a box with named input and output pins. + + ``inputs`` is [(pin_name, source)] where source is a Call, a Signal or + None. ``outputs`` is [(pin_name, assigned_variable)]. Pin order is kept + exactly as exported; unlike LD there is no power pin to hoist. + """ + + def __init__(self, type_name=None, instance_name=None, inputs=None, outputs=None, active_output=None): + self.type_name = type_name + self.instance_name = instance_name + self.inputs = inputs if inputs is not None else [] + self.outputs = outputs if outputs is not None else [] + self.active_output = active_output + + @property + def title(self): + if self.instance_name: + return self.instance_name + " : " + (self.type_name or "?") + return self.type_name or "?" + + @property + def is_operator(self): + """Operators and functions have no instance, so they inline as expressions.""" + return not self.instance_name + + def __repr__(self): + return "Call(%r, %r)" % (self.type_name, self.instance_name) + + +class Assign(object): + """An outVariable: a network whose result is stored into a variable.""" + + def __init__(self, label, source=None): + self.label = label + self.source = source + + def __repr__(self): + return "Assign(%r)" % (self.label,) # --- expression tree ------------------------------------------------------- diff --git a/tools/ladder/parse.py b/tools/ladder/parse.py index a48fcbd..eda9131 100644 --- a/tools/ladder/parse.py +++ b/tools/ladder/parse.py @@ -1,33 +1,40 @@ # REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. """Parse Ladder Diagram bodies out of PLCopen XML. -Namespaces are stripped rather than matched, because the exact namespace URI -varies between PLCopen schema revisions (tc6_0200 vs tc6_0201) and CODESYS -adds proprietary extensions of its own. Matching on local tag names keeps this -working across dialects. +The XML-level helpers live in plcopen.py; this module owns the LD-specific +part: turning a flat list of wired elements into one series/parallel +expression tree per rung. """ -import xml.etree.ElementTree as ET - from model import ( BLOCK, - COIL, - CONTACT, IN_VARIABLE, LEFT_RAIL, RAILS, RIGHT_RAIL, - Connection, + CONTACT, + COIL, Element, Empty, Node, Parallel, Pou, Series, - Variable, parallel, series, ) +from plcopen import ( + attr, + block_connections, + block_outputs, + child_text, + direct_connections, + find_child, + is_true, + iter_bodies, + parse_interface, + tag, +) # Elements that carry logic. Rails are structural: they anchor a rung but draw # nothing themselves. @@ -43,114 +50,20 @@ "return", ) -TRUTHY = ("true", "1") - - -def _tag(elem): - """Local tag name, with any namespace stripped.""" - return elem.tag.split("}")[-1] - - -def _find_child(elem, name): - for child in elem: - if _tag(child) == name: - return child - return None - - -def _child_text(elem, name): - child = _find_child(elem, name) - if child is None or child.text is None: - return None - return child.text.strip() - - -def _is_true(elem, attr): - return (elem.get(attr) or "").lower() in TRUTHY - - -def _attr(elem, name): - """An attribute, treating CODESYS's literal "none" as absent.""" - value = elem.get(name) - if value in (None, "", "none"): - return None - return value - - -def _direct_connections(elem): - """Wires arriving at this element's own connectionPointIn children. - - Several under a single connectionPointIn is how PLCopen - spells a parallel branch (a wired OR), so order and multiplicity matter. - This deliberately does not recurse: a block's pins hang off - and are collected separately, with their pin names. - """ - connections = [] - for point in elem: - if _tag(point) != "connectionPointIn": - continue - for child in point: - if _tag(child) != "connection": - continue - ref = child.get("refLocalId") - if ref is not None: - connections.append(Connection(ref, source_pin=_attr(child, "formalParameter"))) - return connections - - -def _block_connections(block_elem): - """Wires arriving at a block, tagged with the pin they land on.""" - connections = [] - for group_name in ("inputVariables", "inOutVariables"): - group = _find_child(block_elem, group_name) - if group is None: - continue - for var in group: - if _tag(var) != "variable": - continue - pin = var.get("formalParameter") - for connection in _direct_connections(var): - connection.target_pin = pin - connections.append(connection) - return connections - - -def _block_outputs(block_elem): - """(pin, assigned variable) for each block output. - - CODESYS writes an assignment straight onto the output pin as - uiCurrSupplyVolt. - """ - outputs = [] - group = _find_child(block_elem, "outputVariables") - if group is None: - return outputs - for var in group: - if _tag(var) != "variable": - continue - assigned = None - point = _find_child(var, "connectionPointOut") - if point is not None: - expression = _find_child(point, "expression") - if expression is not None and expression.text: - assigned = expression.text.strip() - outputs.append((var.get("formalParameter"), assigned)) - return outputs - def _node_label(elem, kind): if kind == BLOCK: # typeName and instanceName are attributes in CODESYS's output, not # the child elements a literal schema reading would suggest. return elem.get("instanceName") or elem.get("typeName") - return _child_text(elem, "variable") or _child_text(elem, "expression") + return child_text(elem, "variable") or child_text(elem, "expression") def parse_ld_body(body_elem): """Return an ordered list of Nodes from an body element.""" nodes = [] for child in body_elem: - kind = _tag(child) + kind = tag(child) if kind not in KNOWN_KINDS: continue local_id = child.get("localId") @@ -162,74 +75,18 @@ def parse_ld_body(body_elem): local_id=local_id, kind=kind, label=_node_label(child, kind), - negated=_is_true(child, "negated"), - edge=_attr(child, "edge"), - storage=_attr(child, "storage"), - inputs=_block_connections(child) if is_block else _direct_connections(child), + negated=is_true(child, "negated"), + edge=attr(child, "edge"), + storage=attr(child, "storage"), + inputs=block_connections(child) if is_block else direct_connections(child), type_name=child.get("typeName") if is_block else None, instance_name=child.get("instanceName") if is_block else None, - outputs=_block_outputs(child) if is_block else None, + outputs=block_outputs(child) if is_block else None, ) ) return nodes -def _type_name(var_elem): - type_elem = _find_child(var_elem, "type") - if type_elem is None: - return "BOOL" - for child in type_elem: - name = _tag(child) - if name == "derived": - return child.get("name") or "UNKNOWN" - return name - return "BOOL" - - -def _initial_value(var_elem): - value_elem = _find_child(var_elem, "initialValue") - if value_elem is None: - return None - simple = _find_child(value_elem, "simpleValue") - if simple is None: - return None - return simple.get("value") - - -SCOPE_TAGS = { - "localVars": "VAR", - "inputVars": "VAR_INPUT", - "outputVars": "VAR_OUTPUT", - "inOutVars": "VAR_IN_OUT", - "tempVars": "VAR_TEMP", - "globalVars": "VAR_GLOBAL", -} - - -def parse_interface(interface_elem): - variables = [] - if interface_elem is None: - return variables - for group in interface_elem: - scope = SCOPE_TAGS.get(_tag(group)) - if scope is None: - continue - if _is_true(group, "constant"): - scope += " CONSTANT" - for var_elem in group: - if _tag(var_elem) != "variable": - continue - variables.append( - Variable( - name=var_elem.get("name") or "", - type_name=_type_name(var_elem), - initial_value=_initial_value(var_elem), - scope=scope, - ) - ) - return variables - - # --- graph to expression tree ---------------------------------------------- @@ -360,7 +217,7 @@ def build_rungs(nodes): A rung is identified by its terminal: an element nothing else consumes. That is the right power rail where one exists, and the coil itself where - the export omits it. + the export omits it - CODESYS exports the right rail unconnected. """ by_id = {} for node in nodes: @@ -385,33 +242,21 @@ def build_rungs(nodes): return rungs -# --- top level ------------------------------------------------------------- - - def parse_pous(source): - """Parse every LD POU in a PLCopen file. Non-LD POUs are skipped. + """Parse every LD POU in a PLCopen file. Other languages are skipped. ``source`` is a path or a file object, as accepted by ElementTree. """ - tree = ET.parse(source) - root = tree.getroot() - pous = [] - for elem in root.iter(): - if _tag(elem) != "pou": - continue - body = _find_child(elem, "body") - if body is None: - continue - ld_body = _find_child(body, "LD") - if ld_body is None: + for pou_elem, language, body in iter_bodies(source): + if language != "LD": continue pous.append( Pou( - name=elem.get("name") or "", - pou_type=elem.get("pouType") or "program", - variables=parse_interface(_find_child(elem, "interface")), - rungs=build_rungs(parse_ld_body(ld_body)), + name=pou_elem.get("name") or "", + pou_type=pou_elem.get("pouType") or "program", + variables=parse_interface(find_child(pou_elem, "interface")), + rungs=build_rungs(parse_ld_body(body)), ) ) return pous diff --git a/tools/ladder/parse_fbd.py b/tools/ladder/parse_fbd.py new file mode 100644 index 0000000..cb39659 --- /dev/null +++ b/tools/ladder/parse_fbd.py @@ -0,0 +1,151 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Parse Function Block Diagram bodies out of PLCopen XML. + +FBD has no power rail, so networks are found the same way rungs are - by +looking for sinks nothing else consumes - but the result is a tree of calls +rather than a series/parallel chain. +""" + +from model import BLOCK, Assign, Call, Node, Pou, Signal +from plcopen import ( + block_connections, + block_outputs, + child_text, + comment_text, + direct_connections, + find_child, + iter_bodies, + parse_interface, + tag, +) + +COMMENT = "comment" +IN_VARIABLE = "inVariable" +OUT_VARIABLE = "outVariable" + +# vendorElement carries CODESYS editor state (network titles, implementation +# attributes) and holds no logic, so it is skipped entirely. +FBD_KINDS = (BLOCK, IN_VARIABLE, OUT_VARIABLE, COMMENT, "jump", "return", "label", "continuation", "connector") + +# Elements that can terminate a network. +SINK_KINDS = (BLOCK, OUT_VARIABLE) + + +def parse_fbd_body(body_elem): + """Return an ordered list of Nodes from an body element.""" + nodes = [] + for child in body_elem: + kind = tag(child) + if kind not in FBD_KINDS: + continue + local_id = child.get("localId") + if local_id is None: + continue + + if kind == COMMENT: + nodes.append(Node(local_id=local_id, kind=COMMENT, label=comment_text(child))) + continue + + is_block = kind == BLOCK + if is_block: + label = child.get("instanceName") or child.get("typeName") + else: + label = child_text(child, "expression") + + nodes.append( + Node( + local_id=local_id, + kind=kind, + label=label, + inputs=block_connections(child) if is_block else direct_connections(child), + type_name=child.get("typeName") if is_block else None, + instance_name=child.get("instanceName") if is_block else None, + outputs=block_outputs(child) if is_block else None, + ) + ) + return nodes + + +def _build(node, by_id, visiting, via_pin=None): + if node.local_id in visiting: + return Signal("" % node.local_id) + visiting = visiting | set([node.local_id]) + + if node.kind == BLOCK: + inputs = [] + for connection in node.inputs: + upstream = by_id.get(connection.ref_id) + source = None + if upstream is not None: + source = _build(upstream, by_id, visiting, connection.source_pin) + inputs.append((connection.target_pin, source)) + + active = via_pin + if active is None and node.outputs: + active = node.outputs[0][0] + + return Call( + type_name=node.type_name, + instance_name=node.instance_name, + inputs=inputs, + outputs=list(node.outputs), + active_output=active, + ) + + if node.kind == OUT_VARIABLE: + source = None + for connection in node.inputs: + upstream = by_id.get(connection.ref_id) + if upstream is not None: + source = _build(upstream, by_id, visiting, connection.source_pin) + break + return Assign(node.label or "?", source) + + return Signal(node.label or "") + + +def build_networks(nodes): + """Split a flat node list into (comment, tree) per network. + + Comments are matched to networks by document order: a comment applies to + the sink that follows it, which is how CODESYS lays out the export. + """ + by_id = {} + for node in nodes: + if node.kind != COMMENT: + by_id[node.local_id] = node + + consumed = set() + for node in nodes: + for connection in node.inputs: + consumed.add(connection.ref_id) + + networks = [] + comment = "" + for node in nodes: + if node.kind == COMMENT: + comment = node.label or "" + continue + if node.local_id in consumed or node.kind not in SINK_KINDS: + continue + networks.append((comment, _build(node, by_id, set()))) + comment = "" + return networks + + +def parse_pous(source): + """Parse every FBD POU in a PLCopen file. Other languages are skipped.""" + pous = [] + for pou_elem, language, body in iter_bodies(source): + if language != "FBD": + continue + pous.append( + Pou( + name=pou_elem.get("name") or "", + pou_type=pou_elem.get("pouType") or "program", + language="FBD", + variables=parse_interface(find_child(pou_elem, "interface")), + networks=build_networks(parse_fbd_body(body)), + ) + ) + return pous diff --git a/tools/ladder/plcopen.py b/tools/ladder/plcopen.py new file mode 100644 index 0000000..6f7b1d7 --- /dev/null +++ b/tools/ladder/plcopen.py @@ -0,0 +1,196 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Low-level PLCopen XML helpers shared by every language renderer. + +Namespaces are stripped rather than matched. The exact URI varies between +schema revisions - the hand-authored fixture is tc6_0201, real CODESYS output +is tc6_0200 - and CODESYS layers proprietary extensions on top. Matching local +tag names survives all of it. +""" + +import xml.etree.ElementTree as ET + +from model import Connection + +TRUTHY = ("true", "1") + +# Body element names, one of which wraps every POU implementation. +BODY_LANGUAGES = ("LD", "FBD", "SFC", "ST", "IL", "CFC") + + +def tag(elem): + """Local tag name, with any namespace stripped.""" + return elem.tag.split("}")[-1] + + +def find_child(elem, name): + for child in elem: + if tag(child) == name: + return child + return None + + +def child_text(elem, name): + child = find_child(elem, name) + if child is None or child.text is None: + return None + return child.text.strip() + + +def is_true(elem, attr_name): + return (elem.get(attr_name) or "").lower() in TRUTHY + + +def attr(elem, name): + """An attribute, treating CODESYS's literal "none" as absent.""" + value = elem.get(name) + if value in (None, "", "none"): + return None + return value + + +def direct_connections(elem): + """Wires arriving at this element's own connectionPointIn children. + + Several under a single connectionPointIn is how PLCopen + spells a parallel branch (a wired OR), so order and multiplicity matter. + This deliberately does not recurse: a block's pins hang off + and are collected separately, with their pin names. + """ + connections = [] + for point in elem: + if tag(point) != "connectionPointIn": + continue + for child in point: + if tag(child) != "connection": + continue + ref = child.get("refLocalId") + if ref is not None: + connections.append(Connection(ref, source_pin=attr(child, "formalParameter"))) + return connections + + +def block_connections(block_elem): + """Wires arriving at a block, tagged with the pin they land on.""" + connections = [] + for group_name in ("inputVariables", "inOutVariables"): + group = find_child(block_elem, group_name) + if group is None: + continue + for var in group: + if tag(var) != "variable": + continue + pin = var.get("formalParameter") + for connection in direct_connections(var): + connection.target_pin = pin + connections.append(connection) + return connections + + +def block_outputs(block_elem): + """(pin, assigned variable) for each block output. + + CODESYS writes an assignment straight onto the output pin as + uiCurrSupplyVolt. + """ + outputs = [] + group = find_child(block_elem, "outputVariables") + if group is None: + return outputs + for var in group: + if tag(var) != "variable": + continue + assigned = None + point = find_child(var, "connectionPointOut") + if point is not None: + expression = find_child(point, "expression") + if expression is not None and expression.text: + assigned = expression.text.strip() + outputs.append((var.get("formalParameter"), assigned)) + return outputs + + +def comment_text(elem): + """The text of a , which nests its content in an xhtml element.""" + content = find_child(elem, "content") + if content is None: + return "" + xhtml = find_child(content, "xhtml") + if xhtml is None or xhtml.text is None: + return "" + return xhtml.text.strip() + + +# --- interface ------------------------------------------------------------- + +SCOPE_TAGS = { + "localVars": "VAR", + "inputVars": "VAR_INPUT", + "outputVars": "VAR_OUTPUT", + "inOutVars": "VAR_IN_OUT", + "tempVars": "VAR_TEMP", + "globalVars": "VAR_GLOBAL", +} + + +def _type_name(var_elem): + type_elem = find_child(var_elem, "type") + if type_elem is None: + return "BOOL" + for child in type_elem: + name = tag(child) + if name == "derived": + return child.get("name") or "UNKNOWN" + return name + return "BOOL" + + +def _initial_value(var_elem): + value_elem = find_child(var_elem, "initialValue") + if value_elem is None: + return None + simple = find_child(value_elem, "simpleValue") + if simple is None: + return None + return simple.get("value") + + +def parse_interface(interface_elem): + """Variables from a POU interface, in declaration order.""" + from model import Variable + + variables = [] + if interface_elem is None: + return variables + for group in interface_elem: + scope = SCOPE_TAGS.get(tag(group)) + if scope is None: + continue + if is_true(group, "constant"): + scope += " CONSTANT" + for var_elem in group: + if tag(var_elem) != "variable": + continue + variables.append( + Variable( + name=var_elem.get("name") or "", + type_name=_type_name(var_elem), + initial_value=_initial_value(var_elem), + scope=scope, + ) + ) + return variables + + +def iter_bodies(source): + """Yield (pou_elem, language, body_elem) for every POU with an implementation.""" + root = ET.parse(source).getroot() + for elem in root.iter(): + if tag(elem) != "pou": + continue + body = find_child(elem, "body") + if body is None: + continue + for child in body: + if tag(child) in BODY_LANGUAGES: + yield elem, tag(child), child + break diff --git a/tools/ladder/render.py b/tools/ladder/render.py new file mode 100644 index 0000000..7da657c --- /dev/null +++ b/tools/ladder/render.py @@ -0,0 +1,83 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Render the graphical POUs in a PLCopen XML file. + + python tools/ladder/render.py [--format art|st|both] [...] + + art ASCII rungs and block diagrams, close to the CODESYS layout + st equivalent Structured Text - diffs and greps far better + both ST first, then the diagram (the default) + +Prototype only - not yet wired into the CODESYS export path. Ladder and +Function Block Diagram are supported; SFC bodies are skipped. +""" + +from __future__ import print_function + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import ascii_render # noqa: E402 +import fbd_render # noqa: E402 +import parse # noqa: E402 +import parse_fbd # noqa: E402 +import st_render # noqa: E402 + +FORMATS = ("art", "st", "both") + + +def _pous(path): + """Every graphical POU in the file, paired with its art renderer.""" + found = [] + for pou in parse.parse_pous(path): + found.append((pou, ascii_render)) + for pou in parse_fbd.parse_pous(path): + found.append((pou, fbd_render)) + return found + + +def render_file(path, output_format="both"): + lines = [] + for pou, art_renderer in _pous(path): + if output_format in ("st", "both"): + lines.extend(st_render.render_pou(pou)) + lines.append("") + if output_format in ("art", "both"): + if output_format == "both": + # The diagram repeats the declaration, which is noise the + # second time around. + lines.extend(art_renderer.render_pou(pou)[len(ascii_render.render_declaration(pou)) :]) + else: + lines.extend(art_renderer.render_pou(pou)) + lines.append("") + return [line.rstrip() for line in lines] + + +def main(argv): + output_format = "both" + paths = [] + index = 0 + while index < len(argv): + if argv[index] == "--format": + index += 1 + if index >= len(argv) or argv[index] not in FORMATS: + print("--format must be one of: " + ", ".join(FORMATS)) + return 2 + output_format = argv[index] + else: + paths.append(argv[index]) + index += 1 + + if not paths: + print(__doc__) + return 2 + + for path in paths: + for line in render_file(path, output_format): + print(line) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/ladder/render_ld.py b/tools/ladder/render_ld.py deleted file mode 100644 index 08961a2..0000000 --- a/tools/ladder/render_ld.py +++ /dev/null @@ -1,39 +0,0 @@ -# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. -"""Render the Ladder POUs in a PLCopen XML file as ASCII. - - python tools/ladder/render_ld.py [more.xml ...] - -Prototype only - not yet wired into the CODESYS export path. -""" - -from __future__ import print_function - -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from ascii_render import render_pou # noqa: E402 -from parse import parse_pous # noqa: E402 - - -def render_file(path): - lines = [] - for pou in parse_pous(path): - lines.extend(render_pou(pou)) - lines.append("") - return lines - - -def main(argv): - if not argv: - print(__doc__) - return 2 - for path in argv: - for line in render_file(path): - print(line) - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/tools/ladder/st_render.py b/tools/ladder/st_render.py new file mode 100644 index 0000000..2288417 --- /dev/null +++ b/tools/ladder/st_render.py @@ -0,0 +1,132 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Emit equivalent Structured Text for LD and FBD networks. + +The ASCII renderers preserve the shape of the diagram; this preserves the +logic and throws the shape away. It is the better of the two for review: it +diffs line by line, it greps, and reviewers already read ST. + +It is a rendering, not a translation - the output is not guaranteed to compile +and must never be fed back into CODESYS. Notably a coil is transparent to +power flow, so the condition carries on past it, which reads oddly in ST but +matches what the rung does. +""" + +from ascii_render import render_declaration +from model import BLOCK, COIL, Assign, Call, Element, Series, Signal +from parse import expr_to_text + + +def _coil_statement(coil, condition): + condition = condition or "TRUE" + target = coil.label or "?" + if coil.storage == "set": + return "IF %s THEN %s := TRUE; END_IF" % (condition, target) + if coil.storage == "reset": + return "IF %s THEN %s := FALSE; END_IF" % (condition, target) + if coil.negated: + return "%s := NOT (%s);" % (target, condition) + return "%s := %s;" % (target, condition) + + +def rung_to_statements(rung): + """One rung to a list of ST statements, walking the power flow left to right.""" + items = rung.items if isinstance(rung, Series) else [rung] + statements = [] + condition = None + + for item in items: + if isinstance(item, Element) and item.kind == BLOCK: + args = [] + for pin, label in item.input_pins: + # A label of None is the power pin, fed by the rung so far. + value = condition if label is None else label + if value: + args.append("%s := %s" % (pin, value)) + name = item.instance_name or item.type_name or "?" + statements.append("%s(%s);" % (name, ", ".join(args))) + condition = (name + "." + item.active_output) if item.active_output else name + elif isinstance(item, Element) and item.kind == COIL: + statements.append(_coil_statement(item, condition)) + else: + text = expr_to_text(item) + if text: + condition = text if condition is None else condition + " AND " + text + + return statements + + +def _fbd_value(node, statements): + """Value of a node as ST text, appending any statements it needs first.""" + if node is None: + return "" + + if isinstance(node, Signal): + return node.label or "" + + if isinstance(node, Assign): + value = _fbd_value(node.source, statements) + statements.append("%s := %s;" % (node.label or "?", value or "FALSE")) + return node.label or "?" + + if isinstance(node, Call): + pairs = [] + for pin, source in node.inputs: + value = _fbd_value(source, statements) + if value: + pairs.append((pin, value)) + + if node.is_operator: + # Operators and functions have no instance to call, so they inline + # as a positional expression rather than a statement. + return "%s(%s)" % (node.type_name or "?", ", ".join(value for _pin, value in pairs)) + + name = node.instance_name + statements.append("%s(%s);" % (name, ", ".join("%s := %s" % (pin, value) for pin, value in pairs))) + for pin, assigned in node.outputs: + if assigned: + statements.append("%s := %s.%s;" % (assigned, name, pin)) + return (name + "." + node.active_output) if node.active_output else name + + return "?" + + +def network_to_statements(tree): + statements = [] + value = _fbd_value(tree, statements) + if not statements and value: + # A bare expression with nothing to assign it to - keep it visible + # rather than dropping the network entirely. + statements.append("(* " + value + " *)") + return statements + + +def _network_header(index, comment): + header = "(* Network " + str(index + 1) + if comment: + header += ": " + comment.lstrip("/").strip() + return header + " *)" + + +def render_pou(pou): + """Render a POU as declaration plus ST statements, one block per network.""" + lines = render_declaration(pou) + lines.append("") + + for index, rung in enumerate(pou.rungs): + lines.append(_network_header(index, "")) + lines.extend(rung_to_statements(rung)) + lines.append("") + + for index, network in enumerate(pou.networks): + comment, tree = network + lines.append(_network_header(index, comment)) + lines.extend(network_to_statements(tree)) + lines.append("") + + if not pou.rungs and not pou.networks: + lines.append("(* no networks *)") + + while lines and lines[-1] == "": + lines.pop() + + return [line.rstrip() for line in lines] diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt new file mode 100644 index 0000000..9441a27 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt @@ -0,0 +1,32 @@ +PROGRAM FB_TESTING +VAR CONSTANT + uiMinVoltage : UINT := 5000; +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; + fbSupplySwitch : ifmIOcommon.SupplySwitch; + uiCurrSupplyVolt : UINT; + TOF_0 : TOF; +END_VAR + +(* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) + fbSystemSupply : ifmIOcommon.SystemSupply + +-----------------------------------------+ +ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15------|eChannel xError| +ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY--|eMode eDiagInfo| + |eFilter xPrepared| + | uiOutVoltage => uiCurrSupplyVolt| + +-----------------------------------------+ + +(* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) + fbSupplySwitch : ifmIOcommon.SupplySwitch + +-----------------------------------------+ +ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH--|eMode xError| + GT TOF_0 : TOF | | + +----------+ +-----------+ | | +uiCurrSupplyVolt--|In1 Out1|--|IN Q|-----|xValue eDiagInfo| +uiMinVoltage------|In2 | | | | xPrepared| + +----------+ | | +-----------------------------------------+ +T#5S----------------------------|PT ET| + +-----------+ + diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt new file mode 100644 index 0000000..210b2b4 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt @@ -0,0 +1,19 @@ +PROGRAM FB_TESTING +VAR CONSTANT + uiMinVoltage : UINT := 5000; +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; + fbSupplySwitch : ifmIOcommon.SupplySwitch; + uiCurrSupplyVolt : UINT; + TOF_0 : TOF; +END_VAR + +(* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) +fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15, eMode := ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY); +uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; + +(* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) +TOF_0(IN := GT(uiCurrSupplyVolt, uiMinVoltage), PT := T#5S); +fbSupplySwitch(eMode := ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH, xValue := TOF_0.Q); + diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt new file mode 100644 index 0000000..5e22665 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt @@ -0,0 +1,19 @@ +PROGRAM LD_TEST +VAR + Sensor1 : BOOL; + Sensor2 : BOOL; + sensor3 : BOOL; + PowerOn : BOOL; + TON_0 : TON; + CTU_0 : CTU; + PowerOff : BOOL; +END_VAR + +(* Network 1 *) +IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF + +(* Network 2 *) +TON_0(IN := PowerOn, PT := T#5S); +CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); +IF CTU_0.Q THEN PowerOff := FALSE; END_IF + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py new file mode 100644 index 0000000..4e205f3 --- /dev/null +++ b/tools/ladder/tests/test_fbd.py @@ -0,0 +1,169 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Tests for the Function Block Diagram renderer and the ST emitter. + +Plain script rather than pytest, matching tools/ci/, so it runs under both +Python 3 and the IronPython 2.7 that CODESYS embeds. + + python tools/ladder/tests/test_fbd.py +""" + +from __future__ import print_function + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..")) + +import fbd_render # noqa: E402 +import parse # noqa: E402 +import parse_fbd # noqa: E402 +import st_render # noqa: E402 +from model import Call, Signal # noqa: E402 + +FIXTURES = os.path.join(HERE, "fixtures", "codesys") +FBD_SOURCE = os.path.join(FIXTURES, "FbTesting.xml") +LD_SOURCE = os.path.join(FIXTURES, "LDTesting.xml") +SFC_SOURCE = os.path.join(FIXTURES, "SFCTesting.xml") + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + else: + failures.append(name) + print("FAIL " + name + ((": " + detail) if detail else "")) + + +def check_equal(name, actual, expected): + check(name, actual == expected, "expected %r, got %r" % (expected, actual)) + + +def check_golden(name, rendered, golden_path): + handle = open(golden_path) + try: + expected = handle.read().replace("\r\n", "\n").rstrip("\n").split("\n") + finally: + handle.close() + if rendered != expected: + print("--- expected ---") + print("\n".join(expected)) + print("--- actual ---") + print("\n".join(rendered)) + check_equal(name, rendered, expected) + + +# --- parsing --------------------------------------------------------------- + +pous = parse_fbd.parse_pous(FBD_SOURCE) +check_equal("one FBD pou is found", len(pous), 1) + +pou = pous[0] +check_equal("pou name", pou.name, "FB_TESTING") +check_equal("language is recorded", pou.language, "FBD") +check_equal("two networks", len(pou.networks), 2) + +# localVars constant="true" is a separate group and must not merge with VAR. +check_equal("constant scope", pou.variables[0].scope, "VAR CONSTANT") +check_equal("constant initial value", pou.variables[0].initial_value, "5000") +check_equal("namespaced derived type", pou.variables[1].type_name, "ifmIOcommon.SystemSupply") + +# Comments carry the network's intent and nest their text in an xhtml element. +comment1, tree1 = pou.networks[0] +check("network 1 comment is captured", comment1.startswith("// Function Block to monitor supply voltage")) + +check("network 1 is a call", isinstance(tree1, Call)) +check_equal("network 1 instance", tree1.instance_name, "fbSystemSupply") +check_equal("network 1 type", tree1.type_name, "ifmIOcommon.SystemSupply") +check_equal("network 1 has three inputs", len(tree1.inputs), 3) +check_equal("first pin name", tree1.inputs[0][0], "eChannel") +check("first pin source is a signal", isinstance(tree1.inputs[0][1], Signal)) +check_equal("first pin value", tree1.inputs[0][1].label, "ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15") + +# An unconnected pin exports as an element with an empty expression, not as a +# missing element - so it reaches the tree as a Signal carrying no label. +check_equal("unwired pin name", tree1.inputs[2][0], "eFilter") +check_equal("unwired pin has an empty label", tree1.inputs[2][1].label, "") +check("unwired pin is not drawn with a wire", not fbd_render._is_wired(tree1.inputs[2][1])) + +# CODESYS writes an assignment straight onto the output pin. +outputs = dict(tree1.outputs) +check_equal("output assignment is captured", outputs["uiOutVoltage"], "uiCurrSupplyVolt") + +# Network 2 nests three calls: GT -> TOF -> SupplySwitch. +comment2, tree2 = pou.networks[1] +check_equal("network 2 root", tree2.instance_name, "fbSupplySwitch") +tof = tree2.inputs[1][1] +check_equal("nested TOF", tof.instance_name, "TOF_0") +check_equal("wire leaves TOF on Q", tof.active_output, "Q") +gt = tof.inputs[0][1] +check_equal("nested GT", gt.type_name, "GT") + +# An operator has no instance name, so it inlines as an expression in ST. +check("GT is an operator", gt.is_operator) +check("TOF is not an operator", not tof.is_operator) +check_equal("operator title omits the instance", gt.title, "GT") +check_equal("function block title includes it", tof.title, "TOF_0 : TOF") + +# --- FBD rendering --------------------------------------------------------- + +art = fbd_render.render_pou(pou) +check("art: no trailing whitespace", all(line == line.rstrip() for line in art)) +check("art: boxes do not fuse together", not any("++" in line for line in art)) +check("art: output assignment is drawn", any("uiOutVoltage => uiCurrSupplyVolt" in line for line in art)) +check("art: nested operator box is drawn", any("|In1 Out1|" in line for line in art)) + +# Every position in this export is x="0" y="0". If layout depended on those +# coordinates the three boxes would land on top of each other, so finding each +# title on its own distinct row is what proves layout comes from topology. +title_rows = {} +for row, line in enumerate(art): + for title in ("GT", "TOF_0 : TOF", "fbSupplySwitch : ifmIOcommon.SupplySwitch"): + if title in line and title not in title_rows: + title_rows[title] = row +check_equal("art: all three boxes are placed", len(title_rows), 3) +check_equal("art: no two boxes share a row", len(set(title_rows.values())), 3) + +check_golden("art: golden output matches", art, os.path.join(FIXTURES, "FbTesting.art.expected.txt")) + +# --- ST emission ----------------------------------------------------------- + +fbd_st = st_render.render_pou(pou) + +SUPPLY_CALL = ( + "fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15," + " eMode := ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY);" +) +SWITCH_CALL = "fbSupplySwitch(eMode := ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH, xValue := TOF_0.Q);" + +check("st: function block becomes a call statement", SUPPLY_CALL in fbd_st) +check("st: output assignment becomes its own statement", "uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage;" in fbd_st) +check("st: operator inlines positionally", "TOF_0(IN := GT(uiCurrSupplyVolt, uiMinVoltage), PT := T#5S);" in fbd_st) +check("st: nested output is referenced by pin", SWITCH_CALL in fbd_st) +check("st: unwired pin is omitted", not any("eFilter" in line for line in fbd_st)) + +check_golden("st: FBD golden matches", fbd_st, os.path.join(FIXTURES, "FbTesting.st.expected.txt")) + +ld_pou = parse.parse_pous(LD_SOURCE)[0] +ld_st = st_render.render_pou(ld_pou) +check("st: parallel branch becomes OR", "IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF" in ld_st) +check("st: ladder block becomes a call", "TON_0(IN := PowerOn, PT := T#5S);" in ld_st) +check("st: block chains through its output pin", "CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10);" in ld_st) +check("st: reset coil becomes a conditional", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" in ld_st) + +check_golden("st: LD golden matches", ld_st, os.path.join(FIXTURES, "LDTesting.st.expected.txt")) + +# --- language dispatch ----------------------------------------------------- + +check_equal("LD parser ignores FBD bodies", parse.parse_pous(FBD_SOURCE), []) +check_equal("FBD parser ignores LD bodies", parse_fbd.parse_pous(LD_SOURCE), []) +check_equal("SFC is skipped by both", parse.parse_pous(SFC_SOURCE) + parse_fbd.parse_pous(SFC_SOURCE), []) + +print("") +if failures: + print("%d check(s) failed" % len(failures)) +else: + print("all checks passed") +sys.exit(1 if failures else 0) From ee39f4a49616d848866a8c58aeb9685071d8391d Mon Sep 17 00:00:00 2001 From: kehinde Date: Wed, 5 Aug 2026 12:52:31 +1000 Subject: [PATCH 03/91] draw the diagrams with Unicode box characters Replaces the ASCII art with box drawing, which reads far closer to the CODESYS editor: wires are continuous rather than dashed, and a tee on a box edge marks a genuine connection so an unwired or unconsumed pin is visibly different from a wired one. The glyphs live in charset.py as \u escapes rather than literal characters. IronPython 2.7 enforces PEP 263 and refuses to load a source file containing a non-ASCII byte without an encoding declaration, so a literal box character anywhere in these modules would stop CODESYS loading them at all. The tests reference the same table for the same reason. An ASCII set is kept and selectable with --charset ascii, for terminals and diff viewers that mangle box drawing. Output is written as UTF-8 explicitly, since a Windows console codepage cannot encode the characters this tool exists to produce. ascii_render.py is renamed to ld_render.py, which was always the better name and is now the accurate one. Co-Authored-By: Claude Opus 5 --- tools/ladder/charset.py | 71 +++++++++++++++ tools/ladder/fbd_render.py | 35 ++++++-- tools/ladder/layout.py | 17 ++-- .../ladder/{ascii_render.py => ld_render.py} | 88 ++++++++++++------- tools/ladder/model.py | 11 ++- tools/ladder/parse.py | 3 + tools/ladder/parse_fbd.py | 2 + tools/ladder/render.py | 56 +++++++++--- tools/ladder/st_render.py | 2 +- .../codesys/FbTesting.art.expected.txt | 30 +++---- .../fixtures/codesys/LDTesting.expected.txt | 20 ++--- .../tests/fixtures/motor_control.expected.txt | 16 ++-- tools/ladder/tests/test_fbd.py | 27 ++++-- tools/ladder/tests/test_ladder.py | 54 +++++++++--- 14 files changed, 318 insertions(+), 114 deletions(-) create mode 100644 tools/ladder/charset.py rename tools/ladder/{ascii_render.py => ld_render.py} (71%) diff --git a/tools/ladder/charset.py b/tools/ladder/charset.py new file mode 100644 index 0000000..a0bdd6d --- /dev/null +++ b/tools/ladder/charset.py @@ -0,0 +1,71 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Drawing characters for the graphical renderers. + +The glyphs are written as \\u escapes rather than literal box-drawing +characters on purpose: CODESYS runs these scripts under IronPython 2.7, which +enforces PEP 263 and refuses to load a source file containing a non-ASCII byte +without an encoding declaration. Escapes keep the source pure ASCII while the +output is Unicode. + +The rendered text is written as UTF-8, matching the .st files CODESCRIBE +already exports. + +An ASCII set is kept alongside for terminals, diff viewers and pasted-into- +email situations where box drawing turns to mojibake. +""" + +from __future__ import unicode_literals + +UNICODE = { + "H": "\u2500", # horizontal wire + "V": "\u2502", # vertical wire + "TL": "\u250c", # box corners + "TR": "\u2510", + "BL": "\u2514", + "BR": "\u2518", + "T_DOWN": "\u252c", # branch leaves downward + "T_UP": "\u2534", + "T_RIGHT": "\u251c", # wire joins and continues right + "T_LEFT": "\u2524", # wire arrives from the left + # A ladder contact is a pair of bars the wire runs between. + "CONTACT_L": "\u2524", + "CONTACT_R": "\u251c", + # Box edges at a pin: the tee marks a real connection, so an unwired pin + # stays a plain wall and is visibly different. + "PIN_L": "\u2524", + "PIN_R": "\u251c", +} + +ASCII = { + "H": "-", + "V": "|", + "TL": "+", + "TR": "+", + "BL": "+", + "BR": "+", + "T_DOWN": "+", + "T_UP": "+", + "T_RIGHT": "+", + "T_LEFT": "+", + "CONTACT_L": "|", + "CONTACT_R": "|", + "PIN_L": "|", + "PIN_R": "|", +} + +SETS = {"unicode": UNICODE, "ascii": ASCII} + +_active = UNICODE + + +def use(name): + """Select the character set by name. Returns the set now in use.""" + global _active + if name not in SETS: + raise ValueError("unknown charset %r, expected one of %s" % (name, ", ".join(sorted(SETS)))) + _active = SETS[name] + return _active + + +def active(): + return _active diff --git a/tools/ladder/fbd_render.py b/tools/ladder/fbd_render.py index 1eda25f..8de4ae7 100644 --- a/tools/ladder/fbd_render.py +++ b/tools/ladder/fbd_render.py @@ -7,8 +7,11 @@ whatever row their source ended up on, which keeps every wire horizontal. """ -from ascii_render import render_declaration +from __future__ import unicode_literals + +import charset from layout import Block, stack +from ld_render import render_declaration from model import Assign, Call, Signal @@ -17,9 +20,10 @@ def _render_signal(node): def _render_assign(node): + chars = charset.active() source = _render(node.source) if node.source is not None else Block([""], 0) lines = source.padded(source.width) - tail = "---> " + (node.label or "?") + tail = chars["H"] * 3 + "> " + (node.label or "?") out = [] for index, line in enumerate(lines): out.append(line + tail if index == source.connect_row else line) @@ -38,6 +42,7 @@ def _is_wired(source): def _render_call(call): + chars = charset.active() input_blocks = [] for _pin, source in call.inputs: input_blocks.append(_render(source) if source is not None else Block([""], 0)) @@ -56,7 +61,7 @@ def _render_call(call): left = [] for index, line in enumerate(left_lines): - fill = "-" if index in handoff else " " + fill = chars["H"] if index in handoff else " " left.append(line + fill * (left_width - len(line))) input_rows = list(pin_rows) @@ -103,16 +108,34 @@ def _render_call(call): height = max(len(left), box_last + 2) left += [" " * left_width] * (height - len(left)) + # handoff was computed before the shift; recompute against the final rows. + handoff_pins = set() + for index, pin_and_source in enumerate(call.inputs): + if _is_wired(pin_and_source[1]): + handoff_pins.add(input_rows[index]) + + # The active output only breaks the box wall with a tee if a consumer is + # actually there to receive it. + pins = [pin for pin, _assigned in call.outputs] + live_output_row = None + if call.output_wired and call.active_output in pins: + live_output_row = output_rows[pins.index(call.active_output)] + lines = [] for row in range(height): if row == box_first - 2: box = title.center(inner + 2) - elif row == box_first - 1 or row == box_last + 1: - box = "+" + "-" * inner + "+" + elif row == box_first - 1: + box = chars["TL"] + chars["H"] * inner + chars["TR"] + elif row == box_last + 1: + box = chars["BL"] + chars["H"] * inner + chars["BR"] elif box_first <= row <= box_last: left_pin = in_at.get(row, "") right_pin = out_at.get(row, "") - box = "|" + left_pin + " " * (inner - len(left_pin) - len(right_pin)) + right_pin + "|" + left_edge = chars["PIN_L"] if row in handoff_pins else chars["V"] + right_edge = chars["PIN_R"] if row == live_output_row else chars["V"] + gap = inner - len(left_pin) - len(right_pin) + box = left_edge + left_pin + " " * gap + right_pin + right_edge else: box = " " * (inner + 2) lines.append(left[row] + box) diff --git a/tools/ladder/layout.py b/tools/ladder/layout.py index c49f313..f1dff67 100644 --- a/tools/ladder/layout.py +++ b/tools/ladder/layout.py @@ -6,6 +6,10 @@ to know about absolute coordinates. """ +from __future__ import unicode_literals + +import charset + class Block(object): def __init__(self, lines, connect_row): @@ -18,19 +22,20 @@ def width(self): return 0 return max(len(line) for line in self.lines) - def padded(self, width, wire_rows=None): - """Lines padded to ``width``, extending wires with dashes. + def padded(self, width, wire_rows=None, fill=None): + """Lines padded to ``width``, extending wires horizontally. Rows listed in ``wire_rows`` (defaulting to this Block's own connect - row) are filled with dashes so a short branch still reaches the - junction on its right. Every other row is filled with spaces. + row) are filled with the wire character so a short branch still reaches + the junction on its right. Every other row is filled with spaces. """ if wire_rows is None: wire_rows = set([self.connect_row]) + if fill is None: + fill = charset.active()["H"] out = [] for index, line in enumerate(self.lines): - fill = "-" if index in wire_rows else " " - out.append(line + fill * (width - len(line))) + out.append(line + (fill if index in wire_rows else " ") * (width - len(line))) return out diff --git a/tools/ladder/ascii_render.py b/tools/ladder/ld_render.py similarity index 71% rename from tools/ladder/ascii_render.py rename to tools/ladder/ld_render.py index 86fc97a..5952ed8 100644 --- a/tools/ladder/ascii_render.py +++ b/tools/ladder/ld_render.py @@ -1,5 +1,5 @@ # REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. -"""Render a parsed Ladder Diagram as ASCII rungs. +"""Render a parsed Ladder Diagram as rungs. Layout comes from the expression tree only - the x/y coordinates in the source XML are deliberately ignored. Dragging a contact sideways in CODESYS must not @@ -8,8 +8,14 @@ Composition works on Blocks: a rectangle of text plus the row index its wire enters and leaves on. Series concatenates Blocks horizontally aligned on that row; Parallel stacks them and threads a junction column down each side. + +Drawing characters come from charset, so the same layout renders as either +box-drawing Unicode or plain ASCII. """ +from __future__ import unicode_literals + +import charset from layout import Block from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series @@ -22,29 +28,30 @@ def _symbol_and_label(element): """The drawn symbol, and the caption sitting above it.""" + chars = charset.active() kind = element.kind if kind == CONTACT: if element.edge == "rising": - symbol = "|P|" + middle = "P" elif element.edge == "falling": - symbol = "|N|" + middle = "N" elif element.negated: - symbol = "|/|" + middle = "/" else: - symbol = "| |" - return symbol, element.label or "" + middle = " " + return chars["CONTACT_L"] + middle + chars["CONTACT_R"], element.label or "" if kind == COIL: if element.storage == "set": - symbol = "(S)" + middle = "S" elif element.storage == "reset": - symbol = "(R)" + middle = "R" elif element.negated: - symbol = "(/)" + middle = "/" else: - symbol = "( )" - return symbol, element.label or "" + middle = " " + return "(" + middle + ")", element.label or "" if kind == "jump": return ">>" + (element.label or "?"), "" @@ -60,21 +67,21 @@ def _symbol_and_label(element): def _render_block(element): """Draw a function block as a pin box. - TON_0 : TON - +-----------+ - ---|IN Q|--- - |PT := T#5S | - +-----------+ - The power pin sorts first, so the wire enters and leaves on the same row. + Only pins that are genuinely wired get a tee on the box edge; a + parameterised or unconsumed pin leaves the wall unbroken. """ + chars = charset.active() + left = [] + wired = [] for pin, label in element.input_pins: text = pin or "?" # A label of None is the power pin - it is wired, not parameterised. if label is not None: text += " := " + label if label else "" left.append(text) + wired.append(label is None) right = [] for pin, assigned in element.output_pins: @@ -85,17 +92,21 @@ def _render_block(element): rows = max(len(left), len(right), 1) left += [""] * (rows - len(left)) + wired += [False] * (rows - len(wired)) right += [""] * (rows - len(right)) title = element.title inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)]) lines = [title.center(inner + 2)] - lines.append("+" + "-" * inner + "+") + lines.append(chars["TL"] + chars["H"] * inner + chars["TR"]) for index in range(rows): gap = inner - len(left[index]) - len(right[index]) - lines.append("|" + left[index] + " " * gap + right[index] + "|") - lines.append("+" + "-" * inner + "+") + left_edge = chars["PIN_L"] if wired[index] else chars["V"] + # Only the active output continues onward, and only if consumed. + right_edge = chars["PIN_R"] if (index == 0 and element.output_wired) else chars["V"] + lines.append(left_edge + left[index] + " " * gap + right[index] + right_edge) + lines.append(chars["BL"] + chars["H"] * inner + chars["BR"]) # Row 0 is the title and row 1 the top border, so the first pin is row 2. connect_row = 2 @@ -104,7 +115,7 @@ def _render_block(element): # unreadable run of border characters. stubbed = [] for index, line in enumerate(lines): - stub = "-" if index == connect_row else " " + stub = chars["H"] if index == connect_row else " " stubbed.append(stub + line + stub) return Block(stubbed, connect_row) @@ -114,11 +125,12 @@ def _render_element(element): if element.kind == BLOCK: return _render_block(element) + chars = charset.active() symbol, label = _symbol_and_label(element) width = max(len(label) + 2, len(symbol) + 4) lead = (width - len(symbol)) // 2 - symbol_line = "-" * lead + symbol + "-" * (width - len(symbol) - lead) + symbol_line = chars["H"] * lead + symbol + chars["H"] * (width - len(symbol) - lead) lead = (width - len(label)) // 2 label_line = " " * lead + label + " " * (width - len(label) - lead) @@ -147,6 +159,7 @@ def _render_series(items): def _render_parallel(branches): + chars = charset.active() blocks = [_render(branch) for branch in branches] width = max(block.width for block in blocks) @@ -155,9 +168,9 @@ def _render_parallel(branches): for block in blocks: connect_rows.append(len(stacked) + block.connect_row) for index, line in enumerate(block.lines): - # The wire itself extends with dashes; everything else with spaces, - # so short branches still reach the junction on the right. - fill = "-" if index == block.connect_row else " " + # The wire itself extends horizontally; everything else with + # spaces, so short branches still reach the junction on the right. + fill = chars["H"] if index == block.connect_row else " " stacked.append(line + fill * (width - len(line))) junctions = set(connect_rows) @@ -165,20 +178,26 @@ def _render_parallel(branches): lines = [] for row, line in enumerate(stacked): - if row in junctions: - edge = "+" + if row == first: + # The main line carries straight on and drops a branch downward. + left, right = chars["T_DOWN"], chars["T_DOWN"] + elif row == last: + left, right = chars["BL"], chars["BR"] + elif row in junctions: + left, right = chars["T_RIGHT"], chars["T_LEFT"] elif first < row < last: - edge = "|" + left = right = chars["V"] else: - edge = " " - lines.append(edge + line + edge) + left = right = " " + lines.append(left + line + right) return Block(lines, first) def _render(expr): + chars = charset.active() if isinstance(expr, Empty): - return Block([" ", "---"], 1) + return Block([" ", chars["H"] * 3], 1) if isinstance(expr, Element): return _render_element(expr) if isinstance(expr, Series): @@ -189,14 +208,15 @@ def _render(expr): def render_rung(expr): - """Render one rung, bounded by the left power rail.""" + """Render one rung, bounded by the power rails.""" + chars = charset.active() block = _render(expr) lines = [] for row, line in enumerate(block.lines): if row == block.connect_row: - lines.append("|--" + line + "--|") + lines.append(chars["T_RIGHT"] + chars["H"] * 2 + line + chars["H"] * 2 + chars["T_LEFT"]) else: - lines.append("| " + line) + lines.append(chars["V"] + " " + line) return lines diff --git a/tools/ladder/model.py b/tools/ladder/model.py index 0573226..2e7a1ef 100644 --- a/tools/ladder/model.py +++ b/tools/ladder/model.py @@ -123,12 +123,17 @@ class Call(object): exactly as exported; unlike LD there is no power pin to hoist. """ - def __init__(self, type_name=None, instance_name=None, inputs=None, outputs=None, active_output=None): + def __init__( + self, type_name=None, instance_name=None, inputs=None, outputs=None, active_output=None, output_wired=False + ): self.type_name = type_name self.instance_name = instance_name self.inputs = inputs if inputs is not None else [] self.outputs = outputs if outputs is not None else [] self.active_output = active_output + # True when something downstream consumes the active output. A network + # sink has an active output but nothing to hand it to. + self.output_wired = output_wired @property def title(self): @@ -190,6 +195,7 @@ def __init__( input_pins=None, output_pins=None, active_output=None, + output_wired=False, ): self.kind = kind self.label = label @@ -201,6 +207,9 @@ def __init__( self.input_pins = input_pins if input_pins is not None else [] self.output_pins = output_pins if output_pins is not None else [] self.active_output = active_output + # True when something downstream actually consumes the active output, + # so the renderer knows whether to break the box edge with a tee. + self.output_wired = output_wired @property def title(self): diff --git a/tools/ladder/parse.py b/tools/ladder/parse.py index eda9131..8c3ace8 100644 --- a/tools/ladder/parse.py +++ b/tools/ladder/parse.py @@ -176,6 +176,9 @@ def _build_block(node, by_id, visiting, via_pin): input_pins=input_pins, output_pins=output_pins, active_output=active, + # via_pin is set by whatever consumed this block; a block terminating + # the rung has none. + output_wired=via_pin is not None, ) return series([power_expr, element]) diff --git a/tools/ladder/parse_fbd.py b/tools/ladder/parse_fbd.py index cb39659..d6fec94 100644 --- a/tools/ladder/parse_fbd.py +++ b/tools/ladder/parse_fbd.py @@ -90,6 +90,8 @@ def _build(node, by_id, visiting, via_pin=None): inputs=inputs, outputs=list(node.outputs), active_output=active, + # via_pin is set by the consumer; a network sink has none. + output_wired=via_pin is not None, ) if node.kind == OUT_VARIABLE: diff --git a/tools/ladder/render.py b/tools/ladder/render.py index 7da657c..540961f 100644 --- a/tools/ladder/render.py +++ b/tools/ladder/render.py @@ -1,25 +1,32 @@ # REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. """Render the graphical POUs in a PLCopen XML file. - python tools/ladder/render.py [--format art|st|both] [...] + python tools/ladder/render.py [options] [...] - art ASCII rungs and block diagrams, close to the CODESYS layout - st equivalent Structured Text - diffs and greps far better - both ST first, then the diagram (the default) + --format art|st|both art diagrams, close to the CODESYS layout + st equivalent Structured Text - diffs and greps + both ST first, then the diagram (the default) + + --charset unicode|ascii box-drawing characters (the default), or plain + ASCII for terminals and diff viewers that mangle + them + +Output is written as UTF-8 regardless of the console encoding. Prototype only - not yet wired into the CODESYS export path. Ladder and Function Block Diagram are supported; SFC bodies are skipped. """ -from __future__ import print_function +from __future__ import print_function, unicode_literals import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import ascii_render # noqa: E402 +import charset # noqa: E402 import fbd_render # noqa: E402 +import ld_render # noqa: E402 import parse # noqa: E402 import parse_fbd # noqa: E402 import st_render # noqa: E402 @@ -31,7 +38,7 @@ def _pous(path): """Every graphical POU in the file, paired with its art renderer.""" found = [] for pou in parse.parse_pous(path): - found.append((pou, ascii_render)) + found.append((pou, ld_render)) for pou in parse_fbd.parse_pous(path): found.append((pou, fbd_render)) return found @@ -44,29 +51,51 @@ def render_file(path, output_format="both"): lines.extend(st_render.render_pou(pou)) lines.append("") if output_format in ("art", "both"): + rendered = art_renderer.render_pou(pou) if output_format == "both": # The diagram repeats the declaration, which is noise the # second time around. - lines.extend(art_renderer.render_pou(pou)[len(ascii_render.render_declaration(pou)) :]) - else: - lines.extend(art_renderer.render_pou(pou)) + rendered = rendered[len(ld_render.render_declaration(pou)) :] + lines.extend(rendered) lines.append("") return [line.rstrip() for line in lines] +def write(lines, stream=None): + """Write as UTF-8 bytes. + + A Windows console defaults to a codepage that cannot encode box drawing, + so going through print() would raise UnicodeEncodeError on exactly the + output this tool exists to produce. + """ + if stream is None: + stream = sys.stdout + buffer = getattr(stream, "buffer", stream) + for line in lines: + buffer.write((line + "\n").encode("utf-8")) + buffer.flush() + + def main(argv): output_format = "both" paths = [] index = 0 while index < len(argv): - if argv[index] == "--format": + argument = argv[index] + if argument == "--format": index += 1 if index >= len(argv) or argv[index] not in FORMATS: print("--format must be one of: " + ", ".join(FORMATS)) return 2 output_format = argv[index] + elif argument == "--charset": + index += 1 + if index >= len(argv) or argv[index] not in charset.SETS: + print("--charset must be one of: " + ", ".join(sorted(charset.SETS))) + return 2 + charset.use(argv[index]) else: - paths.append(argv[index]) + paths.append(argument) index += 1 if not paths: @@ -74,8 +103,7 @@ def main(argv): return 2 for path in paths: - for line in render_file(path, output_format): - print(line) + write(render_file(path, output_format)) return 0 diff --git a/tools/ladder/st_render.py b/tools/ladder/st_render.py index 2288417..3470735 100644 --- a/tools/ladder/st_render.py +++ b/tools/ladder/st_render.py @@ -11,7 +11,7 @@ matches what the rung does. """ -from ascii_render import render_declaration +from ld_render import render_declaration from model import BLOCK, COIL, Assign, Call, Element, Series, Signal from parse import expr_to_text diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt index 9441a27..0630c54 100644 --- a/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt @@ -11,22 +11,22 @@ END_VAR (* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) fbSystemSupply : ifmIOcommon.SystemSupply - +-----------------------------------------+ -ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15------|eChannel xError| -ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY--|eMode eDiagInfo| - |eFilter xPrepared| - | uiOutVoltage => uiCurrSupplyVolt| - +-----------------------------------------+ + ┌─────────────────────────────────────────┐ +ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15──────┤eChannel xError│ +ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY──┤eMode eDiagInfo│ + │eFilter xPrepared│ + │ uiOutVoltage => uiCurrSupplyVolt│ + └─────────────────────────────────────────┘ (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) fbSupplySwitch : ifmIOcommon.SupplySwitch - +-----------------------------------------+ -ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH--|eMode xError| - GT TOF_0 : TOF | | - +----------+ +-----------+ | | -uiCurrSupplyVolt--|In1 Out1|--|IN Q|-----|xValue eDiagInfo| -uiMinVoltage------|In2 | | | | xPrepared| - +----------+ | | +-----------------------------------------+ -T#5S----------------------------|PT ET| - +-----------+ + ┌─────────────────────────────────────────┐ +ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH──┤eMode xError│ + GT TOF_0 : TOF │ │ + ┌──────────┐ ┌───────────┐ │ │ +uiCurrSupplyVolt──┤In1 Out1├──┤IN Q├─────┤xValue eDiagInfo│ +uiMinVoltage──────┤In2 │ │ │ │ xPrepared│ + └──────────┘ │ │ └─────────────────────────────────────────┘ +T#5S────────────────────────────┤PT ET│ + └───────────┘ diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt index 2a61220..bc8a4ba 100644 --- a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt @@ -10,16 +10,16 @@ VAR END_VAR (* Network 1 *) -| Sensor1 Sensor2 PowerOn -|--+---| |---+---|/|------(S)-----| -| | sensor3 | -| +---| |---+ +│ Sensor1 Sensor2 PowerOn +├──┬───┤ ├───┬───┤/├──────(S)─────┤ +│ │ sensor3 │ +│ └───┤ ├───┘ (* Network 2 *) -| TON_0 : TON CTU_0 : CTU -| PowerOn +---------------+ +----------------------+ PowerOff -|-----| |----|IN Q|--|CU Q|----(R)------| -| |PT := T#5S ET| |RESET := PowerOff CV| -| +---------------+ |PV := 10 | -| +----------------------+ +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff +├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ +│ │PT := T#5S ET│ │RESET := PowerOff CV│ +│ └───────────────┘ │PV := 10 │ +│ └──────────────────────┘ diff --git a/tools/ladder/tests/fixtures/motor_control.expected.txt b/tools/ladder/tests/fixtures/motor_control.expected.txt index 9c49316..983b232 100644 --- a/tools/ladder/tests/fixtures/motor_control.expected.txt +++ b/tools/ladder/tests/fixtures/motor_control.expected.txt @@ -11,16 +11,16 @@ VAR END_VAR (* Network 1 *) -| Start_PB Stop_PB Motor_Run -|--+---| |-----+---|/|-------( )------| -| | Motor_Run | -| +----| |----+ +│ Start_PB Stop_PB Motor_Run +├──┬───┤ ├─────┬───┤/├───────( )──────┤ +│ │ Motor_Run │ +│ └────┤ ├────┘ (* Network 2 *) -| Fault_In Fault_Latch -|-----|P|---------(S)-------| +│ Fault_In Fault_Latch +├─────┤P├─────────(S)───────┤ (* Network 3 *) -| Reset_PB Ack Fault_Latch -|-----| |------|/|-------(R)-------| +│ Reset_PB Ack Fault_Latch +├─────┤ ├──────┤/├───────(R)───────┤ diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 4e205f3..7f92743 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -7,19 +7,26 @@ python tools/ladder/tests/test_fbd.py """ -from __future__ import print_function +from __future__ import print_function, unicode_literals +import io import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..")) +import charset # noqa: E402 import fbd_render # noqa: E402 import parse # noqa: E402 import parse_fbd # noqa: E402 import st_render # noqa: E402 from model import Call, Signal # noqa: E402 +from render import write # noqa: E402 + +# Referenced through the charset table rather than as literal glyphs: this +# source file has to stay pure ASCII for IronPython 2.7 to load it at all. +U = charset.UNICODE FIXTURES = os.path.join(HERE, "fixtures", "codesys") FBD_SOURCE = os.path.join(FIXTURES, "FbTesting.xml") @@ -42,16 +49,15 @@ def check_equal(name, actual, expected): def check_golden(name, rendered, golden_path): - handle = open(golden_path) + # Goldens hold box-drawing characters, so the encoding cannot be left to + # the platform default - and neither can printing them on a mismatch. + handle = io.open(golden_path, encoding="utf-8") try: expected = handle.read().replace("\r\n", "\n").rstrip("\n").split("\n") finally: handle.close() if rendered != expected: - print("--- expected ---") - print("\n".join(expected)) - print("--- actual ---") - print("\n".join(rendered)) + write(["--- expected ---"] + expected + ["--- actual ---"] + rendered) check_equal(name, rendered, expected) @@ -111,9 +117,14 @@ def check_golden(name, rendered, golden_path): art = fbd_render.render_pou(pou) check("art: no trailing whitespace", all(line == line.rstrip() for line in art)) -check("art: boxes do not fuse together", not any("++" in line for line in art)) +check("art: boxes do not fuse together", not any(U["TR"] + U["TL"] in line for line in art)) check("art: output assignment is drawn", any("uiOutVoltage => uiCurrSupplyVolt" in line for line in art)) -check("art: nested operator box is drawn", any("|In1 Out1|" in line for line in art)) +check("art: nested operator box is drawn", any(U["PIN_L"] + "In1 Out1" + U["PIN_R"] in line for line in art)) + +# A tee marks a real connection, so an unconsumed output must leave the wall +# unbroken. fbSupplySwitch is a network sink: nothing takes its xError. +check("art: sink output is not teed", any("xError" + U["V"] in line for line in art)) +check("art: consumed output is teed", any("Out1" + U["PIN_R"] in line for line in art)) # Every position in this export is x="0" y="0". If layout depended on those # coordinates the three boxes would land on top of each other, so finding each diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index ebed1ef..9fe263a 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -8,17 +8,20 @@ python tools/ladder/tests/test_ladder.py """ -from __future__ import print_function +from __future__ import print_function, unicode_literals +import io import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..")) -from ascii_render import render_pou # noqa: E402 +import charset # noqa: E402 +from ld_render import render_pou # noqa: E402 from model import COIL, CONTACT, Element, Parallel, Series # noqa: E402 from parse import parse_pous # noqa: E402 +from render import write # noqa: E402 FIXTURES = os.path.join(HERE, "fixtures") SOURCE = os.path.join(FIXTURES, "motor_control.plcopen.xml") @@ -85,8 +88,11 @@ def check_equal(name, actual, expected): # --- layout independence --------------------------------------------------- -with open(SOURCE) as handle: +handle = io.open(SOURCE, encoding="utf-8") +try: source_text = handle.read() +finally: + handle.close() # Shifting every element 500px right must not change a single character of # output. This is the property that keeps diffs meaningful. @@ -110,23 +116,49 @@ def check_equal(name, actual, expected): check("no trailing whitespace", all(line == line.rstrip() for line in rendered)) check("declaration comes first", rendered[0] == "PROGRAM Motor_Control") -check("seal-in branch is drawn", any("+----| |----+" in line for line in rendered)) -check("negated contact is drawn", any("|/|" in line for line in rendered)) -check("rising edge contact is drawn", any("|P|" in line for line in rendered)) + +# Referenced through the charset table rather than as literal glyphs: this +# source file has to stay pure ASCII for IronPython 2.7 to load it at all. +U = charset.UNICODE + +# The seal-in branch closes on its own row: a bottom-left corner, a contact, +# and a bottom-right corner. Matching the shape rather than an exact wire +# length keeps this from breaking every time a variable is renamed. +branch_rows = [line for line in rendered if U["BL"] in line and U["BR"] in line] +check_equal("exactly one branch closes", len(branch_rows), 1) +check("seal-in branch holds a contact", U["CONTACT_L"] in branch_rows[0]) +check("branch opens with a tee", any(U["T_DOWN"] in line for line in rendered)) +check("negated contact is drawn", any(U["CONTACT_L"] + "/" + U["CONTACT_R"] in line for line in rendered)) +check("rising edge contact is drawn", any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in line for line in rendered)) check("set coil is drawn", any("(S)" in line for line in rendered)) check("reset coil is drawn", any("(R)" in line for line in rendered)) + +# --- character sets -------------------------------------------------------- + +# The ASCII set exists for terminals and diff viewers that mangle box drawing, +# so its defining property is that nothing in the output is non-ASCII. +charset.use("ascii") +try: + ascii_rendered = render_pou(pou) +finally: + charset.use("unicode") + +check("ascii charset emits no non-ASCII", all(ord(ch) < 128 for line in ascii_rendered for ch in line)) +check("ascii charset still draws the branch", any("+----| |----+" in line for line in ascii_rendered)) +check("unicode is restored afterwards", any(U["V"] in line for line in render_pou(pou))) +check_equal("both charsets produce the same shape", len(ascii_rendered), len(rendered)) + def check_golden(name, rendered_lines, golden_path): - handle = open(golden_path) + # Goldens hold box-drawing characters, so the encoding cannot be left to + # the platform default - and neither can printing them on a mismatch. + handle = io.open(golden_path, encoding="utf-8") try: expected_lines = handle.read().replace("\r\n", "\n").rstrip("\n").split("\n") finally: handle.close() if rendered_lines != expected_lines: - print("--- expected ---") - print("\n".join(expected_lines)) - print("--- actual ---") - print("\n".join(rendered_lines)) + write(["--- expected ---"] + expected_lines + ["--- actual ---"] + rendered_lines) check_equal(name, rendered_lines, expected_lines) From bde8cf2550e33d665895b2e155e3546f236d48ac Mon Sep 17 00:00:00 2001 From: kehinde Date: Wed, 5 Aug 2026 13:42:02 +1000 Subject: [PATCH 04/91] render graphical POUs during Export To Files Wires the LD and FBD renderers into the export path. A graphical POU now exports its native xml as before, plus a .txt holding the equivalent Structured Text followed by the diagram. The .txt is derived and read-only. The native xml stays the only thing Import From Files reads, so the round trip is untouched. That contract rests on import_directory_child dispatching solely on .xml and .st, which a test now pins down along with a control asserting the native xml still imports - otherwise the test would pass for the wrong reason. The renderers move from tools/ladder into src/ rather than being copied, so there is one copy to maintain. That also puts them under the existing ASCII and IronPython CI checks, and into the import smoke test, which is the first real verification that the Python 2/3 common subset claim holds. Rendering goes via export_xml (PLCopen) rather than the native format, because PLCopen has a published schema for graphical bodies. The temp file is staged outside the export folder, since exports are swapped into place wholesale and a failed cleanup would otherwise ship the temp file too. A rendering failure is reported and skipped rather than raised: the native xml is already written and complete, and a diagram nobody can draw is not a reason to fail an otherwise good export. SFC and CFC parse to nothing renderable and are skipped the same way, rather than writing an empty file. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 3 + README.md | 29 +++++ {tools/ladder => src}/charset.py | 0 {tools/ladder => src}/fbd_render.py | 0 src/graphical_export.py | 90 +++++++++++++ src/import_export.py | 12 +- {tools/ladder => src}/layout.py | 0 {tools/ladder => src}/ld_render.py | 0 {tools/ladder => src}/model.py | 0 {tools/ladder => src}/parse_fbd.py | 0 tools/ladder/parse.py => src/parse_ld.py | 0 {tools/ladder => src}/plcopen.py | 0 {tools/ladder => src}/st_render.py | 2 +- tools/ci/import_smoke.py | 12 ++ tools/ladder/render.py | 8 +- tools/ladder/tests/test_export.py | 158 +++++++++++++++++++++++ tools/ladder/tests/test_fbd.py | 11 +- tools/ladder/tests/test_ladder.py | 5 +- 18 files changed, 316 insertions(+), 14 deletions(-) rename {tools/ladder => src}/charset.py (100%) rename {tools/ladder => src}/fbd_render.py (100%) create mode 100644 src/graphical_export.py rename {tools/ladder => src}/layout.py (100%) rename {tools/ladder => src}/ld_render.py (100%) rename {tools/ladder => src}/model.py (100%) rename {tools/ladder => src}/parse_fbd.py (100%) rename tools/ladder/parse.py => src/parse_ld.py (100%) rename {tools/ladder => src}/plcopen.py (100%) rename {tools/ladder => src}/st_render.py (99%) create mode 100644 tools/ladder/tests/test_export.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005b0dd..90ca154 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } .\ipy\net45\ipy.exe tools\ladder\tests\test_fbd.py if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + .\ipy\net45\ipy.exe tools\ladder\tests\test_export.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ladder: name: ladder @@ -52,3 +54,4 @@ jobs: run: | python3 tools/ladder/tests/test_ladder.py python3 tools/ladder/tests/test_fbd.py + python3 tools/ladder/tests/test_export.py diff --git a/README.md b/README.md index 35dad81..7285399 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,35 @@ Items are exported in formatted structured text (`.st`) where possible, and in n Actions and Transitions export as `.st` with the kind encoded in the filename (`MyPou.MyAction.action.st`, `MyPou.MyTransition.transition.st`). The file contains the implementation text only, as these objects have no textual declaration. +### Reading graphical POUs + +Ladder and Function Block Diagram POUs have no textual implementation, so they export as native xml that git can store but nobody can review. Alongside that xml, CODESCRIBE writes a `.txt` holding the equivalent Structured Text followed by a diagram: + +``` +(* Network 2 *) +TON_0(IN := PowerOn, PT := T#5S); +CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); +IF CTU_0.Q THEN PowerOff := FALSE; END_IF + +(* Network 2 *) +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff +├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ +│ │PT := T#5S ET│ │RESET := PowerOff CV│ +│ └───────────────┘ │PV := 10 │ +│ └──────────────────────┘ +``` + +This file is **derived and read-only**. The native xml remains the only thing `Import From Files` reads, so editing the `.txt` changes nothing — it exists to make diffs and code review possible. Layout comes from how the elements are wired, not from their coordinates, so moving a block in the CODESYS editor produces no diff. + +SFC and CFC POUs are not yet rendered; they export as native xml alone. + +To render an exported PLCopen file by hand, or to get plain ASCII instead of box drawing: + +``` +python tools/ladder/render.py --format st --charset ascii MyPou.xml +``` + Visualisations export as `.vis.xml`, so a `Main` visualisation cannot collide with a `Main` POU. Exports made with older versions of CODESCRIBE use different filenames for some of these objects; they still import correctly, and re-exporting once migrates the tracked files. See [CHANGELOG.md](CHANGELOG.md) for the details. diff --git a/tools/ladder/charset.py b/src/charset.py similarity index 100% rename from tools/ladder/charset.py rename to src/charset.py diff --git a/tools/ladder/fbd_render.py b/src/fbd_render.py similarity index 100% rename from tools/ladder/fbd_render.py rename to src/fbd_render.py diff --git a/src/graphical_export.py b/src/graphical_export.py new file mode 100644 index 0000000..b3d6c03 --- /dev/null +++ b/src/graphical_export.py @@ -0,0 +1,90 @@ +# REMEMBER: this is python 2.7 +"""Write a human-readable rendering of a graphical POU alongside its native xml. + +Graphical POUs (LD, FBD, SFC, CFC) have no textual implementation, so they +export as CODESYS native xml, which git can store but nobody can review. This +adds a derived .txt next to it: the ST equivalent followed by the diagram. + +The .txt is READ-ONLY as far as CODESCRIBE is concerned. The native xml stays +the only thing Import From Files reads, so the round trip is unaffected and +editing the .txt achieves nothing. import_from_files dispatches on ".xml" and +".st", so a ".txt" is ignored by construction. + +The rendering goes through PLCopen xml rather than the native format, because +PLCopen has a published schema for graphical bodies while the native format +does not. +""" + +import os +import tempfile + +import fbd_render +import ld_render +import parse_fbd +import parse_ld +import st_render +from util import open_utf8 + +# Suffix for the derived file. Deliberately not .st: these are not importable +# and must never be mistaken for source. +RENDERED_SUFFIX = ".txt" + + +def _render_pous(plcopen_path): + """(pou, art_renderer) for every POU in the file we know how to draw.""" + found = [] + for pou in parse_ld.parse_pous(plcopen_path): + found.append((pou, ld_render)) + for pou in parse_fbd.parse_pous(plcopen_path): + found.append((pou, fbd_render)) + return found + + +def render_plcopen(plcopen_path): + """Render every renderable POU in a PLCopen file. [] if there are none.""" + lines = [] + for pou, art_renderer in _render_pous(plcopen_path): + lines.extend(st_render.render_pou(pou)) + lines.append(u"") + # The diagram repeats the declaration, which is noise the second time. + declaration_length = len(ld_render.render_declaration(pou)) + lines.extend(art_renderer.render_pou(pou)[declaration_length:]) + lines.append(u"") + + while lines and lines[-1] == u"": + lines.pop() + return lines + + +def write_rendered_text(obj, base_path): + """Export obj as PLCopen xml, render it, and write .txt. + + Returns True if a file was written. SFC and CFC bodies parse to nothing + renderable, so they are skipped rather than producing an empty file. + + A rendering failure must not fail the export: the native xml has already + been written and is complete and correct on its own. The problem is + reported and the export carries on. + """ + # Staged outside the export folder: exports are written to a staging + # directory that gets swapped into place wholesale, and a temp file left + # behind by a failed cleanup would be swapped in along with it. + handle, temp_path = tempfile.mkstemp(suffix=".plcopen.xml") + os.close(handle) + try: + obj.export_xml(path=temp_path, recursive=False) + + lines = render_plcopen(temp_path) + if not lines: + return False + + with open_utf8(base_path + RENDERED_SUFFIX, "w") as f: + f.write(u"\n".join(lines)) + f.write(u"\n") + return True + except Exception as error: + print("WARNING: could not render " + obj.get_name() + ": " + repr(error)) + return False + finally: + if os.path.exists(temp_path): + os.remove(temp_path) diff --git a/src/import_export.py b/src/import_export.py index e9879e1..0d72d04 100644 --- a/src/import_export.py +++ b/src/import_export.py @@ -4,6 +4,7 @@ import scriptengine # type: ignore +from graphical_export import write_rendered_text from object_type import ObjectType, get_object_type from util import * @@ -101,6 +102,8 @@ def export_pou(child_obj, parent_obj, parent_folder_path, export_child_fn): write_st(child_obj, f) else: export_native(child_obj, parent_obj, parent_folder_path, export_child_fn) + # Derived, review-only. The native xml above stays the import source. + write_rendered_text(child_obj, os.path.join(parent_folder_path, child_obj.get_name())) for c in child_obj.get_children(): export_child_fn(c, child_obj, parent_folder_path) @@ -200,11 +203,9 @@ def export_method(child_obj, parent_obj, parent_folder_path, export_child_fn): ) as f: write_st(child_obj, f) else: - write_native( - child_obj, - os.path.join(parent_folder_path, parent_obj.get_name() + "." + child_obj.get_name() + ".xml"), - recursive=False, - ) + base = os.path.join(parent_folder_path, parent_obj.get_name() + "." + child_obj.get_name()) + write_native(child_obj, base + ".xml", recursive=False) + write_rendered_text(child_obj, base) def import_method_st(child, dir_path, dir_parent_obj, import_dir_fn): @@ -232,6 +233,7 @@ def _export_member_st_or_xml(child_obj, parent_obj, parent_folder_path, st_suffi f.write(child_obj.textual_implementation.text) else: write_native(child_obj, base + ".xml", recursive=False) + write_rendered_text(child_obj, base) def export_action(child_obj, parent_obj, parent_folder_path, export_child_fn): diff --git a/tools/ladder/layout.py b/src/layout.py similarity index 100% rename from tools/ladder/layout.py rename to src/layout.py diff --git a/tools/ladder/ld_render.py b/src/ld_render.py similarity index 100% rename from tools/ladder/ld_render.py rename to src/ld_render.py diff --git a/tools/ladder/model.py b/src/model.py similarity index 100% rename from tools/ladder/model.py rename to src/model.py diff --git a/tools/ladder/parse_fbd.py b/src/parse_fbd.py similarity index 100% rename from tools/ladder/parse_fbd.py rename to src/parse_fbd.py diff --git a/tools/ladder/parse.py b/src/parse_ld.py similarity index 100% rename from tools/ladder/parse.py rename to src/parse_ld.py diff --git a/tools/ladder/plcopen.py b/src/plcopen.py similarity index 100% rename from tools/ladder/plcopen.py rename to src/plcopen.py diff --git a/tools/ladder/st_render.py b/src/st_render.py similarity index 99% rename from tools/ladder/st_render.py rename to src/st_render.py index 3470735..7e908c7 100644 --- a/tools/ladder/st_render.py +++ b/src/st_render.py @@ -13,7 +13,7 @@ from ld_render import render_declaration from model import BLOCK, COIL, Assign, Call, Element, Series, Signal -from parse import expr_to_text +from parse_ld import expr_to_text def _coil_statement(coil, condition): diff --git a/tools/ci/import_smoke.py b/tools/ci/import_smoke.py index 861c50a..5751b7c 100644 --- a/tools/ci/import_smoke.py +++ b/tools/ci/import_smoke.py @@ -26,6 +26,18 @@ "device_tree_import_export", "import_from_files", "project_template", + # Renderers for graphical POUs. No scriptengine dependency of their own, + # but they have to load under IronPython 2.7 like everything else here. + "charset", + "layout", + "model", + "plcopen", + "parse_ld", + "parse_fbd", + "ld_render", + "fbd_render", + "st_render", + "graphical_export", ] failures = [] diff --git a/tools/ladder/render.py b/tools/ladder/render.py index 540961f..b09c67d 100644 --- a/tools/ladder/render.py +++ b/tools/ladder/render.py @@ -22,12 +22,14 @@ import os import sys -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +HERE = os.path.dirname(os.path.abspath(__file__)) +# The renderers live in src/, alongside the CODESYS export scripts. +sys.path.insert(0, os.path.join(HERE, "..", "..", "src")) import charset # noqa: E402 import fbd_render # noqa: E402 import ld_render # noqa: E402 -import parse # noqa: E402 +import parse_ld # noqa: E402 import parse_fbd # noqa: E402 import st_render # noqa: E402 @@ -37,7 +39,7 @@ def _pous(path): """Every graphical POU in the file, paired with its art renderer.""" found = [] - for pou in parse.parse_pous(path): + for pou in parse_ld.parse_pous(path): found.append((pou, ld_render)) for pou in parse_fbd.parse_pous(path): found.append((pou, fbd_render)) diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py new file mode 100644 index 0000000..cb62299 --- /dev/null +++ b/tools/ladder/tests/test_export.py @@ -0,0 +1,158 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Tests for the export-path bridge and the derived file's contract. + +The renderers being correct is not enough: the derived .txt must not disturb +Export To Files / Import From Files. These cover the parts that would break a +working project rather than just produce an ugly diagram. + + python tools/ladder/tests/test_export.py +""" + +from __future__ import print_function, unicode_literals + +import io +import os +import shutil +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.join(HERE, "..", "..", "..") +sys.path.insert(0, os.path.join(REPO, "src")) +sys.path.insert(0, os.path.join(REPO, "tools", "ci")) # stubbed scriptengine + +import graphical_export # noqa: E402 +import import_from_files # noqa: E402 + +FIXTURES = os.path.join(HERE, "fixtures", "codesys") + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + else: + failures.append(name) + print("FAIL " + name + ((": " + detail) if detail else "")) + + +def check_equal(name, actual, expected): + check(name, actual == expected, "expected %r, got %r" % (expected, actual)) + + +class FakePou(object): + """Stands in for a CODESYS ScriptObject. + + export_xml hands back a fixture instead of talking to CODESYS, which is + exactly what the real call does from this module's point of view. + """ + + def __init__(self, name, source=None): + self._name = name + self._source = source + self.export_calls = [] + + def get_name(self): + return self._name + + def export_xml(self, path, recursive): + self.export_calls.append((path, recursive)) + if self._source is None: + raise RuntimeError("export_xml exploded") + shutil.copyfile(self._source, path) + + +class RecordingParent(object): + """Records anything the importer tries to do to the project.""" + + def __init__(self): + self.calls = [] + + def __getattr__(self, name): + def record(*args, **kwargs): + self.calls.append(name) + return RecordingParent() + + return record + + +def read(path): + handle = io.open(path, encoding="utf-8") + try: + return handle.read() + finally: + handle.close() + + +# --- the derived file is written next to the native xml -------------------- + +workspace = tempfile.mkdtemp() +try: + base = os.path.join(workspace, "LD_TEST") + pou = FakePou("LD_TEST", os.path.join(FIXTURES, "LDTesting.xml")) + + check("ladder pou is rendered", graphical_export.write_rendered_text(pou, base) is True) + check("derived file lands beside the xml", os.path.exists(base + ".txt")) + check_equal("export_xml is asked for a single object", pou.export_calls[0][1], False) + + content = read(base + ".txt") + check("derived file leads with ST", content.startswith("PROGRAM LD_TEST")) + check("derived file contains the ST equivalent", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" in content) + check("derived file contains the diagram", "TON_0 : TON" in content) + check("declaration is not repeated", content.count("END_VAR") == 1) + check("derived file ends with a newline", content.endswith("\n")) + + # The temp PLCopen file is staged outside the export folder, so nothing but + # the rendering may appear next to the native xml. + check_equal("no stray files left behind", sorted(os.listdir(workspace)), ["LD_TEST.txt"]) + + # --- languages we cannot draw are skipped, not written empty ------------ + + sfc_base = os.path.join(workspace, "SFC_TEST") + sfc = FakePou("SFC_TEST", os.path.join(FIXTURES, "SFCTesting.xml")) + check("sfc reports nothing rendered", graphical_export.write_rendered_text(sfc, sfc_base) is False) + check("sfc writes no empty file", not os.path.exists(sfc_base + ".txt")) + + # --- a rendering failure must not fail the export ----------------------- + + broken_base = os.path.join(workspace, "BROKEN") + broken = FakePou("BROKEN", None) + check("a broken export is reported, not raised", graphical_export.write_rendered_text(broken, broken_base) is False) + check("broken pou writes no file", not os.path.exists(broken_base + ".txt")) +finally: + shutil.rmtree(workspace) + + +# --- the importer ignores the derived file --------------------------------- + +# This is the contract that keeps the round trip intact. import_directory_child +# dispatches on ".xml" and ".st"; a ".txt" matches no branch. Asserting it here +# means a later change to that dispatch cannot silently start importing +# derived files. +workspace = tempfile.mkdtemp() +try: + for name in ("Main.txt", "Main.Method.txt", "Main.gvl.txt"): + handle = io.open(os.path.join(workspace, name), "w", encoding="utf-8") + handle.write("PROGRAM Main\n") + handle.close() + + parent = RecordingParent() + import_from_files.import_directory_child(name, workspace, parent) + check_equal("importer ignores " + name, parent.calls, []) + + # A control: the native xml alongside it must still import, or the test + # above would pass for the wrong reason. + shutil.copyfile(os.path.join(FIXTURES, "LDTesting.xml"), os.path.join(workspace, "Main.xml")) + parent = RecordingParent() + import_from_files.import_directory_child("Main.xml", workspace, parent) + check_equal("native xml still imports", parent.calls, ["import_native"]) +finally: + shutil.rmtree(workspace) + +print("") +if failures: + print("%d check(s) failed" % len(failures)) +else: + print("all checks passed") +sys.exit(1 if failures else 0) diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 7f92743..22939d5 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -14,11 +14,14 @@ import sys HERE = os.path.dirname(os.path.abspath(__file__)) +# The renderers live in src/ so CODESYS can load them; tools/ladder keeps only +# the dev CLI and these tests. +sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) sys.path.insert(0, os.path.join(HERE, "..")) import charset # noqa: E402 import fbd_render # noqa: E402 -import parse # noqa: E402 +import parse_ld # noqa: E402 import parse_fbd # noqa: E402 import st_render # noqa: E402 from model import Call, Signal # noqa: E402 @@ -157,7 +160,7 @@ def check_golden(name, rendered, golden_path): check_golden("st: FBD golden matches", fbd_st, os.path.join(FIXTURES, "FbTesting.st.expected.txt")) -ld_pou = parse.parse_pous(LD_SOURCE)[0] +ld_pou = parse_ld.parse_pous(LD_SOURCE)[0] ld_st = st_render.render_pou(ld_pou) check("st: parallel branch becomes OR", "IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF" in ld_st) check("st: ladder block becomes a call", "TON_0(IN := PowerOn, PT := T#5S);" in ld_st) @@ -168,9 +171,9 @@ def check_golden(name, rendered, golden_path): # --- language dispatch ----------------------------------------------------- -check_equal("LD parser ignores FBD bodies", parse.parse_pous(FBD_SOURCE), []) +check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) check_equal("FBD parser ignores LD bodies", parse_fbd.parse_pous(LD_SOURCE), []) -check_equal("SFC is skipped by both", parse.parse_pous(SFC_SOURCE) + parse_fbd.parse_pous(SFC_SOURCE), []) +check_equal("SFC is skipped by both", parse_ld.parse_pous(SFC_SOURCE) + parse_fbd.parse_pous(SFC_SOURCE), []) print("") if failures: diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 9fe263a..dfcd78f 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -15,12 +15,15 @@ import sys HERE = os.path.dirname(os.path.abspath(__file__)) +# The renderers live in src/ so CODESYS can load them; tools/ladder keeps only +# the dev CLI and these tests. +sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) sys.path.insert(0, os.path.join(HERE, "..")) import charset # noqa: E402 from ld_render import render_pou # noqa: E402 from model import COIL, CONTACT, Element, Parallel, Series # noqa: E402 -from parse import parse_pous # noqa: E402 +from parse_ld import parse_pous # noqa: E402 from render import write # noqa: E402 FIXTURES = os.path.join(HERE, "fixtures") From 23e8dd47a7c4caf60559452e8179409d6648a1c2 Mon Sep 17 00:00:00 2001 From: kehinde Date: Wed, 5 Aug 2026 15:23:38 +1000 Subject: [PATCH 05/91] add a script to diagnose PLCopen parsing inside CODESYS Every graphical POU fails to render in a real CODESYS run with "Syntax error at line 1: illegal data at start of file". That is not an ElementTree message - it is xmllib's - and CODESYS logs a DeprecationWarning for its own ScriptLib copy of xmllib on startup, so the xml package being imported is probably not the standard library one. The competing explanation is the UTF-8 BOM that CODESYS writes, which older parsers reject with exactly that message and which expat accepts silently - which is why this passes under CPython and fails in CODESYS. The two have different fixes, so this reports which xml module resolves, how it copes with a BOM through both fromstring and parse, and what the first bytes of a real export_xml file are. Read-only; changes nothing. Co-Authored-By: Claude Opus 5 --- src/script_diagnose_xml.py | 153 +++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/script_diagnose_xml.py diff --git a/src/script_diagnose_xml.py b/src/script_diagnose_xml.py new file mode 100644 index 0000000..a158ae1 --- /dev/null +++ b/src/script_diagnose_xml.py @@ -0,0 +1,153 @@ +# REMEMBER: this is python 2.7 +"""Diagnose why PLCopen rendering fails inside CODESYS. + +Run this the same way as the other scripts (Tools > Scripting > Execute Script +File, or add it as a toolbar command) with the affected project open. It writes +everything to the message view and changes nothing. + +It answers three questions: + + 1. Which xml module is actually being imported? CODESYS ships its own XML + modules in ScriptLib, which is on sys.path and can shadow the standard + library. + 2. Can that module parse a trivial document, with and without a UTF-8 BOM? + CODESYS writes a BOM, and older parsers reject it as "illegal data at + start of file". + 3. What do the first bytes of a real export_xml file actually look like? +""" + +from __future__ import print_function + +import os +import sys +import tempfile + +import scriptengine # type: ignore + +from util import print_python_version + +PLAIN = b'hi' +WITH_BOM = b"\xef\xbb\xbf" + PLAIN + + +def report(label, value): + print(" " + label.ljust(28) + str(value)) + + +def probe_parser(): + print("--- xml module ---") + try: + import xml + + report("xml.__file__", getattr(xml, "__file__", "")) + report("xml.__path__", getattr(xml, "__path__", "")) + except Exception as error: + report("import xml FAILED", repr(error)) + + try: + import xml.etree.ElementTree as ET + + report("ElementTree.__file__", getattr(ET, "__file__", "")) + report("ElementTree.VERSION", getattr(ET, "VERSION", "")) + except Exception as error: + report("import ElementTree FAILED", repr(error)) + return None + + for label, data in (("without BOM", PLAIN), ("with BOM", WITH_BOM)): + try: + root = ET.fromstring(data) + report("fromstring " + label, "OK, root tag " + repr(root.tag)) + except Exception as error: + report("fromstring " + label, "FAILED " + repr(error)) + + # parse() takes a different path to fromstring() in some implementations, + # and parse() is what the renderer actually uses. + for label, data in (("without BOM", PLAIN), ("with BOM", WITH_BOM)): + handle, path = tempfile.mkstemp(suffix=".xml") + try: + os.write(handle, data) + os.close(handle) + tree = ET.parse(path) + report("parse " + label, "OK, root tag " + repr(tree.getroot().tag)) + except Exception as error: + report("parse " + label, "FAILED " + repr(error)) + finally: + if os.path.exists(path): + os.remove(path) + + return ET + + +def find_graphical_object(obj, depth=0): + """First object with no textual implementation - i.e. a graphical one.""" + if depth > 12: + return None + try: + children = obj.get_children() + except Exception: + return None + for child in children: + try: + if child.has_textual_implementation is False: + return child + except Exception: + pass + found = find_graphical_object(child, depth + 1) + if found is not None: + return found + return None + + +def probe_export(ET): + print("--- a real export_xml file ---") + project = scriptengine.projects.primary + if project is None: + report("project", "none open - open the affected project and re-run") + return + + target = find_graphical_object(project) + if target is None: + report("graphical object", "none found") + return + + report("object", target.get_name()) + + handle, path = tempfile.mkstemp(suffix=".plcopen.xml") + os.close(handle) + try: + target.export_xml(path=path, recursive=False) + size = os.path.getsize(path) + report("bytes written", size) + if size == 0: + report("verdict", "export_xml wrote an EMPTY file") + return + + f = open(path, "rb") + try: + head = f.read(160) + finally: + f.close() + report("first bytes", repr(head)) + report("starts with BOM", head[:3] == b"\xef\xbb\xbf") + + if ET is not None: + try: + ET.parse(path) + report("parse of real file", "OK") + except Exception as error: + report("parse of real file", "FAILED " + repr(error)) + except Exception as error: + report("export_xml FAILED", repr(error)) + finally: + if os.path.exists(path): + os.remove(path) + + +print("=== codescribe xml diagnosis ===") +print_python_version() +element_tree = probe_parser() +probe_export(element_tree) +print("--- sys.path ---") +for entry in sys.path: + print(" " + str(entry)) +print("=== end ===") From fcc94ef66fa1d9f888c88ea6a8123be41573a9ed Mon Sep 17 00:00:00 2001 From: kehinde Date: Wed, 5 Aug 2026 15:30:14 +1000 Subject: [PATCH 06/91] strip the byte order mark before parsing PLCopen xml Every graphical POU failed to render in a real CODESYS run: WARNING: could not render SYSTEM_ALARMS: Error('Syntax error at line 1: illegal data at start of file',) CODESYS puts its own ScriptLib on sys.path ahead of the standard library, so "import xml.etree.ElementTree" resolves to the copy CODESYS ships rather than IronPython's. That module is otherwise fine - it parses correctly with both fromstring and parse - but it rejects a UTF-8 BOM, and export_xml writes one on every file. Slicing to the first "<" removes the BOM however it happens to be represented, along with any leading whitespace. Nothing before the first tag can be XML anyway. io.open is used for the read so a binary read yields real bytes under IronPython too. No CI run could have caught this. The fixtures already carried BOMs, but both CPython's expat and stock IronPython accept them silently - only the ElementTree inside CODESYS is strict, and CI does not have ScriptLib. The new tests therefore assert on the bytes handed to the parser rather than on whether a parse succeeds, which is checkable anywhere. Co-Authored-By: Claude Opus 5 --- src/plcopen.py | 37 ++++++++++++++++++++++++++++++- tools/ladder/tests/test_ladder.py | 34 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/plcopen.py b/src/plcopen.py index 6f7b1d7..ba2734c 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -7,6 +7,7 @@ tag names survives all of it. """ +import io import xml.etree.ElementTree as ET from model import Connection @@ -181,9 +182,43 @@ def parse_interface(interface_elem): return variables +def read_document(source): + """Document bytes, with anything before the first tag removed. + + CODESYS writes a UTF-8 BOM on every export_xml file, and the ElementTree + that CODESYS ships in ScriptLib rejects it outright: + + Error('Syntax error at line 1: illegal data at start of file',) + + CPython's expat accepts a BOM silently, so this is invisible outside + CODESYS. Slicing to the first "<" handles the BOM however it is + represented, plus any stray leading whitespace, in one step - nothing + before the first tag can be XML anyway. + + ``source`` is a path or a file object. io.open is used rather than the + builtin so a binary read returns real bytes under IronPython too. + """ + if hasattr(source, "read"): + data = source.read() + else: + handle = io.open(source, "rb") + try: + data = handle.read() + finally: + handle.close() + + if not isinstance(data, bytes): + data = data.encode("utf-8") + + start = data.find(b"<") + if start > 0: + data = data[start:] + return data + + def iter_bodies(source): """Yield (pou_elem, language, body_elem) for every POU with an implementation.""" - root = ET.parse(source).getroot() + root = ET.fromstring(read_document(source)) for elem in root.iter(): if tag(elem) != "pou": continue diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index dfcd78f..af789cf 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -168,6 +168,40 @@ def check_golden(name, rendered_lines, golden_path): check_golden("golden output matches", rendered, EXPECTED) +# --- byte order mark ------------------------------------------------------- + +# CODESYS writes a BOM on every export_xml file, and the ElementTree it ships +# in ScriptLib rejects one outright. CPython's expat accepts it silently, and +# so does stock IronPython, so no amount of CI could catch this by parsing +# alone - it only reproduces inside CODESYS. Asserting on the bytes handed to +# the parser is what makes it catchable here. +import plcopen # noqa: E402 + +BOM = b"\xef\xbb\xbf" +CODESYS_FIXTURES = os.path.join(FIXTURES, "codesys") + +for name in sorted(os.listdir(CODESYS_FIXTURES)): + if not name.endswith(".xml"): + continue + path = os.path.join(CODESYS_FIXTURES, name) + raw = open(path, "rb").read() + # The fixtures are real exports, so they should still carry their BOM. If + # one loses it, this test stops proving anything. + check(name + " is a real export, BOM and all", raw.startswith(BOM)) + check_equal(name + " is fed to the parser without its BOM", plcopen.read_document(path)[:1], b"<") + +check_equal( + "leading whitespace is dropped too", + plcopen.read_document(io.BytesIO(BOM + b"\n ")), + b"", +) +check_equal( + "a document with no BOM is untouched", + plcopen.read_document(io.BytesIO(b"")), + b"", +) + + # --- real CODESYS export --------------------------------------------------- # Exported from CODESYS V3.5 SP11 via Project > Export > PLCopenXML. This is From d36f7f9afc4a3db857803051d7cc2fecd211fffc Mon Sep 17 00:00:00 2001 From: kehinde Date: Wed, 5 Aug 2026 15:44:52 +1000 Subject: [PATCH 07/91] escape non-ASCII before handing PLCopen xml to the parser With the BOM fixed, one POU out of the project still failed: WARNING: could not render ENGINE_TX_INPUT_MAPPING: Error('Syntax error at line 216: illegal character in content',) The ElementTree CODESYS ships works byte-wise and rejects UTF-8 multi-byte sequences, so a single degree sign or accented character in a comment loses the whole POU. Rewriting non-ASCII as XML numeric character references gives the parser pure ASCII; every parser expands the references back to the same characters, so the parsed result is unchanged. There is a test asserting the character survives the round trip rather than just that parsing succeeds. Safe as a blanket transform because PLCopen exports have no CDATA sections, which are the one place a numeric reference would stay literal text. Checked against all three real exports. A rendering failure now also reports the control and non-ASCII characters it found, with line and column, so the next failure explains itself instead of needing another diagnostic run. Control characters are called out separately because XML 1.0 forbids them outright - they cannot be escaped, and a document containing one is malformed at the source. None of the fixtures contained a single non-ASCII byte, which is why this was invisible until a real project hit it. Co-Authored-By: Claude Opus 5 --- src/graphical_export.py | 6 +++ src/plcopen.py | 72 ++++++++++++++++++++++++++++++- tools/ladder/tests/test_ladder.py | 52 ++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/graphical_export.py b/src/graphical_export.py index b3d6c03..ad72fe8 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -22,6 +22,7 @@ import ld_render import parse_fbd import parse_ld +import plcopen import st_render from util import open_utf8 @@ -84,6 +85,11 @@ def write_rendered_text(obj, base_path): return True except Exception as error: print("WARNING: could not render " + obj.get_name() + ": " + repr(error)) + # Say what is actually in the file, so a failure explains itself + # instead of needing a separate diagnostic run. + if os.path.exists(temp_path): + for note in plcopen.describe_suspect_characters(temp_path): + print(" " + note) return False finally: if os.path.exists(temp_path): diff --git a/src/plcopen.py b/src/plcopen.py index ba2734c..ab80305 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -213,7 +213,77 @@ def read_document(source): start = data.find(b"<") if start > 0: data = data[start:] - return data + return _to_ascii(data) + + +def _to_ascii(data): + """Replace non-ASCII characters with XML numeric character references. + + The ElementTree CODESYS ships works byte-wise and rejects UTF-8 multi-byte + sequences outright: + + Error('Syntax error at line 216: illegal character in content',) + + A numeric reference is plain ASCII, and every parser expands it back to + the same character, so the parsed result is identical while the bytes + handed to the parser are safe. One degree sign in a comment is enough to + lose a whole POU otherwise. + + Safe as a blanket transform because PLCopen exports contain no CDATA + sections, which are the one place a numeric reference would stay literal + text instead of being expanded. + """ + if not any(byte > 0x7F for byte in bytearray(data)): + return data + + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + # Not valid UTF-8 despite the declaration. latin-1 cannot fail, and + # preserves every byte as a character so nothing is lost. + text = data.decode("latin-1") + + pieces = [] + for character in text: + if ord(character) < 128: + pieces.append(character) + else: + pieces.append("&#%d;" % ord(character)) + return "".join(pieces).encode("ascii") + + +# XML 1.0 forbids these outright - they cannot even be written as a numeric +# reference, so a document containing one is malformed at the source. +_LEGAL_CONTROL = (0x09, 0x0A, 0x0D) + + +def describe_suspect_characters(source, limit=5): + """Characters likely to make a parser reject the document. + + Reported on a rendering failure so the next run explains itself, rather + than needing another round of manual diagnosis. + """ + try: + handle = io.open(source, "rb") + try: + raw = handle.read() + finally: + handle.close() + except (IOError, OSError) as error: + return ["could not re-read the file: " + repr(error)] + + notes = [] + for line_number, line in enumerate(raw.split(b"\n"), 1): + for column, byte in enumerate(bytearray(line), 1): + if byte < 0x20 and byte not in _LEGAL_CONTROL: + notes.append( + "line %d column %d: control character 0x%02X, illegal in XML 1.0" % (line_number, column, byte) + ) + elif byte > 0x7F: + notes.append("line %d column %d: non-ASCII byte 0x%02X" % (line_number, column, byte)) + if len(notes) >= limit: + return notes + return notes def iter_bodies(source): diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index af789cf..57d8f29 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -202,6 +202,58 @@ def check_golden(name, rendered_lines, golden_path): ) +# --- non-ASCII content ----------------------------------------------------- + +# The parser CODESYS ships works byte-wise and rejects UTF-8 multi-byte +# sequences, so one degree sign in a comment loses the whole POU. Numeric +# character references are ASCII and every parser expands them identically. +DEGREE = b'Temp \xc2\xb0C' + +check_equal( + "non-ASCII becomes a numeric character reference", + plcopen.read_document(io.BytesIO(DEGREE)), + b"Temp °C", +) +check("escaped bytes are pure ASCII", all(b < 128 for b in bytearray(plcopen.read_document(io.BytesIO(DEGREE))))) + +# The whole point: the parsed text must come back unchanged. +import xml.etree.ElementTree as ET # noqa: E402 + +check_equal( + "the character survives the round trip", + ET.fromstring(plcopen.read_document(io.BytesIO(DEGREE)))[0].text, + u"Temp \u00b0C", +) +check_equal( + "pure ASCII documents are left alone", + plcopen.read_document(io.BytesIO(b"plain")), + b"plain", +) + +# A failure has to explain itself, so the next CODESYS run needs no separate +# diagnostic script. +handle = io.open(os.path.join(FIXTURES, "codesys", "LDTesting.xml"), "rb") +try: + clean = handle.read() +finally: + handle.close() + +import tempfile # noqa: E402 + +descriptor, suspect_path = tempfile.mkstemp(suffix=".xml") +try: + os.write(descriptor, clean.replace(b" Export > PLCopenXML. This is From 7b0244f311fb64baad6689f85c27580ac93cb0ff Mon Sep 17 00:00:00 2001 From: kehinde Date: Wed, 5 Aug 2026 15:57:08 +1000 Subject: [PATCH 08/91] stop silently dropping jumps, inline ST and negated inputs A real FBD program rendered without errors but with logic missing, which is worse than the parse failures it replaced - the output looked complete. Three things were being dropped: * A jump was not treated as something that can terminate a network, so the whole guard network feeding it vanished. In the program that surfaced this, that was the clause short-circuiting the entire POU when the device was uninitialised or in E-stop. * An EXECUTE box carries its whole body as inline ST in addData. The box was drawn empty, losing a dozen statements while still looking plausible. * inVariable negated="true" was ignored, which inverts the logic rather than merely omitting it. Labels are now rendered too, so a reader can see where a jump lands. Operators additionally render infix where that is how they read in ST - "RawPressure / 100" rather than "DIV(RawPressure, 100)", and "(NOT xInitDone) OR (Mode.Current = Mode.ESTOP)". Conversions like REAL_TO_UINT stay function calls, because that is how they read in ST too. Operands containing a space are bracketed: redundant brackets beat an expression that reads correctly and groups wrongly. The fixture is hand-authored and modelled on the customer program that surfaced this rather than copied from it, so no customer logic enters the repo. Co-Authored-By: Claude Opus 5 --- src/fbd_render.py | 32 +++- src/model.py | 50 ++++- src/parse_fbd.py | 51 +++-- src/plcopen.py | 19 ++ src/st_render.py | 70 ++++++- .../codesys/FbTesting.st.expected.txt | 2 +- .../fixtures/fbd_control_flow.plcopen.xml | 175 ++++++++++++++++++ tools/ladder/tests/test_fbd.py | 45 ++++- 8 files changed, 414 insertions(+), 30 deletions(-) create mode 100644 tools/ladder/tests/fixtures/fbd_control_flow.plcopen.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 8de4ae7..91b16b6 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -12,11 +12,28 @@ import charset from layout import Block, stack from ld_render import render_declaration -from model import Assign, Call, Signal +from model import Assign, Call, Jump, Label, Signal def _render_signal(node): - return Block([node.label or ""], 0) + return Block([node.text], 0) + + +def _render_label(node): + return Block(["(* label: " + node.name + " *)"], 0) + + +def _render_jump(node): + chars = charset.active() + tail = chars["H"] * 3 + ">> " + (node.target or "?") + if node.condition is None: + return Block([tail], 0) + source = _render(node.condition) + lines = source.padded(source.width) + out = [] + for index, line in enumerate(lines): + out.append(line + tail if index == source.connect_row else line) + return Block(out, source.connect_row) def _render_assign(node): @@ -156,13 +173,22 @@ def _render(node): return _render_call(node) if isinstance(node, Assign): return _render_assign(node) + if isinstance(node, Jump): + return _render_jump(node) + if isinstance(node, Label): + return _render_label(node) if isinstance(node, Signal): return _render_signal(node) raise TypeError("cannot render %r" % (node,)) def render_network(tree): - return _render(tree).lines + lines = _render(node=tree).lines + # An EXECUTE box's body is the logic; drawing the box without it would be + # an empty rectangle where a dozen lines of ST should be. + if isinstance(tree, Call) and tree.st_code: + lines = lines + [""] + [" " + line for line in tree.st_code] + return lines def render_pou(pou): diff --git a/src/model.py b/src/model.py index 2e7a1ef..dd9d934 100644 --- a/src/model.py +++ b/src/model.py @@ -60,7 +60,9 @@ def __init__( type_name=None, instance_name=None, outputs=None, + st_code=None, ): + self.st_code = st_code if st_code is not None else [] # blocks only: inline ST self.local_id = local_id self.kind = kind self.label = label @@ -106,13 +108,43 @@ def __init__(self, name, pou_type, variables=None, rungs=None, networks=None, la class Signal(object): - """A named value entering a network: a variable, a literal, or nothing.""" + """A named value entering a network: a variable, a literal, or nothing. - def __init__(self, label): + CODESYS can negate an inVariable in place, which is easy to miss and + inverts the logic if it is dropped. + """ + + def __init__(self, label, negated=False): self.label = label + self.negated = negated + + @property + def text(self): + return ("NOT " + (self.label or "")) if self.negated else (self.label or "") + + def __repr__(self): + return "Signal(%r, negated=%r)" % (self.label, self.negated) + + +class Jump(object): + """A conditional jump to a label. Terminates its network.""" + + def __init__(self, target, condition=None): + self.target = target + self.condition = condition def __repr__(self): - return "Signal(%r)" % (self.label,) + return "Jump(%r)" % (self.target,) + + +class Label(object): + """A jump target. Marks a point in the network order, carries no logic.""" + + def __init__(self, name): + self.name = name + + def __repr__(self): + return "Label(%r)" % (self.name,) class Call(object): @@ -124,13 +156,23 @@ class Call(object): """ def __init__( - self, type_name=None, instance_name=None, inputs=None, outputs=None, active_output=None, output_wired=False + self, + type_name=None, + instance_name=None, + inputs=None, + outputs=None, + active_output=None, + output_wired=False, + st_code=None, ): self.type_name = type_name self.instance_name = instance_name self.inputs = inputs if inputs is not None else [] self.outputs = outputs if outputs is not None else [] self.active_output = active_output + # An EXECUTE box carries inline ST as its whole body. Dropping it loses + # the logic entirely while still drawing a plausible-looking box. + self.st_code = st_code if st_code is not None else [] # True when something downstream consumes the active output. A network # sink has an active output but nothing to hand it to. self.output_wired = output_wired diff --git a/src/parse_fbd.py b/src/parse_fbd.py index d6fec94..e1269d4 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -6,14 +6,16 @@ rather than a series/parallel chain. """ -from model import BLOCK, Assign, Call, Node, Pou, Signal +from model import BLOCK, Assign, Call, Jump, Label, Node, Pou, Signal from plcopen import ( block_connections, block_outputs, + block_st_code, child_text, comment_text, direct_connections, find_child, + is_true, iter_bodies, parse_interface, tag, @@ -22,13 +24,18 @@ COMMENT = "comment" IN_VARIABLE = "inVariable" OUT_VARIABLE = "outVariable" +JUMP = "jump" +LABEL = "label" +RETURN = "return" # vendorElement carries CODESYS editor state (network titles, implementation # attributes) and holds no logic, so it is skipped entirely. -FBD_KINDS = (BLOCK, IN_VARIABLE, OUT_VARIABLE, COMMENT, "jump", "return", "label", "continuation", "connector") +FBD_KINDS = (BLOCK, IN_VARIABLE, OUT_VARIABLE, COMMENT, JUMP, RETURN, LABEL, "continuation", "connector") -# Elements that can terminate a network. -SINK_KINDS = (BLOCK, OUT_VARIABLE) +# Elements that can terminate a network. A jump or return ends one just as +# surely as an assignment does - leaving them out drops the entire guard +# network they belong to, silently. +SINK_KINDS = (BLOCK, OUT_VARIABLE, JUMP, RETURN, LABEL) def parse_fbd_body(body_elem): @@ -49,20 +56,24 @@ def parse_fbd_body(body_elem): is_block = kind == BLOCK if is_block: label = child.get("instanceName") or child.get("typeName") + elif kind in (JUMP, LABEL): + # Both carry their target in a "label" attribute, not a child. + label = child.get("label") else: label = child_text(child, "expression") - nodes.append( - Node( - local_id=local_id, - kind=kind, - label=label, - inputs=block_connections(child) if is_block else direct_connections(child), - type_name=child.get("typeName") if is_block else None, - instance_name=child.get("instanceName") if is_block else None, - outputs=block_outputs(child) if is_block else None, - ) + node = Node( + local_id=local_id, + kind=kind, + label=label, + negated=is_true(child, "negated"), + inputs=block_connections(child) if is_block else direct_connections(child), + type_name=child.get("typeName") if is_block else None, + instance_name=child.get("instanceName") if is_block else None, + outputs=block_outputs(child) if is_block else None, + st_code=block_st_code(child) if is_block else None, ) + nodes.append(node) return nodes @@ -92,18 +103,24 @@ def _build(node, by_id, visiting, via_pin=None): active_output=active, # via_pin is set by the consumer; a network sink has none. output_wired=via_pin is not None, + st_code=list(node.st_code), ) - if node.kind == OUT_VARIABLE: + if node.kind in (OUT_VARIABLE, JUMP, RETURN): source = None for connection in node.inputs: upstream = by_id.get(connection.ref_id) if upstream is not None: source = _build(upstream, by_id, visiting, connection.source_pin) break - return Assign(node.label or "?", source) + if node.kind == OUT_VARIABLE: + return Assign(node.label or "?", source) + return Jump(node.label or ("RETURN" if node.kind == RETURN else "?"), source) + + if node.kind == LABEL: + return Label(node.label or "?") - return Signal(node.label or "") + return Signal(node.label or "", negated=node.negated) def build_networks(nodes): diff --git a/src/plcopen.py b/src/plcopen.py index ab80305..7fa56f4 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -110,6 +110,25 @@ def block_outputs(block_elem): return outputs +def block_st_code(block_elem): + """Inline ST carried by an EXECUTE box, as a list of lines. + + CODESYS puts the whole body of an EXECUTE box in an addData STCode + element. It is the only content the box has, so ignoring it draws an empty + box where a dozen lines of logic should be. + """ + add_data = find_child(block_elem, "addData") + if add_data is None: + return [] + for data in add_data: + if tag(data) != "data": + continue + code = find_child(data, "STCode") + if code is not None and code.text: + return code.text.replace("\r\n", "\n").strip("\n").split("\n") + return [] + + def comment_text(elem): """The text of a , which nests its content in an xhtml element.""" content = find_child(elem, "content") diff --git a/src/st_render.py b/src/st_render.py index 7e908c7..c7cea4f 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -12,7 +12,7 @@ """ from ld_render import render_declaration -from model import BLOCK, COIL, Assign, Call, Element, Series, Signal +from model import BLOCK, COIL, Assign, Call, Element, Jump, Label, Series, Signal from parse_ld import expr_to_text @@ -55,13 +55,63 @@ def rung_to_statements(rung): return statements +# Operators CODESYS draws as boxes but everyone reads as infix. A conversion +# like REAL_TO_UINT is left as a call, because that is how it reads in ST too. +INFIX_OPERATORS = { + "AND": "AND", + "OR": "OR", + "XOR": "XOR", + "ADD": "+", + "SUB": "-", + "MUL": "*", + "DIV": "/", + "MOD": "MOD", + "GT": ">", + "GE": ">=", + "LT": "<", + "LE": "<=", + "EQ": "=", + "NE": "<>", +} + + +def _operand(text): + """Parenthesise anything that is not a single term. + + Redundant brackets are preferable to an expression that reads correctly + but groups wrongly. + """ + return ("(" + text + ")") if " " in text else text + + +def _operator_expression(node, values): + symbol = INFIX_OPERATORS.get(node.type_name) + if symbol and len(values) >= 2: + return (" " + symbol + " ").join(_operand(value) for value in values) + if node.type_name == "NOT" and len(values) == 1: + return "NOT " + _operand(values[0]) + return "%s(%s)" % (node.type_name or "?", ", ".join(values)) + + def _fbd_value(node, statements): """Value of a node as ST text, appending any statements it needs first.""" if node is None: return "" if isinstance(node, Signal): - return node.label or "" + return node.text + + if isinstance(node, Label): + statements.append("(* label: %s *)" % node.name) + return "" + + if isinstance(node, Jump): + condition = _fbd_value(node.condition, statements) + if condition: + statements.append("IF %s THEN (* JMP %s *) END_IF" % (condition, node.target)) + else: + statements.append("(* JMP %s *)" % node.target) + return "" if isinstance(node, Assign): value = _fbd_value(node.source, statements) @@ -75,10 +125,22 @@ def _fbd_value(node, statements): if value: pairs.append((pin, value)) + if node.st_code: + # An EXECUTE box is inline ST already, so emit it as itself rather + # than as a call to a box that has no body. + guard = dict(pairs).get("EN") + if guard and guard != "TRUE": + statements.append("IF %s THEN" % guard) + statements.extend(" " + line for line in node.st_code) + statements.append("END_IF") + else: + statements.extend(node.st_code) + return "" + if node.is_operator: # Operators and functions have no instance to call, so they inline - # as a positional expression rather than a statement. - return "%s(%s)" % (node.type_name or "?", ", ".join(value for _pin, value in pairs)) + # as an expression rather than a statement. + return _operator_expression(node, [value for _pin, value in pairs]) name = node.instance_name statements.append("%s(%s);" % (name, ", ".join("%s := %s" % (pin, value) for pin, value in pairs))) diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt index 210b2b4..272b9fa 100644 --- a/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt @@ -14,6 +14,6 @@ fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15, eMode := ifmIO uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) -TOF_0(IN := GT(uiCurrSupplyVolt, uiMinVoltage), PT := T#5S); +TOF_0(IN := uiCurrSupplyVolt > uiMinVoltage, PT := T#5S); fbSupplySwitch(eMode := ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH, xValue := TOF_0.Q); diff --git a/tools/ladder/tests/fixtures/fbd_control_flow.plcopen.xml b/tools/ladder/tests/fixtures/fbd_control_flow.plcopen.xml new file mode 100644 index 0000000..81f9633 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_control_flow.plcopen.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xInitDone + + + + + Mode.Current = Mode.ESTOP + + + + + + + + + + + + + + + + + + operator + + + + + + + + + + + + + + + RawPressure + + + + + 100 + + + + + + + + + + + + + + + + + + operator + + + + + + + + + + + + + + + + + + + operator + + + + + + + + + Status.PressureBar + + + + + + + + + + xInitDone + + + + + + + + + + + + + + + execute + + + IF NOT xInitDone THEN +Status.PressureBar := 0; +Status.Faulted := FALSE; +END_IF + + + + + + + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 22939d5..23ad6f5 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -154,7 +154,7 @@ def check_golden(name, rendered, golden_path): check("st: function block becomes a call statement", SUPPLY_CALL in fbd_st) check("st: output assignment becomes its own statement", "uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage;" in fbd_st) -check("st: operator inlines positionally", "TOF_0(IN := GT(uiCurrSupplyVolt, uiMinVoltage), PT := T#5S);" in fbd_st) +check("st: comparison operator inlines infix", "TOF_0(IN := uiCurrSupplyVolt > uiMinVoltage, PT := T#5S);" in fbd_st) check("st: nested output is referenced by pin", SWITCH_CALL in fbd_st) check("st: unwired pin is omitted", not any("eFilter" in line for line in fbd_st)) @@ -169,6 +169,49 @@ def check_golden(name, rendered, golden_path): check_golden("st: LD golden matches", ld_st, os.path.join(FIXTURES, "LDTesting.st.expected.txt")) +# --- control flow ---------------------------------------------------------- + +# Everything below was silently dropped before, which is worse than failing: +# the rendering looked complete while a guard clause and a body of inline ST +# were simply absent. +CONTROL_FLOW = os.path.join(HERE, "fixtures", "fbd_control_flow.plcopen.xml") +flow = parse_fbd.parse_pous(CONTROL_FLOW)[0] +flow_st = st_render.render_pou(flow) +flow_art = fbd_render.render_pou(flow) + +check_equal("flow: four networks survive", len(flow.networks), 4) + +# A jump terminates a network. Leaving it out of SINK_KINDS dropped the entire +# guard network, because nothing else consumed the OR feeding it. +check("flow: the guard network is not dropped", any("JMP END" in line for line in flow_st)) +check("flow: the jump condition is kept", any("Mode.Current = Mode.ESTOP" in line for line in flow_st)) +check("flow: the jump target is drawn", any(">> END" in line for line in flow_art)) +check("flow: the label is shown", any("(* label: END *)" in line for line in flow_st)) + +# negated="true" on an inVariable inverts the logic if it is ignored. +guard = flow.networks[0][1] +check_equal("flow: negation reaches the tree", guard.condition.inputs[0][1].negated, True) +check_equal("flow: negation renders", guard.condition.inputs[0][1].text, "NOT xInitDone") +check("flow: negation survives into ST", any("(NOT xInitDone) OR" in line for line in flow_st)) + +# An EXECUTE box is nothing but inline ST; drawing the box alone loses it all. +execute = flow.networks[3][1] +check_equal("flow: inline ST is captured", len(execute.st_code), 4) +check("flow: inline ST reaches the ST output", any("Status.Faulted := FALSE;" in line for line in flow_st)) +# The EN pin genuinely guards the box, so it has to show up as a condition +# rather than being dropped for looking redundant. +check("flow: the EN guard wraps the inline ST", any(line == "IF xInitDone THEN" for line in flow_st)) +check("flow: inline ST reaches the diagram", any("Status.Faulted := FALSE;" in line for line in flow_art)) + +# Operators read as operators, not as function calls. +check("flow: arithmetic inlines infix", any("RawPressure / 100" in line for line in flow_st)) +check("flow: conversions stay function calls", any("REAL_TO_UINT(" in line for line in flow_st)) +check( + "flow: compound operands are bracketed", + any("(NOT xInitDone) OR (Mode.Current = Mode.ESTOP)" in line for line in flow_st), +) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) From 4ad2b204254a2cf55d4a0deb226b1c494b0dcfb0 Mon Sep 17 00:00:00 2001 From: kehinde Date: Wed, 5 Aug 2026 16:05:19 +1000 Subject: [PATCH 09/91] silence the xmllib warning and report what rendering costs Two follow-ups from a real export. The two red lines in the message view are one DeprecationWarning, not two errors: Python prints "file:line: Category: message" followed by the source line that triggered it, and line 1 of ScriptLib's xmllib.py is its docstring. CODESYS red-flags anything on stderr, so it reads as two errors. It fires because importing ElementTree pulls in CODESYS's own bundled xml package, so it is silenced at the import that causes it - nobody can act on it. On the export getting slower, the honest answer is that most of the cost is export_xml itself: rendering adds a second CODESYS-side export per graphical POU on top of export_native, and PLCopen is the only format with a published schema for graphical bodies, so it cannot be avoided. Rather than guess, the export now reports the split between CODESYS export_xml time and rendering time, so the next run says where it actually goes. Two genuine wastes are gone regardless. Every file was parsed twice, once per language, because each parser re-read the document looking for its own bodies - now one pass dispatches on the body language (measured 1.2ms -> 0.7ms per file under CPython). And the ASCII check scanned every byte in a Python loop where str.decode does it natively, with encode(errors="xmlcharrefreplace") replacing the hand-rolled escape loop. Stats are reset at the start of each export because the ScriptEngine can keep modules loaded between runs, which would otherwise report cumulative totals across every Export click since CODESYS started. Co-Authored-By: Claude Opus 5 --- src/graphical_export.py | 55 ++++++++++++++++++++++++++++--- src/parse_fbd.py | 31 ++++++++++------- src/parse_ld.py | 31 +++++++++++------ src/plcopen.py | 33 ++++++++++++++----- src/script_export_to_files.py | 6 ++++ src/script_lib_export_to_files.py | 6 ++++ tools/ladder/tests/test_export.py | 13 ++++++++ 7 files changed, 140 insertions(+), 35 deletions(-) diff --git a/src/graphical_export.py b/src/graphical_export.py index ad72fe8..4b7c9e4 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -17,6 +17,7 @@ import os import tempfile +import time import fbd_render import ld_render @@ -30,14 +31,51 @@ # and must never be mistaken for source. RENDERED_SUFFIX = ".txt" +# Rendering adds a second CODESYS-side export per graphical POU, so the cost +# is worth reporting rather than leaving people to wonder why the export got +# slower. Split so it is obvious whether CODESYS or this code is the cost. +STATS = {"rendered": 0, "skipped": 0, "export_xml_seconds": 0.0, "render_seconds": 0.0} + + +def reset_stats(): + STATS.update({"rendered": 0, "skipped": 0, "export_xml_seconds": 0.0, "render_seconds": 0.0}) + + +def summary(): + """One line describing what rendering cost, or None if it did nothing.""" + if not STATS["rendered"] and not STATS["skipped"]: + return None + return "Rendered %d graphical POUs in %.1fs (%.1fs CODESYS export_xml, %.1fs rendering); skipped %d" % ( + STATS["rendered"], + STATS["export_xml_seconds"] + STATS["render_seconds"], + STATS["export_xml_seconds"], + STATS["render_seconds"], + STATS["skipped"], + ) + + +# Body language -> (parser, diagram renderer). SFC and CFC are absent, so they +# fall through and no file is written for them. +RENDERERS = { + parse_ld.LANGUAGE: (parse_ld, ld_render), + parse_fbd.LANGUAGE: (parse_fbd, fbd_render), +} + def _render_pous(plcopen_path): - """(pou, art_renderer) for every POU in the file we know how to draw.""" + """(pou, art_renderer) for every POU in the file we know how to draw. + + One pass over the document. Asking each language parser in turn would + re-read and re-parse the whole file once per language, which is pure waste + on a project with hundreds of POUs. + """ found = [] - for pou in parse_ld.parse_pous(plcopen_path): - found.append((pou, ld_render)) - for pou in parse_fbd.parse_pous(plcopen_path): - found.append((pou, fbd_render)) + for pou_elem, language, body in plcopen.iter_bodies(plcopen_path): + entry = RENDERERS.get(language) + if entry is None: + continue + parser, art_renderer = entry + found.append((parser.pou_from_body(pou_elem, body), art_renderer)) return found @@ -73,15 +111,22 @@ def write_rendered_text(obj, base_path): handle, temp_path = tempfile.mkstemp(suffix=".plcopen.xml") os.close(handle) try: + started = time.time() obj.export_xml(path=temp_path, recursive=False) + STATS["export_xml_seconds"] += time.time() - started + started = time.time() lines = render_plcopen(temp_path) if not lines: + STATS["skipped"] += 1 + STATS["render_seconds"] += time.time() - started return False with open_utf8(base_path + RENDERED_SUFFIX, "w") as f: f.write(u"\n".join(lines)) f.write(u"\n") + STATS["rendered"] += 1 + STATS["render_seconds"] += time.time() - started return True except Exception as error: print("WARNING: could not render " + obj.get_name() + ": " + repr(error)) diff --git a/src/parse_fbd.py b/src/parse_fbd.py index e1269d4..1f497da 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -152,19 +152,28 @@ def build_networks(nodes): return networks +LANGUAGE = "FBD" + + +def pou_from_body(pou_elem, body_elem): + """Build a Pou from an already-located body. + + Split out from parse_pous so a caller handling several languages can make + a single pass over the document rather than one per language. + """ + return Pou( + name=pou_elem.get("name") or "", + pou_type=pou_elem.get("pouType") or "program", + language=LANGUAGE, + variables=parse_interface(find_child(pou_elem, "interface")), + networks=build_networks(parse_fbd_body(body_elem)), + ) + + def parse_pous(source): """Parse every FBD POU in a PLCopen file. Other languages are skipped.""" pous = [] for pou_elem, language, body in iter_bodies(source): - if language != "FBD": - continue - pous.append( - Pou( - name=pou_elem.get("name") or "", - pou_type=pou_elem.get("pouType") or "program", - language="FBD", - variables=parse_interface(find_child(pou_elem, "interface")), - networks=build_networks(parse_fbd_body(body)), - ) - ) + if language == LANGUAGE: + pous.append(pou_from_body(pou_elem, body)) return pous diff --git a/src/parse_ld.py b/src/parse_ld.py index 8c3ace8..050efbd 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -245,6 +245,25 @@ def build_rungs(nodes): return rungs +LANGUAGE = "LD" + + +def pou_from_body(pou_elem, body_elem): + """Build a Pou from an already-located body. + + Split out from parse_pous so a caller handling several languages can make + a single pass over the document instead of re-reading and re-parsing it + once per language. + """ + return Pou( + name=pou_elem.get("name") or "", + pou_type=pou_elem.get("pouType") or "program", + language=LANGUAGE, + variables=parse_interface(find_child(pou_elem, "interface")), + rungs=build_rungs(parse_ld_body(body_elem)), + ) + + def parse_pous(source): """Parse every LD POU in a PLCopen file. Other languages are skipped. @@ -252,14 +271,6 @@ def parse_pous(source): """ pous = [] for pou_elem, language, body in iter_bodies(source): - if language != "LD": - continue - pous.append( - Pou( - name=pou_elem.get("name") or "", - pou_type=pou_elem.get("pouType") or "program", - variables=parse_interface(find_child(pou_elem, "interface")), - rungs=build_rungs(parse_ld_body(body)), - ) - ) + if language == LANGUAGE: + pous.append(pou_from_body(pou_elem, body)) return pous diff --git a/src/plcopen.py b/src/plcopen.py index 7fa56f4..ed724e4 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -8,7 +8,16 @@ """ import io -import xml.etree.ElementTree as ET +import warnings + +# CODESYS puts its own ScriptLib ahead of the standard library, and its xml +# package imports the deprecated xmllib on the way in. That prints a +# DeprecationWarning plus the offending source line into the message view, +# where CODESYS red-flags both as errors. Nobody can act on it - it is +# CODESYS's own bundled library - so it is silenced at the point it fires. +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import xml.etree.ElementTree as ET from model import Connection @@ -252,8 +261,13 @@ def _to_ascii(data): sections, which are the one place a numeric reference would stay literal text instead of being expanded. """ - if not any(byte > 0x7F for byte in bytearray(data)): + try: + # Native-speed check, and the overwhelmingly common case. Scanning + # byte by byte in Python costs real time on a large project. + data.decode("ascii") return data + except UnicodeDecodeError: + pass try: text = data.decode("utf-8") @@ -262,13 +276,14 @@ def _to_ascii(data): # preserves every byte as a character so nothing is lost. text = data.decode("latin-1") - pieces = [] - for character in text: - if ord(character) < 128: - pieces.append(character) - else: - pieces.append("&#%d;" % ord(character)) - return "".join(pieces).encode("ascii") + try: + # This error handler does exactly the job, natively. + return text.encode("ascii", "xmlcharrefreplace") + except (LookupError, ValueError): + pieces = [] + for character in text: + pieces.append(character if ord(character) < 128 else "&#%d;" % ord(character)) + return "".join(pieces).encode("ascii") # XML 1.0 forbids these outright - they cannot even be written as a numeric diff --git a/src/script_export_to_files.py b/src/script_export_to_files.py index fae8223..d67f44f 100644 --- a/src/script_export_to_files.py +++ b/src/script_export_to_files.py @@ -5,6 +5,7 @@ import scriptengine # type: ignore +import graphical_export from communication_import_export import export_communication from device_tree_import_export import export_device_tree_siblings from entrypoint import find_application, find_communication, get_device_entrypoints, get_src_folder @@ -42,6 +43,7 @@ def export_child(child_obj, parent_obj, parent_folder_path): try: print_python_version() assert_project_open() + graphical_export.reset_stats() src_folder = get_src_folder(scriptengine.projects.primary) print("Writing to: " + src_folder) @@ -66,6 +68,10 @@ def export_child(child_obj, parent_obj, parent_folder_path): export_device_tree_siblings(device_obj, device_folder, application, communication) finalize_export_folder(src_folder, staging_folder) + + rendering_summary = graphical_export.summary() + if rendering_summary is not None: + print(rendering_summary) except Exception as e: print(e) ui_error_with_traceback("Export To Files failed!") diff --git a/src/script_lib_export_to_files.py b/src/script_lib_export_to_files.py index 03ed9b8..191220b 100644 --- a/src/script_lib_export_to_files.py +++ b/src/script_lib_export_to_files.py @@ -5,6 +5,7 @@ import scriptengine # type: ignore +import graphical_export from entrypoint import get_src_folder from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, write_native from object_type import ObjectType, get_object_type @@ -57,6 +58,7 @@ def export_child(child_obj, parent_obj, parent_folder_path): try: print_python_version() assert_project_open() + graphical_export.reset_stats() src_folder = get_src_folder(scriptengine.projects.primary) print("Writing to: " + src_folder) @@ -78,6 +80,10 @@ def export_child(child_obj, parent_obj, parent_folder_path): export_child(child_obj, None, staging_folder) finalize_export_folder(src_folder, staging_folder) + + rendering_summary = graphical_export.summary() + if rendering_summary is not None: + print(rendering_summary) except Exception as e: print(e) ui_error_with_traceback("Export Lib To Files failed.") diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index cb62299..c596b51 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -114,6 +114,19 @@ def read(path): check("sfc reports nothing rendered", graphical_export.write_rendered_text(sfc, sfc_base) is False) check("sfc writes no empty file", not os.path.exists(sfc_base + ".txt")) + # --- cost reporting ----------------------------------------------------- + + # The ScriptEngine can keep modules loaded between runs, so without an + # explicit reset the summary would report totals accumulated across every + # Export click since CODESYS started. + check_equal("one render is counted", graphical_export.STATS["rendered"], 1) + check_equal("the skipped sfc is counted", graphical_export.STATS["skipped"], 1) + check("the summary names both costs", "CODESYS export_xml" in graphical_export.summary()) + + graphical_export.reset_stats() + check_equal("reset clears the counts", graphical_export.STATS["rendered"], 0) + check_equal("nothing to report after a reset", graphical_export.summary(), None) + # --- a rendering failure must not fail the export ----------------------- broken_base = os.path.join(workspace, "BROKEN") From b5ba0f35894fcd3df90d0d0946176e4343dc0c3b Mon Sep 17 00:00:00 2001 From: kehinde Date: Wed, 5 Aug 2026 16:24:33 +1000 Subject: [PATCH 10/91] ignore Claude Code's local tool permissions settings.local.json holds per-developer tool permissions with absolute paths from whoever generated it. Committing it would put those paths in a public repo and hand the permission grants to anyone who clones it. Sits next to the existing .vscode/ entry, which is ignored for the same reason. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index b745cbd..015f290 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ .vscode/ +# Claude Code's per-developer tool permissions. Machine-specific paths, and +# permission grants that should not be inherited by whoever clones the repo. +.claude/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From d006924507451836b4b5a00f27d4214c4d3390fc Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Wed, 5 Aug 2026 19:45:56 +1000 Subject: [PATCH 11/91] stop dropping and inverting logic in LD and FBD renderings Four shapes rendered something other than the program: - outVariable negated="true" lost its negation, so the ST read the exact inverse of the deployed logic. Assign now carries the flag; the ST emits NOT and the diagram marks the pin with an o. - an inVariable negated="true" wired to an LD block pin was flattened to its raw label, silently inverting a block parameter. The pin caption now goes through expr_to_text, which keeps the NOT. - a connector/continuation pair dropped the whole upstream network and rendered the consumer as a fabricated FALSE assignment. A connector is now a sink that assigns to the wire's name, and the continuation reads the name back like any other signal. - LD jump rungs emitted no ST at all and drew ">>?": the target lives in a "label" attribute (as the FBD parser already knew), the label element was not a known kind, and the ST walker had no jump case. All three fixed; return and label get ST forms too, matching the FBD comment style. Also emit LD block output-pin assignments in the ST - they were drawn in the diagram but absent from the text reviewers are told to trust. Two hand-authored fixtures pin all of these down; every new check fails on the previous code. --- src/fbd_render.py | 4 +- src/ld_render.py | 4 + src/model.py | 12 +- src/parse_fbd.py | 22 ++- src/parse_ld.py | 17 +- src/st_render.py | 23 ++- .../tests/fixtures/fbd_fidelity.plcopen.xml | 105 ++++++++++++ .../tests/fixtures/ld_fidelity.plcopen.xml | 151 ++++++++++++++++++ tools/ladder/tests/test_fbd.py | 25 +++ tools/ladder/tests/test_ladder.py | 54 ++++++- 10 files changed, 402 insertions(+), 15 deletions(-) create mode 100644 tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml create mode 100644 tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 91b16b6..d7f8b8f 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -40,7 +40,9 @@ def _render_assign(node): chars = charset.active() source = _render(node.source) if node.source is not None else Block([""], 0) lines = source.padded(source.width) - tail = chars["H"] * 3 + "> " + (node.label or "?") + # The negation circle CODESYS draws on the pin, as an "o" on the wire. + head = "o> " if node.negated else "> " + tail = chars["H"] * 3 + head + (node.label or "?") out = [] for index, line in enumerate(lines): out.append(line + tail if index == source.connect_row else line) diff --git a/src/ld_render.py b/src/ld_render.py index 5952ed8..70c1eb2 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -59,6 +59,10 @@ def _symbol_and_label(element): if kind == "return": return "", "" + if kind == "label": + # A jump target: a marker in the rung order, not a symbol on a wire. + return (element.label or "?") + ":", "" + # In/out variables and anything unrecognised draw as a named box so # unhandled logic is visible rather than silently dropped. return "[" + (element.label or "?") + "]", "" diff --git a/src/model.py b/src/model.py index dd9d934..b7439cf 100644 --- a/src/model.py +++ b/src/model.py @@ -23,6 +23,7 @@ OUT_VARIABLE = "outVariable" JUMP = "jump" RETURN = "return" +LABEL = "label" RAILS = (LEFT_RAIL, RIGHT_RAIL) @@ -193,14 +194,19 @@ def __repr__(self): class Assign(object): - """An outVariable: a network whose result is stored into a variable.""" + """An outVariable: a network whose result is stored into a variable. - def __init__(self, label, source=None): + Like an inVariable, CODESYS can negate the pin in place - and dropping + that inverts the stored value. + """ + + def __init__(self, label, source=None, negated=False): self.label = label self.source = source + self.negated = negated def __repr__(self): - return "Assign(%r)" % (self.label,) + return "Assign(%r, negated=%r)" % (self.label, self.negated) # --- expression tree ------------------------------------------------------- diff --git a/src/parse_fbd.py b/src/parse_fbd.py index 1f497da..ff3e182 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -27,15 +27,19 @@ JUMP = "jump" LABEL = "label" RETURN = "return" +CONNECTOR = "connector" +CONTINUATION = "continuation" # vendorElement carries CODESYS editor state (network titles, implementation # attributes) and holds no logic, so it is skipped entirely. -FBD_KINDS = (BLOCK, IN_VARIABLE, OUT_VARIABLE, COMMENT, JUMP, RETURN, LABEL, "continuation", "connector") +FBD_KINDS = (BLOCK, IN_VARIABLE, OUT_VARIABLE, COMMENT, JUMP, RETURN, LABEL, CONTINUATION, CONNECTOR) # Elements that can terminate a network. A jump or return ends one just as # surely as an assignment does - leaving them out drops the entire guard -# network they belong to, silently. -SINK_KINDS = (BLOCK, OUT_VARIABLE, JUMP, RETURN, LABEL) +# network they belong to, silently. A connector too: its continuations refer +# to it by name, never by localId, so nothing ever "consumes" it and without +# a sink entry its whole upstream network would vanish. +SINK_KINDS = (BLOCK, OUT_VARIABLE, JUMP, RETURN, LABEL, CONNECTOR) def parse_fbd_body(body_elem): @@ -59,6 +63,9 @@ def parse_fbd_body(body_elem): elif kind in (JUMP, LABEL): # Both carry their target in a "label" attribute, not a child. label = child.get("label") + elif kind in (CONNECTOR, CONTINUATION): + # The wire's name is a "name" attribute; there is no expression. + label = child.get("name") else: label = child_text(child, "expression") @@ -106,7 +113,7 @@ def _build(node, by_id, visiting, via_pin=None): st_code=list(node.st_code), ) - if node.kind in (OUT_VARIABLE, JUMP, RETURN): + if node.kind in (OUT_VARIABLE, JUMP, RETURN, CONNECTOR): source = None for connection in node.inputs: upstream = by_id.get(connection.ref_id) @@ -114,12 +121,19 @@ def _build(node, by_id, visiting, via_pin=None): source = _build(upstream, by_id, visiting, connection.source_pin) break if node.kind == OUT_VARIABLE: + return Assign(node.label or "?", source, negated=node.negated) + if node.kind == CONNECTOR: + # A connector names the wire feeding it, so it renders as an + # assignment to that name and the matching continuation reads the + # name back. Not real ST - but the logic stays on the page. return Assign(node.label or "?", source) return Jump(node.label or ("RETURN" if node.kind == RETURN else "?"), source) if node.kind == LABEL: return Label(node.label or "?") + # A continuation lands here: its label is the wire's name, so it reads + # like any other signal. return Signal(node.label or "", negated=node.negated) diff --git a/src/parse_ld.py b/src/parse_ld.py index 050efbd..ff92c40 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -9,8 +9,11 @@ from model import ( BLOCK, IN_VARIABLE, + JUMP, + LABEL, LEFT_RAIL, RAILS, + RETURN, RIGHT_RAIL, CONTACT, COIL, @@ -46,8 +49,9 @@ BLOCK, "inVariable", "outVariable", - "jump", - "return", + JUMP, + RETURN, + LABEL, ) @@ -56,6 +60,10 @@ def _node_label(elem, kind): # typeName and instanceName are attributes in CODESYS's output, not # the child elements a literal schema reading would suggest. return elem.get("instanceName") or elem.get("typeName") + if kind in (JUMP, LABEL): + # The target is a "label" attribute, not a child element - same as in + # FBD bodies. Reading child elements here loses the target entirely. + return elem.get("label") return child_text(elem, "variable") or child_text(elem, "expression") @@ -148,7 +156,10 @@ def _build_block(node, by_id, visiting, via_pin): continue sub_expr = _build_expr(upstream, by_id, visiting, connection.source_pin) if upstream.kind == IN_VARIABLE: - side_pins.append((connection.target_pin, upstream.label or "")) + # Flattened through expr_to_text, not taken from the raw label: + # an in-place negated inVariable must keep its NOT, or the pin + # silently inverts. + side_pins.append((connection.target_pin, expr_to_text(sub_expr))) elif power_pin is None: power_pin = connection.target_pin power_expr = sub_expr diff --git a/src/st_render.py b/src/st_render.py index c7cea4f..3a9f58f 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -12,7 +12,7 @@ """ from ld_render import render_declaration -from model import BLOCK, COIL, Assign, Call, Element, Jump, Label, Series, Signal +from model import BLOCK, COIL, JUMP, LABEL, RETURN, Assign, Call, Element, Jump, Label, Series, Signal from parse_ld import expr_to_text @@ -44,9 +44,24 @@ def rung_to_statements(rung): args.append("%s := %s" % (pin, value)) name = item.instance_name or item.type_name or "?" statements.append("%s(%s);" % (name, ", ".join(args))) + # An assignment written straight onto an output pin executes every + # scan; the diagram draws it, so the ST must say it too. + for pin, assigned in item.output_pins: + if assigned: + statements.append("%s := %s.%s;" % (assigned, name, pin)) condition = (name + "." + item.active_output) if item.active_output else name elif isinstance(item, Element) and item.kind == COIL: statements.append(_coil_statement(item, condition)) + elif isinstance(item, Element) and item.kind in (JUMP, RETURN): + # A jump ends the rung; its guard is the rung condition so far. + # Same comment form as the FBD path, so both grep alike. + target = (item.label or "?") if item.kind == JUMP else "RETURN" + if condition: + statements.append("IF %s THEN (* JMP %s *) END_IF" % (condition, target)) + else: + statements.append("(* JMP %s *)" % target) + elif isinstance(item, Element) and item.kind == LABEL: + statements.append("(* label: %s *)" % (item.label or "?")) else: text = expr_to_text(item) if text: @@ -114,8 +129,10 @@ def _fbd_value(node, statements): return "" if isinstance(node, Assign): - value = _fbd_value(node.source, statements) - statements.append("%s := %s;" % (node.label or "?", value or "FALSE")) + value = _fbd_value(node.source, statements) or "FALSE" + if node.negated: + value = "NOT " + _operand(value) + statements.append("%s := %s;" % (node.label or "?", value)) return node.label or "?" if isinstance(node, Call): diff --git a/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml b/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml new file mode 100644 index 0000000..ba68a82 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xIn + + + + + + + xInverted + + + + + + + xRun + + + + + xReady + + + + + + + + + + + + + + + + + + operator + + + + + + + + + + + + + + + + + + + + + xBoth + + + + + + + + diff --git a/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml b/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml new file mode 100644 index 0000000..6c6c250 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xGo + + + + + + + + + + + + + + + + + + + + + + + + + xStart + + + + + xManual + + + + + + + + + + + + + + + + + + + + + + + + + + iCount + + + + + + + + + + + + xDone + + + + + + + + + + + + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 23ad6f5..e96d5ef 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -212,6 +212,31 @@ def check_golden(name, rendered, golden_path): ) +# --- logic fidelity ---------------------------------------------------------- + +# Shapes whose mishandling renders the *inverse* of the program, or fabricates +# logic that is not there. For a review artifact that is worse than a crash. +FIDELITY = os.path.join(HERE, "fixtures", "fbd_fidelity.plcopen.xml") +fid = parse_fbd.parse_pous(FIDELITY)[0] +fid_st = st_render.render_pou(fid) +fid_art = fbd_render.render_pou(fid) + +# A connector terminates its network, so all three must survive. +check_equal("fidelity: all three networks survive", len(fid.networks), 3) + +# negated="true" on an outVariable inverts the logic if it is dropped. +check("fidelity: negated output inverts in ST", any("xInverted := NOT xIn;" in line for line in fid_st)) +check("fidelity: negated output is marked in the diagram", any("o> xInverted" in line for line in fid_art)) + +# A connector names a wire; the continuation re-emits it. Before these were +# handled, the AND network vanished and the consumer rendered "xBoth := FALSE;" +# - fabricated logic, not just missing logic. +check("fidelity: connector network keeps its logic", any("C1 := xRun AND xReady;" in line for line in fid_st)) +check("fidelity: continuation resolves to the named wire", any("xBoth := C1;" in line for line in fid_st)) +check("fidelity: nothing is fabricated as FALSE", not any(":= FALSE" in line for line in fid_st)) +check("fidelity: the connector's source reaches the diagram", any("xRun" in line for line in fid_art)) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 57d8f29..a11100b 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -100,6 +100,10 @@ def check_equal(name, actual, expected): # Shifting every element 500px right must not change a single character of # output. This is the property that keeps diffs meaningful. moved = source_text.replace('>?" and +# emitted no ST for the whole rung, guard included. +check("fidelity: jump target is drawn", any(">>SKIP" in line for line in fidelity_art)) +check("fidelity: guarded jump reaches ST", any("IF xGo THEN (* JMP SKIP *) END_IF" in line for line in fidelity_st)) +check("fidelity: label is drawn", any("SKIP:" in line for line in fidelity_art)) +check("fidelity: label reaches ST", any("(* label: SKIP *)" in line for line in fidelity_st)) + +# model.Signal's docstring warns that dropping negated inverts the logic; the +# LD block-pin path did exactly that. +check("fidelity: negated pin keeps its NOT in ST", any("RESET := NOT xManual" in line for line in fidelity_st)) +check("fidelity: negated pin keeps its NOT in the box", any("RESET := NOT xManual" in line for line in fidelity_art)) + +# An assignment on a block output pin executes every scan; the diagram drew it +# but the ST - the half reviewers are told to trust - left it out. +check("fidelity: output pin assignment reaches ST", any("iCount := ctr.CV;" in line for line in fidelity_st)) +check("fidelity: output pin assignment is drawn", any("CV => iCount" in line for line in fidelity_art)) + + # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships @@ -180,9 +226,11 @@ def check_golden(name, rendered_lines, golden_path): BOM = b"\xef\xbb\xbf" CODESYS_FIXTURES = os.path.join(FIXTURES, "codesys") +bom_fixtures = 0 for name in sorted(os.listdir(CODESYS_FIXTURES)): if not name.endswith(".xml"): continue + bom_fixtures += 1 path = os.path.join(CODESYS_FIXTURES, name) raw = open(path, "rb").read() # The fixtures are real exports, so they should still carry their BOM. If @@ -190,6 +238,10 @@ def check_golden(name, rendered_lines, golden_path): check(name + " is a real export, BOM and all", raw.startswith(BOM)) check_equal(name + " is fed to the parser without its BOM", plcopen.read_document(path)[:1], b"<") +# If the fixtures move, the loop above runs zero times and the BOM contract - +# the one that only reproduces inside CODESYS - silently stops being tested. +check("the BOM sweep found the real exports", bom_fixtures >= 3, "found %d" % bom_fixtures) + check_equal( "leading whitespace is dropped too", plcopen.read_document(io.BytesIO(BOM + b"\n ")), From d612fb4e1137faf3c143c6c1e7ef139d04b340b4 Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Wed, 5 Aug 2026 19:45:56 +1000 Subject: [PATCH 12/91] hold the rendering-failure barrier around its own scaffolding write_rendered_text promises a rendering failure never fails the export, but two statements sat outside its try/except: tempfile.mkstemp ran before the try, and os.remove ran in the finally, where an exception replaces the normal return. A full %TEMP% or an antivirus scan holding the fresh temp file would have aborted the entire Export To Files run over a derived file. Both are now inside the barrier, the failure-path diagnostic is best-effort, and a write that dies halfway removes its truncated .txt rather than letting a half-written rendering ride the staging swap into the export looking valid. --- src/graphical_export.py | 40 +++++++++++++---- tools/ladder/tests/test_export.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/graphical_export.py b/src/graphical_export.py index 4b7c9e4..8b92783 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -95,6 +95,15 @@ def render_plcopen(plcopen_path): return lines +def _remove_quietly(path): + """Best-effort delete. Cleanup trouble is never worth failing an export.""" + try: + if os.path.exists(path): + os.remove(path) + except Exception: + pass + + def write_rendered_text(obj, base_path): """Export obj as PLCopen xml, render it, and write .txt. @@ -103,13 +112,20 @@ def write_rendered_text(obj, base_path): A rendering failure must not fail the export: the native xml has already been written and is complete and correct on its own. The problem is - reported and the export carries on. + reported and the export carries on. That barrier has to hold around the + temp-file scaffolding too, not just the rendering itself - a full %TEMP% + or an antivirus scan holding the temp file open must degrade to a warning + exactly like a parse failure does. """ # Staged outside the export folder: exports are written to a staging # directory that gets swapped into place wholesale, and a temp file left # behind by a failed cleanup would be swapped in along with it. - handle, temp_path = tempfile.mkstemp(suffix=".plcopen.xml") - os.close(handle) + try: + handle, temp_path = tempfile.mkstemp(suffix=".plcopen.xml") + os.close(handle) + except Exception as error: + print("WARNING: could not render " + obj.get_name() + ": " + repr(error)) + return False try: started = time.time() obj.export_xml(path=temp_path, recursive=False) @@ -131,11 +147,17 @@ def write_rendered_text(obj, base_path): except Exception as error: print("WARNING: could not render " + obj.get_name() + ": " + repr(error)) # Say what is actually in the file, so a failure explains itself - # instead of needing a separate diagnostic run. - if os.path.exists(temp_path): - for note in plcopen.describe_suspect_characters(temp_path): - print(" " + note) + # instead of needing a separate diagnostic run. The diagnostic is + # best-effort: it must not turn a reported failure into a raised one. + try: + if os.path.exists(temp_path): + for note in plcopen.describe_suspect_characters(temp_path): + print(" " + note) + except Exception: + pass + # A write that died halfway leaves a truncated rendering that looks + # exactly like a valid one. No file at all is the honest outcome. + _remove_quietly(base_path + RENDERED_SUFFIX) return False finally: - if os.path.exists(temp_path): - os.remove(temp_path) + _remove_quietly(temp_path) diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index c596b51..24f967f 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -133,6 +133,79 @@ def read(path): broken = FakePou("BROKEN", None) check("a broken export is reported, not raised", graphical_export.write_rendered_text(broken, broken_base) is False) check("broken pou writes no file", not os.path.exists(broken_base + ".txt")) + + # The barrier must also hold around its own scaffolding: a temp file that + # cannot be created (%TEMP% full) or removed (an antivirus scan holding it) + # is exactly the kind of environmental hiccup that must not abort a whole + # Export To Files run over a derived file. + + real_mkstemp = tempfile.mkstemp + + def failing_mkstemp(*args, **kwargs): + raise OSError("no temp space") + + tempfile.mkstemp = failing_mkstemp + try: + no_temp = FakePou("NO_TEMP", os.path.join(FIXTURES, "LDTesting.xml")) + try: + outcome = graphical_export.write_rendered_text(no_temp, os.path.join(workspace, "NO_TEMP")) + check("a temp-file creation failure is reported, not raised", outcome is False) + except Exception as error: + check("a temp-file creation failure is reported, not raised", False, repr(error)) + finally: + tempfile.mkstemp = real_mkstemp + + real_remove = os.remove + + def failing_remove(path): + raise OSError("sharing violation") + + os.remove = failing_remove + try: + sticky = FakePou("STICKY", os.path.join(FIXTURES, "LDTesting.xml")) + try: + outcome = graphical_export.write_rendered_text(sticky, os.path.join(workspace, "STICKY")) + check("a temp-file cleanup failure is reported, not raised", outcome is True) + except Exception as error: + check("a temp-file cleanup failure is reported, not raised", False, repr(error)) + finally: + os.remove = real_remove + + # A write that dies halfway must not leave a truncated .txt behind: the + # staging folder is swapped into place wholesale, and a half-written + # rendering looks exactly like a valid one that misstates the logic. + real_open_utf8 = graphical_export.open_utf8 + + class FailingWriter(object): + def __init__(self, handle): + self._handle = handle + + def __enter__(self): + return self + + def __exit__(self, *args): + self._handle.close() + return False + + def write(self, text): + self._handle.write(text[: len(text) // 2]) + raise IOError("disk full") + + def failing_open_utf8(path, mode): + return FailingWriter(real_open_utf8(path, mode)) + + graphical_export.open_utf8 = failing_open_utf8 + try: + torn = FakePou("TORN", os.path.join(FIXTURES, "LDTesting.xml")) + torn_base = os.path.join(workspace, "TORN") + try: + outcome = graphical_export.write_rendered_text(torn, torn_base) + check("a mid-write failure is reported, not raised", outcome is False) + except Exception as error: + check("a mid-write failure is reported, not raised", False, repr(error)) + check("a truncated rendering is not left behind", not os.path.exists(torn_base + ".txt")) + finally: + graphical_export.open_utf8 = real_open_utf8 finally: shutil.rmtree(workspace) From c1859836afeb01a60040900c176df422f46450df Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Wed, 5 Aug 2026 20:08:37 +1000 Subject: [PATCH 13/91] honour negation bubbles on block pins and LD outVariable stores An adversarial review of the previous fidelity commit found the same inversion class in four more places, each verified by execution: - negated="true" on a block's own input pin variable was never read: block_connections now carries it, FBD wraps the source in an explicit NOT (a flipped Signal, or a visible NOT box for a subtree), and LD side pins spell it out in the caption. A negated LD power pin inverts the condition at the box wall and draws the bubble as an o on the box edge. - negated="true" on an output pin was dropped, which made the new inline output assignments affirmatively wrong rather than merely absent: the ST now stores NOT pin, downstream consumers read NOT pin, and the diagram marks the pin with =o> (assigned) or o (wired). - an LD rung storing through an outVariable element emitted no ST at all and a negated one drew a plain box; it now emits the assignment like a coil does, and the box spells out its NOT. - NOT applied to a compound expression rendered without parentheses, regrouping the logic (NOT binds tighter than OR in IEC 61131-3): Signal text and expr_to_text now bracket compound terms. Test hygiene from the same review: the y-shift determinism check now scrambles relative order instead of applying an order-preserving uniform prefix, the STICKY cleanup test no longer strands a temp file in the real %TEMP% every run, and the FBD fidelity fixture gets the fileHeader every real export carries. --- src/fbd_render.py | 5 +- src/ld_render.py | 18 +++- src/model.py | 31 ++++++- src/parse_fbd.py | 25 +++++- src/parse_ld.py | 33 ++++++- src/plcopen.py | 28 +++++- src/st_render.py | 38 +++++++-- .../tests/fixtures/fbd_fidelity.plcopen.xml | 85 +++++++++++++++++++ .../tests/fixtures/ld_fidelity.plcopen.xml | 78 +++++++++++++++++ tools/ladder/tests/test_export.py | 14 +++ tools/ladder/tests/test_fbd.py | 19 ++++- tools/ladder/tests/test_ladder.py | 19 ++++- 12 files changed, 372 insertions(+), 21 deletions(-) diff --git a/src/fbd_render.py b/src/fbd_render.py index d7f8b8f..602236b 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -115,7 +115,10 @@ def _render_call(call): pin, assigned = pin_and_assignment text = pin or "?" if assigned: - text += " => " + assigned + # =o> is => with the negation bubble: the pin stores its inverse. + text += (" =o> " if pin in call.negated_outputs else " => ") + assigned + elif pin in call.negated_outputs: + text += " o" out_at[output_rows[index]] = text title = call.title diff --git a/src/ld_render.py b/src/ld_render.py index 70c1eb2..37ac6c6 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -64,8 +64,12 @@ def _symbol_and_label(element): return (element.label or "?") + ":", "" # In/out variables and anything unrecognised draw as a named box so - # unhandled logic is visible rather than silently dropped. - return "[" + (element.label or "?") + "]", "" + # unhandled logic is visible rather than silently dropped. A negated + # variable spells its NOT out - there is no bubble to draw on a box. + label = element.label or "?" + if element.negated: + label = "NOT " + label + return "[" + label + "]", "" def _render_block(element): @@ -91,7 +95,10 @@ def _render_block(element): for pin, assigned in element.output_pins: text = pin or "?" if assigned: - text += " => " + assigned + # =o> is => with the negation bubble: the pin stores its inverse. + text += (" =o> " if pin in element.negated_outputs else " => ") + assigned + elif pin in element.negated_outputs: + text += " o" right.append(text) rows = max(len(left), len(right), 1) @@ -107,8 +114,13 @@ def _render_block(element): for index in range(rows): gap = inner - len(left[index]) - len(right[index]) left_edge = chars["PIN_L"] if wired[index] else chars["V"] + if wired[index] and element.power_negated: + # The negation bubble on the power pin, drawn on the box wall. + left_edge = "o" # Only the active output continues onward, and only if consumed. right_edge = chars["PIN_R"] if (index == 0 and element.output_wired) else chars["V"] + if index == 0 and element.output_wired and element.active_output in element.negated_outputs: + right_edge = "o" lines.append(left_edge + left[index] + " " * gap + right[index] + right_edge) lines.append(chars["BL"] + chars["H"] * inner + chars["BR"]) diff --git a/src/model.py b/src/model.py index b7439cf..7f9f11c 100644 --- a/src/model.py +++ b/src/model.py @@ -35,12 +35,17 @@ class Connection(object): (CODESYS writes ``formalParameter="Q"`` on the connection itself), while ``target_pin`` is the input pin on *this* element. Only blocks have named pins; for contacts and coils both are None. + + ``negated`` is the bubble CODESYS draws on the *pin itself* (negated="true" + on the pin's variable element) - separate from a negated inVariable, and + just as logic-inverting if dropped. """ - def __init__(self, ref_id, source_pin=None, target_pin=None): + def __init__(self, ref_id, source_pin=None, target_pin=None, negated=False): self.ref_id = ref_id self.source_pin = source_pin self.target_pin = target_pin + self.negated = negated def __repr__(self): return "Connection(%s, source_pin=%r, target_pin=%r)" % (self.ref_id, self.source_pin, self.target_pin) @@ -62,6 +67,7 @@ def __init__( instance_name=None, outputs=None, st_code=None, + negated_outputs=None, ): self.st_code = st_code if st_code is not None else [] # blocks only: inline ST self.local_id = local_id @@ -74,6 +80,9 @@ def __init__( self.type_name = type_name # blocks only self.instance_name = instance_name # blocks only, absent for operators self.outputs = outputs if outputs is not None else [] # blocks only: (pin, assigned_var) + # blocks only: output pins whose in-place negation bubble inverts the + # value leaving them + self.negated_outputs = negated_outputs if negated_outputs is not None else set() def __repr__(self): return "Node(%s, %s, %r, inputs=%r)" % (self.local_id, self.kind, self.label, self.inputs) @@ -121,7 +130,14 @@ def __init__(self, label, negated=False): @property def text(self): - return ("NOT " + (self.label or "")) if self.negated else (self.label or "") + label = self.label or "" + if not self.negated: + return label + # NOT binds tighter than OR/AND in IEC 61131-3, so a compound + # expression must keep its parentheses or the logic regroups. + if " " in label: + return "NOT (" + label + ")" + return "NOT " + label def __repr__(self): return "Signal(%r, negated=%r)" % (self.label, self.negated) @@ -165,12 +181,16 @@ def __init__( active_output=None, output_wired=False, st_code=None, + negated_outputs=None, ): self.type_name = type_name self.instance_name = instance_name self.inputs = inputs if inputs is not None else [] self.outputs = outputs if outputs is not None else [] self.active_output = active_output + # Pins carrying CODESYS's in-place negation bubble: the value leaving + # them is the inverse of the pin. + self.negated_outputs = negated_outputs if negated_outputs is not None else set() # An EXECUTE box carries inline ST as its whole body. Dropping it loses # the logic entirely while still drawing a plausible-looking box. self.st_code = st_code if st_code is not None else [] @@ -244,6 +264,8 @@ def __init__( output_pins=None, active_output=None, output_wired=False, + power_negated=False, + negated_outputs=None, ): self.kind = kind self.label = label @@ -255,6 +277,11 @@ def __init__( self.input_pins = input_pins if input_pins is not None else [] self.output_pins = output_pins if output_pins is not None else [] self.active_output = active_output + # Blocks only: the negation bubble on the pin the rung's power enters + # through, and the set of output pins carrying one. Both invert the + # logic in place if dropped. + self.power_negated = power_negated + self.negated_outputs = negated_outputs if negated_outputs is not None else set() # True when something downstream actually consumes the active output, # so the renderer knows whether to break the box edge with a tee. self.output_wired = output_wired diff --git a/src/parse_fbd.py b/src/parse_fbd.py index ff3e182..2f0f6ad 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -17,6 +17,7 @@ find_child, is_true, iter_bodies, + negated_output_pins, parse_interface, tag, ) @@ -79,11 +80,29 @@ def parse_fbd_body(body_elem): instance_name=child.get("instanceName") if is_block else None, outputs=block_outputs(child) if is_block else None, st_code=block_st_code(child) if is_block else None, + negated_outputs=negated_output_pins(child) if is_block else None, ) nodes.append(node) return nodes +def _negate(source): + """Wrap a pin's source in the negation its pin bubble demands. + + A Signal simply flips; anything else becomes an explicit NOT operator so + the inversion is visible in both the ST and the diagram. + """ + if isinstance(source, Signal): + return Signal(source.label, negated=not source.negated) + return Call( + type_name="NOT", + inputs=[("In", source)], + outputs=[("Out", None)], + active_output="Out", + output_wired=True, + ) + + def _build(node, by_id, visiting, via_pin=None): if node.local_id in visiting: return Signal("" % node.local_id) @@ -96,6 +115,9 @@ def _build(node, by_id, visiting, via_pin=None): source = None if upstream is not None: source = _build(upstream, by_id, visiting, connection.source_pin) + if connection.negated and source is not None: + # The bubble on the pin itself, not on what feeds it. + source = _negate(source) inputs.append((connection.target_pin, source)) active = via_pin @@ -111,6 +133,7 @@ def _build(node, by_id, visiting, via_pin=None): # via_pin is set by the consumer; a network sink has none. output_wired=via_pin is not None, st_code=list(node.st_code), + negated_outputs=set(node.negated_outputs), ) if node.kind in (OUT_VARIABLE, JUMP, RETURN, CONNECTOR): @@ -126,7 +149,7 @@ def _build(node, by_id, visiting, via_pin=None): # A connector names the wire feeding it, so it renders as an # assignment to that name and the matching continuation reads the # name back. Not real ST - but the logic stays on the page. - return Assign(node.label or "?", source) + return Assign(node.label or "?", source, negated=node.negated) return Jump(node.label or ("RETURN" if node.kind == RETURN else "?"), source) if node.kind == LABEL: diff --git a/src/parse_ld.py b/src/parse_ld.py index ff92c40..b1f16d6 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -35,6 +35,7 @@ find_child, is_true, iter_bodies, + negated_output_pins, parse_interface, tag, ) @@ -90,6 +91,7 @@ def parse_ld_body(body_elem): type_name=child.get("typeName") if is_block else None, instance_name=child.get("instanceName") if is_block else None, outputs=block_outputs(child) if is_block else None, + negated_outputs=negated_output_pins(child) if is_block else None, ) ) return nodes @@ -133,11 +135,22 @@ def expr_to_text(expr): if expr.edge == "falling": return "F(" + label + ")" if expr.negated: - return "NOT " + label + return "NOT " + _bracket(label) return label return "?" +def _bracket(text): + """Parenthesise a compound term before negating or nesting it. + + NOT binds tighter than OR/AND in IEC 61131-3, so "NOT xA OR xB" regroups + the logic that "NOT (xA OR xB)" states. + """ + if " " in text: + return "(" + text + ")" + return text + + def _build_block(node, by_id, visiting, via_pin): """Build a block call, separating power flow from parameter inputs. @@ -147,6 +160,7 @@ def _build_block(node, by_id, visiting, via_pin): """ power_expr = Empty() power_pin = None + power_negated = False side_pins = [] for connection in node.inputs: @@ -159,12 +173,15 @@ def _build_block(node, by_id, visiting, via_pin): # Flattened through expr_to_text, not taken from the raw label: # an in-place negated inVariable must keep its NOT, or the pin # silently inverts. - side_pins.append((connection.target_pin, expr_to_text(sub_expr))) + side_pins.append((connection.target_pin, _pin_text(sub_expr, connection))) elif power_pin is None: power_pin = connection.target_pin power_expr = sub_expr + # The pin's own negation bubble; it inverts the power flow at the + # box wall, after everything the rung has accumulated. + power_negated = connection.negated else: - side_pins.append((connection.target_pin, expr_to_text(sub_expr))) + side_pins.append((connection.target_pin, _pin_text(sub_expr, connection))) input_pins = [] if power_pin is not None: @@ -190,10 +207,20 @@ def _build_block(node, by_id, visiting, via_pin): # via_pin is set by whatever consumed this block; a block terminating # the rung has none. output_wired=via_pin is not None, + power_negated=power_negated, + negated_outputs=set(node.negated_outputs), ) return series([power_expr, element]) +def _pin_text(sub_expr, connection): + """A side pin's caption, honouring the pin's own negation bubble.""" + text = expr_to_text(sub_expr) + if connection.negated: + return "NOT " + _bracket(text) if text else "NOT ?" + return text + + def _build_expr(node, by_id, visiting, via_pin=None): """Walk backwards from a node to the power rail, building series/parallel. diff --git a/src/plcopen.py b/src/plcopen.py index ed724e4..bc8b033 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -80,7 +80,12 @@ def direct_connections(elem): def block_connections(block_elem): - """Wires arriving at a block, tagged with the pin they land on.""" + """Wires arriving at a block, tagged with the pin they land on. + + A pin variable can carry negated="true" - the bubble CODESYS draws on the + pin itself. It applies to everything arriving at that pin, so it rides on + each connection. + """ connections = [] for group_name in ("inputVariables", "inOutVariables"): group = find_child(block_elem, group_name) @@ -90,8 +95,10 @@ def block_connections(block_elem): if tag(var) != "variable": continue pin = var.get("formalParameter") + pin_negated = is_true(var, "negated") for connection in direct_connections(var): connection.target_pin = pin + connection.negated = pin_negated connections.append(connection) return connections @@ -119,6 +126,25 @@ def block_outputs(block_elem): return outputs +def negated_output_pins(block_elem): + """Output pins carrying an in-place negation bubble (negated="true"). + + The value leaving such a pin is the inverse of the pin, so an inline + assignment stores NOT pin and a consumer reads NOT pin. Dropping the flag + renders the exact opposite of the program. + """ + pins = set() + group = find_child(block_elem, "outputVariables") + if group is None: + return pins + for var in group: + if tag(var) != "variable": + continue + if is_true(var, "negated"): + pins.add(var.get("formalParameter")) + return pins + + def block_st_code(block_elem): """Inline ST carried by an EXECUTE box, as a list of lines. diff --git a/src/st_render.py b/src/st_render.py index 3a9f58f..932400c 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -12,7 +12,7 @@ """ from ld_render import render_declaration -from model import BLOCK, COIL, JUMP, LABEL, RETURN, Assign, Call, Element, Jump, Label, Series, Signal +from model import BLOCK, COIL, JUMP, LABEL, OUT_VARIABLE, RETURN, Assign, Call, Element, Jump, Label, Series, Signal from parse_ld import expr_to_text @@ -40,18 +40,34 @@ def rung_to_statements(rung): for pin, label in item.input_pins: # A label of None is the power pin, fed by the rung so far. value = condition if label is None else label + if label is None and item.power_negated and value: + # The negation bubble on the power pin itself. + value = "NOT " + _operand(value) if value: args.append("%s := %s" % (pin, value)) name = item.instance_name or item.type_name or "?" statements.append("%s(%s);" % (name, ", ".join(args))) # An assignment written straight onto an output pin executes every - # scan; the diagram draws it, so the ST must say it too. + # scan; the diagram draws it, so the ST must say it too. A negated + # pin stores its inverse. for pin, assigned in item.output_pins: if assigned: - statements.append("%s := %s.%s;" % (assigned, name, pin)) + value = "%s.%s" % (name, pin) + if pin in item.negated_outputs: + value = "NOT " + value + statements.append("%s := %s;" % (assigned, value)) condition = (name + "." + item.active_output) if item.active_output else name + if item.active_output in item.negated_outputs: + condition = "NOT " + condition elif isinstance(item, Element) and item.kind == COIL: statements.append(_coil_statement(item, condition)) + elif isinstance(item, Element) and item.kind == OUT_VARIABLE: + # A store through an outVariable element - the standard shape for + # a non-boolean result. Power passes through, like a coil. + value = condition or "TRUE" + if item.negated: + value = "NOT " + _operand(value) + statements.append("%s := %s;" % (item.label or "?", value)) elif isinstance(item, Element) and item.kind in (JUMP, RETURN): # A jump ends the rung; its guard is the rung condition so far. # Same comment form as the FBD path, so both grep alike. @@ -157,14 +173,24 @@ def _fbd_value(node, statements): if node.is_operator: # Operators and functions have no instance to call, so they inline # as an expression rather than a statement. - return _operator_expression(node, [value for _pin, value in pairs]) + expression = _operator_expression(node, [value for _pin, value in pairs]) + if node.active_output in node.negated_outputs: + expression = "NOT " + _operand(expression) + return expression name = node.instance_name statements.append("%s(%s);" % (name, ", ".join("%s := %s" % (pin, value) for pin, value in pairs))) for pin, assigned in node.outputs: if assigned: - statements.append("%s := %s.%s;" % (assigned, name, pin)) - return (name + "." + node.active_output) if node.active_output else name + value = "%s.%s" % (name, pin) + # A negated output pin stores its inverse. + if pin in node.negated_outputs: + value = "NOT " + value + statements.append("%s := %s;" % (assigned, value)) + result = (name + "." + node.active_output) if node.active_output else name + if node.active_output in node.negated_outputs: + result = "NOT " + result + return result return "?" diff --git a/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml b/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml index ba68a82..5c9e039 100644 --- a/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml +++ b/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml @@ -9,8 +9,17 @@ * a connector/continuation pair - the named wire CODESYS uses to split a network for readability. Mishandled, the upstream network vanished entirely and the consumer rendered as a fabricated FALSE assignment. + * negated="true" on a block's own input pin variable - the bubble CODESYS + draws on the pin itself, distinct from a negated inVariable element + * negated="true" on a block output pin carrying an inline assignment - + dropping it stores the inverse + * a negated inVariable holding a compound expression - NOT binds tighter + than OR in IEC 61131-3, so rendering it without parentheses regroups + the logic --> + @@ -97,6 +106,82 @@ xBoth + + + + + xRun2 + + + + + xReady2 + + + + + + + + + + + + + + + + + + operator + + + + + + + + + xMasked + + + + + + + xGo + + + + + + + + + + + + + xIdle + + + + + + + + + + xA OR xB + + + + + + + xGuard + + diff --git a/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml b/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml index 6c6c250..fd4a906 100644 --- a/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml +++ b/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml @@ -10,6 +10,10 @@ * an assignment written straight onto a block output pin - drawn in the diagram but absent from the ST, which is the half reviewers are told to trust + * a rung storing its result through an outVariable element - the store + vanished from the ST entirely, and a negated one lost its NOT + * the negation bubble on a block's own pins: a negated power input and a + negated output pin with an inline assignment both inverted silently --> + + + + + + + + + + + + + + + @@ -143,6 +162,65 @@ + + + + + + + + + + + + + xPress + + + + + + + + xStop + + + + + + + + + + + + + + + xRun + + + + + + + + + + + + + + + + + xCool + + + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 24f967f..54dd5ce 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -156,10 +156,18 @@ def failing_mkstemp(*args, **kwargs): tempfile.mkstemp = real_mkstemp real_remove = os.remove + real_sticky_mkstemp = tempfile.mkstemp + stranded = [] def failing_remove(path): raise OSError("sharing violation") + def recording_mkstemp(*args, **kwargs): + result = real_sticky_mkstemp(*args, **kwargs) + stranded.append(result[1]) + return result + + tempfile.mkstemp = recording_mkstemp os.remove = failing_remove try: sticky = FakePou("STICKY", os.path.join(FIXTURES, "LDTesting.xml")) @@ -170,6 +178,12 @@ def failing_remove(path): check("a temp-file cleanup failure is reported, not raised", False, repr(error)) finally: os.remove = real_remove + tempfile.mkstemp = real_sticky_mkstemp + # The blocked cleanup deliberately strands the temp file; without this + # the suite leaks one orphan into the real temp directory per run. + for leaked in stranded: + if os.path.exists(leaked): + os.remove(leaked) # A write that dies halfway must not leave a truncated .txt behind: the # staging folder is swapped into place wholesale, and a half-written diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index e96d5ef..a1e3318 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -221,8 +221,8 @@ def check_golden(name, rendered, golden_path): fid_st = st_render.render_pou(fid) fid_art = fbd_render.render_pou(fid) -# A connector terminates its network, so all three must survive. -check_equal("fidelity: all three networks survive", len(fid.networks), 3) +# A connector terminates its network, so all six must survive. +check_equal("fidelity: all six networks survive", len(fid.networks), 6) # negated="true" on an outVariable inverts the logic if it is dropped. check("fidelity: negated output inverts in ST", any("xInverted := NOT xIn;" in line for line in fid_st)) @@ -236,6 +236,21 @@ def check_golden(name, rendered, golden_path): check("fidelity: nothing is fabricated as FALSE", not any(":= FALSE" in line for line in fid_st)) check("fidelity: the connector's source reaches the diagram", any("xRun" in line for line in fid_art)) +# The negation bubble on a block's own input pin, distinct from a negated +# inVariable element. Dropping it computes AND where the program computes +# AND NOT. +check("fidelity: negated input pin inverts in ST", any("xMasked := xRun2 AND (NOT xReady2);" in line for line in fid_st)) +check("fidelity: negated input pin reaches the diagram", any("NOT" in line and "xReady2" in line for line in fid_art)) + +# The same bubble on an output pin carrying an inline assignment: the stored +# value is the inverse of the pin. +check("fidelity: negated output pin inverts its assignment", any("xIdle := NOT tmr.Q;" in line for line in fid_st)) +check("fidelity: negated output pin is marked in the diagram", any("Q =o> xIdle" in line for line in fid_art)) + +# NOT binds tighter than OR in IEC 61131-3, so a negated compound expression +# must keep its parentheses or the logic regroups. +check("fidelity: negated compound expression keeps its grouping", any("xGuard := NOT (xA OR xB);" in line for line in fid_st)) + # --- language dispatch ----------------------------------------------------- diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index a11100b..bdb734e 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -119,7 +119,10 @@ def check_equal(name, actual, expected): # The same must hold vertically - a renderer that started ordering rungs by y # coordinate would break the promise while the x-only check stayed green. -moved_down = source_text.replace('" y="', '" y="7') +# Only the first few positions are shifted: a uniform prefix is +# order-preserving, so shifting everything could never catch a renderer that +# sorts by coordinate - scrambling relative order is what makes this bite. +moved_down = source_text.replace('" y="', '" y="9', 3) check("the y shift touched the fixture", moved_down != source_text) try: moved_down_pou = parse_pous(io.BytesIO(moved_down.encode("utf-8")))[0] @@ -194,7 +197,7 @@ def check_golden(name, rendered_lines, golden_path): fidelity_art = render_pou(fidelity_pou) # The jump rung and the label rung must both survive as rungs at all. -check_equal("fidelity: all three rungs survive", len(fidelity_pou.rungs), 3) +check_equal("fidelity: all five rungs survive", len(fidelity_pou.rungs), 5) # A jump's target lives in a "label" attribute; losing it drew ">>?" and # emitted no ST for the whole rung, guard included. @@ -213,6 +216,18 @@ def check_golden(name, rendered_lines, golden_path): check("fidelity: output pin assignment reaches ST", any("iCount := ctr.CV;" in line for line in fidelity_st)) check("fidelity: output pin assignment is drawn", any("CV => iCount" in line for line in fidelity_art)) +# A rung can store through an outVariable element instead of a coil - the +# standard shape for a non-boolean result. It emitted no ST at all, and a +# negated one lost its NOT in the diagram too. +check("fidelity: outVariable store reaches ST", any("xStop := NOT xPress;" in line for line in fidelity_st)) +check("fidelity: negated outVariable is marked in the diagram", any("[NOT xStop]" in line for line in fidelity_art)) + +# The negation bubble on the block's own pins: a negated power input and a +# negated, assigned output pin. Both inverted silently. +check("fidelity: negated power pin inverts in ST", any("tmr2(IN := NOT xRun);" in line for line in fidelity_st)) +check("fidelity: negated output pin inverts its assignment", any("xCool := NOT tmr2.Q;" in line for line in fidelity_st)) +check("fidelity: negated output pin is marked in the diagram", any("Q =o> xCool" in line for line in fidelity_art)) + # --- byte order mark ------------------------------------------------------- From e57380d75bccbf4469645af6e4fe6f2c4c074cbc Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Wed, 5 Aug 2026 20:24:04 +1000 Subject: [PATCH 14/91] keep the NOT on flattened block outputs and spaceless compounds Second adversarial pass, three more demonstrated inversions and one cosmetic slip in the round before: - expr_to_text's block branch ignored negated_outputs, so a negated output consumed through a side pin or parallel branch - a different path from the power flow - still rendered inverted in both ST and caption. - deciding 'compound' by looking for a space missed expressions typed without them: 'NOT iCount>5' states '(NOT iCount)>5' because NOT binds above comparison. Bracketing now keys on is_simple_term (identifier, member access, literal or direct address - anything else gets brackets), shared by Signal.text, expr_to_text and _operand. - a negated power pin fed straight from the rail emitted a bare call indistinguishable from the un-negated case while the diagram drew the bubble; it now states 'IN := NOT TRUE'. - a wired negated output drew its bubble twice ('Q oo'): the pin caption now leaves the bubble to the box edge. --- src/ld_render.py | 5 +- src/model.py | 25 +++- src/parse_ld.py | 19 ++- src/st_render.py | 30 ++++- .../tests/fixtures/fbd_fidelity.plcopen.xml | 15 +++ .../tests/fixtures/ld_fidelity.plcopen.xml | 124 ++++++++++++++++++ tools/ladder/tests/test_fbd.py | 8 +- tools/ladder/tests/test_ladder.py | 15 ++- 8 files changed, 220 insertions(+), 21 deletions(-) diff --git a/src/ld_render.py b/src/ld_render.py index 37ac6c6..517a75e 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -94,10 +94,13 @@ def _render_block(element): right = [] for pin, assigned in element.output_pins: text = pin or "?" + wired_out = element.output_wired and pin == element.active_output if assigned: # =o> is => with the negation bubble: the pin stores its inverse. text += (" =o> " if pin in element.negated_outputs else " => ") + assigned - elif pin in element.negated_outputs: + elif pin in element.negated_outputs and not wired_out: + # A wired pin draws its bubble on the box edge instead - one + # bubble, not two. text += " o" right.append(text) diff --git a/src/model.py b/src/model.py index 7f9f11c..93c1cc6 100644 --- a/src/model.py +++ b/src/model.py @@ -12,6 +12,21 @@ CODESYS editor does not churn the diff. """ +import re + +# A bare identifier, member access or literal - something safe to negate or +# nest without brackets. Anything else (operators, calls, spaces) must be +# parenthesised: expressions are free-form ST, often typed without spaces, +# and NOT binds above comparison in IEC 61131-3, so "NOT iCount>5" states +# "(NOT iCount)>5". The % covers direct addresses like %IX0.0. +_SIMPLE_TERM = re.compile(r"^[A-Za-z0-9_.#%]+$") + + +def is_simple_term(text): + """True when text can be negated or nested without changing its grouping.""" + return _SIMPLE_TERM.match(text) is not None + + # Element kinds we understand. Anything else is carried through as an opaque # element so unknown logic is visibly wrong rather than silently missing. LEFT_RAIL = "leftPowerRail" @@ -133,11 +148,11 @@ def text(self): label = self.label or "" if not self.negated: return label - # NOT binds tighter than OR/AND in IEC 61131-3, so a compound - # expression must keep its parentheses or the logic regroups. - if " " in label: - return "NOT (" + label + ")" - return "NOT " + label + # A compound expression must keep its parentheses or the logic + # regroups - see is_simple_term for the precedence trap. + if is_simple_term(label): + return "NOT " + label + return "NOT (" + label + ")" def __repr__(self): return "Signal(%r, negated=%r)" % (self.label, self.negated) diff --git a/src/parse_ld.py b/src/parse_ld.py index b1f16d6..61c2176 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -23,6 +23,7 @@ Parallel, Pou, Series, + is_simple_term, parallel, series, ) @@ -128,7 +129,12 @@ def expr_to_text(expr): if isinstance(expr, Element): if expr.kind == BLOCK: base = expr.instance_name or expr.type_name or "?" - return base + "." + expr.active_output if expr.active_output else base + text = (base + "." + expr.active_output) if expr.active_output else base + # The negation bubble on the consumed output inverts what leaves + # the box - on this flattened path just like on the power flow. + if expr.active_output in expr.negated_outputs: + return "NOT " + text + return text label = expr.label or "" if expr.edge == "rising": return "R(" + label + ")" @@ -143,12 +149,13 @@ def expr_to_text(expr): def _bracket(text): """Parenthesise a compound term before negating or nesting it. - NOT binds tighter than OR/AND in IEC 61131-3, so "NOT xA OR xB" regroups - the logic that "NOT (xA OR xB)" states. + NOT binds above OR, AND and even comparison in IEC 61131-3, so both + "NOT xA OR xB" and the spaceless "NOT iCount>5" regroup the logic their + bracketed forms state. """ - if " " in text: - return "(" + text + ")" - return text + if is_simple_term(text): + return text + return "(" + text + ")" def _build_block(node, by_id, visiting, via_pin): diff --git a/src/st_render.py b/src/st_render.py index 932400c..607a43d 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -12,7 +12,22 @@ """ from ld_render import render_declaration -from model import BLOCK, COIL, JUMP, LABEL, OUT_VARIABLE, RETURN, Assign, Call, Element, Jump, Label, Series, Signal +from model import ( + BLOCK, + COIL, + JUMP, + LABEL, + OUT_VARIABLE, + RETURN, + Assign, + Call, + Element, + Jump, + Label, + Series, + Signal, + is_simple_term, +) from parse_ld import expr_to_text @@ -40,9 +55,11 @@ def rung_to_statements(rung): for pin, label in item.input_pins: # A label of None is the power pin, fed by the rung so far. value = condition if label is None else label - if label is None and item.power_negated and value: - # The negation bubble on the power pin itself. - value = "NOT " + _operand(value) + if label is None and item.power_negated: + # The negation bubble on the power pin itself. A bare + # rail feed has no condition, but the inversion must + # still be stated or the ST reads as un-negated. + value = "NOT " + _operand(value) if value else "NOT TRUE" if value: args.append("%s := %s" % (pin, value)) name = item.instance_name or item.type_name or "?" @@ -110,9 +127,10 @@ def _operand(text): """Parenthesise anything that is not a single term. Redundant brackets are preferable to an expression that reads correctly - but groups wrongly. + but groups wrongly - and "iCount>5" is as compound as "xA OR xB", see + is_simple_term. """ - return ("(" + text + ")") if " " in text else text + return text if is_simple_term(text) else "(" + text + ")" def _operator_expression(node, values): diff --git a/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml b/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml index 5c9e039..f9eb214 100644 --- a/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml +++ b/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml @@ -182,6 +182,21 @@ xGuard + + + + + iCount>5 + + + + + + + xHot + + diff --git a/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml b/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml index fd4a906..aec150c 100644 --- a/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml +++ b/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml @@ -64,6 +64,24 @@ + + + + + + + + + + + + + + + + + + @@ -221,6 +239,112 @@ + + + + + + + + + + + + + xB + + + + + + + + + + + + + + + + + + + + + + + + + + xGo2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xFin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index a1e3318..65b657f 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -221,8 +221,8 @@ def check_golden(name, rendered, golden_path): fid_st = st_render.render_pou(fid) fid_art = fbd_render.render_pou(fid) -# A connector terminates its network, so all six must survive. -check_equal("fidelity: all six networks survive", len(fid.networks), 6) +# A connector terminates its network, so all seven must survive. +check_equal("fidelity: all seven networks survive", len(fid.networks), 7) # negated="true" on an outVariable inverts the logic if it is dropped. check("fidelity: negated output inverts in ST", any("xInverted := NOT xIn;" in line for line in fid_st)) @@ -251,6 +251,10 @@ def check_golden(name, rendered, golden_path): # must keep its parentheses or the logic regroups. check("fidelity: negated compound expression keeps its grouping", any("xGuard := NOT (xA OR xB);" in line for line in fid_st)) +# Expressions are free-form ST and are routinely typed without spaces; NOT +# still binds above the comparison, so "NOT iCount>5" states (NOT iCount)>5. +check("fidelity: spaceless compound keeps its grouping", any("xHot := NOT (iCount>5);" in line for line in fid_st)) + # --- language dispatch ----------------------------------------------------- diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index bdb734e..3350f26 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -197,7 +197,7 @@ def check_golden(name, rendered_lines, golden_path): fidelity_art = render_pou(fidelity_pou) # The jump rung and the label rung must both survive as rungs at all. -check_equal("fidelity: all five rungs survive", len(fidelity_pou.rungs), 5) +check_equal("fidelity: all seven rungs survive", len(fidelity_pou.rungs), 7) # A jump's target lives in a "label" attribute; losing it drew ">>?" and # emitted no ST for the whole rung, guard included. @@ -228,6 +228,19 @@ def check_golden(name, rendered_lines, golden_path): check("fidelity: negated output pin inverts its assignment", any("xCool := NOT tmr2.Q;" in line for line in fidelity_st)) check("fidelity: negated output pin is marked in the diagram", any("Q =o> xCool" in line for line in fidelity_art)) +# A negated output consumed through a SIDE PIN goes via expr_to_text, a +# different path from the power flow - it must keep the NOT too. +check("fidelity: negated output survives into a side pin", any("RESET := xB AND NOT tmrA.Q" in line for line in fidelity_st)) +check("fidelity: side pin caption matches the ST", any("RESET := xB AND NOT tmrA.Q" in line for line in fidelity_art)) + +# A negated wired output feeding a coil, and only one bubble drawn for it. +check("fidelity: negated wired output inverts the coil", any("xFin := NOT ctr2.Q;" in line for line in fidelity_st)) +check("fidelity: no double bubble on a wired negated output", not any("Q oo" in line for line in fidelity_art)) + +# A negated power pin fed straight from the rail still states its inversion, +# instead of emitting a bare call identical to the un-negated case. +check("fidelity: rail-fed negated power pin is stated", any("tmrD(IN := NOT TRUE);" in line for line in fidelity_st)) + # --- byte order mark ------------------------------------------------------- From 5325a942b5725610c1ed163bce75025855b5963f Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 10:29:46 +1000 Subject: [PATCH 15/91] group a fan-out into one network instead of splitting it Reported from a real project: networks 5 and 6 of SOLENOID_FLAGS were two separate networks in the rendering where CODESYS shows one, with the shared expression written out in full twice. The whole POU was affected - networks 1&2, 3&4, 5&6, 7&8 and 9&10 were all one network each, giving 56 headers where the editor shows about half that. Network numbers are the first thing a reviewer lines up against the editor, so having them disagree undermines the artifact. Networks were being found one per sink. They are now found one per connected component, following wires in both directions, so every output driven by the same logic lands under one header with one comment. The parser also memoises on (localId, pin), so a shared upstream node comes back as the same object rather than two equal copies. That is what lets the ST emitter call a function block once however many outputs hang off it - a block driving two outputs is called once in the program, and emitting the call per output would misstate what runs - and lets the diagram draw the box once and branch: xRun--|IN Q|--+--> Status.Elapsed T#5S--|PT ET| +--> Status.Done Identity is what distinguishes a genuine fan-out from two coincidentally equal expressions, which is why the sharing has to happen in the parser rather than being detected later. The negation reported alongside this was already correct: a negated output bubble reads through from the real export as NOT, confirmed against the project that surfaced the split. Co-Authored-By: Claude Opus 5 --- src/fbd_render.py | 85 ++++++++-- src/model.py | 17 ++ src/parse_fbd.py | 98 +++++++++-- src/st_render.py | 54 ++++-- .../tests/fixtures/fbd_fanout.plcopen.xml | 154 ++++++++++++++++++ tools/ladder/tests/test_fbd.py | 51 +++++- 6 files changed, 412 insertions(+), 47 deletions(-) create mode 100644 tools/ladder/tests/fixtures/fbd_fanout.plcopen.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 602236b..ec68a9c 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -187,12 +187,78 @@ def _render(node): raise TypeError("cannot render %r" % (node,)) -def render_network(tree): - lines = _render(node=tree).lines - # An EXECUTE box's body is the logic; drawing the box without it would be - # an empty rectangle where a dozen lines of ST should be. - if isinstance(tree, Call) and tree.st_code: - lines = lines + [""] + [" " + line for line in tree.st_code] +def _assign_tail(node): + chars = charset.active() + # The negation circle CODESYS draws on the pin, as an "o" on the wire. + return chars["H"] * 2 + ("o " if node.negated else "> ") + (node.label or "?") + + +def _render_fanout(outputs): + """One source driving several outputs: draw it once and branch. + + This is how CODESYS shows it, and drawing the box once per output would + both misrepresent the program and double the width of the diff. + """ + chars = charset.active() + source = _render(outputs[0].source) + # A short lead before the junction, so the branch is not welded to the box + # edge. padded() extends the wire row and pads the rest with spaces. + width = source.width + 2 + lines = source.padded(width) + + rows = [source.connect_row + index for index in range(len(outputs))] + while len(lines) <= rows[-1]: + lines.append(" " * width) + + first, last = rows[0], rows[-1] + out = [] + for row, line in enumerate(lines): + if row == first: + joint = chars["T_DOWN"] if len(rows) > 1 else chars["H"] + elif row == last: + joint = chars["BL"] + elif row in rows: + joint = chars["T_RIGHT"] + elif first < row < last: + joint = chars["V"] + else: + joint = " " + tail = _assign_tail(outputs[rows.index(row)]) if row in rows else "" + out.append(line + joint + tail) + + return Block(out, first) + + +def _shared_source(outputs): + """The single source every output hangs off, or None. + + Identity, not equality: the parser memoises shared nodes, so two outputs + fed by one block hold the very same object. + """ + if len(outputs) < 2: + return None + if not all(isinstance(output, Assign) for output in outputs): + return None + first = outputs[0].source + if first is None: + return None + return first if all(output.source is first for output in outputs) else None + + +def render_network(network): + """Render one network, which may drive several outputs from one source.""" + outputs = getattr(network, "outputs", [network]) + + if _shared_source(outputs) is not None: + return _render_fanout(outputs).lines + + lines = [] + for tree in outputs: + lines.extend(_render(tree).lines) + # An EXECUTE box's body is the logic; drawing the box without it would + # be an empty rectangle where a dozen lines of ST should be. + if isinstance(tree, Call) and tree.st_code: + lines = lines + [""] + [" " + line for line in tree.st_code] return lines @@ -205,14 +271,13 @@ def render_pou(pou): lines.append("(* no networks *)") for index, network in enumerate(pou.networks): - comment, tree = network header = "(* Network " + str(index + 1) - if comment: + if network.comment: # CODESYS comments usually already start with //, which would read # oddly nested inside an ST block comment. - header += ": " + comment.lstrip("/").strip() + header += ": " + network.comment.lstrip("/").strip() lines.append(header + " *)") - lines.extend(render_network(tree)) + lines.extend(render_network(network)) lines.append("") while lines and lines[-1] == "": diff --git a/src/model.py b/src/model.py index 93c1cc6..4cd02ca 100644 --- a/src/model.py +++ b/src/model.py @@ -228,6 +228,23 @@ def __repr__(self): return "Call(%r, %r)" % (self.type_name, self.instance_name) +class Network(object): + """One FBD network: a comment, and the outputs its logic drives. + + A network can drive several outputs from shared logic - CODESYS draws that + as one box with the wire branching. Treating each output as its own + network duplicates the shared expression and makes the numbering disagree + with the editor, which is what a reviewer compares against. + """ + + def __init__(self, comment="", outputs=None): + self.comment = comment + self.outputs = outputs if outputs is not None else [] + + def __repr__(self): + return "Network(%r, %d outputs)" % (self.comment, len(self.outputs)) + + class Assign(object): """An outVariable: a network whose result is stored into a variable. diff --git a/src/parse_fbd.py b/src/parse_fbd.py index 2f0f6ad..a8e5613 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -6,7 +6,7 @@ rather than a series/parallel chain. """ -from model import BLOCK, Assign, Call, Jump, Label, Node, Pou, Signal +from model import BLOCK, Assign, Call, Jump, Label, Network, Node, Pou, Signal from plcopen import ( block_connections, block_outputs, @@ -103,7 +103,22 @@ def _negate(source): ) -def _build(node, by_id, visiting, via_pin=None): +def _build(node, by_id, visiting, via_pin=None, memo=None): + """Build the tree feeding a node. + + Results are memoised on (localId, pin) so a block feeding two outputs + yields the same object to both, which is what lets the renderers draw one + box with a branch instead of two identical boxes. + """ + if memo is None: + memo = {} + key = (node.local_id, via_pin) + if key not in memo: + memo[key] = _build_node(node, by_id, visiting, via_pin, memo) + return memo[key] + + +def _build_node(node, by_id, visiting, via_pin, memo): if node.local_id in visiting: return Signal("" % node.local_id) visiting = visiting | set([node.local_id]) @@ -114,7 +129,7 @@ def _build(node, by_id, visiting, via_pin=None): upstream = by_id.get(connection.ref_id) source = None if upstream is not None: - source = _build(upstream, by_id, visiting, connection.source_pin) + source = _build(upstream, by_id, visiting, connection.source_pin, memo) if connection.negated and source is not None: # The bubble on the pin itself, not on what feeds it. source = _negate(source) @@ -141,7 +156,7 @@ def _build(node, by_id, visiting, via_pin=None): for connection in node.inputs: upstream = by_id.get(connection.ref_id) if upstream is not None: - source = _build(upstream, by_id, visiting, connection.source_pin) + source = _build(upstream, by_id, visiting, connection.source_pin, memo) break if node.kind == OUT_VARIABLE: return Assign(node.label or "?", source, negated=node.negated) @@ -160,32 +175,83 @@ def _build(node, by_id, visiting, via_pin=None): return Signal(node.label or "", negated=node.negated) +def _component_finder(logic): + """Union-find over the wires, ignoring direction. + + Two outputs fed from one block belong to the same network, so grouping has + to follow wires backwards as well as forwards. + """ + parent = {} + for node in logic: + parent[node.local_id] = node.local_id + + def find(item): + root = item + while parent[root] != root: + root = parent[root] + while parent[item] != root: + parent[item], item = root, parent[item] + return root + + for node in logic: + for connection in node.inputs: + if connection.ref_id not in parent: + continue + left, right = find(node.local_id), find(connection.ref_id) + if left != right: + parent[left] = right + return find + + def build_networks(nodes): - """Split a flat node list into (comment, tree) per network. + """Group a flat node list into Networks. - Comments are matched to networks by document order: a comment applies to - the sink that follows it, which is how CODESYS lays out the export. + One network per connected component, not one per sink. A block driving two + outVariables is a single network in the editor; splitting it produced two + networks with the whole shared expression written out twice, and threw the + numbering out against what a reviewer sees in CODESYS. """ + logic = [node for node in nodes if node.kind != COMMENT] + by_id = {} - for node in nodes: - if node.kind != COMMENT: - by_id[node.local_id] = node + for node in logic: + by_id[node.local_id] = node + + find = _component_finder(logic) consumed = set() - for node in nodes: + for node in logic: for connection in node.inputs: consumed.add(connection.ref_id) - networks = [] - comment = "" + # A comment applies to the component whose first element follows it. + comments = {} + pending = "" for node in nodes: if node.kind == COMMENT: - comment = node.label or "" + pending = node.label or "" continue + root = find(node.local_id) + if root not in comments: + comments[root] = pending + pending = "" + + # Shared upstream nodes must come back as the same object, so the + # renderers can tell a fan-out from two coincidentally equal expressions. + memo = {} + networks = [] + by_root = {} + for node in logic: if node.local_id in consumed or node.kind not in SINK_KINDS: continue - networks.append((comment, _build(node, by_id, set()))) - comment = "" + tree = _build(node, by_id, set(), None, memo) + root = find(node.local_id) + if root in by_root: + by_root[root].outputs.append(tree) + else: + network = Network(comment=comments.get(root, ""), outputs=[tree]) + by_root[root] = network + networks.append(network) return networks diff --git a/src/st_render.py b/src/st_render.py index 607a43d..cf8020f 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -142,8 +142,14 @@ def _operator_expression(node, values): return "%s(%s)" % (node.type_name or "?", ", ".join(values)) -def _fbd_value(node, statements): - """Value of a node as ST text, appending any statements it needs first.""" +def _fbd_value(node, statements, emitted=None): + """Value of a node as ST text, appending any statements it needs first. + + ``emitted`` maps an already-rendered node to its value, so a block + feeding two outputs is called once rather than once per output. + """ + if emitted is None: + emitted = {} if node is None: return "" @@ -155,7 +161,7 @@ def _fbd_value(node, statements): return "" if isinstance(node, Jump): - condition = _fbd_value(node.condition, statements) + condition = _fbd_value(node.condition, statements, emitted) if condition: statements.append("IF %s THEN (* JMP %s *) END_IF" % (condition, node.target)) else: @@ -163,19 +169,25 @@ def _fbd_value(node, statements): return "" if isinstance(node, Assign): - value = _fbd_value(node.source, statements) or "FALSE" + value = _fbd_value(node.source, statements, emitted) or "FALSE" if node.negated: value = "NOT " + _operand(value) statements.append("%s := %s;" % (node.label or "?", value)) return node.label or "?" if isinstance(node, Call): + if id(node) in emitted: + return emitted[id(node)] pairs = [] for pin, source in node.inputs: - value = _fbd_value(source, statements) + value = _fbd_value(source, statements, emitted) if value: pairs.append((pin, value)) + def remember(value): + emitted[id(node)] = value + return value + if node.st_code: # An EXECUTE box is inline ST already, so emit it as itself rather # than as a call to a box that has no body. @@ -186,7 +198,7 @@ def _fbd_value(node, statements): statements.append("END_IF") else: statements.extend(node.st_code) - return "" + return remember("") if node.is_operator: # Operators and functions have no instance to call, so they inline @@ -194,7 +206,7 @@ def _fbd_value(node, statements): expression = _operator_expression(node, [value for _pin, value in pairs]) if node.active_output in node.negated_outputs: expression = "NOT " + _operand(expression) - return expression + return remember(expression) name = node.instance_name statements.append("%s(%s);" % (name, ", ".join("%s := %s" % (pin, value) for pin, value in pairs))) @@ -208,18 +220,27 @@ def _fbd_value(node, statements): result = (name + "." + node.active_output) if node.active_output else name if node.active_output in node.negated_outputs: result = "NOT " + result - return result + return remember(result) return "?" -def network_to_statements(tree): +def network_to_statements(network): + """Statements for one network, which may drive several outputs. + + The shared logic is emitted once: a function block feeding two outputs is + called once in the program, so calling it twice here would misrepresent + it. Plain expressions still repeat, which is what ST would say anyway. + """ statements = [] - value = _fbd_value(tree, statements) - if not statements and value: - # A bare expression with nothing to assign it to - keep it visible - # rather than dropping the network entirely. - statements.append("(* " + value + " *)") + emitted = {} + for tree in getattr(network, "outputs", [network]): + before = len(statements) + value = _fbd_value(tree, statements, emitted) + if len(statements) == before and value: + # A bare expression with nothing to assign it to - keep it visible + # rather than dropping it entirely. + statements.append("(* " + value + " *)") return statements @@ -241,9 +262,8 @@ def render_pou(pou): lines.append("") for index, network in enumerate(pou.networks): - comment, tree = network - lines.append(_network_header(index, comment)) - lines.extend(network_to_statements(tree)) + lines.append(_network_header(index, network.comment)) + lines.extend(network_to_statements(network)) lines.append("") if not pou.rungs and not pou.networks: diff --git a/tools/ladder/tests/fixtures/fbd_fanout.plcopen.xml b/tools/ladder/tests/fixtures/fbd_fanout.plcopen.xml new file mode 100644 index 0000000..d58af15 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_fanout.plcopen.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + Conveyor off is the opposite of conveyor on + + + + + + Flags.FwdSolOn + + + + + Flags.RevSolOn + + + + + + + + + + + + + + + + + + operator + + + + + + + + + Flags.ConvOn + + + + + + + Flags.ConvOff + + + + + + + Run timer + + + + + + xRun + + + + + T#5S + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + Status.Done + + + + + + + Status.Latched + + + + + + + Raw.Level + + + + + Status.Level + + + + + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 65b657f..5288f23 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -80,7 +80,7 @@ def check_golden(name, rendered, golden_path): check_equal("namespaced derived type", pou.variables[1].type_name, "ifmIOcommon.SystemSupply") # Comments carry the network's intent and nest their text in an xhtml element. -comment1, tree1 = pou.networks[0] +comment1, tree1 = pou.networks[0].comment, pou.networks[0].outputs[0] check("network 1 comment is captured", comment1.startswith("// Function Block to monitor supply voltage")) check("network 1 is a call", isinstance(tree1, Call)) @@ -102,7 +102,7 @@ def check_golden(name, rendered, golden_path): check_equal("output assignment is captured", outputs["uiOutVoltage"], "uiCurrSupplyVolt") # Network 2 nests three calls: GT -> TOF -> SupplySwitch. -comment2, tree2 = pou.networks[1] +comment2, tree2 = pou.networks[1].comment, pou.networks[1].outputs[0] check_equal("network 2 root", tree2.instance_name, "fbSupplySwitch") tof = tree2.inputs[1][1] check_equal("nested TOF", tof.instance_name, "TOF_0") @@ -189,13 +189,13 @@ def check_golden(name, rendered, golden_path): check("flow: the label is shown", any("(* label: END *)" in line for line in flow_st)) # negated="true" on an inVariable inverts the logic if it is ignored. -guard = flow.networks[0][1] +guard = flow.networks[0].outputs[0] check_equal("flow: negation reaches the tree", guard.condition.inputs[0][1].negated, True) check_equal("flow: negation renders", guard.condition.inputs[0][1].text, "NOT xInitDone") check("flow: negation survives into ST", any("(NOT xInitDone) OR" in line for line in flow_st)) # An EXECUTE box is nothing but inline ST; drawing the box alone loses it all. -execute = flow.networks[3][1] +execute = flow.networks[3].outputs[0] check_equal("flow: inline ST is captured", len(execute.st_code), 4) check("flow: inline ST reaches the ST output", any("Status.Faulted := FALSE;" in line for line in flow_st)) # The EN pin genuinely guards the box, so it has to show up as a condition @@ -256,6 +256,49 @@ def check_golden(name, rendered, golden_path): check("fidelity: spaceless compound keeps its grouping", any("xHot := NOT (iCount>5);" in line for line in fid_st)) +# --- fan-out --------------------------------------------------------------- + +# One source driving several outputs is a single network in the editor. +# Treating each output as its own network split every one of them in two and +# duplicated the shared expression, so the numbering disagreed with CODESYS. +FANOUT = os.path.join(HERE, "fixtures", "fbd_fanout.plcopen.xml") +fan = parse_fbd.parse_pous(FANOUT)[0] +fan_st = st_render.render_pou(fan) +fan_art = fbd_render.render_pou(fan) + +check_equal("fanout: three networks, not five", len(fan.networks), 3) +check_equal("fanout: the OR drives two outputs", len(fan.networks[0].outputs), 2) +check_equal("fanout: the timer drives two outputs", len(fan.networks[1].outputs), 2) +check_equal("fanout: a plain network keeps one", len(fan.networks[2].outputs), 1) + +# Both outputs of a network sit under its one header, with its one comment. +header_rows = [row for row, line in enumerate(fan_st) if line.startswith("(* Network")] +check_equal("fanout: three headers, not five", len(header_rows), 3) +check("fanout: the comment lands on the network", "Conveyor off is the opposite" in fan_st[header_rows[0]]) +check_equal( + "fanout: both stores share a header", + fan_st[header_rows[0] + 1 : header_rows[0] + 3], + [ + "Flags.ConvOn := Flags.FwdSolOn OR Flags.RevSolOn;", + "Flags.ConvOff := NOT (Flags.FwdSolOn OR Flags.RevSolOn);", + ], +) + +# The sharper case: the block is called once in the program, so emitting the +# call per output would misstate what runs. +check_equal("fanout: the block is called once", len([l for l in fan_st if l.startswith("TON_0(")]), 1) +check("fanout: both stores are still made", "Status.Done := TON_0.Q;" in fan_st and "Status.Latched := TON_0.Q;" in fan_st) + +# The shared source is drawn once and branched, not drawn per output. +check_equal("fanout: one OR box is drawn", len([l for l in fan_art if "In1 Out1" in l]), 1) +check("fanout: the branch is drawn", any(U["T_DOWN"] in l and "Flags.ConvOn" in l for l in fan_art)) +check("fanout: the negated leg keeps its bubble", any(U["BL"] in l and "o Flags.ConvOff" in l for l in fan_art)) + +# Identity, not equality, is what tells a fan-out from two equal expressions. +first, second = fan.networks[0].outputs +check("fanout: shared nodes are one object", first.source is second.source) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) From 886f4717b76d4ff632d21dce202e84c9ac8aeac7 Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 10:36:09 +1000 Subject: [PATCH 16/91] write only the diagram, not an ST rendering alongside it The derived .txt held the equivalent Structured Text followed by the diagram. On a real project that reads worse than either alone: the same network appears twice in two notations, and a reader has to work out that they are the same thing rather than two steps. The export now writes the declaration and the diagrams. tools/ladder/render.py defaults to the same, so the CLI and the export agree about what a rendering is, with --format st and --format both still there for anyone who wants the ST view of a file. The ST emitter itself stays. It is tested, it is the only rendering that survives a network too wide to draw, and SFC will want a textual form for step actions. It is simply not what the export writes. Co-Authored-By: Claude Opus 5 --- README.md | 14 ++++++-------- src/graphical_export.py | 16 +++++++++------- tools/ladder/render.py | 10 +++++----- tools/ladder/tests/test_export.py | 9 ++++++--- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7285399..0b85461 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,9 @@ Actions and Transitions export as `.st` with the kind encoded in the filename (` ### Reading graphical POUs -Ladder and Function Block Diagram POUs have no textual implementation, so they export as native xml that git can store but nobody can review. Alongside that xml, CODESCRIBE writes a `.txt` holding the equivalent Structured Text followed by a diagram: +Ladder and Function Block Diagram POUs have no textual implementation, so they export as native xml that git can store but nobody can review. Alongside that xml, CODESCRIBE writes a `.txt` holding the declaration and a diagram of each network: ``` -(* Network 2 *) -TON_0(IN := PowerOn, PT := T#5S); -CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); -IF CTU_0.Q THEN PowerOff := FALSE; END_IF - (* Network 2 *) │ TON_0 : TON CTU_0 : CTU │ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff @@ -84,10 +79,13 @@ This file is **derived and read-only**. The native xml remains the only thing `I SFC and CFC POUs are not yet rendered; they export as native xml alone. -To render an exported PLCopen file by hand, or to get plain ASCII instead of box drawing: +Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor. + +To render an exported PLCopen file by hand, to get plain ASCII instead of box drawing, or to see the equivalent Structured Text (which the export does not write, since showing each network twice in two notations reads worse than showing it once): ``` -python tools/ladder/render.py --format st --charset ascii MyPou.xml +python tools/ladder/render.py --charset ascii MyPou.xml +python tools/ladder/render.py --format st MyPou.xml ``` Visualisations export as `.vis.xml`, so a `Main` visualisation cannot collide with a `Main` POU. diff --git a/src/graphical_export.py b/src/graphical_export.py index 8b92783..b2dacdc 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -24,7 +24,6 @@ import parse_fbd import parse_ld import plcopen -import st_render from util import open_utf8 # Suffix for the derived file. Deliberately not .st: these are not importable @@ -80,14 +79,17 @@ def _render_pous(plcopen_path): def render_plcopen(plcopen_path): - """Render every renderable POU in a PLCopen file. [] if there are none.""" + """Render every renderable POU in a PLCopen file. [] if there are none. + + The declaration and the diagram only. An equivalent-ST rendering was + written alongside these at first, but showing the same network twice in + two notations made the files harder to read rather than easier. The ST + emitter is still there and reachable from tools/ladder/render.py for + anyone who wants it; it is just not what the export writes. + """ lines = [] for pou, art_renderer in _render_pous(plcopen_path): - lines.extend(st_render.render_pou(pou)) - lines.append(u"") - # The diagram repeats the declaration, which is noise the second time. - declaration_length = len(ld_render.render_declaration(pou)) - lines.extend(art_renderer.render_pou(pou)[declaration_length:]) + lines.extend(art_renderer.render_pou(pou)) lines.append(u"") while lines and lines[-1] == u"": diff --git a/tools/ladder/render.py b/tools/ladder/render.py index b09c67d..28b342e 100644 --- a/tools/ladder/render.py +++ b/tools/ladder/render.py @@ -3,9 +3,9 @@ python tools/ladder/render.py [options] [...] - --format art|st|both art diagrams, close to the CODESYS layout - st equivalent Structured Text - diffs and greps - both ST first, then the diagram (the default) + --format art|st|both art diagrams, as the export writes them (default) + st equivalent Structured Text + both the ST followed by the diagram --charset unicode|ascii box-drawing characters (the default), or plain ASCII for terminals and diff viewers that mangle @@ -46,7 +46,7 @@ def _pous(path): return found -def render_file(path, output_format="both"): +def render_file(path, output_format="art"): lines = [] for pou, art_renderer in _pous(path): if output_format in ("st", "both"): @@ -79,7 +79,7 @@ def write(lines, stream=None): def main(argv): - output_format = "both" + output_format = "art" paths = [] index = 0 while index < len(argv): diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 54dd5ce..1aedefd 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -97,10 +97,13 @@ def read(path): check_equal("export_xml is asked for a single object", pou.export_calls[0][1], False) content = read(base + ".txt") - check("derived file leads with ST", content.startswith("PROGRAM LD_TEST")) - check("derived file contains the ST equivalent", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" in content) + check("derived file leads with the declaration", content.startswith("PROGRAM LD_TEST")) + # Diagram only. Rendering the same network twice, once as ST and once as a + # diagram, made the files harder to read rather than easier. + check("no ST rendering is written", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" not in content) + check("networks are numbered", "(* Network 1 *)" in content) check("derived file contains the diagram", "TON_0 : TON" in content) - check("declaration is not repeated", content.count("END_VAR") == 1) + check("the declaration appears once", content.count("END_VAR") == 1) check("derived file ends with a newline", content.endswith("\n")) # The temp PLCopen file is staged outside the export folder, so nothing but From 58bce55fe5bf825f0e49ec84ab4ab6e13569c9ee Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 11:13:31 +1000 Subject: [PATCH 17/91] split the cost report into parsing and drawing The first real measurement contradicted the guess. Rendering 25 POUs took 6.8s, of which CODESYS's export_xml was 0.2s and this code was 6.6s - the opposite of what the previous commit message asserted. Reporting "rendering" as one figure was not enough to say which half of it that is. Under CPython the split is 0.73ms parsing against 0.08ms drawing, so parsing is ~90% even with expat behind it. CODESYS's bundled ElementTree is the xmllib-era one, which parses in pure Python; that would account for the two orders of magnitude, and would mean the fix is the XML backend rather than anything in the layout code. The next export's split settles it either way, which is the point of measuring rather than assuming twice. Co-Authored-By: Claude Opus 5 --- src/graphical_export.py | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/graphical_export.py b/src/graphical_export.py index b2dacdc..3e2ac94 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -33,22 +33,38 @@ # Rendering adds a second CODESYS-side export per graphical POU, so the cost # is worth reporting rather than leaving people to wonder why the export got # slower. Split so it is obvious whether CODESYS or this code is the cost. -STATS = {"rendered": 0, "skipped": 0, "export_xml_seconds": 0.0, "render_seconds": 0.0} +EMPTY_STATS = { + "rendered": 0, + "skipped": 0, + "export_xml_seconds": 0.0, + "parse_seconds": 0.0, + "draw_seconds": 0.0, +} + +STATS = dict(EMPTY_STATS) def reset_stats(): - STATS.update({"rendered": 0, "skipped": 0, "export_xml_seconds": 0.0, "render_seconds": 0.0}) + STATS.update(EMPTY_STATS) def summary(): - """One line describing what rendering cost, or None if it did nothing.""" + """One line describing what rendering cost, or None if it did nothing. + + Split three ways because the first measurement overturned the guess: the + CODESYS-side export turned out to be a rounding error next to this code, + and "rendering" as a single figure does not say whether that is the XML + parser or the layout. + """ if not STATS["rendered"] and not STATS["skipped"]: return None - return "Rendered %d graphical POUs in %.1fs (%.1fs CODESYS export_xml, %.1fs rendering); skipped %d" % ( + total = STATS["export_xml_seconds"] + STATS["parse_seconds"] + STATS["draw_seconds"] + return "Rendered %d graphical POUs in %.1fs (%.1fs CODESYS export_xml, %.1fs parsing, %.1fs drawing); skipped %d" % ( STATS["rendered"], - STATS["export_xml_seconds"] + STATS["render_seconds"], + total, STATS["export_xml_seconds"], - STATS["render_seconds"], + STATS["parse_seconds"], + STATS["draw_seconds"], STATS["skipped"], ) @@ -87,13 +103,19 @@ def render_plcopen(plcopen_path): emitter is still there and reachable from tools/ladder/render.py for anyone who wants it; it is just not what the export writes. """ + started = time.time() + pous = _render_pous(plcopen_path) + STATS["parse_seconds"] += time.time() - started + + started = time.time() lines = [] - for pou, art_renderer in _render_pous(plcopen_path): + for pou, art_renderer in pous: lines.extend(art_renderer.render_pou(pou)) lines.append(u"") while lines and lines[-1] == u"": lines.pop() + STATS["draw_seconds"] += time.time() - started return lines @@ -133,18 +155,16 @@ def write_rendered_text(obj, base_path): obj.export_xml(path=temp_path, recursive=False) STATS["export_xml_seconds"] += time.time() - started - started = time.time() + # render_plcopen accounts for its own parse and draw time. lines = render_plcopen(temp_path) if not lines: STATS["skipped"] += 1 - STATS["render_seconds"] += time.time() - started return False with open_utf8(base_path + RENDERED_SUFFIX, "w") as f: f.write(u"\n".join(lines)) f.write(u"\n") STATS["rendered"] += 1 - STATS["render_seconds"] += time.time() - started return True except Exception as error: print("WARNING: could not render " + obj.get_name() + ": " + repr(error)) From 7562558f22f7f6a898567e65bbd776e06032ac0d Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 11:20:27 +1000 Subject: [PATCH 18/91] parse XML through .NET where it is available The measurement pinned the cost precisely: 25 POUs took 6.8s, of which 6.4s was parsing and 0.3s was drawing. The layout code was never the problem. The ElementTree CODESYS ships in ScriptLib is the xmllib-era one that parses in pure Python, which is two orders of magnitude slower than expat. IronPython runs on .NET, so System.Xml is already there and native. This adds a backend that uses it when available and keeps ElementTree for CPython, behind the one module that already touched XML. Only the slice of the ElementTree element API this project uses is implemented, and the parts that are easy to get subtly wrong are pinned by tests: an absent attribute must read as None rather than "" because callers distinguish those, and .text must stop at the first child element rather than flattening descendants the way InnerText would. The goldens are generated under CPython and consumed by CODESYS, so a disagreement between the backends would render something in CODESYS that no test ever saw. test_xmlbackend.py therefore parses every fixture with both and compares the trees, the attributes and the rendered output. That can only run where both exist, which is the IronPython CI job; everywhere else it prints a SKIPPED banner rather than passing quietly. No external DTD is ever fetched: a POU export should not be able to make CODESYS reach out to the network while someone clicks Export. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 + src/plcopen.py | 4 +- src/xmlbackend.py | 145 +++++++++++++++++++++++++ tools/ci/import_smoke.py | 1 + tools/ladder/tests/test_xmlbackend.py | 146 ++++++++++++++++++++++++++ 5 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 src/xmlbackend.py create mode 100644 tools/ladder/tests/test_xmlbackend.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90ca154..f991ad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,10 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } .\ipy\net45\ipy.exe tools\ladder\tests\test_export.py if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # The only host with both XML backends, so the only place their + # equivalence can actually be checked. + .\ipy\net45\ipy.exe tools\ladder\tests\test_xmlbackend.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ladder: name: ladder @@ -55,3 +59,4 @@ jobs: python3 tools/ladder/tests/test_ladder.py python3 tools/ladder/tests/test_fbd.py python3 tools/ladder/tests/test_export.py + python3 tools/ladder/tests/test_xmlbackend.py diff --git a/src/plcopen.py b/src/plcopen.py index bc8b033..a3f60e5 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -17,7 +17,7 @@ # CODESYS's own bundled library - so it is silenced at the point it fires. with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - import xml.etree.ElementTree as ET + import xmlbackend from model import Connection @@ -348,7 +348,7 @@ def describe_suspect_characters(source, limit=5): def iter_bodies(source): """Yield (pou_elem, language, body_elem) for every POU with an implementation.""" - root = ET.fromstring(read_document(source)) + root = xmlbackend.parse(read_document(source)) for elem in root.iter(): if tag(elem) != "pou": continue diff --git a/src/xmlbackend.py b/src/xmlbackend.py new file mode 100644 index 0000000..0cea5fd --- /dev/null +++ b/src/xmlbackend.py @@ -0,0 +1,145 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Parse XML with whatever the host can do fastest. + +CODESYS puts its own ScriptLib ahead of the standard library, and the +ElementTree it ships there is the xmllib-era one that parses in pure Python. +Measured on a real project: 25 POUs took 6.8s, of which 6.4s was parsing and +0.3s was drawing the diagrams. The layout code was never the problem. + +IronPython runs on .NET, so System.Xml is right there and native. This picks +it when it is available and falls back to ElementTree otherwise, which is +what CPython uses when running the tests. + +The two backends must agree exactly, because the golden files are generated +under CPython and consumed by CODESYS. test_xmlbackend.py compares them +element for element wherever both are available - which is the IronPython CI +job, the only place that can. + +Only the small slice of the ElementTree API this project actually uses is +implemented: a tag, attributes, leading text, iteration over child elements, +and a recursive walk. +""" + +import xml.etree.ElementTree as ET + +ELEMENT_TREE = "ElementTree" +SYSTEM_XML = "System.Xml" + +try: + import clr + + clr.AddReference("System.Xml") + from System import Array, Byte + from System.IO import MemoryStream + from System.Xml import XmlDocument, XmlNodeType + + _SYSTEM_XML_AVAILABLE = True +except Exception: # pragma: no cover - only reachable off IronPython + _SYSTEM_XML_AVAILABLE = False + + +class _DotNetElement(object): + """The slice of the ElementTree element API this project uses.""" + + __slots__ = ("_node",) + + def __init__(self, node): + self._node = node + + @property + def tag(self): + # LocalName drops the namespace, which is what plcopen.tag() would + # have stripped anyway. + return self._node.LocalName + + def get(self, name, default=None): + attributes = self._node.Attributes + if attributes is None: + return default + found = attributes.GetNamedItem(name) + # An absent attribute must be None rather than "": callers use + # "is None" to tell "not written" from "written empty". + return found.Value if found is not None else default + + @property + def text(self): + """Text before the first child element, as ElementTree defines it. + + Not InnerText, which would flatten descendants and make the two + backends disagree on mixed content. + """ + parts = [] + for child in self._node.ChildNodes: + node_type = child.NodeType + if node_type == XmlNodeType.Element: + break + if node_type in ( + XmlNodeType.Text, + XmlNodeType.CDATA, + XmlNodeType.Whitespace, + XmlNodeType.SignificantWhitespace, + ): + parts.append(child.Value) + return "".join(parts) if parts else None + + def __iter__(self): + for child in self._node.ChildNodes: + if child.NodeType == XmlNodeType.Element: + yield _DotNetElement(child) + + def iter(self): + yield self + for child in self: + for descendant in child.iter(): + yield descendant + + +def _parse_dotnet(data): + document = XmlDocument() + # Never fetch an external DTD: a POU export should not be able to make + # CODESYS reach out to the network while someone clicks Export. + document.XmlResolver = None + stream = MemoryStream(Array[Byte](bytearray(data))) + try: + document.Load(stream) + finally: + stream.Close() + return _DotNetElement(document.DocumentElement) + + +def _parse_element_tree(data): + return ET.fromstring(data) + + +def available(): + """Backend names this host can use, fastest first.""" + names = [] + if _SYSTEM_XML_AVAILABLE: + names.append(SYSTEM_XML) + names.append(ELEMENT_TREE) + return names + + +_PARSERS = {SYSTEM_XML: _parse_dotnet, ELEMENT_TREE: _parse_element_tree} + +_active = available()[0] + + +def use(name): + """Force a backend. Returns the previous one, so tests can restore it.""" + global _active + if name not in _PARSERS: + raise ValueError("unknown xml backend %r" % (name,)) + if name == SYSTEM_XML and not _SYSTEM_XML_AVAILABLE: + raise ValueError("System.Xml is not available on this host") + previous, _active = _active, name + return previous + + +def active(): + return _active + + +def parse(data, backend=None): + """Parse document bytes and return the root element.""" + return _PARSERS[backend or _active](data) diff --git a/tools/ci/import_smoke.py b/tools/ci/import_smoke.py index 5751b7c..2963ce1 100644 --- a/tools/ci/import_smoke.py +++ b/tools/ci/import_smoke.py @@ -29,6 +29,7 @@ # Renderers for graphical POUs. No scriptengine dependency of their own, # but they have to load under IronPython 2.7 like everything else here. "charset", + "xmlbackend", "layout", "model", "plcopen", diff --git a/tools/ladder/tests/test_xmlbackend.py b/tools/ladder/tests/test_xmlbackend.py new file mode 100644 index 0000000..79bdf2e --- /dev/null +++ b/tools/ladder/tests/test_xmlbackend.py @@ -0,0 +1,146 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Tests for the XML backend, and for the two backends agreeing. + +The golden files are generated under CPython with ElementTree and consumed by +CODESYS with System.Xml. If the backends disagree anywhere, CODESYS silently +renders something the goldens never saw. So the important test here can only +run where both backends exist - the IronPython CI job - and it is written to +report loudly when it is skipped rather than passing quietly. + + python tools/ladder/tests/test_xmlbackend.py +""" + +from __future__ import print_function, unicode_literals + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) +sys.path.insert(0, os.path.join(HERE, "..")) + +import plcopen # noqa: E402 +import xmlbackend # noqa: E402 + +FIXTURES = os.path.join(HERE, "fixtures") +CODESYS = os.path.join(FIXTURES, "codesys") + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + else: + failures.append(name) + print("FAIL " + name + ((": " + detail) if detail else "")) + + +def check_equal(name, actual, expected): + check(name, actual == expected, "expected %r, got %r" % (expected, actual)) + + +def every_fixture(): + paths = [] + for folder in (FIXTURES, CODESYS): + for name in sorted(os.listdir(folder)): + if name.endswith(".xml"): + paths.append(os.path.join(folder, name)) + return paths + + +SAMPLE = ( + b'' + b'' + b"texttail" + b"" + b"" +) + + +# --- the active backend behaves like ElementTree --------------------------- + +print("available backends: " + ", ".join(xmlbackend.available())) +print("active backend: " + xmlbackend.active()) + +root = xmlbackend.parse(SAMPLE) + +check_equal("namespace is stripped from the tag", plcopen.tag(root), "root") +first = list(root)[0] +check_equal("attributes read back", first.get("name"), "one") +# "not written" and "written empty" are different, and callers rely on it. +check_equal("an absent attribute is None", first.get("missing"), None) +check_equal("an empty attribute is not None", first.get("empty"), "") +check_equal("a default is honoured", first.get("missing", "fallback"), "fallback") +# Leading text only, as ElementTree defines it - not a flattened InnerText, +# which would fold "tail" in and make the backends disagree. +check_equal("text stops at the first child element", first.text, "text") +check_equal("an element with no text is None", list(root)[1].text, None) +check_equal("iteration yields child elements", len(list(root)), 2) +check_equal("iter walks the whole tree", len(list(root.iter())), 4) + + +# --- the two backends must agree ------------------------------------------- + + +def describe(elem): + """A comparable shape for an element tree.""" + children = [describe(child) for child in elem] + return (plcopen.tag(elem), elem.text, children) + + +def attribute_values(elem, names): + return [elem.get(name) for name in names] + + +if len(xmlbackend.available()) < 2: + # Not a pass. The comparison below is the whole point of this file, and it + # cannot run here. + print("") + print("SKIPPED the backend comparison: only %s is available on this host." % xmlbackend.active()) + print(" It runs in the IronPython CI job, where System.Xml exists.") + print("") +else: + for path in every_fixture(): + name = os.path.basename(path) + data = plcopen.read_document(path) + + one = xmlbackend.parse(data, xmlbackend.ELEMENT_TREE) + two = xmlbackend.parse(data, xmlbackend.SYSTEM_XML) + check(name + ": both backends build the same tree", describe(one) == describe(two)) + + # Shape equality would not catch attributes, which is where most of + # the parsing decisions actually live. + interesting = ("localId", "refLocalId", "formalParameter", "negated", "typeName", "name", "edge", "storage") + left = [attribute_values(e, interesting) for e in one.iter()] + right = [attribute_values(e, interesting) for e in two.iter()] + check(name + ": both backends read the same attributes", left == right) + + # The contract that actually matters: identical rendered output. + import fbd_render # noqa: E402 + import ld_render # noqa: E402 + import parse_fbd # noqa: E402 + import parse_ld # noqa: E402 + + for path in every_fixture(): + name = os.path.basename(path) + rendered = {} + for backend in (xmlbackend.ELEMENT_TREE, xmlbackend.SYSTEM_XML): + previous = xmlbackend.use(backend) + try: + lines = [] + for pou in parse_ld.parse_pous(path): + lines.extend(ld_render.render_pou(pou)) + for pou in parse_fbd.parse_pous(path): + lines.extend(fbd_render.render_pou(pou)) + rendered[backend] = lines + finally: + xmlbackend.use(previous) + check(name + ": both backends render identically", rendered[xmlbackend.ELEMENT_TREE] == rendered[xmlbackend.SYSTEM_XML]) + +print("") +if failures: + print("%d check(s) failed" % len(failures)) +else: + print("all checks passed") +sys.exit(1 if failures else 0) From bbe30523c23cddf88305fc0b16b6fc101670c149 Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 11:23:04 +1000 Subject: [PATCH 19/91] keep whitespace so both XML backends agree The equivalence test failed on its first CI run, which is the point of having written it. XmlDocument drops insignificant whitespace by default, so an element whose only content is a newline and indentation reported no text where ElementTree reported "\n ". Every current caller strips that text, so the rendered output was already identical - the "render identically" checks all passed while the tree comparison failed. That is exactly the kind of latent difference that stays harmless until some later caller stops stripping, and then diverges only inside CODESYS where no test would see it. Co-Authored-By: Claude Opus 5 --- src/xmlbackend.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/xmlbackend.py b/src/xmlbackend.py index 0cea5fd..bbc6e15 100644 --- a/src/xmlbackend.py +++ b/src/xmlbackend.py @@ -96,6 +96,13 @@ def iter(self): def _parse_dotnet(data): document = XmlDocument() + # XmlDocument drops insignificant whitespace by default, so an element + # whose only content is a newline and some indentation would report no + # text at all where ElementTree reports "\n ". Harmless for every + # current caller, since they all strip - but the backends have to agree + # about what the document says, not merely about what today's callers + # make of it. + document.PreserveWhitespace = True # Never fetch an external DTD: a POU export should not be able to make # CODESYS reach out to the network while someone clicks Export. document.XmlResolver = None From e245375f89a80af005c6e8a0d5af8023861dd1a4 Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 11:25:03 +1000 Subject: [PATCH 20/91] report where the two XML backends disagree A bare pass/fail sent me back to CI to guess, twice. The comparison now names the element and the field, which is the only way to debug a difference that only exists on a host neither a laptop nor the Python 3 job can reproduce. Co-Authored-By: Claude Opus 5 --- tools/ladder/tests/test_xmlbackend.py | 35 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tools/ladder/tests/test_xmlbackend.py b/tools/ladder/tests/test_xmlbackend.py index 79bdf2e..6b041df 100644 --- a/tools/ladder/tests/test_xmlbackend.py +++ b/tools/ladder/tests/test_xmlbackend.py @@ -83,10 +83,34 @@ def every_fixture(): # --- the two backends must agree ------------------------------------------- -def describe(elem): - """A comparable shape for an element tree.""" - children = [describe(child) for child in elem] - return (plcopen.tag(elem), elem.text, children) +def first_difference(left, right, path="/"): + """Where two trees first disagree, or None. Reported, not just counted. + + A bare "the trees differ" sends whoever sees it back to CI to guess again; + the whole value of this test is that it can say which element and which + field, in a place no debugger reaches. + """ + left_tag, right_tag = plcopen.tag(left), plcopen.tag(right) + if left_tag != right_tag: + return "%s tag %r vs %r" % (path, left_tag, right_tag) + if left.text != right.text: + return "%s<%s> text %r vs %r" % (path, left_tag, left.text, right.text) + left_children, right_children = list(left), list(right) + if len(left_children) != len(right_children): + return "%s<%s> child count %d vs %d (%r vs %r)" % ( + path, + left_tag, + len(left_children), + len(right_children), + [plcopen.tag(c) for c in left_children][:6], + [plcopen.tag(c) for c in right_children][:6], + ) + for index in range(len(left_children)): + child_path = "%s%s[%d]/" % (path, plcopen.tag(left_children[index]), index) + found = first_difference(left_children[index], right_children[index], child_path) + if found: + return found + return None def attribute_values(elem, names): @@ -107,7 +131,8 @@ def attribute_values(elem, names): one = xmlbackend.parse(data, xmlbackend.ELEMENT_TREE) two = xmlbackend.parse(data, xmlbackend.SYSTEM_XML) - check(name + ": both backends build the same tree", describe(one) == describe(two)) + difference = first_difference(one, two) + check(name + ": both backends build the same tree", difference is None, difference or "") # Shape equality would not catch attributes, which is where most of # the parsing decisions actually live. From c664eb5d7dfa4b337b7c5a705140f108557b597d Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 11:26:43 +1000 Subject: [PATCH 21/91] normalise line endings in the .NET backend's text The reported difference was '\n ' against '\r\n '. XML requires a parser to normalise line endings to \n and ElementTree does; XmlDocument does too, except for the whitespace nodes PreserveWhitespace keeps, which come back with CR intact. Not a corner case: real CODESYS exports are CRLF throughout, so every element with children would have disagreed between the two backends. Co-Authored-By: Claude Opus 5 --- src/xmlbackend.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/xmlbackend.py b/src/xmlbackend.py index bbc6e15..c28d4ec 100644 --- a/src/xmlbackend.py +++ b/src/xmlbackend.py @@ -80,7 +80,16 @@ def text(self): XmlNodeType.SignificantWhitespace, ): parts.append(child.Value) - return "".join(parts) if parts else None + if not parts: + return None + text = "".join(parts) + # XML requires a parser to normalise line endings to \n, and + # ElementTree does. XmlDocument does too - except for the whitespace + # nodes PreserveWhitespace keeps, which come back with CR intact. Real + # CODESYS exports are CRLF throughout, so this is not a corner case. + if "\r" in text: + text = text.replace("\r\n", "\n").replace("\r", "\n") + return text def __iter__(self): for child in self._node.ChildNodes: From 4f408942f50fc22087ad200026e9d8e7fbc1f9c5 Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 11:35:03 +1000 Subject: [PATCH 22/91] stop walking the whole document to find two POUs Parsing came down from 6.4s to 1.9s for 25 POUs on the native backend, but 1.9s is still more than the parse itself should cost. Two wastes account for a good part of it, and both are worse under a backend whose elements are wrapped in Python objects. iter_bodies scanned every element in the document looking for pou tags - a few thousand nodes touched per file to reach one or two. PLCopen puts them at project/types/pous/pou, so it now goes straight there, keeping the full walk as a fallback for any layout that does not match. The .NET wrapper also re-wrapped every child on each iteration, and the parsers call find_child several times on the same element - a block asks for inputVariables, inOutVariables and outputVariables in turn. Children are now wrapped once and kept. iter() also swaps recursive generators for an explicit stack, since delegating a yield up through every level of a deep document costs more than the walk. Its document order is now pinned by a test, because that is easy to get backwards. Co-Authored-By: Claude Opus 5 --- src/plcopen.py | 23 +++++++++++++++++--- src/xmlbackend.py | 31 ++++++++++++++++++++------- tools/ladder/tests/test_xmlbackend.py | 13 +++++++++++ 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/plcopen.py b/src/plcopen.py index a3f60e5..f75da17 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -346,12 +346,29 @@ def describe_suspect_characters(source, limit=5): return notes +def find_pous(root): + """POU elements, without walking the whole document to find them. + + PLCopen puts them at project/types/pous/pou. Scanning every element + instead meant touching a few thousand nodes per file to reach one or two, + which is pure waste under any backend and expensive under one whose + elements are wrapped in Python objects. The full walk stays as a fallback + for any layout that does not match. + """ + types = find_child(root, "types") + if types is not None: + pous = find_child(types, "pous") + if pous is not None: + found = [child for child in pous if tag(child) == "pou"] + if found: + return found + return [elem for elem in root.iter() if tag(elem) == "pou"] + + def iter_bodies(source): """Yield (pou_elem, language, body_elem) for every POU with an implementation.""" root = xmlbackend.parse(read_document(source)) - for elem in root.iter(): - if tag(elem) != "pou": - continue + for elem in find_pous(root): body = find_child(elem, "body") if body is None: continue diff --git a/src/xmlbackend.py b/src/xmlbackend.py index c28d4ec..be8c157 100644 --- a/src/xmlbackend.py +++ b/src/xmlbackend.py @@ -41,10 +41,11 @@ class _DotNetElement(object): """The slice of the ElementTree element API this project uses.""" - __slots__ = ("_node",) + __slots__ = ("_node", "_children") def __init__(self, node): self._node = node + self._children = None @property def tag(self): @@ -92,15 +93,29 @@ def text(self): return text def __iter__(self): - for child in self._node.ChildNodes: - if child.NodeType == XmlNodeType.Element: - yield _DotNetElement(child) + # Wrapped once and kept. The parsers call find_child several times on + # the same element - a block asks for inputVariables, inOutVariables + # and outputVariables in turn - and re-wrapping every child on each + # call was most of what this backend spent its time doing. + if self._children is None: + self._children = [ + _DotNetElement(child) for child in self._node.ChildNodes if child.NodeType == XmlNodeType.Element + ] + return iter(self._children) def iter(self): - yield self - for child in self: - for descendant in child.iter(): - yield descendant + """Pre-order walk, as ElementTree does it. + + An explicit stack rather than recursive generators: delegating a yield + up through every level of a deep document costs more than the walk. + """ + stack = [self] + while stack: + node = stack.pop() + yield node + children = list(node) + for index in range(len(children) - 1, -1, -1): + stack.append(children[index]) def _parse_dotnet(data): diff --git a/tools/ladder/tests/test_xmlbackend.py b/tools/ladder/tests/test_xmlbackend.py index 6b041df..6c686f4 100644 --- a/tools/ladder/tests/test_xmlbackend.py +++ b/tools/ladder/tests/test_xmlbackend.py @@ -78,6 +78,19 @@ def every_fixture(): check_equal("an element with no text is None", list(root)[1].text, None) check_equal("iteration yields child elements", len(list(root)), 2) check_equal("iter walks the whole tree", len(list(root.iter())), 4) +# Pre-order, like ElementTree - a stack walk is easy to get backwards. +check_equal("iter is in document order", [plcopen.tag(e) for e in root.iter()], ["root", "a", "b", "a"]) +# Children are cached per element, so repeated find_child calls stay cheap. +check("repeated iteration is stable", list(root)[0] is list(root)[0]) + +# POUs are found without walking the document, but an unusual layout must +# still work rather than silently rendering nothing. +NESTED = b'' +check_equal( + "a pou outside types/pous is still found", + [p.get("name") for p in plcopen.find_pous(xmlbackend.parse(NESTED))], + ["X"], +) # --- the two backends must agree ------------------------------------------- From c8cebb297adce15e47d9438ac46471327bac285c Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 13:55:28 +1000 Subject: [PATCH 23/91] keep comments, pragmas and attributes in the declaration The declaration was rebuilt from the structured , which has nowhere to put a comment, a pragma or an attribute, so all three were dropped from every rendering. A pragma is not decoration - {attribute 'qualified_only'} changes what the code means - so paraphrasing it away is worse than not showing the declaration at all. export_xml grows declarations_as_plaintext=True, which CODESYS documents as lossless, and the declaration is then used verbatim. The structured interface stays as the fallback, so exports from a build without that overload still render; IronPython raises TypeError when no overload matches, which is what the fallback catches. The addData element carrying the text is matched on shape rather than by name. It is a proprietary 3S extension whose name has moved between CODESYS versions, and matching a name that later changed would silently drop back to the lossy path with nothing to show that it had. Co-Authored-By: Claude Opus 5 --- src/graphical_export.py | 20 +++++++++- src/ld_render.py | 11 ++++++ src/model.py | 8 +++- src/parse_fbd.py | 2 + src/parse_ld.py | 2 + src/plcopen.py | 29 ++++++++++++++ tools/ladder/tests/test_export.py | 29 ++++++++++++-- tools/ladder/tests/test_ladder.py | 64 ++++++++++++++++++++++++++++++- 8 files changed, 159 insertions(+), 6 deletions(-) diff --git a/src/graphical_export.py b/src/graphical_export.py index 3e2ac94..22e2b1a 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -119,6 +119,24 @@ def render_plcopen(plcopen_path): return lines +def _export_plcopen(obj, path): + """Export one object as PLCopen xml, asking for plaintext declarations. + + The structured has nowhere to put a comment, a pragma or an + attribute, so without this the declaration in the rendering silently drops + all three. CODESYS documents the flag as lossless. + + It is a proprietary extension and an overload this ScriptEngine build may + not have, so a TypeError - which is what IronPython raises when no + overload matches - falls back to the plain call rather than losing the + rendering altogether. + """ + try: + obj.export_xml(path=path, recursive=False, declarations_as_plaintext=True) + except TypeError: + obj.export_xml(path=path, recursive=False) + + def _remove_quietly(path): """Best-effort delete. Cleanup trouble is never worth failing an export.""" try: @@ -152,7 +170,7 @@ def write_rendered_text(obj, base_path): return False try: started = time.time() - obj.export_xml(path=temp_path, recursive=False) + _export_plcopen(obj, temp_path) STATS["export_xml_seconds"] += time.time() - started # render_plcopen accounts for its own parse and draw time. diff --git a/src/ld_render.py b/src/ld_render.py index 517a75e..9875af4 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -240,6 +240,17 @@ def render_rung(expr): def render_declaration(pou): + """The POU's declaration. + + Verbatim when CODESYS gave us the plaintext version, because that is the + only form carrying comments, pragmas and attributes - and a pragma like + {attribute 'qualified_only'} changes what the code means, so paraphrasing + it away is worse than not showing it. Otherwise rebuilt from the + structured interface, which is all older exports offer. + """ + if pou.declaration_text: + return pou.declaration_text.split("\n") + keyword = POU_TYPE_KEYWORDS.get(pou.pou_type, "PROGRAM") lines = [keyword + " " + pou.name] diff --git a/src/model.py b/src/model.py index 4cd02ca..1d1126b 100644 --- a/src/model.py +++ b/src/model.py @@ -116,10 +116,16 @@ def __init__(self, name, type_name, initial_value=None, scope="VAR"): class Pou(object): """A parsed POU. ``rungs`` is populated for LD, ``networks`` for FBD.""" - def __init__(self, name, pou_type, variables=None, rungs=None, networks=None, language=None): + def __init__( + self, name, pou_type, variables=None, rungs=None, networks=None, language=None, declaration_text=None + ): self.name = name self.pou_type = pou_type self.language = language + # The declaration exactly as CODESYS wrote it, comments, pragmas and + # attributes included. None when the export did not carry one, in + # which case it gets rebuilt from `variables` and loses all three. + self.declaration_text = declaration_text self.variables = variables if variables is not None else [] self.rungs = rungs if rungs is not None else [] self.networks = networks if networks is not None else [] diff --git a/src/parse_fbd.py b/src/parse_fbd.py index a8e5613..5e7707e 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -12,6 +12,7 @@ block_outputs, block_st_code, child_text, + declaration_text, comment_text, direct_connections, find_child, @@ -269,6 +270,7 @@ def pou_from_body(pou_elem, body_elem): pou_type=pou_elem.get("pouType") or "program", language=LANGUAGE, variables=parse_interface(find_child(pou_elem, "interface")), + declaration_text=declaration_text(find_child(pou_elem, "interface")), networks=build_networks(parse_fbd_body(body_elem)), ) diff --git a/src/parse_ld.py b/src/parse_ld.py index 61c2176..98d3f3b 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -32,6 +32,7 @@ block_connections, block_outputs, child_text, + declaration_text, direct_connections, find_child, is_true, @@ -305,6 +306,7 @@ def pou_from_body(pou_elem, body_elem): pou_type=pou_elem.get("pouType") or "program", language=LANGUAGE, variables=parse_interface(find_child(pou_elem, "interface")), + declaration_text=declaration_text(find_child(pou_elem, "interface")), rungs=build_rungs(parse_ld_body(body_elem)), ) diff --git a/src/plcopen.py b/src/plcopen.py index f75da17..d0a1686 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -209,6 +209,35 @@ def _initial_value(var_elem): return simple.get("value") +def declaration_text(interface_elem): + """The lossless plaintext declaration, if CODESYS wrote one. + + Requested with export_xml(declarations_as_plaintext=True). The structured + has nowhere to put a comment, a pragma or an attribute, so + rebuilding a declaration from it silently drops all three - and a pragma + like {attribute 'qualified_only'} changes what the code means. + + The element name is deliberately not matched exactly. This is a + proprietary 3S extension, the addData name has moved between CODESYS + versions, and guessing wrong would silently fall back to the lossy path. + Anything under the interface's addData that reads like a declaration is + taken instead. + """ + if interface_elem is None: + return None + add_data = find_child(interface_elem, "addData") + if add_data is None: + return None + for data in add_data: + if tag(data) != "data": + continue + for candidate in [data] + list(data): + text = candidate.text + if text and "VAR" in text: + return text.replace("\r\n", "\n").strip("\n") + return None + + def parse_interface(interface_elem): """Variables from a POU interface, in declaration order.""" from model import Variable diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 1aedefd..00b15c3 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -56,13 +56,25 @@ def __init__(self, name, source=None): def get_name(self): return self._name - def export_xml(self, path, recursive): - self.export_calls.append((path, recursive)) + def export_xml(self, path, recursive, declarations_as_plaintext=None): + self.export_calls.append((path, recursive, declarations_as_plaintext)) if self._source is None: raise RuntimeError("export_xml exploded") shutil.copyfile(self._source, path) +class OldScriptEnginePou(FakePou): + """A build without the declarations_as_plaintext overload. + + IronPython raises TypeError when no overload matches, which must fall back + to the plain call rather than losing the rendering. + """ + + def export_xml(self, path, recursive): + self.export_calls.append((path, recursive)) + shutil.copyfile(self._source, path) + + class RecordingParent(object): """Records anything the importer tries to do to the project.""" @@ -95,6 +107,8 @@ def read(path): check("ladder pou is rendered", graphical_export.write_rendered_text(pou, base) is True) check("derived file lands beside the xml", os.path.exists(base + ".txt")) check_equal("export_xml is asked for a single object", pou.export_calls[0][1], False) + # Without this the declaration loses comments, pragmas and attributes. + check_equal("plaintext declarations are requested", pou.export_calls[0][2], True) content = read(base + ".txt") check("derived file leads with the declaration", content.startswith("PROGRAM LD_TEST")) @@ -110,6 +124,13 @@ def read(path): # the rendering may appear next to the native xml. check_equal("no stray files left behind", sorted(os.listdir(workspace)), ["LD_TEST.txt"]) + # --- an older ScriptEngine without the plaintext overload --------------- + + old_base = os.path.join(workspace, "OLD") + old_pou = OldScriptEnginePou("OLD", os.path.join(FIXTURES, "LDTesting.xml")) + check("an older ScriptEngine still renders", graphical_export.write_rendered_text(old_pou, old_base) is True) + check("it fell back to the plain call", os.path.exists(old_base + ".txt")) + # --- languages we cannot draw are skipped, not written empty ------------ sfc_base = os.path.join(workspace, "SFC_TEST") @@ -122,7 +143,9 @@ def read(path): # The ScriptEngine can keep modules loaded between runs, so without an # explicit reset the summary would report totals accumulated across every # Export click since CODESYS started. - check_equal("one render is counted", graphical_export.STATS["rendered"], 1) + # Two ladder POUs rendered by this point: the plain one and the one + # standing in for an older ScriptEngine. + check_equal("each render is counted", graphical_export.STATS["rendered"], 2) check_equal("the skipped sfc is counted", graphical_export.STATS["skipped"], 1) check("the summary names both costs", "CODESYS export_xml" in graphical_export.summary()) diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 3350f26..31eeab3 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -21,7 +21,7 @@ sys.path.insert(0, os.path.join(HERE, "..")) import charset # noqa: E402 -from ld_render import render_pou # noqa: E402 +from ld_render import render_declaration, render_pou # noqa: E402 from model import COIL, CONTACT, Element, Parallel, Series # noqa: E402 from parse_ld import parse_pous # noqa: E402 from render import write # noqa: E402 @@ -334,6 +334,68 @@ def check_golden(name, rendered_lines, golden_path): check_equal("a clean file reports nothing suspect", plcopen.describe_suspect_characters(SOURCE), []) +# --- plaintext declarations ------------------------------------------------ + +# The structured has nowhere to put a comment, a pragma or an +# attribute. export_xml(declarations_as_plaintext=True) carries the real text, +# and a pragma like {attribute 'qualified_only'} changes what the code means - +# so paraphrasing it away is worse than not showing it. +DECLARATION = """{attribute 'qualified_only'} +PROGRAM PLAIN +VAR + xStart : BOOL; // start button, NO contact + (* the seal-in *) + xRun : BOOL := FALSE; +END_VAR""" + + +def with_interface(interface): + body = '' + body += '' + body += "xRun" + document = '' + document += interface + body + "" + return io.BytesIO(document.encode("utf-8")) + + +PLAINTEXT_INTERFACE = ( + "" + '' + "" + DECLARATION + "" +) +STRUCTURED_INTERFACE = '' + +plain_pou = parse_pous(with_interface(PLAINTEXT_INTERFACE))[0] +check_equal("the plaintext declaration is picked up", plain_pou.declaration_text, DECLARATION) + +declaration = render_declaration(plain_pou) +check_equal("it is used verbatim, line for line", declaration, DECLARATION.split("\n")) +check("a pragma survives", any("{attribute 'qualified_only'}" in line for line in declaration)) +check("a line comment survives", any("// start button, NO contact" in line for line in declaration)) +check("a block comment survives", any("(* the seal-in *)" in line for line in declaration)) + +# It has to reach the rendered file, not just the model. +check_equal("the rendering leads with it", render_pou(plain_pou)[0], "{attribute 'qualified_only'}") + +# Older exports carry no plaintext, and must still render something. +structured_pou = parse_pous(with_interface(STRUCTURED_INTERFACE))[0] +check_equal("no plaintext means none is invented", structured_pou.declaration_text, None) +check_equal("the structured interface is the fallback", render_declaration(structured_pou)[0], "PROGRAM PLAIN") +check("the fallback still lists the variable", any("xStart : BOOL;" in line for line in render_declaration(structured_pou))) + +# The addData element name is a proprietary extension that has moved between +# CODESYS versions, so the lookup matches on shape rather than on a name that +# would silently fall back to the lossy path if it ever changed again. +RENAMED = PLAINTEXT_INTERFACE.replace("Declarations", "DeclarationText").replace( + "plcopenxml/declarations", "plcopenxml/pou-declaration" +) +check_equal( + "a renamed addData element is still found", + parse_pous(with_interface(RENAMED))[0].declaration_text, + DECLARATION, +) + + # --- real CODESYS export --------------------------------------------------- # Exported from CODESYS V3.5 SP11 via Project > Export > PLCopenXML. This is From e8aef6fd5ea1509abef4641f117c8b19a0050e6d Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 14:15:07 +1000 Subject: [PATCH 24/91] try every export_xml shape, and say when the declaration is rebuilt The plaintext declaration never arrived on a real re-export: nothing changed in any .txt. The likely reason is my own fallback. export_xml is a .NET overload set and IronPython resolves it by signature, so the keyword call can fail to bind where the same call positionally succeeds - and it fails with a TypeError, which is indistinguishable from "this build has no such overload". The fallback then quietly produced the lossy declaration. Each call shape is now tried in turn, positional first, since that matches the documented signature exactly. The deeper problem was that the fallback was silent. Falling back costs every comment, pragma and attribute in the file, and nothing said so - the export looked identical to a successful one. The summary now carries a NOTE when no POU came back with a plaintext declaration. script_diagnose_xml.py reports which shape binds and dumps whatever addData the interface actually carries, so if this still does not land, one run says why instead of another round of guessing. Co-Authored-By: Claude Opus 5 --- src/graphical_export.py | 39 ++++++++++++++++++++++----- src/script_diagnose_xml.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/graphical_export.py b/src/graphical_export.py index 22e2b1a..67dc78b 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -3,7 +3,7 @@ Graphical POUs (LD, FBD, SFC, CFC) have no textual implementation, so they export as CODESYS native xml, which git can store but nobody can review. This -adds a derived .txt next to it: the ST equivalent followed by the diagram. +adds a derived .txt next to it: the declaration and a diagram per network. The .txt is READ-ONLY as far as CODESCRIBE is concerned. The native xml stays the only thing Import From Files reads, so the round trip is unaffected and @@ -39,6 +39,7 @@ "export_xml_seconds": 0.0, "parse_seconds": 0.0, "draw_seconds": 0.0, + "verbatim_declarations": 0, } STATS = dict(EMPTY_STATS) @@ -59,7 +60,7 @@ def summary(): if not STATS["rendered"] and not STATS["skipped"]: return None total = STATS["export_xml_seconds"] + STATS["parse_seconds"] + STATS["draw_seconds"] - return "Rendered %d graphical POUs in %.1fs (%.1fs CODESYS export_xml, %.1fs parsing, %.1fs drawing); skipped %d" % ( + line = "Rendered %d graphical POUs in %.1fs (%.1fs CODESYS export_xml, %.1fs parsing, %.1fs drawing); skipped %d" % ( STATS["rendered"], total, STATS["export_xml_seconds"], @@ -67,6 +68,12 @@ def summary(): STATS["draw_seconds"], STATS["skipped"], ) + # Falling back to the rebuilt declaration is silent otherwise, and it + # costs every comment, pragma and attribute in the file. Say so. + if STATS["rendered"] and not STATS["verbatim_declarations"]: + line += "\n NOTE: no POU carried a plaintext declaration, so comments, pragmas" + line += " and attributes are missing from every declaration." + return line # Body language -> (parser, diagram renderer). SFC and CFC are absent, so they @@ -110,6 +117,8 @@ def render_plcopen(plcopen_path): started = time.time() lines = [] for pou, art_renderer in pous: + if pou.declaration_text: + STATS["verbatim_declarations"] += 1 lines.extend(art_renderer.render_pou(pou)) lines.append(u"") @@ -119,6 +128,18 @@ def render_plcopen(plcopen_path): return lines +# Ways of asking for plaintext declarations, most likely to bind first. +# ScriptEngine methods are .NET overloads, and IronPython resolves them by +# signature: keyword arguments frequently fail to bind where the same call +# positionally succeeds. The documented overload is +# export_xml(path, recursive, export_folder_structure, declarations_as_plaintext). +_EXPORT_ATTEMPTS = ( + lambda obj, path: obj.export_xml(path, False, False, True), + lambda obj, path: obj.export_xml(path=path, recursive=False, declarations_as_plaintext=True), + lambda obj, path: obj.export_xml(None, path, False, False, True), +) + + def _export_plcopen(obj, path): """Export one object as PLCopen xml, asking for plaintext declarations. @@ -131,10 +152,16 @@ def _export_plcopen(obj, path): overload matches - falls back to the plain call rather than losing the rendering altogether. """ - try: - obj.export_xml(path=path, recursive=False, declarations_as_plaintext=True) - except TypeError: - obj.export_xml(path=path, recursive=False) + for attempt in _EXPORT_ATTEMPTS: + try: + attempt(obj, path) + return + except TypeError: + # No matching overload on this build. Try the next shape. + continue + # Nothing with plaintext bound, so fall back to the lossy declaration + # rather than losing the rendering. + obj.export_xml(path=path, recursive=False) def _remove_quietly(path): diff --git a/src/script_diagnose_xml.py b/src/script_diagnose_xml.py index a158ae1..24cb2f9 100644 --- a/src/script_diagnose_xml.py +++ b/src/script_diagnose_xml.py @@ -143,10 +143,65 @@ def probe_export(ET): os.remove(path) +def probe_declarations(): + """Which export_xml call binds, and where the declaration text lands. + + export_xml is a .NET overload set. IronPython resolves it by signature, so + a keyword call can fail to bind where the same call positionally succeeds + - and the failure is a TypeError that looks exactly like "this build has + no such overload". Only trying each shape distinguishes them. + """ + print("--- plaintext declarations ---") + project = scriptengine.projects.primary + if project is None: + report("project", "none open") + return + + target = find_graphical_object(project) + if target is None: + report("graphical object", "none found") + return + report("object", target.get_name()) + + shapes = ( + ("positional (path, rec, folders, plaintext)", lambda o, p: o.export_xml(p, False, False, True)), + ("keyword", lambda o, p: o.export_xml(path=p, recursive=False, declarations_as_plaintext=True)), + ("reporter-first", lambda o, p: o.export_xml(None, p, False, False, True)), + ("plain (no plaintext)", lambda o, p: o.export_xml(p, False)), + ) + + for label, call in shapes: + handle, path = tempfile.mkstemp(suffix=".plcopen.xml") + os.close(handle) + try: + call(target, path) + f = open(path, "rb") + try: + content = f.read() + finally: + f.close() + # Any addData under the interface is what carries the declaration. + marker = content.find(b"") + head = content[:marker] if marker > 0 else content + has_add_data = b" Date: Thu, 6 Aug 2026 15:08:40 +1000 Subject: [PATCH 25/91] probe a real POU in the declaration diagnostic The diagnostic reported plaintext declarations present when they were not. It picked its subject with has_textual_implementation is False, which is true of plenty of non-POU objects - it found Project Information. That export has no at all, so the search for failed, fell back to scanning the whole document, and matched the contentHeader's addData. Every answer it gave was about the wrong object. It now requires ObjectType.POU, and only looks for addData inside the element, reporting explicitly when there is none rather than widening the search until something matches. The one real finding survives: all four export_xml call shapes bind on this build, so the flag is accepted and the question is only what it produces. Co-Authored-By: Claude Opus 5 --- src/script_diagnose_xml.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/script_diagnose_xml.py b/src/script_diagnose_xml.py index 24cb2f9..bc58106 100644 --- a/src/script_diagnose_xml.py +++ b/src/script_diagnose_xml.py @@ -24,6 +24,7 @@ import scriptengine # type: ignore +from object_type import ObjectType, get_object_type from util import print_python_version PLAIN = b'hi' @@ -79,7 +80,13 @@ def probe_parser(): def find_graphical_object(obj, depth=0): - """First object with no textual implementation - i.e. a graphical one.""" + """First graphical POU in the project. + + has_textual_implementation is False on plenty of objects that are not + POUs, and the first one found is usually Project Information - whose + export has no at all, so probing it says nothing about + declarations while looking like it did. + """ if depth > 12: return None try: @@ -88,7 +95,7 @@ def find_graphical_object(obj, depth=0): return None for child in children: try: - if child.has_textual_implementation is False: + if get_object_type(child) == ObjectType.POU and child.has_textual_implementation is False: return child except Exception: pass @@ -159,9 +166,9 @@ def probe_declarations(): target = find_graphical_object(project) if target is None: - report("graphical object", "none found") + report("graphical POU", "none found - open a project with an LD or FBD POU") return - report("object", target.get_name()) + report("graphical POU", target.get_name()) shapes = ( ("positional (path, rec, folders, plaintext)", lambda o, p: o.export_xml(p, False, False, True)), @@ -180,14 +187,21 @@ def probe_declarations(): content = f.read() finally: f.close() - # Any addData under the interface is what carries the declaration. - marker = content.find(b"") - head = content[:marker] if marker > 0 else content - has_add_data = b" counts. Scanning the whole + # document instead matches the contentHeader's addData and reports + # a plaintext declaration that is not there. + start = content.find(b"" % len(content)) + continue + end = content.find(b"", start) + interface = content[start:end] if end > start else content[start : start + 2000] + marker = interface.find(b"" % len(content)) + else: + report(label, "OK, %d bytes, addData INSIDE " % len(content)) + report(" interface addData", repr(interface[marker : marker + 500])) except TypeError as error: report(label, "no such overload (%s)" % error) except Exception as error: From d8a8fb7b145b9000c3b04d0a3abc32549e1b43bd Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 6 Aug 2026 15:16:42 +1000 Subject: [PATCH 26/91] look for the plaintext declaration outside too The flag is working: asking for plaintext declarations grows the export from 54792 to 56463 bytes, so CODESYS is writing the text. It is simply not inside , which was the only place the lookup searched. The POU's own addData is now searched as well. The loose match also requires END_VAR alongside VAR, so widening where it looks does not widen what it will accept. The diagnostic now names every addData in both versions and reports which appear only with the flag, dumping them - and if the names match, finds the first differing byte instead. Guessing at where the text lands has cost two round trips already; this says exactly where it is. Co-Authored-By: Claude Opus 5 --- src/parse_fbd.py | 2 +- src/parse_ld.py | 2 +- src/plcopen.py | 46 +++++++++++++++++++----------- src/script_diagnose_xml.py | 58 +++++++++++++++++++++++++++----------- 4 files changed, 73 insertions(+), 35 deletions(-) diff --git a/src/parse_fbd.py b/src/parse_fbd.py index 5e7707e..0c44967 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -270,7 +270,7 @@ def pou_from_body(pou_elem, body_elem): pou_type=pou_elem.get("pouType") or "program", language=LANGUAGE, variables=parse_interface(find_child(pou_elem, "interface")), - declaration_text=declaration_text(find_child(pou_elem, "interface")), + declaration_text=declaration_text(pou_elem), networks=build_networks(parse_fbd_body(body_elem)), ) diff --git a/src/parse_ld.py b/src/parse_ld.py index 98d3f3b..f184f81 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -306,7 +306,7 @@ def pou_from_body(pou_elem, body_elem): pou_type=pou_elem.get("pouType") or "program", language=LANGUAGE, variables=parse_interface(find_child(pou_elem, "interface")), - declaration_text=declaration_text(find_child(pou_elem, "interface")), + declaration_text=declaration_text(pou_elem), rungs=build_rungs(parse_ld_body(body_elem)), ) diff --git a/src/plcopen.py b/src/plcopen.py index d0a1686..faa1a86 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -209,23 +209,11 @@ def _initial_value(var_elem): return simple.get("value") -def declaration_text(interface_elem): - """The lossless plaintext declaration, if CODESYS wrote one. - - Requested with export_xml(declarations_as_plaintext=True). The structured - has nowhere to put a comment, a pragma or an attribute, so - rebuilding a declaration from it silently drops all three - and a pragma - like {attribute 'qualified_only'} changes what the code means. - - The element name is deliberately not matched exactly. This is a - proprietary 3S extension, the addData name has moved between CODESYS - versions, and guessing wrong would silently fall back to the lossy path. - Anything under the interface's addData that reads like a declaration is - taken instead. - """ - if interface_elem is None: +def _add_data_declaration(owner): + """A declaration blob in this element's own addData, or None.""" + if owner is None: return None - add_data = find_child(interface_elem, "addData") + add_data = find_child(owner, "addData") if add_data is None: return None for data in add_data: @@ -233,11 +221,35 @@ def declaration_text(interface_elem): continue for candidate in [data] + list(data): text = candidate.text - if text and "VAR" in text: + if text and "VAR" in text and "END_VAR" in text: return text.replace("\r\n", "\n").strip("\n") return None +def declaration_text(pou_elem): + """The lossless plaintext declaration, if CODESYS wrote one. + + Requested with export_xml(declarations_as_plaintext=True). The structured + has nowhere to put a comment, a pragma or an attribute, so + rebuilding a declaration from it silently drops all three - and a pragma + like {attribute 'qualified_only'} changes what the code means. + + Both the interface's addData and the POU's own are searched, because the + flag demonstrably writes the text - it grows the export by well over a + kilobyte - but not inside , which was the only place the first + attempt looked. + + Neither the element name nor the data name is matched exactly: this is a + proprietary 3S extension whose naming has moved between CODESYS versions, + and pinning a name that later changed would drop silently back to the + lossy path. Requiring both VAR and END_VAR keeps that loose match from + catching arbitrary prose. + """ + if pou_elem is None: + return None + return _add_data_declaration(find_child(pou_elem, "interface")) or _add_data_declaration(pou_elem) + + def parse_interface(interface_elem): """Variables from a POU interface, in declaration order.""" from model import Variable diff --git a/src/script_diagnose_xml.py b/src/script_diagnose_xml.py index bc58106..c00c774 100644 --- a/src/script_diagnose_xml.py +++ b/src/script_diagnose_xml.py @@ -177,6 +177,7 @@ def probe_declarations(): ("plain (no plaintext)", lambda o, p: o.export_xml(p, False)), ) + exports = {} for label, call in shapes: handle, path = tempfile.mkstemp(suffix=".plcopen.xml") os.close(handle) @@ -184,24 +185,10 @@ def probe_declarations(): call(target, path) f = open(path, "rb") try: - content = f.read() + exports[label] = f.read() finally: f.close() - # Only what is inside counts. Scanning the whole - # document instead matches the contentHeader's addData and reports - # a plaintext declaration that is not there. - start = content.find(b"" % len(content)) - continue - end = content.find(b"", start) - interface = content[start:end] if end > start else content[start : start + 2000] - marker = interface.find(b"" % len(content)) - else: - report(label, "OK, %d bytes, addData INSIDE " % len(content)) - report(" interface addData", repr(interface[marker : marker + 500])) + report(label, "OK, %d bytes" % len(exports[label])) except TypeError as error: report(label, "no such overload (%s)" % error) except Exception as error: @@ -210,6 +197,45 @@ def probe_declarations(): if os.path.exists(path): os.remove(path) + # The flag grows the export, so the text is being written somewhere. Name + # every addData in each version and report what the flag adds - guessing + # at where it lands has already cost two round trips. + plain = exports.get("plain (no plaintext)") + with_text = exports.get("positional (path, rec, folders, plaintext)") or exports.get("keyword") + if plain is None or with_text is None: + return + + report("size difference", "%d bytes added by the flag" % (len(with_text) - len(plain))) + + def data_names(content): + names = [] + index = content.find(b' Date: Thu, 6 Aug 2026 15:29:09 +1000 Subject: [PATCH 27/91] walk the whole addData subtree for the plaintext declaration The diagnostic named it: the flag adds a data element called ".../plcopenxml/interfaceasplaintext", and despite the name it sits at POU level rather than inside . The text nests below it, deeper than the two levels the lookup was checking. The whole addData subtree is now walked, so neither where CODESYS puts the element nor how deeply it nests the text can silently drop this back to the rebuilt declaration. Both VAR and END_VAR are still required, so widening the search does not widen what it accepts. The test fixture now models the shape a real project actually produces, rather than the one I assumed twice. Co-Authored-By: Claude Opus 5 --- src/plcopen.py | 21 +++++++++++++-------- tools/ladder/tests/test_ladder.py | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/plcopen.py b/src/plcopen.py index faa1a86..d0e4b04 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -210,19 +210,24 @@ def _initial_value(var_elem): def _add_data_declaration(owner): - """A declaration blob in this element's own addData, or None.""" + """A declaration blob anywhere in this element's own addData, or None. + + The whole addData subtree is walked rather than its first two levels. + CODESYS writes the text under a data element named + ".../plcopenxml/interfaceasplaintext", and how deeply it nests inside that + is exactly the kind of detail that differs between versions. + """ if owner is None: return None add_data = find_child(owner, "addData") if add_data is None: return None - for data in add_data: - if tag(data) != "data": - continue - for candidate in [data] + list(data): - text = candidate.text - if text and "VAR" in text and "END_VAR" in text: - return text.replace("\r\n", "\n").strip("\n") + for element in add_data.iter(): + text = element.text + # Both markers, so a loose structural match cannot catch prose that + # merely mentions a variable. + if text and "VAR" in text and "END_VAR" in text: + return text.replace("\r\n", "\n").strip("\n") return None diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 31eeab3..60db6bd 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -383,6 +383,33 @@ def with_interface(interface): check_equal("the structured interface is the fallback", render_declaration(structured_pou)[0], "PROGRAM PLAIN") check("the fallback still lists the variable", any("xStart : BOOL;" in line for line in render_declaration(structured_pou))) +# The shape CODESYS actually writes, confirmed by diagnosing a real project: +# a data element named ".../interfaceasplaintext", sitting at POU level rather +# than inside despite the name, with the text nested below it. +# The first two attempts at this searched only inside , and then +# only two levels down. +REAL_SHAPE = ( + "" + "' + + DECLARATION + + "" +) + + +def with_pou_level_add_data(extra): + body = '' + body += '' + body += "xRun" + document = '' + document += extra.replace("", "" + body, 1) + "" + return io.BytesIO(document.encode("utf-8")) + + +real_pou = parse_pous(with_pou_level_add_data(REAL_SHAPE))[0] +check_equal("the real CODESYS shape is found", real_pou.declaration_text, DECLARATION) +check("nested text is reached, not just two levels", "// start button, NO contact" in (real_pou.declaration_text or "")) + # The addData element name is a proprietary extension that has moved between # CODESYS versions, so the lookup matches on shape rather than on a name that # would silently fall back to the lossy path if it ever changed again. From 0b1524a0b4b81d084f05f987dddc86fa529e0e8b Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 20 Aug 2026 13:22:29 +1000 Subject: [PATCH 28/91] preserve graphical POU declarations --- src/graphical_export.py | 7 +++++-- tools/ladder/tests/test_export.py | 20 +++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/graphical_export.py b/src/graphical_export.py index 67dc78b..e592122 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -101,7 +101,7 @@ def _render_pous(plcopen_path): return found -def render_plcopen(plcopen_path): +def render_plcopen(plcopen_path, declaration_text=None): """Render every renderable POU in a PLCopen file. [] if there are none. The declaration and the diagram only. An equivalent-ST rendering was @@ -112,6 +112,8 @@ def render_plcopen(plcopen_path): """ started = time.time() pous = _render_pous(plcopen_path) + if declaration_text is not None and pous: + pous[0][0].declaration_text = declaration_text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") STATS["parse_seconds"] += time.time() - started started = time.time() @@ -201,7 +203,8 @@ def write_rendered_text(obj, base_path): STATS["export_xml_seconds"] += time.time() - started # render_plcopen accounts for its own parse and draw time. - lines = render_plcopen(temp_path) + textual_declaration = getattr(getattr(obj, "textual_declaration", None), "text", None) + lines = render_plcopen(temp_path, textual_declaration) if not lines: STATS["skipped"] += 1 return False diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 00b15c3..9a6b238 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -48,10 +48,12 @@ class FakePou(object): exactly what the real call does from this module's point of view. """ - def __init__(self, name, source=None): + def __init__(self, name, source=None, declaration=None): self._name = name self._source = source self.export_calls = [] + if declaration is not None: + self.textual_declaration = type("TextualDeclaration", (object,), {"text": declaration})() def get_name(self): return self._name @@ -153,6 +155,22 @@ def read(path): check_equal("reset clears the counts", graphical_export.STATS["rendered"], 0) check_equal("nothing to report after a reset", graphical_export.summary(), None) + source_declaration = """{attribute 'qualified_only'} +PROGRAM LD_TEST +VAR + S_xSafe : SAFEBOOL; + // OUT0200 is the hardware channel identifier. + uiChannel : UINT := 0200; +END_VAR""" + source_pou = FakePou("LD_TEST", os.path.join(FIXTURES, "LDTesting.xml"), source_declaration) + source_base = os.path.join(workspace, "SOURCE") + check("source declaration is rendered verbatim", graphical_export.write_rendered_text(source_pou, source_base) is True) + source_content = read(source_base + ".txt") + check("safety type survives", "S_xSafe : SAFEBOOL;" in source_content) + check("declaration comment survives", "OUT0200 is the hardware channel identifier." in source_content) + check("padded literal survives", "UINT := 0200;" in source_content) + check("declaration pragma survives", "{attribute 'qualified_only'}" in source_content) + # --- a rendering failure must not fail the export ----------------------- broken_base = os.path.join(workspace, "BROKEN") From c860635d3df3a0732849fc241adb869c5f9f1f42 Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 20 Aug 2026 13:51:29 +1000 Subject: [PATCH 29/91] harden graphical renderer review output --- .github/workflows/ci.yml | 2 ++ src/fbd_render.py | 5 ++--- src/graphical_export.py | 11 ++++++++--- src/plcopen.py | 17 ++++++++++++++--- src/st_render.py | 1 + tools/ci/compile_python3.py | 21 +++++++++++++++++++++ tools/ladder/render.py | 3 +-- tools/ladder/tests/test_export.py | 9 +++++++++ tools/ladder/tests/test_fbd.py | 14 +++++++++++++- tools/ladder/tests/test_ladder.py | 8 ++++++++ 10 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 tools/ci/compile_python3.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f991ad9..4ff7eae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: - uses: actions/checkout@v4 - name: Fail on non-ASCII bytes in src/*.py run: python3 tools/ci/check_ascii.py + - name: Compile src files with Python 3 + run: python3 tools/ci/compile_python3.py ironpython: name: ironpython diff --git a/src/fbd_render.py b/src/fbd_render.py index ec68a9c..03e8382 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -273,9 +273,8 @@ def render_pou(pou): for index, network in enumerate(pou.networks): header = "(* Network " + str(index + 1) if network.comment: - # CODESYS comments usually already start with //, which would read - # oddly nested inside an ST block comment. - header += ": " + network.comment.lstrip("/").strip() + comment = network.comment.replace("\r", " ").replace("\n", " ").replace("*)", "* )") + header += ": " + comment.lstrip("/").strip() lines.append(header + " *)") lines.extend(render_network(network)) lines.append("") diff --git a/src/graphical_export.py b/src/graphical_export.py index e592122..6b65013 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -40,6 +40,7 @@ "parse_seconds": 0.0, "draw_seconds": 0.0, "verbatim_declarations": 0, + "fallback_declarations": 0, } STATS = dict(EMPTY_STATS) @@ -70,9 +71,11 @@ def summary(): ) # Falling back to the rebuilt declaration is silent otherwise, and it # costs every comment, pragma and attribute in the file. Say so. - if STATS["rendered"] and not STATS["verbatim_declarations"]: - line += "\n NOTE: no POU carried a plaintext declaration, so comments, pragmas" - line += " and attributes are missing from every declaration." + if STATS["fallback_declarations"]: + line += "\n NOTE: %d POU declaration(s) were rebuilt from structured XML; comments," % STATS[ + "fallback_declarations" + ] + line += " pragmas and attributes may be missing from those declarations." return line @@ -121,6 +124,8 @@ def render_plcopen(plcopen_path, declaration_text=None): for pou, art_renderer in pous: if pou.declaration_text: STATS["verbatim_declarations"] += 1 + else: + STATS["fallback_declarations"] += 1 lines.extend(art_renderer.render_pou(pou)) lines.append(u"") diff --git a/src/plcopen.py b/src/plcopen.py index d0e4b04..764aea4 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -222,12 +222,23 @@ def _add_data_declaration(owner): add_data = find_child(owner, "addData") if add_data is None: return None + candidates = [] for element in add_data.iter(): text = element.text - # Both markers, so a loose structural match cannot catch prose that - # merely mentions a variable. if text and "VAR" in text and "END_VAR" in text: - return text.replace("\r\n", "\n").strip("\n") + candidates.append((element, text.replace("\r\n", "\n").strip("\n"))) + if not candidates: + return None + + named = [] + for element, text in candidates: + name = (element.get("name") or "").lower() + if "interfaceasplaintext" in name or "declaration" in name: + named.append(text) + if len(named) == 1: + return named[0] + if len(candidates) == 1: + return candidates[0][1] return None diff --git a/src/st_render.py b/src/st_render.py index cf8020f..f5ace5d 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -247,6 +247,7 @@ def network_to_statements(network): def _network_header(index, comment): header = "(* Network " + str(index + 1) if comment: + comment = comment.replace("\r", " ").replace("\n", " ").replace("*)", "* )") header += ": " + comment.lstrip("/").strip() return header + " *)" diff --git a/tools/ci/compile_python3.py b/tools/ci/compile_python3.py new file mode 100644 index 0000000..b7b3ef2 --- /dev/null +++ b/tools/ci/compile_python3.py @@ -0,0 +1,21 @@ +"""Compile src/*.py with the host Python 3 interpreter.""" +import os +import sys + +SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "src") + +failures = [] +for name in sorted(os.listdir(SRC)): + if not name.endswith(".py"): + continue + path = os.path.join(SRC, name) + try: + with open(path, "r", encoding="utf-8") as handle: + source = handle.read() + compile(source, path, "exec") + print("OK " + name) + except (OSError, SyntaxError) as error: + failures.append(name) + print("FAIL %s: %s" % (name, error)) + +sys.exit(1 if failures else 0) diff --git a/tools/ladder/render.py b/tools/ladder/render.py index 28b342e..ad82d55 100644 --- a/tools/ladder/render.py +++ b/tools/ladder/render.py @@ -13,8 +13,7 @@ Output is written as UTF-8 regardless of the console encoding. -Prototype only - not yet wired into the CODESYS export path. Ladder and -Function Block Diagram are supported; SFC bodies are skipped. +Ladder and Function Block Diagram are supported; SFC bodies are skipped. """ from __future__ import print_function, unicode_literals diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 9a6b238..4cf8665 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -155,6 +155,15 @@ def read(path): check_equal("reset clears the counts", graphical_export.STATS["rendered"], 0) check_equal("nothing to report after a reset", graphical_export.summary(), None) + graphical_export.STATS["rendered"] = 1 + graphical_export.STATS["verbatim_declarations"] = 1 + graphical_export.STATS["fallback_declarations"] = 1 + check( + "mixed declaration sources are reported", + "1 POU declaration(s) were rebuilt" in graphical_export.summary(), + ) + graphical_export.reset_stats() + source_declaration = """{attribute 'qualified_only'} PROGRAM LD_TEST VAR diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 5288f23..5810120 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -24,7 +24,7 @@ import parse_ld # noqa: E402 import parse_fbd # noqa: E402 import st_render # noqa: E402 -from model import Call, Signal # noqa: E402 +from model import Call, Network, Pou, Signal # noqa: E402 from render import write # noqa: E402 # Referenced through the charset table rather than as literal glyphs: this @@ -83,6 +83,18 @@ def check_golden(name, rendered, golden_path): comment1, tree1 = pou.networks[0].comment, pou.networks[0].outputs[0] check("network 1 comment is captured", comment1.startswith("// Function Block to monitor supply voltage")) +hostile_comment = "// first\nsecond *) third" +check_equal( + "network comments cannot break generated block comments", + fbd_render.render_pou(Pou("HOSTILE", "program", networks=[Network(hostile_comment, [Signal("x")])]))[2], + "(* Network 1: first second * ) third *)", +) +check_equal( + "ST network comments cannot break generated block comments", + st_render._network_header(0, hostile_comment), + "(* Network 1: first second * ) third *)", +) + check("network 1 is a call", isinstance(tree1, Call)) check_equal("network 1 instance", tree1.instance_name, "fbSystemSupply") check_equal("network 1 type", tree1.type_name, "ifmIOcommon.SystemSupply") diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 60db6bd..ca64ae8 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -422,6 +422,14 @@ def with_pou_level_add_data(extra): DECLARATION, ) +DECOY = 'VAR fake END_VAR' +AMBIGUOUS = PLAINTEXT_INTERFACE.replace("", "" + DECOY, 1) +check_equal( + "ambiguous declaration-like addData is rejected", + parse_pous(with_interface(AMBIGUOUS))[0].declaration_text, + None, +) + # --- real CODESYS export --------------------------------------------------- From 5b33acbed3a9200888693ecacefc1ad85a3cfa5f Mon Sep 17 00:00:00 2001 From: kehinde Date: Thu, 20 Aug 2026 13:59:33 +1000 Subject: [PATCH 30/91] document graphical declaration sources --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0b85461..acfd981 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Ladder and Function Block Diagram POUs have no textual implementation, so they e │ └──────────────────────┘ ``` +The declaration is copied from the original CODESYS declaration source, preserving comments, pragmas, safety-qualified types, and literal spelling. The diagram is derived from PLCopen XML. On older CODESYS versions where the plaintext declaration is unavailable, the declaration is rebuilt from the structured interface and the export summary warns that comments, pragmas, or exact formatting may be missing. + This file is **derived and read-only**. The native xml remains the only thing `Import From Files` reads, so editing the `.txt` changes nothing — it exists to make diffs and code review possible. Layout comes from how the elements are wired, not from their coordinates, so moving a block in the CODESYS editor produces no diff. SFC and CFC POUs are not yet rendered; they export as native xml alone. From a7b9d615ceaf8c0554e631901d644b591994c2f0 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 10:34:46 +1000 Subject: [PATCH 31/91] give a box on a side pin its own call A box wired into another box's side pin was flattened into that pin's caption together with everything feeding it, so ld_fidelity network 6 read "RESET := xB AND NOT tmrA.Q" for a rung that resets on NOT tmrA.Q alone - and tmrA, having no call of its own, never ran in the rendered text at all. The power path has always handled this shape; only the side pin folded the chain in. The chain up to and including the box is now hoisted out of the caption into the box's own sub-rung, drawn on its own wire above the box that reads it and called before it in the ST. The caption names only the output the pin takes. --- src/ld_render.py | 38 ++++- src/model.py | 7 + src/parse_ld.py | 46 +++++- src/st_render.py | 6 + .../fixtures/ld_side_pin_latch.plcopen.xml | 145 ++++++++++++++++++ tools/ladder/tests/test_ladder.py | 34 +++- 6 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld_side_pin_latch.plcopen.xml diff --git a/src/ld_render.py b/src/ld_render.py index 9875af4..3195c00 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -226,8 +226,28 @@ def _render(expr): raise TypeError("cannot render %r" % (expr,)) -def render_rung(expr): - """Render one rung, bounded by the power rails.""" +def _pin_block_rungs(expr, found): + """Collect the sub-rungs feeding side pins, in the order they execute. + + A box wired into another box's side pin is drawn on a wire of its own + above the box that reads it, which names it in its pin caption. Deeper + boxes come first, because that is the order the values are produced in. + """ + if isinstance(expr, Series): + for item in expr.items: + _pin_block_rungs(item, found) + elif isinstance(expr, Parallel): + for branch in expr.branches: + _pin_block_rungs(branch, found) + elif isinstance(expr, Element): + for pin_block in expr.pin_blocks: + _pin_block_rungs(pin_block, found) + found.append(pin_block) + return found + + +def _render_wire(expr): + """One wire between the rails.""" chars = charset.active() block = _render(expr) lines = [] @@ -239,6 +259,20 @@ def render_rung(expr): return lines +def render_rung(expr): + """Render one rung, bounded by the power rails. + + Boxes feeding side pins are drawn first, on wires of their own: the + caption that reads one names only its output, so without the box the + diagram would not say what feeds it. + """ + lines = [] + for pin_block in _pin_block_rungs(expr, []): + lines.extend(_render_wire(pin_block)) + lines.extend(_render_wire(expr)) + return lines + + def render_declaration(pou): """The POU's declaration. diff --git a/src/model.py b/src/model.py index 1d1126b..cfe944c 100644 --- a/src/model.py +++ b/src/model.py @@ -304,6 +304,7 @@ def __init__( output_wired=False, power_negated=False, negated_outputs=None, + pin_blocks=None, ): self.kind = kind self.label = label @@ -323,6 +324,12 @@ def __init__( # True when something downstream actually consumes the active output, # so the renderer knows whether to break the box edge with a tee. self.output_wired = output_wired + # Blocks only: whole sub-rungs feeding this block's side pins. A box + # wired into a side pin has a call of its own to make, with its own + # inputs; the pin caption only names the output it reads. They are + # rendered and emitted before this block, in the order the pins were + # wired, because that is the order they execute in. + self.pin_blocks = pin_blocks if pin_blocks is not None else [] @property def title(self): diff --git a/src/parse_ld.py b/src/parse_ld.py index f184f81..93e897d 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -170,6 +170,7 @@ def _build_block(node, by_id, visiting, via_pin): power_pin = None power_negated = False side_pins = [] + pin_blocks = [] for connection in node.inputs: upstream = by_id.get(connection.ref_id) @@ -181,7 +182,7 @@ def _build_block(node, by_id, visiting, via_pin): # Flattened through expr_to_text, not taken from the raw label: # an in-place negated inVariable must keep its NOT, or the pin # silently inverts. - side_pins.append((connection.target_pin, _pin_text(sub_expr, connection))) + side_pins.append((connection.target_pin, _pin_text(sub_expr, connection, pin_blocks))) elif power_pin is None: power_pin = connection.target_pin power_expr = sub_expr @@ -189,7 +190,7 @@ def _build_block(node, by_id, visiting, via_pin): # box wall, after everything the rung has accumulated. power_negated = connection.negated else: - side_pins.append((connection.target_pin, _pin_text(sub_expr, connection))) + side_pins.append((connection.target_pin, _pin_text(sub_expr, connection, pin_blocks))) input_pins = [] if power_pin is not None: @@ -217,13 +218,50 @@ def _build_block(node, by_id, visiting, via_pin): output_wired=via_pin is not None, power_negated=power_negated, negated_outputs=set(node.negated_outputs), + pin_blocks=pin_blocks, ) return series([power_expr, element]) -def _pin_text(sub_expr, connection): +def _pin_expr_text(expr, hoisted): + """A side pin's condition text, hoisting any box out of it. + + A box wired into a side pin is not a term of that pin's condition: it is + a call in its own right, and everything to its left on the wire is its + input, not the pin's. Flattening the whole chain into the caption states + logic the program does not have - "RESET := xB AND NOT tmrA.Q" for a + rung that resets on NOT tmrA.Q alone - so the chain up to and including + the box is hoisted into ``hoisted`` to be rendered and called as its own + sub-rung, and only the output the pin reads is named here. This is what + the power path already does; see rung_to_statements. + """ + if isinstance(expr, Series): + items = expr.items + # The last box on the wire is the one the pin reads. Anything before + # it feeds it, anything after it operates on its output. + cut = -1 + for index, item in enumerate(items): + if isinstance(item, Element) and item.kind == BLOCK: + cut = index + if cut < 0: + return expr_to_text(expr) + hoisted.append(series(items[: cut + 1])) + parts = [part for part in (expr_to_text(item) for item in items[cut:]) if part] + return " AND ".join(parts) + if isinstance(expr, Parallel): + parts = [part for part in (_pin_expr_text(branch, hoisted) for branch in expr.branches) if part] + return "(" + " OR ".join(parts) + ")" + if isinstance(expr, Element) and expr.kind == BLOCK: + # A box feeding the pin directly still has a call to make; without + # this it is named in the caption and never called at all. + hoisted.append(expr) + return expr_to_text(expr) + return expr_to_text(expr) + + +def _pin_text(sub_expr, connection, hoisted): """A side pin's caption, honouring the pin's own negation bubble.""" - text = expr_to_text(sub_expr) + text = _pin_expr_text(sub_expr, hoisted) if connection.negated: return "NOT " + _bracket(text) if text else "NOT ?" return text diff --git a/src/st_render.py b/src/st_render.py index f5ace5d..0a7bdfa 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -51,6 +51,12 @@ def rung_to_statements(rung): for item in items: if isinstance(item, Element) and item.kind == BLOCK: + # A box wired into one of this box's side pins runs first and has + # inputs of its own to state. It is a sub-rung with its own power + # flow, so it walks the same way rather than folding into this + # rung's condition. + for pin_block in item.pin_blocks: + statements.extend(rung_to_statements(pin_block)) args = [] for pin, label in item.input_pins: # A label of None is the power pin, fed by the rung so far. diff --git a/tools/ladder/tests/fixtures/ld_side_pin_latch.plcopen.xml b/tools/ladder/tests/fixtures/ld_side_pin_latch.plcopen.xml new file mode 100644 index 0000000..a85813e --- /dev/null +++ b/tools/ladder/tests/fixtures/ld_side_pin_latch.plcopen.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xSet + + + + + xClear + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xCount + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xDone + + + + + + + + + + + + + + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index ca64ae8..3167b17 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -230,8 +230,38 @@ def check_golden(name, rendered_lines, golden_path): # A negated output consumed through a SIDE PIN goes via expr_to_text, a # different path from the power flow - it must keep the NOT too. -check("fidelity: negated output survives into a side pin", any("RESET := xB AND NOT tmrA.Q" in line for line in fidelity_st)) -check("fidelity: side pin caption matches the ST", any("RESET := xB AND NOT tmrA.Q" in line for line in fidelity_art)) +check("fidelity: negated output survives into a side pin", any("RESET := NOT tmrA.Q" in line for line in fidelity_st)) +check("fidelity: side pin caption matches the ST", any("RESET := NOT tmrA.Q" in line for line in fidelity_art)) + +# The chain feeding a box on a side pin is the BOX's input, not a term of the +# pin's condition. Folding it in ("xB AND NOT tmrA.Q") says the counter also +# resets on NOT xB, and left tmrA with no call at all - a timer that the text +# never runs. +check("fidelity: a side-pin box gets its own call", any("tmrA(IN := xB);" in line for line in fidelity_st)) +check("fidelity: a side-pin box is drawn", any("tmrA : TON" in line for line in fidelity_art)) +check( + "fidelity: the side pin does not absorb the box's input", + not any("xB AND" in line for line in fidelity_st + fidelity_art), +) +check( + "fidelity: the side-pin call comes before the box that reads it", + fidelity_st.index("tmrA(IN := xB);") < fidelity_st.index("ctr2(CU := xGo2, RESET := NOT tmrA.Q);"), +) + +# The same shape with nothing negated and no chain to absorb: an SR latch +# feeding a counter's RESET. The latch was named in the caption and never +# called. +LD_SIDE_PIN = os.path.join(FIXTURES, "ld_side_pin_latch.plcopen.xml") +side_pin_pou = parse_pous(LD_SIDE_PIN)[0] +side_pin_st = st_render.render_pou(side_pin_pou) +side_pin_art = render_pou(side_pin_pou) + +check_equal("side pin: one rung", len(side_pin_pou.rungs), 1) +check("side pin: the latch is called", any("latch(SET1 := xSet, RESET := xClear);" in line for line in side_pin_st)) +check("side pin: the pin reads only the latch output", any("RESET := latch.Q1" in line for line in side_pin_st)) +check("side pin: the caption matches the ST", any("RESET := latch.Q1" in line for line in side_pin_art)) +check("side pin: the latch box is drawn", any("latch : SR" in line for line in side_pin_art)) +check("side pin: no chain folded into the pin", not any("xSet AND" in line for line in side_pin_st + side_pin_art)) # A negated wired output feeding a coil, and only one bubble drawn for it. check("fidelity: negated wired output inverts the coil", any("xFin := NOT ctr2.Q;" in line for line in fidelity_st)) From 98d0ddda458022cef0091d9b77c9ecf1f9f0a0d8 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 10:41:00 +1000 Subject: [PATCH 32/91] call a block once however many of its pins are read The FBD tree was memoised on (localId, pin), so a block read through two of its output pins came back as two blocks: two boxes drawn, and two calls emitted, telling a reader that a stateful block runs twice each cycle. The pin belongs to the wire, not the box. Calls are now memoised on the localId alone and the reading pin travels on the wire, as an OutputRef. A box records every pin something reads, so it breaks its wall with a tee once per read pin, and the fan-out drawing groups tails by pin: several outputs off one pin still share a junction column, while two pins get a wire each - joining those would draw two signals as one. --- src/fbd_render.py | 123 +++++++++++++----- src/layout.py | 6 +- src/model.py | 34 ++++- src/parse_fbd.py | 37 +++--- src/st_render.py | 15 +++ .../fixtures/36-2-fbd-two-output-pins.xml | 37 ++++++ tools/ladder/tests/test_fbd.py | 65 ++++++++- 7 files changed, 252 insertions(+), 65 deletions(-) create mode 100644 tools/ladder/tests/fixtures/36-2-fbd-two-output-pins.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 03e8382..4415257 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -12,7 +12,7 @@ import charset from layout import Block, stack from ld_render import render_declaration -from model import Assign, Call, Jump, Label, Signal +from model import Assign, Call, Jump, Label, OutputRef, Signal def _render_signal(node): @@ -60,7 +60,7 @@ def _is_wired(source): return not (isinstance(source, Signal) and not source.label) -def _render_call(call): +def _render_call(call, read_pin=None): chars = charset.active() input_blocks = [] for _pin, source in call.inputs: @@ -136,12 +136,14 @@ def _render_call(call): if _is_wired(pin_and_source[1]): handoff_pins.add(input_rows[index]) - # The active output only breaks the box wall with a tee if a consumer is - # actually there to receive it. + # An output pin only breaks the box wall with a tee if a consumer is + # actually there to receive it - and a box read through two pins breaks + # it twice. pins = [pin for pin, _assigned in call.outputs] - live_output_row = None - if call.output_wired and call.active_output in pins: - live_output_row = output_rows[pins.index(call.active_output)] + live_output_rows = set() + for pin in call.wired_outputs: + if pin in pins: + live_output_rows.add(output_rows[pins.index(pin)]) lines = [] for row in range(height): @@ -155,25 +157,32 @@ def _render_call(call): left_pin = in_at.get(row, "") right_pin = out_at.get(row, "") left_edge = chars["PIN_L"] if row in handoff_pins else chars["V"] - right_edge = chars["PIN_R"] if row == live_output_row else chars["V"] + right_edge = chars["PIN_R"] if row in live_output_rows else chars["V"] gap = inner - len(left_pin) - len(right_pin) box = left_edge + left_pin + " " * gap + right_pin + right_edge else: box = " " * (inner + 2) lines.append(left[row] + box) - # The wire leaves on whichever output pin the consumer asked for. + # The wire leaves on whichever output pin this consumer asked for. + pin_rows = {} + for index, pin in enumerate(pins): + pin_rows[pin] = output_rows[index] + + wanted = read_pin if read_pin is not None else call.active_output connect_row = box_first - pins = [pin for pin, _assigned in call.outputs] - if call.active_output in pins: - connect_row = output_rows[pins.index(call.active_output)] + if wanted in pin_rows: + connect_row = pin_rows[wanted] elif output_rows: connect_row = output_rows[0] - return Block(lines, connect_row) + return Block(lines, connect_row, pin_rows) def _render(node): + if isinstance(node, OutputRef): + # One box, entered on the pin this wire reads. + return _render_call(node.call, node.pin) if isinstance(node, Call): return _render_call(node) if isinstance(node, Assign): @@ -193,6 +202,39 @@ def _assign_tail(node): return chars["H"] * 2 + ("o " if node.negated else "> ") + (node.label or "?") +def _fanout_groups(source, outputs): + """[(rows, outputs)] - one group per output pin that is read. + + Outputs reading the same pin share one wire and are branched off it, so + they stack on consecutive rows under that pin. Outputs reading different + pins do not share anything: each leaves the box on its own pin's row, and + joining them into one junction column would draw two signals as one. + """ + order = [] + at_pin = {} + for output in outputs: + pin = output.source.pin if isinstance(output.source, OutputRef) else None + if pin not in at_pin: + at_pin[pin] = [] + order.append(pin) + at_pin[pin].append(output) + order.sort(key=lambda pin: source.pin_rows.get(pin, source.connect_row)) + + groups = [] + taken = set() + for pin in order: + rows = [] + row = source.pin_rows.get(pin, source.connect_row) + for output in at_pin[pin]: + while row in taken: + row += 1 + taken.add(row) + rows.append(row) + row += 1 + groups.append((rows, at_pin[pin])) + return groups + + def _render_fanout(outputs): """One source driving several outputs: draw it once and branch. @@ -204,45 +246,58 @@ def _render_fanout(outputs): # A short lead before the junction, so the branch is not welded to the box # edge. padded() extends the wire row and pads the rest with spaces. width = source.width + 2 - lines = source.padded(width) - - rows = [source.connect_row + index for index in range(len(outputs))] - while len(lines) <= rows[-1]: + groups = _fanout_groups(source, outputs) + + tails = {} + joints = {} + verticals = set() + for rows, group in groups: + for row, output in zip(rows, group): + tails[row] = output + if len(rows) == 1: + joints[rows[0]] = chars["H"] + continue + # One wire, branched: the junction column belongs to this pin alone. + joints[rows[0]] = chars["T_DOWN"] + joints[rows[-1]] = chars["BL"] + for row in rows[1:-1]: + joints[row] = chars["T_RIGHT"] + for row in range(rows[0] + 1, rows[-1]): + verticals.add(row) + + # Only the row a wire actually leaves the box on is extended to the + # junction; the rows below it are carried by the junction column. + lines = source.padded(width, wire_rows=set(rows[0] for rows, _group in groups)) + + last = max(tails) + while len(lines) <= last: lines.append(" " * width) - first, last = rows[0], rows[-1] out = [] for row, line in enumerate(lines): - if row == first: - joint = chars["T_DOWN"] if len(rows) > 1 else chars["H"] - elif row == last: - joint = chars["BL"] - elif row in rows: - joint = chars["T_RIGHT"] - elif first < row < last: - joint = chars["V"] - else: - joint = " " - tail = _assign_tail(outputs[rows.index(row)]) if row in rows else "" + joint = joints.get(row, chars["V"] if row in verticals else " ") + tail = _assign_tail(tails[row]) if row in tails else "" out.append(line + joint + tail) - return Block(out, first) + return Block(out, min(tails)) def _shared_source(outputs): """The single source every output hangs off, or None. Identity, not equality: the parser memoises shared nodes, so two outputs - fed by one block hold the very same object. + fed by one block hold the very same object - through an OutputRef each + when they read different pins of it. """ if len(outputs) < 2: return None if not all(isinstance(output, Assign) for output in outputs): return None - first = outputs[0].source - if first is None: + sources = [output.source for output in outputs] + if sources[0] is None: return None - return first if all(output.source is first for output in outputs) else None + boxes = [source.call if isinstance(source, OutputRef) else source for source in sources] + return sources[0] if all(box is boxes[0] for box in boxes) else None def render_network(network): diff --git a/src/layout.py b/src/layout.py index f1dff67..e683226 100644 --- a/src/layout.py +++ b/src/layout.py @@ -12,9 +12,13 @@ class Block(object): - def __init__(self, lines, connect_row): + def __init__(self, lines, connect_row, pin_rows=None): self.lines = lines self.connect_row = connect_row + # For a box: the row each output pin sits on, so a caller branching + # several wires off it can leave each one level with the pin it + # reads instead of guessing. + self.pin_rows = pin_rows if pin_rows is not None else {} @property def width(self): diff --git a/src/model.py b/src/model.py index cfe944c..1325c6d 100644 --- a/src/model.py +++ b/src/model.py @@ -200,7 +200,7 @@ def __init__( inputs=None, outputs=None, active_output=None, - output_wired=False, + wired_outputs=None, st_code=None, negated_outputs=None, ): @@ -208,6 +208,10 @@ def __init__( self.instance_name = instance_name self.inputs = inputs if inputs is not None else [] self.outputs = outputs if outputs is not None else [] + # The pin a reader gets when it does not name one. Which pin a given + # reader takes lives on the reader, in an OutputRef. + if active_output is None and self.outputs: + active_output = self.outputs[0][0] self.active_output = active_output # Pins carrying CODESYS's in-place negation bubble: the value leaving # them is the inverse of the pin. @@ -215,9 +219,15 @@ def __init__( # An EXECUTE box carries inline ST as its whole body. Dropping it loses # the logic entirely while still drawing a plausible-looking box. self.st_code = st_code if st_code is not None else [] - # True when something downstream consumes the active output. A network - # sink has an active output but nothing to hand it to. - self.output_wired = output_wired + # Every output pin something downstream reads. A network sink reads + # none; a block read through two of its pins has two, and is still one + # box, called once. + self.wired_outputs = set(wired_outputs) if wired_outputs is not None else set() + + @property + def output_wired(self): + """True when anything downstream reads an output of this call.""" + return bool(self.wired_outputs) @property def title(self): @@ -234,6 +244,22 @@ def __repr__(self): return "Call(%r, %r)" % (self.type_name, self.instance_name) +class OutputRef(object): + """The value on one output pin of a Call, as read by its consumer. + + The pin belongs to the wire, not to the box. Holding it on the Call meant + a block read through two pins was two Calls: drawn twice, and called twice + in the ST, so a reader concluded a stateful block ran twice per cycle. + """ + + def __init__(self, call, pin): + self.call = call + self.pin = pin + + def __repr__(self): + return "OutputRef(%r, %r)" % (self.call, self.pin) + + class Network(object): """One FBD network: a comment, and the outputs its logic drives. diff --git a/src/parse_fbd.py b/src/parse_fbd.py index 0c44967..b9bbf8b 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -6,7 +6,7 @@ rather than a series/parallel chain. """ -from model import BLOCK, Assign, Call, Jump, Label, Network, Node, Pou, Signal +from model import BLOCK, Assign, Call, Jump, Label, Network, Node, OutputRef, Pou, Signal from plcopen import ( block_connections, block_outputs, @@ -100,26 +100,32 @@ def _negate(source): inputs=[("In", source)], outputs=[("Out", None)], active_output="Out", - output_wired=True, + wired_outputs=["Out"], ) def _build(node, by_id, visiting, via_pin=None, memo=None): - """Build the tree feeding a node. - - Results are memoised on (localId, pin) so a block feeding two outputs - yields the same object to both, which is what lets the renderers draw one - box with a branch instead of two identical boxes. + """Build the tree feeding a node, as read through ``via_pin``. + + Memoised on the localId alone, so every reader of an element gets the very + same object - which is what lets the renderers draw one box with a branch + instead of two identical boxes, and lets the ST emit one call. Keying on + the pin as well made a block read through two of its pins into two blocks. + The pin is instead recorded on the wire, as an OutputRef, and remembered + on the call so the renderer knows which pins to break the box wall for. """ if memo is None: memo = {} - key = (node.local_id, via_pin) - if key not in memo: - memo[key] = _build_node(node, by_id, visiting, via_pin, memo) - return memo[key] + if node.local_id not in memo: + memo[node.local_id] = _build_node(node, by_id, visiting, memo) + built = memo[node.local_id] + if via_pin is not None and isinstance(built, Call): + built.wired_outputs.add(via_pin) + return OutputRef(built, via_pin) + return built -def _build_node(node, by_id, visiting, via_pin, memo): +def _build_node(node, by_id, visiting, memo): if node.local_id in visiting: return Signal("" % node.local_id) visiting = visiting | set([node.local_id]) @@ -136,18 +142,11 @@ def _build_node(node, by_id, visiting, via_pin, memo): source = _negate(source) inputs.append((connection.target_pin, source)) - active = via_pin - if active is None and node.outputs: - active = node.outputs[0][0] - return Call( type_name=node.type_name, instance_name=node.instance_name, inputs=inputs, outputs=list(node.outputs), - active_output=active, - # via_pin is set by the consumer; a network sink has none. - output_wired=via_pin is not None, st_code=list(node.st_code), negated_outputs=set(node.negated_outputs), ) diff --git a/src/st_render.py b/src/st_render.py index 0a7bdfa..ee6e47d 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -24,6 +24,7 @@ Element, Jump, Label, + OutputRef, Series, Signal, is_simple_term, @@ -159,6 +160,20 @@ def _fbd_value(node, statements, emitted=None): if node is None: return "" + if isinstance(node, OutputRef): + # The call is emitted once however many of its pins are read; only the + # value differs per reader, so it is computed here rather than + # memoised with the call. + value = _fbd_value(node.call, statements, emitted) + if node.call.is_operator or not node.pin: + # An operator has no instance to take a pin from; it inlines as + # the one expression whichever pin reads it. + return value + text = "%s.%s" % (node.call.instance_name, node.pin) + if node.pin in node.call.negated_outputs: + text = "NOT " + text + return text + if isinstance(node, Signal): return node.text diff --git a/tools/ladder/tests/fixtures/36-2-fbd-two-output-pins.xml b/tools/ladder/tests/fixtures/36-2-fbd-two-output-pins.xml new file mode 100644 index 0000000..b8fcefa --- /dev/null +++ b/tools/ladder/tests/fixtures/36-2-fbd-two-output-pins.xml @@ -0,0 +1,37 @@ + + + + + + + + xStart + T#1S + + + + + + + xQ + tEt + xTrip + xLatched + xEn + iA + iB + iC + + + + + + + + + iSum + xSumOk + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 5810120..88542ef 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -24,7 +24,7 @@ import parse_ld # noqa: E402 import parse_fbd # noqa: E402 import st_render # noqa: E402 -from model import Call, Network, Pou, Signal # noqa: E402 +from model import Call, Network, OutputRef, Pou, Signal # noqa: E402 from render import write # noqa: E402 # Referenced through the charset table rather than as literal glyphs: this @@ -36,6 +36,15 @@ LD_SOURCE = os.path.join(FIXTURES, "LDTesting.xml") SFC_SOURCE = os.path.join(FIXTURES, "SFCTesting.xml") +def box(node): + """The Call a wire reads, unwrapping the pin the wire names. + + A wire that reads a named output pin arrives as an OutputRef around the + call, so that a box read through two pins stays one box. + """ + return node.call if isinstance(node, OutputRef) else node + + failures = [] @@ -116,10 +125,16 @@ def check_golden(name, rendered, golden_path): # Network 2 nests three calls: GT -> TOF -> SupplySwitch. comment2, tree2 = pou.networks[1].comment, pou.networks[1].outputs[0] check_equal("network 2 root", tree2.instance_name, "fbSupplySwitch") -tof = tree2.inputs[1][1] + +# The pin a wire reads is on the wire, not on the box it reads: a box is one +# box however many of its pins are read. +tof_wire = tree2.inputs[1][1] +check("the wire into fbSupplySwitch names its pin", isinstance(tof_wire, OutputRef)) +check_equal("wire leaves TOF on Q", tof_wire.pin, "Q") +tof = tof_wire.call check_equal("nested TOF", tof.instance_name, "TOF_0") -check_equal("wire leaves TOF on Q", tof.active_output, "Q") -gt = tof.inputs[0][1] +check("TOF knows Q is read", "Q" in tof.wired_outputs) +gt = box(tof.inputs[0][1]) check_equal("nested GT", gt.type_name, "GT") # An operator has no instance name, so it inlines as an expression in ST. @@ -202,8 +217,8 @@ def check_golden(name, rendered, golden_path): # negated="true" on an inVariable inverts the logic if it is ignored. guard = flow.networks[0].outputs[0] -check_equal("flow: negation reaches the tree", guard.condition.inputs[0][1].negated, True) -check_equal("flow: negation renders", guard.condition.inputs[0][1].text, "NOT xInitDone") +check_equal("flow: negation reaches the tree", box(guard.condition).inputs[0][1].negated, True) +check_equal("flow: negation renders", box(guard.condition).inputs[0][1].text, "NOT xInitDone") check("flow: negation survives into ST", any("(NOT xInitDone) OR" in line for line in flow_st)) # An EXECUTE box is nothing but inline ST; drawing the box alone loses it all. @@ -307,8 +322,44 @@ def check_golden(name, rendered, golden_path): check("fanout: the negated leg keeps its bubble", any(U["BL"] in l and "o Flags.ConvOff" in l for l in fan_art)) # Identity, not equality, is what tells a fan-out from two equal expressions. +# The wires differ - each names the pin it reads - but the box behind them is +# one object. first, second = fan.networks[0].outputs -check("fanout: shared nodes are one object", first.source is second.source) +check("fanout: shared nodes are one object", box(first.source) is box(second.source)) + + +# --- one instance read through two of its pins ------------------------------- + +# A timer whose Q feeds one store and whose ET feeds another. Memoising the +# tree on (localId, pin) made that two timers: two boxes drawn, and two calls +# emitted, so a reader concluded the timer ran twice each cycle. +TWO_PINS = os.path.join(HERE, "fixtures", "36-2-fbd-two-output-pins.xml") +two_pins = parse_fbd.parse_pous(TWO_PINS)[0] +two_pins_st = st_render.render_pou(two_pins) +# The network alone: render_pou would also give us the declaration, where +# "tmr : TON" appears again as the variable it is. +two_pins_art = fbd_render.render_network(two_pins.networks[0]) + +check_equal("two pins: one network", len(two_pins.networks[0].outputs), 2) +check( + "two pins: both stores read the same box", + box(two_pins.networks[0].outputs[0].source) is box(two_pins.networks[0].outputs[1].source), +) +check_equal( + "two pins: the pins are on the wires", + sorted(output.source.pin for output in two_pins.networks[0].outputs), + ["ET", "Q"], +) +check_equal("two pins: the timer is called once", len([l for l in two_pins_st if l.startswith("tmr(")]), 1) +check("two pins: Q is stored", "xQ := tmr.Q;" in two_pins_st) +check("two pins: ET is stored", "tEt := tmr.ET;" in two_pins_st) +check_equal("two pins: one box is drawn", len([l for l in two_pins_art if "tmr : TON" in l]), 1) + +# Two pins are two wires, not one branched wire: a junction column here would +# draw ET and Q as the same signal. +check("two pins: Q leaves on its own row", any(l.rstrip().endswith("> xQ") and "Q" + U["PIN_R"] in l for l in two_pins_art)) +check("two pins: ET leaves on its own row", any(l.rstrip().endswith("> tEt") and "ET" + U["PIN_R"] in l for l in two_pins_art)) +check("two pins: no junction between different pins", not any(U["T_DOWN"] in l and "xQ" in l for l in two_pins_art)) # --- language dispatch ----------------------------------------------------- From 54cc59ac4c8cb497491bf05225db4b995253a3ef Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 10:50:24 +1000 Subject: [PATCH 33/91] number LD networks, not LD outputs A network with more than one output rendered as one network per output: each drew its box again, called its block again, and pushed the number of every later network out. A reviewer who opens "Network 5" in CODESYS then reads different logic under "(* Network 5 *)". Rungs are now grouped into networks by connectivity, as parse_fbd already groups its own - but with the power rails left out of the grouping, because CODESYS exports one left rail for the whole LD body rather than one per network, and following that wire fuses every network in the POU into one. A right rail is not logic either, so it joins the network of the rung that ends at it. Within a network a block is built once, on the first rung that reaches it; the rest name the pin they read ("[tmr.ET]"), so the box is drawn once and called once. Networks now carry their rungs, so both renderers number the network rather than the rung, and the ST emitter picks its walker from the POU's language. --- src/ld_render.py | 14 +- src/model.py | 49 ++- src/parse_fbd.py | 33 +-- src/parse_ld.py | 108 ++++++- src/st_render.py | 22 +- .../tests/fixtures/36-3-ld-two-coils-sp11.xml | 279 ++++++++++++++++++ .../tests/fixtures/36-3-ld-two-coils.xml | 40 +++ tools/ladder/tests/test_ladder.py | 41 +++ 8 files changed, 524 insertions(+), 62 deletions(-) create mode 100644 tools/ladder/tests/fixtures/36-3-ld-two-coils-sp11.xml create mode 100644 tools/ladder/tests/fixtures/36-3-ld-two-coils.xml diff --git a/src/ld_render.py b/src/ld_render.py index 3195c00..ed8b3a7 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -306,16 +306,22 @@ def render_declaration(pou): def render_pou(pou): - """Render a whole POU: declaration, then one block per rung.""" + """Render a whole POU: declaration, then the rungs of each network. + + A network can hold more than one rung - a block driving three outputs is + one network in the editor - so the number belongs to the network, not to + the rung. + """ lines = render_declaration(pou) lines.append("") - if not pou.rungs: + if not pou.networks: lines.append("(* no rungs *)") - for index, rung in enumerate(pou.rungs): + for index, network in enumerate(pou.networks): lines.append("(* Network " + str(index + 1) + " *)") - lines.extend(render_rung(rung)) + for rung in network.outputs: + lines.extend(render_rung(rung)) lines.append("") while lines and lines[-1] == "": diff --git a/src/model.py b/src/model.py index 1325c6d..1c9232b 100644 --- a/src/model.py +++ b/src/model.py @@ -39,6 +39,9 @@ def is_simple_term(text): JUMP = "jump" RETURN = "return" LABEL = "label" +# Carries no logic, but CODESYS writes one above each network that has a +# comment, so it marks where one network begins. +COMMENT = "comment" RAILS = (LEFT_RAIL, RIGHT_RAIL) @@ -103,6 +106,36 @@ def __repr__(self): return "Node(%s, %s, %r, inputs=%r)" % (self.local_id, self.kind, self.label, self.inputs) +def component_finder(nodes): + """Union-find over the wires, ignoring direction. + + Two outputs fed from one block belong to the same network, so grouping has + to follow wires backwards as well as forwards. Connections to elements + outside ``nodes`` are ignored, which is how a caller keeps a shared + anchor - an LD power rail - from fusing every network into one. + """ + parent = {} + for node in nodes: + parent[node.local_id] = node.local_id + + def find(item): + root = item + while parent[root] != root: + root = parent[root] + while parent[item] != root: + parent[item], item = root, parent[item] + return root + + for node in nodes: + for connection in node.inputs: + if connection.ref_id not in parent: + continue + left, right = find(node.local_id), find(connection.ref_id) + if left != right: + parent[left] = right + return find + + class Variable(object): """One entry from the POU interface, for rendering the declaration block.""" @@ -114,10 +147,16 @@ def __init__(self, name, type_name, initial_value=None, scope="VAR"): class Pou(object): - """A parsed POU. ``rungs`` is populated for LD, ``networks`` for FBD.""" + """A parsed POU. ``networks`` holds one entry per network in the editor. + + Both languages fill it: for FBD each network holds the trees driving its + outputs, for LD the rungs of that network. A network can hold more than + one of either - a block driving three outputs is one network in the + editor, and numbering it as three throws every later number out. + """ def __init__( - self, name, pou_type, variables=None, rungs=None, networks=None, language=None, declaration_text=None + self, name, pou_type, variables=None, networks=None, language=None, declaration_text=None ): self.name = name self.pou_type = pou_type @@ -127,9 +166,13 @@ def __init__( # which case it gets rebuilt from `variables` and loses all three. self.declaration_text = declaration_text self.variables = variables if variables is not None else [] - self.rungs = rungs if rungs is not None else [] self.networks = networks if networks is not None else [] + @property + def rungs(self): + """Every LD rung in the POU, network grouping flattened away.""" + return [tree for network in self.networks for tree in network.outputs] + # --- FBD tree -------------------------------------------------------------- # diff --git a/src/parse_fbd.py b/src/parse_fbd.py index b9bbf8b..838ce67 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -6,7 +6,7 @@ rather than a series/parallel chain. """ -from model import BLOCK, Assign, Call, Jump, Label, Network, Node, OutputRef, Pou, Signal +from model import BLOCK, COMMENT, Assign, Call, Jump, Label, Network, Node, OutputRef, Pou, Signal, component_finder from plcopen import ( block_connections, block_outputs, @@ -23,7 +23,6 @@ tag, ) -COMMENT = "comment" IN_VARIABLE = "inVariable" OUT_VARIABLE = "outVariable" JUMP = "jump" @@ -175,34 +174,6 @@ def _build_node(node, by_id, visiting, memo): return Signal(node.label or "", negated=node.negated) -def _component_finder(logic): - """Union-find over the wires, ignoring direction. - - Two outputs fed from one block belong to the same network, so grouping has - to follow wires backwards as well as forwards. - """ - parent = {} - for node in logic: - parent[node.local_id] = node.local_id - - def find(item): - root = item - while parent[root] != root: - root = parent[root] - while parent[item] != root: - parent[item], item = root, parent[item] - return root - - for node in logic: - for connection in node.inputs: - if connection.ref_id not in parent: - continue - left, right = find(node.local_id), find(connection.ref_id) - if left != right: - parent[left] = right - return find - - def build_networks(nodes): """Group a flat node list into Networks. @@ -217,7 +188,7 @@ def build_networks(nodes): for node in logic: by_id[node.local_id] = node - find = _component_finder(logic) + find = component_finder(logic) consumed = set() for node in logic: diff --git a/src/parse_ld.py b/src/parse_ld.py index 93e897d..59190ab 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -8,6 +8,7 @@ from model import ( BLOCK, + COMMENT, IN_VARIABLE, JUMP, LABEL, @@ -19,10 +20,12 @@ COIL, Element, Empty, + Network, Node, Parallel, Pou, Series, + component_finder, is_simple_term, parallel, series, @@ -32,6 +35,7 @@ block_connections, block_outputs, child_text, + comment_text, declaration_text, direct_connections, find_child, @@ -55,6 +59,7 @@ JUMP, RETURN, LABEL, + COMMENT, ) @@ -80,6 +85,14 @@ def parse_ld_body(body_elem): local_id = child.get("localId") if local_id is None: continue + + if kind == COMMENT: + # No logic of its own, but CODESYS writes one above each network + # that has a comment, which is the only thing in an LD body that + # says where one network ends and the next begins. + nodes.append(Node(local_id=local_id, kind=COMMENT, label=comment_text(child))) + continue + is_block = kind == BLOCK nodes.append( Node( @@ -159,13 +172,36 @@ def _bracket(text): return "(" + text + ")" -def _build_block(node, by_id, visiting, via_pin): +def _block_reference(node, via_pin): + """A box already drawn in this network, named by the output being read. + + A block driving three outputs is one box that runs once. Rebuilding it for + every output drew it three times and called it three times, which reads as + three timers where the program has one; every reader after the first names + the pin it takes instead. + """ + pin = via_pin + if pin is None and node.outputs: + pin = node.outputs[0][0] + base = node.instance_name or node.type_name or "?" + return Element( + kind=IN_VARIABLE, + label=(base + "." + pin) if pin else base, + negated=pin in node.negated_outputs, + ) + + +def _build_block(node, by_id, visiting, via_pin, drawn): """Build a block call, separating power flow from parameter inputs. Exactly one input carries the rung's power flow. Pins fed by a literal or an inVariable are parameters, not power, so the first genuinely wired pin wins and the rest become captions inside the box. """ + if node.local_id in drawn: + return _block_reference(node, via_pin) + drawn.add(node.local_id) + power_expr = Empty() power_pin = None power_negated = False @@ -177,7 +213,7 @@ def _build_block(node, by_id, visiting, via_pin): if upstream is None: side_pins.append((connection.target_pin, "?")) continue - sub_expr = _build_expr(upstream, by_id, visiting, connection.source_pin) + sub_expr = _build_expr(upstream, by_id, visiting, connection.source_pin, drawn) if upstream.kind == IN_VARIABLE: # Flattened through expr_to_text, not taken from the raw label: # an in-place negated inVariable must keep its NOT, or the pin @@ -267,12 +303,16 @@ def _pin_text(sub_expr, connection, hoisted): return text -def _build_expr(node, by_id, visiting, via_pin=None): +def _build_expr(node, by_id, visiting, via_pin=None, drawn=None): """Walk backwards from a node to the power rail, building series/parallel. A node's expression is everything feeding it (OR'd together if there is - more than one input) followed by the node itself. + more than one input) followed by the node itself. ``drawn`` carries the + blocks already built for this network, so a block read by several outputs + is drawn and called once. """ + if drawn is None: + drawn = set() if node.local_id in visiting: # Feedback loops are not legal in a rung, but a malformed export should # produce a visible marker rather than blow the stack. @@ -281,14 +321,14 @@ def _build_expr(node, by_id, visiting, via_pin=None): visiting = visiting | set([node.local_id]) if node.kind == BLOCK: - return _build_block(node, by_id, visiting, via_pin) + return _build_block(node, by_id, visiting, via_pin, drawn) branches = [] for connection in node.inputs: upstream = by_id.get(connection.ref_id) if upstream is None: continue - branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin)) + branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin, drawn)) incoming = parallel(branches) if branches else Empty() @@ -299,34 +339,70 @@ def _build_expr(node, by_id, visiting, via_pin=None): return series([incoming, _to_element(node)]) -def build_rungs(nodes): - """Split a flat node list into one expression tree per rung. +def build_networks(nodes): + """Group a flat node list into Networks, each holding its rungs. A rung is identified by its terminal: an element nothing else consumes. That is the right power rail where one exists, and the coil itself where the export omits it - CODESYS exports the right rail unconnected. + + Networks are the connected components, as in parse_fbd - but the rails + are left out of the grouping. CODESYS exports one left rail for the whole + LD body, not one per network, so every rung in the POU hangs off the same + element and following that wire would fuse the lot into a single network. + One network per sink is no better: a block driving three outputs is one + network in the editor, and numbering it as three throws out every number + after it. """ by_id = {} for node in nodes: by_id[node.local_id] = node + logic = [node for node in nodes if node.kind not in RAILS and node.kind != COMMENT] + known = set(node.local_id for node in logic) + find = component_finder(logic) + + def root_of(node): + """Which network a terminal belongs to. + + A right power rail is an anchor rather than logic, so it is not in the + grouping itself - but it is the terminal of the rung that ends at it, + and it belongs to that rung's network. + """ + if node.local_id in known: + return find(node.local_id) + for connection in node.inputs: + if connection.ref_id in known: + return find(connection.ref_id) + return node.local_id + consumed = set() for node in nodes: for connection in node.inputs: consumed.add(connection.ref_id) - rungs = [] + # A block read by several outputs is built once, on the first rung that + # reaches it; the rest name its output pin. The set is per POU, and a + # block belongs to one network, so this cannot leak across networks. + drawn = set() + + order = [] + rungs_by_root = {} for node in nodes: - if node.local_id in consumed: - continue - if node.kind == LEFT_RAIL: + if node.local_id in consumed or node.kind in (LEFT_RAIL, COMMENT): # An unconnected left rail is an empty rung, not a terminal. continue - expr = _build_expr(node, by_id, set()) + expr = _build_expr(node, by_id, set(), None, drawn) if isinstance(expr, Empty): + # An unconnected rail or a stray element with nothing on it. continue - rungs.append(expr) - return rungs + root = root_of(node) + if root not in rungs_by_root: + rungs_by_root[root] = [] + order.append(root) + rungs_by_root[root].append(expr) + + return [Network(comment="", outputs=rungs_by_root[root]) for root in order] LANGUAGE = "LD" @@ -345,7 +421,7 @@ def pou_from_body(pou_elem, body_elem): language=LANGUAGE, variables=parse_interface(find_child(pou_elem, "interface")), declaration_text=declaration_text(pou_elem), - rungs=build_rungs(parse_ld_body(body_elem)), + networks=build_networks(parse_ld_body(body_elem)), ) diff --git a/src/st_render.py b/src/st_render.py index ee6e47d..6eb96ec 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -273,22 +273,28 @@ def _network_header(index, comment): return header + " *)" +LD = "LD" + + def render_pou(pou): - """Render a POU as declaration plus ST statements, one block per network.""" + """Render a POU as declaration plus ST statements, one block per network. + + A network holds rungs in LD and call trees in FBD; they walk differently, + so the POU's language picks the walker. + """ lines = render_declaration(pou) lines.append("") - for index, rung in enumerate(pou.rungs): - lines.append(_network_header(index, "")) - lines.extend(rung_to_statements(rung)) - lines.append("") - for index, network in enumerate(pou.networks): lines.append(_network_header(index, network.comment)) - lines.extend(network_to_statements(network)) + if pou.language == LD: + for rung in network.outputs: + lines.extend(rung_to_statements(rung)) + else: + lines.extend(network_to_statements(network)) lines.append("") - if not pou.rungs and not pou.networks: + if not pou.networks: lines.append("(* no networks *)") while lines and lines[-1] == "": diff --git a/tools/ladder/tests/fixtures/36-3-ld-two-coils-sp11.xml b/tools/ladder/tests/fixtures/36-3-ld-two-coils-sp11.xml new file mode 100644 index 0000000..da59384 --- /dev/null +++ b/tools/ladder/tests/fixtures/36-3-ld-two-coils-sp11.xml @@ -0,0 +1,279 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + Sensor1 + + + + + + + + sensor3 + + + + + + + + + Sensor2 + + + + + + + + PowerOn + + + + + + + + Lamp + + + + + + + + + + + + + + + networktitle + + + + + + + + + + PowerOn + + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + + + + + + 4f4ad042-bbb9-4292-adb4-f91543e47fce + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/fixtures/36-3-ld-two-coils.xml b/tools/ladder/tests/fixtures/36-3-ld-two-coils.xml new file mode 100644 index 0000000..d6636e2 --- /dev/null +++ b/tools/ladder/tests/fixtures/36-3-ld-two-coils.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + Network one comment + + xA + T#2S + + + + + + + + + + + xOut1 + + xOut2 + + tElapsed + Network two comment + + xB + + xOut3 + + + + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 3167b17..bb42674 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -263,6 +263,47 @@ def check_golden(name, rendered_lines, golden_path): check("side pin: the latch box is drawn", any("latch : SR" in line for line in side_pin_art)) check("side pin: no chain folded into the pin", not any("xSet AND" in line for line in side_pin_st + side_pin_art)) + +# --- one editor network, several outputs ------------------------------------- + +# A timer driving three outputs. One network per sink made it three networks, +# each drawing the box again and calling the timer again, and threw out the +# number of every network after it. CODESYS exports one power rail for the +# whole body, so the rungs cannot be told apart by what they hang off - only +# by what they are connected to. +TWO_COILS = os.path.join(FIXTURES, "36-3-ld-two-coils.xml") +two_coils_pou = parse_pous(TWO_COILS)[0] +two_coils_st = st_render.render_pou(two_coils_pou) +two_coils_art = render_pou(two_coils_pou) + +check_equal("two coils: two networks, not four", len(two_coils_pou.networks), 2) +check_equal("two coils: three outputs in the first", len(two_coils_pou.networks[0].outputs), 3) +check_equal( + "two coils: one header per network", + len([line for line in two_coils_art if line.startswith("(* Network")]), + 2, +) +check_equal("two coils: the timer is called once", len([l for l in two_coils_st if l.startswith("tmr(")]), 1) +check_equal("two coils: one box is drawn", len([l for l in two_coils_art if "tmr : TON" in l]), 1) + +# All three outputs still there, and the second and third name the pin they +# read rather than redrawing the box that produces it. +check("two coils: the plain coil stores", "xOut1 := tmr.Q;" in two_coils_st) +check("two coils: the set coil latches", "IF tmr.Q THEN xOut2 := TRUE; END_IF" in two_coils_st) +check("two coils: the outVariable stores the other pin", "tElapsed := tmr.ET;" in two_coils_st) +check("two coils: a later output names the box", any("[tmr.ET]" in line for line in two_coils_art)) + +# The same shape in a real SP11 export: LDTesting with one coil added to its +# first network. +SP11_TWO_COILS = os.path.join(FIXTURES, "36-3-ld-two-coils-sp11.xml") +sp11 = parse_pous(SP11_TWO_COILS)[0] +sp11_st = st_render.render_pou(sp11) + +check_equal("sp11 two coils: two networks, not three", len(sp11.networks), 2) +check_equal("sp11 two coils: both coils in network 1", len(sp11.networks[0].outputs), 2) +check("sp11 two coils: the added coil is kept", any("Lamp :=" in line for line in sp11_st)) +check_equal("sp11 two coils: the timer is called once", len([l for l in sp11_st if l.startswith("TON_0(")]), 1) + # A negated wired output feeding a coil, and only one bubble drawn for it. check("fidelity: negated wired output inverts the coil", any("xFin := NOT ctr2.Q;" in line for line in fidelity_st)) check("fidelity: no double bubble on a wired negated output", not any("Q oo" in line for line in fidelity_art)) From fd087ab0223b64bd21c65c1acfef965c54dca69b Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 10:53:56 +1000 Subject: [PATCH 34/91] show the set and reset on a store storage="set" on an outVariable was read by nobody, so a latch rendered as "xLatched := xTrip;" with a plain arrow - text that says the latch clears the moment its input drops, where the program holds it until a reset. The LD parser has always read the same attribute for coils. Both parsers now read storage off an outVariable and off a block's output pins, and every store goes through one emitter: a set or reset becomes the guarded "IF xTrip THEN xLatched := TRUE; END_IF", and the diagram marks the arrow "(S)>" or "(R)>", an output pin "=S>" or "=R>". --- src/fbd_render.py | 38 +++++++-- src/ld_render.py | 24 +++++- src/model.py | 19 ++++- src/parse_fbd.py | 9 +- src/parse_ld.py | 3 + src/plcopen.py | 21 +++++ src/st_render.py | 49 ++++++----- .../tests/fixtures/fbd_storage.plcopen.xml | 84 +++++++++++++++++++ tools/ladder/tests/test_fbd.py | 29 +++++++ 9 files changed, 245 insertions(+), 31 deletions(-) create mode 100644 tools/ladder/tests/fixtures/fbd_storage.plcopen.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 4415257..23bd052 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -36,12 +36,25 @@ def _render_jump(node): return Block(out, source.connect_row) +def _store_head(node): + """The arrow head on a store: its set/reset marker, or its negation. + + A set or reset holds the target until the other one fires. Drawing it as + a plain arrow says the store follows its input, which is the opposite. + """ + if node.storage == "set": + return "(S)> " + if node.storage == "reset": + return "(R)> " + # The negation circle CODESYS draws on the pin, as an "o" on the wire. + return "o> " if node.negated else "> " + + def _render_assign(node): chars = charset.active() source = _render(node.source) if node.source is not None else Block([""], 0) lines = source.padded(source.width) - # The negation circle CODESYS draws on the pin, as an "o" on the wire. - head = "o> " if node.negated else "> " + head = _store_head(node) tail = chars["H"] * 3 + head + (node.label or "?") out = [] for index, line in enumerate(lines): @@ -49,6 +62,20 @@ def _render_assign(node): return Block(out, source.connect_row) +def _pin_arrow(box, pin): + """The arrow for an assignment written straight onto an output pin. + + "=o>" is "=>" with the negation bubble: the pin stores its inverse. "=S>" + and "=R>" are the set and reset a pin can carry, exactly as a coil does. + """ + storage = box.stored_outputs.get(pin) + if storage == "set": + return " =S> " + if storage == "reset": + return " =R> " + return " =o> " if pin in box.negated_outputs else " => " + + def _is_wired(source): """False for a pin CODESYS exported with no source, or an empty expression. @@ -115,8 +142,7 @@ def _render_call(call, read_pin=None): pin, assigned = pin_and_assignment text = pin or "?" if assigned: - # =o> is => with the negation bubble: the pin stores its inverse. - text += (" =o> " if pin in call.negated_outputs else " => ") + assigned + text += _pin_arrow(call, pin) + assigned elif pin in call.negated_outputs: text += " o" out_at[output_rows[index]] = text @@ -198,8 +224,8 @@ def _render(node): def _assign_tail(node): chars = charset.active() - # The negation circle CODESYS draws on the pin, as an "o" on the wire. - return chars["H"] * 2 + ("o " if node.negated else "> ") + (node.label or "?") + head = "o " if node.negated and not node.storage else _store_head(node) + return chars["H"] * 2 + head + (node.label or "?") def _fanout_groups(source, outputs): diff --git a/src/ld_render.py b/src/ld_render.py index ed8b3a7..069a99c 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -67,11 +67,30 @@ def _symbol_and_label(element): # unhandled logic is visible rather than silently dropped. A negated # variable spells its NOT out - there is no bubble to draw on a box. label = element.label or "?" - if element.negated: + if element.storage == "set": + # The same marker a set coil carries: this store holds until a reset. + label = "(S) " + label + elif element.storage == "reset": + label = "(R) " + label + elif element.negated: label = "NOT " + label return "[" + label + "]", "" +def _pin_arrow(box, pin): + """The arrow for an assignment written straight onto an output pin. + + "=o>" is "=>" with the negation bubble: the pin stores its inverse. "=S>" + and "=R>" are the set and reset a pin can carry, exactly as a coil does. + """ + storage = box.stored_outputs.get(pin) + if storage == "set": + return " =S> " + if storage == "reset": + return " =R> " + return " =o> " if pin in box.negated_outputs else " => " + + def _render_block(element): """Draw a function block as a pin box. @@ -96,8 +115,7 @@ def _render_block(element): text = pin or "?" wired_out = element.output_wired and pin == element.active_output if assigned: - # =o> is => with the negation bubble: the pin stores its inverse. - text += (" =o> " if pin in element.negated_outputs else " => ") + assigned + text += _pin_arrow(element, pin) + assigned elif pin in element.negated_outputs and not wired_out: # A wired pin draws its bubble on the box edge instead - one # bubble, not two. diff --git a/src/model.py b/src/model.py index 1c9232b..d972a73 100644 --- a/src/model.py +++ b/src/model.py @@ -86,6 +86,7 @@ def __init__( outputs=None, st_code=None, negated_outputs=None, + stored_outputs=None, ): self.st_code = st_code if st_code is not None else [] # blocks only: inline ST self.local_id = local_id @@ -101,6 +102,8 @@ def __init__( # blocks only: output pins whose in-place negation bubble inverts the # value leaving them self.negated_outputs = negated_outputs if negated_outputs is not None else set() + # blocks only: {pin: "set" | "reset"} for inline assignments that store + self.stored_outputs = dict(stored_outputs) if stored_outputs is not None else {} def __repr__(self): return "Node(%s, %s, %r, inputs=%r)" % (self.local_id, self.kind, self.label, self.inputs) @@ -246,6 +249,7 @@ def __init__( wired_outputs=None, st_code=None, negated_outputs=None, + stored_outputs=None, ): self.type_name = type_name self.instance_name = instance_name @@ -259,6 +263,9 @@ def __init__( # Pins carrying CODESYS's in-place negation bubble: the value leaving # them is the inverse of the pin. self.negated_outputs = negated_outputs if negated_outputs is not None else set() + # {pin: "set" | "reset"} for inline assignments that store instead of + # assigning outright. + self.stored_outputs = dict(stored_outputs) if stored_outputs is not None else {} # An EXECUTE box carries inline ST as its whole body. Dropping it loses # the logic entirely while still drawing a plausible-looking box. self.st_code = st_code if st_code is not None else [] @@ -327,13 +334,17 @@ class Assign(object): that inverts the stored value. """ - def __init__(self, label, source=None, negated=False): + def __init__(self, label, source=None, negated=False, storage=None): self.label = label self.source = source self.negated = negated + # "set" | "reset" | None. A stored value is held until something + # resets it; rendering one as a plain assignment says it clears as + # soon as its condition drops, which is the opposite of the program. + self.storage = storage def __repr__(self): - return "Assign(%r, negated=%r)" % (self.label, self.negated) + return "Assign(%r, negated=%r, storage=%r)" % (self.label, self.negated, self.storage) # --- expression tree ------------------------------------------------------- @@ -373,6 +384,7 @@ def __init__( output_wired=False, power_negated=False, negated_outputs=None, + stored_outputs=None, pin_blocks=None, ): self.kind = kind @@ -390,6 +402,9 @@ def __init__( # logic in place if dropped. self.power_negated = power_negated self.negated_outputs = negated_outputs if negated_outputs is not None else set() + # {pin: "set" | "reset"} for inline assignments that store instead of + # assigning outright. + self.stored_outputs = dict(stored_outputs) if stored_outputs is not None else {} # True when something downstream actually consumes the active output, # so the renderer knows whether to break the box edge with a tee. self.output_wired = output_wired diff --git a/src/parse_fbd.py b/src/parse_fbd.py index 838ce67..c06711d 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -8,6 +8,7 @@ from model import BLOCK, COMMENT, Assign, Call, Jump, Label, Network, Node, OutputRef, Pou, Signal, component_finder from plcopen import ( + attr, block_connections, block_outputs, block_st_code, @@ -20,6 +21,7 @@ iter_bodies, negated_output_pins, parse_interface, + stored_output_pins, tag, ) @@ -75,12 +77,16 @@ def parse_fbd_body(body_elem): kind=kind, label=label, negated=is_true(child, "negated"), + # A store held until something resets it, on an outVariable + # exactly as on an LD coil. + storage=attr(child, "storage"), inputs=block_connections(child) if is_block else direct_connections(child), type_name=child.get("typeName") if is_block else None, instance_name=child.get("instanceName") if is_block else None, outputs=block_outputs(child) if is_block else None, st_code=block_st_code(child) if is_block else None, negated_outputs=negated_output_pins(child) if is_block else None, + stored_outputs=stored_output_pins(child) if is_block else None, ) nodes.append(node) return nodes @@ -148,6 +154,7 @@ def _build_node(node, by_id, visiting, memo): outputs=list(node.outputs), st_code=list(node.st_code), negated_outputs=set(node.negated_outputs), + stored_outputs=node.stored_outputs, ) if node.kind in (OUT_VARIABLE, JUMP, RETURN, CONNECTOR): @@ -158,7 +165,7 @@ def _build_node(node, by_id, visiting, memo): source = _build(upstream, by_id, visiting, connection.source_pin, memo) break if node.kind == OUT_VARIABLE: - return Assign(node.label or "?", source, negated=node.negated) + return Assign(node.label or "?", source, negated=node.negated, storage=node.storage) if node.kind == CONNECTOR: # A connector names the wire feeding it, so it renders as an # assignment to that name and the matching continuation reads the diff --git a/src/parse_ld.py b/src/parse_ld.py index 59190ab..7d74311 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -43,6 +43,7 @@ iter_bodies, negated_output_pins, parse_interface, + stored_output_pins, tag, ) @@ -107,6 +108,7 @@ def parse_ld_body(body_elem): instance_name=child.get("instanceName") if is_block else None, outputs=block_outputs(child) if is_block else None, negated_outputs=negated_output_pins(child) if is_block else None, + stored_outputs=stored_output_pins(child) if is_block else None, ) ) return nodes @@ -254,6 +256,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn): output_wired=via_pin is not None, power_negated=power_negated, negated_outputs=set(node.negated_outputs), + stored_outputs=node.stored_outputs, pin_blocks=pin_blocks, ) return series([power_expr, element]) diff --git a/src/plcopen.py b/src/plcopen.py index 764aea4..9788562 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -126,6 +126,27 @@ def block_outputs(block_elem): return outputs +def stored_output_pins(block_elem): + """{pin: "set" | "reset"} for output pins that store rather than assign. + + CODESYS writes storage="set" on the pin carrying an inline assignment, + exactly as it does on a coil. A stored value is held until something + resets it, so rendering one as a plain assignment says it clears the + moment its condition drops - the opposite of what the program does. + """ + stored = {} + group = find_child(block_elem, "outputVariables") + if group is None: + return stored + for var in group: + if tag(var) != "variable": + continue + storage = attr(var, "storage") + if storage: + stored[var.get("formalParameter")] = storage + return stored + + def negated_output_pins(block_elem): """Output pins carrying an in-place negation bubble (negated="true"). diff --git a/src/st_render.py b/src/st_render.py index 6eb96ec..907f6e2 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -32,16 +32,31 @@ from parse_ld import expr_to_text +def store_statement(target, value, storage=None, negated=False): + """One store, however it is drawn: coil, outVariable or output pin. + + A set or reset holds its target until the other one fires, so it has to + read as a guarded write. Emitting "xLatched := xTrip;" for a set says the + latch clears the moment its input drops, which is the opposite of what + the program does. + """ + value = value or "TRUE" + target = target or "?" + if negated: + value = "NOT " + _operand(value) + if storage == "set": + return "IF %s THEN %s := TRUE; END_IF" % (value, target) + if storage == "reset": + return "IF %s THEN %s := FALSE; END_IF" % (value, target) + return "%s := %s;" % (target, value) + + def _coil_statement(coil, condition): - condition = condition or "TRUE" - target = coil.label or "?" - if coil.storage == "set": - return "IF %s THEN %s := TRUE; END_IF" % (condition, target) - if coil.storage == "reset": - return "IF %s THEN %s := FALSE; END_IF" % (condition, target) - if coil.negated: - return "%s := NOT (%s);" % (target, condition) - return "%s := %s;" % (target, condition) + if coil.negated and not coil.storage: + # A negated coil stores the inverse of the whole rung condition, and + # brackets it rather than negating only its first term. + return "%s := NOT (%s);" % (coil.label or "?", condition or "TRUE") + return store_statement(coil.label, condition, coil.storage) def rung_to_statements(rung): @@ -79,7 +94,7 @@ def rung_to_statements(rung): value = "%s.%s" % (name, pin) if pin in item.negated_outputs: value = "NOT " + value - statements.append("%s := %s;" % (assigned, value)) + statements.append(store_statement(assigned, value, item.stored_outputs.get(pin))) condition = (name + "." + item.active_output) if item.active_output else name if item.active_output in item.negated_outputs: condition = "NOT " + condition @@ -87,11 +102,9 @@ def rung_to_statements(rung): statements.append(_coil_statement(item, condition)) elif isinstance(item, Element) and item.kind == OUT_VARIABLE: # A store through an outVariable element - the standard shape for - # a non-boolean result. Power passes through, like a coil. - value = condition or "TRUE" - if item.negated: - value = "NOT " + _operand(value) - statements.append("%s := %s;" % (item.label or "?", value)) + # a non-boolean result. Power passes through, like a coil, and so + # does the set/reset a coil can carry. + statements.append(store_statement(item.label, condition, item.storage, item.negated)) elif isinstance(item, Element) and item.kind in (JUMP, RETURN): # A jump ends the rung; its guard is the rung condition so far. # Same comment form as the FBD path, so both grep alike. @@ -191,9 +204,7 @@ def _fbd_value(node, statements, emitted=None): if isinstance(node, Assign): value = _fbd_value(node.source, statements, emitted) or "FALSE" - if node.negated: - value = "NOT " + _operand(value) - statements.append("%s := %s;" % (node.label or "?", value)) + statements.append(store_statement(node.label, value, node.storage, node.negated)) return node.label or "?" if isinstance(node, Call): @@ -237,7 +248,7 @@ def remember(value): # A negated output pin stores its inverse. if pin in node.negated_outputs: value = "NOT " + value - statements.append("%s := %s;" % (assigned, value)) + statements.append(store_statement(assigned, value, node.stored_outputs.get(pin))) result = (name + "." + node.active_output) if node.active_output else name if node.active_output in node.negated_outputs: result = "NOT " + result diff --git a/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml b/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml new file mode 100644 index 0000000..9368830 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + xClear + + + + + xLatched + + + + + + + xRun + + + + + T#3S + + + + + + + + + + + + + + + xHeld + + + + + + + + + + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 88542ef..56fd5f4 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -362,6 +362,35 @@ def check_golden(name, rendered, golden_path): check("two pins: no junction between different pins", not any(U["T_DOWN"] in l and "xQ" in l for l in two_pins_art)) +# --- a store that holds: set and reset --------------------------------------- + +# storage="set" on an outVariable was dropped, so a latch rendered as +# "xLatched := xTrip;" - text that says the latch clears the moment its input +# drops, where the program holds it. +latch_st = st_render.render_pou(two_pins) +latch_art = fbd_render.render_network(two_pins.networks[1]) + +check_equal("set: the store is recorded", two_pins.networks[1].outputs[0].storage, "set") +check("set: the ST guards the write", "IF xTrip THEN xLatched := TRUE; END_IF" in latch_st) +check("set: no plain assignment survives", not any("xLatched := xTrip" in line for line in latch_st)) +check("set: the arrow is marked", any("(S)> xLatched" in line for line in latch_art)) + +# The reset counterpart, and the same store written straight onto a block's +# output pin instead of onto a wire. +STORAGE = os.path.join(HERE, "fixtures", "fbd_storage.plcopen.xml") +storage_pou = parse_fbd.parse_pous(STORAGE)[0] +storage_st = st_render.render_pou(storage_pou) +storage_art = fbd_render.render_pou(storage_pou) + +check_equal("reset: the store is recorded", storage_pou.networks[0].outputs[0].storage, "reset") +check("reset: the ST guards the write", "IF xClear THEN xLatched := FALSE; END_IF" in storage_st) +check("reset: the arrow is marked", any("(R)> xLatched" in line for line in storage_art)) + +check_equal("output pin store: recorded on the box", box(storage_pou.networks[1].outputs[0]).stored_outputs["Q"], "set") +check("output pin store: the ST guards the write", "IF tmr.Q THEN xHeld := TRUE; END_IF" in storage_st) +check("output pin store: the pin arrow is marked", any("Q =S> xHeld" in line for line in storage_art)) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) From 5dccd3be65f808cbb2a53a054f33d1802fee2527 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 10:57:02 +1000 Subject: [PATCH 35/91] show edge detection on a block pin edge="rising" on a pin was read by nobody, so a counter that counts once per change rendered as "ctr(CU := xPulse, ...)" - a counter that counts every cycle its input is true. A contact has always carried its P; the pin had nowhere to put one. The edge now rides on the connection beside the negation bubble, and is applied after it, because the bubble inverts what arrives and the detector then sees that value change. It reads as R() or F() - the spelling the contacts already use - and the LD box wall carries the same P or N letter a contact does, where the bubble's "o" goes. --- src/ld_render.py | 10 +++- src/model.py | 40 +++++++++---- src/parse_fbd.py | 41 ++++++++++++- src/parse_ld.py | 29 ++++++++-- src/plcopen.py | 7 ++- src/st_render.py | 12 ++-- .../tests/fixtures/36-5-fbd-pin-edge.xml | 47 +++++++++++++++ .../tests/fixtures/36-5-ld-pin-edge.xml | 57 +++++++++++++++++++ tools/ladder/tests/test_fbd.py | 16 ++++++ tools/ladder/tests/test_ladder.py | 20 +++++++ 10 files changed, 252 insertions(+), 27 deletions(-) create mode 100644 tools/ladder/tests/fixtures/36-5-fbd-pin-edge.xml create mode 100644 tools/ladder/tests/fixtures/36-5-ld-pin-edge.xml diff --git a/src/ld_render.py b/src/ld_render.py index 069a99c..27a1900 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -19,6 +19,10 @@ from layout import Block from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series +# The letter a contact carries for edge detection, reused on a block's power +# pin so both read the same. +EDGE_MARKER = {"rising": "P", "falling": "N"} + POU_TYPE_KEYWORDS = { "program": "PROGRAM", "functionBlock": "FUNCTION_BLOCK", @@ -135,7 +139,11 @@ def _render_block(element): for index in range(rows): gap = inner - len(left[index]) - len(right[index]) left_edge = chars["PIN_L"] if wired[index] else chars["V"] - if wired[index] and element.power_negated: + if wired[index] and element.power_edge in EDGE_MARKER: + # The P or N on the power pin, drawn on the box wall in the same + # place the bubble goes and the same letter a contact carries. + left_edge = EDGE_MARKER[element.power_edge] + elif wired[index] and element.power_negated: # The negation bubble on the power pin, drawn on the box wall. left_edge = "o" # Only the active output continues onward, and only if consumed. diff --git a/src/model.py b/src/model.py index d972a73..74ca9e8 100644 --- a/src/model.py +++ b/src/model.py @@ -57,13 +57,18 @@ class Connection(object): ``negated`` is the bubble CODESYS draws on the *pin itself* (negated="true" on the pin's variable element) - separate from a negated inVariable, and just as logic-inverting if dropped. + + ``edge`` is the P or N CODESYS draws on the pin: the pin sees a single + scan when its value changes, not the value itself. A block behind one + runs once per edge, and reads as running every cycle without it. """ - def __init__(self, ref_id, source_pin=None, target_pin=None, negated=False): + def __init__(self, ref_id, source_pin=None, target_pin=None, negated=False, edge=None): self.ref_id = ref_id self.source_pin = source_pin self.target_pin = target_pin self.negated = negated + self.edge = edge # "rising" | "falling" | None def __repr__(self): return "Connection(%s, source_pin=%r, target_pin=%r)" % (self.ref_id, self.source_pin, self.target_pin) @@ -184,30 +189,39 @@ def rungs(self): # by a named value or by another block's output. +# How an edge-triggered pin reads. The same spelling the LD contacts use, so +# both languages grep alike. +EDGE_FUNCTION = {"rising": "R", "falling": "F"} + + class Signal(object): """A named value entering a network: a variable, a literal, or nothing. CODESYS can negate an inVariable in place, which is easy to miss and - inverts the logic if it is dropped. + inverts the logic if it is dropped. ``edge`` is the pin's own P or N: the + bubble inverts what arrives, and the edge detector then sees that value + change, so the negation goes inside. """ - def __init__(self, label, negated=False): + def __init__(self, label, negated=False, edge=None): self.label = label self.negated = negated + self.edge = edge @property def text(self): label = self.label or "" - if not self.negated: - return label - # A compound expression must keep its parentheses or the logic - # regroups - see is_simple_term for the precedence trap. - if is_simple_term(label): - return "NOT " + label - return "NOT (" + label + ")" + if self.negated: + # A compound expression must keep its parentheses or the logic + # regroups - see is_simple_term for the precedence trap. + label = ("NOT " + label) if is_simple_term(label) else ("NOT (" + label + ")") + function = EDGE_FUNCTION.get(self.edge) + if function: + return function + "(" + label + ")" + return label def __repr__(self): - return "Signal(%r, negated=%r)" % (self.label, self.negated) + return "Signal(%r, negated=%r, edge=%r)" % (self.label, self.negated, self.edge) class Jump(object): @@ -383,6 +397,7 @@ def __init__( active_output=None, output_wired=False, power_negated=False, + power_edge=None, negated_outputs=None, stored_outputs=None, pin_blocks=None, @@ -401,6 +416,9 @@ def __init__( # through, and the set of output pins carrying one. Both invert the # logic in place if dropped. self.power_negated = power_negated + # The P or N on that same pin: the block runs once per edge, not once + # per scan the condition holds. + self.power_edge = power_edge self.negated_outputs = negated_outputs if negated_outputs is not None else set() # {pin: "set" | "reset"} for inline assignments that store instead of # assigning outright. diff --git a/src/parse_fbd.py b/src/parse_fbd.py index c06711d..4524d56 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -6,7 +6,21 @@ rather than a series/parallel chain. """ -from model import BLOCK, COMMENT, Assign, Call, Jump, Label, Network, Node, OutputRef, Pou, Signal, component_finder +from model import ( + BLOCK, + COMMENT, + EDGE_FUNCTION, + Assign, + Call, + Jump, + Label, + Network, + Node, + OutputRef, + Pou, + Signal, + component_finder, +) from plcopen import ( attr, block_connections, @@ -109,6 +123,27 @@ def _negate(source): ) +def _edge(source, edge): + """Wrap a pin's source in the edge detection its P or N marker demands. + + A Signal carries the marker itself; anything else becomes an explicit R + or F box, the same shape _negate uses, so the trigger is visible in both + the ST and the diagram rather than reading as a plain level. + """ + function = EDGE_FUNCTION.get(edge) + if function is None: + return source + if isinstance(source, Signal): + return Signal(source.label, negated=source.negated, edge=edge) + return Call( + type_name=function, + inputs=[("In", source)], + outputs=[("Out", None)], + active_output="Out", + wired_outputs=["Out"], + ) + + def _build(node, by_id, visiting, via_pin=None, memo=None): """Build the tree feeding a node, as read through ``via_pin``. @@ -145,6 +180,10 @@ def _build_node(node, by_id, visiting, memo): if connection.negated and source is not None: # The bubble on the pin itself, not on what feeds it. source = _negate(source) + if connection.edge and source is not None: + # The P or N on that same pin, applied after the bubble: the + # detector sees the value the pin actually receives. + source = _edge(source, connection.edge) inputs.append((connection.target_pin, source)) return Call( diff --git a/src/parse_ld.py b/src/parse_ld.py index 7d74311..c5880f5 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -9,6 +9,7 @@ from model import ( BLOCK, COMMENT, + EDGE_FUNCTION, IN_VARIABLE, JUMP, LABEL, @@ -207,6 +208,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn): power_expr = Empty() power_pin = None power_negated = False + power_edge = None side_pins = [] pin_blocks = [] @@ -225,8 +227,10 @@ def _build_block(node, by_id, visiting, via_pin, drawn): power_pin = connection.target_pin power_expr = sub_expr # The pin's own negation bubble; it inverts the power flow at the - # box wall, after everything the rung has accumulated. + # box wall, after everything the rung has accumulated. The P or N + # on that pin sits there too. power_negated = connection.negated + power_edge = connection.edge else: side_pins.append((connection.target_pin, _pin_text(sub_expr, connection, pin_blocks))) @@ -255,6 +259,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn): # the rung has none. output_wired=via_pin is not None, power_negated=power_negated, + power_edge=power_edge, negated_outputs=set(node.negated_outputs), stored_outputs=node.stored_outputs, pin_blocks=pin_blocks, @@ -298,14 +303,26 @@ def _pin_expr_text(expr, hoisted): return expr_to_text(expr) -def _pin_text(sub_expr, connection, hoisted): - """A side pin's caption, honouring the pin's own negation bubble.""" - text = _pin_expr_text(sub_expr, hoisted) - if connection.negated: - return "NOT " + _bracket(text) if text else "NOT ?" +def pin_value(text, negated=False, edge=None): + """A value as the pin receives it: bubble first, then edge detection. + + The bubble inverts what arrives; the P or N then triggers on that value + changing. Dropping the edge renders a block that runs once per change as + one that runs every cycle its input is true. + """ + if negated: + text = "NOT " + _bracket(text) if text else "NOT ?" + function = EDGE_FUNCTION.get(edge) + if function: + return function + "(" + (text or "?") + ")" return text +def _pin_text(sub_expr, connection, hoisted): + """A side pin's caption, honouring the pin's own bubble and edge.""" + return pin_value(_pin_expr_text(sub_expr, hoisted), connection.negated, connection.edge) + + def _build_expr(node, by_id, visiting, via_pin=None, drawn=None): """Walk backwards from a node to the power rail, building series/parallel. diff --git a/src/plcopen.py b/src/plcopen.py index 9788562..660d59d 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -83,8 +83,9 @@ def block_connections(block_elem): """Wires arriving at a block, tagged with the pin they land on. A pin variable can carry negated="true" - the bubble CODESYS draws on the - pin itself. It applies to everything arriving at that pin, so it rides on - each connection. + pin itself - and edge="rising"/"falling", the P or N that makes the pin + see a change rather than a level. Both apply to everything arriving at + that pin, so they ride on each connection. """ connections = [] for group_name in ("inputVariables", "inOutVariables"): @@ -96,9 +97,11 @@ def block_connections(block_elem): continue pin = var.get("formalParameter") pin_negated = is_true(var, "negated") + pin_edge = attr(var, "edge") for connection in direct_connections(var): connection.target_pin = pin connection.negated = pin_negated + connection.edge = pin_edge connections.append(connection) return connections diff --git a/src/st_render.py b/src/st_render.py index 907f6e2..fd4d092 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -29,7 +29,7 @@ Signal, is_simple_term, ) -from parse_ld import expr_to_text +from parse_ld import expr_to_text, pin_value def store_statement(target, value, storage=None, negated=False): @@ -77,11 +77,11 @@ def rung_to_statements(rung): for pin, label in item.input_pins: # A label of None is the power pin, fed by the rung so far. value = condition if label is None else label - if label is None and item.power_negated: - # The negation bubble on the power pin itself. A bare - # rail feed has no condition, but the inversion must - # still be stated or the ST reads as un-negated. - value = "NOT " + _operand(value) if value else "NOT TRUE" + if label is None and (item.power_negated or item.power_edge): + # The bubble and the P or N on the power pin itself. A + # bare rail feed has no condition, but the markers must + # still be stated or the ST reads as an unmarked level. + value = pin_value(value or "TRUE", item.power_negated, item.power_edge) if value: args.append("%s := %s" % (pin, value)) name = item.instance_name or item.type_name or "?" diff --git a/tools/ladder/tests/fixtures/36-5-fbd-pin-edge.xml b/tools/ladder/tests/fixtures/36-5-fbd-pin-edge.xml new file mode 100644 index 0000000..601b00d --- /dev/null +++ b/tools/ladder/tests/fixtures/36-5-fbd-pin-edge.xml @@ -0,0 +1,47 @@ + + + + + + + + xPulse + xRst + + + + + + iCv + xCntDone + iX + iY + + + + + + + iDiff + xP + xQ2 + + + + + + + xBoth + xR + + + + + + + xEither + + + + + diff --git a/tools/ladder/tests/fixtures/36-5-ld-pin-edge.xml b/tools/ladder/tests/fixtures/36-5-ld-pin-edge.xml new file mode 100644 index 0000000..8f649b2 --- /dev/null +++ b/tools/ladder/tests/fixtures/36-5-ld-pin-edge.xml @@ -0,0 +1,57 @@ + + + + + + + + + + xA + + xEdge + + xCount + + xRst + + + + + + + + 10 + + xFull + + xD + + xE + + xF + + xG + + xEn + iA + iB + + + + + + + + + + + + xAdded + + iSum + + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 56fd5f4..895c55c 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -391,6 +391,22 @@ def check_golden(name, rendered, golden_path): check("output pin store: the pin arrow is marked", any("Q =S> xHeld" in line for line in storage_art)) +# --- edge detection on a block pin ------------------------------------------- + +# edge="rising" on a pin was read by nobody, so a counter that counts once per +# change rendered as one that counts every cycle its input is true. Contacts +# have always carried the marker; the pins had nowhere to put it. +PIN_EDGE = os.path.join(HERE, "fixtures", "36-5-fbd-pin-edge.xml") +pin_edge = parse_fbd.parse_pous(PIN_EDGE)[0] +pin_edge_st = st_render.render_pou(pin_edge) +pin_edge_art = fbd_render.render_network(pin_edge.networks[0]) + +check_equal("pin edge: the edge reaches the tree", box(pin_edge.networks[0].outputs[0].source).inputs[0][1].edge, "rising") +check("pin edge: the ST shows the trigger", "ctr(CU := R(xPulse), RESET := xRst);" in pin_edge_st) +check("pin edge: the diagram marks the pin", any("R(xPulse)" in line for line in pin_edge_art)) +check("pin edge: an unmarked pin stays unmarked", not any("R(xRst)" in line for line in pin_edge_st)) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index bb42674..c98c823 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -304,6 +304,26 @@ def check_golden(name, rendered_lines, golden_path): check("sp11 two coils: the added coil is kept", any("Lamp :=" in line for line in sp11_st)) check_equal("sp11 two coils: the timer is called once", len([l for l in sp11_st if l.startswith("TON_0(")]), 1) + +# --- edge detection on a block pin ------------------------------------------- + +# A contact has always carried its P; the block pin the rung's power enters +# through had nowhere to put one, so a counter that counts once per change +# rendered as one that counts every cycle its input is true. +PIN_EDGE = os.path.join(FIXTURES, "36-5-ld-pin-edge.xml") +pin_edge_pou = parse_pous(PIN_EDGE)[0] +pin_edge_st = st_render.render_pou(pin_edge_pou) +pin_edge_art = render_pou(pin_edge_pou) + +check("ld pin edge: the ST shows the trigger", "ctr(CU := R(xCount), RESET := xRst, PV := 10);" in pin_edge_st) +check("ld pin edge: the box wall carries the marker", any("PCU" in line for line in pin_edge_art)) +check("ld pin edge: an unmarked pin stays unmarked", not any("R(xRst)" in line for line in pin_edge_st)) + +# The contact form, which already worked, must keep working: same spelling in +# the ST, same letter in the diagram. +check("ld pin edge: a contact still triggers", "xEdge := R(xA);" in pin_edge_st) +check("ld pin edge: a contact still draws its P", any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in line for line in pin_edge_art)) + # A negated wired output feeding a coil, and only one bubble drawn for it. check("fidelity: negated wired output inverts the coil", any("xFin := NOT ctr2.Q;" in line for line in fidelity_st)) check("fidelity: no double bubble on a wired negated output", not any("Q oo" in line for line in fidelity_art)) From 21f3cf75752ef8bfd9cd7978e528731d63f1a2c3 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 11:06:20 +1000 Subject: [PATCH 36/91] number the networks the editor shows A network with no logic in it - one holding only a comment, or emptied out - was dropped, and its comment then attached to the network after it. So the file showed one network numbered 1 carrying the second network's comment, and every number after a dropped network was wrong: a reviewer opening "Network 5" in CODESYS read different logic under "(* Network 5 *)". Both parsers now assemble their components into networks through the same routine. A comment or a title element heads the network whose elements follow it, and a second one of either means the header before it headed a network of its own - which is the network holding only documentation. The header is not treated as a boundary on its own, because CODESYS writes no comment element for a network that has none; connectivity still separates the networks, and the header only names the one it precedes. A jump label is stored on its network in CODESYS but exported as a free-standing element just before it. It now joins the network it labels instead of taking a number of its own. The LD parser had no idea the comment element existed, so every LD header read a bare "(* Network n *)"; and a network's title, which CODESYS keeps separately from its comment and is often the only description a network has, was skipped with the rest of the vendorElements. Both are now read, and one header builder serves all three renderers. --- src/fbd_render.py | 8 +- src/ld_render.py | 30 +- src/model.py | 62 +++- src/parse_fbd.py | 70 +++-- src/parse_ld.py | 48 ++- src/plcopen.py | 29 ++ src/st_render.py | 12 +- .../36-6-fbd-comment-only-network.xml | 19 ++ .../ladder/tests/fixtures/36-6-ld-comment.xml | 271 ++++++++++++++++ .../tests/fixtures/36-6-ld-empty-network.xml | 288 ++++++++++++++++++ tools/ladder/tests/test_fbd.py | 58 +++- tools/ladder/tests/test_ladder.py | 46 ++- 12 files changed, 877 insertions(+), 64 deletions(-) create mode 100644 tools/ladder/tests/fixtures/36-6-fbd-comment-only-network.xml create mode 100644 tools/ladder/tests/fixtures/36-6-ld-comment.xml create mode 100644 tools/ladder/tests/fixtures/36-6-ld-empty-network.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 23bd052..492d9e2 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -11,7 +11,7 @@ import charset from layout import Block, stack -from ld_render import render_declaration +from ld_render import network_headers, render_declaration from model import Assign, Call, Jump, Label, OutputRef, Signal @@ -352,11 +352,7 @@ def render_pou(pou): lines.append("(* no networks *)") for index, network in enumerate(pou.networks): - header = "(* Network " + str(index + 1) - if network.comment: - comment = network.comment.replace("\r", " ").replace("\n", " ").replace("*)", "* )") - header += ": " + comment.lstrip("/").strip() - lines.append(header + " *)") + lines.extend(network_headers(index + 1, network)) lines.extend(render_network(network)) lines.append("") diff --git a/src/ld_render.py b/src/ld_render.py index 27a1900..0351ec7 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -30,6 +30,34 @@ } +def _one_line(text): + """Comment text safe to put inside a generated (* *) block. + + A comment can span lines and can contain "*)", either of which would + terminate the block early and leave the rest of it as code. + """ + return text.replace("\r", " ").replace("\n", " ").replace("*)", "* )") + + +def network_headers(number, network): + """The header lines above one network: its number, comment and title. + + CODESYS keeps a network's title separately from its comment and draws it + above one, so it gets a line of its own rather than being folded into the + comment - a network can carry either, both or neither, and the title is + often the only description there is. + """ + header = "(* Network " + str(number) + comment = _one_line(network.comment or "").lstrip("/").strip() + if comment: + header += ": " + comment + lines = [header + " *)"] + title = _one_line(getattr(network, "title", "") or "").lstrip("/").strip() + if title: + lines.append("(* title: " + title + " *)") + return lines + + def _symbol_and_label(element): """The drawn symbol, and the caption sitting above it.""" chars = charset.active() @@ -345,7 +373,7 @@ def render_pou(pou): lines.append("(* no rungs *)") for index, network in enumerate(pou.networks): - lines.append("(* Network " + str(index + 1) + " *)") + lines.extend(network_headers(index + 1, network)) for rung in network.outputs: lines.extend(render_rung(rung)) lines.append("") diff --git a/src/model.py b/src/model.py index 74ca9e8..2482a33 100644 --- a/src/model.py +++ b/src/model.py @@ -42,6 +42,9 @@ def is_simple_term(text): # Carries no logic, but CODESYS writes one above each network that has a # comment, so it marks where one network begins. COMMENT = "comment" +# The network's title, exported as a vendorElement. Also a header, also a +# boundary, and often the only description a network carries. +TITLE = "networktitle" RAILS = (LEFT_RAIL, RIGHT_RAIL) @@ -144,6 +147,58 @@ def find(item): return find +def assemble_networks(nodes, root_of, outputs_by_root, label_roots=()): + """Order the components of a body into networks, in editor order. + + Returns [(comment, title, [outputs])]. Three things decide where a + network begins and what it is called: + + * A comment or a title element is a header, and heads the network whose + elements follow it. A second one of either means the header before it + headed a network of its own - a network holding nothing but + documentation. Dropping those silently renumbered every network after + them, so a reviewer opening "Network 5" in CODESYS read different logic + under "(* Network 5 *)" in the file. + * A header is not a reliable boundary on its own: CODESYS writes no + comment element for a network that has none. Connectivity is what + separates networks; the header only names the one it precedes. + * A jump label is stored on the network in CODESYS but exported as a + free-standing element just before it, so it arrives as a component of + its own. It belongs to the network that follows it. + """ + label_roots = set(label_roots) + networks = [] + header = [None, None] + carried = [] + seen = set() + + for node in nodes: + if node.kind in (COMMENT, TITLE): + index = 0 if node.kind == COMMENT else 1 + if header[index] is not None: + networks.append((header[0] or "", header[1] or "", list(carried))) + del carried[:] + header = [None, None] + header[index] = node.label or "" + continue + + root = root_of(node) + if root is None or root in seen or root not in outputs_by_root: + continue + seen.add(root) + if root in label_roots: + carried.extend(outputs_by_root[root]) + continue + + networks.append((header[0] or "", header[1] or "", carried + outputs_by_root[root])) + del carried[:] + header = [None, None] + + if header[0] is not None or header[1] is not None or carried: + networks.append((header[0] or "", header[1] or "", list(carried))) + return networks + + class Variable(object): """One entry from the POU interface, for rendering the declaration block.""" @@ -333,12 +388,15 @@ class Network(object): with the editor, which is what a reviewer compares against. """ - def __init__(self, comment="", outputs=None): + def __init__(self, comment="", outputs=None, title=""): self.comment = comment + # CODESYS keeps a network's title separately from its comment, and + # draws it above one. A network can carry either, both or neither. + self.title = title self.outputs = outputs if outputs is not None else [] def __repr__(self): - return "Network(%r, %d outputs)" % (self.comment, len(self.outputs)) + return "Network(%r, %r, %d outputs)" % (self.comment, self.title, len(self.outputs)) class Assign(object): diff --git a/src/parse_fbd.py b/src/parse_fbd.py index 4524d56..98daa5c 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -10,6 +10,7 @@ BLOCK, COMMENT, EDGE_FUNCTION, + TITLE, Assign, Call, Jump, @@ -19,6 +20,7 @@ OutputRef, Pou, Signal, + assemble_networks, component_finder, ) from plcopen import ( @@ -34,6 +36,7 @@ is_true, iter_bodies, negated_output_pins, + network_title, parse_interface, stored_output_pins, tag, @@ -47,9 +50,22 @@ CONNECTOR = "connector" CONTINUATION = "continuation" -# vendorElement carries CODESYS editor state (network titles, implementation -# attributes) and holds no logic, so it is skipped entirely. -FBD_KINDS = (BLOCK, IN_VARIABLE, OUT_VARIABLE, COMMENT, JUMP, RETURN, LABEL, CONTINUATION, CONNECTOR) +VENDOR_ELEMENT = "vendorElement" + +# A vendorElement carries CODESYS editor state and mostly holds no logic - but +# a network title is one, and a title heads a network just as a comment does. +FBD_KINDS = ( + BLOCK, + IN_VARIABLE, + OUT_VARIABLE, + COMMENT, + JUMP, + RETURN, + LABEL, + CONTINUATION, + CONNECTOR, + VENDOR_ELEMENT, +) # Elements that can terminate a network. A jump or return ends one just as # surely as an assignment does - leaving them out drops the entire guard @@ -74,6 +90,12 @@ def parse_fbd_body(body_elem): nodes.append(Node(local_id=local_id, kind=COMMENT, label=comment_text(child))) continue + if kind == VENDOR_ELEMENT: + title = network_title(child) + if title is not None: + nodes.append(Node(local_id=local_id, kind=TITLE, label=title)) + continue + is_block = kind == BLOCK if is_block: label = child.get("instanceName") or child.get("typeName") @@ -228,7 +250,7 @@ def build_networks(nodes): networks with the whole shared expression written out twice, and threw the numbering out against what a reviewer sees in CODESYS. """ - logic = [node for node in nodes if node.kind != COMMENT] + logic = [node for node in nodes if node.kind not in (COMMENT, TITLE)] by_id = {} for node in logic: @@ -241,35 +263,29 @@ def build_networks(nodes): for connection in node.inputs: consumed.add(connection.ref_id) - # A comment applies to the component whose first element follows it. - comments = {} - pending = "" - for node in nodes: - if node.kind == COMMENT: - pending = node.label or "" - continue - root = find(node.local_id) - if root not in comments: - comments[root] = pending - pending = "" - # Shared upstream nodes must come back as the same object, so the # renderers can tell a fan-out from two coincidentally equal expressions. memo = {} - networks = [] - by_root = {} + outputs_by_root = {} for node in logic: if node.local_id in consumed or node.kind not in SINK_KINDS: continue - tree = _build(node, by_id, set(), None, memo) - root = find(node.local_id) - if root in by_root: - by_root[root].outputs.append(tree) - else: - network = Network(comment=comments.get(root, ""), outputs=[tree]) - by_root[root] = network - networks.append(network) - return networks + outputs_by_root.setdefault(find(node.local_id), []).append(_build(node, by_id, set(), None, memo)) + + # A component that is nothing but a jump label is the label of the network + # that follows it, not a network of its own. + label_roots = set() + for root, outputs in outputs_by_root.items(): + if outputs and all(isinstance(tree, Label) for tree in outputs): + label_roots.add(root) + + def root_of(node): + return find(node.local_id) if node.local_id in by_id else None + + return [ + Network(comment=comment, title=title, outputs=outputs) + for comment, title, outputs in assemble_networks(nodes, root_of, outputs_by_root, label_roots) + ] LANGUAGE = "FBD" diff --git a/src/parse_ld.py b/src/parse_ld.py index c5880f5..f89e05d 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -17,6 +17,7 @@ RAILS, RETURN, RIGHT_RAIL, + TITLE, CONTACT, COIL, Element, @@ -26,6 +27,7 @@ Parallel, Pou, Series, + assemble_networks, component_finder, is_simple_term, parallel, @@ -43,13 +45,17 @@ is_true, iter_bodies, negated_output_pins, + network_title, parse_interface, stored_output_pins, tag, ) +VENDOR_ELEMENT = "vendorElement" + # Elements that carry logic. Rails are structural: they anchor a rung but draw -# nothing themselves. +# nothing themselves; a comment and a network title carry no logic either, but +# both head the network they precede. KNOWN_KINDS = ( LEFT_RAIL, RIGHT_RAIL, @@ -62,6 +68,7 @@ RETURN, LABEL, COMMENT, + VENDOR_ELEMENT, ) @@ -90,11 +97,16 @@ def parse_ld_body(body_elem): if kind == COMMENT: # No logic of its own, but CODESYS writes one above each network - # that has a comment, which is the only thing in an LD body that - # says where one network ends and the next begins. + # that has a comment, and it names the network that follows it. nodes.append(Node(local_id=local_id, kind=COMMENT, label=comment_text(child))) continue + if kind == VENDOR_ELEMENT: + title = network_title(child) + if title is not None: + nodes.append(Node(local_id=local_id, kind=TITLE, label=title)) + continue + is_block = kind == BLOCK nodes.append( Node( @@ -378,7 +390,7 @@ def build_networks(nodes): for node in nodes: by_id[node.local_id] = node - logic = [node for node in nodes if node.kind not in RAILS and node.kind != COMMENT] + logic = [node for node in nodes if node.kind not in RAILS and node.kind not in (COMMENT, TITLE)] known = set(node.local_id for node in logic) find = component_finder(logic) @@ -406,23 +418,33 @@ def root_of(node): # block belongs to one network, so this cannot leak across networks. drawn = set() - order = [] rungs_by_root = {} for node in nodes: - if node.local_id in consumed or node.kind in (LEFT_RAIL, COMMENT): + if node.local_id in consumed or node.kind in (LEFT_RAIL, COMMENT, TITLE): # An unconnected left rail is an empty rung, not a terminal. continue expr = _build_expr(node, by_id, set(), None, drawn) if isinstance(expr, Empty): # An unconnected rail or a stray element with nothing on it. continue - root = root_of(node) - if root not in rungs_by_root: - rungs_by_root[root] = [] - order.append(root) - rungs_by_root[root].append(expr) - - return [Network(comment="", outputs=rungs_by_root[root]) for root in order] + rungs_by_root.setdefault(root_of(node), []).append(expr) + + # A component that is nothing but a jump label is the label of the network + # that follows it, not a network of its own. + label_roots = set() + for root, rungs in rungs_by_root.items(): + if rungs and all(isinstance(rung, Element) and rung.kind == LABEL for rung in rungs): + label_roots.add(root) + + def network_root(node): + if node.kind == LEFT_RAIL: + return None + return root_of(node) + + return [ + Network(comment=comment, title=title, outputs=rungs) + for comment, title, rungs in assemble_networks(nodes, network_root, rungs_by_root, label_roots) + ] LANGUAGE = "LD" diff --git a/src/plcopen.py b/src/plcopen.py index 660d59d..dc4bb77 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -199,6 +199,35 @@ def comment_text(elem): return xhtml.text.strip() +# CODESYS stores a network's title as a vendorElement of this element type, +# with the text in alternativeText rather than in a content element. +NETWORK_TITLE = "networktitle" + + +def network_title(elem): + """The title text of a network-title vendorElement, else None. + + A vendorElement is CODESYS editor state and most of them hold no logic, + but this one holds the network's title - which is often the only + description a network has, and marks where a network begins even when it + has no comment. + """ + is_title = False + for child in elem.iter(): + if tag(child) == "ElementType" and (child.text or "").strip() == NETWORK_TITLE: + is_title = True + break + if not is_title: + return None + alternative = find_child(elem, "alternativeText") + if alternative is None: + return "" + xhtml = find_child(alternative, "xhtml") + if xhtml is None or xhtml.text is None: + return "" + return xhtml.text.strip() + + # --- interface ------------------------------------------------------------- SCOPE_TAGS = { diff --git a/src/st_render.py b/src/st_render.py index fd4d092..0e4220e 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -11,7 +11,7 @@ matches what the rung does. """ -from ld_render import render_declaration +from ld_render import network_headers, render_declaration from model import ( BLOCK, COIL, @@ -276,14 +276,6 @@ def network_to_statements(network): return statements -def _network_header(index, comment): - header = "(* Network " + str(index + 1) - if comment: - comment = comment.replace("\r", " ").replace("\n", " ").replace("*)", "* )") - header += ": " + comment.lstrip("/").strip() - return header + " *)" - - LD = "LD" @@ -297,7 +289,7 @@ def render_pou(pou): lines.append("") for index, network in enumerate(pou.networks): - lines.append(_network_header(index, network.comment)) + lines.extend(network_headers(index + 1, network)) if pou.language == LD: for rung in network.outputs: lines.extend(rung_to_statements(rung)) diff --git a/tools/ladder/tests/fixtures/36-6-fbd-comment-only-network.xml b/tools/ladder/tests/fixtures/36-6-fbd-comment-only-network.xml new file mode 100644 index 0000000..bc0b42b --- /dev/null +++ b/tools/ladder/tests/fixtures/36-6-fbd-comment-only-network.xml @@ -0,0 +1,19 @@ + + + + + + + + // Section header: E-STOP CHAIN (documentation-only network) + Title of network one + networktitle + // second network comment + Title of network two + networktitle + xIn + xOut + + + + diff --git a/tools/ladder/tests/fixtures/36-6-ld-comment.xml b/tools/ladder/tests/fixtures/36-6-ld-comment.xml new file mode 100644 index 0000000..56177e6 --- /dev/null +++ b/tools/ladder/tests/fixtures/36-6-ld-comment.xml @@ -0,0 +1,271 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + Sensor1 + + + + + + + + sensor3 + + + + + + + + + Sensor2 + + + + + + + + PowerOn + + + + + XXX - WARNING: Timer then counter + + + + + + + + + + networktitle + + + + + + + + + + PowerOn + + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + + + + + + 4f4ad042-bbb9-4292-adb4-f91543e47fce + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/fixtures/36-6-ld-empty-network.xml b/tools/ladder/tests/fixtures/36-6-ld-empty-network.xml new file mode 100644 index 0000000..7d6053b --- /dev/null +++ b/tools/ladder/tests/fixtures/36-6-ld-empty-network.xml @@ -0,0 +1,288 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + Sensor1 + + + + + + + + sensor3 + + + + + + + + + Sensor2 + + + + + + + + PowerOn + + + + + SECTION: safety interlocks + + + + + + + + + + networktitle + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + PowerOn + + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + + + + + + 4f4ad042-bbb9-4292-adb4-f91543e47fce + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 895c55c..5762496 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -24,7 +24,7 @@ import parse_ld # noqa: E402 import parse_fbd # noqa: E402 import st_render # noqa: E402 -from model import Call, Network, OutputRef, Pou, Signal # noqa: E402 +from model import Call, Label, Network, OutputRef, Pou, Signal # noqa: E402 from render import write # noqa: E402 # Referenced through the charset table rather than as literal glyphs: this @@ -100,10 +100,21 @@ def check_golden(name, rendered, golden_path): ) check_equal( "ST network comments cannot break generated block comments", - st_render._network_header(0, hostile_comment), + st_render.render_pou(Pou("HOSTILE", "program", networks=[Network(hostile_comment, [Signal("x")])]))[2], "(* Network 1: first second * ) third *)", ) +# A title is a second field, and can break the block comment just as a +# comment can. +hostile_title = "one *) two" +check_equal( + "network titles cannot break generated block comments", + fbd_render.render_pou( + Pou("HOSTILE", "program", networks=[Network("", [Signal("x")], title=hostile_title)]) + )[3], + "(* title: one * ) two *)", +) + check("network 1 is a call", isinstance(tree1, Call)) check_equal("network 1 instance", tree1.instance_name, "fbSystemSupply") check_equal("network 1 type", tree1.type_name, "ifmIOcommon.SystemSupply") @@ -206,7 +217,12 @@ def check_golden(name, rendered, golden_path): flow_st = st_render.render_pou(flow) flow_art = fbd_render.render_pou(flow) -check_equal("flow: four networks survive", len(flow.networks), 4) +# Three, not four: CODESYS stores a jump label on the network it labels, but +# PLCopen exports it as a free-standing element just before that network, so +# it arrives wired to nothing. Counting it as a network of its own put the +# label under its own number and pushed every later number out by one. +check_equal("flow: three networks survive", len(flow.networks), 3) +check("flow: the label heads the network it labels", isinstance(flow.networks[2].outputs[0], Label)) # A jump terminates a network. Leaving it out of SINK_KINDS dropped the entire # guard network, because nothing else consumed the OR feeding it. @@ -222,7 +238,7 @@ def check_golden(name, rendered, golden_path): check("flow: negation survives into ST", any("(NOT xInitDone) OR" in line for line in flow_st)) # An EXECUTE box is nothing but inline ST; drawing the box alone loses it all. -execute = flow.networks[3].outputs[0] +execute = flow.networks[2].outputs[1] check_equal("flow: inline ST is captured", len(execute.st_code), 4) check("flow: inline ST reaches the ST output", any("Status.Faulted := FALSE;" in line for line in flow_st)) # The EN pin genuinely guards the box, so it has to show up as a condition @@ -407,6 +423,40 @@ def check_golden(name, rendered, golden_path): check("pin edge: an unmarked pin stays unmarked", not any("R(xRst)" in line for line in pin_edge_st)) +# --- a network that holds only a comment ------------------------------------- + +# A network with no logic in it was dropped, and its comment then attached to +# the next network - so the file showed one network numbered 1 carrying the +# second network's comment. Every number after a dropped network is wrong, +# and a reviewer opening "Network 5" in CODESYS reads different logic under +# "(* Network 5 *)" in the file. +COMMENT_ONLY = os.path.join(HERE, "fixtures", "36-6-fbd-comment-only-network.xml") +comment_only = parse_fbd.parse_pous(COMMENT_ONLY)[0] +comment_only_art = fbd_render.render_pou(comment_only) + +check_equal("comment-only: two networks", len(comment_only.networks), 2) +check_equal("comment-only: the first has no logic", len(comment_only.networks[0].outputs), 0) +check( + "comment-only: the first keeps its own comment", + comment_only.networks[0].comment.startswith("// Section header: E-STOP CHAIN"), +) +check_equal("comment-only: the second keeps its own", comment_only.networks[1].comment, "// second network comment") +check_equal( + "comment-only: the numbering follows the editor", + [line for line in comment_only_art if line.startswith("(* Network")], + [ + "(* Network 1: Section header: E-STOP CHAIN (documentation-only network) *)", + "(* Network 2: second network comment *)", + ], +) + +# The title is a second field CODESYS draws above the comment, and can be the +# only description a network has. It was skipped with the rest of the +# vendorElements. +check_equal("comment-only: titles are read", comment_only.networks[0].title, "Title of network one") +check("comment-only: titles are rendered", "(* title: Title of network one *)" in comment_only_art) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index c98c823..62a6c84 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -22,7 +22,7 @@ import charset # noqa: E402 from ld_render import render_declaration, render_pou # noqa: E402 -from model import COIL, CONTACT, Element, Parallel, Series # noqa: E402 +from model import COIL, CONTACT, LABEL, Element, Parallel, Series # noqa: E402 from parse_ld import parse_pous # noqa: E402 from render import write # noqa: E402 @@ -324,6 +324,50 @@ def check_golden(name, rendered_lines, golden_path): check("ld pin edge: a contact still triggers", "xEdge := R(xA);" in pin_edge_st) check("ld pin edge: a contact still draws its P", any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in line for line in pin_edge_art)) + +# --- network comments, and a network that holds only one --------------------- + +# An LD network's comment never reached its header: the parser did not know +# the element existed, so every header read a bare "(* Network n *)". +LD_COMMENT = os.path.join(FIXTURES, "36-6-ld-comment.xml") +ld_comment_pou = parse_pous(LD_COMMENT)[0] +ld_comment_art = render_pou(ld_comment_pou) + +check_equal("ld comment: two networks", len(ld_comment_pou.networks), 2) +check_equal("ld comment: the comment is read", ld_comment_pou.networks[1].comment, "XXX - WARNING: Timer then counter") +check("ld comment: it reaches the header", "(* Network 2: XXX - WARNING: Timer then counter *)" in ld_comment_art) + +# A network holding nothing but a comment was dropped, and every network after +# it renumbered. +LD_EMPTY = os.path.join(FIXTURES, "36-6-ld-empty-network.xml") +ld_empty_pou = parse_pous(LD_EMPTY)[0] +ld_empty_art = render_pou(ld_empty_pou) + +check_equal("ld empty: three networks", len(ld_empty_pou.networks), 3) +check_equal("ld empty: the middle one has no rungs", len(ld_empty_pou.networks[1].outputs), 0) +check_equal( + "ld empty: the numbering follows the editor", + [line for line in ld_empty_art if line.startswith("(* Network")], + ["(* Network 1 *)", "(* Network 2: SECTION: safety interlocks *)", "(* Network 3 *)"], +) +# A header with nothing under it: the number is occupied, the body is empty, +# and the next network carries the next number. +empty_at = ld_empty_art.index("(* Network 2: SECTION: safety interlocks *)") +check_equal("ld empty: the comment-only network has no body", ld_empty_art[empty_at + 1], "") +check_equal("ld empty: Network 3 follows it", ld_empty_art[empty_at + 2], "(* Network 3 *)") +check_equal("ld empty: and holds the timer", len(ld_empty_pou.networks[2].outputs), 1) + +# A jump label is stored on its network in CODESYS but exported just before +# it, wired to nothing. Counting it as a network of its own put it under a +# number of its own and pushed every later number out by one. +check_equal("fidelity: six networks, not seven", len(fidelity_pou.networks), 6) +check_equal("fidelity: the label joins the network it labels", len(fidelity_pou.networks[1].outputs), 2) +check( + "fidelity: the label is drawn above that network", + isinstance(fidelity_pou.networks[1].outputs[0], Element) + and fidelity_pou.networks[1].outputs[0].kind == LABEL, +) + # A negated wired output feeding a coil, and only one bubble drawn for it. check("fidelity: negated wired output inverts the coil", any("xFin := NOT ctr2.Q;" in line for line in fidelity_st)) check("fidelity: no double bubble on a wired negated output", not any("Q oo" in line for line in fidelity_art)) From 2795286acd8e959bbf54d52eaf7df88bfc591c35 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 11:09:45 +1000 Subject: [PATCH 37/91] centre a box title the way CODESYS does str.center splits an odd remainder on opposite sides under CPython 3 and IronPython 2.7, so any box whose title needs odd padding came out one column further right from the dev CLI than from the export CODESYS runs - a phantom diff on a line nobody had touched. CPython only shifts the extra space left when the margin and the width are both odd, which is why it took a particular shape to show up at all. The padding is now worked out here rather than left to the interpreter: the left half is the floor, which is what CODESYS produces. The fixture carries a box built to need odd padding, so both interpreters are checked against the same expected text. --- src/fbd_render.py | 4 +-- src/layout.py | 15 +++++++++ src/ld_render.py | 4 +-- .../tests/fixtures/fbd_storage.plcopen.xml | 32 +++++++++++++++++++ tools/ladder/tests/test_fbd.py | 13 ++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/fbd_render.py b/src/fbd_render.py index 492d9e2..86f42e4 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -10,7 +10,7 @@ from __future__ import unicode_literals import charset -from layout import Block, stack +from layout import Block, centred, stack from ld_render import network_headers, render_declaration from model import Assign, Call, Jump, Label, OutputRef, Signal @@ -174,7 +174,7 @@ def _render_call(call, read_pin=None): lines = [] for row in range(height): if row == box_first - 2: - box = title.center(inner + 2) + box = centred(title, inner + 2) elif row == box_first - 1: box = chars["TL"] + chars["H"] * inner + chars["TR"] elif row == box_last + 1: diff --git a/src/layout.py b/src/layout.py index e683226..feaddea 100644 --- a/src/layout.py +++ b/src/layout.py @@ -43,6 +43,21 @@ def padded(self, width, wire_rows=None, fill=None): return out +def centred(text, width): + """``text`` centred in ``width``, with any odd space on the right. + + str.center splits an odd remainder the other way round under IronPython + 2.7 than under CPython 3, so a file rendered inside CODESYS and the same + file rendered by the dev CLI differ by one column on any box whose title + needs odd padding. Doing the arithmetic here settles it: the left padding + is the floor, which is what CODESYS itself produces. + """ + if width <= len(text): + return text + lead = (width - len(text)) // 2 + return " " * lead + text + " " * (width - len(text) - lead) + + def stack(blocks): """Stack Blocks vertically. Returns (lines, absolute connect rows).""" lines = [] diff --git a/src/ld_render.py b/src/ld_render.py index 0351ec7..e00266b 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -16,7 +16,7 @@ from __future__ import unicode_literals import charset -from layout import Block +from layout import Block, centred from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series # The letter a contact carries for edge detection, reused on a block's power @@ -162,7 +162,7 @@ def _render_block(element): title = element.title inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)]) - lines = [title.center(inner + 2)] + lines = [centred(title, inner + 2)] lines.append(chars["TL"] + chars["H"] * inner + chars["TR"]) for index in range(rows): gap = inner - len(left[index]) - len(right[index]) diff --git a/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml b/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml index 9368830..d3def85 100644 --- a/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml +++ b/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml @@ -28,6 +28,7 @@ + @@ -75,6 +76,37 @@ + + + + + + xGo + + + + + + + + + + + + + + + xPulseOut + + + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 5762496..9f7dd18 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -20,6 +20,7 @@ sys.path.insert(0, os.path.join(HERE, "..")) import charset # noqa: E402 +import layout # noqa: E402 import fbd_render # noqa: E402 import parse_ld # noqa: E402 import parse_fbd # noqa: E402 @@ -406,6 +407,18 @@ def check_golden(name, rendered, golden_path): check("output pin store: the ST guards the write", "IF tmr.Q THEN xHeld := TRUE; END_IF" in storage_st) check("output pin store: the pin arrow is marked", any("Q =S> xHeld" in line for line in storage_art)) +# str.center splits an odd remainder on opposite sides under CPython 3 and +# IronPython 2.7, so a box title needing odd padding came out one column +# further right from the dev CLI than from the export CODESYS runs. The two +# have to agree on the byte, or every such line is a phantom diff. +check_equal("centred: the odd space goes right", layout.centred("ab", 5), " ab ") +check_equal("centred: an even split is unchanged", layout.centred("ab", 6), " ab ") +check_equal("centred: no room to centre in", layout.centred("abcd", 3), "abcd") +check( + "centred: a title with odd padding sits where CODESYS puts it", + " pulse : TP" in storage_art, +) + # --- edge detection on a block pin ------------------------------------------- From 47fbe6796af745ab0e6b0429efc035263de5206b Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 12:14:28 +1000 Subject: [PATCH 38/91] draw a shared box once even when a reader is nested A block read by two of a network's outputs is one block that runs once. Where both readers hang straight off it the fan-out already drew it once and branched, but a reader sitting behind another box is a tree of its own, and that tree was drawn from scratch - putting a second copy of the same instance on the page. FB_TESTING network 9 in the GraphicalTesting project shows it: one timer drawn as two. The first tree to reach a box now draws it and the rest name the pin they read, which is what the ladder renderer already does. Only an instance is collapsed this way: an operator has no name to print, and being stateless it costs nothing to draw again. --- src/fbd_render.py | 68 ++++++++++---- .../tests/fixtures/fbd_shared_box.plcopen.xml | 93 +++++++++++++++++++ tools/ladder/tests/test_fbd.py | 24 +++++ 3 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 tools/ladder/tests/fixtures/fbd_shared_box.plcopen.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 86f42e4..77b1d04 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -23,12 +23,12 @@ def _render_label(node): return Block(["(* label: " + node.name + " *)"], 0) -def _render_jump(node): +def _render_jump(node, drawn): chars = charset.active() tail = chars["H"] * 3 + ">> " + (node.target or "?") if node.condition is None: return Block([tail], 0) - source = _render(node.condition) + source = _render(node.condition, drawn) lines = source.padded(source.width) out = [] for index, line in enumerate(lines): @@ -50,9 +50,9 @@ def _store_head(node): return "o> " if node.negated else "> " -def _render_assign(node): +def _render_assign(node, drawn): chars = charset.active() - source = _render(node.source) if node.source is not None else Block([""], 0) + source = _render(node.source, drawn) if node.source is not None else Block([""], 0) lines = source.padded(source.width) head = _store_head(node) tail = chars["H"] * 3 + head + (node.label or "?") @@ -87,11 +87,11 @@ def _is_wired(source): return not (isinstance(source, Signal) and not source.label) -def _render_call(call, read_pin=None): +def _render_call(call, read_pin, drawn): chars = charset.active() input_blocks = [] for _pin, source in call.inputs: - input_blocks.append(_render(source) if source is not None else Block([""], 0)) + input_blocks.append(_render(source, drawn) if source is not None else Block([""], 0)) left_lines, pin_rows = stack(input_blocks) # A minimum lead-in, so a source exactly as wide as the column still shows @@ -205,16 +205,45 @@ def _render_call(call, read_pin=None): return Block(lines, connect_row, pin_rows) -def _render(node): - if isinstance(node, OutputRef): - # One box, entered on the pin this wire reads. - return _render_call(node.call, node.pin) - if isinstance(node, Call): - return _render_call(node) +def _reference(call, pin): + """The name of a box already drawn in this network, on the pin being read. + + Only an instance can be referred to this way: an operator has no name to + print, and being stateless it costs nothing to draw again. + """ + if not call.instance_name: + return None + text = call.instance_name + "." + pin if pin else call.instance_name + if pin in call.negated_outputs: + text = "NOT " + text + return text + + +def _render(node, drawn): + """Draw one tree. ``drawn`` holds the boxes this network has already shown. + + A block read by two of the network's outputs is one block that runs once. + Where both readers hang straight off it the fan-out draws it once and + branches, but a reader sitting behind another box is a tree of its own, + and drawing that tree from scratch put a second copy of the same instance + on the page - two timers where the program has one. The first tree to + reach a box draws it; the rest name the pin they read, exactly as the + ladder renderer does. + """ + call = node.call if isinstance(node, OutputRef) else node + if isinstance(call, Call): + pin = node.pin if isinstance(node, OutputRef) else call.active_output + if id(call) in drawn: + reference = _reference(call, pin) + if reference is not None: + return Block([reference], 0) + else: + drawn.add(id(call)) + return _render_call(call, pin, drawn) if isinstance(node, Assign): - return _render_assign(node) + return _render_assign(node, drawn) if isinstance(node, Jump): - return _render_jump(node) + return _render_jump(node, drawn) if isinstance(node, Label): return _render_label(node) if isinstance(node, Signal): @@ -261,14 +290,14 @@ def _fanout_groups(source, outputs): return groups -def _render_fanout(outputs): +def _render_fanout(outputs, drawn): """One source driving several outputs: draw it once and branch. This is how CODESYS shows it, and drawing the box once per output would both misrepresent the program and double the width of the diff. """ chars = charset.active() - source = _render(outputs[0].source) + source = _render(outputs[0].source, drawn) # A short lead before the junction, so the branch is not welded to the box # edge. padded() extends the wire row and pads the rest with spaces. width = source.width + 2 @@ -329,13 +358,16 @@ def _shared_source(outputs): def render_network(network): """Render one network, which may drive several outputs from one source.""" outputs = getattr(network, "outputs", [network]) + # Per network: a box drawn for one output must not be drawn again for the + # next, but a box shared between two networks is two boxes on the page. + drawn = set() if _shared_source(outputs) is not None: - return _render_fanout(outputs).lines + return _render_fanout(outputs, drawn).lines lines = [] for tree in outputs: - lines.extend(_render(tree).lines) + lines.extend(_render(tree, drawn).lines) # An EXECUTE box's body is the logic; drawing the box without it would # be an empty rectangle where a dozen lines of ST should be. if isinstance(tree, Call) and tree.st_code: diff --git a/tools/ladder/tests/fixtures/fbd_shared_box.plcopen.xml b/tools/ladder/tests/fixtures/fbd_shared_box.plcopen.xml new file mode 100644 index 0000000..56ce0d0 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_shared_box.plcopen.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + xStart + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + xDone + + + + + xManual + + + + + + + + + + + + + + + + + + + + xAny + + + + + + + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 9f7dd18..a08eac3 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -379,6 +379,30 @@ def check_golden(name, rendered, golden_path): check("two pins: no junction between different pins", not any(U["T_DOWN"] in l and "xQ" in l for l in two_pins_art)) +# --- one box read by two of a network's outputs ------------------------------ + +# Where both readers hang straight off the box the fan-out draws it once and +# branches. A reader sitting behind another box is a tree of its own, and +# drawing that tree from scratch put a second copy of the same instance on the +# page - two timers where the program has one. This is FB_TESTING network 9 in +# the GraphicalTesting project. +SHARED_BOX = os.path.join(HERE, "fixtures", "fbd_shared_box.plcopen.xml") +shared = parse_fbd.parse_pous(SHARED_BOX)[0] +shared_st = st_render.render_pou(shared) +shared_art = fbd_render.render_network(shared.networks[0]) + +check_equal("shared box: one network", len(shared.networks), 1) +check_equal("shared box: two outputs", len(shared.networks[0].outputs), 2) +check_equal("shared box: the timer is called once", len([l for l in shared_st if l.startswith("fbTimer(")]), 1) +check_equal("shared box: one box is drawn", len([l for l in shared_art if "fbTimer : TON" in l]), 1) +check("shared box: the second reader names the pin", any(l.startswith("fbTimer.Q") for l in shared_art)) +check("shared box: both stores are still made", "xDone := fbTimer.Q;" in shared_st and "xAny := fbTimer.Q OR xManual;" in shared_st) + +# An operator has no instance name to refer to, and being stateless it costs +# nothing to draw again - so it is not collapsed. +check("shared box: the operator is still drawn", any("Out1" in l for l in shared_art)) + + # --- a store that holds: set and reset --------------------------------------- # storage="set" on an outVariable was dropped, so a latch rendered as From cf2b3fddc3ff1375f9b3b7b695e1f25ce549474e Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 12:16:06 +1000 Subject: [PATCH 39/91] stop reading a box's EN pin as one of its operands EN decides whether a box runs; it is not one of the things being added. It was folded in with the operands, so a three-way addition that runs only while xEn read as a four-way addition of the enable itself: "iSum := xEn + iA + iB + iC;". ENO reports that the box ran, and reading it as the box's result put that same sum into a boolean. EN is now kept out of the operands, and the store the operator feeds carries it as a guard, which is the only place an expression has to say it: "IF xEn THEN iSum := iA + iB + iC; END_IF". A reader of ENO gets the enable it reports, or TRUE where no enable is wired. A function block is unchanged: it states its own EN as a call argument, which already reads correctly. --- src/st_render.py | 56 ++++++++++++++++++++++++++++++++-- tools/ladder/tests/test_fbd.py | 17 +++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/st_render.py b/src/st_render.py index 0e4220e..fa72333 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -153,6 +153,29 @@ def _operand(text): return text if is_simple_term(text) else "(" + text + ")" +# The enable pair CODESYS draws on a box. EN decides whether the box runs at +# all and ENO reports that it did; neither is an operand, and neither is the +# box's result. +EN_PIN = "EN" +ENO_PIN = "ENO" + + +def _enable(call, statements, emitted): + """The EN pin's value as text, or None when the box has no live enable. + + Re-reading the pin is safe: a call is memoised in ``emitted``, so asking + for its value again returns the same text without emitting the call a + second time, and a signal has nothing to emit. + """ + for pin, source in call.inputs: + if pin != EN_PIN: + continue + text = _fbd_value(source, statements, emitted) + # A box wired to a constant TRUE is a box with no enable worth stating. + return None if text in (None, "", "TRUE") else text + return None + + def _operator_expression(node, values): symbol = INFIX_OPERATORS.get(node.type_name) if symbol and len(values) >= 2: @@ -178,6 +201,11 @@ def _fbd_value(node, statements, emitted=None): # value differs per reader, so it is computed here rather than # memoised with the call. value = _fbd_value(node.call, statements, emitted) + if node.call.is_operator and node.pin == ENO_PIN: + # ENO says the box ran, which is what its EN said. It is not the + # result: reading it as the expression made "xSumOk := iA + iB + + # iC;" out of a boolean that only ever says whether the add ran. + return _enable(node.call, statements, emitted) or "TRUE" if node.call.is_operator or not node.pin: # An operator has no instance to take a pin from; it inlines as # the one expression whichever pin reads it. @@ -204,7 +232,13 @@ def _fbd_value(node, statements, emitted=None): if isinstance(node, Assign): value = _fbd_value(node.source, statements, emitted) or "FALSE" - statements.append(store_statement(node.label, value, node.storage, node.negated)) + statement = store_statement(node.label, value, node.storage, node.negated) + guard = _assign_guard(node.source, statements, emitted) + if guard: + # An operator behind an EN pin computes only while EN holds, and + # an expression has nowhere to say so - the store it feeds does. + statement = "IF %s THEN %s END_IF" % (guard, statement) + statements.append(statement) return node.label or "?" if isinstance(node, Call): @@ -234,8 +268,10 @@ def remember(value): if node.is_operator: # Operators and functions have no instance to call, so they inline - # as an expression rather than a statement. - expression = _operator_expression(node, [value for _pin, value in pairs]) + # as an expression rather than a statement. EN is not one of the + # operands: folding it in made "iSum := xEn + iA + iB + iC;" out + # of a three-way addition that runs only while xEn. + expression = _operator_expression(node, [value for pin, value in pairs if pin != EN_PIN]) if node.active_output in node.negated_outputs: expression = "NOT " + _operand(expression) return remember(expression) @@ -257,6 +293,20 @@ def remember(value): return "?" +def _assign_guard(source, statements, emitted): + """The EN a store inherits from the operator it reads, if any. + + Only for a store reading an operator's result directly. A function block + states its own EN as a call argument, and an ENO reader is the enable + rather than something the enable gates. + """ + if not isinstance(source, OutputRef) or not source.call.is_operator: + return None + if source.pin == ENO_PIN: + return None + return _enable(source.call, statements, emitted) + + def network_to_statements(network): """Statements for one network, which may drive several outputs. diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index a08eac3..78fd640 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -403,6 +403,23 @@ def check_golden(name, rendered, golden_path): check("shared box: the operator is still drawn", any("Out1" in l for l in shared_art)) +# --- EN and ENO on an operator box ------------------------------------------- + +# EN decides whether a box runs; it is not one of the things being added. It +# was folded into the operands, so a three-way addition that runs only while +# xEn read as a four-way addition of the enable itself. ENO reports that the +# box ran, and reading it as the box's result made a boolean out of the sum. +enable_st = st_render.render_pou(two_pins) + +check("enable: EN is not an operand", "IF xEn THEN iSum := iA + iB + iC; END_IF" in enable_st) +check("enable: no four-way sum survives", not any("xEn + iA" in line for line in enable_st)) +check("enable: ENO reports the enable", "xSumOk := xEn;" in enable_st) +check("enable: the ENO store is not itself guarded", not any(line.startswith("IF xEn THEN xSumOk") for line in enable_st)) + +# A box with no EN wired keeps its plain expression and no guard. +check("enable: an unguarded operator is unchanged", "xAny := fbTimer.Q OR xManual;" in shared_st) + + # --- a store that holds: set and reset --------------------------------------- # storage="set" on an outVariable was dropped, so a latch rendered as From 266a2f9d38adc632510e0e2ec778b87afe9c111d Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 12:21:43 +1000 Subject: [PATCH 40/91] write the equivalent Structured Text as its own file The diagram approximates where the ST is exact. A block read through two of its output pins is one call, and no single-wire diagram can say so - the best it manages is to draw the box once and name it again for the second reader. The ST also diffs line by line, where renaming a variable can re-flow every line of a diagram. The ST emitter has been there all along, reachable only from the dev CLI. It was written into the .txt itself at first and taken out again because the same network twice, in two notations one after the other, is harder to read than either alone. That objection is about one file, not about the ST, so it goes beside the diagram as .st.txt instead. The suffix is deliberately not .st: these must never be mistaken for source. Import From Files dispatches on ".xml" and ".st", and splitext sees ".txt" for both derived files, so both are ignored by construction - and the ST file opens with a banner saying it is a rendering, not a translation, and must never go back into CODESYS. A failed export removes both files rather than leaving a diagram with no ST beside it. The export summary now reports what the ST costs separately, which on GraphicalTesting is 0.1s of 1.0s. --- README.md | 13 +++ src/graphical_export.py | 126 ++++++++++++++++++++++-------- tools/ladder/tests/test_export.py | 36 +++++++-- 3 files changed, 135 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index acfd981..344b520 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,19 @@ The declaration is copied from the original CODESYS declaration source, preservi This file is **derived and read-only**. The native xml remains the only thing `Import From Files` reads, so editing the `.txt` changes nothing — it exists to make diffs and code review possible. Layout comes from how the elements are wired, not from their coordinates, so moving a block in the CODESYS editor produces no diff. +Beside those two, CODESCRIBE writes a `.st.txt` holding the same networks as equivalent Structured Text: + +``` +(* Network 2 *) +TON_0(IN := PowerOn, PT := T#5S); +CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); +IF CTU_0.Q THEN PowerOff := FALSE; END_IF +``` + +The diagram shows the shape; the ST states the logic exactly, and says things a single-wire diagram cannot. A block read through two of its output pins is one call in the ST, where the diagram has to draw the box once and name it again for the second reader. The ST also diffs line by line, where renaming a variable can re-flow every line of a diagram. + +It is **a rendering, not a translation**: it is not guaranteed to compile, it must never be imported or pasted back into CODESYS, and the file opens with a banner saying so. Like the `.txt`, `Import From Files` ignores it — the dispatch is on `.xml` and `.st`, and both derived files end in `.txt`. + SFC and CFC POUs are not yet rendered; they export as native xml alone. Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor. diff --git a/src/graphical_export.py b/src/graphical_export.py index 6b65013..dfdac19 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -3,12 +3,22 @@ Graphical POUs (LD, FBD, SFC, CFC) have no textual implementation, so they export as CODESYS native xml, which git can store but nobody can review. This -adds a derived .txt next to it: the declaration and a diagram per network. - -The .txt is READ-ONLY as far as CODESCRIBE is concerned. The native xml stays +adds two derived files next to it: a ".txt" holding the declaration and a +diagram per network, and a ".st.txt" holding the same networks as equivalent +Structured Text. + +Two files rather than one, because one file holding both was worse: the same +network twice, in two notations, one after the other. Separately, each is +read for what it is good at - the diagram for the shape, the ST for the exact +logic, which is where a diagram can only approximate. A block read through +two of its pins is the clearest case: the ST says one call, and no +single-wire diagram can. + +Both are READ-ONLY as far as CODESCRIBE is concerned. The native xml stays the only thing Import From Files reads, so the round trip is unaffected and -editing the .txt achieves nothing. import_from_files dispatches on ".xml" and -".st", so a ".txt" is ignored by construction. +editing either achieves nothing. import_from_files dispatches on ".xml" and +".st", and os.path.splitext sees ".txt" for both of these, so both are +ignored by construction. The rendering goes through PLCopen xml rather than the native format, because PLCopen has a published schema for graphical bodies while the native format @@ -24,11 +34,26 @@ import parse_fbd import parse_ld import plcopen +import st_render from util import open_utf8 -# Suffix for the derived file. Deliberately not .st: these are not importable -# and must never be mistaken for source. +# Suffixes for the derived files. Deliberately not .st: these are not +# importable and must never be mistaken for source. import_from_files +# dispatches on ".xml" and ".st", and os.path.splitext sees ".txt" for both of +# these, so both are ignored by construction. RENDERED_SUFFIX = ".txt" +ST_SUFFIX = ".st.txt" + +# Stated in the file itself, not just in the docs. The ST reads like source +# and sits next to real .st exports, so the one thing a reader must not +# assume is that it can go back into CODESYS. +ST_HEADER = [ + u"(* Equivalent Structured Text for a graphical POU, written by codescribe.", + u" READ ONLY. This is a rendering of the native xml beside it, not a", + u" translation: it is not guaranteed to compile and must never be imported", + u" or pasted back into CODESYS. The native xml is the source. *)", + u"", +] # Rendering adds a second CODESYS-side export per graphical POU, so the cost # is worth reporting rather than leaving people to wonder why the export got @@ -39,6 +64,7 @@ "export_xml_seconds": 0.0, "parse_seconds": 0.0, "draw_seconds": 0.0, + "st_seconds": 0.0, "verbatim_declarations": 0, "fallback_declarations": 0, } @@ -60,14 +86,21 @@ def summary(): """ if not STATS["rendered"] and not STATS["skipped"]: return None - total = STATS["export_xml_seconds"] + STATS["parse_seconds"] + STATS["draw_seconds"] - line = "Rendered %d graphical POUs in %.1fs (%.1fs CODESYS export_xml, %.1fs parsing, %.1fs drawing); skipped %d" % ( - STATS["rendered"], - total, - STATS["export_xml_seconds"], - STATS["parse_seconds"], - STATS["draw_seconds"], - STATS["skipped"], + total = ( + STATS["export_xml_seconds"] + STATS["parse_seconds"] + STATS["draw_seconds"] + STATS["st_seconds"] + ) + line = ( + "Rendered %d graphical POUs in %.1fs" + " (%.1fs CODESYS export_xml, %.1fs parsing, %.1fs drawing, %.1fs ST); skipped %d" + % ( + STATS["rendered"], + total, + STATS["export_xml_seconds"], + STATS["parse_seconds"], + STATS["draw_seconds"], + STATS["st_seconds"], + STATS["skipped"], + ) ) # Falling back to the rebuilt declaration is silent otherwise, and it # costs every comment, pragma and attribute in the file. Say so. @@ -87,6 +120,13 @@ def summary(): } +def _write_lines(path, lines): + """Write one rendering, newline-terminated, as UTF-8.""" + with open_utf8(path, "w") as f: + f.write(u"\n".join(lines)) + f.write(u"\n") + + def _render_pous(plcopen_path): """(pou, art_renderer) for every POU in the file we know how to draw. @@ -104,14 +144,30 @@ def _render_pous(plcopen_path): return found -def render_plcopen(plcopen_path, declaration_text=None): - """Render every renderable POU in a PLCopen file. [] if there are none. +def _joined(blocks): + """One POU rendering after another, with the blank lines tidied up.""" + lines = [] + for block in blocks: + lines.extend(block) + lines.append(u"") + while lines and lines[-1] == u"": + lines.pop() + return lines + - The declaration and the diagram only. An equivalent-ST rendering was - written alongside these at first, but showing the same network twice in - two notations made the files harder to read rather than easier. The ST - emitter is still there and reachable from tools/ladder/render.py for - anyone who wants it; it is just not what the export writes. +def render_plcopen(plcopen_path, declaration_text=None): + """(diagram lines, ST lines) for a PLCopen file. ([], []) if none apply. + + Two renderings of the same networks, for two files. They were written + into one file at first and that was worse, not better: the same network + twice in two notations, one after the other, is harder to read than + either alone. In separate files the choice stays with the reader - the + diagram shows the shape, and the ST states the logic exactly where the + diagram can only approximate it. A block read through two of its pins is + the clearest case: the ST says one call, and no single-wire diagram can. + + The ST is a rendering, not a translation. It is not guaranteed to compile + and must never be fed back into CODESYS; the file says so at the top. """ started = time.time() pous = _render_pous(plcopen_path) @@ -120,19 +176,20 @@ def render_plcopen(plcopen_path, declaration_text=None): STATS["parse_seconds"] += time.time() - started started = time.time() - lines = [] + drawn = [] for pou, art_renderer in pous: if pou.declaration_text: STATS["verbatim_declarations"] += 1 else: STATS["fallback_declarations"] += 1 - lines.extend(art_renderer.render_pou(pou)) - lines.append(u"") - - while lines and lines[-1] == u"": - lines.pop() + drawn.append(art_renderer.render_pou(pou)) STATS["draw_seconds"] += time.time() - started - return lines + + started = time.time() + text = [st_render.render_pou(pou) for pou, _art_renderer in pous] + STATS["st_seconds"] += time.time() - started + + return _joined(drawn), _joined(text) # Ways of asking for plaintext declarations, most likely to bind first. @@ -207,16 +264,16 @@ def write_rendered_text(obj, base_path): _export_plcopen(obj, temp_path) STATS["export_xml_seconds"] += time.time() - started - # render_plcopen accounts for its own parse and draw time. + # render_plcopen accounts for its own parse, draw and ST time. textual_declaration = getattr(getattr(obj, "textual_declaration", None), "text", None) - lines = render_plcopen(temp_path, textual_declaration) + lines, st_lines = render_plcopen(temp_path, textual_declaration) if not lines: STATS["skipped"] += 1 return False - with open_utf8(base_path + RENDERED_SUFFIX, "w") as f: - f.write(u"\n".join(lines)) - f.write(u"\n") + _write_lines(base_path + RENDERED_SUFFIX, lines) + if st_lines: + _write_lines(base_path + ST_SUFFIX, ST_HEADER + st_lines) STATS["rendered"] += 1 return True except Exception as error: @@ -233,6 +290,7 @@ def write_rendered_text(obj, base_path): # A write that died halfway leaves a truncated rendering that looks # exactly like a valid one. No file at all is the honest outcome. _remove_quietly(base_path + RENDERED_SUFFIX) + _remove_quietly(base_path + ST_SUFFIX) return False finally: _remove_quietly(temp_path) diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 4cf8665..d275bb2 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -114,17 +114,36 @@ def read(path): content = read(base + ".txt") check("derived file leads with the declaration", content.startswith("PROGRAM LD_TEST")) - # Diagram only. Rendering the same network twice, once as ST and once as a - # diagram, made the files harder to read rather than easier. - check("no ST rendering is written", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" not in content) + # The diagram file holds the diagram. The two notations were written into + # one file at first and that was worse, not better: the same network twice, + # one rendering after the other, is harder to read than either alone. + check("no ST rendering in the diagram file", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" not in content) check("networks are numbered", "(* Network 1 *)" in content) check("derived file contains the diagram", "TON_0 : TON" in content) check("the declaration appears once", content.count("END_VAR") == 1) check("derived file ends with a newline", content.endswith("\n")) + # --- the ST rendering, in a file of its own ----------------------------- + + st_content = read(base + ".st.txt") + check("ST file lands beside the diagram", os.path.exists(base + ".st.txt")) + # It reads like source and sits next to real .st exports, so the file has + # to say what it is before it says anything else. + check("ST file opens with the read-only banner", st_content.startswith("(* Equivalent Structured Text")) + check("the banner forbids importing it", "must never be imported" in st_content) + check("ST file carries the declaration", "PROGRAM LD_TEST" in st_content) + check("ST file states the logic", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" in st_content) + check("ST file is numbered like the diagram", "(* Network 1" in st_content) + check("ST file has no diagram in it", "TON_0 : TON" not in st_content.replace("TON_0 : TON;", "")) + check("ST file ends with a newline", st_content.endswith("\n")) + # The temp PLCopen file is staged outside the export folder, so nothing but - # the rendering may appear next to the native xml. - check_equal("no stray files left behind", sorted(os.listdir(workspace)), ["LD_TEST.txt"]) + # the two renderings may appear next to the native xml. + check_equal( + "no stray files left behind", + sorted(os.listdir(workspace)), + ["LD_TEST.st.txt", "LD_TEST.txt"], + ) # --- an older ScriptEngine without the plaintext overload --------------- @@ -139,6 +158,7 @@ def read(path): sfc = FakePou("SFC_TEST", os.path.join(FIXTURES, "SFCTesting.xml")) check("sfc reports nothing rendered", graphical_export.write_rendered_text(sfc, sfc_base) is False) check("sfc writes no empty file", not os.path.exists(sfc_base + ".txt")) + check("sfc writes no empty ST file", not os.path.exists(sfc_base + ".st.txt")) # --- cost reporting ----------------------------------------------------- @@ -271,6 +291,10 @@ def failing_open_utf8(path, mode): except Exception as error: check("a mid-write failure is reported, not raised", False, repr(error)) check("a truncated rendering is not left behind", not os.path.exists(torn_base + ".txt")) + # Both files go, not just the one that happened to fail: a diagram + # with no ST beside it, or the reverse, is a rendering that disagrees + # with itself. + check("no half-written ST is left behind", not os.path.exists(torn_base + ".st.txt")) finally: graphical_export.open_utf8 = real_open_utf8 finally: @@ -285,7 +309,7 @@ def failing_open_utf8(path, mode): # derived files. workspace = tempfile.mkdtemp() try: - for name in ("Main.txt", "Main.Method.txt", "Main.gvl.txt"): + for name in ("Main.txt", "Main.Method.txt", "Main.gvl.txt", "Main.st.txt", "Main.Method.st.txt"): handle = io.open(os.path.join(workspace, name), "w", encoding="utf-8") handle.write("PROGRAM Main\n") handle.close() From 62fd2aebf2f2fa58f398de65f892568d7869a4c7 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 12:59:03 +1000 Subject: [PATCH 41/91] render an action or method from its own body, not its parent's PLCopen has no top-level element for an action, a transition or a method, so CODESYS exports the parent POU with the member nested inside it - parent body included. Rendering the pou's own body from such a file drew the parent's networks under the member's filename: a file named for one POU describing another, which a reviewer has no way to notice. On the Anvil sources every PLC_PRG.ACT_* file carried a copy of the parent's dump and no action's own logic appeared anywhere. The member is now picked out by name, and the rendering says at the top whose declaration it shows, because the export only carries the parent's. Where an export carries no body for the member, nothing is written and the summary says so: an absent rendering sends the reviewer to the native xml, a foreign one does not. Lifted from the work on PR #38, where it is proven on SP11, because it is a defect in what this branch ships and is independent of the alignment work that PR is still deciding. One change on the way in: the tag comparison is case-insensitive. CODESYS writes with a capital M where it writes in lower case, so matching lower case alone meant a graphical method never found its own body and silently rendered nothing at all. --- README.md | 2 + src/graphical_export.py | 83 ++++++++++++++++--- src/import_export.py | 10 ++- src/plcopen.py | 66 +++++++++++++-- .../tests/fixtures/action_member.plcopen.xml | 63 ++++++++++++++ .../tests/fixtures/method_member.plcopen.xml | 62 ++++++++++++++ tools/ladder/tests/test_export.py | 78 +++++++++++++++++ 7 files changed, 343 insertions(+), 21 deletions(-) create mode 100644 tools/ladder/tests/fixtures/action_member.plcopen.xml create mode 100644 tools/ladder/tests/fixtures/method_member.plcopen.xml diff --git a/README.md b/README.md index 344b520..6e31305 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ The diagram shows the shape; the ST states the logic exactly, and says things a It is **a rendering, not a translation**: it is not guaranteed to compile, it must never be imported or pasted back into CODESYS, and the file opens with a banner saying so. Like the `.txt`, `Import From Files` ignores it — the dispatch is on `.xml` and `.st`, and both derived files end in `.txt`. +A graphical action, transition or method is rendered from its own body, not from the parent POU it is exported inside. PLCopen has no top-level element for one, so CODESYS exports the parent with the member nested in it, parent body included; the rendering picks the member out by name and says at the top whose declaration it is showing, because the export only carries the parent's. Where an export does not carry the member's body at all, no files are written for it and the export summary says so - an absent rendering sends you to the native xml, a rendering of the wrong POU does not. + SFC and CFC POUs are not yet rendered; they export as native xml alone. Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor. diff --git a/src/graphical_export.py b/src/graphical_export.py index dfdac19..023df0d 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -67,6 +67,7 @@ "st_seconds": 0.0, "verbatim_declarations": 0, "fallback_declarations": 0, + "members_missing": 0, } STATS = dict(EMPTY_STATS) @@ -109,6 +110,13 @@ def summary(): "fallback_declarations" ] line += " pragmas and attributes may be missing from those declarations." + # No file at all for a member is deliberate, but it is still a POU with no + # rendering beside its xml, so it has to be said rather than inferred. + if STATS["members_missing"]: + line += "\n NOTE: %d action/transition/method export(s) did not carry the" % STATS[ + "members_missing" + ] + line += " member's own body; nothing was written for those - review their native xml." return line @@ -127,20 +135,38 @@ def _write_lines(path, lines): f.write(u"\n") -def _render_pous(plcopen_path): +def _render_pous(plcopen_path, member_name=None): """(pou, art_renderer) for every POU in the file we know how to draw. One pass over the document. Asking each language parser in turn would re-read and re-parse the whole file once per language, which is pure waste on a project with hundreds of POUs. + + With a member_name, only that member's own body qualifies. The exported + file also carries the parent POU's body, and rendering that instead is + exactly the foreign-dump defect this parameter exists to prevent. """ found = [] - for pou_elem, language, body in plcopen.iter_bodies(plcopen_path): + if member_name is None: + bodies = plcopen.iter_bodies(plcopen_path) + else: + bodies = plcopen.iter_member_bodies(plcopen_path, member_name) + for pou_elem, language, body in bodies: entry = RENDERERS.get(language) if entry is None: continue parser, art_renderer = entry - found.append((parser.pou_from_body(pou_elem, body), art_renderer)) + pou = parser.pou_from_body(pou_elem, body) + if member_name is not None: + lowered = pou.name.lower() + if lowered != member_name.lower() and not lowered.endswith("." + member_name.lower()): + # The body came from a member nested in the parent pou, whose + # name the parser picked up. Title the rendering for what it + # actually shows - and flag it, because the declaration in + # the export is the parent's and the rendering should say so. + pou.name = pou.name + "." + member_name + pou.member_of_parent = True + found.append((pou, art_renderer)) return found @@ -155,7 +181,7 @@ def _joined(blocks): return lines -def render_plcopen(plcopen_path, declaration_text=None): +def render_plcopen(plcopen_path, declaration_text=None, member_name=None): """(diagram lines, ST lines) for a PLCopen file. ([], []) if none apply. Two renderings of the same networks, for two files. They were written @@ -168,25 +194,38 @@ def render_plcopen(plcopen_path, declaration_text=None): The ST is a rendering, not a translation. It is not guaranteed to compile and must never be fed back into CODESYS; the file says so at the top. + + ``member_name`` restricts the rendering to that sub-POU member's own + body; see _render_pous. """ started = time.time() - pous = _render_pous(plcopen_path) + pous = _render_pous(plcopen_path, member_name) if declaration_text is not None and pous: pous[0][0].declaration_text = declaration_text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") STATS["parse_seconds"] += time.time() - started + # A member's export carries the parent's declaration, not its own, so + # both renderings have to open by saying whose declaration they show. + notes = [] + for pou, _art_renderer in pous: + if getattr(pou, "member_of_parent", False): + notes.append([u"(* " + pou.name + u" - the declaration below is the parent POU's *)", u""]) + else: + notes.append([]) + started = time.time() drawn = [] - for pou, art_renderer in pous: + for index, entry in enumerate(pous): + pou, art_renderer = entry if pou.declaration_text: STATS["verbatim_declarations"] += 1 else: STATS["fallback_declarations"] += 1 - drawn.append(art_renderer.render_pou(pou)) + drawn.append(notes[index] + art_renderer.render_pou(pou)) STATS["draw_seconds"] += time.time() - started started = time.time() - text = [st_render.render_pou(pou) for pou, _art_renderer in pous] + text = [notes[index] + st_render.render_pou(pou) for index, (pou, _art) in enumerate(pous)] STATS["st_seconds"] += time.time() - started return _joined(drawn), _joined(text) @@ -237,11 +276,19 @@ def _remove_quietly(path): pass -def write_rendered_text(obj, base_path): - """Export obj as PLCopen xml, render it, and write .txt. +def write_rendered_text(obj, base_path, member_name=None): + """Export obj as PLCopen xml, render it, and write the derived files. + + Returns True if files were written. SFC and CFC bodies parse to nothing + renderable, so they are skipped rather than producing empty files. - Returns True if a file was written. SFC and CFC bodies parse to nothing - renderable, so they are skipped rather than producing an empty file. + ``member_name`` marks obj as a sub-POU member (action, transition, + graphical method) whose PLCopen export wraps it in its *parent* POU. + Only the member's own body is rendered then - never the parent's, whose + networks under the member's filename would have a reviewer reading a + different POU. If the export carries no such body, nothing is written at + all: an absent rendering sends the reviewer to the native xml, a foreign + one does not. A rendering failure must not fail the export: the native xml has already been written and is complete and correct on its own. The problem is @@ -266,8 +313,18 @@ def write_rendered_text(obj, base_path): # render_plcopen accounts for its own parse, draw and ST time. textual_declaration = getattr(getattr(obj, "textual_declaration", None), "text", None) - lines, st_lines = render_plcopen(temp_path, textual_declaration) + lines, st_lines = render_plcopen(temp_path, textual_declaration, member_name) if not lines: + if member_name is not None: + # No file at all is the honest outcome: an absent rendering + # sends the reviewer to the native xml, a foreign one does not. + STATS["members_missing"] += 1 + print( + "WARNING: the PLCopen export of " + + obj.get_name() + + " carries no renderable body for the member itself;" + + " nothing written - review the native xml" + ) STATS["skipped"] += 1 return False diff --git a/src/import_export.py b/src/import_export.py index 0d72d04..ce7025e 100644 --- a/src/import_export.py +++ b/src/import_export.py @@ -205,7 +205,10 @@ def export_method(child_obj, parent_obj, parent_folder_path, export_child_fn): else: base = os.path.join(parent_folder_path, parent_obj.get_name() + "." + child_obj.get_name()) write_native(child_obj, base + ".xml", recursive=False) - write_rendered_text(child_obj, base) + # member_name: the PLCopen export of a sub-POU wraps it in its parent, + # parent body included. Without the name, the rendering draws the + # parent's networks under this member's filename. + write_rendered_text(child_obj, base, member_name=child_obj.get_name()) def import_method_st(child, dir_path, dir_parent_obj, import_dir_fn): @@ -233,7 +236,10 @@ def _export_member_st_or_xml(child_obj, parent_obj, parent_folder_path, st_suffi f.write(child_obj.textual_implementation.text) else: write_native(child_obj, base + ".xml", recursive=False) - write_rendered_text(child_obj, base) + # member_name: see export_method. An action's export carries the whole + # parent POU - rendering without the name is how the HMI PLC_PRG.ACT_* + # dumps came to describe the parent instead of the action. + write_rendered_text(child_obj, base, member_name=child_obj.get_name()) def export_action(child_obj, parent_obj, parent_folder_path, export_child_fn): diff --git a/src/plcopen.py b/src/plcopen.py index dc4bb77..0437878 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -475,14 +475,68 @@ def find_pous(root): return [elem for elem in root.iter() if tag(elem) == "pou"] +def _language_body(owner): + """(language, body_elem) for an element's own , or None.""" + body = find_child(owner, "body") + if body is None: + return None + for child in body: + if tag(child) in BODY_LANGUAGES: + return tag(child), child + return None + + def iter_bodies(source): """Yield (pou_elem, language, body_elem) for every POU with an implementation.""" root = xmlbackend.parse(read_document(source)) for elem in find_pous(root): - body = find_child(elem, "body") - if body is None: + found = _language_body(elem) + if found is not None: + yield elem, found[0], found[1] + + +# Sub-POU elements that carry a name and a body of their own. Matched without +# regard to case: an export writes with a capital M where +# it writes in lower case, and matching only one spelling means the +# other kind never renders at all. An SFC body's inline blocks share +# the tag but carry a localId instead of a name, so requiring the name to +# match keeps them out. +MEMBER_TAGS = ("action", "transition", "method") + + +def iter_member_bodies(source, member_name): + """Yield (pou_elem, language, body_elem) for a named sub-POU member. + + PLCopen has no top-level element for an action or transition, so CODESYS + exports the *parent* POU with the member nested inside it - parent body + included. Rendering the pou's own from such a file draws the + parent's networks under the member's filename, which is how the HMI + PLC_PRG.ACT_* dumps came to describe a different POU than the .xml + beside them. + + The member is found by name: either a pou element named for the member + itself (how some builds export methods), or an action/transition/method + element with a matching name attribute anywhere inside a pou. IEC + identifiers are case-insensitive, so the comparison is too. Yielding + nothing at all is the honest outcome when the export simply does not + carry the member's body - the caller then writes no rendering rather + than a foreign one. + """ + root = xmlbackend.parse(read_document(source)) + wanted = member_name.lower() + for pou_elem in find_pous(root): + pou_name = (pou_elem.get("name") or "").lower() + if pou_name == wanted or pou_name.endswith("." + wanted): + found = _language_body(pou_elem) + if found is not None: + yield pou_elem, found[0], found[1] continue - for child in body: - if tag(child) in BODY_LANGUAGES: - yield elem, tag(child), child - break + for elem in pou_elem.iter(): + if tag(elem).lower() not in MEMBER_TAGS: + continue + elem_name = (elem.get("name") or "").lower() + if elem_name != wanted and not elem_name.endswith("." + wanted): + continue + found = _language_body(elem) + if found is not None: + yield pou_elem, found[0], found[1] diff --git a/tools/ladder/tests/fixtures/action_member.plcopen.xml b/tools/ladder/tests/fixtures/action_member.plcopen.xml new file mode 100644 index 0000000..37fb0c4 --- /dev/null +++ b/tools/ladder/tests/fixtures/action_member.plcopen.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + xAction + + + + + Status.Action + + + + + + + + + + + xParent + + + + + Status.Parent + + + + + + + diff --git a/tools/ladder/tests/fixtures/method_member.plcopen.xml b/tools/ladder/tests/fixtures/method_member.plcopen.xml new file mode 100644 index 0000000..c5ea8ea --- /dev/null +++ b/tools/ladder/tests/fixtures/method_member.plcopen.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + xMethod + + + + + Status.Method + + + + + + + + + + + + xParent + + + + + Status.Parent + + + + + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index d275bb2..1e36ad5 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -301,6 +301,84 @@ def failing_open_utf8(path, mode): shutil.rmtree(workspace) +# --- sub-POU members render their own body, never the parent's -------------- + +# An action's PLCopen export wraps it in its parent POU, parent body included. +# Without the member name the rendering drew the parent's networks under the +# action's filename - a dump describing a different POU than the .xml beside +# it, which a reviewer has no way to notice. +ACTION_FIXTURE = os.path.join(HERE, "fixtures", "action_member.plcopen.xml") +METHOD_FIXTURE = os.path.join(HERE, "fixtures", "method_member.plcopen.xml") + +workspace = tempfile.mkdtemp() +try: + graphical_export.reset_stats() + + action_base = os.path.join(workspace, "PLC_TEST.ACT_TEST") + action = FakePou("ACT_TEST", ACTION_FIXTURE) + check( + "an action renders", + graphical_export.write_rendered_text(action, action_base, member_name="ACT_TEST") is True, + ) + action_content = read(action_base + ".txt") + check("the action's own body is drawn", "Status.Action" in action_content) + check("the parent's body is not drawn", "Status.Parent" not in action_content) + check("the rendering is titled for the member", "PLC_TEST.ACT_TEST" in action_content) + check( + "the rendering says whose declaration it shows", + "the declaration below is the parent POU's" in action_content, + ) + + # Both derived files describe the member. An ST file showing the parent + # beside a diagram showing the action would be worse than either alone. + action_st = read(action_base + ".st.txt") + check("the ST file follows the member too", "Status.Action := xAction;" in action_st) + check("the ST file does not show the parent", "Status.Parent" not in action_st) + check("the ST file carries the member note", "the declaration below is the parent POU's" in action_st) + + # A graphical method: CODESYS spells the tag with a capital M + # where it spells actions . Matching one case only meant methods + # never found their own body and silently rendered nothing. + method_base = os.path.join(workspace, "FB_TEST.Compute") + method = FakePou("Compute", METHOD_FIXTURE) + check( + "a graphical method renders", + graphical_export.write_rendered_text(method, method_base, member_name="Compute") is True, + ) + method_content = read(method_base + ".txt") + check("the method's own body is drawn", "Status.Method" in method_content) + check("the method does not draw the parent", "Status.Parent" not in method_content) + + # The parent's own rendering must still be the parent body, members + # excluded - iter_bodies only ever took the pou's direct . + parent_base = os.path.join(workspace, "PLC_TEST") + parent = FakePou("PLC_TEST", ACTION_FIXTURE) + check("the parent still renders", graphical_export.write_rendered_text(parent, parent_base) is True) + parent_content = read(parent_base + ".txt") + check("the parent draws its own body", "Status.Parent" in parent_content) + check("the parent does not absorb the action", "Status.Action" not in parent_content) + check( + "the parent's dump carries no member note", + "the declaration below is the parent POU's" not in parent_content, + ) + + # A member whose body the export does not carry must produce NO file: an + # absent rendering sends the reviewer to the native xml, a foreign one + # does not. + missing_base = os.path.join(workspace, "PLC_TEST.ACT_MISSING") + missing = FakePou("ACT_MISSING", os.path.join(FIXTURES, "LDTesting.xml")) + check( + "a member the export lacks writes nothing", + graphical_export.write_rendered_text(missing, missing_base, member_name="ACT_MISSING") is False, + ) + check("no foreign dump is written", not os.path.exists(missing_base + ".txt")) + check("no foreign ST is written either", not os.path.exists(missing_base + ".st.txt")) + check_equal("the missing member is counted", graphical_export.STATS["members_missing"], 1) + check("the summary reports the missing member", "nothing was written for those" in graphical_export.summary()) +finally: + shutil.rmtree(workspace) + + # --- the importer ignores the derived file --------------------------------- # This is the contract that keeps the round trip intact. import_directory_child From 89e3b893c02e1c4b5fec7eabbe1a90ee44f05907 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 13:20:26 +1000 Subject: [PATCH 42/91] hang a store off its pin, and branch a shared box instead of repeating it Two things were wrong on the output side of a box. A store written on an output pin was drawn inside the box, so "uiOutVoltage => uiCurrSupplyVolt" sat in among the pin names where it reads as another pin. CODESYS draws it hanging off the pin, and so does this now. Not on the row a wire already leaves by: in LD that row carries the rung on to the rail, and a store sharing it would read as the rung running through the store, so there it stays inline. A box read from two places was drawn once and then named in text on the second reader - "fbenable.ENO" as a bare word feeding the next box. The wire is what says the two readers are the same signal, so the box is now drawn once and its readers hang off a junction on the pin they read, which is what the editor draws. Only a named instance is joined this way: an operator has no name, no state, and nothing is gained by it. A network with more than one shared box would need a real two-dimensional layout, which this renderer does not have, so those still fall back to naming it. The test helper also had to change to find this. A failure detail quotes rendered lines, and printing box-drawing characters through print() raises on a Windows console - which aborted the run at the first golden mismatch, so every check after it went unreported and the suite looked shorter rather than broken. Failures now go out as UTF-8 like the goldens already did. --- src/fbd_render.py | 232 ++++++++++++++++-- src/ld_render.py | 48 +++- .../codesys/FbTesting.art.expected.txt | 2 +- .../tests/fixtures/fbd_storage.plcopen.xml | 8 + tools/ladder/tests/test_export.py | 14 +- tools/ladder/tests/test_fbd.py | 44 +++- tools/ladder/tests/test_ladder.py | 19 +- tools/ladder/tests/test_xmlbackend.py | 10 +- 8 files changed, 331 insertions(+), 46 deletions(-) diff --git a/src/fbd_render.py b/src/fbd_render.py index 77b1d04..659fad0 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -23,12 +23,12 @@ def _render_label(node): return Block(["(* label: " + node.name + " *)"], 0) -def _render_jump(node, drawn): +def _render_jump(node, drawn, subs=None): chars = charset.active() tail = chars["H"] * 3 + ">> " + (node.target or "?") if node.condition is None: return Block([tail], 0) - source = _render(node.condition, drawn) + source = _render(node.condition, drawn, subs) lines = source.padded(source.width) out = [] for index, line in enumerate(lines): @@ -50,9 +50,9 @@ def _store_head(node): return "o> " if node.negated else "> " -def _render_assign(node, drawn): +def _render_assign(node, drawn, subs=None): chars = charset.active() - source = _render(node.source, drawn) if node.source is not None else Block([""], 0) + source = _render(node.source, drawn, subs) if node.source is not None else Block([""], 0) lines = source.padded(source.width) head = _store_head(node) tail = chars["H"] * 3 + head + (node.label or "?") @@ -63,11 +63,7 @@ def _render_assign(node, drawn): def _pin_arrow(box, pin): - """The arrow for an assignment written straight onto an output pin. - - "=o>" is "=>" with the negation bubble: the pin stores its inverse. "=S>" - and "=R>" are the set and reset a pin can carry, exactly as a coil does. - """ + """The inline form, for a store on a pin that also feeds a wire onward.""" storage = box.stored_outputs.get(pin) if storage == "set": return " =S> " @@ -76,6 +72,20 @@ def _pin_arrow(box, pin): return " =o> " if pin in box.negated_outputs else " => " +def _pin_head(box, pin): + """The arrow head on a store written straight onto an output pin. + + The same heads a store on a wire uses: "o>" for the negation bubble, + "(S)>" and "(R)>" for the set and reset a pin can carry. + """ + storage = box.stored_outputs.get(pin) + if storage == "set": + return "(S)> " + if storage == "reset": + return "(R)> " + return "o> " if pin in box.negated_outputs else "> " + + def _is_wired(source): """False for a pin CODESYS exported with no source, or an empty expression. @@ -87,11 +97,11 @@ def _is_wired(source): return not (isinstance(source, Signal) and not source.label) -def _render_call(call, read_pin, drawn): +def _render_call(call, read_pin, drawn, subs=None): chars = charset.active() input_blocks = [] for _pin, source in call.inputs: - input_blocks.append(_render(source, drawn) if source is not None else Block([""], 0)) + input_blocks.append(_render(source, drawn, subs) if source is not None else Block([""], 0)) left_lines, pin_rows = stack(input_blocks) # A minimum lead-in, so a source exactly as wide as the column still shows @@ -137,11 +147,19 @@ def _render_call(call, read_pin, drawn): for index, pin_and_source in enumerate(call.inputs): in_at[input_rows[index]] = pin_and_source[0] or "?" + # A store written on an output pin hangs off that pin on a wire of its + # own, the way CODESYS draws it. Writing it inside the box put the target + # variable in among the pin names, where it reads as another pin. out_at = {} + tail_at = {} for index, pin_and_assignment in enumerate(call.outputs): pin, assigned = pin_and_assignment text = pin or "?" - if assigned: + if assigned and pin not in call.wired_outputs: + tail_at[output_rows[index]] = chars["H"] * 3 + _pin_head(call, pin) + assigned + elif assigned: + # The pin already carries a wire onward; a second thing hung off + # the same row would cross it. text += _pin_arrow(call, pin) + assigned elif pin in call.negated_outputs: text += " o" @@ -183,9 +201,10 @@ def _render_call(call, read_pin, drawn): left_pin = in_at.get(row, "") right_pin = out_at.get(row, "") left_edge = chars["PIN_L"] if row in handoff_pins else chars["V"] - right_edge = chars["PIN_R"] if row in live_output_rows else chars["V"] + wired_out = row in live_output_rows or row in tail_at + right_edge = chars["PIN_R"] if wired_out else chars["V"] gap = inner - len(left_pin) - len(right_pin) - box = left_edge + left_pin + " " * gap + right_pin + right_edge + box = left_edge + left_pin + " " * gap + right_pin + right_edge + tail_at.get(row, "") else: box = " " * (inner + 2) lines.append(left[row] + box) @@ -219,7 +238,14 @@ def _reference(call, pin): return text -def _render(node, drawn): +# Stands in for a shared box while the branch that reads it is drawn, so the +# row the wire arrives on can be found once the branch has been composed. +# Composition is text, so a marker in the text is the cheapest way to carry a +# position through it, and it never survives into the output. +MARKER = chr(1) + + +def _render(node, drawn, subs=None): """Draw one tree. ``drawn`` holds the boxes this network has already shown. A block read by two of the network's outputs is one block that runs once. @@ -232,6 +258,10 @@ def _render(node, drawn): """ call = node.call if isinstance(node, OutputRef) else node if isinstance(call, Call): + if subs is not None and id(call) in subs: + # Drawn once already, on the left of this branch; the wire into it + # comes from the junction rather than from another copy of the box. + return Block([MARKER], 0) pin = node.pin if isinstance(node, OutputRef) else call.active_output if id(call) in drawn: reference = _reference(call, pin) @@ -239,11 +269,11 @@ def _render(node, drawn): return Block([reference], 0) else: drawn.add(id(call)) - return _render_call(call, pin, drawn) + return _render_call(call, pin, drawn, subs) if isinstance(node, Assign): - return _render_assign(node, drawn) + return _render_assign(node, drawn, subs) if isinstance(node, Jump): - return _render_jump(node, drawn) + return _render_jump(node, drawn, subs) if isinstance(node, Label): return _render_label(node) if isinstance(node, Signal): @@ -355,6 +385,168 @@ def _shared_source(outputs): return sources[0] if all(box is boxes[0] for box in boxes) else None +def _shared_call(outputs): + """The one box this network reads from more than one place, or None. + + A box read twice is one box that runs once, and the second reader is a + branch off its pin - not a second copy, and not a name in text. Only an + instance qualifies: an operator has no name, no state, and nothing is + gained by joining two copies of it. + + More than one shared box in a network needs a real two-dimensional + layout, which this renderer does not have; those fall back to naming the + box, which is wrong-looking but never wrong. + """ + counts = {} + order = [] + + def walk(node): + call = node.call if isinstance(node, OutputRef) else node + if isinstance(call, Call): + if id(call) in counts: + counts[id(call)] += 1 + return + counts[id(call)] = 1 + order.append(call) + for _pin, source in call.inputs: + if source is not None: + walk(source) + elif isinstance(node, Assign): + if node.source is not None: + walk(node.source) + elif isinstance(node, Jump): + if node.condition is not None: + walk(node.condition) + + for tree in outputs: + walk(tree) + + shared = [call for call in order if counts[id(call)] > 1 and call.instance_name] + return shared[0] if len(shared) == 1 else None + + +def _entry_row(block): + """The row a substituted box's wire arrives on, with the marker removed.""" + chars = charset.active() + for row, line in enumerate(block.lines): + if MARKER in line: + block.lines[row] = line.replace(MARKER, chars["H"]) + return row + return None + + +def _reads_pin(tree, call): + """The pin a tree reads ``call`` through, or None if it does not read it.""" + found = [] + + def walk(node): + if isinstance(node, OutputRef) and node.call is call: + found.append(node.pin) + return + inner = node.call if isinstance(node, OutputRef) else node + if isinstance(inner, Call): + for _pin, source in inner.inputs: + if source is not None: + walk(source) + elif isinstance(node, Assign): + if node.source is not None: + walk(node.source) + elif isinstance(node, Jump): + if node.condition is not None: + walk(node.condition) + + walk(tree) + return found[0] if found else None + + +def _render_joined(network, call): + """Draw a shared box once and branch its readers off the pin they read. + + The box goes on the left; each reader is composed on its own to the right + of it and hangs off a junction column, level with the row its wire leaves + the box on. Readers of the same pin share the column, which is what the + editor draws. + """ + chars = charset.active() + outputs = list(network.outputs) + + drawn = set([id(call)]) + subs = {id(call): call} + + branches = [] + for tree in outputs: + pin = _reads_pin(tree, call) + block = _render(tree, drawn, subs if pin is not None else None) + branches.append((block, _entry_row(block) if pin is not None else None, pin)) + + source = _render_call(call, None, set()) + pin_rows = source.pin_rows + + # Place each branch: the first at the row of the pin it reads, the rest + # stacked below whatever came before, so no two branches overlap. + placed = [] + next_top = None + for block, entry, pin in branches: + if entry is None: + # Reads nothing from the shared box - it is its own drawing, and + # goes below everything rather than joining the column. + top = 0 if next_top is None else next_top + elif next_top is None: + top = pin_rows.get(pin, source.connect_row) - entry + else: + top = next_top + placed.append((block, entry, top)) + next_top = top + len(block.lines) + + shift = -min([top for _block, _entry, top in placed] + [0]) + lines = [" " * source.width] * shift + list(source.lines) + placed = [(block, entry, top + shift) for block, entry, top in placed] + + joins = sorted(top + entry for _block, entry, top in placed if entry is not None) + # The wire leaves the box once per pin that is read; everything below that + # is carried by the junction column, so only a pin's own row is filled + # across to it. Filling every join row drew a wire out of the box's + # bottom border. + leaves = set() + for index, entry_and_top in enumerate(placed): + _block, entry, top = entry_and_top + if entry is None: + continue + pin = branches[index][2] + leaves.add(pin_rows.get(pin, source.connect_row) + shift) + + height = max([len(lines)] + [top + len(block.lines) for block, _entry, top in placed]) + + width = source.width + 2 + body = [] + for row in range(height): + line = lines[row] if row < len(lines) else "" + fill = chars["H"] if row in leaves else " " + body.append(line + fill * (width - len(line))) + + # The junction column, and then each branch on its own rows. + out = [] + for row in range(height): + if joins and row == joins[0] and len(joins) > 1: + joint = chars["T_DOWN"] + elif joins and row == joins[-1] and len(joins) > 1: + joint = chars["BL"] + elif row in joins: + joint = chars["T_RIGHT"] if len(joins) > 1 else chars["H"] + elif joins and joins[0] < row < joins[-1]: + joint = chars["V"] + else: + joint = " " + tail = "" + for block, _entry, top in placed: + if top <= row < top + len(block.lines): + tail = block.lines[row - top] + break + out.append((body[row] + joint + tail).rstrip()) + + return Block(out, joins[0] if joins else 0) + + def render_network(network): """Render one network, which may drive several outputs from one source.""" outputs = getattr(network, "outputs", [network]) @@ -365,6 +557,10 @@ def render_network(network): if _shared_source(outputs) is not None: return _render_fanout(outputs, drawn).lines + shared = _shared_call(outputs) + if shared is not None: + return _render_joined(network, shared).lines + lines = [] for tree in outputs: lines.extend(_render(tree, drawn).lines) diff --git a/src/ld_render.py b/src/ld_render.py index e00266b..294e088 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -110,7 +110,7 @@ def _symbol_and_label(element): def _pin_arrow(box, pin): - """The arrow for an assignment written straight onto an output pin. + """The inline form, for a store on the pin the rung's wire leaves by. "=o>" is "=>" with the negation bubble: the pin stores its inverse. "=S>" and "=R>" are the set and reset a pin can carry, exactly as a coil does. @@ -123,6 +123,20 @@ def _pin_arrow(box, pin): return " =o> " if pin in box.negated_outputs else " => " +def _pin_head(box, pin): + """The arrow head on a store hung off an output pin on its own wire. + + The same heads a coil carries: "o>" for the negation bubble, "(S)>" and + "(R)>" for a set and a reset. + """ + storage = box.stored_outputs.get(pin) + if storage == "set": + return "(S)> " + if storage == "reset": + return "(R)> " + return "o> " if pin in box.negated_outputs else "> " + + def _render_block(element): """Draw a function block as a pin box. @@ -142,22 +156,35 @@ def _render_block(element): left.append(text) wired.append(label is None) + # A store written on an output pin hangs off that pin on a wire of its + # own, as CODESYS draws it. Writing it inside the box put the target + # variable in among the pin names, where it reads as another pin. right = [] - for pin, assigned in element.output_pins: + tails = [] + for index, pin_and_assignment in enumerate(element.output_pins): + pin, assigned = pin_and_assignment text = pin or "?" wired_out = element.output_wired and pin == element.active_output - if assigned: + tail = "" + if assigned and index > 0: + # Hung off the pin, as CODESYS draws it. Only below the first row: + # that one carries the rung's own wire onward to the rail, and a + # store sharing it would read as the rung running through it. + tail = chars["H"] * 3 + _pin_head(element, pin) + assigned + elif assigned: text += _pin_arrow(element, pin) + assigned elif pin in element.negated_outputs and not wired_out: # A wired pin draws its bubble on the box edge instead - one # bubble, not two. text += " o" right.append(text) + tails.append(tail) rows = max(len(left), len(right), 1) left += [""] * (rows - len(left)) wired += [False] * (rows - len(wired)) right += [""] * (rows - len(right)) + tails += [""] * (rows - len(tails)) title = element.title inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)]) @@ -174,11 +201,13 @@ def _render_block(element): elif wired[index] and element.power_negated: # The negation bubble on the power pin, drawn on the box wall. left_edge = "o" - # Only the active output continues onward, and only if consumed. - right_edge = chars["PIN_R"] if (index == 0 and element.output_wired) else chars["V"] - if index == 0 and element.output_wired and element.active_output in element.negated_outputs: + # Only the active output continues onward, and only if consumed - but + # a pin with a store on it breaks the wall for that wire too. + onward = index == 0 and element.output_wired + right_edge = chars["PIN_R"] if (onward or tails[index]) else chars["V"] + if onward and element.active_output in element.negated_outputs: right_edge = "o" - lines.append(left_edge + left[index] + " " * gap + right[index] + right_edge) + lines.append(left_edge + left[index] + " " * gap + right[index] + right_edge + tails[index]) lines.append(chars["BL"] + chars["H"] * inner + chars["BR"]) # Row 0 is the title and row 1 the top border, so the first pin is row 2. @@ -221,7 +250,10 @@ def _render_series(items): width = block.width above = connect_row - block.connect_row lines = [" " * width] * above - lines += [line.ljust(width) for line in block.lines] + # The wire row is padded with wire, not spaces: a box with a store + # hanging off a lower pin is wider than its own wire row, and padding + # that with spaces broke the rung in half. + lines += block.padded(width) lines += [" " * width] * (height - len(lines)) columns.append(lines) diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt index 0630c54..3e468b9 100644 --- a/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt @@ -15,7 +15,7 @@ END_VAR ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15──────┤eChannel xError│ ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY──┤eMode eDiagInfo│ │eFilter xPrepared│ - │ uiOutVoltage => uiCurrSupplyVolt│ + │ uiOutVoltage├───> uiCurrSupplyVolt └─────────────────────────────────────────┘ (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) diff --git a/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml b/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml index d3def85..d1ad20f 100644 --- a/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml +++ b/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml @@ -87,6 +87,11 @@ xGo + + + + xEnableIn + @@ -96,6 +101,9 @@ + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 1e36ad5..4e5453c 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -20,9 +20,11 @@ REPO = os.path.join(HERE, "..", "..", "..") sys.path.insert(0, os.path.join(REPO, "src")) sys.path.insert(0, os.path.join(REPO, "tools", "ci")) # stubbed scriptengine +sys.path.insert(0, os.path.join(REPO, "tools", "ladder")) import graphical_export # noqa: E402 import import_from_files # noqa: E402 +from render import write # noqa: E402 FIXTURES = os.path.join(HERE, "fixtures", "codesys") @@ -32,9 +34,15 @@ def check(name, condition, detail=""): if condition: print("OK " + name) - else: - failures.append(name) - print("FAIL " + name + ((": " + detail) if detail else "")) + return + failures.append(name) + # The detail quotes rendered lines, which hold box-drawing characters a + # Windows console cannot encode. print() raises UnicodeEncodeError on + # exactly those, which aborts the run at the first golden mismatch - so + # the failures after it are never reported and the suite looks shorter + # than it is rather than looking broken. + sys.stdout.flush() + write(["FAIL " + name + ((": " + detail) if detail else "")]) def check_equal(name, actual, expected): diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 78fd640..bbaa36c 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -52,9 +52,15 @@ def box(node): def check(name, condition, detail=""): if condition: print("OK " + name) - else: - failures.append(name) - print("FAIL " + name + ((": " + detail) if detail else "")) + return + failures.append(name) + # The detail quotes rendered lines, which hold box-drawing characters a + # Windows console cannot encode. print() raises UnicodeEncodeError on + # exactly those, which aborts the run at the first golden mismatch - so + # the failures after it are never reported and the suite looks shorter + # than it is rather than looking broken. + sys.stdout.flush() + write(["FAIL " + name + ((": " + detail) if detail else "")]) def check_equal(name, actual, expected): @@ -160,7 +166,12 @@ def check_golden(name, rendered, golden_path): art = fbd_render.render_pou(pou) check("art: no trailing whitespace", all(line == line.rstrip() for line in art)) check("art: boxes do not fuse together", not any(U["TR"] + U["TL"] in line for line in art)) -check("art: output assignment is drawn", any("uiOutVoltage => uiCurrSupplyVolt" in line for line in art)) +# The store hangs off its pin on a wire, outside the box. Written inside, the +# target variable sat among the pin names where it read as another pin. +check( + "art: output assignment hangs off the pin", + any("uiOutVoltage" + U["PIN_R"] + "---> uiCurrSupplyVolt".replace("---", U["H"] * 3) in line for line in art), +) check("art: nested operator box is drawn", any(U["PIN_L"] + "In1 Out1" + U["PIN_R"] in line for line in art)) # A tee marks a real connection, so an unconsumed output must leave the wall @@ -289,7 +300,10 @@ def check_golden(name, rendered, golden_path): # The same bubble on an output pin carrying an inline assignment: the stored # value is the inverse of the pin. check("fidelity: negated output pin inverts its assignment", any("xIdle := NOT tmr.Q;" in line for line in fid_st)) -check("fidelity: negated output pin is marked in the diagram", any("Q =o> xIdle" in line for line in fid_art)) +check( + "fidelity: negated output pin is marked in the diagram", + any("Q" + U["PIN_R"] + U["H"] * 3 + "o> xIdle" in line for line in fid_art), +) # NOT binds tighter than OR in IEC 61131-3, so a negated compound expression # must keep its parentheses or the logic regroups. @@ -395,11 +409,17 @@ def check_golden(name, rendered, golden_path): check_equal("shared box: two outputs", len(shared.networks[0].outputs), 2) check_equal("shared box: the timer is called once", len([l for l in shared_st if l.startswith("fbTimer(")]), 1) check_equal("shared box: one box is drawn", len([l for l in shared_art if "fbTimer : TON" in l]), 1) -check("shared box: the second reader names the pin", any(l.startswith("fbTimer.Q") for l in shared_art)) check("shared box: both stores are still made", "xDone := fbTimer.Q;" in shared_st and "xAny := fbTimer.Q OR xManual;" in shared_st) +# The second reader hangs off the pin on a junction, not on a copy of the box +# and not on its name in text: the wire is what says the two readers are the +# same signal. +check("shared box: the pin branches", any(U["T_DOWN"] in l and "xDone" in l for l in shared_art)) +check("shared box: the branch reaches the operator", any(U["BL"] in l and "In1" in l for l in shared_art)) +check("shared box: the box is not named in text", not any("fbTimer.Q" in l for l in shared_art)) + # An operator has no instance name to refer to, and being stateless it costs -# nothing to draw again - so it is not collapsed. +# nothing to draw again - so it is never the box a network is joined around. check("shared box: the operator is still drawn", any("Out1" in l for l in shared_art)) @@ -446,7 +466,10 @@ def check_golden(name, rendered, golden_path): check_equal("output pin store: recorded on the box", box(storage_pou.networks[1].outputs[0]).stored_outputs["Q"], "set") check("output pin store: the ST guards the write", "IF tmr.Q THEN xHeld := TRUE; END_IF" in storage_st) -check("output pin store: the pin arrow is marked", any("Q =S> xHeld" in line for line in storage_art)) +check( + "output pin store: the pin arrow is marked", + any("Q" + U["PIN_R"] + U["H"] * 3 + "(S)> xHeld" in line for line in storage_art), +) # str.center splits an odd remainder on opposite sides under CPython 3 and # IronPython 2.7, so a box title needing odd padding came out one column @@ -455,9 +478,12 @@ def check_golden(name, rendered, golden_path): check_equal("centred: the odd space goes right", layout.centred("ab", 5), " ab ") check_equal("centred: an even split is unchanged", layout.centred("ab", 6), " ab ") check_equal("centred: no room to centre in", layout.centred("abcd", 3), "abcd") +# The box is 15 columns wide for a 10-character title, so the margin and the +# width are both odd - the one case where CPython puts the extra space on the +# other side from IronPython. Both must produce this line. check( "centred: a title with odd padding sits where CODESYS puts it", - " pulse : TP" in storage_art, + " pulse : TP" in storage_art, ) diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 62a6c84..8ef06fe 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -36,9 +36,15 @@ def check(name, condition, detail=""): if condition: print("OK " + name) - else: - failures.append(name) - print("FAIL " + name + ((": " + detail) if detail else "")) + return + failures.append(name) + # The detail quotes rendered lines, which hold box-drawing characters a + # Windows console cannot encode. print() raises UnicodeEncodeError on + # exactly those, which aborts the run at the first golden mismatch - so + # the failures after it are never reported and the suite looks shorter + # than it is rather than looking broken. + sys.stdout.flush() + write(["FAIL " + name + ((": " + detail) if detail else "")]) def check_equal(name, actual, expected): @@ -214,7 +220,12 @@ def check_golden(name, rendered_lines, golden_path): # An assignment on a block output pin executes every scan; the diagram drew it # but the ST - the half reviewers are told to trust - left it out. check("fidelity: output pin assignment reaches ST", any("iCount := ctr.CV;" in line for line in fidelity_st)) -check("fidelity: output pin assignment is drawn", any("CV => iCount" in line for line in fidelity_art)) +# A store below the first row hangs off its pin outside the box. The first +# row carries the rung's own wire onward, so a store there stays inline. +check( + "fidelity: output pin assignment hangs off the pin", + any("CV" + U["PIN_R"] + U["H"] * 3 + "> iCount" in line for line in fidelity_art), +) # A rung can store through an outVariable element instead of a coil - the # standard shape for a non-boolean result. It emitted no ST at all, and a diff --git a/tools/ladder/tests/test_xmlbackend.py b/tools/ladder/tests/test_xmlbackend.py index 6c686f4..8c566a0 100644 --- a/tools/ladder/tests/test_xmlbackend.py +++ b/tools/ladder/tests/test_xmlbackend.py @@ -21,6 +21,7 @@ import plcopen # noqa: E402 import xmlbackend # noqa: E402 +from render import write # noqa: E402 FIXTURES = os.path.join(HERE, "fixtures") CODESYS = os.path.join(FIXTURES, "codesys") @@ -31,9 +32,12 @@ def check(name, condition, detail=""): if condition: print("OK " + name) - else: - failures.append(name) - print("FAIL " + name + ((": " + detail) if detail else "")) + return + failures.append(name) + # See test_fbd: a detail quoting rendered lines cannot go through print() + # on a Windows console without aborting the run. + sys.stdout.flush() + write(["FAIL " + name + ((": " + detail) if detail else "")]) def check_equal(name, actual, expected): From 9707d54fc3e9f88fe12ddb2f64d69afa729a2e1e Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 16:24:02 +1000 Subject: [PATCH 43/91] add the GraphicalTesting sources as a worked example The application folders of a real SP11 export, showing what the renderer writes: an LD POU, an FBD POU with an action, and an SFC one it skips. The device and fieldbus xml is left out - 449,000 lines of CAN and IO descriptions that say nothing about rendering - and so is the project file itself, which is a large binary every clone would carry for ever and which carries a controller serial number and a gateway address with it. The template and these sources together regenerate the project. .gitignore now covers the CODESYS working files that sit beside a project: the .project binaries, the per-user .opt session state, and the .~u lock that holds the user and machine name. Review correspondence stays beside the checkout rather than in it. --- .gitignore | 12 + .../SafetyPLC/application/EVL.gvl.st | 7 + .../SafetyPLC/application/EVL.gvl.xml | 90 + .../SafetyPLC/application/PLC_PRG.st | 8 + .../application/TaskConfiguration.xml | 112 + .../StandardPLC/application/EVC.xml | 32 + .../FB_TESTING.PleaseIhaveKids.st.txt | 32 + .../FB_TESTING.PleaseIhaveKids.txt | 27 + .../FB_TESTING.PleaseIhaveKids.xml | 103 + .../StandardPLC/application/FB_TESTING.st.txt | 45 + .../StandardPLC/application/FB_TESTING.txt | 51 + .../StandardPLC/application/FB_TESTING.xml | 1162 ++++++++++ .../StandardPLC/application/LD_TEST.st.txt | 31 + .../StandardPLC/application/LD_TEST.txt | 34 + .../StandardPLC/application/LD_TEST.xml | 630 ++++++ .../application/PersistentVars.gvl.st | 25 + .../application/PersistentVars.gvl.xml | 173 ++ .../StandardPLC/application/SFC_TEST.xml | 1959 +++++++++++++++++ .../application/TaskConfiguration.xml | 116 + .../application/Visualization.vis.xml | 701 ++++++ 20 files changed, 5350 insertions(+) create mode 100644 GraphicalTesting/SafetyPLC/application/EVL.gvl.st create mode 100644 GraphicalTesting/SafetyPLC/application/EVL.gvl.xml create mode 100644 GraphicalTesting/SafetyPLC/application/PLC_PRG.st create mode 100644 GraphicalTesting/SafetyPLC/application/TaskConfiguration.xml create mode 100644 GraphicalTesting/StandardPLC/application/EVC.xml create mode 100644 GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.st.txt create mode 100644 GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt create mode 100644 GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.xml create mode 100644 GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt create mode 100644 GraphicalTesting/StandardPLC/application/FB_TESTING.txt create mode 100644 GraphicalTesting/StandardPLC/application/FB_TESTING.xml create mode 100644 GraphicalTesting/StandardPLC/application/LD_TEST.st.txt create mode 100644 GraphicalTesting/StandardPLC/application/LD_TEST.txt create mode 100644 GraphicalTesting/StandardPLC/application/LD_TEST.xml create mode 100644 GraphicalTesting/StandardPLC/application/PersistentVars.gvl.st create mode 100644 GraphicalTesting/StandardPLC/application/PersistentVars.gvl.xml create mode 100644 GraphicalTesting/StandardPLC/application/SFC_TEST.xml create mode 100644 GraphicalTesting/StandardPLC/application/TaskConfiguration.xml create mode 100644 GraphicalTesting/StandardPLC/application/Visualization.vis.xml diff --git a/.gitignore b/.gitignore index 015f290..7877a85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,15 @@ +# CODESYS working files. None of these belong in the repository: +# a .project is a large binary that every clone then carries for ever, and it +# and the .opt session files carry device identifiers - serial numbers and +# gateway addresses - along with the user and machine name in the .~u lock. +# The template and the exported sources together regenerate the project. +*.project +*.opt +*.~u + +# Review correspondence. Kept beside the checkout, not in it. +review/ + .vscode/ # Claude Code's per-developer tool permissions. Machine-specific paths, and diff --git a/GraphicalTesting/SafetyPLC/application/EVL.gvl.st b/GraphicalTesting/SafetyPLC/application/EVL.gvl.st new file mode 100644 index 0000000..3196153 --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/EVL.gvl.st @@ -0,0 +1,7 @@ +(* THIS EVL IS AUTOGENERATED. DO NOT MODIFY THESE ATTRIBUTES. *) +{attribute 'location' := '16#800' } +{attribute 'linkalways'} +{attribute 'qualified_only'} +VAR_GLOBAL + +END_VAR diff --git a/GraphicalTesting/SafetyPLC/application/EVL.gvl.xml b/GraphicalTesting/SafetyPLC/application/EVL.gvl.xml new file mode 100644 index 0000000..0258b8e --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/EVL.gvl.xml @@ -0,0 +1,90 @@ + + + + + + + True + + c92bf834-0a70-4e8b-9367-2d81823c2f23 + 4403a37e-f528-4863-8b5f-3c6def9b45a5 + EVL + + + + 0fc3aa33-c9bf-436a-b5e4-fcd0ccb10d58 + + + + + + + ffbfa93a-b94d-45fc-a329-229860183b1d + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + + 0 + + + + + + + 59 + + (* THIS EVL IS AUTOGENERATED. DO NOT MODIFY THESE ATTRIBUTES. *) + + + 60 + + {attribute 'location' := '16#800' } + + + 61 + + {attribute 'linkalways'} + + + 62 + + {attribute 'qualified_only'} + + + 63 + + VAR_GLOBAL + + + 64 + + + + + 65 + + END_VAR + + + 1 + + + + + + + + False + False + + 4403a37e-f528-4863-8b5f-3c6def9b45a5 + + SafetyPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/SafetyPLC/application/PLC_PRG.st b/GraphicalTesting/SafetyPLC/application/PLC_PRG.st new file mode 100644 index 0000000..194f411 --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/PLC_PRG.st @@ -0,0 +1,8 @@ +PROGRAM PLC_PRG +VAR + +END_VAR + +// --- BEGIN IMPLEMENTATION --- + + diff --git a/GraphicalTesting/SafetyPLC/application/TaskConfiguration.xml b/GraphicalTesting/SafetyPLC/application/TaskConfiguration.xml new file mode 100644 index 0000000..969bb81 --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/TaskConfiguration.xml @@ -0,0 +1,112 @@ + + + + + + + True + + 8fcf8f4e-747e-4afc-bebd-2ebdf08d5272 + 4403a37e-f528-4863-8b5f-3c6def9b45a5 + TaskConfiguration + + ae1de277-a207-4a28-9efb-456c06bd52f3 + + 0 + + + + + + 43f23742-dece-5e41-901c-02c6552b03dc + 9f564949-54f6-5de0-a5ad-d6e23224445c + 032a6610-5901-5920-9c37-9d23b19bc3b6 + f93e51af-18d4-5273-b6f9-b20a2445edb2 + cc9aa7ec-7090-59b2-bbe8-58036d812d0f + d9e6755d-f94b-563f-b03c-4806c2d88733 + dea6d353-a39e-53b0-98c9-31e44b306387 + 70fb98b7-5143-57e8-8073-b23d95fa3c97 + b4ea44ff-6309-5f81-9900-86ea43c12e41 + 3e4f103e-b93b-5f1f-bb80-011c5864de82 + 07f9199e-3544-5624-ba50-17c19c45fb08 + 76078381-55d8-5270-b3a5-8af547761d98 + 054c9e67-a8c2-5f23-bbd7-b1602bf93bba + 34ff86c0-633c-5389-9a16-a22363759955 + d775bf58-982d-5bfa-968e-db4dc501f3aa + + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + + 00000000-0000-0000-0000-000000000000 + + 4403a37e-f528-4863-8b5f-3c6def9b45a5 + + SafetyPLC + PLC Logic + Application + + -1 + + + False + + e2894a44-2e4c-477e-9bf1-3cc1b1eae293 + 8fcf8f4e-747e-4afc-bebd-2ebdf08d5272 + Task + + 98a2708a-9b18-4f31-82ed-a1465b24fa2d + + 0 + + + Cyclic + + + + + + 1 + + True + + t#8ms + ms + + 1 + + + t#10ms + + + + + b041315d-8268-483e-b4be-e89bb85eddf8 + c177d10e-0864-40cc-bb92-d152e1fd7768 + + + PLC_PRG + + + + False + True + -2 + + + 8fcf8f4e-747e-4afc-bebd-2ebdf08d5272 + + SafetyPLC + PLC Logic + Application + Task Configuration + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/EVC.xml b/GraphicalTesting/StandardPLC/application/EVC.xml new file mode 100644 index 0000000..67eb5d7 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/EVC.xml @@ -0,0 +1,32 @@ + + + + + + + True + + 3c56bead-196b-477a-b2ce-c9db55772c1a + f9c00bdb-2f18-4e80-863e-6da977e8f304 + EVC + + 327b6465-4e7f-4116-846a-8369c730fd66 + + 0 + + + + + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.st.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.st.txt new file mode 100644 index 0000000..c3a9475 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.st.txt @@ -0,0 +1,32 @@ +(* Equivalent Structured Text for a graphical POU, written by codescribe. + READ ONLY. This is a rendering of the native xml beside it, not a + translation: it is not guaranteed to compile and must never be imported + or pasted back into CODESYS. The native xml is the source. *) + +(* FB_TESTING.PleaseIhaveKids - the declaration below is the parent POU's *) + +PROGRAM FB_TESTING +(*********************************************************************************************** +Object Name : PLC_SUPPLY +Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. +Author : KP +Date : 17/06/25 +Rev : P1 +***********************************************************************************************) +VAR CONSTANT + uiMinVoltage : UINT := 5000; // Minimum Voltage in mV +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) + fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + uiCurrSupplyVolt : UINT ; // Operating Voltage in mV + TOF_0: TOF; + Test: BOOL; + dude: BOOL; + fbenable: TON; + xResult : BOOL; + xTimerDone : BOOL; +END_VAR + +(* Network 1 *) +TRUE := dude; diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt new file mode 100644 index 0000000..b737106 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt @@ -0,0 +1,27 @@ +(* FB_TESTING.PleaseIhaveKids - the declaration below is the parent POU's *) + +PROGRAM FB_TESTING +(*********************************************************************************************** +Object Name : PLC_SUPPLY +Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. +Author : KP +Date : 17/06/25 +Rev : P1 +***********************************************************************************************) +VAR CONSTANT + uiMinVoltage : UINT := 5000; // Minimum Voltage in mV +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) + fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + uiCurrSupplyVolt : UINT ; // Operating Voltage in mV + TOF_0: TOF; + Test: BOOL; + dude: BOOL; + fbenable: TON; + xResult : BOOL; + xTimerDone : BOOL; +END_VAR + +(* Network 1 *) +dude───> TRUE diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.xml b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.xml new file mode 100644 index 0000000..eb9def1 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.xml @@ -0,0 +1,103 @@ + + + + + + + True + + 13dcd058-9705-4825-ae9f-af8738989171 + cedc2742-8922-46db-927d-5f652c9943c9 + PleaseIhaveKids + + 8ac092e5-3128-4e26-9e7e-11016c6684f2 + + 25e509de-33d4-4447-93f8-c9e4ea381c8b + + 0 + + + + + Fbd + + + False + False + False + + + + + False + + + + + + TRUE + BOOL + + + + + 0 + False + False + + True + False + False + 4 + + + + + 0 + False + False + + + + dude + BOOL + + + + + 0 + False + False + + False + False + False + 3 + + 2 + + 5 + + + + 1 + + + 0 + True + + 5 + + cedc2742-8922-46db-927d-5f652c9943c9 + + StandardPLC + PLC Logic + Application + FB_TESTING + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt new file mode 100644 index 0000000..dd8564c --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt @@ -0,0 +1,45 @@ +(* Equivalent Structured Text for a graphical POU, written by codescribe. + READ ONLY. This is a rendering of the native xml beside it, not a + translation: it is not guaranteed to compile and must never be imported + or pasted back into CODESYS. The native xml is the source. *) + +PROGRAM FB_TESTING +(*********************************************************************************************** +Object Name : PLC_SUPPLY +Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. +Author : KP +Date : 17/06/25 +Rev : P1 +***********************************************************************************************) +VAR CONSTANT + uiMinVoltage : UINT := 5000; // Minimum Voltage in mV +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) + fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + uiCurrSupplyVolt : UINT ; // Operating Voltage in mV + TOF_0: TOF; + Test: BOOL; + dude: BOOL; + fbenable: TON; + xResult : BOOL; + xTimerDone : BOOL; +END_VAR + +(* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) +fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15, eMode := ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY); +uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; + +(* Network 2: Comment only network *) +(* label: ByeBye *) + +(* Network 3: Comment only network with comment slash *) + +(* Network 4: (* Comment only network with brackets * ) *) +IF Test THEN (* JMP ByeBye *) END_IF + +(* Network 5: comment for the sakes of comments *) +fbenable(EN := Test, IN := Test, PT := T#5s); +xTimerDone := fbenable.Q; +xResult := fbenable.ENO; +Test := (fbenable.ENO OR dude) AND xTimerDone; diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt new file mode 100644 index 0000000..e8f6bbd --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt @@ -0,0 +1,51 @@ +PROGRAM FB_TESTING +(*********************************************************************************************** +Object Name : PLC_SUPPLY +Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. +Author : KP +Date : 17/06/25 +Rev : P1 +***********************************************************************************************) +VAR CONSTANT + uiMinVoltage : UINT := 5000; // Minimum Voltage in mV +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) + fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + uiCurrSupplyVolt : UINT ; // Operating Voltage in mV + TOF_0: TOF; + Test: BOOL; + dude: BOOL; + fbenable: TON; + xResult : BOOL; + xTimerDone : BOOL; +END_VAR + +(* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) + fbSystemSupply : ifmIOcommon.SystemSupply + ┌─────────────────────────────────────────┐ +ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15──────┤eChannel xError│ +ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY──┤eMode eDiagInfo│ + │eFilter xPrepared│ + │ uiOutVoltage├───> uiCurrSupplyVolt + └─────────────────────────────────────────┘ + +(* Network 2: Comment only network *) +(* label: ByeBye *) + +(* Network 3: Comment only network with comment slash *) + +(* Network 4: (* Comment only network with brackets * ) *) +Test───>> ByeBye + +(* Network 5: comment for the sakes of comments *) + fbenable : TON + ┌──────────────┐ +Test──┤EN ENO├─────────────────┬────> xResult +Test──┤IN Q├───> xTimerDone │ OR AND +T#5s──┤PT ET│ │ ┌──────────┐ ┌──────────┐ + └──────────────┘ └──────┤In1 Out1├──┤In1 Out1├───> Test + dude──┤In2 │ │ │ + └──────────┘ │ │ + xTimerDone──────────┤In2 │ + └──────────┘ diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.xml b/GraphicalTesting/StandardPLC/application/FB_TESTING.xml new file mode 100644 index 0000000..10837cc --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.xml @@ -0,0 +1,1162 @@ + + + + + + + True + + cedc2742-8922-46db-927d-5f652c9943c9 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + FB_TESTING + + 6f9dac99-8de1-4efc-8465-68ac443b7d08 + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + 25e509de-33d4-4447-93f8-c9e4ea381c8b + + 0 + + + None + + + Fbd + + + False + False + False + + // Function Block to monitor supply voltage on VBB15 (from ignition) + + + False + + + ifmIOcommon.SystemSupply + + fbSystemSupply + ifmIOcommon.SystemSupply + + + + + 0 + False + False + + False + False + True + 22 + + + + + + + + + + + + 0 + False + False + + True + False + False + 24 + + + + + + + + + 0 + False + False + + True + False + False + 25 + + + uiCurrSupplyVolt + UINT + + + + + 0 + False + False + + True + False + False + 26 + + + + + 0 + True + False + + + + + + ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15 + SYS_VOLTAGE_CHANNEL + + + + + 0 + False + False + + False + False + False + 19 + + 18 + + + + ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY + MODE_SYSTEM_SUPPLY + + + + + 0 + False + False + + False + False + False + 21 + + 20 + + + + + + + + + + 0 + False + False + + False + False + False + 28 + + 27 + + + + + eChannel + eMode + eFilter + + + SYS_VOLTAGE_CHANNEL + MODE_SYSTEM_SUPPLY + FILTER_INPUT + + + + + xError + eDiagInfo + xPrepared + uiOutVoltage + + + BOOL + ifmTypes.DIAG_INFO + BOOL + UINT + + + FunctionBlock + False + False + + False + False + 23 + + + + 5 + + + False + False + False + + //Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge + + + True + + + ifmIOcommon.SupplySwitch + + fbSupplySwitch + ifmIOcommon.SupplySwitch + + + + + 0 + False + False + + False + False + True + 105 + + + + + + + + + + + + 0 + False + False + + True + False + False + 107 + + + + + + + + + 0 + False + False + + True + False + False + 108 + + + + + 0 + True + False + + + + + + ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH + MODE_SUPPLY_SWITCH + + + + + 0 + False + False + + False + False + False + 96 + + 95 + + + TOF + + TOF_0 + TOF + + + + + 0 + False + False + + False + False + True + 124 + + + + + + + + + + + + 0 + False + False + + True + False + False + 115 + + + + + 0 + True + False + + + + + GT + + + + + + + + 0 + False + False + + False + False + True + 126 + + + + + + + + 0 + True + False + + + + + + uiCurrSupplyVolt + UINT + + + + + 0 + False + False + + False + False + False + 119 + + 118 + + + + uiMinVoltage + UINT + + + + + 0 + False + False + + False + False + False + 121 + + 120 + + + + + + + + + + + + BOOL + + + Gt + False + False + + False + False + 127 + + + + T#5S + TIME + + + + + 0 + False + False + + False + False + False + 112 + + 111 + + + + + IN + PT + + + BOOL + TIME + + + + + Q + ET + + + BOOL + TIME + + + FunctionBlock + False + False + + False + False + 125 + + + + + eMode + xValue + + + MODE_SUPPLY_SWITCH + BOOL + + + + + xError + eDiagInfo + xPrepared + + + BOOL + ifmTypes.DIAG_INFO + BOOL + + + FunctionBlock + False + False + + False + False + 106 + + + + 92 + + + False + False + False + + + + ByeBye + False + + + 142 + + + False + False + False + + + + + False + + + 132 + + + False + False + False + + Comment only network + + + False + + + 133 + + + False + False + False + + // Comment only network with comment slash + + + False + + + 134 + + + False + False + False + + (* Comment only network with brackets *) + + + False + + + 135 + + + False + False + False + + + + + False + + + + + + ByeBye + BOOL + + + + + 4 + False + False + + True + False + False + 139 + + + + + 0 + False + False + + + + Test + BOOL + + + + + 0 + False + False + + False + False + False + 138 + + 137 + + 140 + + + + 136 + + + False + False + False + + comment for the sakes of comments + + + False + + + 6 + + TON + + fbenable + TON + + + + + 0 + False + False + + False + False + True + 162 + + + + + + xTimerDone + BOOL + + + + + 0 + False + False + + True + False + False + 153 + + + + + + + + + 0 + False + False + + True + False + False + 164 + + + + + 0 + True + False + + + + + + Test + BOOL + + + + + 0 + False + False + + False + False + False + 155 + + 154 + + + + Test + BOOL + + + + + 0 + False + False + + False + False + False + 157 + + 156 + + + + T#5s + TIME + + + + + 0 + False + False + + False + False + False + 159 + + 158 + + + + + EN + IN + PT + + + BOOL + BOOL + TIME + + + + + ENO + Q + ET + + + BOOL + BOOL + TIME + + + FunctionBlock + True + True + + False + False + 163 + + 166 + + + + + + xResult + BOOL + + + + + 0 + False + False + + True + False + False + 175 + + + + + 0 + False + False + + + 6 + + 167 + + 176 + + + + + + Test + BOOL + + + + + 0 + False + False + + True + False + False + 185 + + + + + 0 + False + False + + + AND + + + + + + + + 0 + False + False + + False + False + True + 183 + + + + + + + + 0 + True + False + + + + + OR + + + + + + + + 0 + False + False + + False + False + True + 173 + + + + + + + + 0 + True + False + + + + + 6 + + 168 + + + + dude + BOOL + + + + + 0 + False + False + + False + False + False + 172 + + 171 + + + + + + + + + + + + BOOL + + + Or + False + False + + False + False + 174 + + + + xTimerDone + BOOL + + + + + 0 + False + False + + False + False + False + 182 + + 181 + + + + + + + + + + + + BOOL + + + And + False + False + + False + False + 184 + + 186 + + + + 150 + + + 6 + True + + + + + + 2 + + PROGRAM FB_TESTING + + + 7 + + (*********************************************************************************************** + + + 8 + + Object Name : PLC_SUPPLY + + + 9 + + Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. + + + 10 + + Author : KP + + + 11 + + Date : 17/06/25 + + + 12 + + Rev : P1 + + + 6 + + ***********************************************************************************************) + + + 46 + + VAR CONSTANT + + + 47 + + uiMinVoltage : UINT := 5000; // Minimum Voltage in mV + + + 48 + + END_VAR + + + 3 + + VAR + + + 13 + + fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) + + + 14 + + fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + + + 131 + + uiCurrSupplyVolt : UINT ; // Operating Voltage in mV + + + 102 + + TOF_0: TOF; + + + 141 + + Test: BOOL; + + + 149 + + dude: BOOL; + + + 165 + + fbenable: TON; + + + 177 + + xResult : BOOL; + + + 178 + + xTimerDone : BOOL; + + + 4 + + END_VAR + + + 71 + + + + + + + 186 + Standard + + False + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt new file mode 100644 index 0000000..8997fed --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt @@ -0,0 +1,31 @@ +(* Equivalent Structured Text for a graphical POU, written by codescribe. + READ ONLY. This is a rendering of the native xml beside it, not a + translation: it is not guaranteed to compile and must never be imported + or pasted back into CODESYS. The native xml is the source. *) + +PROGRAM LD_TEST +VAR + Sensor1: BOOL; + Sensor2: BOOL; + sensor3: BOOL; + PowerOn: BOOL; + TON_0: TON; + CTU_0: CTU; + PowerOff: BOOL; +END_VAR + +(* Network 1: comment without backslash *) +(* title: Try me Codesys I swear *) +IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF + +(* Network 2 *) +(* JMP TestJump *) + +(* Network 3: Comment *) +(* title: header text *) +TON_0(IN := PowerOn, PT := T#5S); +CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); +IF CTU_0.Q THEN PowerOff := FALSE; END_IF + +(* Network 4 *) +(* label: TestJump *) diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt new file mode 100644 index 0000000..17522aa --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -0,0 +1,34 @@ +PROGRAM LD_TEST +VAR + Sensor1: BOOL; + Sensor2: BOOL; + sensor3: BOOL; + PowerOn: BOOL; + TON_0: TON; + CTU_0: CTU; + PowerOff: BOOL; +END_VAR + +(* Network 1: comment without backslash *) +(* title: Try me Codesys I swear *) +│ Sensor1 Sensor2 PowerOn +├──┬───┤ ├───┬───┤/├──────(S)─────┤ +│ │ sensor3 │ +│ └───┤ ├───┘ + +(* Network 2 *) +│ +├────>>TestJump────┤ + +(* Network 3: Comment *) +(* title: header text *) +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff +├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ +│ │PT := T#5S ET│ │RESET := PowerOff CV│ +│ └───────────────┘ │PV := 10 │ +│ └──────────────────────┘ + +(* Network 4 *) +│ +├────TestJump:────┤ diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.xml b/GraphicalTesting/StandardPLC/application/LD_TEST.xml new file mode 100644 index 0000000..a275976 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.xml @@ -0,0 +1,630 @@ + + + + + + + True + + 4f4ad042-bbb9-4292-adb4-f91543e47fce + f9c00bdb-2f18-4e80-863e-6da977e8f304 + LD_TEST + + 6f9dac99-8de1-4efc-8465-68ac443b7d08 + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + 25e509de-33d4-4447-93f8-c9e4ea381c8b + + 0 + + + None + + + Ld + + + False + False + False + + comment without backslash + Try me Codesys I swear + + False + + + + + + PowerOn + BOOL + + + + + 2 + True + False + + True + True + False + 22 + + + + + 0 + False + False + + + AND + + + + + + + + 0 + False + False + + False + False + True + 9 + + + + + + 0 + False + False + + + + + OR + + + + + + + + 0 + False + False + + False + False + True + 17 + + + + + + 0 + False + False + + + + + + Sensor1 + BOOL + + + + + 0 + True + False + + False + True + False + 7 + + 6 + + + + sensor3 + BOOL + + + + + 0 + True + False + + False + True + False + 20 + + 19 + + + + + + + + + + + Or + + + + False + False + 18 + + + + Sensor2 + BOOL + + + + + 1 + True + False + + False + True + False + 12 + + 11 + + + + + + + + + + + And + + + + False + False + 10 + + 23 + + + + 5 + + + False + False + False + + + + + False + + + + + + TestJump + + + + + + 4 + False + False + + True + False + False + 52 + + + + + 0 + False + False + + + + + 0 + False + False + + 51 + + 53 + + + + 50 + + + False + False + False + + // Comment + header text + + False + + + + + + PowerOff + BOOL + + + + + 3 + True + False + + True + True + False + 34 + + + + + 0 + False + False + + + CTU + + CTU_0 + CTU + + + + + 0 + False + False + + False + False + True + 36 + + + + + + + WORD + + + + + 0 + False + False + + True + False + False + 38 + + + + + 0 + False + False + + + + + TON + + TON_0 + TON + + + + + 0 + False + False + + False + False + True + 26 + + + + + + + TIME + + + + + 0 + False + False + + True + False + False + 28 + + + + + 0 + False + False + + + + + + PowerOn + BOOL + + + + + 0 + False + False + + False + True + False + 30 + + 29 + + + + T#5S + TIME + + + + + 0 + False + False + + False + False + False + 32 + + 31 + + + + + IN + PT + + + BOOL + TIME + + + + + Q + ET + + + BOOL + TIME + + + FunctionBlock + False + False + + False + False + 27 + + + + PowerOff + BOOL + + + + + 0 + False + False + + False + True + False + 40 + + 39 + + + + 10 + INT + + + + + 0 + False + False + + False + False + False + 42 + + 41 + + + + + CU + RESET + PV + + + BOOL + BOOL + WORD + + + + + Q + CV + + + BOOL + WORD + + + FunctionBlock + False + False + + False + False + 37 + + 35 + + + + 25 + + + False + False + False + + + + TestJump + False + + + 49 + + + 0 + True + + + + + + 2 + + PROGRAM LD_TEST + + + 3 + + VAR + + + 8 + + Sensor1: BOOL; + + + 13 + + Sensor2: BOOL; + + + 21 + + sensor3: BOOL; + + + 24 + + PowerOn: BOOL; + + + 33 + + TON_0: TON; + + + 43 + + CTU_0: CTU; + + + 44 + + PowerOff: BOOL; + + + 4 + + END_VAR + + + 1 + + + + + + + 53 + Standard + + False + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.st b/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.st new file mode 100644 index 0000000..53abfd9 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.st @@ -0,0 +1,25 @@ +{attribute 'qualified_only'} +VAR_GLOBAL PERSISTENT RETAIN + +wEngineOnCount :WORD; //Count OF times Engine is turned ON +wGpsOnCount :WORD; //Count OF times GPS Activated +wAutoOnCount :WORD; //Count OF times Auto Harvest Activated +wFlightOnCount :WORD; //Count OF times Flight Converyor is turned ON +wTotalMaintCount :WORD; //Count of times Maintenance Mode Activated +wTotalFilterCount :WORD; //Count of Filter Bypass Alarms +wTotalPressCount :WORD; //Count of Pressure Alarms +wTotalTempCount :WORD; //Count OF Temperature Alarms +wTotalEstopCount :WORD; //Count of times E-Stop is Activated +wTotalStallCount :WORD; //Count of Machine Stalls +wTotalDerateCount :WORD; //Count of De-rate Events +wTotalVoltageCount :WORD; //Count of Voltage Alarms + +wHarvPlcVoltCount : WORD; // Total count of Trailer PLC Voltage Alarms +wHarvDispVoltCount : WORD; // Total count of Trailer Display Voltage Alarms +wTracDispVoltCount : WORD; // Total count of Prime Mover Display Voltage Alarms + +wNode20VoltCount : WORD; // Total count of IO module Node 20 Module Voltage Alarm +wNode0VoltCount : WORD; // Total count of Engine Node 50 Voltage Alarm + + +END_VAR diff --git a/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.xml b/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.xml new file mode 100644 index 0000000..067701b --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.xml @@ -0,0 +1,173 @@ + + + + + + + True + + facaed1c-8cc9-4b97-81b6-7a7fcbbc7dc3 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + PersistentVars + + 261bd6e6-249c-4232-bb6f-84c2fbeef430 + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + + 0 + + + + + + + + 53 + + {attribute 'qualified_only'} + + + 54 + + VAR_GLOBAL PERSISTENT RETAIN + + + 55 + + + + + 56 + + wEngineOnCount :WORD; //Count OF times Engine is turned ON + + + 57 + + wGpsOnCount :WORD; //Count OF times GPS Activated + + + 58 + + wAutoOnCount :WORD; //Count OF times Auto Harvest Activated + + + 59 + + wFlightOnCount :WORD; //Count OF times Flight Converyor is turned ON + + + 60 + + wTotalMaintCount :WORD; //Count of times Maintenance Mode Activated + + + 61 + + wTotalFilterCount :WORD; //Count of Filter Bypass Alarms + + + 62 + + wTotalPressCount :WORD; //Count of Pressure Alarms + + + 63 + + wTotalTempCount :WORD; //Count OF Temperature Alarms + + + 64 + + wTotalEstopCount :WORD; //Count of times E-Stop is Activated + + + 65 + + wTotalStallCount :WORD; //Count of Machine Stalls + + + 66 + + wTotalDerateCount :WORD; //Count of De-rate Events + + + 67 + + wTotalVoltageCount :WORD; //Count of Voltage Alarms + + + 68 + + + + + 69 + + wHarvPlcVoltCount : WORD; // Total count of Trailer PLC Voltage Alarms + + + 70 + + wHarvDispVoltCount : WORD; // Total count of Trailer Display Voltage Alarms + + + 71 + + wTracDispVoltCount : WORD; // Total count of Prime Mover Display Voltage Alarms + + + 72 + + + + + 73 + + wNode20VoltCount : WORD; // Total count of IO module Node 20 Module Voltage Alarm + + + 74 + + wNode0VoltCount : WORD; // Total count of Engine Node 50 Voltage Alarm + + + 75 + + + + + 76 + + + + + 77 + + END_VAR + + + 1 + + + + + + + + 6b1b8188-8909-4566-85c8-3e49f9c6b06c + e359ed2f-25b4-4dea-b318-58fd4edee8c7 + 2 + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/SFC_TEST.xml b/GraphicalTesting/StandardPLC/application/SFC_TEST.xml new file mode 100644 index 0000000..dbd5c80 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/SFC_TEST.xml @@ -0,0 +1,1959 @@ + + + + + + + True + + d158652c-96fd-4ed4-aadb-28e74dfb74df + f9c00bdb-2f18-4e80-863e-6da977e8f304 + SFC_TEST + + + + 58548d2c-f1b1-453d-9195-66c94e2a3ae0 + + + + + + IecSfc + System + 3.4.2.0 + IecSfc + + + + CurrentStep + + + + False + True + + + + + + EnableLimit + + + + False + True + + + + + + Error + + + + False + True + + + + + + ErrorPOU + + + + False + True + + + + + + ErrorStep + + + + False + True + + + + + + Init + + + + False + True + + + + + + Pause + + + + False + True + + + + + + QuitError + + + + False + True + + + + + + Reset + + + + False + True + + + + + + Tip + + + + False + True + + + + + + TipMode + + + + False + True + + + + + + Trans + + + + False + True + + + + + + SFCErrorAnalyzation + + + + False + True + + + + + + SFCErrorAnalyzationTable + + + + False + True + + + + + False + True + + + + + 6f9dac99-8de1-4efc-8465-68ac443b7d08 + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + 74f81948-1328-492c-b6c1-a9ea72048b28 + + 0 + + + None + + + + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + CmdBarriers + + + 285 + 284 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 286 + 284 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 287 + 284 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 288 + 284 + + + e174fc0d-80b0-4a9e-a530-ca239c249a50 + + R + + + 289 + 284 + + + 284 + 5 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + WaitingForTrain + + + 6 + 5 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 7 + 5 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 8 + 5 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 9 + 5 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + TRUE + FALSE + + 10 + 5 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 11 + 5 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 12 + 5 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 13 + 5 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 14 + 5 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 15 + 5 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 16 + 5 + + + 5 + 4 + + + + + + + + + Branch0__WaitingForTrain__to__TrainFromAboveA + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + SensorA + + + 18 + 17 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 19 + 17 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 20 + 17 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 21 + 17 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 22 + 17 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 23 + 17 + + + 17 + 136 + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + CmdBarriers + + + 190 + 189 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 191 + 189 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 192 + 189 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 193 + 189 + + + e174fc0d-80b0-4a9e-a530-ca239c249a50 + + S + + + 194 + 189 + + + 189 + 170 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + TrainFromAboveA + + + 171 + 170 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 172 + 170 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 173 + 170 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 174 + 170 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + FALSE + FALSE + + 175 + 170 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 176 + 170 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 177 + 170 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 178 + 170 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 179 + 170 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 180 + 170 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 181 + 170 + + + 170 + 136 + + + TrainFromAboveA__to__TrainFromAboveB + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + SensorB + + + 183 + 182 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 184 + 182 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 185 + 182 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 186 + 182 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 187 + 182 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 188 + 182 + + + 182 + 136 + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + TrainFromAboveB + + + 202 + 201 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 203 + 201 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 204 + 201 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 205 + 201 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + FALSE + FALSE + + 206 + 201 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 207 + 201 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 208 + 201 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 209 + 201 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 210 + 201 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 211 + 201 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 212 + 201 + + + 201 + 136 + + + TrainFromAboveB__to__WaitingForTrain + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + Not SensorB + + + 252 + 251 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 253 + 251 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 254 + 251 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 255 + 251 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 256 + 251 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 257 + 251 + + + 251 + 136 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + + + + 137 + 136 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 138 + 136 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 139 + 136 + + + 136 + 131 + + + + + + Branch1__WaitingForTrain__to__TrainFromBelowB + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + SensorB + + + 145 + 144 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 146 + 144 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 147 + 144 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 148 + 144 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 149 + 144 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 150 + 144 + + + 144 + 140 + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + CmdBarriers + + + 196 + 195 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 197 + 195 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 198 + 195 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 199 + 195 + + + e174fc0d-80b0-4a9e-a530-ca239c249a50 + + S + + + 200 + 195 + + + 195 + 151 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + TrainFromBelowB + + + 152 + 151 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 153 + 151 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 154 + 151 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 155 + 151 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + FALSE + FALSE + + 156 + 151 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 157 + 151 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 158 + 151 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 159 + 151 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 160 + 151 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 161 + 151 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 162 + 151 + + + 151 + 140 + + + TrainFromBelowB__to__TrainFromBelowA + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + SensorA + + + 164 + 163 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 165 + 163 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 166 + 163 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 167 + 163 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 168 + 163 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 169 + 163 + + + 163 + 140 + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + TrainFromBelowA + + + 220 + 219 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 221 + 219 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 222 + 219 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 223 + 219 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + FALSE + FALSE + + 224 + 219 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 225 + 219 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 226 + 219 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 227 + 219 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 228 + 219 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 229 + 219 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 230 + 219 + + + 219 + 140 + + + TrainFromBelowA__to__WaitingForTrain + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + Not SensorA + + + 259 + 258 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 260 + 258 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 261 + 258 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 262 + 258 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 263 + 258 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 264 + 258 + + + 258 + 140 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + + + + 141 + 140 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 142 + 140 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 143 + 140 + + + 140 + 131 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + Branch0 + + + 132 + 131 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 133 + 131 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 134 + 131 + + + 23bdaa98-72ec-41f7-817b-9dede5697086 + + FALSE + FALSE + + 135 + 131 + + + 131 + 4 + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + WaitingForTrain + + + 25 + 24 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 26 + 24 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 27 + 24 + + + 24 + 4 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + + + + 28 + 4 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 29 + 4 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 30 + 4 + + + 4 + -1 + + + + + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + + + + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Common + 00000000-0000-0000-0000-000000000000 + Common properties + + + + + + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + + + + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Specific + 00000000-0000-0000-0000-000000000000 + Element type specific properties + + + + + + d336ba06-8a67-4e6b-9697-50f83ba6bdde + + + + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + d336ba06-8a67-4e6b-9697-50f83ba6bdde + Times + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Time definitions + + + + + + 20f0fc67-978d-438b-979f-eef294f34ee4 + + + + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 20f0fc67-978d-438b-979f-eef294f34ee4 + Actions + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Action definitions + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + Name + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Name of the element + + + + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + False + False + True + True + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 7d894980-aeea-405c-a0f6-e2b26429c58f + Comment + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Comment of the element + + + + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + + + False + True + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 01580b27-6378-448b-8ecb-0e4b795b58d6 + Exclude + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Exclude this element from build + + + + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + bc882c11-1e91-4dd8-a6b8-2075724ed18b + Symbol + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Symbol configuration setting of the element + + + + + + e174fc0d-80b0-4a9e-a530-ca239c249a50 + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + e174fc0d-80b0-4a9e-a530-ca239c249a50 + Qualifier + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Qualifier of the action association + + + + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + InitStep + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Sets the step as the initial step + + + + + + 23bdaa98-72ec-41f7-817b-9dede5697086 + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 23bdaa98-72ec-41f7-817b-9dede5697086 + Parallel + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Sets the branch to parallel mode + + + + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + cacf1a68-236e-47c2-b7b1-1cf9199718cb + MinTime + d336ba06-8a67-4e6b-9697-50f83ba6bdde + Defines the minimal active time for the step + + + + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + b693554c-1b01-4a8d-afde-9e3a46f7465d + MaxTime + d336ba06-8a67-4e6b-9697-50f83ba6bdde + Defines the maximal active time for the step + + + + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 62e1754b-7629-4e63-9cec-10ae0c536f1f + Duplication + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Copies the implementation of step actions or transitions with the element. Otherwise, copies the reference to an existing implementation. + + + + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 8294df19-5962-4dee-a874-1051dabb0e3e + Monitoring + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Indicates transition monitoring setting. + + + + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + MainAction + 20f0fc67-978d-438b-979f-eef294f34ee4 + Name of the action to be called if the step is active + + + + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + EntryAction + 20f0fc67-978d-438b-979f-eef294f34ee4 + Name of the action to be called if the step is activated + + + + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + ExitAction + 20f0fc67-978d-438b-979f-eef294f34ee4 + Name of the action to be called if the step is deactivated + + + + + + + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + + Unspecified + 0 + Unspecified + + + None + 1 + None + + + Read + 2 + Read + + + Write + 3 + Write + + + ReadWrite + 4 + ReadWrite + + + INT + + + + + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + + None + 0 + None + + + Variable + 1 + Variable + + + Call + 2 + Call + + + INT + + + + + + + + + + 290 + + PROGRAM SFC_TEST + + + 291 + + VAR + + + 292 + + SensorA: BOOL; + + + 293 + + SensorB: BOOL; + + + 294 + + END_VAR + + + + + 293 + Standard + + False + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml b/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml new file mode 100644 index 0000000..b06912d --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml @@ -0,0 +1,116 @@ + + + + + + + True + + b0d50df9-cc5b-471e-b9d7-197d5a99c3a1 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + TaskConfiguration + + ae1de277-a207-4a28-9efb-456c06bd52f3 + + 0 + + + + + + 829378c7-1267-51f4-b4ac-521cf1d5bbf9 + 53ecd2e5-d70a-5903-bab3-942f6b111150 + ccd76387-5c50-5aed-90e7-2fad510510da + 837ac483-1677-559b-8066-58eb0bf867af + 2fce9459-f52d-5106-a284-5b1ba25f58cc + 6aaa34df-c788-548e-a605-796ed125fcb6 + 1b7debd5-2424-518a-a4cc-7b6538957411 + 375b88a9-fec3-53fd-966d-d87dfdb105c1 + ccbac5e1-21c2-5fc1-94ff-1d5855925882 + f4c5c858-7cb3-5803-9646-9fe3082d62bc + d5cd0959-13c9-5347-b8d1-315299b2535a + ad2fce79-1837-5391-9e37-1df24b2f70d1 + 2433f898-297a-5757-be54-a58e387c5752 + 05fb4d09-8e96-5918-a033-9002184f3456 + 30434813-fff6-5bf5-92c8-9bd7c86e4f0c + + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + + 00000000-0000-0000-0000-000000000000 + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + False + + 12674cf4-d25e-4151-96b2-08a6cc6d1e52 + b0d50df9-cc5b-471e-b9d7-197d5a99c3a1 + Main + + 98a2708a-9b18-4f31-82ed-a1465b24fa2d + + 0 + + + Cyclic + + + + + + 1 + + True + + t#18ms + + + 1 + + + t#20ms + + + + + 30e3ab47-1b98-4397-9eff-c6534e91a6b8 + 205b1f78-9060-49b9-896f-0aab4eb3cc59 + + + PLC_PRG + + + + _CAN_MAIN + + + + False + True + -2 + + + b0d50df9-cc5b-471e-b9d7-197d5a99c3a1 + + StandardPLC + PLC Logic + Application + Task Configuration + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/Visualization.vis.xml b/GraphicalTesting/StandardPLC/application/Visualization.vis.xml new file mode 100644 index 0000000..a3f1774 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/Visualization.vis.xml @@ -0,0 +1,701 @@ + + + + + + + True + + 4b4a5658-f23b-461e-8789-077c2d695574 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + Visualization + + f18bec89-9fef-401d-9953-2f11739a6808 + + 0 + + + + 3 + + + + + + + + + + 571893170 + + + + 2340015797 + HCENTER + + + 2565699834 + VCENTER + + + 4134387352 + NONE + + + 1603690730 + Arial + + + 4253639993 + 12 + + + 2729990903 + 0 + + + 1213979116 + 0 + + + 3488306084 + 4278190080 + + + 1647042231 + <toggle/tap variable> + + + 2812299069 + 4294967295 + + + 494569607 + 4278190080 + + + 3719097617 + 0 + + + 1649127785 + 588 + + + 357335551 + 298 + + + 2422045748 + 173 + + + 2134141914 + 66 + + + 1651471674 + True + + + 2341735680 + + + -2830136 + Element-Control-Color + + + + + 438423234 + + + -2830136 + Element-Alarm-Fill-Color + + + + + 3729828405 + + + 0 + 0 + -16777216 + Font-Standard + Arial + 12 + + -16777216 + Element-Button-FontColor + + + + + + 550940142 + 674 + + + 1473355128 + 331 + + + 493260384 + 4294967295 + + + 135947015 + 4278190080 + + + 2678395525 + 1 + + + 2478807622 + + + + 390574330 + Button Test: %u + + + 2477733581 + FB_TESTING.uiCurrSupplyVolt + + + 2597686782 + False + + + 823443203 + 41 + + + + Button + VisuFbElemButton + True + GenElemInst_1 + + + + a09d6f25-75d3-406f-85ff-b0c3a82d3c7a + 4b4a5658-f23b-461e-8789-077c2d695574 + + + 0 + + + + + 16777215 + + + + + False + False + 16777215 + False + + + + + + + + HasVisibilityAccess + + + 1aeece20-f66b-4232-92b1-6b0c211bfce2 + + + + + ContainsPoint + + + 1d82e146-0648-4282-94b7-768e7e7c49e9 + + + + + SetStaticState + + + 363c4b16-c894-432a-bacf-a45ccaddd76d + + + + + HasInputAccessIntern + + + 3e744d84-41d5-441e-a784-1b949677b271 + + + + + GetTooltip + + + 4f997422-5590-47e7-9b38-442f2faa0669 + + + + + GetDialogInterfaceSize + + + 3669a9fb-fa11-4581-92c7-8707ce55d86b + + + + + GetUpdateRects + + + 773393dc-c275-4fc0-879b-549a1ed96a9b + + + + + GetNamespace + + + 92e810b7-5164-4790-9641-659fa39c63ab + + + + + SetResult + + + f3aa4726-0cc0-4dc7-a64d-5c32b369d481 + + + + + GetTranslator + + + 7de83b06-fce9-472f-a3c6-614f497cd929 + + + + + Update + + + ea9d9b95-97e3-48e3-8243-9c8728a70539 + + + + + SetClientData + + + 0203f412-21b6-4343-bdb3-4d2598857239 + + + + + GetInstance + + + b324b92d-4243-48db-937d-c757f6c03499 + + + + + GetClientData + + + 77cc945a-30b8-4f66-9b7d-67287152bb2e + + + + + Paint + + + a7d5b928-5347-44f8-8b23-57ab344b9c46 + + + + + GetElementArray + + + 1cf97d7b-e95b-4be9-81ff-608cf957c3a2 + + + + + SetVisuFlagsInternal + + + 7adbe719-186c-4f28-8785-df2aaf60fe99 + + + + + GetText + + + 8c01ba72-6544-4cb9-84bc-1511b61f8e4c + + + + + GetTextProperties + + + f26b17f5-e015-445d-adde-3d33e97282d2 + + + + + Initialize + + + c58a1782-de69-40e7-9260-fbde35b45a89 + + + + + HasInputAccess + + + fffb67fc-325c-43b8-a41d-f7cdbd041b94 + + + + + HasVisibilityAccessIntern + + + 6fafe75f-5fdd-4e3a-ab51-d6a143f30f09 + + + + + GetSurroundingRect + + + afd9b12e-ff3e-45fc-9fcc-a5834e89209b + + + + + ElementInfo + + + 0a7cf83c-ea63-4ee9-ab46-6325dac33359 + + + + + GetResult + + + 0643cb0a-05a4-4046-8727-d019c005bf73 + + + + + GetLocalUsergroup + + + 2e3d2538-65ee-441f-92bf-80aee35f11a9 + + + + + GetName + + + 58667e8c-78a7-4c46-a689-89fd256162b8 + + + + + GetSize + + + 17f62a3c-e229-43f1-826b-93ff0fe8ae50 + + + + + SetDialogInterface + + + 98c03eef-350f-42b5-a725-de6d62961789 + + + + + FB_Reinit + + + 1f281ec0-334b-402a-b528-2273ea613c6b + + + + + FB_Exit + + + 0acdf951-a900-443a-8eaa-6f57a5e87e85 + + + + + GetInitializeVersion + + + 08524235-3d94-447a-99cc-f1dbe8cc2c92 + + + + + GetDialogInterface + + + 2bc006af-0c56-4da4-aad7-ff287194d14d + + + + + GetElementIdArray + + + 58d3a8b6-c5d3-4fb5-97e4-2a04c9eee93a + + + + + HandleInput + + + a5bc28d7-237b-4fca-b6d1-2b2e1370e82c + + + + + Destruct + + + c09bf3e7-349a-464f-acba-2c42868697fa + + + + NotImportant + 4b4a5658-f23b-461e-8789-077c2d695574 + + 76437654-1af3-4cfe-b897-a74708d6748f + f07f42e9-60c6-4a38-82d4-481c179f8473 + 365103e2-e114-489d-bb45-c1aa2bf8852b + + + + + + FB_Init + + + 0c112ad0-8dae-4adf-ac75-21ab50cb33b9 + + + + + FB_Exit + + + 73e957e1-708a-4907-96a5-a87640cbfcc2 + + + + + FB_Reinit + + + 6e2b75a7-a548-4090-abe2-6719ea95caa6 + + + + NotImportant + 8a0a9ea4-59ac-43cf-bb80-52dd1c1887b3 + + 81cff34b-1afa-42fd-8c1b-b8dc48d7cacf + + + + + + + ExecuteLooseCapture + + + 79d86b52-77d7-481a-9f5a-d115bc1deaf7 + + + + + ExecuteMouseDblClick + + + 1eac03a0-f73a-4ac6-8e91-a4b6a2ea588b + + + + + ExecuteMouseDown + + + 8aad3597-d523-424d-9c50-d9761e6e8cd6 + + + + + ExecuteMouseUp + + + 82a70ba4-e42a-43b9-b5a1-8f90d344bc70 + + + + + GetElementInfo + + + a19dd937-2161-41b2-bcb6-ca7f78916aa2 + + + + + abstrGetDefaultCursor + + + 5da54c43-d856-44fe-98f4-505cc3f51209 + + + + + ExecuteDialogClosed + + + e555a48f-05be-44c3-b675-fbbbc6a8ceed + + + + + ExecuteKeyUp + + + 65dfb130-83ad-4919-a581-adb64a6b10ba + + + + + ExecuteKeyDown + + + 4d9d65e2-1b12-47df-9de9-1f89fe1943d1 + + + + + ExecuteMouseMove + + + 5c0eeb45-2da9-401c-ae4e-16b551d3864d + + + + + Initialize + + + cbf0a683-59bb-41e2-8ab8-5bb5c394134f + + + + + ExecuteMouseEnter + + + 61305a06-0aca-4ded-a988-82c973f2f406 + + + + + ExecuteMouseLeave + + + 0b618d96-4309-4e93-b923-dc736674b524 + + + + + ExecuteMouseClick + + + d5550a8e-69de-4fd5-bfc8-e2ee4b0122e7 + + + + NotImportant + 9feab06b-467b-4727-b6d4-2b94ff432618 + + 514ce764-adc8-4f8f-ab62-404ad5703e4f + + 1 + + + + 2 + + VAR_IN_OUT + + + 3 + + + + + 1 + + END_VAR + + + + False + + 4140216668 + 0 + 1 + + 481037385728 + 549755813887 + 481037385728 + 549754765312 + 1048576 + + + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + From 4d2709921836333d4b81cad6855e36a5bd8e992a Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 16:42:01 +1000 Subject: [PATCH 44/91] head a network with its title, and put the comment under it CODESYS keeps a network's title separately from its comment and draws the title above it, as the network's heading. The rendering had them the other way round: the comment on the number line and the title beneath it, which reads as though the comment were the heading and the title an afterthought. The title now goes on the number line and the comment below it, in the order the editor shows them. A network with no title still puts its comment on the number line rather than spending a line on an empty heading - most networks carry one or the other, not both, and that keeps the common case to a single greppable line. --- .../StandardPLC/application/LD_TEST.st.txt | 8 +++--- .../StandardPLC/application/LD_TEST.txt | 8 +++--- README.md | 2 +- src/ld_render.py | 28 +++++++++++-------- tools/ladder/tests/test_fbd.py | 20 +++++++------ 5 files changed, 36 insertions(+), 30 deletions(-) diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt index 8997fed..a7ff308 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt @@ -14,15 +14,15 @@ VAR PowerOff: BOOL; END_VAR -(* Network 1: comment without backslash *) -(* title: Try me Codesys I swear *) +(* Network 1: Try me Codesys I swear *) +(* comment without backslash *) IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF (* Network 2 *) (* JMP TestJump *) -(* Network 3: Comment *) -(* title: header text *) +(* Network 3: header text *) +(* Comment *) TON_0(IN := PowerOn, PT := T#5S); CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); IF CTU_0.Q THEN PowerOff := FALSE; END_IF diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt index 17522aa..58e25a3 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -9,8 +9,8 @@ VAR PowerOff: BOOL; END_VAR -(* Network 1: comment without backslash *) -(* title: Try me Codesys I swear *) +(* Network 1: Try me Codesys I swear *) +(* comment without backslash *) │ Sensor1 Sensor2 PowerOn ├──┬───┤ ├───┬───┤/├──────(S)─────┤ │ │ sensor3 │ @@ -20,8 +20,8 @@ END_VAR │ ├────>>TestJump────┤ -(* Network 3: Comment *) -(* title: header text *) +(* Network 3: header text *) +(* Comment *) │ TON_0 : TON CTU_0 : CTU │ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff ├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ diff --git a/README.md b/README.md index 6e31305..4f08ef5 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ A graphical action, transition or method is rendered from its own body, not from SFC and CFC POUs are not yet rendered; they export as native xml alone. -Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor. +Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor. Each is headed by its title, as the editor heads it, with the network's comment on the line below; a network with no title puts its comment on the number line instead. To render an exported PLCopen file by hand, to get plain ASCII instead of box drawing, or to see the equivalent Structured Text (which the export does not write, since showing each network twice in two notations reads worse than showing it once): diff --git a/src/ld_render.py b/src/ld_render.py index 294e088..5905fd8 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -40,21 +40,25 @@ def _one_line(text): def network_headers(number, network): - """The header lines above one network: its number, comment and title. - - CODESYS keeps a network's title separately from its comment and draws it - above one, so it gets a line of its own rather than being folded into the - comment - a network can carry either, both or neither, and the title is - often the only description there is. + """The header lines above one network: its number, title and comment. + + CODESYS keeps a network's title separately from its comment and draws the + title above it, as the network's heading. So the title goes on the number + line and the comment below it, in the order the editor shows them. A + network with no title puts its comment on the number line instead, rather + than spending a line on an empty heading - most networks have one or the + other, not both. """ - header = "(* Network " + str(number) comment = _one_line(network.comment or "").lstrip("/").strip() - if comment: - header += ": " + comment - lines = [header + " *)"] title = _one_line(getattr(network, "title", "") or "").lstrip("/").strip() - if title: - lines.append("(* title: " + title + " *)") + + header = "(* Network " + str(number) + heading = title or comment + if heading: + header += ": " + heading + lines = [header + " *)"] + if title and comment: + lines.append("(* " + comment + " *)") return lines diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index bbaa36c..84d3b18 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -112,14 +112,14 @@ def check_golden(name, rendered, golden_path): ) # A title is a second field, and can break the block comment just as a -# comment can. +# comment can. With no comment beside it, the title is the heading. hostile_title = "one *) two" check_equal( "network titles cannot break generated block comments", fbd_render.render_pou( Pou("HOSTILE", "program", networks=[Network("", [Signal("x")], title=hostile_title)]) - )[3], - "(* title: one * ) two *)", + )[2], + "(* Network 1: one * ) two *)", ) check("network 1 is a call", isinstance(tree1, Call)) @@ -524,17 +524,19 @@ def check_golden(name, rendered, golden_path): check_equal( "comment-only: the numbering follows the editor", [line for line in comment_only_art if line.startswith("(* Network")], - [ - "(* Network 1: Section header: E-STOP CHAIN (documentation-only network) *)", - "(* Network 2: second network comment *)", - ], + ["(* Network 1: Title of network one *)", "(* Network 2: Title of network two *)"], ) # The title is a second field CODESYS draws above the comment, and can be the # only description a network has. It was skipped with the rest of the -# vendorElements. +# vendorElements. It heads the network, so it goes on the number line and the +# comment follows it, in the order the editor shows them. check_equal("comment-only: titles are read", comment_only.networks[0].title, "Title of network one") -check("comment-only: titles are rendered", "(* title: Title of network one *)" in comment_only_art) +check_equal( + "comment-only: the title heads the network and the comment follows", + comment_only_art[comment_only_art.index("(* Network 1: Title of network one *)") + 1], + "(* Section header: E-STOP CHAIN (documentation-only network) *)", +) # --- language dispatch ----------------------------------------------------- From 5cd3f82a2d91ef699832c78463682d48a1a8f493 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 16:50:32 +1000 Subject: [PATCH 45/91] note where the template will be tracked once it is cleaned Geoff's item 4.2 keeps the template - it and the exported sources together regenerate the working project - but only after it is re-saved with the gateway and device addresses cleared. Today it still carries them, in the connection label "CR711S STD IP:
, SN:" interned in the project's shared string table. Opening and re-saving does not clear it; the communication settings have to be cleared first. The exception is written out and commented, so tracking the template is one line once the file is clean, rather than a decision to make again. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 7877a85..1169142 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ *.project *.opt *.~u +# The template is the exception: it and the exported sources together +# regenerate the working project, so it is worth tracking - but only once it +# has been re-saved with the gateway and device addresses cleared. It carries +# them in a connection label today, which is why it is still commented out. +# !GraphicalTesting_template_v1.project # Review correspondence. Kept beside the checkout, not in it. review/ From c3900b148f3219e81a0c67b8ed8f130af9abc00f Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 17:05:51 +1000 Subject: [PATCH 46/91] number the networks as the editor does, from the native export CODESYS leaves out of the PLCopen export every network that carries no elements. An out-commented one goes entirely - its comment with it - and so does an empty one. Numbering what survived 1..n drifted from what the editor shows: FB_TESTING has nine networks in CODESYS and the rendering had five, with nothing in the file to say so. A reviewer opening "Network 5" in the editor was reading a different network in the file. The native export written beside the rendering carries the whole list, and it is now the authority for the structure - how many networks there are, their order, and each one's comment, title and label. PLCopen supplies only the logic, and the two are joined by matching the networks that carry logic, in order. Networks the export left out keep their number and say why they have no diagram. This is deliberately not a position-for-position match over both lists, which is what the first attempt at this did. Matching position for position means guessing which side an absent network belongs to; matching only what both sides agree exists leaves nothing to guess, and it is what puts the ByeBye label back on the empty network that owns it instead of on the comment-only network after it. Where the two still cannot be reconciled the file says so at the top and falls back to export order. The committed GraphicalTesting rendering is now checked against the native list beside it, so a renderer change cannot rewrite those files unnoticed. --- .../StandardPLC/application/FB_TESTING.st.txt | 22 ++- .../StandardPLC/application/FB_TESTING.txt | 22 ++- .../StandardPLC/application/LD_TEST.st.txt | 1 + .../StandardPLC/application/LD_TEST.txt | 4 +- README.md | 9 + src/graphical_export.py | 43 ++++- src/ld_render.py | 9 + src/model.py | 9 +- src/native_networks.py | 181 ++++++++++++++++++ tools/ladder/tests/test_export.py | 65 +++++++ 10 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 src/native_networks.py diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt index dd8564c..aac8273 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt @@ -30,15 +30,29 @@ END_VAR fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15, eMode := ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY); uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; -(* Network 2: Comment only network *) +(* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) +(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) + +(* Network 3 *) (* label: ByeBye *) +(* empty network *) + +(* Network 4 *) +(* empty network *) + +(* Network 5: Comment only network *) +(* empty network *) + +(* Network 6: Comment only network with comment slash *) +(* empty network *) -(* Network 3: Comment only network with comment slash *) +(* Network 7: (* Comment only network with brackets * ) *) +(* empty network *) -(* Network 4: (* Comment only network with brackets * ) *) +(* Network 8 *) IF Test THEN (* JMP ByeBye *) END_IF -(* Network 5: comment for the sakes of comments *) +(* Network 9: comment for the sakes of comments *) fbenable(EN := Test, IN := Test, PT := T#5s); xTimerDone := fbenable.Q; xResult := fbenable.ENO; diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt index e8f6bbd..8172655 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt @@ -30,15 +30,29 @@ ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY──┤eMode │ uiOutVoltage├───> uiCurrSupplyVolt └─────────────────────────────────────────┘ -(* Network 2: Comment only network *) +(* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) +(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) + +(* Network 3 *) (* label: ByeBye *) +(* empty network *) + +(* Network 4 *) +(* empty network *) + +(* Network 5: Comment only network *) +(* empty network *) + +(* Network 6: Comment only network with comment slash *) +(* empty network *) -(* Network 3: Comment only network with comment slash *) +(* Network 7: (* Comment only network with brackets * ) *) +(* empty network *) -(* Network 4: (* Comment only network with brackets * ) *) +(* Network 8 *) Test───>> ByeBye -(* Network 5: comment for the sakes of comments *) +(* Network 9: comment for the sakes of comments *) fbenable : TON ┌──────────────┐ Test──┤EN ENO├─────────────────┬────> xResult diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt index a7ff308..e11dd16 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt @@ -29,3 +29,4 @@ IF CTU_0.Q THEN PowerOff := FALSE; END_IF (* Network 4 *) (* label: TestJump *) +(* empty network *) diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt index 58e25a3..2182346 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -30,5 +30,5 @@ END_VAR │ └──────────────────────┘ (* Network 4 *) -│ -├────TestJump:────┤ +(* label: TestJump *) +(* empty network *) diff --git a/README.md b/README.md index 4f08ef5..f7fe526 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,15 @@ SFC and CFC POUs are not yet rendered; they export as native xml alone. Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor. Each is headed by its title, as the editor heads it, with the network's comment on the line below; a network with no title puts its comment on the number line instead. +CODESYS leaves out of the PLCopen export every network that carries no elements: an out-commented one goes entirely, comment included, and so does an empty one. The numbering comes from the native xml written beside the rendering, which lists every network the editor shows, so those keep their number and say why they have no diagram: + +``` +(* Network 2: Safely power off PLC when ignition is lower than 5V *) +(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) +``` + +If the two cannot be lined up, the file says so at the top and falls back to numbering in export order, rather than showing numbers that quietly disagree with the editor. + To render an exported PLCopen file by hand, to get plain ASCII instead of box drawing, or to see the equivalent Structured Text (which the export does not write, since showing each network twice in two notations reads worse than showing it once): ``` diff --git a/src/graphical_export.py b/src/graphical_export.py index 023df0d..69c9735 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -31,6 +31,7 @@ import fbd_render import ld_render +import native_networks import parse_fbd import parse_ld import plcopen @@ -44,6 +45,14 @@ RENDERED_SUFFIX = ".txt" ST_SUFFIX = ".st.txt" +# Said in the file itself when the editor's numbering could not be recovered. +# Numbering silently adrift from the editor is how an off-by-one review +# happens; a reviewer has to be able to see the doubt in the file they read. +ALIGNMENT_WARNING = ( + "(* WARNING: could not line these networks up with the native export;" + " the numbering may not match the CODESYS editor *)" +) + # Stated in the file itself, not just in the docs. The ST reads like source # and sits next to real .st exports, so the one thing a reader must not # assume is that it can go back into CODESYS. @@ -68,6 +77,7 @@ "verbatim_declarations": 0, "fallback_declarations": 0, "members_missing": 0, + "alignment_failures": 0, } STATS = dict(EMPTY_STATS) @@ -117,6 +127,11 @@ def summary(): "members_missing" ] line += " member's own body; nothing was written for those - review their native xml." + if STATS["alignment_failures"]: + line += "\n NOTE: %d POU(s) could not be lined up with their native export;" % STATS[ + "alignment_failures" + ] + line += " those are numbered in export order and say so at the top." return line @@ -181,7 +196,7 @@ def _joined(blocks): return lines -def render_plcopen(plcopen_path, declaration_text=None, member_name=None): +def render_plcopen(plcopen_path, declaration_text=None, member_name=None, native_path=None): """(diagram lines, ST lines) for a PLCopen file. ([], []) if none apply. Two renderings of the same networks, for two files. They were written @@ -196,7 +211,9 @@ def render_plcopen(plcopen_path, declaration_text=None, member_name=None): and must never be fed back into CODESYS; the file says so at the top. ``member_name`` restricts the rendering to that sub-POU member's own - body; see _render_pous. + body; see _render_pous. ``native_path`` is the native export written + beside the rendering, read back for the editor's own network list; see + native_networks. """ started = time.time() pous = _render_pous(plcopen_path, member_name) @@ -204,14 +221,28 @@ def render_plcopen(plcopen_path, declaration_text=None, member_name=None): pous[0][0].declaration_text = declaration_text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") STATS["parse_seconds"] += time.time() - started + # The editor's own network list, where one is available. Only for a file + # holding a single renderable POU: the list belongs to one POU, and there + # is nothing in it to say which. + warnings = [] + if native_path is not None and len(pous) == 1: + native = native_networks.read_networks(native_path) + if native: + aligned = native_networks.align(native, pous[0][0].networks) + if aligned is None: + STATS["alignment_failures"] += 1 + warnings.append(ALIGNMENT_WARNING) + else: + pous[0][0].networks = aligned + # A member's export carries the parent's declaration, not its own, so # both renderings have to open by saying whose declaration they show. notes = [] for pou, _art_renderer in pous: + head = list(warnings) if getattr(pou, "member_of_parent", False): - notes.append([u"(* " + pou.name + u" - the declaration below is the parent POU's *)", u""]) - else: - notes.append([]) + head.append(u"(* " + pou.name + u" - the declaration below is the parent POU's *)") + notes.append(head + [u""] if head else []) started = time.time() drawn = [] @@ -313,7 +344,7 @@ def write_rendered_text(obj, base_path, member_name=None): # render_plcopen accounts for its own parse, draw and ST time. textual_declaration = getattr(getattr(obj, "textual_declaration", None), "text", None) - lines, st_lines = render_plcopen(temp_path, textual_declaration, member_name) + lines, st_lines = render_plcopen(temp_path, textual_declaration, member_name, base_path + ".xml") if not lines: if member_name is not None: # No file at all is the honest outcome: an absent rendering diff --git a/src/ld_render.py b/src/ld_render.py index 5905fd8..f9c75fe 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -59,6 +59,15 @@ def network_headers(number, network): lines = [header + " *)"] if title and comment: lines.append("(* " + comment + " *)") + label = _one_line(getattr(network, "label", "") or "").strip() + if label: + # CODESYS keeps the label on the network; PLCopen exports it as a + # loose element, so it is only known here when the native export has + # been read. + lines.append("(* label: " + label + " *)") + note = getattr(network, "note", None) + if note: + lines.append("(* " + note + " *)") return lines diff --git a/src/model.py b/src/model.py index 2482a33..0d7e28f 100644 --- a/src/model.py +++ b/src/model.py @@ -388,11 +388,18 @@ class Network(object): with the editor, which is what a reviewer compares against. """ - def __init__(self, comment="", outputs=None, title=""): + def __init__(self, comment="", outputs=None, title="", label="", note=None): self.comment = comment # CODESYS keeps a network's title separately from its comment, and # draws it above one. A network can carry either, both or neither. self.title = title + # The jump-target label CODESYS keeps on the network. Only filled in + # when the native export has been read, which is the only place it + # survives as a property of the network rather than a loose element. + self.label = label + # Why this network has no body: out-commented, or empty. Set only for + # networks the PLCopen export left out entirely. + self.note = note self.outputs = outputs if outputs is not None else [] def __repr__(self): diff --git a/src/native_networks.py b/src/native_networks.py new file mode 100644 index 0000000..f8b47d0 --- /dev/null +++ b/src/native_networks.py @@ -0,0 +1,181 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Number the rendered networks the way the CODESYS editor numbers them. + +The rendering is derived from a PLCopen export, but PLCopen is not what a +reviewer holds next to it - the native xml is, and the two disagree about +what a network is. CODESYS omits from PLCopen every network that carries no +elements: an out-commented one (Toggle Network Comment State) goes entirely, +comment included, and so does an empty one. Numbering what survives 1..n +therefore drifts from what the editor shows, silently, and a reviewer opening +"Network 5" in CODESYS reads different logic under "(* Network 5 *)". + +The native export, which codescribe writes immediately before rendering, +carries the full list: every network in editor order with its out-commented +flag, its comment, its title, its label and its items. That list is the +authority for the *structure* - how many networks there are, in what order, +and what each is called. PLCopen supplies only the logic, and the two are +joined by matching networks that carry logic, in order. + +That is the whole of the alignment, and it is deliberately not a positional +match over both lists. Matching position for position means guessing which +side an absent network belongs to, and getting it wrong silently; matching +only the networks that both sides agree exist leaves nothing to guess. + +The native format is proprietary and undocumented, so everything here is +best-effort: any surprise degrades to None and the caller falls back to +sequential numbering, saying so in the file. +""" + +import os + +import plcopen +import xmlbackend +from model import LABEL, Element, Label, Network + +# Said in the file itself, under the number the network occupies. A reviewer +# reading only the .txt has to learn that logic exists here without +# executing, which is the fact the silent drop hid. +NOTE_OUT_COMMENTED = "out-commented in CODESYS - does not execute; diagram not exported, see the native xml" +NOTE_EMPTY = "empty network" + + +class NativeNetwork(object): + """One network as the native export records it.""" + + def __init__(self, out_commented=False, empty=False, comment="", title="", label=""): + self.out_commented = out_commented + self.empty = empty + self.comment = comment + self.title = title + # The jump-target label CODESYS stores on the network itself. PLCopen + # exports it as a free-standing element instead, which is why the + # label is taken from here rather than from the parsed body. + self.label = label + + @property + def has_logic(self): + """True when this network has a body PLCopen would have exported.""" + return not self.out_commented and not self.empty + + def __repr__(self): + return "NativeNetwork(out_commented=%r, empty=%r, comment=%r, title=%r, label=%r)" % ( + self.out_commented, + self.empty, + self.comment, + self.title, + self.label, + ) + + +def read_networks(path): + """[NativeNetwork] in editor order, or None if the file yields none. + + Networks live under ; each entry carries its + flags as children. Entries without an OutCommented + flag are not networks and are skipped. None rather than [] on any + trouble: the caller treats None as "no authority available" and keeps the + sequential numbering. + """ + try: + if not os.path.exists(path): + return None + root = xmlbackend.parse(plcopen.read_document(path)) + except Exception: + return None + + networks = [] + for elem in root.iter(): + if plcopen.tag(elem) != "List2" or elem.get("Name") != "NetworkList": + continue + for net in elem: + out_commented = None + comment = "" + title = "" + label = "" + items_elem = None + for child in net: + tag_name, name = plcopen.tag(child), child.get("Name") + if tag_name == "Single" and name == "OutCommented": + out_commented = (child.text or "").strip() == "True" + elif tag_name == "Single" and name == "Comment": + comment = (child.text or "").strip() + elif tag_name == "Single" and name == "Title": + title = (child.text or "").strip() + elif tag_name == "Single" and name == "Label": + label = (child.text or "").strip() + elif tag_name == "List2" and name == "NetworkItems": + items_elem = child + if out_commented is None: + continue + # The .NET element wrapper has no __len__, so emptiness is probed + # by iterating. + empty = items_elem is None or not any(True for _ in items_elem) + networks.append( + NativeNetwork(out_commented=out_commented, empty=empty, comment=comment, title=title, label=label) + ) + return networks or None + + +def _is_label(tree): + """True for a parsed jump label, in either language's spelling. + + FBD parses one into a Label; LD parses it into an Element whose kind says + label. The two are different classes, and treating only one of them as a + label leaves the other counting as a network body. + """ + if isinstance(tree, Label): + return True + return isinstance(tree, Element) and tree.kind == LABEL + + +def _carries_logic(network): + """True for a parsed network that PLCopen exported a body for. + + A network the parser built from a comment element alone has no outputs, + and one holding only a jump label has nothing but labels - CODESYS keeps + a label on the network, so a label standing on its own is an artefact of + the export rather than a network. Neither is something the native list + has a body for, so neither takes part in the match. + """ + outputs = getattr(network, "outputs", []) + if not outputs: + return False + return not all(_is_label(tree) for tree in outputs) + + +def align(native, parsed): + """[Network] numbered as the editor numbers them, or None. + + One entry per native network, in editor order, carrying that network's + own comment, title and label. The networks that carry logic take their + bodies from the parsed list in order; the rest render as a placeholder + saying why they have none. + + None when the two disagree about how many networks carry logic. That + means an assumption broke - a body split in two, or a CODESYS that does + export out-commented networks after all - and misnumbering silently is + worse than saying so. + """ + if not native: + return None + + bodies = [network for network in parsed if _carries_logic(network)] + if len(bodies) != len([network for network in native if network.has_logic]): + return None + + aligned = [] + index = 0 + for entry in native: + outputs = [] + note = None + if entry.has_logic: + outputs = bodies[index].outputs + index += 1 + elif entry.out_commented: + note = NOTE_OUT_COMMENTED + else: + note = NOTE_EMPTY + aligned.append( + Network(comment=entry.comment, outputs=outputs, title=entry.title, label=entry.label, note=note) + ) + return aligned diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 4e5453c..0405105 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -387,6 +387,71 @@ def failing_open_utf8(path, mode): shutil.rmtree(workspace) +# --- the editor's own network numbering ------------------------------------- + +# CODESYS leaves out of the PLCopen export every network that carries no +# elements - an out-commented one goes entirely, comment included, and so does +# an empty one. Numbering what survives 1..n drifts from what the editor +# shows, so a reviewer opening "Network 5" in CODESYS reads different logic +# under "(* Network 5 *)". The native export beside the rendering carries the +# full list, and it is the authority for the structure. +import native_networks # noqa: E402 + +EXPORT = os.path.join(REPO, "GraphicalTesting") + + +def native_of(pou_path): + return native_networks.read_networks(pou_path) + + +fb_native = native_of(os.path.join(EXPORT, "StandardPLC", "application", "FB_TESTING.xml")) +check_equal("the native list has every network", len(fb_native), 9) +check("the out-commented network is in it", fb_native[1].out_commented) +check("and it keeps its comment", fb_native[1].comment.startswith("//Safely power off PLC")) +check_equal("the label is on the network that owns it", fb_native[2].label, "ByeBye") +check("an empty network is marked empty", fb_native[3].empty) +check_equal("a comment-only network keeps its comment", fb_native[4].comment, "Comment only network") + +# The committed rendering is the worked example, so it has to agree with the +# native list beside it - one numbered network per editor network, in order, +# each headed by its own title or comment. Without this a renderer change +# rewrites those files and nothing notices. +for device in ("SafetyPLC", "StandardPLC"): + folder = os.path.join(EXPORT, device, "application") + for name in sorted(os.listdir(folder)): + if not name.endswith(".xml"): + continue + native = native_of(os.path.join(folder, name)) + rendered = os.path.join(folder, name[: -len(".xml")] + ".txt") + if native is None or not os.path.exists(rendered): + continue + headers = [line for line in read(rendered).split("\n") if line.startswith("(* Network ")] + check_equal("committed " + name + ": one header per editor network", len(headers), len(native)) + for index, entry in enumerate(native): + heading = entry.title or entry.comment + expected = "(* Network " + str(index + 1) + if heading: + expected += ": " + heading.replace("*)", "* )").lstrip("/").strip() + check_equal( + "committed " + name + " network " + str(index + 1), + headers[index], + expected + " *)", + ) + + +# The alignment refuses rather than guesses. One body more on the parsed side +# than the native list accounts for means an assumption broke, and a silently +# misnumbered file is worse than one that says it could not tell. +class FakeNetwork(object): + def __init__(self, outputs): + self.outputs = outputs + + +spare = [FakeNetwork(["logic"]) for _ in range(len([n for n in fb_native if n.has_logic]) + 1)] +check("a body the native list cannot account for is refused", native_networks.align(fb_native, spare) is None) +check("and no native list at all is refused", native_networks.align(None, spare) is None) + + # --- the importer ignores the derived file --------------------------------- # This is the contract that keeps the round trip intact. import_directory_child From 7b10066d4e4b4b610216b33c878ea1229e14d1ca Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 17:15:16 +1000 Subject: [PATCH 47/91] track the template, with the controller address and serial taken out The template and the exported sources together regenerate the working project, which is why item 4.2 of the review keeps the template and drops only the project itself. Clearing the gateway in CODESYS is not enough to make it safe to track. The address and serial live in one connection label, "CR711S STD IP:
, SN:", interned in the project's shared string table - and that member is not rewritten on save. Opening the template, saving it, and comparing member by member leaves the string table byte-identical. So the label is replaced in the string table itself. The table is a flat pool of length-prefixed entries, and the replacement is the same 45 bytes long, so every entry after it keeps its offset. Verified end to end rather than by inspection: CODESYS opens the result, imports the sources, saves and exports, and every rendered file comes out identical to the one committed here. The container is a re-zip, so it is smaller than a CODESYS-written file - the members themselves are unchanged apart from that one label. --- .gitignore | 8 ++++---- GraphicalTesting_template_v1.project | Bin 0 -> 188291 bytes 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 GraphicalTesting_template_v1.project diff --git a/.gitignore b/.gitignore index 1169142..c76cea9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,10 @@ *.opt *.~u # The template is the exception: it and the exported sources together -# regenerate the working project, so it is worth tracking - but only once it -# has been re-saved with the gateway and device addresses cleared. It carries -# them in a connection label today, which is why it is still commented out. -# !GraphicalTesting_template_v1.project +# regenerate the working project, so it is tracked. Its connection label has +# had the controller address and serial taken out of it - see the commit that +# tracked it for why clearing the gateway in CODESYS is not enough. +!GraphicalTesting_template_v1.project # Review correspondence. Kept beside the checkout, not in it. review/ diff --git a/GraphicalTesting_template_v1.project b/GraphicalTesting_template_v1.project new file mode 100644 index 0000000000000000000000000000000000000000..94935d81d802e818306fa98101591b7961fcf5ab GIT binary patch literal 188291 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%0AU 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

3+~vxI006S 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 z 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 R2K)&JZqt5j0kT)B{{<%~_b31W literal 0 HcmV?d00001 From 6d61217979214f6cbd37e6a3318a3927b7028032 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 17:30:21 +1000 Subject: [PATCH 48/91] draw a value feeding a side pin outside the box, not in it An LD block wrote the value feeding each side pin inside the box, as "PT := T#5S" alongside the pin names, where it reads as part of the pin name rather than as something arriving on a wire. It widened the box by the length of every value in it, and it was the last place the two languages disagreed - the FBD renderer has always drawn its inputs to the left. Values now sit to the left of the box on a wire into the pin, and a pin fed that way breaks the box wall like any other wired pin. LD_TEST network 3 goes from | |PT := T#5S ET| |RESET := PowerOff CV| to | T#5S--|PT ET| PowerOff--|RESET CV| The one value still written inline is a store on the row the rung leaves by, which cannot share that row with the wire to the right rail. The flag tests move off the live GraphicalTesting project and on to a fixture. Switching a network back on in CODESYS - which is what happened to network 2 while this was being written - should not fail a test about reading flags. --- .../StandardPLC/application/FB_TESTING.st.txt | 3 +- .../StandardPLC/application/FB_TESTING.txt | 11 +++- .../StandardPLC/application/LD_TEST.txt | 12 ++-- README.md | 15 ++--- src/ld_render.py | 39 ++++++++++--- .../fixtures/codesys/LDTesting.expected.txt | 13 ++--- .../ladder/tests/fixtures/native_networks.xml | 58 +++++++++++++++++++ tools/ladder/tests/test_export.py | 34 ++++++++--- tools/ladder/tests/test_ladder.py | 21 ++++++- 9 files changed, 163 insertions(+), 43 deletions(-) create mode 100644 tools/ladder/tests/fixtures/native_networks.xml diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt index aac8273..a3bec41 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt @@ -31,7 +31,8 @@ fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15, eMode := ifmIO uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) -(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) +TOF_0(IN := uiCurrSupplyVolt > uiMinVoltage, PT := T#5S); +fbSupplySwitch(eMode := ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH, xValue := TOF_0.Q); (* Network 3 *) (* label: ByeBye *) diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt index 8172655..b76eb87 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt @@ -31,7 +31,16 @@ ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY──┤eMode └─────────────────────────────────────────┘ (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) -(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) + fbSupplySwitch : ifmIOcommon.SupplySwitch + ┌─────────────────────────────────────────┐ +ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH──┤eMode xError│ + GT TOF_0 : TOF │ │ + ┌──────────┐ ┌───────────┐ │ │ +uiCurrSupplyVolt──┤In1 Out1├──┤IN Q├─────┤xValue eDiagInfo│ +uiMinVoltage──────┤In2 │ │ │ │ xPrepared│ + └──────────┘ │ │ └─────────────────────────────────────────┘ +T#5S────────────────────────────┤PT ET│ + └───────────┘ (* Network 3 *) (* label: ByeBye *) diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt index 2182346..2ae5caa 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -22,12 +22,12 @@ END_VAR (* Network 3: header text *) (* Comment *) -│ TON_0 : TON CTU_0 : CTU -│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff -├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ -│ │PT := T#5S ET│ │RESET := PowerOff CV│ -│ └───────────────┘ │PV := 10 │ -│ └──────────────────────┘ +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff +├─────┤ ├──────────┤IN Q├────────────┤CU Q├────(R)──────┤ +│ T#5S──┤PT ET│ PowerOff──┤RESET CV│ +│ └───────────┘ 10────────┤PV │ +│ └───────────┘ (* Network 4 *) (* label: TestJump *) diff --git a/README.md b/README.md index f7fe526..926ee00 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,14 @@ Actions and Transitions export as `.st` with the kind encoded in the filename (` Ladder and Function Block Diagram POUs have no textual implementation, so they export as native xml that git can store but nobody can review. Alongside that xml, CODESCRIBE writes a `.txt` holding the declaration and a diagram of each network: ``` -(* Network 2 *) -│ TON_0 : TON CTU_0 : CTU -│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff -├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ -│ │PT := T#5S ET│ │RESET := PowerOff CV│ -│ └───────────────┘ │PV := 10 │ -│ └──────────────────────┘ +(* Network 2: header text *) +(* Comment *) +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff +├─────┤ ├──────────┤IN Q├────────────┤CU Q├────(R)──────┤ +│ T#5S──┤PT ET│ PowerOff──┤RESET CV│ +│ └───────────┘ 10────────┤PV │ +│ └───────────┘ ``` The declaration is copied from the original CODESYS declaration source, preserving comments, pragmas, safety-qualified types, and literal spelling. The diagram is derived from PLCopen XML. On older CODESYS versions where the plaintext declaration is unavailable, the declaration is rebuilt from the structured interface and the export summary warns that comments, pragmas, or exact formatting may be missing. diff --git a/src/ld_render.py b/src/ld_render.py index f9c75fe..6963faa 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -159,15 +159,18 @@ def _render_block(element): """ chars = charset.active() + # The value feeding a side pin is drawn to the left of the box, on a wire + # into the pin, the way the editor draws it and the way the FBD renderer + # already does. Written inside as "PT := T#5S" it reads as part of the pin + # name, and it widens the box by the length of every value in it. left = [] wired = [] + values = [] for pin, label in element.input_pins: - text = pin or "?" + left.append(pin or "?") # A label of None is the power pin - it is wired, not parameterised. - if label is not None: - text += " := " + label if label else "" - left.append(text) wired.append(label is None) + values.append("" if label is None else (label or "")) # A store written on an output pin hangs off that pin on a wire of its # own, as CODESYS draws it. Writing it inside the box put the target @@ -196,17 +199,35 @@ def _render_block(element): rows = max(len(left), len(right), 1) left += [""] * (rows - len(left)) wired += [False] * (rows - len(wired)) + values += [""] * (rows - len(values)) right += [""] * (rows - len(right)) tails += [""] * (rows - len(tails)) title = element.title inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)]) - lines = [centred(title, inner + 2)] - lines.append(chars["TL"] + chars["H"] * inner + chars["TR"]) + # Two columns to the left of the box: the widest value, then a short wire + # into the pin. The power pin's row is all wire - the rung feeds that one. + lead = max([len(value) for value in values] + [0]) + lead = lead + 2 if lead else 0 + + def feed(index): + if not lead: + return "" + if wired[index]: + return chars["H"] * lead + value = values[index] + if not value: + return " " * lead + return value + chars["H"] * (lead - len(value)) + + lines = [" " * lead + centred(title, inner + 2)] + lines.append(" " * lead + chars["TL"] + chars["H"] * inner + chars["TR"]) for index in range(rows): gap = inner - len(left[index]) - len(right[index]) - left_edge = chars["PIN_L"] if wired[index] else chars["V"] + # A pin fed from the left breaks the wall, whether the rung feeds it + # or a value does. A pin with nothing on it leaves the wall unbroken. + left_edge = chars["PIN_L"] if (wired[index] or values[index]) else chars["V"] if wired[index] and element.power_edge in EDGE_MARKER: # The P or N on the power pin, drawn on the box wall in the same # place the bubble goes and the same letter a contact carries. @@ -220,8 +241,8 @@ def _render_block(element): right_edge = chars["PIN_R"] if (onward or tails[index]) else chars["V"] if onward and element.active_output in element.negated_outputs: right_edge = "o" - lines.append(left_edge + left[index] + " " * gap + right[index] + right_edge + tails[index]) - lines.append(chars["BL"] + chars["H"] * inner + chars["BR"]) + lines.append(feed(index) + left_edge + left[index] + " " * gap + right[index] + right_edge + tails[index]) + lines.append(" " * lead + chars["BL"] + chars["H"] * inner + chars["BR"]) # Row 0 is the title and row 1 the top border, so the first pin is row 2. connect_row = 2 diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt index bc8a4ba..25694cf 100644 --- a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt @@ -16,10 +16,9 @@ END_VAR │ └───┤ ├───┘ (* Network 2 *) -│ TON_0 : TON CTU_0 : CTU -│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff -├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ -│ │PT := T#5S ET│ │RESET := PowerOff CV│ -│ └───────────────┘ │PV := 10 │ -│ └──────────────────────┘ - +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff +├─────┤ ├──────────┤IN Q├────────────┤CU Q├────(R)──────┤ +│ T#5S──┤PT ET│ PowerOff──┤RESET CV│ +│ └───────────┘ 10────────┤PV │ +│ └───────────┘ diff --git a/tools/ladder/tests/fixtures/native_networks.xml b/tools/ladder/tests/fixtures/native_networks.xml new file mode 100644 index 0000000..d7b7517 --- /dev/null +++ b/tools/ladder/tests/fixtures/native_networks.xml @@ -0,0 +1,58 @@ + + + + + + // counts the pulses + Pulse counter + + False + + + CTU + + + + + // switched off while we test the interlock + + + True + + + TON + + + + + + + RETRY + False + + + + SECTION: shutdown + + + False + + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 0405105..a346e73 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -404,13 +404,20 @@ def native_of(pou_path): return native_networks.read_networks(pou_path) -fb_native = native_of(os.path.join(EXPORT, "StandardPLC", "application", "FB_TESTING.xml")) -check_equal("the native list has every network", len(fb_native), 9) -check("the out-commented network is in it", fb_native[1].out_commented) -check("and it keeps its comment", fb_native[1].comment.startswith("//Safely power off PLC")) -check_equal("the label is on the network that owns it", fb_native[2].label, "ByeBye") -check("an empty network is marked empty", fb_native[3].empty) -check_equal("a comment-only network keeps its comment", fb_native[4].comment, "Comment only network") +# Pinned to a fixture, not to the real project: a network switched back on in +# CODESYS should not fail a test about reading flags. +sample = native_of(os.path.join(HERE, "fixtures", "native_networks.xml")) +check_equal("every network in the list is read", len(sample), 4) +check("a network with a body is neither out-commented nor empty", sample[0].has_logic) +check_equal("its title is read", sample[0].title, "Pulse counter") +check_equal("its comment is read", sample[0].comment, "// counts the pulses") +check("an out-commented network is marked", sample[1].out_commented) +check("and it keeps the comment PLCopen throws away", sample[1].comment.startswith("// switched off")) +check("an out-commented network has no body to match", not sample[1].has_logic) +check("an empty network is marked empty", sample[2].empty) +check_equal("the label is on the network that owns it", sample[2].label, "RETRY") +check_equal("a comment-only network keeps its comment", sample[3].comment, "SECTION: shutdown") +check_equal("only the network with a body takes part in the match", len([n for n in sample if n.has_logic]), 1) # The committed rendering is the worked example, so it has to agree with the # native list beside it - one numbered network per editor network, in order, @@ -447,10 +454,19 @@ def __init__(self, outputs): self.outputs = outputs -spare = [FakeNetwork(["logic"]) for _ in range(len([n for n in fb_native if n.has_logic]) + 1)] -check("a body the native list cannot account for is refused", native_networks.align(fb_native, spare) is None) +spare = [FakeNetwork(["logic"]) for _ in range(2)] +check("a body the native list cannot account for is refused", native_networks.align(sample, spare) is None) check("and no native list at all is refused", native_networks.align(None, spare) is None) +# The right number of bodies lines up, and the networks without one keep their +# number and say why they have no diagram. +aligned = native_networks.align(sample, [FakeNetwork(["logic"])]) +check_equal("one entry per network the editor shows", len(aligned), 4) +check_equal("the body lands on the network that has one", aligned[0].outputs, ["logic"]) +check_equal("an out-commented network says it does not execute", aligned[1].note, native_networks.NOTE_OUT_COMMENTED) +check_equal("an empty one says it is empty", aligned[2].note, native_networks.NOTE_EMPTY) +check_equal("and carries its label", aligned[2].label, "RETRY") + # --- the importer ignores the derived file --------------------------------- diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 8ef06fe..b5560ba 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -215,7 +215,10 @@ def check_golden(name, rendered_lines, golden_path): # model.Signal's docstring warns that dropping negated inverts the logic; the # LD block-pin path did exactly that. check("fidelity: negated pin keeps its NOT in ST", any("RESET := NOT xManual" in line for line in fidelity_st)) -check("fidelity: negated pin keeps its NOT in the box", any("RESET := NOT xManual" in line for line in fidelity_art)) +check( + "fidelity: negated pin keeps its NOT beside the box", + any("NOT xManual" + U["H"] * 2 + U["PIN_L"] + "RESET" in line for line in fidelity_art), +) # An assignment on a block output pin executes every scan; the diagram drew it # but the ST - the half reviewers are told to trust - left it out. @@ -242,7 +245,10 @@ def check_golden(name, rendered_lines, golden_path): # A negated output consumed through a SIDE PIN goes via expr_to_text, a # different path from the power flow - it must keep the NOT too. check("fidelity: negated output survives into a side pin", any("RESET := NOT tmrA.Q" in line for line in fidelity_st)) -check("fidelity: side pin caption matches the ST", any("RESET := NOT tmrA.Q" in line for line in fidelity_art)) +check( + "fidelity: the side pin names what the ST names", + any("NOT tmrA.Q" + U["H"] * 2 + U["PIN_L"] + "RESET" in line for line in fidelity_art), +) # The chain feeding a box on a side pin is the BOX's input, not a term of the # pin's condition. Folding it in ("xB AND NOT tmrA.Q") says the counter also @@ -270,7 +276,10 @@ def check_golden(name, rendered_lines, golden_path): check_equal("side pin: one rung", len(side_pin_pou.rungs), 1) check("side pin: the latch is called", any("latch(SET1 := xSet, RESET := xClear);" in line for line in side_pin_st)) check("side pin: the pin reads only the latch output", any("RESET := latch.Q1" in line for line in side_pin_st)) -check("side pin: the caption matches the ST", any("RESET := latch.Q1" in line for line in side_pin_art)) +check( + "side pin: the pin names what the ST names", + any("latch.Q1" + U["H"] * 2 + U["PIN_L"] + "RESET" in line for line in side_pin_art), +) check("side pin: the latch box is drawn", any("latch : SR" in line for line in side_pin_art)) check("side pin: no chain folded into the pin", not any("xSet AND" in line for line in side_pin_st + side_pin_art)) @@ -304,6 +313,12 @@ def check_golden(name, rendered_lines, golden_path): check("two coils: the outVariable stores the other pin", "tElapsed := tmr.ET;" in two_coils_st) check("two coils: a later output names the box", any("[tmr.ET]" in line for line in two_coils_art)) +# A value feeding a side pin is drawn to the left of the box on a wire into +# the pin, the way the editor draws it. Written inside as "PT := T#2S" it +# reads as part of the pin name, and widens the box by every value in it. +check("two coils: the pin value sits outside the box", any("T#2S" + U["H"] * 2 + U["PIN_L"] + "PT" in line for line in two_coils_art)) +check("two coils: no value is left inside a box", not any(" := " in line for line in two_coils_art)) + # The same shape in a real SP11 export: LDTesting with one coil added to its # first network. SP11_TWO_COILS = os.path.join(FIXTURES, "36-3-ld-two-coils-sp11.xml") From 80cd6e7c356f37555e23a25e076bcfbfbd86e680 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 17:40:17 +1000 Subject: [PATCH 49/91] write a jump label the way ST writes one A jump target was rendered "(* label: ByeBye *)" - inside the delimiters this file uses for comments, which is the one thing a label is not. It is program structure: something jumps to it, and removing it changes what runs. It now reads "ByeBye:" everywhere, which is how ST spells a label and how the ladder renderer already drew one on a rung. The four places that disagreed - the FBD diagram, the aligned network header, and both halves of the ST emitter - now agree. --- .../StandardPLC/application/FB_TESTING.st.txt | 5 ++--- .../StandardPLC/application/FB_TESTING.txt | 13 ++----------- .../StandardPLC/application/LD_TEST.st.txt | 2 +- .../StandardPLC/application/LD_TEST.txt | 2 +- src/fbd_render.py | 5 ++++- src/ld_render.py | 5 +++-- src/st_render.py | 4 ++-- tools/ladder/tests/test_fbd.py | 3 ++- tools/ladder/tests/test_ladder.py | 5 ++++- 9 files changed, 21 insertions(+), 23 deletions(-) diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt index a3bec41..013b18e 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt @@ -31,11 +31,10 @@ fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15, eMode := ifmIO uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) -TOF_0(IN := uiCurrSupplyVolt > uiMinVoltage, PT := T#5S); -fbSupplySwitch(eMode := ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH, xValue := TOF_0.Q); +(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) (* Network 3 *) -(* label: ByeBye *) +ByeBye: (* empty network *) (* Network 4 *) diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt index b76eb87..3cc5fd1 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt @@ -31,19 +31,10 @@ ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY──┤eMode └─────────────────────────────────────────┘ (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) - fbSupplySwitch : ifmIOcommon.SupplySwitch - ┌─────────────────────────────────────────┐ -ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH──┤eMode xError│ - GT TOF_0 : TOF │ │ - ┌──────────┐ ┌───────────┐ │ │ -uiCurrSupplyVolt──┤In1 Out1├──┤IN Q├─────┤xValue eDiagInfo│ -uiMinVoltage──────┤In2 │ │ │ │ xPrepared│ - └──────────┘ │ │ └─────────────────────────────────────────┘ -T#5S────────────────────────────┤PT ET│ - └───────────┘ +(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) (* Network 3 *) -(* label: ByeBye *) +ByeBye: (* empty network *) (* Network 4 *) diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt index e11dd16..5378dd0 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt @@ -28,5 +28,5 @@ CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); IF CTU_0.Q THEN PowerOff := FALSE; END_IF (* Network 4 *) -(* label: TestJump *) +TestJump: (* empty network *) diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt index 2ae5caa..0266108 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -30,5 +30,5 @@ END_VAR │ └───────────┘ (* Network 4 *) -(* label: TestJump *) +TestJump: (* empty network *) diff --git a/src/fbd_render.py b/src/fbd_render.py index 659fad0..03d9c55 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -20,7 +20,10 @@ def _render_signal(node): def _render_label(node): - return Block(["(* label: " + node.name + " *)"], 0) + # "NAME:" is how a label is written in ST, and how the ladder renderer + # already draws one. Inside (* *) it reads as documentation, which is the + # one thing a jump target is not. + return Block([node.name + ":"], 0) def _render_jump(node, drawn, subs=None): diff --git a/src/ld_render.py b/src/ld_render.py index 6963faa..4535e23 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -63,8 +63,9 @@ def network_headers(number, network): if label: # CODESYS keeps the label on the network; PLCopen exports it as a # loose element, so it is only known here when the native export has - # been read. - lines.append("(* label: " + label + " *)") + # been read. Written as ST writes it - a jump target is program + # structure, and inside (* *) it would read as a comment. + lines.append(label + ":") note = getattr(network, "note", None) if note: lines.append("(* " + note + " *)") diff --git a/src/st_render.py b/src/st_render.py index fa72333..5af5c56 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -114,7 +114,7 @@ def rung_to_statements(rung): else: statements.append("(* JMP %s *)" % target) elif isinstance(item, Element) and item.kind == LABEL: - statements.append("(* label: %s *)" % (item.label or "?")) + statements.append("%s:" % (item.label or "?")) else: text = expr_to_text(item) if text: @@ -219,7 +219,7 @@ def _fbd_value(node, statements, emitted=None): return node.text if isinstance(node, Label): - statements.append("(* label: %s *)" % node.name) + statements.append("%s:" % node.name) return "" if isinstance(node, Jump): diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 84d3b18..bb7913c 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -241,7 +241,8 @@ def check_golden(name, rendered, golden_path): check("flow: the guard network is not dropped", any("JMP END" in line for line in flow_st)) check("flow: the jump condition is kept", any("Mode.Current = Mode.ESTOP" in line for line in flow_st)) check("flow: the jump target is drawn", any(">> END" in line for line in flow_art)) -check("flow: the label is shown", any("(* label: END *)" in line for line in flow_st)) +check("flow: the label is shown", "END:" in flow_st) +check("flow: the label is not dressed as a comment", not any("(* label" in line for line in flow_st + flow_art)) # negated="true" on an inVariable inverts the logic if it is ignored. guard = flow.networks[0].outputs[0] diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index b5560ba..0de3839 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -210,7 +210,10 @@ def check_golden(name, rendered_lines, golden_path): check("fidelity: jump target is drawn", any(">>SKIP" in line for line in fidelity_art)) check("fidelity: guarded jump reaches ST", any("IF xGo THEN (* JMP SKIP *) END_IF" in line for line in fidelity_st)) check("fidelity: label is drawn", any("SKIP:" in line for line in fidelity_art)) -check("fidelity: label reaches ST", any("(* label: SKIP *)" in line for line in fidelity_st)) +# A jump target is program structure, not documentation: it is written the +# way ST writes it, and not inside the delimiters this file uses for comments. +check("fidelity: label reaches ST", "SKIP:" in fidelity_st) +check("fidelity: the label is not dressed as a comment", not any("(* label" in line for line in fidelity_st + fidelity_art)) # model.Signal's docstring warns that dropping negated inverts the logic; the # LD block-pin path did exactly that. From abb92105e5c8abbc74aefdfcf234215531166b4f Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 19:57:16 +1000 Subject: [PATCH 50/91] write a jump the way ST writes one, like the label it targets "(* JMP ByeBye *)" left the jump reading as a note about the program while the label it targets had just become program text. A jump decides what runs next; CODESYS ST spells it "JMP ByeBye;" and a return "RETURN;", so that is what both now say, guard and all: IF Test THEN JMP ByeBye; END_IF A return reaches the FBD emitter as a jump whose target is "RETURN". That is a reserved word, so no label can be named it and the two cannot be confused. The diagrams are unchanged: ">>ByeBye" and "" are how the editor draws a jump, and neither ever looked like a comment. --- .../StandardPLC/application/FB_TESTING.st.txt | 2 +- .../StandardPLC/application/LD_TEST.st.txt | 2 +- src/st_render.py | 17 +++++++++++------ tools/ladder/tests/test_ladder.py | 5 ++++- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt index 013b18e..026f3fc 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt @@ -50,7 +50,7 @@ ByeBye: (* empty network *) (* Network 8 *) -IF Test THEN (* JMP ByeBye *) END_IF +IF Test THEN JMP ByeBye; END_IF (* Network 9: comment for the sakes of comments *) fbenable(EN := Test, IN := Test, PT := T#5s); diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt index 5378dd0..b8a3630 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt @@ -19,7 +19,7 @@ END_VAR IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF (* Network 2 *) -(* JMP TestJump *) +JMP TestJump; (* Network 3: header text *) (* Comment *) diff --git a/src/st_render.py b/src/st_render.py index 5af5c56..517ab24 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -107,12 +107,14 @@ def rung_to_statements(rung): statements.append(store_statement(item.label, condition, item.storage, item.negated)) elif isinstance(item, Element) and item.kind in (JUMP, RETURN): # A jump ends the rung; its guard is the rung condition so far. - # Same comment form as the FBD path, so both grep alike. - target = (item.label or "?") if item.kind == JUMP else "RETURN" + # Written as CODESYS ST writes it, like the label it targets: + # inside (* *) it would read as a note about the program rather + # than as the thing that decides what runs next. + statement = ("JMP " + (item.label or "?") + ";") if item.kind == JUMP else "RETURN;" if condition: - statements.append("IF %s THEN (* JMP %s *) END_IF" % (condition, target)) + statements.append("IF %s THEN %s END_IF" % (condition, statement)) else: - statements.append("(* JMP %s *)" % target) + statements.append(statement) elif isinstance(item, Element) and item.kind == LABEL: statements.append("%s:" % (item.label or "?")) else: @@ -224,10 +226,13 @@ def _fbd_value(node, statements, emitted=None): if isinstance(node, Jump): condition = _fbd_value(node.condition, statements, emitted) + # A return arrives here as a jump to "RETURN"; RETURN is a reserved + # word, so no label can be called that and the two cannot be confused. + statement = "RETURN;" if node.target == "RETURN" else ("JMP " + (node.target or "?") + ";") if condition: - statements.append("IF %s THEN (* JMP %s *) END_IF" % (condition, node.target)) + statements.append("IF %s THEN %s END_IF" % (condition, statement)) else: - statements.append("(* JMP %s *)" % node.target) + statements.append(statement) return "" if isinstance(node, Assign): diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 0de3839..694c236 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -208,7 +208,10 @@ def check_golden(name, rendered_lines, golden_path): # A jump's target lives in a "label" attribute; losing it drew ">>?" and # emitted no ST for the whole rung, guard included. check("fidelity: jump target is drawn", any(">>SKIP" in line for line in fidelity_art)) -check("fidelity: guarded jump reaches ST", any("IF xGo THEN (* JMP SKIP *) END_IF" in line for line in fidelity_st)) +# A jump decides what runs next, so it is written as CODESYS ST writes it, +# like the label it targets - not as a note about the program. +check("fidelity: guarded jump reaches ST", "IF xGo THEN JMP SKIP; END_IF" in fidelity_st) +check("fidelity: no jump is dressed as a comment", not any("(* JMP" in line for line in fidelity_st)) check("fidelity: label is drawn", any("SKIP:" in line for line in fidelity_art)) # A jump target is program structure, not documentation: it is written the # way ST writes it, and not inside the delimiters this file uses for comments. From 9f55fca1367b7791b2cf0d8c2cecb7d71cc0735f Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 20:14:54 +1000 Subject: [PATCH 51/91] take the ST rendering off the export path, keep it as a script The export writes the diagram again and nothing else. The ST emitter stays where it is - st_render is unchanged, and tools/ladder/render.py still prints it - and tools/ladder/write_st.py writes the ".st.txt" files on demand from a PLCopen export. That script's docstring is the instruction for putting it back on the export path: the three edits it takes in graphical_export, and why the suffix has to stay ".st.txt" rather than ".st" - import_from_files dispatches on ".xml" and ".st", and splitext sees ".txt" for that one, so it is ignored by construction the way the diagram is. The three committed .st.txt files go with it. The summary line drops its ST timing, which was 0.1s of 1.1s. --- .../FB_TESTING.PleaseIhaveKids.st.txt | 32 ------ .../StandardPLC/application/FB_TESTING.st.txt | 59 ---------- .../StandardPLC/application/LD_TEST.st.txt | 32 ------ README.md | 15 +-- src/graphical_export.py | 49 ++------ tools/ladder/tests/test_export.py | 39 +------ tools/ladder/write_st.py | 107 ++++++++++++++++++ 7 files changed, 122 insertions(+), 211 deletions(-) delete mode 100644 GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.st.txt delete mode 100644 GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt delete mode 100644 GraphicalTesting/StandardPLC/application/LD_TEST.st.txt create mode 100644 tools/ladder/write_st.py diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.st.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.st.txt deleted file mode 100644 index c3a9475..0000000 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.st.txt +++ /dev/null @@ -1,32 +0,0 @@ -(* Equivalent Structured Text for a graphical POU, written by codescribe. - READ ONLY. This is a rendering of the native xml beside it, not a - translation: it is not guaranteed to compile and must never be imported - or pasted back into CODESYS. The native xml is the source. *) - -(* FB_TESTING.PleaseIhaveKids - the declaration below is the parent POU's *) - -PROGRAM FB_TESTING -(*********************************************************************************************** -Object Name : PLC_SUPPLY -Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. -Author : KP -Date : 17/06/25 -Rev : P1 -***********************************************************************************************) -VAR CONSTANT - uiMinVoltage : UINT := 5000; // Minimum Voltage in mV -END_VAR -VAR - fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) - fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) - uiCurrSupplyVolt : UINT ; // Operating Voltage in mV - TOF_0: TOF; - Test: BOOL; - dude: BOOL; - fbenable: TON; - xResult : BOOL; - xTimerDone : BOOL; -END_VAR - -(* Network 1 *) -TRUE := dude; diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt deleted file mode 100644 index 026f3fc..0000000 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.st.txt +++ /dev/null @@ -1,59 +0,0 @@ -(* Equivalent Structured Text for a graphical POU, written by codescribe. - READ ONLY. This is a rendering of the native xml beside it, not a - translation: it is not guaranteed to compile and must never be imported - or pasted back into CODESYS. The native xml is the source. *) - -PROGRAM FB_TESTING -(*********************************************************************************************** -Object Name : PLC_SUPPLY -Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. -Author : KP -Date : 17/06/25 -Rev : P1 -***********************************************************************************************) -VAR CONSTANT - uiMinVoltage : UINT := 5000; // Minimum Voltage in mV -END_VAR -VAR - fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) - fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) - uiCurrSupplyVolt : UINT ; // Operating Voltage in mV - TOF_0: TOF; - Test: BOOL; - dude: BOOL; - fbenable: TON; - xResult : BOOL; - xTimerDone : BOOL; -END_VAR - -(* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) -fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15, eMode := ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY); -uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; - -(* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) -(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) - -(* Network 3 *) -ByeBye: -(* empty network *) - -(* Network 4 *) -(* empty network *) - -(* Network 5: Comment only network *) -(* empty network *) - -(* Network 6: Comment only network with comment slash *) -(* empty network *) - -(* Network 7: (* Comment only network with brackets * ) *) -(* empty network *) - -(* Network 8 *) -IF Test THEN JMP ByeBye; END_IF - -(* Network 9: comment for the sakes of comments *) -fbenable(EN := Test, IN := Test, PT := T#5s); -xTimerDone := fbenable.Q; -xResult := fbenable.ENO; -Test := (fbenable.ENO OR dude) AND xTimerDone; diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt deleted file mode 100644 index b8a3630..0000000 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.st.txt +++ /dev/null @@ -1,32 +0,0 @@ -(* Equivalent Structured Text for a graphical POU, written by codescribe. - READ ONLY. This is a rendering of the native xml beside it, not a - translation: it is not guaranteed to compile and must never be imported - or pasted back into CODESYS. The native xml is the source. *) - -PROGRAM LD_TEST -VAR - Sensor1: BOOL; - Sensor2: BOOL; - sensor3: BOOL; - PowerOn: BOOL; - TON_0: TON; - CTU_0: CTU; - PowerOff: BOOL; -END_VAR - -(* Network 1: Try me Codesys I swear *) -(* comment without backslash *) -IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF - -(* Network 2 *) -JMP TestJump; - -(* Network 3: header text *) -(* Comment *) -TON_0(IN := PowerOn, PT := T#5S); -CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); -IF CTU_0.Q THEN PowerOff := FALSE; END_IF - -(* Network 4 *) -TestJump: -(* empty network *) diff --git a/README.md b/README.md index 926ee00..e1d82a5 100644 --- a/README.md +++ b/README.md @@ -80,20 +80,7 @@ The declaration is copied from the original CODESYS declaration source, preservi This file is **derived and read-only**. The native xml remains the only thing `Import From Files` reads, so editing the `.txt` changes nothing — it exists to make diffs and code review possible. Layout comes from how the elements are wired, not from their coordinates, so moving a block in the CODESYS editor produces no diff. -Beside those two, CODESCRIBE writes a `.st.txt` holding the same networks as equivalent Structured Text: - -``` -(* Network 2 *) -TON_0(IN := PowerOn, PT := T#5S); -CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); -IF CTU_0.Q THEN PowerOff := FALSE; END_IF -``` - -The diagram shows the shape; the ST states the logic exactly, and says things a single-wire diagram cannot. A block read through two of its output pins is one call in the ST, where the diagram has to draw the box once and name it again for the second reader. The ST also diffs line by line, where renaming a variable can re-flow every line of a diagram. - -It is **a rendering, not a translation**: it is not guaranteed to compile, it must never be imported or pasted back into CODESYS, and the file opens with a banner saying so. Like the `.txt`, `Import From Files` ignores it — the dispatch is on `.xml` and `.st`, and both derived files end in `.txt`. - -A graphical action, transition or method is rendered from its own body, not from the parent POU it is exported inside. PLCopen has no top-level element for one, so CODESYS exports the parent with the member nested in it, parent body included; the rendering picks the member out by name and says at the top whose declaration it is showing, because the export only carries the parent's. Where an export does not carry the member's body at all, no files are written for it and the export summary says so - an absent rendering sends you to the native xml, a rendering of the wrong POU does not. +An equivalent-Structured-Text rendering of the same networks is available but not written by the export. The ST states the logic exactly where the diagram can only approximate it - a block read through two of its output pins is one call, and no single-wire diagram can say so - but it is a rendering, not a translation, and must never be fed back into CODESYS. `tools/ladder/write_st.py` writes one from a PLCopen export when it is wanted, and its docstring says how to put it back on the export path. SFC and CFC POUs are not yet rendered; they export as native xml alone. diff --git a/src/graphical_export.py b/src/graphical_export.py index 69c9735..ff397f2 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -35,7 +35,6 @@ import parse_fbd import parse_ld import plcopen -import st_render from util import open_utf8 # Suffixes for the derived files. Deliberately not .st: these are not @@ -43,7 +42,6 @@ # dispatches on ".xml" and ".st", and os.path.splitext sees ".txt" for both of # these, so both are ignored by construction. RENDERED_SUFFIX = ".txt" -ST_SUFFIX = ".st.txt" # Said in the file itself when the editor's numbering could not be recovered. # Numbering silently adrift from the editor is how an off-by-one review @@ -53,17 +51,6 @@ " the numbering may not match the CODESYS editor *)" ) -# Stated in the file itself, not just in the docs. The ST reads like source -# and sits next to real .st exports, so the one thing a reader must not -# assume is that it can go back into CODESYS. -ST_HEADER = [ - u"(* Equivalent Structured Text for a graphical POU, written by codescribe.", - u" READ ONLY. This is a rendering of the native xml beside it, not a", - u" translation: it is not guaranteed to compile and must never be imported", - u" or pasted back into CODESYS. The native xml is the source. *)", - u"", -] - # Rendering adds a second CODESYS-side export per graphical POU, so the cost # is worth reporting rather than leaving people to wonder why the export got # slower. Split so it is obvious whether CODESYS or this code is the cost. @@ -73,7 +60,6 @@ "export_xml_seconds": 0.0, "parse_seconds": 0.0, "draw_seconds": 0.0, - "st_seconds": 0.0, "verbatim_declarations": 0, "fallback_declarations": 0, "members_missing": 0, @@ -97,19 +83,16 @@ def summary(): """ if not STATS["rendered"] and not STATS["skipped"]: return None - total = ( - STATS["export_xml_seconds"] + STATS["parse_seconds"] + STATS["draw_seconds"] + STATS["st_seconds"] - ) + total = STATS["export_xml_seconds"] + STATS["parse_seconds"] + STATS["draw_seconds"] line = ( "Rendered %d graphical POUs in %.1fs" - " (%.1fs CODESYS export_xml, %.1fs parsing, %.1fs drawing, %.1fs ST); skipped %d" + " (%.1fs CODESYS export_xml, %.1fs parsing, %.1fs drawing); skipped %d" % ( STATS["rendered"], total, STATS["export_xml_seconds"], STATS["parse_seconds"], STATS["draw_seconds"], - STATS["st_seconds"], STATS["skipped"], ) ) @@ -197,18 +180,13 @@ def _joined(blocks): def render_plcopen(plcopen_path, declaration_text=None, member_name=None, native_path=None): - """(diagram lines, ST lines) for a PLCopen file. ([], []) if none apply. - - Two renderings of the same networks, for two files. They were written - into one file at first and that was worse, not better: the same network - twice in two notations, one after the other, is harder to read than - either alone. In separate files the choice stays with the reader - the - diagram shows the shape, and the ST states the logic exactly where the - diagram can only approximate it. A block read through two of its pins is - the clearest case: the ST says one call, and no single-wire diagram can. + """The diagram lines for a PLCopen file, or [] if none apply. - The ST is a rendering, not a translation. It is not guaranteed to compile - and must never be fed back into CODESYS; the file says so at the top. + The declaration and a diagram per network. An equivalent-ST rendering is + written by st_render and reachable from tools/ladder/render.py, but the + export does not write one: it was tried in the same file, which was worse + than either notation alone, and then in a file of its own, which is not + what is wanted for now. ``member_name`` restricts the rendering to that sub-POU member's own body; see _render_pous. ``native_path`` is the native export written @@ -255,11 +233,7 @@ def render_plcopen(plcopen_path, declaration_text=None, member_name=None, native drawn.append(notes[index] + art_renderer.render_pou(pou)) STATS["draw_seconds"] += time.time() - started - started = time.time() - text = [notes[index] + st_render.render_pou(pou) for index, (pou, _art) in enumerate(pous)] - STATS["st_seconds"] += time.time() - started - - return _joined(drawn), _joined(text) + return _joined(drawn) # Ways of asking for plaintext declarations, most likely to bind first. @@ -344,7 +318,7 @@ def write_rendered_text(obj, base_path, member_name=None): # render_plcopen accounts for its own parse, draw and ST time. textual_declaration = getattr(getattr(obj, "textual_declaration", None), "text", None) - lines, st_lines = render_plcopen(temp_path, textual_declaration, member_name, base_path + ".xml") + lines = render_plcopen(temp_path, textual_declaration, member_name, base_path + ".xml") if not lines: if member_name is not None: # No file at all is the honest outcome: an absent rendering @@ -360,8 +334,6 @@ def write_rendered_text(obj, base_path, member_name=None): return False _write_lines(base_path + RENDERED_SUFFIX, lines) - if st_lines: - _write_lines(base_path + ST_SUFFIX, ST_HEADER + st_lines) STATS["rendered"] += 1 return True except Exception as error: @@ -378,7 +350,6 @@ def write_rendered_text(obj, base_path, member_name=None): # A write that died halfway leaves a truncated rendering that looks # exactly like a valid one. No file at all is the honest outcome. _remove_quietly(base_path + RENDERED_SUFFIX) - _remove_quietly(base_path + ST_SUFFIX) return False finally: _remove_quietly(temp_path) diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index a346e73..2a6513e 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -131,27 +131,9 @@ def read(path): check("the declaration appears once", content.count("END_VAR") == 1) check("derived file ends with a newline", content.endswith("\n")) - # --- the ST rendering, in a file of its own ----------------------------- - - st_content = read(base + ".st.txt") - check("ST file lands beside the diagram", os.path.exists(base + ".st.txt")) - # It reads like source and sits next to real .st exports, so the file has - # to say what it is before it says anything else. - check("ST file opens with the read-only banner", st_content.startswith("(* Equivalent Structured Text")) - check("the banner forbids importing it", "must never be imported" in st_content) - check("ST file carries the declaration", "PROGRAM LD_TEST" in st_content) - check("ST file states the logic", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" in st_content) - check("ST file is numbered like the diagram", "(* Network 1" in st_content) - check("ST file has no diagram in it", "TON_0 : TON" not in st_content.replace("TON_0 : TON;", "")) - check("ST file ends with a newline", st_content.endswith("\n")) - - # The temp PLCopen file is staged outside the export folder, so nothing but - # the two renderings may appear next to the native xml. - check_equal( - "no stray files left behind", - sorted(os.listdir(workspace)), - ["LD_TEST.st.txt", "LD_TEST.txt"], - ) + # The temp PLCopen file is staged outside the export folder, so nothing + # but the rendering may appear next to the native xml. + check_equal("no stray files left behind", sorted(os.listdir(workspace)), ["LD_TEST.txt"]) # --- an older ScriptEngine without the plaintext overload --------------- @@ -166,7 +148,6 @@ def read(path): sfc = FakePou("SFC_TEST", os.path.join(FIXTURES, "SFCTesting.xml")) check("sfc reports nothing rendered", graphical_export.write_rendered_text(sfc, sfc_base) is False) check("sfc writes no empty file", not os.path.exists(sfc_base + ".txt")) - check("sfc writes no empty ST file", not os.path.exists(sfc_base + ".st.txt")) # --- cost reporting ----------------------------------------------------- @@ -299,10 +280,6 @@ def failing_open_utf8(path, mode): except Exception as error: check("a mid-write failure is reported, not raised", False, repr(error)) check("a truncated rendering is not left behind", not os.path.exists(torn_base + ".txt")) - # Both files go, not just the one that happened to fail: a diagram - # with no ST beside it, or the reverse, is a rendering that disagrees - # with itself. - check("no half-written ST is left behind", not os.path.exists(torn_base + ".st.txt")) finally: graphical_export.open_utf8 = real_open_utf8 finally: @@ -337,13 +314,6 @@ def failing_open_utf8(path, mode): "the declaration below is the parent POU's" in action_content, ) - # Both derived files describe the member. An ST file showing the parent - # beside a diagram showing the action would be worse than either alone. - action_st = read(action_base + ".st.txt") - check("the ST file follows the member too", "Status.Action := xAction;" in action_st) - check("the ST file does not show the parent", "Status.Parent" not in action_st) - check("the ST file carries the member note", "the declaration below is the parent POU's" in action_st) - # A graphical method: CODESYS spells the tag with a capital M # where it spells actions . Matching one case only meant methods # never found their own body and silently rendered nothing. @@ -380,7 +350,6 @@ def failing_open_utf8(path, mode): graphical_export.write_rendered_text(missing, missing_base, member_name="ACT_MISSING") is False, ) check("no foreign dump is written", not os.path.exists(missing_base + ".txt")) - check("no foreign ST is written either", not os.path.exists(missing_base + ".st.txt")) check_equal("the missing member is counted", graphical_export.STATS["members_missing"], 1) check("the summary reports the missing member", "nothing was written for those" in graphical_export.summary()) finally: @@ -476,7 +445,7 @@ def __init__(self, outputs): # derived files. workspace = tempfile.mkdtemp() try: - for name in ("Main.txt", "Main.Method.txt", "Main.gvl.txt", "Main.st.txt", "Main.Method.st.txt"): + for name in ("Main.txt", "Main.Method.txt", "Main.gvl.txt"): handle = io.open(os.path.join(workspace, name), "w", encoding="utf-8") handle.write("PROGRAM Main\n") handle.close() diff --git a/tools/ladder/write_st.py b/tools/ladder/write_st.py new file mode 100644 index 0000000..fc77db7 --- /dev/null +++ b/tools/ladder/write_st.py @@ -0,0 +1,107 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Write the equivalent-ST rendering of a graphical POU beside its diagram. + + python tools/ladder/write_st.py [...] + +The export does not write these. It did for a while, as ".st.txt" next +to ".txt", and that is easy to turn back on - see below - but for now +the diagram is the only rendering the export produces, and this script is how +to get the ST when it is wanted. + +The ST states the logic exactly where the diagram can only approximate it: a +block read through two of its output pins is one call, and no single-wire +diagram can say so. It is a rendering, not a translation - not guaranteed to +compile, and never to be fed back into CODESYS - so every file it writes +opens by saying that. + +Input is PLCopen xml, which is what the renderer reads. To get some from a +project, call graphical_export._export_plcopen(obj, path) from a CODESYS +script; codesys-headless-test.md shows how to run one. + +To put this back on the export path, in graphical_export.py: + + * import st_render + * have render_plcopen return the ST lines alongside the diagram lines, + built the same way as the diagram - notes first, then one + st_render.render_pou(pou) per POU, joined by _joined + * in write_rendered_text, write them to base_path + ".st.txt" with + ST_HEADER at the top, and remove that file alongside the diagram when a + write fails + +The suffix has to stay ".st.txt" rather than ".st": import_from_files +dispatches on ".xml" and ".st", and os.path.splitext sees ".txt" for this +one, so it is ignored by construction the way the diagram is. +""" + +from __future__ import print_function, unicode_literals + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "..", "src")) + +import parse_fbd # noqa: E402 +import parse_ld # noqa: E402 +import plcopen # noqa: E402 +import st_render # noqa: E402 +from render import write # noqa: E402 + +# Language -> parser. Kept here rather than imported from graphical_export, +# which needs the CODESYS scriptengine module to load at all. +PARSERS = {parse_ld.LANGUAGE: parse_ld, parse_fbd.LANGUAGE: parse_fbd} + +# Stated in the file itself, not just in the docs. The ST reads like source +# and sits next to real .st exports, so the one thing a reader must not +# assume is that it can go back into CODESYS. +ST_HEADER = [ + "(* Equivalent Structured Text for a graphical POU, written by codescribe.", + " READ ONLY. This is a rendering of the native xml beside it, not a", + " translation: it is not guaranteed to compile and must never be imported", + " or pasted back into CODESYS. The native xml is the source. *)", + "", +] + +SUFFIX = ".st.txt" + + +def render(path): + """The ST lines for every renderable POU in a PLCopen file.""" + lines = [] + for pou_elem, language, body in plcopen.iter_bodies(path): + parser = PARSERS.get(language) + if parser is None: + continue + lines.extend(st_render.render_pou(parser.pou_from_body(pou_elem, body))) + lines.append("") + while lines and lines[-1] == "": + lines.pop() + return lines + + +def main(argv): + if not argv: + write(__doc__.strip().split("\n")[:3]) + return 2 + for path in argv: + lines = render(path) + if not lines: + print("nothing renderable in " + path) + continue + base = path + for ending in (".plcopen.xml", ".xml"): + if base.endswith(ending): + base = base[: -len(ending)] + break + target = base + SUFFIX + handle = open(target, "wb") + try: + handle.write(("\n".join(ST_HEADER + lines) + "\n").encode("utf-8")) + finally: + handle.close() + print("wrote " + target) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From bb25272c56692020e694243fd1cb20d495b8077a Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 20:24:08 +1000 Subject: [PATCH 52/91] re-export GraphicalTesting: network 2 switched back on, and a new ST POU FB_TESTING network 2 is no longer out-commented, so it renders its diagram in place of the placeholder, and the POU gains a TON_0. Standard_ST is a new ST program, and the task configuration now calls it along with SFC_TEST, LD_TEST and FB_TESTING. The numbering still follows the editor: nine networks in CODESYS, nine in the rendering, four of them carrying logic where there were three. --- .../FB_TESTING.PleaseIhaveKids.txt | 1 + .../StandardPLC/application/FB_TESTING.txt | 12 ++++++++- .../StandardPLC/application/FB_TESTING.xml | 11 +++++--- .../StandardPLC/application/Standard_ST.st | 25 +++++++++++++++++++ .../application/TaskConfiguration.xml | 12 +++++++-- 5 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 GraphicalTesting/StandardPLC/application/Standard_ST.st diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt index b737106..9c62f8f 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt @@ -21,6 +21,7 @@ VAR fbenable: TON; xResult : BOOL; xTimerDone : BOOL; + TON_0: TON; END_VAR (* Network 1 *) diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt index 3cc5fd1..d98c4e0 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt @@ -19,6 +19,7 @@ VAR fbenable: TON; xResult : BOOL; xTimerDone : BOOL; + TON_0: TON; END_VAR (* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) @@ -31,7 +32,16 @@ ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY──┤eMode └─────────────────────────────────────────┘ (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) -(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) + fbSupplySwitch : ifmIOcommon.SupplySwitch + ┌─────────────────────────────────────────┐ +ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH──┤eMode xError│ + GT TOF_0 : TOF │ │ + ┌──────────┐ ┌───────────┐ │ │ +uiCurrSupplyVolt──┤In1 Out1├──┤IN Q├─────┤xValue eDiagInfo│ +uiMinVoltage──────┤In2 │ │ │ │ xPrepared│ + └──────────┘ │ │ └─────────────────────────────────────────┘ +T#5S────────────────────────────┤PT ET│ + └───────────┘ (* Network 3 *) ByeBye: diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.xml b/GraphicalTesting/StandardPLC/application/FB_TESTING.xml index 10837cc..238b26d 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.xml +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.xml @@ -215,7 +215,7 @@ //Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge - True + False ifmIOcommon.SupplySwitch @@ -1019,7 +1019,7 @@ 150 - 6 + 7 True @@ -1130,6 +1130,11 @@ xTimerDone : BOOL; + + 214 + + TON_0: TON; + 4 @@ -1143,7 +1148,7 @@ - 186 + 214 Standard False diff --git a/GraphicalTesting/StandardPLC/application/Standard_ST.st b/GraphicalTesting/StandardPLC/application/Standard_ST.st new file mode 100644 index 0000000..9943c6e --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/Standard_ST.st @@ -0,0 +1,25 @@ +PROGRAM Standard_ST +VAR + xDoYouWork: BOOL; + xYayyyy: BOOL; +END_VAR + +// --- BEGIN IMPLEMENTATION --- + +xDoYouWork := TRUE; + +// some random comment +(* Some +random + multi + +line +comment + +*) +IF xDoYouWork THEN + xYayyyy := TRUE; + +ELSE + xYayyyy := FALSE; +END_IF \ No newline at end of file diff --git a/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml b/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml index b06912d..24e4ca0 100644 --- a/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml +++ b/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml @@ -88,11 +88,19 @@ 205b1f78-9060-49b9-896f-0aab4eb3cc59 - PLC_PRG + Standard_ST - _CAN_MAIN + SFC_TEST + + + + LD_TEST + + + + FB_TESTING From c0a1fa4a7bf44c43d60eb85aac6aa48cc275a317 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 21:18:15 +1000 Subject: [PATCH 53/91] pin the line endings of the export, so a clone matches what CODESYS writes Line endings were decided by each developer's core.autocrlf. The same commit checked out differently on different machines, and whoever had it set the other way would see every exported file rewritten the next time they exported - a diff of the whole tree that says nothing about what changed. It also made "byte-identical to a real CODESYS export" true only by accident, on a machine configured the right way. The export and the captured CODESYS fixtures now check out CRLF everywhere, which is what CODESYS writes and what gets imported back into it. Only the working tree is pinned; git still stores them normalised, so diffs stay line by line. Verified by checking the index out again with core.autocrlf off and with core.eol=lf, as CI has it: both give CRLF. A .project is a zip, so it is marked binary outright - converting one would corrupt it. Checked that it survives a checkout byte for byte. Nothing in the index changed: it already held every text file normalised. --- .gitattributes | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c1f7374 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Without this file, line endings are decided by each developer's +# core.autocrlf. The same commit then checks out differently on different +# machines, and whoever has it set the other way sees every exported file +# rewritten the next time they export - a diff of the whole tree that says +# nothing about what changed. +* text=auto + +# CODESYS writes CRLF, and so does the export. These files are compared +# against what CODESYS writes and imported back into it, so they check out +# CRLF everywhere, whatever git is configured to do locally. Only the working +# tree is pinned; git still stores them normalised, so diffs stay line by +# line rather than whole-file. +GraphicalTesting/** text eol=crlf +tools/ladder/tests/fixtures/codesys/** text eol=crlf + +# A .project is a zip. Converting one would corrupt it. +*.project binary From fb5267b6d5759d14c72dd289b918d35a8edfe93a Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 21:29:14 +1000 Subject: [PATCH 54/91] correct the docs the ST removal left behind graphical_export's module docstring still described the ".st.txt" it no longer writes, and the README still gave the old reason for not writing one - that two notations in one file read worse than one - which stopped being the reason when it moved to a file of its own and then off the export path altogether. The CHANGELOG had no entry for the rendering at all, which is the whole of this branch. It has one now: what the file holds, that the numbering follows the editor rather than the PLCopen export, that a member renders its own body rather than its parent's, and that the file is read-only because the importer dispatches on ".xml" and ".st". --- CHANGELOG.md | 2 ++ README.md | 2 +- src/graphical_export.py | 27 +++++++++++---------------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e3c4f..99d900f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. + - Visualisations export as `.vis.xml`. Earlier versions wrote `.xml`, which silently collided with any POU of the same name (a `Main` program plus a `Main` visualisation is common). Old plain `.xml` exports still import correctly; re-exporting once migrates the tracked files. - Visualisation service objects (visualisation manager and related service GUIDs) are recognised and no longer exported. - `Export To Files` writes the export into a sibling folder named `.codescribe_staging` and only swaps it into place once the export completes. Earlier versions deleted the target folder up front, so a locked folder (an open Explorer window, IDE, git client or antivirus) aborted the export immediately and a mid-export crash left the on-disk copy destroyed. If the target folder is locked and cannot be swapped, the staged files are synced into it instead and the export still succeeds; if that also fails, the error dialog reports the staging folder path, so the completed export is preserved. diff --git a/README.md b/README.md index e1d82a5..267bc1a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ CODESYS leaves out of the PLCopen export every network that carries no elements: If the two cannot be lined up, the file says so at the top and falls back to numbering in export order, rather than showing numbers that quietly disagree with the editor. -To render an exported PLCopen file by hand, to get plain ASCII instead of box drawing, or to see the equivalent Structured Text (which the export does not write, since showing each network twice in two notations reads worse than showing it once): +To render an exported PLCopen file by hand, to get plain ASCII instead of box drawing, or to print the equivalent Structured Text the export does not write: ``` python tools/ladder/render.py --charset ascii MyPou.xml diff --git a/src/graphical_export.py b/src/graphical_export.py index ff397f2..008902a 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -3,22 +3,17 @@ Graphical POUs (LD, FBD, SFC, CFC) have no textual implementation, so they export as CODESYS native xml, which git can store but nobody can review. This -adds two derived files next to it: a ".txt" holding the declaration and a -diagram per network, and a ".st.txt" holding the same networks as equivalent -Structured Text. - -Two files rather than one, because one file holding both was worse: the same -network twice, in two notations, one after the other. Separately, each is -read for what it is good at - the diagram for the shape, the ST for the exact -logic, which is where a diagram can only approximate. A block read through -two of its pins is the clearest case: the ST says one call, and no -single-wire diagram can. - -Both are READ-ONLY as far as CODESCRIBE is concerned. The native xml stays -the only thing Import From Files reads, so the round trip is unaffected and -editing either achieves nothing. import_from_files dispatches on ".xml" and -".st", and os.path.splitext sees ".txt" for both of these, so both are -ignored by construction. +adds a derived ".txt" next to it: the declaration, and a diagram per network. + +An equivalent-ST rendering of the same networks exists in st_render, and it +says things a single-wire diagram cannot - a block read through two of its +output pins is one call. The export does not write one; tools/ladder/write_st.py +does, on demand, and its docstring says how to put it back on this path. + +The ".txt" is READ-ONLY as far as CODESCRIBE is concerned. The native xml +stays the only thing Import From Files reads, so the round trip is unaffected +and editing the ".txt" achieves nothing. import_from_files dispatches on +".xml" and ".st", so a ".txt" is ignored by construction. The rendering goes through PLCopen xml rather than the native format, because PLCopen has a published schema for graphical bodies while the native format From ef5e0073cb982a2a13a5a929fa42a1e116359044 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 22:01:28 +1000 Subject: [PATCH 55/91] export the library references and the visualisation manager read-only Cherry-picked from the work on PR #38 (164931e), with its follow-up (8a8c7c0) that shows what a placeholder reference resolves to. Library behaviour is not exportable, but which exact versions a project resolves is exactly what a bench check needs to know, and it was in no tracked file. The Library Manager now writes ".libraries.txt", one line per reference, and the Visualization Manager writes ".service.txt" recursively so the global hotkey mapping and target settings it carries are visible. Both are read-only, like the graphical renderings: the importer dispatches on ".xml" and ".st". Brought over now rather than waiting for #38 because the export deletes what it does not write. Exporting any project from this branch was removing the "Library Manager.libraries.txt" that the other branch had written, with nothing to say why. The two CHANGELOG entries that came with the original commit for the member rendering and the network numbering are left out: this branch does both its own way and already describes them. --- CHANGELOG.md | 2 + .../application/Library Manager.libraries.txt | 5 + .../application/Library Manager.libraries.txt | 25 ++ .../Visualization Manager.service.txt | 296 ++++++++++++++++++ README.md | 5 + src/import_export.py | 116 +++++++ src/script_export_to_files.py | 9 +- src/script_lib_export_to_files.py | 11 +- tools/ladder/tests/test_export.py | 149 ++++++++- 9 files changed, 615 insertions(+), 3 deletions(-) create mode 100644 GraphicalTesting/SafetyPLC/application/Library Manager.libraries.txt create mode 100644 GraphicalTesting/StandardPLC/application/Library Manager.libraries.txt create mode 100644 GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 99d900f..1d1c952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased - Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. +- The Library Manager exports its reference list as a read-only `.libraries.txt` (name, version, vendor per line). Library behaviour itself is not exportable, but any bench check of a library needs to know exactly which version the project resolves. +- The Visualization Manager exports natively as a read-only `.service.txt`, recursively, so the global hotkey (key configuration) mapping and target/web visualization settings become reviewable. It is still never imported - importing it raises interactive overwrite dialogs, which is why earlier versions dropped it from the export entirely - and the project template continues to carry the real object. - Visualisations export as `.vis.xml`. Earlier versions wrote `.xml`, which silently collided with any POU of the same name (a `Main` program plus a `Main` visualisation is common). Old plain `.xml` exports still import correctly; re-exporting once migrates the tracked files. - Visualisation service objects (visualisation manager and related service GUIDs) are recognised and no longer exported. diff --git a/GraphicalTesting/SafetyPLC/application/Library Manager.libraries.txt b/GraphicalTesting/SafetyPLC/application/Library Manager.libraries.txt new file mode 100644 index 0000000..4167299 --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/Library Manager.libraries.txt @@ -0,0 +1,5 @@ +(* Library references - regenerated by Export To Files; read-only, never imported *) +ifmR360-3, * (ifm electronic) +#IoStandard -> IoStandard, 3.5.10.0 (System) +#CmpSIL2 -> CmpSIL2, 3.5.7.0 (System) +#3SLicense -> 3SLicense, 3.5.10.0 (3S - Smart Software Solutions GmbH) diff --git a/GraphicalTesting/StandardPLC/application/Library Manager.libraries.txt b/GraphicalTesting/StandardPLC/application/Library Manager.libraries.txt new file mode 100644 index 0000000..3b2bdf9 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/Library Manager.libraries.txt @@ -0,0 +1,25 @@ +(* Library references - regenerated by Export To Files; read-only, never imported *) +ifmR360-3, 1.1.1.0 (ifm electronic) +#Util -> Util, 3.5.11.0 (System) +#Standard -> Standard, 3.5.9.0 (System) +CR711S IOWrapper, 1.0.4.9 (ifm electronic) +#IoStandard -> IoStandard, 3.5.10.0 (System) +#3SLicense -> 3SLicense, 3.5.10.0 (3S - Smart Software Solutions GmbH) +#IoDrvJ1939 -> IoDrvJ1939, 3.5.11.0 (3S - Smart Software Solutions GmbH) +#CANbusDevice -> CANbusDevice, 3.5.10.0 (3S - Smart Software Solutions GmbH) +#3S CANopenStack -> 3S CANopenStack, 3.5.11.0 (3S - Smart Software Solutions GmbH) +#CAA CiA405 -> CAA CiA 405, 3.5.8.0 (CAA Technical Workgroup) +#IecSfc -> IecSfc, 3.4.2.0 (System) +#Analyzation -> Analyzation, 3.5.11.0 (System) +#System_VisuElems -> VisuElems, 3.5.11.0 (System) +#System_VisuElemMeter -> VisuElemMeter, 3.5.10.0 (System) +#System_VisuElemsSpecialControls -> VisuElemsSpecialControls, 3.5.11.0 (System) +#System_VisuElemsWinControls -> VisuElemsWinControls, 3.5.11.20 (System) +#System_VisuElemTextEditor -> VisuElemTextEditor, 3.5.11.0 (System) +#System_VisuElemTrace -> VisuElemTrace, 3.5.11.20 (System) +#System_VisuNativeControl -> VisuNativeControl, 3.5.11.0 (System) +#System_VisuElemsAlarm -> VisuElemsAlarm, 3.5.11.0 (System) +#System_VisuElemCamDisplayer -> VisuElemCamDisplayer, 3.5.10.0 (System) +#System_VisuElem3DPath -> VisuElem3DPath, 3.5.10.0 (System) +#System_VisuElemsDateTime -> VisuElemsDateTime, 3.5.11.0 (System) +#system_visuinputs -> visuinputs, 3.5.10.0 (system) diff --git a/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt b/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt new file mode 100644 index 0000000..0e01507 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt @@ -0,0 +1,296 @@ + + + + + + + True + + a886129e-88da-4a4d-9079-d46f717679fc + f9c00bdb-2f18-4e80-863e-6da977e8f304 + Visualization Manager + + 4d3fdb8f-ab50-4c35-9d3a-d4bb9bb9a628 + + 0 + + + Disabled + + + Visualization + False + False + AutoDetect + 2000 + 2000 + False + 50000 + 400000 + False + False + True + Default, 3.5.16.0 (3S-Smart Software Solutions GmbH) + 100 + + VisuDialogs.Numpad + VisuDialogs.Keypad + VisuDialogs.TextinputWithLimits + False + False + False + False + False + VisuUserManagement.VUM_Login + VisuUserManagement.VUM_ChangePassword + VisuUserManagement.VUM_UserManagement + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + True + True + + + + + + + FB_Init + + + c89279aa-6da4-434e-bb9d-7a1092342960 + + + + + FB_Reinit + + + 3f5c1fdc-c478-403e-9db6-413c44798b62 + + + + + FB_Exit + + + 473dd5fb-9ac9-4528-b9ae-4443fbf833b6 + + + + NotImportant + 34d06ee6-3348-4ff4-88e5-5d0e892de8fc + + + + + + 481037385728 + 549755813887 + 481037385728 + 549754765312 + 1048576 + + + + + + + ExecuteLooseCapture + + + a5b292af-c5c5-4060-8b91-55007f1acae4 + + + + + ExecuteMouseUp + + + cacb2953-9ffc-4db1-8545-ebd1aa76f7bc + + + + + Init + + + d686395d-362c-41a0-812d-1e9432b49cdc + + + + + FB_Exit + + + c0ad1bc8-35bf-45cc-9b5d-b2bbe2ada465 + + + + + ExecuteMouseDblClick + + + dc5bfb38-a1eb-4464-b07f-2081ea939424 + + + + + GetElementInfo + + + 66c67735-498e-45a5-804c-6370fbb97df7 + + + + + ExecuteMouseDown + + + d21b7523-2711-423b-af01-519e8c0e9c82 + + + + + FB_Reinit + + + e3f2b997-e1ae-4245-8288-03b5f90ef558 + + + + + Initialize + + + 32672985-f837-4b28-9cce-7bcf785362df + + + + + ExecuteMouseMove + + + 8d88349c-03f3-4f94-927b-2ea19bfe683d + + + + + ExecuteDialogClosed + + + 21fa2514-fea5-47d6-8aea-20e66b1d3a8e + + + + + ExecuteKeyUp + + + 476c673a-06e6-47c2-9cc7-3a989d50ede0 + + + + + ExecuteKeyDown + + + b56c83d3-4073-419e-81ed-82392f9b80ff + + + + + abstrGetDefaultCursor + + + b6adce04-2762-4ad9-8d50-d50300042633 + + + + + ExecuteMouseEnter + + + a4213275-eb0f-4188-83b2-c6fdda524913 + + + + + ExecuteMouseLeave + + + 7cf492e6-63d3-492e-8375-daa4b229e8ea + + + + + FB_Init + + + ad4d0bb3-8301-46bf-90a8-9fd3be6d3486 + + + + + ExecuteMouseClick + + + b1cde74f-4c3e-4bd1-9ef9-b08fff5373e3 + + + + NotImportant + f733e3bc-0de7-42ac-9a38-e978eae9a278 + + + + True + True + + + + + 4a73f210-3b62-411c-a12e-01ca6c70bb64 + cb582427-5c68-4f2a-9ad8-278e904201bb + cd7582e6-5424-47ff-a03a-2b2c8113abd4 + 6309a3e8-c4cd-493a-bc2f-edc2e9e090ac + d290ad05-e9d3-4d2e-8f2e-5c393bca5dcb + b5697eb8-8905-477d-8847-48edc0063cc0 + 8a2cc8a4-cfd6-4a92-a746-f413bcef536c + 8349fd54-39ac-4876-b445-55e350a2b9cc + adba28ee-64e6-42b2-82c0-577039a7fbc5 + c167b3d6-5c9d-4e6f-8792-4fe5561ee1d5 + 491c67c7-6d3c-492a-a8f8-bc42f8350037 + 88d39fae-1bbb-45b7-8f89-497d7c9de4bf + b9cf0740-6815-45a4-ba08-f825c260efdd + da6282b1-7718-4c8d-a00b-a1153f155420 + ccb8b043-0d9a-4cbd-a524-46662e408e7a + 6f28000e-952a-4cdd-8be3-70a5bb859a05 + 274bcba4-c093-40ab-88b9-15948f72e347 + 40558d42-983b-4ff9-ae21-8fde60b37876 + 56bc93a2-20b9-4a9d-854a-45ea1aa97029 + 40600fba-3e82-4fb2-9f37-8254acce03be + 1122f9d0-98c6-4432-9876-e24388506d8d + 5830d667-6647-4129-8046-ab02cf5da887 + be2b76ba-157b-4700-98b5-d65323109648 + 6fad6608-7a0a-47aa-8e12-096ca38a5405 + 116afd95-a448-4dc4-b154-bfd2f74c707a + ad33a782-a45b-4370-9725-4681b035576d + 4267b596-eb10-40ba-b2ee-4e017b6895d8 + 6df6d20d-06e6-42ac-afe2-687eaf9bf0d6 + f61b50de-3f14-435c-89a1-960e68f41871 + 93cbf699-fca2-4a89-9c92-0a38e7a62574 + 626c52a8-7ee6-4fb9-83dc-a61583aafbf0 + 6ab928be-8493-47ea-9fa1-72508a7297b8 + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/README.md b/README.md index 267bc1a..aff9a71 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,11 @@ Items are exported in formatted structured text (`.st`) where possible, and in n Actions and Transitions export as `.st` with the kind encoded in the filename (`MyPou.MyAction.action.st`, `MyPou.MyTransition.transition.st`). The file contains the implementation text only, as these objects have no textual declaration. +Two service objects export **read-only** — written on every export, never imported (the project template carries the real objects): + +- The Library Manager exports its reference list as `.libraries.txt` (one line per library: name, version, vendor), so a review or bench check knows exactly which library versions the project resolves. +- The Visualization Manager exports natively as `.service.txt` (recursively, so the global hotkey/key configuration and target/web visualization settings are included). Importing this object raises interactive overwrite dialogs, which is why it is not round-tripped. + ### Reading graphical POUs Ladder and Function Block Diagram POUs have no textual implementation, so they export as native xml that git can store but nobody can review. Alongside that xml, CODESCRIBE writes a `.txt` holding the declaration and a diagram of each network: diff --git a/src/import_export.py b/src/import_export.py index ce7025e..fcdb950 100644 --- a/src/import_export.py +++ b/src/import_export.py @@ -309,6 +309,122 @@ def import_sub_pou(child, dir_path, dir_parent_obj, import_dir_fn): parent_obj.import_native(full_path) +def _probe_attribute(obj, names): + """First truthy attribute from names, swallowing property getters that raise. + + ScriptEngine objects are .NET objects whose property set varies between + CODESYS versions, and a property that exists can still throw when read. + """ + for name in names: + try: + value = getattr(obj, name, None) + except Exception: + value = None + if value: + return value + return None + + +def _describe_library_reference(reference): + """One line for a library reference, from whatever this build exposes. + + The reference object's shape is version-dependent and mostly undocumented, + so named attributes are probed and str() is the floor - IronPython renders + a .NET object's ToString, which for a reference is its display name. + """ + name = _probe_attribute(reference, ("display_name", "name")) + if not name: + return u"%s" % (reference,) + line = u"%s" % name + version = _probe_attribute(reference, ("effective_version", "resolved_version", "version")) + if version is not None and (u"%s" % version) not in line: + line += u", " + (u"%s" % version) + company = _probe_attribute(reference, ("company",)) + if company is not None and (u"%s" % company) not in line: + line += u" (" + (u"%s" % company) + u")" + # A placeholder reference displays as "#Util", which pins no version at + # all - and the placeholder libraries are exactly the ones a bench check + # is most likely to need. Show what it resolves to when the build says. + resolution = _probe_attribute(reference, ("effective_resolution", "default_resolution", "resolution")) + if resolution is not None and (u"%s" % resolution) not in line: + line += u" -> " + (u"%s" % resolution) + return line + + +def _library_reference_lines(lib_manager_obj): + """One display line per configured library reference, in manager order.""" + lines = [] + try: + references = lib_manager_obj.references + except Exception: + references = None + if references is not None: + for reference in references: + lines.append(_describe_library_reference(reference)) + if lines: + return lines + # IScriptLibManObject.get_libraries returns the list of library names - + # poorer (no per-reference detail to probe) but documented on every build + # since V3.5.5.0. + return [u"%s" % name for name in lib_manager_obj.get_libraries()] + + +def export_library_manager(child_obj, parent_obj, parent_folder_path, export_child_fn): + """Write the library references as a read-only .libraries.txt. + + Library *behaviour* is not exportable - but which exact library versions + the project resolves is, and a bench check of a library is only meaningful + against the version it characterises. The .txt suffix is ignored by the + importer by construction, and the object stays out of + OBJECT_TYPE_TO_EXPORT_FUNCTION deliberately: membership there makes + remove_tracked_objects delete the manager on import, and nothing would + recreate it. + + Best-effort: a failure warns and the export carries on. + """ + try: + lines = _library_reference_lines(child_obj) + path = os.path.join(parent_folder_path, child_obj.get_name() + ".libraries.txt") + with open_utf8(path, "w") as f: + f.write(u"(* Library references - regenerated by Export To Files; read-only, never imported *)\n") + for line in lines: + f.write(line + u"\n") + except Exception as error: + print("WARNING: could not export the library list of " + child_obj.get_name() + ": " + repr(error)) + + +def export_visualisation_manager(child_obj, parent_obj, parent_folder_path, export_child_fn): + """Export the visualisation manager natively as a read-only .service.txt. + + The manager carries configuration nothing else exports - the global hotkey + (key configuration) mapping among it - but importing it back makes CODESYS + raise an interactive overwrite dialog on every later import, which is why + it was dropped from the export entirely. Read-only is the middle ground: + the .service.txt suffix is ignored by the importer by construction + (dispatch is on .xml and .st), the project template still carries the real + object, and the configuration becomes reviewable. recursive=True because + the target/web visualisations and the key configuration live under it. + + Best-effort: a failure warns and the export carries on. + """ + try: + write_native( + child_obj, os.path.join(parent_folder_path, child_obj.get_name() + ".service.txt"), recursive=True + ) + except Exception as error: + print("WARNING: could not export " + child_obj.get_name() + " read-only: " + repr(error)) + + +# Read-only, informational exports for objects the importer must never touch. +# Deliberately a separate table from OBJECT_TYPE_TO_EXPORT_FUNCTION: that +# membership is also what remove_tracked_objects deletes on import, and these +# objects are carried by the project template, not the import. +SERVICE_EXPORT_FUNCTIONS = { + ObjectType.LIBRARY_MANAGER: export_library_manager, + ObjectType.VISUALISATION_MANAGER: export_visualisation_manager, +} + + OBJECT_TYPE_TO_EXPORT_FUNCTION = { ObjectType.FOLDER: export_folder, ObjectType.POU: export_pou, diff --git a/src/script_export_to_files.py b/src/script_export_to_files.py index d67f44f..38b60df 100644 --- a/src/script_export_to_files.py +++ b/src/script_export_to_files.py @@ -9,7 +9,7 @@ from communication_import_export import export_communication from device_tree_import_export import export_device_tree_siblings from entrypoint import find_application, find_communication, get_device_entrypoints, get_src_folder -from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, write_native +from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, SERVICE_EXPORT_FUNCTIONS, write_native from object_type import ObjectType, get_object_type from util import * @@ -21,6 +21,13 @@ def export_child(child_obj, parent_obj, parent_folder_path): export_fn(child_obj, parent_obj, parent_folder_path, export_child) return + # Read-only informational exports (library list, visualisation manager). + # Separate table: these objects are never imported or removed on import. + service_fn = SERVICE_EXPORT_FUNCTIONS.get(child_obj_type) + if service_fn is not None: + service_fn(child_obj, parent_obj, parent_folder_path, export_child) + return + if child_obj_type == ObjectType.UNKNOWN: # An unmapped GUID would otherwise be dropped from the export without a trace # (seen with diagram objects such as an LD nested inside a CFC). Fall back to a diff --git a/src/script_lib_export_to_files.py b/src/script_lib_export_to_files.py index 191220b..26ae2c8 100644 --- a/src/script_lib_export_to_files.py +++ b/src/script_lib_export_to_files.py @@ -7,7 +7,7 @@ import graphical_export from entrypoint import get_src_folder -from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, write_native +from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, SERVICE_EXPORT_FUNCTIONS, write_native from object_type import ObjectType, get_object_type from util import * @@ -36,6 +36,15 @@ def export_child(child_obj, parent_obj, parent_folder_path): export_fn(child_obj, parent_obj, parent_folder_path, export_child) return + # Read-only informational exports (library list, visualisation manager). + # In library projects these managers usually carry unmapped GUIDs and are + # filtered by SKIP_NAMES above instead; the branch is here for consistency + # with script_export_to_files.py. + service_fn = SERVICE_EXPORT_FUNCTIONS.get(child_obj_type) + if service_fn is not None: + service_fn(child_obj, parent_obj, parent_folder_path, export_child) + return + if child_obj_type == ObjectType.UNKNOWN: # An unmapped GUID would otherwise be dropped from the export without a trace # (seen with diagram objects such as an LD nested inside a CFC). Fall back to a diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 2a6513e..9917257 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -23,6 +23,7 @@ sys.path.insert(0, os.path.join(REPO, "tools", "ladder")) import graphical_export # noqa: E402 +import import_export # noqa: E402 import import_from_files # noqa: E402 from render import write # noqa: E402 @@ -437,6 +438,146 @@ def __init__(self, outputs): check_equal("and carries its label", aligned[2].label, "RETRY") +# --- read-only service exports: library list and visualisation manager ------ + +# Library behaviour is not exportable, but which exact versions the project +# resolves is - and a bench check of a library is only meaningful against the +# version it characterises. The visualisation manager carries the global +# hotkey mapping nothing else exports; importing it raises interactive +# overwrite dialogs, so it exports read-only under a suffix the importer +# ignores by construction. + + +class FakeReference(object): + def __init__(self, display_name=None, name=None, version=None, company=None, default_resolution=None): + if display_name is not None: + self.display_name = display_name + if name is not None: + self.name = name + if version is not None: + self.version = version + if company is not None: + self.company = company + if default_resolution is not None: + self.default_resolution = default_resolution + + def __str__(self): + return "raw reference" + + +class FakeLibManager(object): + def __init__(self, name, references=None, names=None): + self._name = name + if references is not None: + self.references = references + self._names = names + + def get_name(self): + return self._name + + def get_libraries(self): + if self._names is None: + raise RuntimeError("no such API on this build") + return self._names + + +class FakeVisuManager(object): + def __init__(self, name): + self._name = name + self.calls = [] + + def get_name(self): + return self._name + + def export_native(self, path, recursive=False): + self.calls.append((path, recursive)) + handle = io.open(path, "w", encoding="utf-8") + handle.write(u"\n") + handle.close() + + +workspace = tempfile.mkdtemp() +try: + manager = FakeLibManager( + "Library Manager", + references=[ + # A display name that already carries version and company must not + # have them appended again. + FakeReference( + display_name="ifmIOcommon, 1.5.0.0 (ifm electronic gmbh)", + version="1.5.0.0", + company="ifm electronic gmbh", + ), + FakeReference(name="Standard", version="3.5.11.0", company="3S"), + # A placeholder pins no version by itself; its resolution must show. + FakeReference(display_name="#Util", default_resolution="Util, 3.5.11.0 (System)"), + FakeReference(), # nothing probeable - falls back to str() + ], + ) + import_export.export_library_manager(manager, None, workspace, None) + lib_list_path = os.path.join(workspace, "Library Manager.libraries.txt") + check("the library list is written", os.path.exists(lib_list_path)) + lib_list = read(lib_list_path) + check("the list says it is read-only", "read-only" in lib_list) + check("a display name is taken verbatim", "ifmIOcommon, 1.5.0.0 (ifm electronic gmbh)\n" in lib_list) + check("embedded details are not duplicated", lib_list.count("1.5.0.0") == 1) + check("probed details are assembled", "Standard, 3.5.11.0 (3S)" in lib_list) + check("a placeholder shows its resolution", "#Util -> Util, 3.5.11.0 (System)" in lib_list) + check("an opaque reference still lands as a line", "raw reference" in lib_list) + + # An older build without .references still exports via get_libraries. + named_only = FakeLibManager("Library Manager", names=["OldLib, 1.0.0.0 (Vendor)"]) + named_dir = tempfile.mkdtemp() + try: + import_export.export_library_manager(named_only, None, named_dir, None) + named_list = read(os.path.join(named_dir, "Library Manager.libraries.txt")) + check("get_libraries is the fallback", "OldLib, 1.0.0.0 (Vendor)" in named_list) + finally: + shutil.rmtree(named_dir) + + # A manager exposing neither API must warn, not raise, and write nothing. + broken_dir = tempfile.mkdtemp() + try: + broken = FakeLibManager("Library Manager") + try: + import_export.export_library_manager(broken, None, broken_dir, None) + check("a hostile lib manager is reported, not raised", True) + except Exception as error: + check("a hostile lib manager is reported, not raised", False, repr(error)) + check("no library list is written for it", os.listdir(broken_dir) == []) + finally: + shutil.rmtree(broken_dir) + + visu = FakeVisuManager("Visualization Manager") + import_export.export_visualisation_manager(visu, None, workspace, None) + service_path = os.path.join(workspace, "Visualization Manager.service.txt") + check("the visualisation manager is exported", os.path.exists(service_path)) + # The key configuration and target/web visualisations live under the + # manager, so a flat export would miss the hotkey mapping entirely. + check_equal("the manager export is recursive", visu.calls[0][1], True) + + # The safety property behind the separate SERVICE table: import must never + # remove these objects, because nothing would recreate them. + class FakeTrackedObject(object): + def __init__(self, type_guid): + self.type = type_guid + self.removed = False + + def get_name(self): + return "service object" + + def remove(self): + self.removed = True + + lib_manager_obj = FakeTrackedObject("adb5cb65-8e1d-4a00-b70a-375ea27582f3") + visu_manager_obj = FakeTrackedObject("4d3fdb8f-ab50-4c35-9d3a-d4bb9bb9a628") + import_export.remove_tracked_objects([lib_manager_obj, visu_manager_obj]) + check("import does not remove the library manager", not lib_manager_obj.removed) + check("import does not remove the visualisation manager", not visu_manager_obj.removed) +finally: + shutil.rmtree(workspace) + + # --- the importer ignores the derived file --------------------------------- # This is the contract that keeps the round trip intact. import_directory_child @@ -445,7 +586,13 @@ def __init__(self, outputs): # derived files. workspace = tempfile.mkdtemp() try: - for name in ("Main.txt", "Main.Method.txt", "Main.gvl.txt"): + for name in ( + "Main.txt", + "Main.Method.txt", + "Main.gvl.txt", + "Library Manager.libraries.txt", + "Visualization Manager.service.txt", + ): handle = io.open(os.path.join(workspace, name), "w", encoding="utf-8") handle.write("PROGRAM Main\n") handle.close() From d1c0f61b53bd222710dd822882bb6230a2fd15f0 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 22:06:09 +1000 Subject: [PATCH 56/91] say so when the network list cannot be used, and correct a borrowed reason The alignment skipped a file holding more than one renderable POU without saying anything: the native list belongs to one POU and carries nothing to say which, so it cannot be used - but that is a failure to line the two up, not a reason to stay quiet. The README already promised the file says so whenever they cannot be lined up. It does now, and the failure is counted in the summary like any other. A fixture with two POUs in one file covers it. The visualisation manager export came across from the other branch with a reason that is not true: recursive=True does not reach the key configuration in a child object, because that lives inside the manager entry itself. It reaches the target and web visualisations. --- src/graphical_export.py | 24 +++++++------- src/import_export.py | 5 +-- .../tests/fixtures/two_pous.plcopen.xml | 32 +++++++++++++++++++ tools/ladder/tests/test_export.py | 14 ++++++++ 4 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 tools/ladder/tests/fixtures/two_pous.plcopen.xml diff --git a/src/graphical_export.py b/src/graphical_export.py index 008902a..bc1002c 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -194,19 +194,19 @@ def render_plcopen(plcopen_path, declaration_text=None, member_name=None, native pous[0][0].declaration_text = declaration_text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") STATS["parse_seconds"] += time.time() - started - # The editor's own network list, where one is available. Only for a file - # holding a single renderable POU: the list belongs to one POU, and there - # is nothing in it to say which. + # The editor's own network list, where one is available. It belongs to one + # POU and carries nothing to say which, so a file holding more than one + # renderable POU cannot use it - and that counts as a failure to line them + # up rather than as a reason to say nothing. warnings = [] - if native_path is not None and len(pous) == 1: - native = native_networks.read_networks(native_path) - if native: - aligned = native_networks.align(native, pous[0][0].networks) - if aligned is None: - STATS["alignment_failures"] += 1 - warnings.append(ALIGNMENT_WARNING) - else: - pous[0][0].networks = aligned + native = native_networks.read_networks(native_path) if native_path is not None else None + if native: + aligned = native_networks.align(native, pous[0][0].networks) if len(pous) == 1 else None + if aligned is None: + STATS["alignment_failures"] += 1 + warnings.append(ALIGNMENT_WARNING) + else: + pous[0][0].networks = aligned # A member's export carries the parent's declaration, not its own, so # both renderings have to open by saying whose declaration they show. diff --git a/src/import_export.py b/src/import_export.py index fcdb950..1d14074 100644 --- a/src/import_export.py +++ b/src/import_export.py @@ -402,8 +402,9 @@ def export_visualisation_manager(child_obj, parent_obj, parent_folder_path, expo it was dropped from the export entirely. Read-only is the middle ground: the .service.txt suffix is ignored by the importer by construction (dispatch is on .xml and .st), the project template still carries the real - object, and the configuration becomes reviewable. recursive=True because - the target/web visualisations and the key configuration live under it. + object, and the configuration becomes reviewable. recursive=True for the + target and web visualisations that live under the manager; the key + configuration is inside the manager entry itself, not in a child. Best-effort: a failure warns and the export carries on. """ diff --git a/tools/ladder/tests/fixtures/two_pous.plcopen.xml b/tools/ladder/tests/fixtures/two_pous.plcopen.xml new file mode 100644 index 0000000..1d4a07f --- /dev/null +++ b/tools/ladder/tests/fixtures/two_pous.plcopen.xml @@ -0,0 +1,32 @@ + + + + + + + + // Section header: E-STOP CHAIN (documentation-only network) + Title of network one + networktitle + // second network comment + Title of network two + networktitle + xIn + xOut + + + + + + // Section header: E-STOP CHAIN (documentation-only network) + Title of network one + networktitle + // second network comment + Title of network two + networktitle + xInTwo + xOutTwo + + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 9917257..519f77c 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -416,6 +416,20 @@ def native_of(pou_path): ) +# A file holding more than one renderable POU cannot use the list at all: it +# belongs to one POU and says nothing about which. That is a failure to line +# them up, not a reason to say nothing - the file has to carry the warning +# either way, or the README's promise that it always says so is false. +graphical_export.reset_stats() +TWO_POUS = os.path.join(HERE, "fixtures", "two_pous.plcopen.xml") +NATIVE = os.path.join(HERE, "fixtures", "native_networks.xml") +two_pou_lines = graphical_export.render_plcopen(TWO_POUS, None, None, NATIVE) +check("two POUs in one file: the rendering warns", graphical_export.ALIGNMENT_WARNING in two_pou_lines) +check_equal("two POUs in one file: the failure is counted", graphical_export.STATS["alignment_failures"], 1) +check("two POUs in one file: both are still drawn", "xOutTwo" in "".join(two_pou_lines)) +graphical_export.reset_stats() + + # The alignment refuses rather than guesses. One body more on the parsed side # than the native list accounts for means an assumption broke, and a silently # misnumbered file is worse than one that says it could not tell. From 752cd126bd6168177e45283424ed5371a6e49eba Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 22:09:38 +1000 Subject: [PATCH 57/91] guard against an empty export, and name what was skipped Cherry-picked from the work on PR #38: b180cf5 and a2d4ccf. An export that wrote no files replaced the previous export with an empty folder, destroying it. Export Lib To Files on a device project is the way in: its walker only exports objects directly under the project root, and a device project keeps everything under Devices, so the walk produces nothing and the swap wipes what was there. It now refuses, leaves the folder alone and says why. The summary said "skipped 1" without saying what, which reads as a fault rather than as the SFC and CFC POUs having no renderer. It names them. --- CHANGELOG.md | 1 + src/graphical_export.py | 16 +++++++++++++ src/script_lib_export_to_files.py | 12 ++++++++++ src/util.py | 26 ++++++++++++++++++++ tools/ladder/tests/test_export.py | 40 +++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d1c952..44dce75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. +- An export that produces no files no longer replaces the previous export folder with an empty one; it fails with an explanation and leaves the folder untouched. `Export Lib To Files` on a device project was the trigger: its walker only exports objects directly under the project root, so on a device project it exported nothing and wiped the existing export. That script now also refuses a project with devices up front and points to `Export To Files`. - The Library Manager exports its reference list as a read-only `.libraries.txt` (name, version, vendor per line). Library behaviour itself is not exportable, but any bench check of a library needs to know exactly which version the project resolves. - The Visualization Manager exports natively as a read-only `.service.txt`, recursively, so the global hotkey (key configuration) mapping and target/web visualization settings become reviewable. It is still never imported - importing it raises interactive overwrite dialogs, which is why earlier versions dropped it from the export entirely - and the project template continues to carry the real object. diff --git a/src/graphical_export.py b/src/graphical_export.py index bc1002c..0f6605e 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -63,9 +63,15 @@ STATS = dict(EMPTY_STATS) +# Names behind STATS["skipped"], so the summary can say *what* was skipped. +# A bare count reads like something went missing; a name plus the reason +# (SFC/CFC have no renderer) answers the question before it is asked. +SKIPPED_POUS = [] + def reset_stats(): STATS.update(EMPTY_STATS) + del SKIPPED_POUS[:] def summary(): @@ -91,6 +97,12 @@ def summary(): STATS["skipped"], ) ) + if SKIPPED_POUS: + shown = SKIPPED_POUS[:6] + names = ", ".join(shown) + if len(SKIPPED_POUS) > len(shown): + names += ", +%d more" % (len(SKIPPED_POUS) - len(shown)) + line += " (no renderable body - SFC/CFC: %s)" % names # Falling back to the rebuilt declaration is silent otherwise, and it # costs every comment, pragma and attribute in the file. Say so. if STATS["fallback_declarations"]: @@ -325,6 +337,10 @@ def write_rendered_text(obj, base_path, member_name=None): + " carries no renderable body for the member itself;" + " nothing written - review the native xml" ) + else: + # Members announce themselves above; these are the SFC/CFC + # bodies no renderer exists for. + SKIPPED_POUS.append(obj.get_name()) STATS["skipped"] += 1 return False diff --git a/src/script_lib_export_to_files.py b/src/script_lib_export_to_files.py index 26ae2c8..b298655 100644 --- a/src/script_lib_export_to_files.py +++ b/src/script_lib_export_to_files.py @@ -72,6 +72,18 @@ def export_child(child_obj, parent_obj, parent_folder_path): src_folder = get_src_folder(scriptengine.projects.primary) print("Writing to: " + src_folder) + # A device project keeps its objects under Devices, which this walker never + # enters - the "export" would be empty. Refuse up front with a pointer to + # the right button instead of touching anything on disk. + for child_obj in scriptengine.projects.primary.get_children(): + if get_object_type(child_obj) == ObjectType.DEVICE: + raise ValueError( + "This project has a device ('" + + child_obj.get_name() + + "'), so it is not a library project. Use Export To Files instead;" + + " Export Lib To Files only exports objects directly under the project root." + ) + staging_folder = begin_export_folder(src_folder) for child_obj in scriptengine.projects.primary.get_children(): diff --git a/src/util.py b/src/util.py index f937be4..9d0e03b 100644 --- a/src/util.py +++ b/src/util.py @@ -18,6 +18,11 @@ class ExportFolderLockedError(EnvironmentError): preserved in the staging folder.""" +class NothingExportedError(EnvironmentError): + """The export produced no files at all, so the existing export folder was + left untouched rather than being replaced with an empty one.""" + + def begin_export_folder(target_folder): # The export is written into a sibling staging folder and only swapped into # place once it completes, so a locked target folder or a mid-export crash @@ -42,7 +47,28 @@ def _sync_export_files(staging_folder, target_folder): shutil.copy2(os.path.join(dir_path, file_name), os.path.join(destination, file_name)) +def _folder_has_files(folder): + for _dir_path, _dir_names, file_names in os.walk(folder): + if file_names: + return True + return False + + def finalize_export_folder(target_folder, staging_folder): + # An export that wrote no files must never replace the previous export: + # swapping in an empty staging folder silently destroys it. Seen when + # Export Lib To Files runs on a device project - its walker only exports + # objects directly under the project root, and a device project keeps + # everything under Devices, so the walk produces nothing. + if not _folder_has_files(staging_folder): + shutil.rmtree(staging_folder) + raise NothingExportedError( + "Nothing was exported, so the existing export folder was left untouched: " + + target_folder + + ". Export To Files needs a device project (objects under a Device); Export Lib To Files" + + " needs a library project (objects directly under the project root)." + ) + backup_folder = target_folder + EXPORT_BACKUP_SUFFIX try: if os.path.exists(target_folder): diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 519f77c..8d8f635 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -160,6 +160,9 @@ def read(path): check_equal("each render is counted", graphical_export.STATS["rendered"], 2) check_equal("the skipped sfc is counted", graphical_export.STATS["skipped"], 1) check("the summary names both costs", "CODESYS export_xml" in graphical_export.summary()) + # A bare "skipped 1" reads like something went missing; the summary must + # say which POU and why. + check("the summary names the skipped pou", "SFC/CFC: SFC_TEST" in graphical_export.summary()) graphical_export.reset_stats() check_equal("reset clears the counts", graphical_export.STATS["rendered"], 0) @@ -592,6 +595,43 @@ def remove(self): shutil.rmtree(workspace) +# --- an empty export must never destroy the previous one --------------------- + +# Export Lib To Files on a device project walks nothing (its objects all live +# under Devices), and the empty staging folder then swapped in over the real +# export, wiping it. The swap now refuses an empty staging outright. +import util # noqa: E402 + +workspace = tempfile.mkdtemp() +try: + target = os.path.join(workspace, "Project") + os.mkdir(target) + handle = io.open(os.path.join(target, "KEEP.st"), "w", encoding="utf-8") + handle.write(u"PROGRAM Keep\n") + handle.close() + + staging = util.begin_export_folder(target) + try: + util.finalize_export_folder(target, staging) + check("an empty export is refused", False, "finalize accepted an empty staging folder") + except util.NothingExportedError as error: + check("an empty export is refused", True) + check("the refusal names the preserved folder", target in str(error)) + check("the previous export survives", os.path.exists(os.path.join(target, "KEEP.st"))) + check("the empty staging folder is cleaned up", not os.path.exists(staging)) + + # A real export must still swap in exactly as before. + staging = util.begin_export_folder(target) + handle = io.open(os.path.join(staging, "NEW.st"), "w", encoding="utf-8") + handle.write(u"PROGRAM New\n") + handle.close() + util.finalize_export_folder(target, staging) + check("a real export still swaps in", os.path.exists(os.path.join(target, "NEW.st"))) + check("the swap still replaces the old copy", not os.path.exists(os.path.join(target, "KEEP.st"))) +finally: + shutil.rmtree(workspace) + + # --- the importer ignores the derived file --------------------------------- # This is the contract that keeps the round trip intact. import_directory_child From 5fdc9c6d9578ceaf1ee1db232dd0e54dc23d9b0e Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 7 Sep 2026 22:20:44 +1000 Subject: [PATCH 58/91] close the outstanding notes from the review Six items Geoff logged as not blocking the merge. Taken together because they are all the same kind of thing: a rendering or a report that is quietly less true than it looks. A rebuilt declaration says so. When the export carries no plaintext declaration the structured interface is used instead, which has nowhere to put a comment, a pragma or an attribute - and the file gave no sign. It opens with one line saying so now. A variable whose type the export omits reads UNKNOWN rather than BOOL: calling it BOOL states a type the program may not have, and UNKNOWN does not compile, which is the point. A rendering that failed was counted as neither rendered nor skipped, so the summary read "skipped 0" straight after warning about it. It says how many failed. An LD EXECUTE box drew an empty rectangle and dropped every line of its body. The whole of such a box is inline ST in an addData element, which the FBD renderer has always read. The ladder one reads it too now, and the ST guards it with the rung condition rather than emitting a call to a box that has no body. A character outside the Basic Multilingual Plane is a surrogate pair in the UTF-16 strings IronPython holds, and encoding it a unit at a time wrote two numeric references for one character. A lone surrogate is not a legal XML character: System.Xml accepts it and expat does not, so the export parsed inside CODESYS and nowhere else. The pair is recombined first. Both interpreters now give "😀" for one emoji, where IronPython gave "��". The export test double took three arguments where the documented SP11 overload takes four, so the first attempt never bound, the tests exercised a fallback, and swapping the two booleans would have passed. It takes the full signature, and swapping them now fails two checks. A locked export folder falls back to copying the staged files in, which left behind anything this export did not write - a rendering from a previous export beside a new native xml, describing a POU that has since changed. Those are removed, and any that cannot be are named. --- src/ld_render.py | 31 ++++++- src/model.py | 4 + src/parse_ld.py | 3 + src/plcopen.py | 42 +++++++-- src/st_render.py | 11 +++ src/util.py | 37 +++++++- .../codesys/FbTesting.art.expected.txt | 2 +- .../codesys/FbTesting.st.expected.txt | 2 +- .../fixtures/codesys/LDTesting.expected.txt | 1 + .../codesys/LDTesting.st.expected.txt | 2 +- .../tests/fixtures/ld_execute.plcopen.xml | 91 +++++++++++++++++++ .../tests/fixtures/motor_control.expected.txt | 2 +- tools/ladder/tests/test_export.py | 59 +++++++++++- tools/ladder/tests/test_fbd.py | 6 +- tools/ladder/tests/test_ladder.py | 28 +++++- tools/ladder/tests/test_xmlbackend.py | 21 +++++ 16 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld_execute.plcopen.xml diff --git a/src/ld_render.py b/src/ld_render.py index 4535e23..6b5a801 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -380,6 +380,22 @@ def _render_wire(expr): return lines +def _inline_st(expr, found): + """Collect the inline ST of any EXECUTE box on this rung.""" + if isinstance(expr, Series): + for item in expr.items: + _inline_st(item, found) + elif isinstance(expr, Parallel): + for branch in expr.branches: + _inline_st(branch, found) + elif isinstance(expr, Element): + for pin_block in expr.pin_blocks: + _inline_st(pin_block, found) + if expr.st_code: + found.append(expr) + return found + + def render_rung(expr): """Render one rung, bounded by the power rails. @@ -391,6 +407,11 @@ def render_rung(expr): for pin_block in _pin_block_rungs(expr, []): lines.extend(_render_wire(pin_block)) lines.extend(_render_wire(expr)) + # An EXECUTE box is nothing but inline ST; the box on its own is an empty + # rectangle where the logic should be. + for element in _inline_st(expr, []): + lines.append("") + lines.extend(" " + line for line in element.st_code) return lines @@ -406,8 +427,16 @@ def render_declaration(pou): if pou.declaration_text: return pou.declaration_text.split("\n") + # The rebuilt form is not what CODESYS holds: the structured interface has + # nowhere to put a comment, a pragma or an attribute, and a variable whose + # type the export omits comes back as UNKNOWN. The summary says how many + # POUs this happened to; the file has to say that it is one of them. keyword = POU_TYPE_KEYWORDS.get(pou.pou_type, "PROGRAM") - lines = [keyword + " " + pou.name] + lines = [ + "(* Declaration rebuilt from the structured interface:" + " comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *)", + keyword + " " + pou.name, + ] scope = None for variable in pou.variables: diff --git a/src/model.py b/src/model.py index 0d7e28f..8ff25c3 100644 --- a/src/model.py +++ b/src/model.py @@ -466,6 +466,7 @@ def __init__( negated_outputs=None, stored_outputs=None, pin_blocks=None, + st_code=None, ): self.kind = kind self.label = label @@ -497,6 +498,9 @@ def __init__( # rendered and emitted before this block, in the order the pins were # wired, because that is the order they execute in. self.pin_blocks = pin_blocks if pin_blocks is not None else [] + # An EXECUTE box carries inline ST as its whole body. Drawing the box + # without it leaves an empty rectangle where the logic should be. + self.st_code = st_code if st_code is not None else [] @property def title(self): diff --git a/src/parse_ld.py b/src/parse_ld.py index f89e05d..c553f3e 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -37,6 +37,7 @@ attr, block_connections, block_outputs, + block_st_code, child_text, comment_text, declaration_text, @@ -120,6 +121,7 @@ def parse_ld_body(body_elem): type_name=child.get("typeName") if is_block else None, instance_name=child.get("instanceName") if is_block else None, outputs=block_outputs(child) if is_block else None, + st_code=block_st_code(child) if is_block else None, negated_outputs=negated_output_pins(child) if is_block else None, stored_outputs=stored_output_pins(child) if is_block else None, ) @@ -270,6 +272,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn): # via_pin is set by whatever consumed this block; a block terminating # the rung has none. output_wired=via_pin is not None, + st_code=list(node.st_code), power_negated=power_negated, power_edge=power_edge, negated_outputs=set(node.negated_outputs), diff --git a/src/plcopen.py b/src/plcopen.py index 0437878..f0479ba 100644 --- a/src/plcopen.py +++ b/src/plcopen.py @@ -241,15 +241,22 @@ def network_title(elem): def _type_name(var_elem): + """The declared type, or UNKNOWN where the export does not carry one. + + Not BOOL: a variable whose type is missing is a variable whose type this + file does not know, and calling it BOOL states a type the program may not + have. UNKNOWN does not compile, which is the point - the rendered + declaration is for reading, and it should not read as fact. + """ type_elem = find_child(var_elem, "type") if type_elem is None: - return "BOOL" + return "UNKNOWN" for child in type_elem: name = tag(child) if name == "derived": return child.get("name") or "UNKNOWN" return name - return "BOOL" + return "UNKNOWN" def _initial_value(var_elem): @@ -412,14 +419,37 @@ def _to_ascii(data): # preserves every byte as a character so nothing is lost. text = data.decode("latin-1") + # A character outside the Basic Multilingual Plane is a surrogate pair in + # a UTF-16 string, which is what IronPython has. Encoding that pair one + # unit at a time writes two references for one character, and a lone + # surrogate is not a legal XML character: System.Xml lets it through, + # expat rejects the document outright. So the same export parses inside + # CODESYS and fails everywhere else. + if any(0xD800 <= ord(character) <= 0xDBFF for character in text): + return _references(text) + try: # This error handler does exactly the job, natively. return text.encode("ascii", "xmlcharrefreplace") except (LookupError, ValueError): - pieces = [] - for character in text: - pieces.append(character if ord(character) < 128 else "&#%d;" % ord(character)) - return "".join(pieces).encode("ascii") + return _references(text) + + +def _references(text): + """Numeric references, one per character, surrogate pairs recombined.""" + pieces = [] + index = 0 + while index < len(text): + code = ord(text[index]) + step = 1 + if 0xD800 <= code <= 0xDBFF and index + 1 < len(text): + low = ord(text[index + 1]) + if 0xDC00 <= low <= 0xDFFF: + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00) + step = 2 + pieces.append(text[index] if code < 128 else "&#%d;" % code) + index += step + return "".join(pieces).encode("ascii") # XML 1.0 forbids these outright - they cannot even be written as a numeric diff --git a/src/st_render.py b/src/st_render.py index 517ab24..889cf50 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -73,6 +73,17 @@ def rung_to_statements(rung): # rung's condition. for pin_block in item.pin_blocks: statements.extend(rung_to_statements(pin_block)) + if item.st_code: + # An EXECUTE box is inline ST already, and the rung condition + # is what decides whether it runs. Emitting a call to a box + # that has no body loses the whole of it. + if condition: + statements.append("IF %s THEN" % condition) + statements.extend(" " + line for line in item.st_code) + statements.append("END_IF") + else: + statements.extend(item.st_code) + continue args = [] for pin, label in item.input_pins: # A label of None is the power pin, fed by the rung so far. diff --git a/src/util.py b/src/util.py index 9d0e03b..269dbc6 100644 --- a/src/util.py +++ b/src/util.py @@ -34,10 +34,33 @@ def begin_export_folder(target_folder): return staging_folder +def _remove_stale_files(staging_folder, target_folder): + """Delete target files this export did not write. Returns those that stay. + + The swap path replaces the folder wholesale, so anything the export did + not produce is gone by definition. The sync path has to do the same, or a + rendering from a previous export is left beside a new native xml and + describes a POU that has since changed - with nothing to say so. Renaming + a folder is what a lock stops; deleting a file inside it usually still + works, and where it does not the file is named rather than left silent. + """ + stale = [] + for dir_path, _dir_names, file_names in os.walk(target_folder): + relative = os.path.relpath(dir_path, target_folder) + source = staging_folder if relative == "." else os.path.join(staging_folder, relative) + for file_name in file_names: + if os.path.exists(os.path.join(source, file_name)): + continue + try: + os.remove(os.path.join(dir_path, file_name)) + except (OSError, IOError): + stale.append(os.path.join(dir_path, file_name)) + return stale + + def _sync_export_files(staging_folder, target_folder): - # Overwrite sync only; files that exist in the target but not in staging are - # left in place, because deleting them would require the same folder access - # that already failed for the rename. + # Overwrite sync; anything in the target the export did not write is + # removed afterwards by _remove_stale_files. for dir_path, dir_names, file_names in os.walk(staging_folder): relative = os.path.relpath(dir_path, staging_folder) destination = target_folder if relative == "." else os.path.join(target_folder, relative) @@ -81,6 +104,7 @@ def finalize_export_folder(target_folder, staging_folder): # the target folder. Fall back to copying the staged files into it. try: _sync_export_files(staging_folder, target_folder) + stale = _remove_stale_files(staging_folder, target_folder) shutil.rmtree(staging_folder) except (OSError, IOError): raise ExportFolderLockedError( @@ -93,6 +117,13 @@ def finalize_export_folder(target_folder, staging_folder): + str(rename_error) ) print("Export folder " + target_folder + " is in use; synced the staged files into it instead of swapping folders.") + if stale: + print( + "WARNING: " + + str(len(stale)) + + " file(s) this export did not write could not be removed and are now stale: " + + ", ".join(stale[:6]) + ) return try: if os.path.exists(backup_folder): diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt index 3e468b9..7cb3c5f 100644 --- a/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt @@ -1,3 +1,4 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) PROGRAM FB_TESTING VAR CONSTANT uiMinVoltage : UINT := 5000; @@ -29,4 +30,3 @@ uiMinVoltage──────┤In2 │ │ │ │ └──────────┘ │ │ └─────────────────────────────────────────┘ T#5S────────────────────────────┤PT ET│ └───────────┘ - diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt index 272b9fa..9768ca3 100644 --- a/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt @@ -1,3 +1,4 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) PROGRAM FB_TESTING VAR CONSTANT uiMinVoltage : UINT := 5000; @@ -16,4 +17,3 @@ uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; (* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) TOF_0(IN := uiCurrSupplyVolt > uiMinVoltage, PT := T#5S); fbSupplySwitch(eMode := ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH, xValue := TOF_0.Q); - diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt index 25694cf..8505455 100644 --- a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt @@ -1,3 +1,4 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) PROGRAM LD_TEST VAR Sensor1 : BOOL; diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt index 5e22665..f4e91c4 100644 --- a/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt @@ -1,3 +1,4 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) PROGRAM LD_TEST VAR Sensor1 : BOOL; @@ -16,4 +17,3 @@ IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF TON_0(IN := PowerOn, PT := T#5S); CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); IF CTU_0.Q THEN PowerOff := FALSE; END_IF - diff --git a/tools/ladder/tests/fixtures/ld_execute.plcopen.xml b/tools/ladder/tests/fixtures/ld_execute.plcopen.xml new file mode 100644 index 0000000..f95da05 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld_execute.plcopen.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xRun + + + + + + + + + + + + + + + + + + + + execute + + + iCount := iCount + 1; +IF iCount > 10 THEN +iCount := 0; +END_IF + + + + + + + + + + diff --git a/tools/ladder/tests/fixtures/motor_control.expected.txt b/tools/ladder/tests/fixtures/motor_control.expected.txt index 983b232..1b01467 100644 --- a/tools/ladder/tests/fixtures/motor_control.expected.txt +++ b/tools/ladder/tests/fixtures/motor_control.expected.txt @@ -1,3 +1,4 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) PROGRAM Motor_Control VAR Start_PB : BOOL; @@ -23,4 +24,3 @@ END_VAR (* Network 3 *) │ Reset_PB Ack Fault_Latch ├─────┤ ├──────┤/├───────(R)───────┤ - diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 8d8f635..89da7c9 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -67,8 +67,13 @@ def __init__(self, name, source=None, declaration=None): def get_name(self): return self._name - def export_xml(self, path, recursive, declarations_as_plaintext=None): - self.export_calls.append((path, recursive, declarations_as_plaintext)) + # The documented SP11 overload, in full: + # export_xml(path, recursive, export_folder_structure, declarations_as_plaintext). + # Taking fewer arguments than that made the first attempt fail to bind, so + # the tests exercised a fallback and a swap of the two booleans would have + # gone unnoticed - which is the one mistake this signature can hide. + def export_xml(self, path, recursive, export_folder_structure=None, declarations_as_plaintext=None): + self.export_calls.append((path, recursive, export_folder_structure, declarations_as_plaintext)) if self._source is None: raise RuntimeError("export_xml exploded") shutil.copyfile(self._source, path) @@ -118,11 +123,13 @@ def read(path): check("ladder pou is rendered", graphical_export.write_rendered_text(pou, base) is True) check("derived file lands beside the xml", os.path.exists(base + ".txt")) check_equal("export_xml is asked for a single object", pou.export_calls[0][1], False) + check_equal("the documented four-argument overload binds", len(pou.export_calls[0]), 4) + check_equal("no folder structure is asked for", pou.export_calls[0][2], False) # Without this the declaration loses comments, pragmas and attributes. - check_equal("plaintext declarations are requested", pou.export_calls[0][2], True) + check_equal("plaintext declarations are requested", pou.export_calls[0][3], True) content = read(base + ".txt") - check("derived file leads with the declaration", content.startswith("PROGRAM LD_TEST")) + check("derived file leads with the declaration", "PROGRAM LD_TEST" in content.split("\n")[1]) # The diagram file holds the diagram. The two notations were written into # one file at first and that was worse, not better: the same network twice, # one rendering after the other, is harder to read than either alone. @@ -632,6 +639,50 @@ def remove(self): shutil.rmtree(workspace) +# --- a locked folder must not leave last export's files behind --------------- + +# When the folder cannot be renamed the staged files are copied into it +# instead. Copying alone leaves anything the export did not write in place: a +# rendering from a previous export sitting beside a new native xml, describing +# a POU that has since changed, with nothing to say so. +workspace = tempfile.mkdtemp() +try: + target = os.path.join(workspace, "Project") + os.makedirs(os.path.join(target, "application")) + for name in ("application/GONE.txt", "application/GONE.xml"): + handle = io.open(os.path.join(target, name), "w", encoding="utf-8") + handle.write(u"from the previous export\n") + handle.close() + + staging = util.begin_export_folder(target) + os.makedirs(os.path.join(staging, "application")) + handle = io.open(os.path.join(staging, "application", "STAYS.xml"), "w", encoding="utf-8") + handle.write(u"from this export\n") + handle.close() + + real_rename = os.rename + + def locked_rename(source, destination): + # Only the folder swap is blocked, which is what a handle on the + # folder does; the files inside it stay writable. + if os.path.isdir(source): + raise OSError("folder is locked") + return real_rename(source, destination) + + os.rename = locked_rename + try: + util.finalize_export_folder(target, staging) + finally: + os.rename = real_rename + + check("the locked path still writes this export", os.path.exists(os.path.join(target, "application", "STAYS.xml"))) + check("a stale rendering is removed", not os.path.exists(os.path.join(target, "application", "GONE.txt"))) + check("a stale source is removed too", not os.path.exists(os.path.join(target, "application", "GONE.xml"))) + check("the staging folder is cleaned up", not os.path.exists(staging)) +finally: + shutil.rmtree(workspace) + + # --- the importer ignores the derived file --------------------------------- # This is the contract that keeps the round trip intact. import_directory_child diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index bb7913c..9c92d3b 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -102,12 +102,12 @@ def check_golden(name, rendered, golden_path): hostile_comment = "// first\nsecond *) third" check_equal( "network comments cannot break generated block comments", - fbd_render.render_pou(Pou("HOSTILE", "program", networks=[Network(hostile_comment, [Signal("x")])]))[2], + fbd_render.render_pou(Pou("HOSTILE", "program", networks=[Network(hostile_comment, [Signal("x")])]))[3], "(* Network 1: first second * ) third *)", ) check_equal( "ST network comments cannot break generated block comments", - st_render.render_pou(Pou("HOSTILE", "program", networks=[Network(hostile_comment, [Signal("x")])]))[2], + st_render.render_pou(Pou("HOSTILE", "program", networks=[Network(hostile_comment, [Signal("x")])]))[3], "(* Network 1: first second * ) third *)", ) @@ -118,7 +118,7 @@ def check_golden(name, rendered, golden_path): "network titles cannot break generated block comments", fbd_render.render_pou( Pou("HOSTILE", "program", networks=[Network("", [Signal("x")], title=hostile_title)]) - )[2], + )[3], "(* Network 1: one * ) two *)", ) diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 694c236..df96445 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -141,7 +141,10 @@ def check_equal(name, actual, expected): rendered = render_pou(pou) check("no trailing whitespace", all(line == line.rstrip() for line in rendered)) -check("declaration comes first", rendered[0] == "PROGRAM Motor_Control") +# A rebuilt declaration says so before it says anything else: it is not what +# CODESYS holds, and nothing else in the file would tell you. +check("a rebuilt declaration is marked", rendered[0].startswith("(* Declaration rebuilt")) +check("declaration comes first", rendered[1] == "PROGRAM Motor_Control") # Referenced through the charset table rather than as literal glyphs: this # source file has to stay pure ASCII for IronPython 2.7 to load it at all. @@ -337,6 +340,26 @@ def check_golden(name, rendered_lines, golden_path): check_equal("sp11 two coils: the timer is called once", len([l for l in sp11_st if l.startswith("TON_0(")]), 1) +# --- an EXECUTE box on a rung ------------------------------------------------ + +# An EXECUTE box has no body of its own: the whole of it is inline ST in an +# addData element. The FBD renderer has always drawn it; the ladder one drew +# an empty rectangle and dropped every line of the logic. +LD_EXECUTE = os.path.join(FIXTURES, "ld_execute.plcopen.xml") +execute_pou = parse_pous(LD_EXECUTE)[0] +execute_st = st_render.render_pou(execute_pou) +execute_art = render_pou(execute_pou) + +check_equal("execute: the inline ST is read", len(execute_pou.networks[0].outputs[0].items[-1].st_code), 4) +check("execute: the diagram shows the body", any("iCount := iCount + 1;" in line for line in execute_art)) +check("execute: the box is still drawn", any("EXECUTE" in line for line in execute_art)) +# The rung condition is what decides whether the box runs, so it guards the +# body rather than being dropped for looking redundant. +check("execute: the ST guards the body with the rung", "IF xRun THEN" in execute_st) +check("execute: the body reaches the ST", any("iCount := iCount + 1;" in line for line in execute_st)) +check("execute: no call to a box with no body", not any(line.startswith("EXECUTE(") for line in execute_st)) + + # --- edge detection on a block pin ------------------------------------------- # A contact has always carried its P; the block pin the rung's power enters @@ -547,7 +570,8 @@ def with_interface(interface): # Older exports carry no plaintext, and must still render something. structured_pou = parse_pous(with_interface(STRUCTURED_INTERFACE))[0] check_equal("no plaintext means none is invented", structured_pou.declaration_text, None) -check_equal("the structured interface is the fallback", render_declaration(structured_pou)[0], "PROGRAM PLAIN") +check_equal("the structured interface is the fallback", render_declaration(structured_pou)[1], "PROGRAM PLAIN") +check("the fallback says it is one", render_declaration(structured_pou)[0].startswith("(* Declaration rebuilt")) check("the fallback still lists the variable", any("xStart : BOOL;" in line for line in render_declaration(structured_pou))) # The shape CODESYS actually writes, confirmed by diagnosing a real project: diff --git a/tools/ladder/tests/test_xmlbackend.py b/tools/ladder/tests/test_xmlbackend.py index 8c566a0..8070114 100644 --- a/tools/ladder/tests/test_xmlbackend.py +++ b/tools/ladder/tests/test_xmlbackend.py @@ -29,6 +29,22 @@ failures = [] +def astral_reference(): + """A character outside the Basic Multilingual Plane, as one reference. + + IronPython holds strings as UTF-16, so such a character is a surrogate + pair, and encoding it a unit at a time writes two numeric references for + one character. A lone surrogate is not a legal XML character: System.Xml + accepts it, expat rejects the whole document - an export that parses + inside CODESYS and nowhere else. + + Written as the UTF-8 bytes a real file holds, so this source stays ASCII + and loads under both interpreters. + """ + data = b"hi \xf0\x9f\x98\x80 there" + return plcopen._to_ascii(data) + + def check(name, condition, detail=""): if condition: print("OK " + name) @@ -69,6 +85,11 @@ def every_fixture(): root = xmlbackend.parse(SAMPLE) +# U+1F600 is one character; it must come back as one reference, not as the +# two surrogates it is stored as. +check_equal("an astral character is one reference", astral_reference(), b"hi 😀 there") +check("no lone surrogate reaches the parser", b"�" not in astral_reference()) + check_equal("namespace is stripped from the tag", plcopen.tag(root), "root") first = list(root)[0] check_equal("attributes read back", first.get("name"), "one") From e6faf7c555af127370fe17db921815fab259e082 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 8 Sep 2026 06:27:05 +1000 Subject: [PATCH 59/91] document what closing the review notes changed The README still said only the export summary warns when a declaration is rebuilt; the rendering says so itself now, on its first line, and a variable whose type the export omits reads UNKNOWN rather than being taken for a BOOL. It said nothing about an EXECUTE box, which shows the inline ST that is its whole body rather than an empty rectangle. The CHANGELOG entry for the staging swap now covers the other half of the locked-folder case: syncing removes what the export did not write, as the swap would have, so a rendering from a previous export is never left beside a newer native xml. --- CHANGELOG.md | 4 ++-- README.md | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44dce75..fbf0ff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,14 @@ ## Unreleased -- Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. +- Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An EXECUTE box shows the inline ST that is its whole body rather than an empty rectangle. Where the export carries no plaintext declaration the rendering is rebuilt from the structured interface and says so on its first line, and a variable whose type the export omits reads `UNKNOWN` rather than being assumed to be a `BOOL`. The export summary reports how many POUs were rendered, how many failed, and which were skipped for having no renderable body. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. - An export that produces no files no longer replaces the previous export folder with an empty one; it fails with an explanation and leaves the folder untouched. `Export Lib To Files` on a device project was the trigger: its walker only exports objects directly under the project root, so on a device project it exported nothing and wiped the existing export. That script now also refuses a project with devices up front and points to `Export To Files`. - The Library Manager exports its reference list as a read-only `.libraries.txt` (name, version, vendor per line). Library behaviour itself is not exportable, but any bench check of a library needs to know exactly which version the project resolves. - The Visualization Manager exports natively as a read-only `.service.txt`, recursively, so the global hotkey (key configuration) mapping and target/web visualization settings become reviewable. It is still never imported - importing it raises interactive overwrite dialogs, which is why earlier versions dropped it from the export entirely - and the project template continues to carry the real object. - Visualisations export as `.vis.xml`. Earlier versions wrote `.xml`, which silently collided with any POU of the same name (a `Main` program plus a `Main` visualisation is common). Old plain `.xml` exports still import correctly; re-exporting once migrates the tracked files. - Visualisation service objects (visualisation manager and related service GUIDs) are recognised and no longer exported. -- `Export To Files` writes the export into a sibling folder named `.codescribe_staging` and only swaps it into place once the export completes. Earlier versions deleted the target folder up front, so a locked folder (an open Explorer window, IDE, git client or antivirus) aborted the export immediately and a mid-export crash left the on-disk copy destroyed. If the target folder is locked and cannot be swapped, the staged files are synced into it instead and the export still succeeds; if that also fails, the error dialog reports the staging folder path, so the completed export is preserved. +- `Export To Files` writes the export into a sibling folder named `.codescribe_staging` and only swaps it into place once the export completes. Earlier versions deleted the target folder up front, so a locked folder (an open Explorer window, IDE, git client or antivirus) aborted the export immediately and a mid-export crash left the on-disk copy destroyed. If the target folder is locked and cannot be swapped, the staged files are synced into it instead and the export still succeeds; if that also fails, the error dialog reports the staging folder path, so the completed export is preserved. Syncing also removes files the export did not write, as the swap would have, so a rendering from a previous export is never left beside a newer native xml; any that cannot be removed are named. - Device-tree sibling devices (Ethernet, Modbus and fieldbus devices that sit next to `Plc Logic` as direct children of the PLC device) are exported to `/devices/.xml`, one file per top-level device. On import, each tracked device is removed and recreated from its exported xml; devices fixed by the device package (for example `Local_IO` and `HMI` on IFM hardware) cannot be removed and are skipped with a message, with their configuration carried by the project template. A `_NO_EXPORT` folder added as a direct child of the PLC device disables the device-tree export. - New `Export Lib To Files` script exports library projects, which keep their objects directly under the project root rather than under a Device. It writes the same on-disk format as `Export To Files`, without the device and application folder levels. Importing a library export back into a project is not yet supported. diff --git a/README.md b/README.md index aff9a71..b705c97 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,14 @@ Ladder and Function Block Diagram POUs have no textual implementation, so they e │ └───────────┘ ``` -The declaration is copied from the original CODESYS declaration source, preserving comments, pragmas, safety-qualified types, and literal spelling. The diagram is derived from PLCopen XML. On older CODESYS versions where the plaintext declaration is unavailable, the declaration is rebuilt from the structured interface and the export summary warns that comments, pragmas, or exact formatting may be missing. +The declaration is copied from the original CODESYS declaration source, preserving comments, pragmas, safety-qualified types, and literal spelling. The diagram is derived from PLCopen XML. On older CODESYS versions where the plaintext declaration is unavailable, the declaration is rebuilt from the structured interface. That form cannot carry comments, pragmas or attributes, so the rendering says on its first line that it is a rebuilt one, and the export summary counts how many POUs it happened to. A variable whose type the export does not carry reads `UNKNOWN` rather than being assumed to be a `BOOL`. This file is **derived and read-only**. The native xml remains the only thing `Import From Files` reads, so editing the `.txt` changes nothing — it exists to make diffs and code review possible. Layout comes from how the elements are wired, not from their coordinates, so moving a block in the CODESYS editor produces no diff. An equivalent-Structured-Text rendering of the same networks is available but not written by the export. The ST states the logic exactly where the diagram can only approximate it - a block read through two of its output pins is one call, and no single-wire diagram can say so - but it is a rendering, not a translation, and must never be fed back into CODESYS. `tools/ladder/write_st.py` writes one from a PLCopen export when it is wanted, and its docstring says how to put it back on the export path. +An EXECUTE box has no body of its own - the whole of it is inline ST carried alongside the box - so the rendering shows that text under the box rather than an empty rectangle, and the equivalent ST guards it with the rung or pin condition that decides whether it runs. + SFC and CFC POUs are not yet rendered; they export as native xml alone. Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor. Each is headed by its title, as the editor heads it, with the network's comment on the line below; a network with no title puts its comment on the number line instead. From 7ad1c824ef14ce7e0cb312d64fb2ce95749701b2 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 8 Sep 2026 07:15:13 +1000 Subject: [PATCH 60/91] keep the export's device and fieldbus xml out of the repo The application folders are what is worth tracking. The device and CAN bus descriptions are 449,000 lines and 36 MB that say nothing about the code under review, and a "git add -A" while they sat untracked on disk swept them in. Ignoring them stops that happening again; a full export still writes them, they are just not committed. --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index c76cea9..d6e2555 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,13 @@ # Review correspondence. Kept beside the checkout, not in it. review/ +# The export's device and fieldbus xml. 449,000 lines of CR711S and CAN bus +# descriptions that say nothing about the code under review, and 36 MB that +# every clone would carry for ever. The application folders are what is +# tracked; a full export still writes these, they are just not committed. +GraphicalTesting/*/devices/ +GraphicalTesting/*/communication/ + .vscode/ # Claude Code's per-developer tool permissions. Machine-specific paths, and From 45057d8c7dded584403d89851d2799134066118e Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 02:15:02 +1000 Subject: [PATCH 61/91] wire each reader of a shared box to the pin it reads A network joined around a shared box stacked its readers in export order and hung them all off one junction column, so the second reader took the row the first one left free. An OR box reading Q was drawn from the ET row while the ST said tmr.Q, and with the readers the other way round ET was drawn into the Q column. An output that read nothing from the box was placed at row 0, beside the title. Readers are now taken pin by pin down the box. The first reader of a pin sits level with it and each later one goes below; each pin has a junction column of its own, the lowest pin's nearest the box, so no wire has to cross another. An output that does not read the box is drawn below the joined one. A reader that takes two pins from the box wires the first and names the second in text. Second review of PR 36, item 4.1. Fixture and its reader-swapped copy added. --- src/fbd_render.py | 276 ++++++++++++------ .../fixtures/r2-1-fbd-shared-box-et-and-q.xml | 11 + .../fixtures/r2-1-fbd-shared-box-q-and-et.xml | 11 + tools/ladder/tests/test_fbd.py | 74 ++++- 4 files changed, 285 insertions(+), 87 deletions(-) create mode 100644 tools/ladder/tests/fixtures/r2-1-fbd-shared-box-et-and-q.xml create mode 100644 tools/ladder/tests/fixtures/r2-1-fbd-shared-box-q-and-et.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 03d9c55..56e5204 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -244,8 +244,35 @@ def _reference(call, pin): # Stands in for a shared box while the branch that reads it is drawn, so the # row the wire arrives on can be found once the branch has been composed. # Composition is text, so a marker in the text is the cheapest way to carry a -# position through it, and it never survives into the output. -MARKER = chr(1) +# position through it, and it never survives into the output. One marker per +# pin, so a branch that reads two pins can say which wire arrives where. They +# are control characters, skipping the ones str.strip and str.split treat as +# whitespace, because a line ending in one is stripped like any other. +MARKERS = [chr(code) for code in list(range(1, 9)) + list(range(14, 28))] + + +class _Shared(object): + """The box a network is joined around, while its readers are composed. + + ``pins`` names the pins a reader may take a wire from; None allows any. + A read of any other pin falls back to naming the box in text, the way a + box already drawn is named, and so does a read once the markers run out. + """ + + def __init__(self, call, pins=None): + self.call = call + self.pins = pins + self.markers = {} + + def marker(self, pin): + """The stand-in for a wire from ``pin``, or None to name it instead.""" + if self.pins is not None and pin not in self.pins: + return None + if pin not in self.markers: + if len(self.markers) >= len(MARKERS): + return None + self.markers[pin] = MARKERS[len(self.markers)] + return self.markers[pin] def _render(node, drawn, subs=None): @@ -261,11 +288,13 @@ def _render(node, drawn, subs=None): """ call = node.call if isinstance(node, OutputRef) else node if isinstance(call, Call): - if subs is not None and id(call) in subs: + pin = node.pin if isinstance(node, OutputRef) else call.active_output + if subs is not None and subs.call is call: # Drawn once already, on the left of this branch; the wire into it # comes from the junction rather than from another copy of the box. - return Block([MARKER], 0) - pin = node.pin if isinstance(node, OutputRef) else call.active_output + marker = subs.marker(pin) + if marker is not None: + return Block([marker], 0) if id(call) in drawn: reference = _reference(call, pin) if reference is not None: @@ -428,25 +457,35 @@ def walk(node): return shared[0] if len(shared) == 1 else None -def _entry_row(block): - """The row a substituted box's wire arrives on, with the marker removed.""" +def _entry_rows(block, shared): + """[(row, pin)] for every wire into the shared box, in row order. + + Each marker is replaced by a piece of wire, so the branch's own lead-in + joins up with the junction it is placed against. + """ chars = charset.active() + entries = [] for row, line in enumerate(block.lines): - if MARKER in line: - block.lines[row] = line.replace(MARKER, chars["H"]) - return row - return None + for pin, marker in shared.markers.items(): + if marker in line: + line = line.replace(marker, chars["H"]) + entries.append((row, pin)) + block.lines[row] = line + entries.sort(key=lambda entry: entry[0]) + return entries -def _reads_pin(tree, call): - """The pin a tree reads ``call`` through, or None if it does not read it.""" +def _reads_pins(tree, call): + """The pins a tree reads ``call`` through, in the order it meets them.""" found = [] def walk(node): - if isinstance(node, OutputRef) and node.call is call: - found.append(node.pin) - return inner = node.call if isinstance(node, OutputRef) else node + if inner is call: + pin = node.pin if isinstance(node, OutputRef) else call.active_output + if pin not in found: + found.append(pin) + return if isinstance(inner, Call): for _pin, source in inner.inputs: if source is not None: @@ -459,95 +498,154 @@ def walk(node): walk(node.condition) walk(tree) - return found[0] if found else None + return found + +def _split_readers(outputs, call): + """([(tree, pins)], [tree]) - the outputs that read the shared box, and the rest.""" + readers = [] + others = [] + for tree in outputs: + pins = _reads_pins(tree, call) + if pins: + readers.append((tree, pins)) + else: + others.append(tree) + return readers, others -def _render_joined(network, call): - """Draw a shared box once and branch its readers off the pin they read. - The box goes on the left; each reader is composed on its own to the right - of it and hangs off a junction column, level with the row its wire leaves - the box on. Readers of the same pin share the column, which is what the - editor draws. +def _place_branches(branches, pin_rows, default_row): + """{index: top row} for each composed branch. + + Branches are taken pin by pin down the box, and stacked so no two overlap: + the first reader of a pin sits level with the pin, and each later one goes + under whatever came before it. A branch that reads several pins is placed + by its topmost pin, and last among that pin's readers - the one position + from which its other wires can arrive without crossing anything. """ - chars = charset.active() - outputs = list(network.outputs) - drawn = set([id(call)]) - subs = {id(call): call} + def row_of(pin): + return pin_rows.get(pin, default_row) - branches = [] - for tree in outputs: - pin = _reads_pin(tree, call) - block = _render(tree, drawn, subs if pin is not None else None) - branches.append((block, _entry_row(block) if pin is not None else None, pin)) + top_pin = [] + for _block, entries in branches: + top_pin.append(min(entries, key=lambda entry: (row_of(entry[1]), entry[0]))[1]) - source = _render_call(call, None, set()) - pin_rows = source.pin_rows + order = [] + for pin in sorted(set(top_pin), key=row_of): + group = [index for index, read in enumerate(top_pin) if read == pin] + group.sort(key=lambda index: len(set(read for _row, read in branches[index][1])) > 1) + order.extend(group) - # Place each branch: the first at the row of the pin it reads, the rest - # stacked below whatever came before, so no two branches overlap. - placed = [] + tops = {} next_top = None - for block, entry, pin in branches: - if entry is None: - # Reads nothing from the shared box - it is its own drawing, and - # goes below everything rather than joining the column. - top = 0 if next_top is None else next_top - elif next_top is None: - top = pin_rows.get(pin, source.connect_row) - entry - else: - top = next_top - placed.append((block, entry, top)) - next_top = top + len(block.lines) + for index in order: + block, entries = branches[index] + entry = min([row for row, read in entries if read == top_pin[index]]) + wanted = row_of(top_pin[index]) - entry + tops[index] = wanted if next_top is None else max(wanted, next_top) + next_top = tops[index] + len(block.lines) + return tops + + +def _reader_rows(branches, tops): + """{pin: [row]} - the rows the wires from each pin arrive on, in order.""" + rows = {} + for index, block_and_entries in enumerate(branches): + for row, pin in block_and_entries[1]: + rows.setdefault(pin, []).append(tops[index] + row) + for pin in rows: + rows[pin].sort() + return rows + + +def _junction(row, position, columns, reader_rows, row_of): + """One cell of the junction columns between a shared box and its readers. + + Each pin that is read has a column of its own, the lowest pin's nearest + the box. A column carries the pin's wire down from the pin's row to the + last reader of it, breaking out to each reader on the way. Every other + cell is a wire passing through - on its way out to a column further from + the box, or in from one nearer it to a reader - or nothing. + """ + chars = charset.active() + pin = columns[position] + pin_row = row_of(pin) + rows = reader_rows[pin] + if row == pin_row: + if rows[0] != pin_row: + return chars["TR"] + return chars["T_DOWN"] if len(rows) > 1 else chars["H"] + if row in rows: + return chars["BL"] if row == rows[-1] else chars["T_RIGHT"] + if pin_row < row < rows[-1]: + return chars["V"] + for other, other_pin in enumerate(columns): + if other > position and row == row_of(other_pin): + return chars["H"] + if other < position and row in reader_rows[other_pin]: + return chars["H"] + return " " + + +def _render_joined(readers, call, drawn): + """Draw a shared box once and branch its readers off the pins they read. + + The box goes on the left; each reader is composed on its own to the right + of it and hangs off a junction column, level with the row its wire leaves + the box on. Readers of one pin share a column, which is what the editor + draws; readers of different pins get a column each, because joining two + pins into one column draws two signals as one. + """ + chars = charset.active() + drawn.add(id(call)) + source = _render_call(call, None, set()) + default_row = source.connect_row + + branches = [] + for tree, pins in readers: + # One wire per reader, from the first pin it reads. Any other pin the + # same reader takes from the box is named in text. + shared = _Shared(call, pins[:1]) + block = _render(tree, drawn, shared) + branches.append((block, _entry_rows(block, shared))) + + tops = _place_branches(branches, source.pin_rows, default_row) + shift = -min([top for top in tops.values()] + [0]) + for index in tops: + tops[index] += shift + + def row_of(pin): + return source.pin_rows.get(pin, default_row) + shift + + reader_rows = _reader_rows(branches, tops) + # Inner to outer: the lowest pin nearest the box, so that no column has + # to be crossed by a wire leaving the box above it. + columns = sorted(reader_rows, key=row_of, reverse=True) + leaves = set(row_of(pin) for pin in columns) - shift = -min([top for _block, _entry, top in placed] + [0]) lines = [" " * source.width] * shift + list(source.lines) - placed = [(block, entry, top + shift) for block, entry, top in placed] + height = max([len(lines)] + [tops[index] + len(block.lines) for index, (block, _entries) in enumerate(branches)]) - joins = sorted(top + entry for _block, entry, top in placed if entry is not None) # The wire leaves the box once per pin that is read; everything below that # is carried by the junction column, so only a pin's own row is filled - # across to it. Filling every join row drew a wire out of the box's + # across to it. Filling every reader row drew a wire out of the box's # bottom border. - leaves = set() - for index, entry_and_top in enumerate(placed): - _block, entry, top = entry_and_top - if entry is None: - continue - pin = branches[index][2] - leaves.add(pin_rows.get(pin, source.connect_row) + shift) - - height = max([len(lines)] + [top + len(block.lines) for block, _entry, top in placed]) - width = source.width + 2 - body = [] - for row in range(height): - line = lines[row] if row < len(lines) else "" - fill = chars["H"] if row in leaves else " " - body.append(line + fill * (width - len(line))) - - # The junction column, and then each branch on its own rows. out = [] for row in range(height): - if joins and row == joins[0] and len(joins) > 1: - joint = chars["T_DOWN"] - elif joins and row == joins[-1] and len(joins) > 1: - joint = chars["BL"] - elif row in joins: - joint = chars["T_RIGHT"] if len(joins) > 1 else chars["H"] - elif joins and joins[0] < row < joins[-1]: - joint = chars["V"] - else: - joint = " " + line = lines[row] if row < len(lines) else "" + line += (chars["H"] if row in leaves else " ") * (width - len(line)) + for position in range(len(columns)): + line += _junction(row, position, columns, reader_rows, row_of) tail = "" - for block, _entry, top in placed: - if top <= row < top + len(block.lines): - tail = block.lines[row - top] + for index, block_and_entries in enumerate(branches): + block = block_and_entries[0] + if tops[index] <= row < tops[index] + len(block.lines): + tail = block.lines[row - tops[index]] break - out.append((body[row] + joint + tail).rstrip()) - - return Block(out, joins[0] if joins else 0) + out.append((line + tail).rstrip()) + return out def render_network(network): @@ -562,7 +660,13 @@ def render_network(network): shared = _shared_call(outputs) if shared is not None: - return _render_joined(network, shared).lines + readers, others = _split_readers(outputs, shared) + lines = _render_joined(readers, shared, drawn) + # An output that reads nothing from the shared box is a drawing of its + # own, and goes below the joined one rather than into its columns. + for tree in others: + lines.extend(_render(tree, drawn).lines) + return lines lines = [] for tree in outputs: diff --git a/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-et-and-q.xml b/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-et-and-q.xml new file mode 100644 index 0000000..1f4a483 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-et-and-q.xml @@ -0,0 +1,11 @@ + + +xStart +T#5S + +xManual + +xAny +tElapsed + + diff --git a/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-q-and-et.xml b/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-q-and-et.xml new file mode 100644 index 0000000..cc902c5 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-q-and-et.xml @@ -0,0 +1,11 @@ + + +xStart +T#5S + +tElapsed +xManual + +xAny + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 9c92d3b..239a3c0 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -25,7 +25,7 @@ import parse_ld # noqa: E402 import parse_fbd # noqa: E402 import st_render # noqa: E402 -from model import Call, Label, Network, OutputRef, Pou, Signal # noqa: E402 +from model import Assign, Call, Label, Network, OutputRef, Pou, Signal # noqa: E402 from render import write # noqa: E402 # Referenced through the charset table rather than as literal glyphs: this @@ -540,6 +540,78 @@ def check_golden(name, rendered, golden_path): ) +# --- a shared box read through two different pins ---------------------------- + +# A timer whose ET feeds a store and whose Q feeds an OR box. The readers were +# stacked in the order the export listed them and hung off one junction +# column, so the second reader took whatever row the first left free: the OR +# box was wired from the ET row while the ST said tmr.Q, and with the readers +# the other way round ET was drawn into the Q column. A reader sits level with +# the pin it reads, and readers of different pins get a column each. +TWO_READERS = os.path.join(HERE, "fixtures", "r2-1-fbd-shared-box-q-and-et.xml") +TWO_READERS_SWAPPED = os.path.join(HERE, "fixtures", "r2-1-fbd-shared-box-et-and-q.xml") + + +def wire_rows(art): + """The row of each wire: Q into the OR box, and ET out to its store.""" + q_rows = [row for row, line in enumerate(art) if "Q" + U["PIN_R"] in line] + et_rows = [row for row, line in enumerate(art) if "ET" + U["PIN_R"] in line] + or_rows = [row for row, line in enumerate(art) if U["PIN_L"] + "In1 Out1" in line] + store_rows = [row for row, line in enumerate(art) if line.rstrip().endswith("> tElapsed")] + return q_rows, et_rows, or_rows, store_rows + + +for order, path in (("Q and ET", TWO_READERS), ("ET and Q", TWO_READERS_SWAPPED)): + pou_two = parse_fbd.parse_pous(path)[0] + st_two = st_render.render_pou(pou_two) + art_two = fbd_render.render_network(pou_two.networks[0]) + q_rows, et_rows, or_rows, store_rows = wire_rows(art_two) + + check_equal(order + ": one box is drawn", len([line for line in art_two if "tmr : TON" in line]), 1) + check("st " + order + ": the OR reads Q", "xAny := tmr.Q OR xManual;" in st_two) + check("st " + order + ": the store reads ET", "tElapsed := tmr.ET;" in st_two) + check_equal(order + ": Q leaves the box on one row", len(q_rows), 1) + check_equal(order + ": ET leaves the box on one row", len(et_rows), 1) + # The wire into the OR box is the one leaving Q: the same row, unbroken. + check_equal(order + ": the OR box is wired from the Q row", or_rows, q_rows) + check( + order + ": the Q wire runs straight into the OR box", + "Q" + U["PIN_R"] in art_two[q_rows[0]] + and set(art_two[q_rows[0]].split("Q" + U["PIN_R"])[1].split(U["PIN_L"] + "In1")[0]) == set([U["H"]]), + ) + # The store cannot sit level with ET, because the OR box is in the way; so + # ET's wire turns down a column of its own and the store hangs off that. + check(order + ": the ET wire turns down its own column", "ET" + U["PIN_R"] + U["H"] * 2 + U["TR"] in art_two[et_rows[0]]) + check_equal(order + ": the store hangs below the ET row", len(store_rows) == 1 and store_rows[0] > et_rows[0], True) + check(order + ": the store is fed from the ET column", art_two[store_rows[0]].lstrip().startswith(U["BL"])) + check(order + ": the ET row does not feed the OR box", U["PIN_L"] + "In1" not in art_two[et_rows[0]]) + check(order + ": no junction between the two pins", not any(U["T_DOWN"] in line for line in art_two)) + +check_equal( + "shared pins: the drawing does not depend on the order of the readers", + fbd_render.render_network(parse_fbd.parse_pous(TWO_READERS)[0].networks[0]), + fbd_render.render_network(parse_fbd.parse_pous(TWO_READERS_SWAPPED)[0].networks[0]), +) + +# An output that reads nothing from the shared box was stacked with the +# readers, and being first it was placed at row 0 - beside the title. It is a +# drawing of its own, and goes below the joined one. +aside_timer = Call("TON", "tmr", inputs=[("IN", Signal("xStart"))], outputs=[("Q", None), ("ET", None)]) +aside_timer.wired_outputs.add("Q") +aside = Network( + "", + [ + Assign("xOther", Signal("xIn")), + Assign("xDone", OutputRef(aside_timer, "Q")), + Assign("xAny", Call("OR", inputs=[("In1", OutputRef(aside_timer, "Q")), ("In2", Signal("xManual"))], outputs=[("Out1", None)])), + ], +) +aside_art = fbd_render.render_network(aside) +check_equal("aside: the title row holds the title alone", aside_art[0].strip(), "tmr : TON") +check("aside: the plain store goes below the joined drawing", aside_art[-1].rstrip().endswith("> xOther")) +check("aside: the joined drawing is still branched", any(U["T_DOWN"] in line and "xDone" in line for line in aside_art)) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) From eb41df5e799d69ebd089df871cd8fffd0efc90bd Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 02:16:35 +1000 Subject: [PATCH 62/91] show the body of an EXECUTE box wherever the box sits in the network The inline ST of an EXECUTE box was printed only when the network's own output was the EXECUTE call. With its ENO pin wired to a variable the output is a store, so the box was drawn as an empty rectangle and the lines of ST inside it were gone. The ladder renderer already walks the rung for such boxes; the FBD renderer now walks every tree in the network the same way, and prints each body once, under the diagram. Second review of PR 36, item 4.2. Fixture added. --- src/fbd_render.py | 61 ++++++++++++++++--- .../fixtures/r2-2-fbd-execute-eno-wired.xml | 8 +++ tools/ladder/tests/test_fbd.py | 34 +++++++++++ 3 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 tools/ladder/tests/fixtures/r2-2-fbd-execute-eno-wired.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 56e5204..63a1b6c 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -648,13 +648,45 @@ def row_of(pin): return out -def render_network(network): - """Render one network, which may drive several outputs from one source.""" - outputs = getattr(network, "outputs", [network]) - # Per network: a box drawn for one output must not be drawn again for the - # next, but a box shared between two networks is two boxes on the page. - drawn = set() +def _execute_bodies(outputs): + """The inline ST of every EXECUTE box in a network, laid out for the page. + Each body once, in the order the boxes are met - a box behind another + runs first, so its body comes first. + """ + seen = set() + found = [] + + def walk(node): + call = node.call if isinstance(node, OutputRef) else node + if isinstance(call, Call): + if id(call) in seen: + return + seen.add(id(call)) + for _pin, source in call.inputs: + if source is not None: + walk(source) + if call.st_code: + found.append(call) + elif isinstance(node, Assign): + if node.source is not None: + walk(node.source) + elif isinstance(node, Jump): + if node.condition is not None: + walk(node.condition) + + for tree in outputs: + walk(tree) + + lines = [] + for call in found: + lines.append("") + lines.extend(" " + line for line in call.st_code) + return lines + + +def _render_outputs(outputs, drawn): + """The diagram of one network's outputs, joined or branched as they share.""" if _shared_source(outputs) is not None: return _render_fanout(outputs, drawn).lines @@ -671,13 +703,22 @@ def render_network(network): lines = [] for tree in outputs: lines.extend(_render(tree, drawn).lines) - # An EXECUTE box's body is the logic; drawing the box without it would - # be an empty rectangle where a dozen lines of ST should be. - if isinstance(tree, Call) and tree.st_code: - lines = lines + [""] + [" " + line for line in tree.st_code] return lines +def render_network(network): + """Render one network, which may drive several outputs from one source.""" + outputs = getattr(network, "outputs", [network]) + # Per network: a box drawn for one output must not be drawn again for the + # next, but a box shared between two networks is two boxes on the page. + drawn = set() + # An EXECUTE box's body is the logic; drawing the box without it would be + # an empty rectangle where a dozen lines of ST should be. The box is found + # wherever it sits - behind the store its ENO feeds, or behind another + # box - and not only when it is the network's own output. + return _render_outputs(outputs, drawn) + _execute_bodies(outputs) + + def render_pou(pou): """Render a whole FBD POU: declaration, then one box tree per network.""" lines = render_declaration(pou) diff --git a/tools/ladder/tests/fixtures/r2-2-fbd-execute-eno-wired.xml b/tools/ladder/tests/fixtures/r2-2-fbd-execute-eno-wired.xml new file mode 100644 index 0000000..dc48277 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-2-fbd-execute-eno-wired.xml @@ -0,0 +1,8 @@ + + +xRun +executea := 1; +b := 2; +xRan + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 239a3c0..232ccdc 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -612,6 +612,40 @@ def wire_rows(art): check("aside: the joined drawing is still branched", any(U["T_DOWN"] in line and "xDone" in line for line in aside_art)) +# --- an EXECUTE box whose ENO pin is wired ----------------------------------- + +# The body was printed only when the network's own output was the EXECUTE +# call. Wire its ENO to a variable and the output is a store, so the box was +# drawn - an empty rectangle - and the two lines of ST inside it were gone. +ENO_WIRED = os.path.join(HERE, "fixtures", "r2-2-fbd-execute-eno-wired.xml") +eno = parse_fbd.parse_pous(ENO_WIRED)[0] +eno_st = st_render.render_pou(eno) +eno_art = fbd_render.render_network(eno.networks[0]) + +check("execute eno: the box is drawn", any(line.strip() == "EXECUTE" for line in eno_art)) +check("execute eno: the wire to the store is drawn", any("ENO" + U["PIN_R"] + U["H"] * 3 + "> xRan" in line for line in eno_art)) +check_equal("execute eno: the body follows the diagram", eno_art[-2:], [" a := 1;", " b := 2;"]) +check_equal("execute eno: a blank line separates the body", eno_art[-3], "") +check_equal("execute eno: the body is printed once", len([line for line in eno_art if line.strip() == "a := 1;"]), 1) +check("execute eno: the ST guards the body with EN", "IF xRun THEN" in eno_st and " a := 1;" in eno_st) +check("execute eno: the ENO store reads the enable", "xRan := xRun;" in eno_st) + +# The same box behind another box: still found, still printed once, and a +# box two outputs share is not printed per output. +deep_execute = Call("EXECUTE", inputs=[("EN", Signal("xRun"))], outputs=[("ENO", None)], st_code=["c := 3;"]) +deep_execute.wired_outputs.add("ENO") +deep = Network( + "", + [ + Assign("xBoth", Call("AND", inputs=[("In1", OutputRef(deep_execute, "ENO")), ("In2", Signal("xOk"))], outputs=[("Out1", None)])), + Assign("xRan", OutputRef(deep_execute, "ENO")), + ], +) +deep_art = fbd_render.render_network(deep) +check_equal("execute deep: the body of a nested box is printed once", len([line for line in deep_art if line.strip() == "c := 3;"]), 1) +check_equal("execute deep: it follows the diagram", deep_art[-1], " c := 3;") + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) From bd00b46ab6d643d51baa0748e60186376b76535c Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 02:20:59 +1000 Subject: [PATCH 63/91] wire every pin an expression reads from a shared box xAlarm := ctr.Q AND (ctr.CV > 5) reads the counter twice in one expression. Each read was replaced by the same marker byte while the branch was composed, and only the first marker was resolved into a wire: the second stayed in the written file as a control character, and the CV pin had no wire. Each pin now has a marker of its own, and every marker becomes a wire from the pin it stands for, down a junction column of that pin's own. If the wires would have to cross - the read of the lower pin sits above the read of the higher one - the reader keeps the wire from the first pin it reads and names the other in text, as a box already drawn is named. No marker survives into the output either way. Second review of PR 36, item 4.3. Fixture added. --- src/fbd_render.py | 59 ++++++++++++++++--- ...fbd-counter-q-and-cv-in-one-expression.xml | 12 ++++ tools/ladder/tests/test_fbd.py | 47 +++++++++++++++ 3 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 tools/ladder/tests/fixtures/r2-3-fbd-counter-q-and-cv-in-one-expression.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 63a1b6c..10235ce 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -588,6 +588,45 @@ def _junction(row, position, columns, reader_rows, row_of): return " " +def _compose_branches(readers, call, drawn, every_pin): + """[(block, entries)] - each reader drawn on its own, its wires marked. + + With ``every_pin`` each read of the shared box becomes a wire; without + it only the first pin a reader takes is wired and the rest are named in + text, the way a box already drawn is named. + """ + branches = [] + for tree, pins in readers: + shared = _Shared(call, None if every_pin else pins[:1]) + block = _render(tree, drawn, shared) + branches.append((block, _entry_rows(block, shared))) + return branches + + +def _wires_cross(branches, tops, pin_rows, default_row): + """True when the placed branches cannot be wired without a crossing. + + A pin's column runs from the pin's row down to the last reader of it, + and a lower pin's column sits inside a higher pin's. Every wire is then + clear of every column it passes only if each pin's readers all sit below + every reader of the pins above it, and none sits above its own pin. + """ + + def row_of(pin): + return pin_rows.get(pin, default_row) + + reader_rows = _reader_rows(branches, tops) + last = None + for pin in sorted(reader_rows, key=row_of): + rows = reader_rows[pin] + if rows[0] < row_of(pin): + return True + if last is not None and rows[0] <= last: + return True + last = rows[-1] + return False + + def _render_joined(readers, call, drawn): """Draw a shared box once and branch its readers off the pins they read. @@ -596,21 +635,25 @@ def _render_joined(readers, call, drawn): the box on. Readers of one pin share a column, which is what the editor draws; readers of different pins get a column each, because joining two pins into one column draws two signals as one. + + A reader that takes two pins from the box gets two wires, unless the + wires would then have to cross: then it keeps the wire from the first + pin it reads and names the other in text, which is wrong-looking but + never wrong. """ chars = charset.active() drawn.add(id(call)) source = _render_call(call, None, set()) default_row = source.connect_row - branches = [] - for tree, pins in readers: - # One wire per reader, from the first pin it reads. Any other pin the - # same reader takes from the box is named in text. - shared = _Shared(call, pins[:1]) - block = _render(tree, drawn, shared) - branches.append((block, _entry_rows(block, shared))) + for every_pin in (True, False): + shown = set(drawn) + branches = _compose_branches(readers, call, shown, every_pin) + tops = _place_branches(branches, source.pin_rows, default_row) + if not every_pin or not _wires_cross(branches, tops, source.pin_rows, default_row): + break + drawn.update(shown) - tops = _place_branches(branches, source.pin_rows, default_row) shift = -min([top for top in tops.values()] + [0]) for index in tops: tops[index] += shift diff --git a/tools/ladder/tests/fixtures/r2-3-fbd-counter-q-and-cv-in-one-expression.xml b/tools/ladder/tests/fixtures/r2-3-fbd-counter-q-and-cv-in-one-expression.xml new file mode 100644 index 0000000..1e7e783 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-3-fbd-counter-q-and-cv-in-one-expression.xml @@ -0,0 +1,12 @@ + + +xPulse +xRst +10 + +5 + + +xAlarm + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 232ccdc..e615061 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -646,6 +646,53 @@ def wire_rows(art): check_equal("execute deep: it follows the diagram", deep_art[-1], " c := 3;") +# --- one expression reading two pins of a shared box ------------------------- + +# xAlarm := ctr.Q AND (ctr.CV > 5). Each read of the shared box was replaced +# by a marker byte while the branch was composed, and only the first marker +# was resolved: the second stayed in the file as a control character, and +# the CV pin had no wire. Every read is a wire from its own pin now, and the +# file holds no byte below a space. +CONTROL_CHARACTERS = [chr(code) for code in range(1, 32)] + + +def control_free(lines): + return not any(character in line for line in lines for character in CONTROL_CHARACTERS) + + +TWO_PINS_ONE_READER = os.path.join(HERE, "fixtures", "r2-3-fbd-counter-q-and-cv-in-one-expression.xml") +two_reads = parse_fbd.parse_pous(TWO_PINS_ONE_READER)[0] +two_reads_st = st_render.render_pou(two_reads) +two_reads_art = fbd_render.render_network(two_reads.networks[0]) + +check("two reads: no control character in the output", control_free(two_reads_art)) +check("two reads: the ST reads both pins", "xAlarm := ctr.Q AND (ctr.CV > 5);" in two_reads_st) +check_equal("two reads: one box is drawn", len([line for line in two_reads_art if "ctr : CTU" in line]), 1) +check("two reads: the box is not named in text", not any("ctr." in line for line in two_reads_art)) +q_line = [line for line in two_reads_art if "Q" + U["PIN_R"] in line][0] +cv_line = [line for line in two_reads_art if "CV" + U["PIN_R"] in line][0] +check("two reads: Q is wired straight into the AND box", U["PIN_L"] + "In1 Out1" + U["PIN_R"] + U["H"] * 3 + "> xAlarm" in q_line) +check("two reads: CV is wired, down a column of its own", "CV" + U["PIN_R"] + U["H"] * 2 + U["TR"] in cv_line) +gt_lines = [line for line in two_reads_art if line.lstrip().startswith(U["BL"]) and U["PIN_L"] + "In1 Out1" in line] +check_equal("two reads: the CV column feeds the GT box", len(gt_lines), 1) +check("two reads: the GT box feeds In2 of the AND box", gt_lines and U["PIN_L"] + "In2" in gt_lines[0]) + +# The same two reads the other way up: (ctr.CV > 5) AND ctr.Q puts the CV +# read above the Q read, where a wire from CV would have to cross the wire +# from Q. Then the first read keeps its wire and the second is named. +crossing_counter = Call("CTU", "ctr", inputs=[("CU", Signal("xPulse")), ("PV", Signal("10"))], outputs=[("Q", None), ("CV", None)]) +crossing_counter.wired_outputs.update(["Q", "CV"]) +crossing_gt = Call("GT", inputs=[("In1", OutputRef(crossing_counter, "CV")), ("In2", Signal("5"))], outputs=[("Out1", None)], wired_outputs=["Out1"]) +crossing_and = Call("AND", inputs=[("In1", crossing_gt), ("In2", OutputRef(crossing_counter, "Q"))], outputs=[("Out1", None)], wired_outputs=["Out1"]) +crossing = Network("", [Assign("xAlarm", crossing_and)]) +crossing_art = fbd_render.render_network(crossing) +check("crossing reads: no control character in the output", control_free(crossing_art)) +check_equal("crossing reads: one box is drawn", len([line for line in crossing_art if "ctr : CTU" in line]), 1) +check("crossing reads: the first read is wired", any("CV" + U["PIN_R"] + U["H"] in line and U["PIN_L"] + "In1 Out1" in line for line in crossing_art)) +check("crossing reads: the second read is named", any("ctr.Q" + U["H"] * 2 in line and U["PIN_L"] + "In2" in line for line in crossing_art)) +check("crossing reads: no wire runs from Q", not any("Q" + U["PIN_R"] + U["H"] in line for line in crossing_art)) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) From 6b7a6c123d43e41ad8aed9db3541ccf4735c0c89 Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 02:24:09 +1000 Subject: [PATCH 64/91] keep a jump label on its network, never in its body CODESYS keeps a jump label on the network; PLCopen writes it as a loose element just before the network's body. The parser carried that element into the body of the network that followed, where the renderers drew it as a rung, and once the header started writing the label from the native list the label appeared twice - under the header and again as a rung, in the diagram, in FBD and in ST alike. The committed GraphicalTesting files never showed it, because both of their labels sit on empty networks. The parser now lifts the label out of the body and onto the network, in both languages, so it is written once under the header whether or not the native list is there; when it is, the editor's own label is taken in preference. The alignment no longer has to recognise a label-only body. Second review of PR 36, item 4.4. The two fixture files added, rendered through graphical_export with the native xml. --- src/model.py | 53 +++++- src/native_networks.py | 33 +--- src/parse_fbd.py | 4 +- src/parse_ld.py | 4 +- .../r2-4-ld-label-on-wired-network.native.xml | 40 ++++ ...r2-4-ld-label-on-wired-network.plcopen.xml | 171 ++++++++++++++++++ tools/ladder/tests/test_export.py | 35 ++++ tools/ladder/tests/test_fbd.py | 7 +- tools/ladder/tests/test_ladder.py | 26 ++- 9 files changed, 326 insertions(+), 47 deletions(-) create mode 100644 tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.native.xml create mode 100644 tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.plcopen.xml diff --git a/src/model.py b/src/model.py index 8ff25c3..f309a6e 100644 --- a/src/model.py +++ b/src/model.py @@ -147,11 +147,43 @@ def find(item): return find +def label_name(tree): + """The jump target a parsed label carries, or None for anything else. + + FBD parses a label into a Label; LD parses it into an Element whose kind + says label. The two are different classes, and treating only one of them + as a label leaves the other in the network's body. + """ + if isinstance(tree, Label): + return tree.name + if isinstance(tree, Element) and tree.kind == LABEL: + return tree.label + return None + + +def _network(header, trees): + """(comment, title, label, outputs) - the label lifted out of the trees. + + CODESYS keeps one label per network, so a second one cannot come from an + export; should one arrive it stays in the body, visible, rather than + being dropped. + """ + label = "" + outputs = [] + for tree in trees: + name = label_name(tree) + if name is not None and not label: + label = name + else: + outputs.append(tree) + return (header[0] or "", header[1] or "", label, outputs) + + def assemble_networks(nodes, root_of, outputs_by_root, label_roots=()): """Order the components of a body into networks, in editor order. - Returns [(comment, title, [outputs])]. Three things decide where a - network begins and what it is called: + Returns [(comment, title, label, [outputs])]. Three things decide where + a network begins and what it is called: * A comment or a title element is a header, and heads the network whose elements follow it. A second one of either means the header before it @@ -164,7 +196,9 @@ def assemble_networks(nodes, root_of, outputs_by_root, label_roots=()): separates networks; the header only names the one it precedes. * A jump label is stored on the network in CODESYS but exported as a free-standing element just before it, so it arrives as a component of - its own. It belongs to the network that follows it. + its own. It belongs to the network that follows it - as that network's + label, not as part of its body. Left in the body it was drawn as a + rung, and twice over once the native export supplied the label too. """ label_roots = set(label_roots) networks = [] @@ -176,7 +210,7 @@ def assemble_networks(nodes, root_of, outputs_by_root, label_roots=()): if node.kind in (COMMENT, TITLE): index = 0 if node.kind == COMMENT else 1 if header[index] is not None: - networks.append((header[0] or "", header[1] or "", list(carried))) + networks.append(_network(header, list(carried))) del carried[:] header = [None, None] header[index] = node.label or "" @@ -190,12 +224,12 @@ def assemble_networks(nodes, root_of, outputs_by_root, label_roots=()): carried.extend(outputs_by_root[root]) continue - networks.append((header[0] or "", header[1] or "", carried + outputs_by_root[root])) + networks.append(_network(header, carried + outputs_by_root[root])) del carried[:] header = [None, None] if header[0] is not None or header[1] is not None or carried: - networks.append((header[0] or "", header[1] or "", list(carried))) + networks.append(_network(header, list(carried))) return networks @@ -393,9 +427,10 @@ def __init__(self, comment="", outputs=None, title="", label="", note=None): # CODESYS keeps a network's title separately from its comment, and # draws it above one. A network can carry either, both or neither. self.title = title - # The jump-target label CODESYS keeps on the network. Only filled in - # when the native export has been read, which is the only place it - # survives as a property of the network rather than a loose element. + # The jump-target label CODESYS keeps on the network. PLCopen writes + # it as a loose element just before the network's body, and the + # parser lifts it back here; the native export carries it as the + # property it is, and that one is taken in preference. self.label = label # Why this network has no body: out-commented, or empty. Set only for # networks the PLCopen export left out entirely. diff --git a/src/native_networks.py b/src/native_networks.py index f8b47d0..98ef554 100644 --- a/src/native_networks.py +++ b/src/native_networks.py @@ -30,7 +30,7 @@ import plcopen import xmlbackend -from model import LABEL, Element, Label, Network +from model import Network # Said in the file itself, under the number the network occupies. A reviewer # reading only the .txt has to learn that logic exists here without @@ -47,9 +47,10 @@ def __init__(self, out_commented=False, empty=False, comment="", title="", label self.empty = empty self.comment = comment self.title = title - # The jump-target label CODESYS stores on the network itself. PLCopen - # exports it as a free-standing element instead, which is why the - # label is taken from here rather than from the parsed body. + # The jump-target label CODESYS stores on the network itself. The + # parser lifts the free-standing element PLCopen writes into the + # network it precedes; this one is the editor's own, and is taken + # in preference. self.label = label @property @@ -116,31 +117,15 @@ def read_networks(path): return networks or None -def _is_label(tree): - """True for a parsed jump label, in either language's spelling. - - FBD parses one into a Label; LD parses it into an Element whose kind says - label. The two are different classes, and treating only one of them as a - label leaves the other counting as a network body. - """ - if isinstance(tree, Label): - return True - return isinstance(tree, Element) and tree.kind == LABEL - - def _carries_logic(network): """True for a parsed network that PLCopen exported a body for. A network the parser built from a comment element alone has no outputs, - and one holding only a jump label has nothing but labels - CODESYS keeps - a label on the network, so a label standing on its own is an artefact of - the export rather than a network. Neither is something the native list - has a body for, so neither takes part in the match. + and neither has one built from a jump label alone: the parser lifts the + label onto the network, where CODESYS keeps it. Neither is something the + native list has a body for, so neither takes part in the match. """ - outputs = getattr(network, "outputs", []) - if not outputs: - return False - return not all(_is_label(tree) for tree in outputs) + return bool(getattr(network, "outputs", [])) def align(native, parsed): diff --git a/src/parse_fbd.py b/src/parse_fbd.py index 98daa5c..90d5348 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -283,8 +283,8 @@ def root_of(node): return find(node.local_id) if node.local_id in by_id else None return [ - Network(comment=comment, title=title, outputs=outputs) - for comment, title, outputs in assemble_networks(nodes, root_of, outputs_by_root, label_roots) + Network(comment=comment, title=title, label=label, outputs=outputs) + for comment, title, label, outputs in assemble_networks(nodes, root_of, outputs_by_root, label_roots) ] diff --git a/src/parse_ld.py b/src/parse_ld.py index c553f3e..877853c 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -445,8 +445,8 @@ def network_root(node): return root_of(node) return [ - Network(comment=comment, title=title, outputs=rungs) - for comment, title, rungs in assemble_networks(nodes, network_root, rungs_by_root, label_roots) + Network(comment=comment, title=title, label=label, outputs=rungs) + for comment, title, label, rungs in assemble_networks(nodes, network_root, rungs_by_root, label_roots) ] diff --git a/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.native.xml b/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.native.xml new file mode 100644 index 0000000..6a429c6 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.native.xml @@ -0,0 +1,40 @@ + + + + + + + + False + Contact + + + + + LATER + False + Contact + + + + + LONELY + False + + + + + + + False + Contact + + + + + + False + Contact + + + diff --git a/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.plcopen.xml b/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.plcopen.xml new file mode 100644 index 0000000..1fc31ea --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.plcopen.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + xGo + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + + xA + + + + + + oA + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + xB + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + xC + + + + + + oC + + + + + + + + + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 89da7c9..3d45b23 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -462,6 +462,41 @@ def __init__(self, outputs): check_equal("and carries its label", aligned[2].label, "RETRY") +# --- a label on a network that has logic ------------------------------------- + +# CODESYS keeps a jump label on its network; PLCopen writes it as a loose +# element just before the network's body. The header wrote the label from the +# native list, and the parsed element was still in the body, so the label +# came out twice: under the header, and again as a rung. The committed +# GraphicalTesting files never showed it, because both of their labels sit +# on empty networks, whose label element parses into a network of its own. +LABELLED = os.path.join(HERE, "fixtures", "r2-4-ld-label-on-wired-network.plcopen.xml") +LABELLED_NATIVE = os.path.join(HERE, "fixtures", "r2-4-ld-label-on-wired-network.native.xml") +graphical_export.reset_stats() +labelled = graphical_export.render_plcopen(LABELLED, None, None, LABELLED_NATIVE) +check("labelled: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in labelled) +check_equal("labelled: one header per editor network", len([line for line in labelled if line.startswith("(* Network ")]), 5) +second = labelled.index("(* Network 2 *)") +check_equal("labelled: the label is under its header", labelled[second + 1], "LATER:") +check("labelled: the rung follows it", "xA" in labelled[second + 2] and "oA" in labelled[second + 2]) +check_equal("labelled: the label appears once", len([line for line in labelled if "LATER:" in line]), 1) +third = labelled.index("(* Network 3 *)") +check_equal( + "labelled: a label on an empty network stays with it", + labelled[third + 1 : third + 3], + ["LONELY:", "(* " + native_networks.NOTE_EMPTY + " *)"], +) +check_equal("labelled: that label appears once too", len([line for line in labelled if "LONELY:" in line]), 1) +check("labelled: the jump still names its target", any(">>LATER" in line for line in labelled)) + +# The same file without the native list, as the dev CLI renders it: the label +# is the network's own either way, so it is written once, under the header. +unlabelled = graphical_export.render_plcopen(LABELLED, None, None, None) +check_equal("labelled: without the native list the label still appears once", len([line for line in unlabelled if "LATER:" in line]), 1) +check_equal("labelled: and under its header", unlabelled[unlabelled.index("(* Network 2 *)") + 1], "LATER:") +graphical_export.reset_stats() + + # --- read-only service exports: library list and visualisation manager ------ # Library behaviour is not exportable, but which exact versions the project diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index e615061..b42b849 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -234,7 +234,8 @@ def check_golden(name, rendered, golden_path): # it arrives wired to nothing. Counting it as a network of its own put the # label under its own number and pushed every later number out by one. check_equal("flow: three networks survive", len(flow.networks), 3) -check("flow: the label heads the network it labels", isinstance(flow.networks[2].outputs[0], Label)) +check_equal("flow: the label is the network's own", flow.networks[2].label, "END") +check("flow: the label is not in the network's body", not any(isinstance(tree, Label) for tree in flow.networks[2].outputs)) # A jump terminates a network. Leaving it out of SINK_KINDS dropped the entire # guard network, because nothing else consumed the OR feeding it. @@ -242,6 +243,8 @@ def check_golden(name, rendered, golden_path): check("flow: the jump condition is kept", any("Mode.Current = Mode.ESTOP" in line for line in flow_st)) check("flow: the jump target is drawn", any(">> END" in line for line in flow_art)) check("flow: the label is shown", "END:" in flow_st) +check("flow: the label heads the diagram too", "END:" in flow_art) +check("flow: the label is not drawn as a rung", not any("END:" in line and line != "END:" for line in flow_art)) check("flow: the label is not dressed as a comment", not any("(* label" in line for line in flow_st + flow_art)) # negated="true" on an inVariable inverts the logic if it is ignored. @@ -251,7 +254,7 @@ def check_golden(name, rendered, golden_path): check("flow: negation survives into ST", any("(NOT xInitDone) OR" in line for line in flow_st)) # An EXECUTE box is nothing but inline ST; drawing the box alone loses it all. -execute = flow.networks[2].outputs[1] +execute = flow.networks[2].outputs[0] check_equal("flow: inline ST is captured", len(execute.st_code), 4) check("flow: inline ST reaches the ST output", any("Status.Faulted := FALSE;" in line for line in flow_st)) # The EN pin genuinely guards the box, so it has to show up as a condition diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index df96445..f0bfafb 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -205,8 +205,9 @@ def check_golden(name, rendered_lines, golden_path): fidelity_st = st_render.render_pou(fidelity_pou) fidelity_art = render_pou(fidelity_pou) -# The jump rung and the label rung must both survive as rungs at all. -check_equal("fidelity: all seven rungs survive", len(fidelity_pou.rungs), 7) +# The jump rung must survive as a rung at all; the label is the network's, +# not a rung, and is counted with the networks below. +check_equal("fidelity: all six rungs survive", len(fidelity_pou.rungs), 6) # A jump's target lives in a "label" attribute; losing it drew ">>?" and # emitted no ST for the whole rung, guard included. @@ -215,7 +216,10 @@ def check_golden(name, rendered_lines, golden_path): # like the label it targets - not as a note about the program. check("fidelity: guarded jump reaches ST", "IF xGo THEN JMP SKIP; END_IF" in fidelity_st) check("fidelity: no jump is dressed as a comment", not any("(* JMP" in line for line in fidelity_st)) -check("fidelity: label is drawn", any("SKIP:" in line for line in fidelity_art)) +# A label is the network's own, so it is written under the network's header +# the way the export writes it - and never as a rung. +check("fidelity: label heads its network", "SKIP:" in fidelity_art) +check("fidelity: label is not drawn as a rung", not any("SKIP:" in line and U["T_RIGHT"] in line for line in fidelity_art)) # A jump target is program structure, not documentation: it is written the # way ST writes it, and not inside the delimiters this file uses for comments. check("fidelity: label reaches ST", "SKIP:" in fidelity_st) @@ -414,13 +418,19 @@ def check_golden(name, rendered_lines, golden_path): # A jump label is stored on its network in CODESYS but exported just before # it, wired to nothing. Counting it as a network of its own put it under a -# number of its own and pushed every later number out by one. +# number of its own and pushed every later number out by one; carrying it +# into the network's body drew it as a rung, and twice over once the native +# export supplied the label as well. It is the network's label, nothing else. check_equal("fidelity: six networks, not seven", len(fidelity_pou.networks), 6) -check_equal("fidelity: the label joins the network it labels", len(fidelity_pou.networks[1].outputs), 2) +check_equal("fidelity: the label is the network's own", fidelity_pou.networks[1].label, "SKIP") +check_equal("fidelity: and is not in its body", len(fidelity_pou.networks[1].outputs), 1) check( - "fidelity: the label is drawn above that network", - isinstance(fidelity_pou.networks[1].outputs[0], Element) - and fidelity_pou.networks[1].outputs[0].kind == LABEL, + "fidelity: no label element is left in any body", + not any( + isinstance(rung, Element) and rung.kind == LABEL + for network in fidelity_pou.networks + for rung in network.outputs + ), ) # A negated wired output feeding a coil, and only one bubble drawn for it. From e3388d34f89a0231de3f26f5120fdbe655f96c82 Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 02:24:55 +1000 Subject: [PATCH 65/91] pin the label of an out-commented network to that network The first review of PR 38 found that an out-commented network with a label took the label of the network after it, so a jump to that label appeared to target dead code. The alignment rebuilt around the native list fixed it, but no suite held the case. This adds it: a native list with the out-commented network 1 labelled SKIP and network 2 labelled RUN, against a PLCopen export carrying network 2 alone. Each label is written once, under its own network, and the out-commented one still says that it does not execute. Second review of PR 36, section 3, item 38-4.1. --- ...-1-ld-label-of-disabled-network.native.xml | 26 +++++ ...1-ld-label-of-disabled-network.plcopen.xml | 100 ++++++++++++++++++ tools/ladder/tests/test_export.py | 32 ++++++ 3 files changed, 158 insertions(+) create mode 100644 tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.native.xml create mode 100644 tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.plcopen.xml diff --git a/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.native.xml b/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.native.xml new file mode 100644 index 0000000..a091c8f --- /dev/null +++ b/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.native.xml @@ -0,0 +1,26 @@ + + + + + + + SKIP + True + Contact + + + + + RUN + False + Contact + + + + + + False + Contact + + + diff --git a/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.plcopen.xml b/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.plcopen.xml new file mode 100644 index 0000000..5bfb69d --- /dev/null +++ b/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.plcopen.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + + xA + + + + + + oA + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + xB + + + + + + oB + + + + + + + + + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 3d45b23..0566b3a 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -497,6 +497,38 @@ def __init__(self, outputs): graphical_export.reset_stats() +# --- the label of an out-commented network ----------------------------------- + +# Item 38-4.1. The native list has network 1 out-commented with the label +# SKIP, and network 2 with the label RUN and a body; the PLCopen export +# carries network 2 alone, its label element first. The old alignment asked +# only whether the native network had a label and whether the next parsed +# element was one, so network 1 consumed RUN: the file showed RUN under the +# "does not execute" line and no label on network 2, and a jump to RUN +# appeared to target dead code. The rebuilt alignment takes every label from +# the native list, on the network that owns it; this pins that. +DISABLED = os.path.join(HERE, "fixtures", "38-1-ld-label-of-disabled-network.plcopen.xml") +DISABLED_NATIVE = os.path.join(HERE, "fixtures", "38-1-ld-label-of-disabled-network.native.xml") +graphical_export.reset_stats() +disabled = graphical_export.render_plcopen(DISABLED, None, None, DISABLED_NATIVE) +check("disabled label: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in disabled) +check_equal("disabled label: one header per editor network", len([line for line in disabled if line.startswith("(* Network ")]), 3) +first = disabled.index("(* Network 1 *)") +second = disabled.index("(* Network 2 *)") +third = disabled.index("(* Network 3 *)") +check_equal( + "disabled label: the out-commented network keeps its own label", + disabled[first + 1 : first + 3], + ["SKIP:", "(* " + native_networks.NOTE_OUT_COMMENTED + " *)"], +) +check("disabled label: it does not take the next network's label", "RUN:" not in disabled[first:second]) +check_equal("disabled label: the next network keeps its label", disabled[second + 1], "RUN:") +check("disabled label: and its body", "xA" in disabled[second + 2] and "oA" in disabled[second + 2]) +check("disabled label: the network after carries no label", "xB" in disabled[third + 1]) +check_equal("disabled label: each label appears once", [disabled.count("SKIP:"), disabled.count("RUN:")], [1, 1]) +graphical_export.reset_stats() + + # --- read-only service exports: library list and visualisation manager ------ # Library behaviour is not exportable, but which exact versions the project From 65ab616402c53276871a8bae0b81361eebe4d8de Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 02:54:06 +1000 Subject: [PATCH 66/91] render a wired OR into a block pin as an OR, not two pins Two contacts in parallel feeding a block's input pin - a seal-in, the most ordinary shape in ladder - is written in PLCopen as two connections under the pin's one connectionPointIn, exactly as a coil collects a parallel. The block builder read each connection as a pin of its own, so the box drew two IN rows and the ST emitted "tmr(IN := xStart, IN := xRun)": the OR was lost and the timer read as having two IN pins. Connections are now grouped by the pin they land on, and a pin fed by more than one is an OR of them - drawn as a parallel of contacts ahead of the box and written as "IN := (xStart OR xRun)". A pin with a single connection is unchanged, so every committed export renders identically. Found in a full-sweep review; no fixture had a multi-connection block pin. --- src/parse_ld.py | 49 ++++++++++++++----- .../tests/fixtures/ld-or-into-block-pin.xml | 9 ++++ tools/ladder/tests/test_ladder.py | 25 ++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld-or-into-block-pin.xml diff --git a/src/parse_ld.py b/src/parse_ld.py index 877853c..75fad2e 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -226,27 +226,54 @@ def _build_block(node, by_id, visiting, via_pin, drawn): side_pins = [] pin_blocks = [] + # Several connections landing on one pin are a wired OR into that pin - + # the same several--under-one-connectionPointIn shape a coil + # or a contact collects into a Parallel. Grouped by pin so those branches + # feed the pin as one OR, rather than being spread across duplicate + # captions ("IN := xStart, IN := xRun") that lose the OR and read as two + # separate pins. + order = [] + grouped = {} for connection in node.inputs: - upstream = by_id.get(connection.ref_id) - if upstream is None: - side_pins.append((connection.target_pin, "?")) + if connection.target_pin not in grouped: + grouped[connection.target_pin] = [] + order.append(connection.target_pin) + grouped[connection.target_pin].append(connection) + + for pin in order: + connections = grouped[pin] + branches = [] + all_from_variables = True + for connection in connections: + upstream = by_id.get(connection.ref_id) + if upstream is None: + continue + if upstream.kind != IN_VARIABLE: + all_from_variables = False + branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin, drawn)) + # The bubble and the P or N ride on the pin, so every connection to it + # carries the same pair; the first speaks for the group. + negated = connections[0].negated + edge = connections[0].edge + if not branches: + side_pins.append((pin, "?")) continue - sub_expr = _build_expr(upstream, by_id, visiting, connection.source_pin, drawn) - if upstream.kind == IN_VARIABLE: + feed = branches[0] if len(branches) == 1 else parallel(branches) + if all_from_variables: # Flattened through expr_to_text, not taken from the raw label: # an in-place negated inVariable must keep its NOT, or the pin # silently inverts. - side_pins.append((connection.target_pin, _pin_text(sub_expr, connection, pin_blocks))) + side_pins.append((pin, _pin_text(feed, connections[0], pin_blocks))) elif power_pin is None: - power_pin = connection.target_pin - power_expr = sub_expr + power_pin = pin + power_expr = feed # The pin's own negation bubble; it inverts the power flow at the # box wall, after everything the rung has accumulated. The P or N # on that pin sits there too. - power_negated = connection.negated - power_edge = connection.edge + power_negated = negated + power_edge = edge else: - side_pins.append((connection.target_pin, _pin_text(sub_expr, connection, pin_blocks))) + side_pins.append((pin, _pin_text(feed, connections[0], pin_blocks))) input_pins = [] if power_pin is not None: diff --git a/tools/ladder/tests/fixtures/ld-or-into-block-pin.xml b/tools/ladder/tests/fixtures/ld-or-into-block-pin.xml new file mode 100644 index 0000000..dabbc1d --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-or-into-block-pin.xml @@ -0,0 +1,9 @@ + + + +xStart +xRun + +xOut + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index f0bfafb..afeb5f6 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -194,6 +194,31 @@ def check_golden(name, rendered_lines, golden_path): check_golden("golden output matches", rendered, EXPECTED) +# --- a wired OR into a block pin --------------------------------------------- + +# Two contacts in parallel feeding a timer's IN pin - a seal-in, the most +# ordinary shape in ladder. PLCopen writes the OR as two under +# the pin's one connectionPointIn, exactly as it does for a coil. The builder +# read each connection as a pin of its own, so the box drew two IN rows and +# the ST emitted "tmr(IN := xStart, IN := xRun)" - the OR lost, and read as a +# timer with two IN pins. The connections on one pin are now OR'd into it. +import st_render # noqa: E402 + +OR_INTO_PIN = os.path.join(FIXTURES, "ld-or-into-block-pin.xml") +or_pou = parse_pous(OR_INTO_PIN)[0] +or_st = st_render.render_pou(or_pou) +or_art = render_pou(or_pou) + +check("or into pin: the pin reads the OR", any("tmr(IN := (xStart OR xRun));" in line for line in or_st)) +check("or into pin: the timer is called once", len([l for l in or_st if l.strip().startswith("tmr(")]) == 1) +check("or into pin: no duplicate IN argument", not any("IN := xStart, IN := xRun" in line for line in or_st)) +check_equal("or into pin: one box is drawn", len([l for l in or_art if "tmr : TON" in l and ";" not in l]), 1) +# The two contacts are drawn in parallel ahead of the box, not as two pins. +check_equal("or into pin: the box has one IN row", len([l for l in or_art if "IN" in l and U["PIN_L"] in l]), 1) +check("or into pin: the branch is drawn", any(U["T_DOWN"] in l for l in or_art) and any(U["BL"] in l for l in or_art)) +check("or into pin: both contacts are shown", any("xStart" in l for l in or_art) and any("xRun" in l for l in or_art)) + + # --- logic fidelity ---------------------------------------------------------- # Shapes that were dropped or inverted: a rung ending in a jump, its label, a From 29e490acc0784de64526feb895451e8163150638 Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 02:57:13 +1000 Subject: [PATCH 67/91] draw a fan-out across two pins with a column per pin A timer whose Q feeds two stores and whose ET feeds a third has every reader hanging straight off the box, so the fan-out renderer claimed it and stacked all three readers in one column. ET, pushed past Q's second reader, landed on the box's bottom border and its wire ran out of the corner. A shared instance read on more than one pin is now handed to the joined renderer, which already gives each pin a column of its own and turns the lower pin's wire down beside the box. A single-pin fan-out is unchanged. Found in a full-sweep review. --- src/fbd_render.py | 12 ++++++++- .../tests/fixtures/fbd-fanout-two-pins.xml | 9 +++++++ tools/ladder/tests/test_fbd.py | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tools/ladder/tests/fixtures/fbd-fanout-two-pins.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 10235ce..b4bdffa 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -414,7 +414,17 @@ def _shared_source(outputs): if sources[0] is None: return None boxes = [source.call if isinstance(source, OutputRef) else source for source in sources] - return sources[0] if all(box is boxes[0] for box in boxes) else None + if not all(box is boxes[0] for box in boxes): + return None + # Readers spread across more than one pin of one instance box need a + # column per pin: _render_joined draws that. _render_fanout stacks every + # reader in a single column and pushes a lower pin's wire onto whatever + # row is free, which for a timer read on Q and ET lands the ET store on + # the box's bottom border. Hand those to _render_joined instead. + pins = set(source.pin for source in sources if isinstance(source, OutputRef)) + if len(pins) > 1 and getattr(boxes[0], "instance_name", None): + return None + return sources[0] def _shared_call(outputs): diff --git a/tools/ladder/tests/fixtures/fbd-fanout-two-pins.xml b/tools/ladder/tests/fixtures/fbd-fanout-two-pins.xml new file mode 100644 index 0000000..2547128 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-fanout-two-pins.xml @@ -0,0 +1,9 @@ + + +xRun +T#5S + +xA +xB +tC + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index b42b849..4064d5e 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -696,6 +696,31 @@ def control_free(lines): check("crossing reads: no wire runs from Q", not any("Q" + U["PIN_R"] + U["H"] in line for line in crossing_art)) +# --- a fan-out spread across two pins ---------------------------------------- + +# A timer whose Q feeds two stores and whose ET feeds a third. Every reader +# hangs straight off the box, so _shared_source claimed it and _render_fanout +# stacked all three in one column: ET, pushed past Q's second reader, landed +# on the box's bottom border and its wire ran out of the "corner". A shared +# instance read across more than one pin now goes through the joined +# renderer, which gives each pin a column of its own. +TWO_PIN_FANOUT = os.path.join(HERE, "fixtures", "fbd-fanout-two-pins.xml") +tpf = parse_fbd.parse_pous(TWO_PIN_FANOUT)[0] +tpf_st = st_render.render_pou(tpf) +tpf_art = fbd_render.render_network(tpf.networks[0]) + +check_equal("two-pin fanout: one box is drawn", len([l for l in tpf_art if "tmr : TON" in l]), 1) +check_equal("two-pin fanout: the timer is called once", len([l for l in tpf_st if l.startswith("tmr(")]), 1) +check("two-pin fanout: Q reaches both stores", "xA := tmr.Q;" in tpf_st and "xB := tmr.Q;" in tpf_st) +check("two-pin fanout: ET reaches its store", "tC := tmr.ET;" in tpf_st) +# The defect: a wire running straight out of the box's bottom-right corner. +check("two-pin fanout: no wire leaves the bottom border", not any(U["BR"] + U["H"] in l for l in tpf_art)) +# Q teed to two readers, ET on its own row to its store. +check("two-pin fanout: Q branches to two readers", any("Q" + U["PIN_R"] in l and U["T_DOWN"] in l for l in tpf_art)) +check("two-pin fanout: ET turns down its own column", any("ET" + U["PIN_R"] in l and U["TR"] in l for l in tpf_art)) +check("two-pin fanout: every store is reached", all(any(name in l for l in tpf_art) for name in ("xA", "xB", "tC"))) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) From 7fc8a063fac2e44fbc340b7759daafb8869a2fde Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 03:02:22 +1000 Subject: [PATCH 68/91] state the stores and enables an operator box carries in the ST Three logic gaps in the equivalent-ST rendering, all off the export path but wrong in the tool that writes the ST: - A store written straight onto an operator's output pin - the MOVE-with- EN shape - was dropped. The operator branch returned its expression before the output-pin loop, so the diagram drew the wire and the ST said nothing. The store is now emitted, guarded by EN. - The negation bubble on an operator's result was looked for on the box's active output, which is ENO on an EN/ENO box because CODESYS lists ENO first. A negated Out1 then lost its NOT. The bubble is now applied on the pin the reader actually takes. - A negated ENO was reported as the plain enable. It now inverts. - The LD walker's EXECUTE branch used the rung condition raw, so a negated or edge-triggered EN pin was ignored: a negated EN read as unguarded and a bare rail with a negated EN as unconditional. It now gates the body the same way every other block's power pin is gated. Found in a full-sweep review. --- src/st_render.py | 67 +++++++++++++++++++++++++------ tools/ladder/tests/test_fbd.py | 35 ++++++++++++++++ tools/ladder/tests/test_ladder.py | 30 ++++++++++++++ 3 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/st_render.py b/src/st_render.py index 889cf50..04dce7e 100644 --- a/src/st_render.py +++ b/src/st_render.py @@ -76,9 +76,15 @@ def rung_to_statements(rung): if item.st_code: # An EXECUTE box is inline ST already, and the rung condition # is what decides whether it runs. Emitting a call to a box - # that has no body loses the whole of it. - if condition: - statements.append("IF %s THEN" % condition) + # that has no body loses the whole of it. The bubble and the + # P or N on the EN pin gate it just as they gate any other + # block, so a negated EN inverts the guard and a bare rail + # with a negated EN never runs it. + guard = condition + if item.power_negated or item.power_edge: + guard = pin_value(condition or "TRUE", item.power_negated, item.power_edge) + if guard: + statements.append("IF %s THEN" % guard) statements.extend(" " + line for line in item.st_code) statements.append("END_IF") else: @@ -214,14 +220,24 @@ def _fbd_value(node, statements, emitted=None): # value differs per reader, so it is computed here rather than # memoised with the call. value = _fbd_value(node.call, statements, emitted) - if node.call.is_operator and node.pin == ENO_PIN: - # ENO says the box ran, which is what its EN said. It is not the - # result: reading it as the expression made "xSumOk := iA + iB + - # iC;" out of a boolean that only ever says whether the add ran. - return _enable(node.call, statements, emitted) or "TRUE" - if node.call.is_operator or not node.pin: - # An operator has no instance to take a pin from; it inlines as - # the one expression whichever pin reads it. + if node.call.is_operator: + if node.pin == ENO_PIN: + # ENO says the box ran, which is what its EN said. It is not + # the result: reading it as the expression made "xSumOk := iA + # + iB + iC;" out of a boolean that only says whether the add + # ran. + result = _enable(node.call, statements, emitted) or "TRUE" + else: + # An operator has no instance to take a pin from; it inlines + # as the one expression whichever pin reads it. + result = value + # The bubble is on the pin being read, not on whichever pin the + # box calls its active output - a box that lists ENO first still + # inverts a negated Out1. + if node.pin in node.call.negated_outputs: + result = "NOT " + _operand(result) + return result + if not node.pin: return value text = "%s.%s" % (node.call.instance_name, node.pin) if node.pin in node.call.negated_outputs: @@ -280,6 +296,14 @@ def remember(value): statements.append("END_IF") else: statements.extend(node.st_code) + # A store written on the ENO pin records that the box ran, which + # is what its EN said. Dropping it lost the assignment entirely. + for pin, assigned in node.outputs: + if assigned and pin == ENO_PIN: + reported = guard or "TRUE" + if pin in node.negated_outputs: + reported = "NOT " + _operand(reported) + statements.append(store_statement(assigned, reported, node.stored_outputs.get(pin))) return remember("") if node.is_operator: @@ -288,8 +312,25 @@ def remember(value): # operands: folding it in made "iSum := xEn + iA + iB + iC;" out # of a three-way addition that runs only while xEn. expression = _operator_expression(node, [value for pin, value in pairs if pin != EN_PIN]) - if node.active_output in node.negated_outputs: - expression = "NOT " + _operand(expression) + # A store written straight onto an output pin - the MOVE-with-EN + # shape - executes while EN holds. It was dropped: the operator + # returned before this loop, so the assignment never appeared. + guard = _enable(node, statements, emitted) + for pin, assigned in node.outputs: + if not assigned: + continue + if pin == ENO_PIN: + stored = _enable(node, statements, emitted) or "TRUE" + else: + stored = expression + if pin in node.negated_outputs: + stored = "NOT " + _operand(stored) + statement = store_statement(assigned, stored, node.stored_outputs.get(pin)) + if guard and guard != "TRUE" and pin != ENO_PIN: + statement = "IF %s THEN %s END_IF" % (guard, statement) + statements.append(statement) + # The bubble on whichever pin a reader takes is applied there, in + # the OutputRef branch, so the shared expression stays un-negated. return remember(expression) name = node.instance_name diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 4064d5e..a29781a 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -721,6 +721,41 @@ def control_free(lines): check("two-pin fanout: every store is reached", all(any(name in l for l in tpf_art) for name in ("xA", "xB", "tC"))) +# --- a store written on an operator's output pin ----------------------------- + +# The MOVE-with-EN shape: an operator gated by EN, its result written straight +# onto an output pin. The operator branch returned its expression before the +# output-pin loop, so the store was dropped from the ST - the diagram drew the +# wire, the ST said nothing. The store now appears, guarded by EN. +move = Call("MOVE", inputs=[("EN", Signal("xCond")), ("In", Signal("iSrc"))], outputs=[("ENO", None), ("Out", "iDst")]) +move_st = st_render.network_to_statements(Network("", [move])) +check("operator store: the guarded store is emitted", "IF xCond THEN iDst := MOVE(iSrc); END_IF" in move_st) + +add = Call("ADD", inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], outputs=[("ENO", None), ("Out1", "iSum")]) +add_st = st_render.network_to_statements(Network("", [add])) +check("operator store: the sum is stored under its guard", "IF xEn THEN iSum := iA + iB; END_IF" in add_st) + +# A bubble on Out1 while ENO is listed first: the box's active output is ENO, +# so the negation used to be looked for on the wrong pin and lost. The bubble +# is on the pin the reader takes. +neg_out = Call("ADD", inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], + outputs=[("ENO", None), ("Out1", None)], negated_outputs=set(["Out1"]), wired_outputs=["Out1"]) +neg_out_st = st_render.network_to_statements(Network("", [Assign("iSum", OutputRef(neg_out, "Out1"))])) +check("operator negate: a negated Out1 inverts though ENO is first", "IF xEn THEN iSum := NOT (iA + iB); END_IF" in neg_out_st) + +# A negated ENO reports the inverse of the enable. +neg_eno = Call("ADD", inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], + outputs=[("ENO", None), ("Out1", None)], negated_outputs=set(["ENO"]), wired_outputs=["ENO"]) +neg_eno_st = st_render.network_to_statements(Network("", [Assign("ok", OutputRef(neg_eno, "ENO"))])) +check("operator negate: a negated ENO inverts the enable", "ok := NOT xEn;" in neg_eno_st) + +# An EXECUTE box whose ENO is stored: the store records that it ran. +exec_eno = Call("EXECUTE", inputs=[("EN", Signal("xRun"))], outputs=[("ENO", "xDid")], st_code=["a := 1;"]) +exec_eno_st = st_render.network_to_statements(Network("", [exec_eno])) +check("execute store: the body is guarded", "IF xRun THEN" in exec_eno_st and " a := 1;" in exec_eno_st) +check("execute store: the ENO store records the run", "xDid := xRun;" in exec_eno_st) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index afeb5f6..14a75ed 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -467,6 +467,36 @@ def check_golden(name, rendered_lines, golden_path): check("fidelity: rail-fed negated power pin is stated", any("tmrD(IN := NOT TRUE);" in line for line in fidelity_st)) +# --- an EXECUTE box's EN pin bubble and edge in ST --------------------------- + +# The EXECUTE branch of the LD walker used the rung condition raw, skipping +# the bubble and the P or N that every other block's power pin gets. A negated +# EN then read as an unguarded run, and a bare rail with a negated EN as an +# unconditional one - the exact inverse of when the program runs the code. +import st_render # noqa: E402 + + +def _execute(power_negated=False, power_edge=None): + return Element( + kind="block", type_name="EXECUTE", input_pins=[("EN", None)], + output_pins=[("ENO", None)], st_code=["a := 1;"], + power_negated=power_negated, power_edge=power_edge, active_output="ENO", + ) + + +def _contact(name): + return Element(kind=CONTACT, label=name) + + +neg = st_render.rung_to_statements(Series([_contact("xRun"), _execute(power_negated=True)])) +check("execute EN: a negated EN inverts the guard", "IF NOT xRun THEN" in neg) +edge = st_render.rung_to_statements(Series([_contact("xRun2"), _execute(power_edge="rising")])) +check("execute EN: an edge EN triggers on the change", "IF R(xRun2) THEN" in edge) +bare = st_render.rung_to_statements(_execute(power_negated=True)) +check("execute EN: a bare rail with a negated EN never runs it", "IF NOT TRUE THEN" in bare) +check("execute EN: a bare negated EN is not emitted unconditionally", bare[0] != "a := 1;") + + # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships From f97b2cc46d8d7aa3ad4f7eec1b34199d036c8a71 Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 03:06:01 +1000 Subject: [PATCH 69/91] stop the export tree being dropped, and tidy two export edges Three unrelated hygiene fixes found in a full-sweep review: - The generic Python .gitignore patterns (build/, lib/, var/, target/ and the rest) were unanchored, so a POU or a folder a developer named any of those anywhere under GraphicalTesting/ was silently dropped from the export. The tracked export tree is now re-included in full, with only the device and communication subtrees left out as before. The staging and backup folders util.py writes beside an export are ignored too, so a stray "git add -A" cannot sweep them in. - install.bat did not quote the script path, so it failed when the repo lived under a path with a space. - A native network list beside a file with nothing renderable in it - a member export with no body of its own - was run through the alignment and counted as a failure to line up, printing a numbering warning for a POU that was never drawn. The alignment now runs only when a POU is present. --- .gitignore | 18 ++++++++++++++++++ install.bat | 2 +- src/graphical_export.py | 6 +++++- tools/ladder/tests/test_export.py | 13 +++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d6e2555..196851b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,13 @@ review/ GraphicalTesting/*/devices/ GraphicalTesting/*/communication/ +# The staging and backup copies util.py writes beside an export. A locked +# target leaves one behind, and a stray "git add -A" would sweep the whole +# export into the repo under it - which is how the device xml got committed +# once already. +*.codescribe_staging/ +*.codescribe_backup/ + .vscode/ # Claude Code's per-developer tool permissions. Machine-specific paths, and @@ -188,3 +195,14 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# The exported source tree is tracked in full, so none of the generic Python +# and tooling patterns above may reach into it: a POU or a folder a developer +# names "lib", "build", "var" or "target" would otherwise be dropped from the +# export with no warning. Re-include the directories first and then their +# files - git will not descend into a directory a bare pattern excluded - and +# re-exclude only the two heavy subtrees that are deliberately left out. +!GraphicalTesting/**/ +!GraphicalTesting/** +GraphicalTesting/*/devices/ +GraphicalTesting/*/communication/ diff --git a/install.bat b/install.bat index 09faef0..b8bd7d6 100644 --- a/install.bat +++ b/install.bat @@ -1,2 +1,2 @@ -python %~dp0/install.py +python "%~dp0install.py" pause diff --git a/src/graphical_export.py b/src/graphical_export.py index 0f6605e..4bba0db 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -212,7 +212,11 @@ def render_plcopen(plcopen_path, declaration_text=None, member_name=None, native # up rather than as a reason to say nothing. warnings = [] native = native_networks.read_networks(native_path) if native_path is not None else None - if native: + if native and pous: + # No POU means nothing to number - a member export that carried no + # renderable body of its own, say. Running the alignment on it counted + # a spurious failure and printed a "could not line up" note for a POU + # that was never drawn. aligned = native_networks.align(native, pous[0][0].networks) if len(pous) == 1 else None if aligned is None: STATS["alignment_failures"] += 1 diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 0566b3a..cb68185 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -529,6 +529,19 @@ def __init__(self, outputs): graphical_export.reset_stats() +# A native list beside a file with nothing renderable in it - a member export +# that carried no body of its own, or an SFC-only file - must not be counted +# as a failure to line up, nor warn about numbering for a POU never drawn. +graphical_export.reset_stats() +SFC_ONLY = os.path.join(HERE, "fixtures", "codesys", "SFCTesting.xml") +NATIVE_LIST = os.path.join(HERE, "fixtures", "native_networks.xml") +no_pou_lines = graphical_export.render_plcopen(SFC_ONLY, None, None, NATIVE_LIST) +check_equal("no renderable POU: nothing is drawn", no_pou_lines, []) +check_equal("no renderable POU: no alignment failure is counted", graphical_export.STATS["alignment_failures"], 0) +check("no renderable POU: no numbering warning is emitted", graphical_export.ALIGNMENT_WARNING not in no_pou_lines) +graphical_export.reset_stats() + + # --- read-only service exports: library list and visualisation manager ------ # Library behaviour is not exportable, but which exact versions the project From c3562ea87ca305c3d1d9a424221133a19e1aa367 Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 03:07:55 +1000 Subject: [PATCH 70/91] do not tell a method's rendering its declaration is the parent's A sub-POU member's rendering opens with a note saying the declaration below is the parent POU's, because an action's PLCopen export carries the parent's declaration. A graphical method carries its own, which the export passes through, so the note misdescribed what followed it. The note is now emitted only when no member declaration was supplied - the action case it was written for. Found in a full-sweep review. --- src/graphical_export.py | 5 ++++- tools/ladder/tests/test_export.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/graphical_export.py b/src/graphical_export.py index 4bba0db..0615e8e 100644 --- a/src/graphical_export.py +++ b/src/graphical_export.py @@ -229,7 +229,10 @@ def render_plcopen(plcopen_path, declaration_text=None, member_name=None, native notes = [] for pou, _art_renderer in pous: head = list(warnings) - if getattr(pou, "member_of_parent", False): + # The note is true only when the rendering opens with the parent's + # declaration. A graphical method carries its own, passed in as + # declaration_text, so there the note would misdescribe what follows. + if getattr(pou, "member_of_parent", False) and declaration_text is None: head.append(u"(* " + pou.name + u" - the declaration below is the parent POU's *)") notes.append(head + [u""] if head else []) diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index cb68185..4c85583 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -338,6 +338,22 @@ def failing_open_utf8(path, mode): check("the method's own body is drawn", "Status.Method" in method_content) check("the method does not draw the parent", "Status.Parent" not in method_content) + # A method carries its own declaration, so the rendering must not claim + # the declaration below is the parent's - the note is only for members + # that open with the parent's declaration, like an action. + method_decl_base = os.path.join(workspace, "FB_TEST.ComputeDecl") + method_decl = FakePou("Compute", METHOD_FIXTURE, declaration="METHOD Compute : INT\nVAR_INPUT\nEND_VAR") + check( + "a method with its own declaration renders", + graphical_export.write_rendered_text(method_decl, method_decl_base, member_name="Compute") is True, + ) + method_decl_content = read(method_decl_base + ".txt") + check("the method shows its own declaration", "METHOD Compute : INT" in method_decl_content) + check( + "the method rendering does not claim the parent's declaration", + "the declaration below is the parent POU's" not in method_decl_content, + ) + # The parent's own rendering must still be the parent body, members # excluded - iter_bodies only ever took the pou's direct . parent_base = os.path.join(workspace, "PLC_TEST") From 394c182816f06af24d00f3537f7925a858ca4047 Mon Sep 17 00:00:00 2001 From: kehinde Date: Sat, 12 Sep 2026 03:09:53 +1000 Subject: [PATCH 71/91] correct stale and overstated claims in the docs Full-sweep review turned up documentation that no longer matches the code: - The library list was said to show "exactly which library versions the project resolves". It shows the resolved version where CODESYS reports one and the requested constraint (such as *) where it does not, as the SafetyPLC export's "ifmR360-3, *" line shows. - The Visualization Manager note credited recursion with including the hotkey configuration. Recursion takes in the target and web visualisation settings; the hotkey mapping is in the manager entry itself. - The CHANGELOG said the export summary reports how many POUs failed to render. There is no such count; a render that raises is reported on its own warning line and the export carries on. - The README described two CI jobs; there are three, and both the ascii and ironpython jobs do more than it said. --- CHANGELOG.md | 6 +++--- README.md | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf0ff1..2cf6766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An EXECUTE box shows the inline ST that is its whole body rather than an empty rectangle. Where the export carries no plaintext declaration the rendering is rebuilt from the structured interface and says so on its first line, and a variable whose type the export omits reads `UNKNOWN` rather than being assumed to be a `BOOL`. The export summary reports how many POUs were rendered, how many failed, and which were skipped for having no renderable body. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. +- Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An EXECUTE box shows the inline ST that is its whole body rather than an empty rectangle. Where the export carries no plaintext declaration the rendering is rebuilt from the structured interface and says so on its first line, and a variable whose type the export omits reads `UNKNOWN` rather than being assumed to be a `BOOL`. The export summary reports how many POUs were rendered and which were skipped for having no renderable body; a POU whose rendering raises is reported on its own warning line as the export carries on. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. - An export that produces no files no longer replaces the previous export folder with an empty one; it fails with an explanation and leaves the folder untouched. `Export Lib To Files` on a device project was the trigger: its walker only exports objects directly under the project root, so on a device project it exported nothing and wiped the existing export. That script now also refuses a project with devices up front and points to `Export To Files`. -- The Library Manager exports its reference list as a read-only `.libraries.txt` (name, version, vendor per line). Library behaviour itself is not exportable, but any bench check of a library needs to know exactly which version the project resolves. -- The Visualization Manager exports natively as a read-only `.service.txt`, recursively, so the global hotkey (key configuration) mapping and target/web visualization settings become reviewable. It is still never imported - importing it raises interactive overwrite dialogs, which is why earlier versions dropped it from the export entirely - and the project template continues to carry the real object. +- The Library Manager exports its reference list as a read-only `.libraries.txt` (name, version, vendor per line). Library behaviour itself is not exportable, but any bench check of a library needs the version it resolves to - shown where CODESYS reports it, and as the requested constraint (such as `*` for newest) where it does not. +- The Visualization Manager exports natively as a read-only `.service.txt`. It is exported recursively to take in the target and web visualization settings under it; the global hotkey (key configuration) mapping is in the manager entry itself. Both become reviewable. It is still never imported - importing it raises interactive overwrite dialogs, which is why earlier versions dropped it from the export entirely - and the project template continues to carry the real object. - Visualisations export as `.vis.xml`. Earlier versions wrote `.xml`, which silently collided with any POU of the same name (a `Main` program plus a `Main` visualisation is common). Old plain `.xml` exports still import correctly; re-exporting once migrates the tracked files. - Visualisation service objects (visualisation manager and related service GUIDs) are recognised and no longer exported. diff --git a/README.md b/README.md index b705c97..05f6205 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ Actions and Transitions export as `.st` with the kind encoded in the filename (` Two service objects export **read-only** — written on every export, never imported (the project template carries the real objects): -- The Library Manager exports its reference list as `.libraries.txt` (one line per library: name, version, vendor), so a review or bench check knows exactly which library versions the project resolves. -- The Visualization Manager exports natively as `.service.txt` (recursively, so the global hotkey/key configuration and target/web visualization settings are included). Importing this object raises interactive overwrite dialogs, which is why it is not round-tripped. +- The Library Manager exports its reference list as `.libraries.txt` (one line per library: name, version, vendor), so a review or bench check can see the version each library resolves to - shown where CODESYS reports it, and as the requested constraint (such as `*` for newest) where it does not. +- The Visualization Manager exports natively as `.service.txt` (recursively, to take in the target and web visualization settings under it; the global hotkey/key configuration is in the manager entry itself). Importing this object raises interactive overwrite dialogs, which is why it is not round-tripped. ### Reading graphical POUs @@ -234,10 +234,11 @@ The scripts run inside the CODESYS ScriptEngine, which embeds IronPython 2.7: - `main` is protected: changes go through a pull request and the CI checks must pass. - Changes that alter the export format or behaviour should be noted in [CHANGELOG.md](CHANGELOG.md). -CI runs two jobs on every pull request (see `.github/workflows/ci.yml` and `tools/ci/`): +CI runs three jobs on every pull request (see `.github/workflows/ci.yml` and `tools/ci/`): -- `ascii-check`: fails on any non-ASCII byte in `src/*.py`. -- `ironpython`: compiles every src file with real IronPython 2.7.12 (catches Python 2 syntax errors) and imports the library modules against a stubbed `scriptengine` (catches module-scope errors). +- `ascii-check`: fails on any non-ASCII byte in `src/*.py`, then compiles every src file with Python 3. +- `ironpython`: compiles every src file with real IronPython 2.7.12 (catches Python 2 syntax errors), imports the library modules against a stubbed `scriptengine` (catches module-scope errors), and runs the four renderer test suites under IronPython 2.7. +- `ladder`: runs the same four renderer test suites under Python 3. Note that `python -m py_compile` under Python 3 is not a sufficient local check; it misses both failure classes above. From 04d60a063f04417dfef7ce2e9bbb29faaa3385c1 Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 14 Sep 2026 15:08:40 +1000 Subject: [PATCH 72/91] redraw a stateless operator read by a second rung, do not name it A block read again in the same network is named by the pin it takes, so a stateful function block stays one box that runs once. That is wrong for a stateless operator: it has no instance, so the reference read "OR.Out1" - a name that points at no variable and is ambiguous the moment a second OR appears. An operator is now redrawn instead, which the FBD renderer already does; only an instance is named across rungs. Found while reviewing a re-export whose network read an OR box's ENO from a later rung. --- src/parse_ld.py | 10 ++++++++-- .../tests/fixtures/ld-operator-across-rungs.xml | 11 +++++++++++ tools/ladder/tests/test_ladder.py | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld-operator-across-rungs.xml diff --git a/src/parse_ld.py b/src/parse_ld.py index 75fad2e..e0779e3 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -195,7 +195,8 @@ def _block_reference(node, via_pin): A block driving three outputs is one box that runs once. Rebuilding it for every output drew it three times and called it three times, which reads as three timers where the program has one; every reader after the first names - the pin it takes instead. + the pin it takes instead. Only an instance can be named this way - an + operator has no name, so its caller redraws it rather than reach here. """ pin = via_pin if pin is None and node.outputs: @@ -215,7 +216,12 @@ def _build_block(node, by_id, visiting, via_pin, drawn): an inVariable are parameters, not power, so the first genuinely wired pin wins and the rest become captions inside the box. """ - if node.local_id in drawn: + if node.local_id in drawn and node.instance_name: + # Drawn once already. A stateful function block is one box that runs + # once, so the next reader names the pin it takes. A stateless + # operator has no instance to name - "OR.Out1" points at no variable, + # and would be ambiguous with a second OR - so it is redrawn instead, + # the rule the FBD renderer already follows. return _block_reference(node, via_pin) drawn.add(node.local_id) diff --git a/tools/ladder/tests/fixtures/ld-operator-across-rungs.xml b/tools/ladder/tests/fixtures/ld-operator-across-rungs.xml new file mode 100644 index 0000000..ad0eb5a --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-across-rungs.xml @@ -0,0 +1,11 @@ + + + +xGo + +xA + + +xB + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 14a75ed..2349d02 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -497,6 +497,23 @@ def _contact(name): check("execute EN: a bare negated EN is not emitted unconditionally", bare[0] != "a := 1;") +# --- a stateless operator read by more than one rung ------------------------- + +# A function block read a second time in a network is named by the pin it +# takes ([tmr.Q]) - one box that runs once. A stateless operator has no +# instance to name: "OR.Out1" points at no variable and would be ambiguous +# with a second OR, so it is redrawn instead, the same rule the FBD renderer +# follows. The ladder path used to name it too. +OPERATOR_ACROSS = os.path.join(FIXTURES, "ld-operator-across-rungs.xml") +opr_pou = parse_pous(OPERATOR_ACROSS)[0] +opr_art = render_pou(opr_pou) + +check("operator reuse: the operator is not named as an instance", not any("OR.Out1" in line for line in opr_art)) +check("operator reuse: the operator is not referenced in brackets", not any("[OR" in line for line in opr_art)) +check_equal("operator reuse: the OR box is drawn in both rungs", len([l for l in opr_art if "In1 Out1" in l]), 2) +check("operator reuse: both coils are still driven", any("xA" in l for l in opr_art) and any("xB" in l for l in opr_art)) + + # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships From f1c79f9da552af8c01ba9d59563cd49b1e4b8d6c Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 14 Sep 2026 15:27:25 +1000 Subject: [PATCH 73/91] draw an EXECUTE box's inline ST inside the box, not beneath it The body was written under the box as a separate indented block. It now sits inside the box, below the EN/ENO pins, with the box widened to the longest line - the way CODESYS shows an inline-ST box. Both renderers draw it wherever the box appears, so the walk that appended it beneath the diagram is gone; a box drawn behind another box carries its own body. Reverses the "under the box" form from the round-2 review at the author's request; README and CHANGELOG updated to match. --- CHANGELOG.md | 2 +- README.md | 2 +- src/fbd_render.py | 63 +++++++++---------------------- src/ld_render.py | 32 +++++----------- tools/ladder/tests/test_fbd.py | 19 ++++------ tools/ladder/tests/test_ladder.py | 1 + 6 files changed, 38 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf6766..7709e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An EXECUTE box shows the inline ST that is its whole body rather than an empty rectangle. Where the export carries no plaintext declaration the rendering is rebuilt from the structured interface and says so on its first line, and a variable whose type the export omits reads `UNKNOWN` rather than being assumed to be a `BOOL`. The export summary reports how many POUs were rendered and which were skipped for having no renderable body; a POU whose rendering raises is reported on its own warning line as the export carries on. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. +- Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An EXECUTE box shows the inline ST that is its whole body inside the box, below its pins, rather than as an empty rectangle. Where the export carries no plaintext declaration the rendering is rebuilt from the structured interface and says so on its first line, and a variable whose type the export omits reads `UNKNOWN` rather than being assumed to be a `BOOL`. The export summary reports how many POUs were rendered and which were skipped for having no renderable body; a POU whose rendering raises is reported on its own warning line as the export carries on. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. - An export that produces no files no longer replaces the previous export folder with an empty one; it fails with an explanation and leaves the folder untouched. `Export Lib To Files` on a device project was the trigger: its walker only exports objects directly under the project root, so on a device project it exported nothing and wiped the existing export. That script now also refuses a project with devices up front and points to `Export To Files`. - The Library Manager exports its reference list as a read-only `.libraries.txt` (name, version, vendor per line). Library behaviour itself is not exportable, but any bench check of a library needs the version it resolves to - shown where CODESYS reports it, and as the requested constraint (such as `*` for newest) where it does not. - The Visualization Manager exports natively as a read-only `.service.txt`. It is exported recursively to take in the target and web visualization settings under it; the global hotkey (key configuration) mapping is in the manager entry itself. Both become reviewable. It is still never imported - importing it raises interactive overwrite dialogs, which is why earlier versions dropped it from the export entirely - and the project template continues to carry the real object. diff --git a/README.md b/README.md index 05f6205..b98d989 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ This file is **derived and read-only**. The native xml remains the only thing `I An equivalent-Structured-Text rendering of the same networks is available but not written by the export. The ST states the logic exactly where the diagram can only approximate it - a block read through two of its output pins is one call, and no single-wire diagram can say so - but it is a rendering, not a translation, and must never be fed back into CODESYS. `tools/ladder/write_st.py` writes one from a PLCopen export when it is wanted, and its docstring says how to put it back on the export path. -An EXECUTE box has no body of its own - the whole of it is inline ST carried alongside the box - so the rendering shows that text under the box rather than an empty rectangle, and the equivalent ST guards it with the rung or pin condition that decides whether it runs. +An EXECUTE box has no body of its own - the whole of it is inline ST carried alongside the box - so the rendering shows that text inside the box, below its pins, rather than an empty rectangle, and the equivalent ST guards it with the rung or pin condition that decides whether it runs. SFC and CFC POUs are not yet rendered; they export as native xml alone. diff --git a/src/fbd_render.py b/src/fbd_render.py index b4bdffa..5a273e4 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -172,9 +172,14 @@ def _render_call(call, read_pin, drawn, subs=None): widths = [len(title)] for row in range(box_first, box_last + 1): widths.append(len(in_at.get(row, "")) + 3 + len(out_at.get(row, ""))) + # An EXECUTE box carries its inline ST as its body: the lines sit inside + # the box, below the pins, and widen it to the longest of them. + code = call.st_code + widths += [len(line) + 2 for line in code] inner = max(widths) - height = max(len(left), box_last + 2) + body_last = box_last + len(code) + height = max(len(left), body_last + 2) left += [" " * left_width] * (height - len(left)) # handoff was computed before the shift; recompute against the final rows. @@ -198,7 +203,7 @@ def _render_call(call, read_pin, drawn, subs=None): box = centred(title, inner + 2) elif row == box_first - 1: box = chars["TL"] + chars["H"] * inner + chars["TR"] - elif row == box_last + 1: + elif row == body_last + 1: box = chars["BL"] + chars["H"] * inner + chars["BR"] elif box_first <= row <= box_last: left_pin = in_at.get(row, "") @@ -208,6 +213,10 @@ def _render_call(call, read_pin, drawn, subs=None): right_edge = chars["PIN_R"] if wired_out else chars["V"] gap = inner - len(left_pin) - len(right_pin) box = left_edge + left_pin + " " * gap + right_pin + right_edge + tail_at.get(row, "") + elif box_last < row <= body_last: + # A body line of an EXECUTE box, inside the box below the pins. + text = code[row - box_last - 1] + box = chars["V"] + " " + text + " " * (inner - len(text) - 1) + chars["V"] else: box = " " * (inner + 2) lines.append(left[row] + box) @@ -701,43 +710,6 @@ def row_of(pin): return out -def _execute_bodies(outputs): - """The inline ST of every EXECUTE box in a network, laid out for the page. - - Each body once, in the order the boxes are met - a box behind another - runs first, so its body comes first. - """ - seen = set() - found = [] - - def walk(node): - call = node.call if isinstance(node, OutputRef) else node - if isinstance(call, Call): - if id(call) in seen: - return - seen.add(id(call)) - for _pin, source in call.inputs: - if source is not None: - walk(source) - if call.st_code: - found.append(call) - elif isinstance(node, Assign): - if node.source is not None: - walk(node.source) - elif isinstance(node, Jump): - if node.condition is not None: - walk(node.condition) - - for tree in outputs: - walk(tree) - - lines = [] - for call in found: - lines.append("") - lines.extend(" " + line for line in call.st_code) - return lines - - def _render_outputs(outputs, drawn): """The diagram of one network's outputs, joined or branched as they share.""" if _shared_source(outputs) is not None: @@ -760,16 +732,17 @@ def _render_outputs(outputs, drawn): def render_network(network): - """Render one network, which may drive several outputs from one source.""" + """Render one network, which may drive several outputs from one source. + + An EXECUTE box's inline ST is its body; _render_call draws it inside the + box, below the pins, wherever the box sits - as the network's own output, + behind the store its ENO feeds, or behind another box. + """ outputs = getattr(network, "outputs", [network]) # Per network: a box drawn for one output must not be drawn again for the # next, but a box shared between two networks is two boxes on the page. drawn = set() - # An EXECUTE box's body is the logic; drawing the box without it would be - # an empty rectangle where a dozen lines of ST should be. The box is found - # wherever it sits - behind the store its ENO feeds, or behind another - # box - and not only when it is the network's own output. - return _render_outputs(outputs, drawn) + _execute_bodies(outputs) + return _render_outputs(outputs, drawn) def render_pou(pou): diff --git a/src/ld_render.py b/src/ld_render.py index 6b5a801..861af13 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -205,7 +205,10 @@ def _render_block(element): tails += [""] * (rows - len(tails)) title = element.title - inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)]) + # An EXECUTE box carries its inline ST as its body: the lines sit inside + # the box, below the pins, and widen it to the longest of them. + code = element.st_code + inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)] + [len(line) + 2 for line in code]) # Two columns to the left of the box: the widest value, then a short wire # into the pin. The power pin's row is all wire - the rung feeds that one. @@ -243,6 +246,9 @@ def feed(index): if onward and element.active_output in element.negated_outputs: right_edge = "o" lines.append(feed(index) + left_edge + left[index] + " " * gap + right[index] + right_edge + tails[index]) + for line in code: + # A body line of an EXECUTE box, inside the box below the pins. + lines.append(" " * lead + chars["V"] + " " + line + " " * (inner - len(line) - 1) + chars["V"]) lines.append(" " * lead + chars["BL"] + chars["H"] * inner + chars["BR"]) # Row 0 is the title and row 1 the top border, so the first pin is row 2. @@ -380,38 +386,18 @@ def _render_wire(expr): return lines -def _inline_st(expr, found): - """Collect the inline ST of any EXECUTE box on this rung.""" - if isinstance(expr, Series): - for item in expr.items: - _inline_st(item, found) - elif isinstance(expr, Parallel): - for branch in expr.branches: - _inline_st(branch, found) - elif isinstance(expr, Element): - for pin_block in expr.pin_blocks: - _inline_st(pin_block, found) - if expr.st_code: - found.append(expr) - return found - - def render_rung(expr): """Render one rung, bounded by the power rails. Boxes feeding side pins are drawn first, on wires of their own: the caption that reads one names only its output, so without the box the - diagram would not say what feeds it. + diagram would not say what feeds it. An EXECUTE box's inline ST is drawn + inside the box, below its pins, by _render_block. """ lines = [] for pin_block in _pin_block_rungs(expr, []): lines.extend(_render_wire(pin_block)) lines.extend(_render_wire(expr)) - # An EXECUTE box is nothing but inline ST; the box on its own is an empty - # rectangle where the logic should be. - for element in _inline_st(expr, []): - lines.append("") - lines.extend(" " + line for line in element.st_code) return lines diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index a29781a..d1921eb 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -620,6 +620,7 @@ def wire_rows(art): # The body was printed only when the network's own output was the EXECUTE # call. Wire its ENO to a variable and the output is a store, so the box was # drawn - an empty rectangle - and the two lines of ST inside it were gone. +# The body now sits inside the box, below the pins. ENO_WIRED = os.path.join(HERE, "fixtures", "r2-2-fbd-execute-eno-wired.xml") eno = parse_fbd.parse_pous(ENO_WIRED)[0] eno_st = st_render.render_pou(eno) @@ -627,26 +628,22 @@ def wire_rows(art): check("execute eno: the box is drawn", any(line.strip() == "EXECUTE" for line in eno_art)) check("execute eno: the wire to the store is drawn", any("ENO" + U["PIN_R"] + U["H"] * 3 + "> xRan" in line for line in eno_art)) -check_equal("execute eno: the body follows the diagram", eno_art[-2:], [" a := 1;", " b := 2;"]) -check_equal("execute eno: a blank line separates the body", eno_art[-3], "") -check_equal("execute eno: the body is printed once", len([line for line in eno_art if line.strip() == "a := 1;"]), 1) +check("execute eno: the body is inside the box", any(U["V"] + " a := 1;" in line for line in eno_art) and any(U["V"] + " b := 2;" in line for line in eno_art)) +check_equal("execute eno: each body line appears once", [len([l for l in eno_art if "a := 1;" in l]), len([l for l in eno_art if "b := 2;" in l])], [1, 1]) +check("execute eno: the box closes below the body", eno_art[-1].strip().startswith(U["BL"])) check("execute eno: the ST guards the body with EN", "IF xRun THEN" in eno_st and " a := 1;" in eno_st) check("execute eno: the ENO store reads the enable", "xRan := xRun;" in eno_st) -# The same box behind another box: still found, still printed once, and a -# box two outputs share is not printed per output. +# A box behind another box shows its body inside it too, wherever it sits. deep_execute = Call("EXECUTE", inputs=[("EN", Signal("xRun"))], outputs=[("ENO", None)], st_code=["c := 3;"]) deep_execute.wired_outputs.add("ENO") deep = Network( "", - [ - Assign("xBoth", Call("AND", inputs=[("In1", OutputRef(deep_execute, "ENO")), ("In2", Signal("xOk"))], outputs=[("Out1", None)])), - Assign("xRan", OutputRef(deep_execute, "ENO")), - ], + [Assign("xBoth", Call("AND", inputs=[("In1", OutputRef(deep_execute, "ENO")), ("In2", Signal("xOk"))], outputs=[("Out1", None)]))], ) deep_art = fbd_render.render_network(deep) -check_equal("execute deep: the body of a nested box is printed once", len([line for line in deep_art if line.strip() == "c := 3;"]), 1) -check_equal("execute deep: it follows the diagram", deep_art[-1], " c := 3;") +check("execute deep: a nested box shows its body inside", any(U["V"] + " c := 3;" in line for line in deep_art)) +check_equal("execute deep: the body appears once", len([line for line in deep_art if "c := 3;" in line]), 1) # --- one expression reading two pins of a shared box ------------------------- diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 2349d02..43888e0 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -381,6 +381,7 @@ def check_golden(name, rendered_lines, golden_path): check_equal("execute: the inline ST is read", len(execute_pou.networks[0].outputs[0].items[-1].st_code), 4) check("execute: the diagram shows the body", any("iCount := iCount + 1;" in line for line in execute_art)) +check("execute: the body is inside the box", any(U["V"] + " iCount := iCount + 1;" in line for line in execute_art)) check("execute: the box is still drawn", any("EXECUTE" in line for line in execute_art)) # The rung condition is what decides whether the box runs, so it guards the # body rather than being dropped for looking redundant. From c5f36b9121026f320e7795f05cfd98fcb6f0a17d Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 14 Sep 2026 15:54:30 +1000 Subject: [PATCH 74/91] draw the shared head of ladder branches once, then split The ladder parser builds one branch per sink, so a contact chain or a block that feeds several sinks was repeated down every branch. Two coils off one contact drew the contact twice; a network whose OR box fed a RETURN and a coil drew the whole box in each branch, so a branch off the return line read as a separate network. The diagram now factors the leading elements common to every parallel branch out in front and splits after them, the way the editor draws it - "P AND (a OR b)" instead of "(P AND a) OR (P AND b)", which is the same power flow. This composes with the operator redraw: the repeated boxes are structurally identical, so they collapse into the one shared head. Done in the diagram renderer only; the ST rendering is untouched. A shared block whose different output pins end up on separate rungs is not merged by this - that is cross-rung and still repeats the box. --- src/ld_render.py | 61 ++++++++++++++++++- .../fixtures/ld-shared-prefix-branches.xml | 8 +++ tools/ladder/tests/test_ladder.py | 21 +++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld-shared-prefix-branches.xml diff --git a/src/ld_render.py b/src/ld_render.py index 861af13..ea47af3 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -17,7 +17,7 @@ import charset from layout import Block, centred -from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series +from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series, parallel, series # The letter a contact carries for edge detection, reused on a block's power # pin so both read the same. @@ -353,6 +353,61 @@ def _render(expr): raise TypeError("cannot render %r" % (expr,)) +def _signature(expr): + """A hashable structural fingerprint, so equal drawn elements can be spotted. + + Two elements with the same fingerprint render identically, which is what + lets a shared prefix be pulled out of parallel branches without changing + what any branch draws. + """ + if isinstance(expr, Series): + return ("series",) + tuple(_signature(item) for item in expr.items) + if isinstance(expr, Parallel): + return ("parallel",) + tuple(_signature(branch) for branch in expr.branches) + if isinstance(expr, Element): + return ( + "element", expr.kind, expr.label, expr.negated, expr.edge, expr.storage, + expr.type_name, expr.instance_name, tuple(expr.input_pins), tuple(expr.output_pins), + expr.active_output, expr.output_wired, expr.power_negated, expr.power_edge, + tuple(sorted(expr.negated_outputs)), tuple(sorted(expr.stored_outputs.items())), + tuple(expr.st_code), tuple(_signature(block) for block in expr.pin_blocks), + ) + return ("empty",) + + +def _factor(expr): + """Pull the leading elements shared by every parallel branch out in front. + + The parser builds one branch per sink, so a contact chain or a block that + feeds several sinks is repeated in each branch - drawn again and again, + which reads as separate rungs rather than one wire that branches. CODESYS + draws the shared part once and splits after it; factoring the common + prefix of the branches produces exactly that. Power flow is unchanged: + "(P AND a) OR (P AND b)" and "P AND (a OR b)" drive the same rung. + """ + if isinstance(expr, Series): + return series([_factor(item) for item in expr.items]) + if isinstance(expr, Parallel): + branches = [_factor(branch) for branch in expr.branches] + + def items_of(branch): + return list(branch.items) if isinstance(branch, Series) else [branch] + + parts = [items_of(branch) for branch in branches] + prefix = [] + while all(part for part in parts): + first = parts[0][0] + signature = _signature(first) + if any(_signature(part[0]) != signature for part in parts): + break + prefix.append(first) + parts = [part[1:] for part in parts] + if not prefix: + return parallel(branches) + return series(prefix + [parallel([series(part) for part in parts])]) + return expr + + def _pin_block_rungs(expr, found): """Collect the sub-rungs feeding side pins, in the order they execute. @@ -397,7 +452,9 @@ def render_rung(expr): lines = [] for pin_block in _pin_block_rungs(expr, []): lines.extend(_render_wire(pin_block)) - lines.extend(_render_wire(expr)) + # Draw the shared head of parallel branches once, then the split - the way + # the editor draws it - instead of repeating it down every branch. + lines.extend(_render_wire(_factor(expr))) return lines diff --git a/tools/ladder/tests/fixtures/ld-shared-prefix-branches.xml b/tools/ladder/tests/fixtures/ld-shared-prefix-branches.xml new file mode 100644 index 0000000..25a5a20 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-shared-prefix-branches.xml @@ -0,0 +1,8 @@ + + + +xGo +xA +xB + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 43888e0..5d66cd1 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -515,6 +515,27 @@ def _contact(name): check("operator reuse: both coils are still driven", any("xA" in l for l in opr_art) and any("xB" in l for l in opr_art)) +# --- a head shared by several branches is drawn once ------------------------- + +# The parser builds one branch per sink, so a contact chain or a block that +# feeds several sinks was repeated down every branch - drawn again and again, +# which read as separate rungs rather than one wire that branches after a +# shared head. The diagram now pulls the common prefix out in front, the way +# the editor draws it. +SHARED_PREFIX = os.path.join(FIXTURES, "ld-shared-prefix-branches.xml") +shared_pou = parse_pous(SHARED_PREFIX)[0] +shared_art = render_pou(shared_pou) + +# xGo feeds two coils. It is drawn once, then the wire branches to each coil. +check_equal("shared prefix: the shared contact is drawn once", len([l for l in shared_art if "xGo" in l]), 1) +check("shared prefix: both coils are still drawn", any("xA" in l for l in shared_art) and any("xB" in l for l in shared_art)) +check("shared prefix: the wire branches after the contact", any(U["T_DOWN"] in l for l in shared_art)) +# The two coils sit on their own rows, one per branch. +xa_row = [i for i, l in enumerate(shared_art) if "xA" in l][0] +xb_row = [i for i, l in enumerate(shared_art) if "xB" in l][0] +check("shared prefix: the coils are on different rows", xa_row != xb_row) + + # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships From a28307d963a1d73172e99811963a1fbb9551721a Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 14 Sep 2026 16:47:28 +1000 Subject: [PATCH 75/91] merge separate ladder rungs that split after a shared box The parser builds one rung per sink, so a box whose output feeds two sinks - a RETURN and a coil off one ENO, say - was drawn once per rung, reading as two separate networks when it is one wire that branches after the box. Rungs that begin with the same chain into the same box are now gathered and rendered as one branched rung: the shared head is drawn once and the sinks branch off it, which is what the editor shows. Rungs that share only leading contacts, or nothing, are left apart - they may be separate rungs the editor keeps separate, and fusing them would misread the program. Still repeats the box across rungs that read it on different output pins (a store on Out2 beside readers of ENO); merging those is the cross-pin case the FBD join handles and the ladder path does not yet. --- src/ld_render.py | 46 ++++++++++++++++++- .../ld-return-and-coil-share-a-box.xml | 13 ++++++ tools/ladder/tests/test_ladder.py | 35 +++++++++++--- 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld-return-and-coil-share-a-box.xml diff --git a/src/ld_render.py b/src/ld_render.py index ea47af3..893bb8b 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -441,6 +441,50 @@ def _render_wire(expr): return lines +def _block_prefix_key(rung): + """The signature of a rung's head up to and including its first block. + + Two rungs with the same key begin with the same chain into the same box. + A box read by several sinks is one box that runs once, so those rungs are + the branches of one wire that splits after it; grouping them by this key + lets the split be drawn once. A rung with no block returns None and is + never merged - rungs that share only leading contacts may be separate + rungs the editor keeps apart, and fusing them would misread the program. + """ + items = rung.items if isinstance(rung, Series) else [rung] + prefix = [] + for item in items: + prefix.append(item) + if isinstance(item, Element) and item.kind == BLOCK: + return tuple(_signature(part) for part in prefix) + return None + + +def _merge_rungs(rungs): + """Combine rungs that split after a shared box into one branched rung. + + Rungs sharing a block are gathered into a Parallel, in the order they + first appear; _factor then pulls the common head (the chain and the box) + out in front so the box is drawn once and the readers branch off it. + Everything else is left exactly as it was. + """ + order = [] + groups = {} + for rung in rungs: + key = _block_prefix_key(rung) + marker = key if key is not None else object() + if marker not in groups: + groups[marker] = [] + order.append(marker) + groups[marker].append(rung) + + merged = [] + for marker in order: + group = groups[marker] + merged.append(group[0] if len(group) == 1 else parallel(group)) + return merged + + def render_rung(expr): """Render one rung, bounded by the power rails. @@ -513,7 +557,7 @@ def render_pou(pou): for index, network in enumerate(pou.networks): lines.extend(network_headers(index + 1, network)) - for rung in network.outputs: + for rung in _merge_rungs(network.outputs): lines.extend(render_rung(rung)) lines.append("") diff --git a/tools/ladder/tests/fixtures/ld-return-and-coil-share-a-box.xml b/tools/ladder/tests/fixtures/ld-return-and-coil-share-a-box.xml new file mode 100644 index 0000000..998315b --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-return-and-coil-share-a-box.xml @@ -0,0 +1,13 @@ + + + +PowerOff +PowerOn +1 +2 + + +edgetrigger +PowerOff + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 5d66cd1..696b930 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -500,18 +500,18 @@ def _contact(name): # --- a stateless operator read by more than one rung ------------------------- -# A function block read a second time in a network is named by the pin it -# takes ([tmr.Q]) - one box that runs once. A stateless operator has no -# instance to name: "OR.Out1" points at no variable and would be ambiguous -# with a second OR, so it is redrawn instead, the same rule the FBD renderer -# follows. The ladder path used to name it too. +# An operator whose output feeds two coils is not named ("OR.Out1" points at +# no variable) and not drawn twice: the two rungs share the box, so they are +# merged into one wire that branches after it, the box drawn once. The ladder +# path used to name the second read. OPERATOR_ACROSS = os.path.join(FIXTURES, "ld-operator-across-rungs.xml") opr_pou = parse_pous(OPERATOR_ACROSS)[0] opr_art = render_pou(opr_pou) check("operator reuse: the operator is not named as an instance", not any("OR.Out1" in line for line in opr_art)) check("operator reuse: the operator is not referenced in brackets", not any("[OR" in line for line in opr_art)) -check_equal("operator reuse: the OR box is drawn in both rungs", len([l for l in opr_art if "In1 Out1" in l]), 2) +check_equal("operator reuse: the shared box is drawn once", len([l for l in opr_art if "In1 Out1" in l]), 1) +check("operator reuse: the readers branch off the box", any(U["T_DOWN"] in l for l in opr_art)) check("operator reuse: both coils are still driven", any("xA" in l for l in opr_art) and any("xB" in l for l in opr_art)) @@ -536,6 +536,29 @@ def _contact(name): check("shared prefix: the coils are on different rows", xa_row != xb_row) +# --- separate sinks that share a box are one branched rung ------------------- + +# A box whose output feeds two sinks reaches each through its own sink, so the +# parser builds a rung apiece and each redraws the box. That reads as two +# separate networks when it is one wire that splits after the box - the shape +# a RETURN and a coil sharing a block's ENO make. The rungs are now merged: +# the box is drawn once and the readers branch off it. +SHARED_BOX_SINKS = os.path.join(FIXTURES, "ld-return-and-coil-share-a-box.xml") +shared_box_pou = parse_pous(SHARED_BOX_SINKS)[0] +shared_box_art = render_pou(shared_box_pou) + +check_equal("shared box sinks: the box is drawn once", len([l for l in shared_box_art if "In2 Out2" in l]), 1) +check_equal("shared box sinks: one rung header, one network", len([l for l in shared_box_art if l.startswith("(* Network")]), 1) +check("shared box sinks: the box output branches", any(U["T_DOWN"] in l for l in shared_box_art)) +check("shared box sinks: the RETURN is drawn", any("" in l for l in shared_box_art)) +check("shared box sinks: the coil branch is drawn", any("( )" in l for l in shared_box_art)) +check("shared box sinks: the edge contact is on the coil branch", any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in l for l in shared_box_art)) +# The two sinks are on their own rows, not stacked into one. +return_row = [i for i, l in enumerate(shared_box_art) if "" in l][0] +coil_row = [i for i, l in enumerate(shared_box_art) if "( )" in l][0] +check("shared box sinks: RETURN and the coil are on different rows", return_row != coil_row) + + # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships From 20eeb1fcb9604aadf81906b17c6790e1ab8dd35e Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 14 Sep 2026 16:53:45 +1000 Subject: [PATCH 76/91] expand tabs in an EXECUTE box body so the wall stays straight A body line indented with a tab drew as several columns but counted as one character, so the box's right wall came out ragged. The body lines now have their tabs expanded to spaces (tab stops of four) before the box is measured and drawn, in both renderers. --- src/fbd_render.py | 6 ++++-- src/ld_render.py | 6 ++++-- tools/ladder/tests/test_fbd.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/fbd_render.py b/src/fbd_render.py index 5a273e4..46f2781 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -173,8 +173,10 @@ def _render_call(call, read_pin, drawn, subs=None): for row in range(box_first, box_last + 1): widths.append(len(in_at.get(row, "")) + 3 + len(out_at.get(row, ""))) # An EXECUTE box carries its inline ST as its body: the lines sit inside - # the box, below the pins, and widen it to the longest of them. - code = call.st_code + # the box, below the pins, and widen it to the longest of them. Tabs are + # expanded to spaces so the box's right wall stays straight - a tab counts + # as one character but draws as several. + code = [line.expandtabs(4) for line in call.st_code] widths += [len(line) + 2 for line in code] inner = max(widths) diff --git a/src/ld_render.py b/src/ld_render.py index 893bb8b..8e5e211 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -206,8 +206,10 @@ def _render_block(element): title = element.title # An EXECUTE box carries its inline ST as its body: the lines sit inside - # the box, below the pins, and widen it to the longest of them. - code = element.st_code + # the box, below the pins, and widen it to the longest of them. Tabs are + # expanded to spaces so the box's right wall stays straight - a tab counts + # as one character but draws as several. + code = [line.expandtabs(4) for line in element.st_code] inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)] + [len(line) + 2 for line in code]) # Two columns to the left of the box: the widest value, then a short wire diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index d1921eb..f944209 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -753,6 +753,19 @@ def control_free(lines): check("execute store: the ENO store records the run", "xDid := xRun;" in exec_eno_st) +# A body line indented with a tab: the tab draws as several columns but counts +# as one character, so it left the box's right wall ragged. Tabs are expanded +# to spaces, and every body row ends its wall in the same column. +tab_box = Call("EXECUTE", inputs=[("EN", Signal("xRun"))], outputs=[("ENO", "dude")], + st_code=["IF dude THEN", "\twhereismycar := TRUE;", "END_IF"]) +tab_art = fbd_render.render_network(Network("", [tab_box])) +check("execute tab: no tab survives into the box", not any("\t" in line for line in tab_art)) +check("execute tab: the tabbed line is inside the box", any(U["V"] + " whereismycar := TRUE;" in line for line in tab_art)) +body_rows = [line for line in tab_art if (U["V"] + " ") in line and line.rstrip().endswith(U["V"])] +check("execute tab: the box has body rows", len(body_rows) >= 3) +check_equal("execute tab: every body row's wall ends in one column", len(set(len(line.rstrip()) for line in body_rows)), 1) + + # --- language dispatch ----------------------------------------------------- check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) From 01c47e2caa5577296becb3d77396a0e3bcb9795f Mon Sep 17 00:00:00 2001 From: kehinde Date: Mon, 14 Sep 2026 17:13:43 +1000 Subject: [PATCH 77/91] re-export GraphicalTesting after editing the test POUs Regenerated the export for the StandardPLC test project: FB_TESTING gains an EXECUTE network and a dude/whereismycar body, its PleaseIhaveKids action is renamed action_test, and LD_TEST gains new networks. The rendered .txt files were produced before the cross-rung merge and tab-expansion renderer changes, so a later re-export will refresh them. --- ...aveKids.txt => FB_TESTING.action_test.txt} | 9 +- ...aveKids.xml => FB_TESTING.action_test.xml} | 2 +- .../StandardPLC/application/FB_TESTING.txt | 16 +- .../StandardPLC/application/FB_TESTING.xml | 168 +++- .../StandardPLC/application/LD_TEST.txt | 36 +- .../StandardPLC/application/LD_TEST.xml | 759 +++++++++++++++--- .../Visualization Manager.service.txt | 136 ++-- 7 files changed, 918 insertions(+), 208 deletions(-) rename GraphicalTesting/StandardPLC/application/{FB_TESTING.PleaseIhaveKids.txt => FB_TESTING.action_test.txt} (74%) rename GraphicalTesting/StandardPLC/application/{FB_TESTING.PleaseIhaveKids.xml => FB_TESTING.action_test.xml} (98%) diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.txt similarity index 74% rename from GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt rename to GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.txt index 9c62f8f..f6bffda 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.txt @@ -1,12 +1,8 @@ -(* FB_TESTING.PleaseIhaveKids - the declaration below is the parent POU's *) +(* FB_TESTING.action_test - the declaration below is the parent POU's *) PROGRAM FB_TESTING (*********************************************************************************************** -Object Name : PLC_SUPPLY -Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. -Author : KP -Date : 17/06/25 -Rev : P1 +FULLY SICK FUNCTION BLOCK DIAGRAM FOR TESTING. Do not worry about what the logic is doing. ***********************************************************************************************) VAR CONSTANT uiMinVoltage : UINT := 5000; // Minimum Voltage in mV @@ -22,6 +18,7 @@ VAR xResult : BOOL; xTimerDone : BOOL; TON_0: TON; + whereismycar : BOOL; END_VAR (* Network 1 *) diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.xml b/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.xml similarity index 98% rename from GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.xml rename to GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.xml index eb9def1..0823f17 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.PleaseIhaveKids.xml +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.xml @@ -8,7 +8,7 @@ 13dcd058-9705-4825-ae9f-af8738989171 cedc2742-8922-46db-927d-5f652c9943c9 - PleaseIhaveKids + action_test 8ac092e5-3128-4e26-9e7e-11016c6684f2 diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt index d98c4e0..6fc9dc7 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt @@ -1,10 +1,6 @@ PROGRAM FB_TESTING (*********************************************************************************************** -Object Name : PLC_SUPPLY -Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. -Author : KP -Date : 17/06/25 -Rev : P1 +FULLY SICK FUNCTION BLOCK DIAGRAM FOR TESTING. Do not worry about what the logic is doing. ***********************************************************************************************) VAR CONSTANT uiMinVoltage : UINT := 5000; // Minimum Voltage in mV @@ -20,6 +16,7 @@ VAR xResult : BOOL; xTimerDone : BOOL; TON_0: TON; + whereismycar : BOOL; END_VAR (* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) @@ -73,3 +70,12 @@ T#5s──┤PT ET│ │ ┌─────── └──────────┘ │ │ xTimerDone──────────┤In2 │ └──────────┘ + +(* Network 10 *) + EXECUTE + ┌───────────────────────────┐ +TRUE──┤EN ENO├───> dude + │ IF dude THEN │ + │ whereismycar := TRUE; │ + │ END_IF │ + └───────────────────────────┘ diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.xml b/GraphicalTesting/StandardPLC/application/FB_TESTING.xml index 238b26d..f5c86c8 100644 --- a/GraphicalTesting/StandardPLC/application/FB_TESTING.xml +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.xml @@ -1018,6 +1018,145 @@ 150 + + False + False + False + + + + + False + + + + + + dude + BOOL + + + + + 0 + False + False + + True + False + False + 222 + + + + + 0 + False + False + + + EXECUTE + + + + + + + + 0 + False + False + + False + False + True + 229 + + + + + + + + 0 + True + False + + + + + + TRUE + BOOL + + + + + 0 + False + False + + False + False + False + 221 + + 220 + + + + + EN + + + BOOL + + + + + ENO + + + BOOL + + + None + True + True + + + + + + 230 + + IF dude THEN + + + 231 + + whereismycar := TRUE; + + + 226 + + END_IF + + + + + 232 + + False + True + 233 + + 223 + + + + 215 + 7 True @@ -1035,30 +1174,10 @@ (*********************************************************************************************** - - 8 - - Object Name : PLC_SUPPLY - - - 9 - - Descrition : Program to control the System Supply Switch to ensure the PLC turns OFF when the ignition turns OFF. - - - 10 - - Author : KP - - - 11 - - Date : 17/06/25 - 12 - Rev : P1 + FULLY SICK FUNCTION BLOCK DIAGRAM FOR TESTING. Do not worry about what the logic is doing. 6 @@ -1135,6 +1254,11 @@ TON_0: TON; + + 234 + + whereismycar : BOOL; + 4 @@ -1148,7 +1272,7 @@ - 214 + 234 Standard False diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt index 0266108..c6effba 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -7,6 +7,8 @@ VAR TON_0: TON; CTU_0: CTU; PowerOff: BOOL; + edgetrigger: BOOL; + answer: UINT; END_VAR (* Network 1: Try me Codesys I swear *) @@ -16,19 +18,37 @@ END_VAR │ │ sensor3 │ │ └───┤ ├───┘ -(* Network 2 *) +(* Network 2: That'll do pig, that'll do. (Babe) *) │ ├────>>TestJump────┤ (* Network 3: header text *) (* Comment *) -│ TON_0 : TON CTU_0 : CTU -│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff -├─────┤ ├──────────┤IN Q├────────────┤CU Q├────(R)──────┤ -│ T#5S──┤PT ET│ PowerOff──┤RESET CV│ -│ └───────────┘ 10────────┤PV │ -│ └───────────┘ +│ PowerOff PowerOff PowerOff +├──┬───┤ ├────────────────────────────────────────────────────────────────┬───┤/├───────(R)──────┤ +│ │ TON_0 : TON CTU_0 : CTU │ +│ │ PowerOn ┌───────────┐ ┌───────────┐ PowerOn │ +│ ├───┤ ├──────────┤IN Q├──────────────────┤CU Q│────┤ ├───┤ +│ │ T#5S──┤PT ET│ R(R(PowerOff))──┤RESET CV│ │ +│ │ └───────────┘ 10──────────────┤PV │ │ +│ │ └───────────┘ │ +│ │ PowerOn │ +│ └───┤ ├────────────────────────────────────────────────────────────────┘ -(* Network 4 *) +(* Network 4: (Babe in the big City) *) TestJump: (* empty network *) + +(* Network 5 *) +│ ADD +│ PowerOff PowerOn ┌──────────┐ +├─────┤/├───────┤ ├───────┤EN ENO├──────────────────┤ +│ 1──┤In2 Out2├───> answer +│ 2──┤In3 │ +│ └──────────┘ +│ ADD +│ PowerOff PowerOn ┌──────────┐ edgetrigger PowerOff +├─────┤/├───────┤ ├───────┤EN ENO│─────────────────┤P├────────( )──────┤ +│ 1──┤In2 Out2├───> answer +│ 2──┤In3 │ +│ └──────────┘ diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.xml b/GraphicalTesting/StandardPLC/application/LD_TEST.xml index a275976..58941da 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.xml +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.xml @@ -217,7 +217,7 @@ False False - + That'll do pig, that'll do. (Babe) False @@ -300,10 +300,10 @@ False - CTU + AND - CTU_0 - CTU + + @@ -315,14 +315,484 @@ False False True - 36 + 126 + + + + + + 0 + False + False + + + + + OR + + + + + + + + 0 + False + False + + False + False + True + 122 + + + + + + 0 + False + False + + + + + + PowerOff + BOOL + + + + + 0 + True + False + + False + True + False + 125 + + 124 + + + AND + + + + + + + + 0 + False + False + + False + False + True + 114 + + + + + + 0 + False + False + + + + + CTU + + CTU_0 + CTU + + + + + 0 + False + False + + False + False + True + 36 + + + + + + + WORD + + + + + 0 + False + False + + True + False + False + 38 + + + + + 0 + False + False + + + + + TON + + TON_0 + TON + + + + + 0 + False + False + + False + False + True + 26 + + + + + + + TIME + + + + + 0 + False + False + + True + False + False + 28 + + + + + 0 + False + False + + + + + + PowerOn + BOOL + + + + + 0 + False + False + + False + True + False + 30 + + 29 + + + + T#5S + TIME + + + + + 0 + False + False + + False + False + False + 32 + + 31 + + + + + IN + PT + + + BOOL + TIME + + + + + Q + ET + + + BOOL + TIME + + + FunctionBlock + False + False + + False + False + 27 + + + + PowerOff + BOOL + + + + + 16 + True + False + + False + True + False + 40 + + 39 + + + + 10 + INT + + + + + 0 + False + False + + False + False + False + 42 + + 41 + + + + + CU + RESET + PV + + + BOOL + BOOL + WORD + + + + + Q + CV + + + BOOL + WORD + + + FunctionBlock + False + False + + False + False + 37 + + + + PowerOn + BOOL + + + + + 0 + True + False + + False + True + False + 117 + + 116 + + + + + + + + + + + And + + + + False + False + 115 + + + + PowerOn + BOOL + + + + + 0 + True + False + + False + True + False + 121 + + 120 + + + + + + + + + + + Or + + + + False + False + 123 + + + + PowerOff + BOOL + + + + + 1 + True + False + + False + True + False + 129 + + 128 + + + + + + + + + + + And + + + + False + False + 127 + + 35 + + + + 25 + + + False + False + False + + (Babe in the big City) + + TestJump + False + + + 49 + + + False + False + False + + + + + False + + + 13 + + ADD + + + + + + + + 0 + False + False + + False + False + True + 111 - - WORD + answer + UINT @@ -334,22 +804,22 @@ True False False - 38 + 57 0 - False + True False - TON + AND - TON_0 - TON + + @@ -361,28 +831,10 @@ False False True - 26 + 64 - - - - - TIME - - - - - 0 - False - False - - True - False - False - 28 - - + 0 @@ -393,27 +845,27 @@ - PowerOn + PowerOff BOOL - 0 - False + 1 + True False False True False - 30 + 67 - 29 + 66 - T#5S - TIME + PowerOn + BOOL @@ -425,43 +877,31 @@ False False False - 32 + 59 - 31 + 58 - - IN - PT - - - BOOL - TIME - + + - - Q - ET - - - BOOL - TIME - + + - FunctionBlock - False - False + And + + False False - 27 + 65 - PowerOff - BOOL + 1 + INT @@ -471,15 +911,15 @@ False False - True + False False - 40 + 61 - 39 + 60 - 10 + 2 INT @@ -492,62 +932,175 @@ False False False - 42 + 63 - 41 + 62 - CU - RESET - PV + EN BOOL - BOOL - WORD - Q - CV + ENO + BOOL - WORD + INT - FunctionBlock - False - False + Add + True + True False False - 37 + 112 - 35 + 72 + + + + + + ??? + BOOL + + + + + 8 + False + False + + True + False + False + 70 + + + + + 0 + False + False + + + 13 + + 73 + + 71 + + + + + + PowerOff + BOOL + + + + + 0 + False + False + + True + False + False + 81 + + + + + 0 + False + False + + + AND + + + + + + + + 0 + False + False + + False + False + True + 75 + + + + + + 0 + False + False + + + + + 13 + + 74 + + + + edgetrigger + BOOL + + + + + 16 + True + False + + False + True + False + 78 + + 77 + + + + + + + + + + + And + + + + False + False + 76 + + 82 - 25 - - - False - False - False - - - - TestJump - False - - - 49 + 54 - 0 + 14 True @@ -598,6 +1151,16 @@ PowerOff: BOOL; + + 80 + + edgetrigger: BOOL; + + + 113 + + answer: UINT; + 4 @@ -611,7 +1174,7 @@ - 53 + 129 Standard False diff --git a/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt b/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt index 0e01507..5b163d9 100644 --- a/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt +++ b/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt @@ -6,7 +6,7 @@ True - a886129e-88da-4a4d-9079-d46f717679fc + 346833a2-b8bb-4a1d-be12-49edf9cc55c3 f9c00bdb-2f18-4e80-863e-6da977e8f304 Visualization Manager @@ -57,28 +57,28 @@ FB_Init - c89279aa-6da4-434e-bb9d-7a1092342960 + c7e2166b-97dd-4993-bada-4bbfb66ec8ac - FB_Reinit + FB_Exit - 3f5c1fdc-c478-403e-9db6-413c44798b62 + c5608289-c5cc-4147-aaa8-fad5eaf1a2af - FB_Exit + FB_Reinit - 473dd5fb-9ac9-4528-b9ae-4443fbf833b6 + ce81c39a-7582-4df2-8d4d-908c63dae772 NotImportant - 34d06ee6-3348-4ff4-88e5-5d0e892de8fc + c0519247-ee79-46c5-b44d-e337bed2ed1f @@ -98,55 +98,55 @@ ExecuteLooseCapture - a5b292af-c5c5-4060-8b91-55007f1acae4 + db44e2e9-251e-4e32-865b-6ad832956598 - ExecuteMouseUp + Init - cacb2953-9ffc-4db1-8545-ebd1aa76f7bc + c006e9d8-a2d0-49eb-8e96-b36e8bfec712 - Init + FB_Exit - d686395d-362c-41a0-812d-1e9432b49cdc + 1df4a714-e6f5-4fbf-a7c1-55931a0b9ad9 - FB_Exit + ExecuteMouseDblClick - c0ad1bc8-35bf-45cc-9b5d-b2bbe2ada465 + a124a2b6-3427-4528-ba94-a9a3e51ef24e - ExecuteMouseDblClick + ExecuteMouseDown - dc5bfb38-a1eb-4464-b07f-2081ea939424 + 4bfa1c8c-98e7-430d-b134-affcadc6cf9e - GetElementInfo + ExecuteMouseUp - 66c67735-498e-45a5-804c-6370fbb97df7 + a9199d8b-ef44-41c5-8658-b65d688170f9 - ExecuteMouseDown + ExecuteMouseEnter - d21b7523-2711-423b-af01-519e8c0e9c82 + 70fd46c1-8141-4c7e-91c8-820053991829 @@ -154,23 +154,23 @@ FB_Reinit - e3f2b997-e1ae-4245-8288-03b5f90ef558 + 6239d873-3a40-4be9-b8dc-4b1df397757d - Initialize + GetElementInfo - 32672985-f837-4b28-9cce-7bcf785362df + a3b59e56-e7f3-467f-b68d-4d4deb1bfedc - ExecuteMouseMove + abstrGetDefaultCursor - 8d88349c-03f3-4f94-927b-2ea19bfe683d + 895a8e24-d2e8-47a2-8ef4-ab8c1adde1b2 @@ -178,7 +178,7 @@ ExecuteDialogClosed - 21fa2514-fea5-47d6-8aea-20e66b1d3a8e + 3dd02592-c0d6-4327-807f-5e09d6918b76 @@ -186,7 +186,7 @@ ExecuteKeyUp - 476c673a-06e6-47c2-9cc7-3a989d50ede0 + 803c64ff-2010-4b2d-a723-8da71f8ad696 @@ -194,23 +194,23 @@ ExecuteKeyDown - b56c83d3-4073-419e-81ed-82392f9b80ff + 202e88a4-d10d-4bbb-9284-50e5a5ca4be2 - abstrGetDefaultCursor + ExecuteMouseMove - b6adce04-2762-4ad9-8d50-d50300042633 + fc65267b-d612-4fed-a0e1-982e05bfba9a - ExecuteMouseEnter + Initialize - a4213275-eb0f-4188-83b2-c6fdda524913 + 6cbdcd7f-d922-418a-99ef-3679359317bd @@ -218,7 +218,7 @@ ExecuteMouseLeave - 7cf492e6-63d3-492e-8375-daa4b229e8ea + 478f1b09-6f9e-4e81-8dde-ea1b9739df4e @@ -226,7 +226,7 @@ FB_Init - ad4d0bb3-8301-46bf-90a8-9fd3be6d3486 + 6b1b99e3-6280-48a4-859a-c97e58e472ed @@ -234,12 +234,12 @@ ExecuteMouseClick - b1cde74f-4c3e-4bd1-9ef9-b08fff5373e3 + a6ff1a7a-47d1-4c19-86e4-76589c360358 NotImportant - f733e3bc-0de7-42ac-9a38-e978eae9a278 + 2de30ff1-b3ea-4c1e-9bc5-344ef440e3db @@ -249,38 +249,38 @@ - 4a73f210-3b62-411c-a12e-01ca6c70bb64 - cb582427-5c68-4f2a-9ad8-278e904201bb - cd7582e6-5424-47ff-a03a-2b2c8113abd4 - 6309a3e8-c4cd-493a-bc2f-edc2e9e090ac - d290ad05-e9d3-4d2e-8f2e-5c393bca5dcb - b5697eb8-8905-477d-8847-48edc0063cc0 - 8a2cc8a4-cfd6-4a92-a746-f413bcef536c - 8349fd54-39ac-4876-b445-55e350a2b9cc - adba28ee-64e6-42b2-82c0-577039a7fbc5 - c167b3d6-5c9d-4e6f-8792-4fe5561ee1d5 - 491c67c7-6d3c-492a-a8f8-bc42f8350037 - 88d39fae-1bbb-45b7-8f89-497d7c9de4bf - b9cf0740-6815-45a4-ba08-f825c260efdd - da6282b1-7718-4c8d-a00b-a1153f155420 - ccb8b043-0d9a-4cbd-a524-46662e408e7a - 6f28000e-952a-4cdd-8be3-70a5bb859a05 - 274bcba4-c093-40ab-88b9-15948f72e347 - 40558d42-983b-4ff9-ae21-8fde60b37876 - 56bc93a2-20b9-4a9d-854a-45ea1aa97029 - 40600fba-3e82-4fb2-9f37-8254acce03be - 1122f9d0-98c6-4432-9876-e24388506d8d - 5830d667-6647-4129-8046-ab02cf5da887 - be2b76ba-157b-4700-98b5-d65323109648 - 6fad6608-7a0a-47aa-8e12-096ca38a5405 - 116afd95-a448-4dc4-b154-bfd2f74c707a - ad33a782-a45b-4370-9725-4681b035576d - 4267b596-eb10-40ba-b2ee-4e017b6895d8 - 6df6d20d-06e6-42ac-afe2-687eaf9bf0d6 - f61b50de-3f14-435c-89a1-960e68f41871 - 93cbf699-fca2-4a89-9c92-0a38e7a62574 - 626c52a8-7ee6-4fb9-83dc-a61583aafbf0 - 6ab928be-8493-47ea-9fa1-72508a7297b8 + 64d50120-051d-4bbe-ace6-e97984314aa8 + 44870d62-e28b-43f9-b98f-0473a3b132c8 + 9fae56b9-b14c-41c6-8cc7-8e40c4e249bf + 0c9f6a20-5848-419d-957b-31931d01c14b + 22fcdeb3-2b7e-417c-b2a5-afeae62350aa + 4313d649-c656-4f94-ae7d-0404c247540f + c30af6cf-89a0-46de-a616-bea3c710b716 + c5706b17-fd15-4ef8-91b6-5213fb5836e5 + ea23f4db-9316-4883-895a-ebf90c19d811 + 51bcf610-d5e6-4339-ab58-5a8e2010bb6b + d69a4065-30d5-4f6a-8d7c-5a91da761b6e + 22ff94e4-c92f-45ff-806d-39662fb8eed9 + c038b976-267a-40e2-a701-1d71b647f576 + 8f95026c-2442-4f17-be57-3bef3c8281e1 + 67d059b5-88ba-4d83-b87a-a7e239c9f2b7 + 9fe503c5-c6ac-4a5c-9564-38354c97acde + 708f4a82-e851-41bf-9466-211f4b4ec15f + 546ec7a1-f2d9-4adb-aa20-b98353538eef + c70c9e80-1f7a-43cf-bc9d-c574e69f8acd + c71dd610-1c28-4e3a-aca8-6e884990fcb8 + 2a8cfd92-0884-4c23-857e-64e89bb1dbda + 6014fccd-47e6-44ee-b4db-cd6ff3649eaa + b01ac028-20b6-456c-97ca-30313bf4bc58 + e61eac95-acfc-4a1d-afff-533f7b742008 + e9235d3e-5580-40f1-b8ce-06360bdaa802 + ac1a51dd-19bb-49fc-bc83-a9351de7ee3e + 1aacb564-6e44-44bf-94c7-d1b8fc9564c5 + 40c4786e-6c3a-4fc0-9e83-69ad01d756b9 + ca873530-db0c-44cb-b134-94179c18efc0 + d80608f7-c949-4e7c-b9af-0dc70db56235 + 2288d0f3-f5b4-471e-a28b-9c785c5c16cf + d751f19b-9d11-437f-b1e7-b39fb4d2d343 f9c00bdb-2f18-4e80-863e-6da977e8f304 From 95077a141b92206762680d4d9c216e84c56ba5b8 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 04:12:50 +1000 Subject: [PATCH 78/91] re-export LD_TEST Refreshed the LD_TEST rendering and native xml from CODESYS. --- .../StandardPLC/application/LD_TEST.txt | 9 + .../StandardPLC/application/LD_TEST.xml | 165 +++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt index c6effba..7951d0f 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -9,6 +9,7 @@ VAR PowerOff: BOOL; edgetrigger: BOOL; answer: UINT; + BLINK_0: BLINK; END_VAR (* Network 1: Try me Codesys I swear *) @@ -52,3 +53,11 @@ TestJump: │ 1──┤In2 Out2├───> answer │ 2──┤In3 │ │ └──────────┘ + +(* Network 6 *) +│ BLINK_0 : BLINK +│ edgetrigger ┌───────────────┐ PowerOn +├───────┤ ├───────────────┤ENABLE OUT├────( )─────┤ +│ T#500ms──┤TIMELOW │ +│ T#500ms──┤TIMEHIGH │ +│ └───────────────┘ diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.xml b/GraphicalTesting/StandardPLC/application/LD_TEST.xml index 58941da..1047b63 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.xml +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.xml @@ -1099,6 +1099,164 @@ 54 + + False + False + False + + + + + False + + + + + + PowerOn + BOOL + + + + + 0 + False + False + + True + True + False + 142 + + + + + 0 + False + False + + + BLINK + + BLINK_0 + BLINK + + + + + 0 + False + False + + False + False + True + 137 + + + + + + + + 0 + True + False + + + + + + edgetrigger + BOOL + + + + + 0 + False + False + + False + False + False + 134 + + 133 + + + + T#500ms + TIME + + + + + 0 + False + False + + False + False + False + 136 + + 135 + + + + T#500ms + TIME + + + + + 0 + False + False + + False + False + False + 140 + + 139 + + + + + ENABLE + TIMELOW + TIMEHIGH + + + BOOL + TIME + TIME + + + + + OUT + + + BOOL + + + FunctionBlock + False + False + + False + False + 138 + + 143 + + + + 130 + 14 True @@ -1161,6 +1319,11 @@ answer: UINT; + + 141 + + BLINK_0: BLINK; + 4 @@ -1174,7 +1337,7 @@ - 129 + 143 Standard False From a00e0accf879598909435874a6c0c55730ff7992 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 04:26:00 +1000 Subject: [PATCH 79/91] tee a block output a contact reads without naming the pin A contact wired to a block's output does not always carry the pin name in its connection, and CODESYS routinely omits it. The builder marked the block's output wired only when the pin was named, so such an output drew no tee (an "ENO|" edge with a wire running out of it), and - worse - the box built differently from the same box read with the pin named. Two rungs that share the box, a RETURN and a coil off one ENO, then failed to line up and would not merge into one branched rung. A block whose id appears in the network's consumed set is now wired whatever the connection names, so the output tees and the box builds the same either way. That is the real Network 5 shape, and it now collapses to one box with the readers branching off it. --- src/parse_ld.py | 17 +++++++++------- .../ld-contact-reads-block-unnamed-pin.xml | 13 ++++++++++++ tools/ladder/tests/test_ladder.py | 20 +++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld-contact-reads-block-unnamed-pin.xml diff --git a/src/parse_ld.py b/src/parse_ld.py index e0779e3..2f4aa5e 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -209,7 +209,7 @@ def _block_reference(node, via_pin): ) -def _build_block(node, by_id, visiting, via_pin, drawn): +def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): """Build a block call, separating power flow from parameter inputs. Exactly one input carries the rung's power flow. Pins fed by a literal or @@ -256,7 +256,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn): continue if upstream.kind != IN_VARIABLE: all_from_variables = False - branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin, drawn)) + branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin, drawn, consumed)) # The bubble and the P or N ride on the pin, so every connection to it # carries the same pair; the first speaks for the group. negated = connections[0].negated @@ -304,7 +304,10 @@ def _build_block(node, by_id, visiting, via_pin, drawn): active_output=active, # via_pin is set by whatever consumed this block; a block terminating # the rung has none. - output_wired=via_pin is not None, + # Wired when a pin was named, or when anything downstream reads the + # block at all - a contact reading a block output does not always + # name the pin, and the box must still tee where it is consumed. + output_wired=via_pin is not None or (consumed is not None and node.local_id in consumed), st_code=list(node.st_code), power_negated=power_negated, power_edge=power_edge, @@ -371,7 +374,7 @@ def _pin_text(sub_expr, connection, hoisted): return pin_value(_pin_expr_text(sub_expr, hoisted), connection.negated, connection.edge) -def _build_expr(node, by_id, visiting, via_pin=None, drawn=None): +def _build_expr(node, by_id, visiting, via_pin=None, drawn=None, consumed=None): """Walk backwards from a node to the power rail, building series/parallel. A node's expression is everything feeding it (OR'd together if there is @@ -389,14 +392,14 @@ def _build_expr(node, by_id, visiting, via_pin=None, drawn=None): visiting = visiting | set([node.local_id]) if node.kind == BLOCK: - return _build_block(node, by_id, visiting, via_pin, drawn) + return _build_block(node, by_id, visiting, via_pin, drawn, consumed) branches = [] for connection in node.inputs: upstream = by_id.get(connection.ref_id) if upstream is None: continue - branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin, drawn)) + branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin, drawn, consumed)) incoming = parallel(branches) if branches else Empty() @@ -459,7 +462,7 @@ def root_of(node): if node.local_id in consumed or node.kind in (LEFT_RAIL, COMMENT, TITLE): # An unconnected left rail is an empty rung, not a terminal. continue - expr = _build_expr(node, by_id, set(), None, drawn) + expr = _build_expr(node, by_id, set(), None, drawn, consumed) if isinstance(expr, Empty): # An unconnected rail or a stray element with nothing on it. continue diff --git a/tools/ladder/tests/fixtures/ld-contact-reads-block-unnamed-pin.xml b/tools/ladder/tests/fixtures/ld-contact-reads-block-unnamed-pin.xml new file mode 100644 index 0000000..c890896 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-reads-block-unnamed-pin.xml @@ -0,0 +1,13 @@ + + + +PowerOff +PowerOn +1 +2 +answer + +edgetrigger +PowerOff + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 696b930..b810ba8 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -559,6 +559,26 @@ def _contact(name): check("shared box sinks: RETURN and the coil are on different rows", return_row != coil_row) +# --- a contact reads a block output without naming the pin ------------------- + +# CODESYS often wires a contact to a block's output without naming the pin, so +# the connection carries no formalParameter. That read still consumes the +# block, so its output must tee - and the box must build the same whether the +# reader named the pin or not, or two rungs that share the box (a RETURN and a +# coil off one ENO) would not line up and would not merge. This is the shape a +# real Network 5 makes. +UNNAMED = os.path.join(FIXTURES, "ld-contact-reads-block-unnamed-pin.xml") +unnamed_pou = parse_pous(UNNAMED)[0] +unnamed_art = render_pou(unnamed_pou) + +check_equal("unnamed pin: the box is drawn once", len([l for l in unnamed_art if "In2 Out2" in l]), 1) +check_equal("unnamed pin: one network, one header", len([l for l in unnamed_art if l.startswith("(* Network")]), 1) +check("unnamed pin: the box output branches", any(U["T_DOWN"] in l for l in unnamed_art)) +check("unnamed pin: the consumed output is teed", any("ENO" + U["PIN_R"] in l for l in unnamed_art)) +check("unnamed pin: no untee'd wire runs from the box", not any("ENO" + U["V"] + U["H"] in l for l in unnamed_art)) +check("unnamed pin: RETURN and the coil are both drawn", any("" in l for l in unnamed_art) and any("( )" in l for l in unnamed_art)) + + # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships From 96a2c67e34713b6cf2bb6aef1d1616363daa21f0 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 04:45:02 +1000 Subject: [PATCH 80/91] draw a parallel's block branch as the main line A parallel branch that carries a function block is the substance of the rung, but the branches kept source order, so a bare contact could sit on the main line while the block hung indented in a lower branch - reading as an afterthought rather than the rung's logic. The branch holding a block is now drawn as the main line, with the plain contacts branching below it, the way the editor draws it. The sort is stable, so a parallel of plain contacts is unchanged. --- src/ld_render.py | 17 ++++++++++++++ .../fixtures/ld-block-branch-on-main-line.xml | 9 ++++++++ tools/ladder/tests/test_ladder.py | 23 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 tools/ladder/tests/fixtures/ld-block-branch-on-main-line.xml diff --git a/src/ld_render.py b/src/ld_render.py index 8e5e211..d0466f6 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -306,8 +306,25 @@ def _render_series(items): return Block(joined, connect_row) +def _branch_has_block(expr): + """True when a parallel branch carries a function block somewhere in it.""" + if isinstance(expr, Element): + return expr.kind == BLOCK + if isinstance(expr, Series): + return any(_branch_has_block(item) for item in expr.items) + if isinstance(expr, Parallel): + return any(_branch_has_block(branch) for branch in expr.branches) + return False + + def _render_parallel(branches): chars = charset.active() + # The main line - the one drawn straight through, with the rest branching + # off it - should carry the substance, so a branch holding a block goes + # first and the plain contacts hang below it, the way the editor draws it. + # A block buried in a lower branch reads as an indented afterthought. The + # sort is stable, so branches keep their order otherwise. + branches = sorted(branches, key=lambda branch: not _branch_has_block(branch)) blocks = [_render(branch) for branch in branches] width = max(block.width for block in blocks) diff --git a/tools/ladder/tests/fixtures/ld-block-branch-on-main-line.xml b/tools/ladder/tests/fixtures/ld-block-branch-on-main-line.xml new file mode 100644 index 0000000..ac99734 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-block-branch-on-main-line.xml @@ -0,0 +1,9 @@ + + + +PowerOff +PowerOn + +oResult + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index b810ba8..8c90b79 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -579,6 +579,29 @@ def _contact(name): check("unnamed pin: RETURN and the coil are both drawn", any("" in l for l in unnamed_art) and any("( )" in l for l in unnamed_art)) +# --- a block in a parallel is drawn on the main line ------------------------- + +# The main line of a rung - the one drawn straight through - should carry the +# substance. A parallel of a plain contact and a branch holding a block used +# to keep source order, leaving the block indented in a lower branch while a +# bare contact sat on the rail. The block branch is now drawn as the main +# line, the contacts branching below it, the way the editor draws it. +MAIN_LINE = os.path.join(FIXTURES, "ld-block-branch-on-main-line.xml") +main_line_pou = parse_pous(MAIN_LINE)[0] +main_line_art = render_pou(main_line_pou) + +# Find the rail rows (they start at the left power rail) and the block row. +box_top = [i for i, l in enumerate(main_line_art) if "TON_0 : TON" in l and ";" not in l][0] +block_row = box_top + 2 # title, top border, then the IN pin row +rail_rows = [i for i, l in enumerate(main_line_art) if l.startswith(U["T_RIGHT"])] +check("main line: the block sits on a rail-connected row", block_row in rail_rows) +check("main line: the block's IN pin is on that row", "IN" in main_line_art[block_row] and "Q" in main_line_art[block_row]) +# The plain contact hangs on a lower branch, not on the block's row. +contact_rows = [i for i, l in enumerate(main_line_art) if "PowerOff" in l] +check("main line: the plain contact is below the block row", all(r > block_row for r in contact_rows)) +check("main line: both are still drawn", any("PowerOn" in l for l in main_line_art) and any("PowerOff" in l for l in main_line_art)) + + # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships From de3e9de44b25586e0badcd9a5bce427a9630b440 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 04:52:03 +1000 Subject: [PATCH 81/91] Revert "draw a parallel's block branch as the main line" This reverts commit 96a2c67e34713b6cf2bb6aef1d1616363daa21f0. --- src/ld_render.py | 17 -------------- .../fixtures/ld-block-branch-on-main-line.xml | 9 -------- tools/ladder/tests/test_ladder.py | 23 ------------------- 3 files changed, 49 deletions(-) delete mode 100644 tools/ladder/tests/fixtures/ld-block-branch-on-main-line.xml diff --git a/src/ld_render.py b/src/ld_render.py index d0466f6..8e5e211 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -306,25 +306,8 @@ def _render_series(items): return Block(joined, connect_row) -def _branch_has_block(expr): - """True when a parallel branch carries a function block somewhere in it.""" - if isinstance(expr, Element): - return expr.kind == BLOCK - if isinstance(expr, Series): - return any(_branch_has_block(item) for item in expr.items) - if isinstance(expr, Parallel): - return any(_branch_has_block(branch) for branch in expr.branches) - return False - - def _render_parallel(branches): chars = charset.active() - # The main line - the one drawn straight through, with the rest branching - # off it - should carry the substance, so a branch holding a block goes - # first and the plain contacts hang below it, the way the editor draws it. - # A block buried in a lower branch reads as an indented afterthought. The - # sort is stable, so branches keep their order otherwise. - branches = sorted(branches, key=lambda branch: not _branch_has_block(branch)) blocks = [_render(branch) for branch in branches] width = max(block.width for block in blocks) diff --git a/tools/ladder/tests/fixtures/ld-block-branch-on-main-line.xml b/tools/ladder/tests/fixtures/ld-block-branch-on-main-line.xml deleted file mode 100644 index ac99734..0000000 --- a/tools/ladder/tests/fixtures/ld-block-branch-on-main-line.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - -PowerOff -PowerOn - -oResult - - diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 8c90b79..b810ba8 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -579,29 +579,6 @@ def _contact(name): check("unnamed pin: RETURN and the coil are both drawn", any("" in l for l in unnamed_art) and any("( )" in l for l in unnamed_art)) -# --- a block in a parallel is drawn on the main line ------------------------- - -# The main line of a rung - the one drawn straight through - should carry the -# substance. A parallel of a plain contact and a branch holding a block used -# to keep source order, leaving the block indented in a lower branch while a -# bare contact sat on the rail. The block branch is now drawn as the main -# line, the contacts branching below it, the way the editor draws it. -MAIN_LINE = os.path.join(FIXTURES, "ld-block-branch-on-main-line.xml") -main_line_pou = parse_pous(MAIN_LINE)[0] -main_line_art = render_pou(main_line_pou) - -# Find the rail rows (they start at the left power rail) and the block row. -box_top = [i for i, l in enumerate(main_line_art) if "TON_0 : TON" in l and ";" not in l][0] -block_row = box_top + 2 # title, top border, then the IN pin row -rail_rows = [i for i, l in enumerate(main_line_art) if l.startswith(U["T_RIGHT"])] -check("main line: the block sits on a rail-connected row", block_row in rail_rows) -check("main line: the block's IN pin is on that row", "IN" in main_line_art[block_row] and "Q" in main_line_art[block_row]) -# The plain contact hangs on a lower branch, not on the block's row. -contact_rows = [i for i, l in enumerate(main_line_art) if "PowerOff" in l] -check("main line: the plain contact is below the block row", all(r > block_row for r in contact_rows)) -check("main line: both are still drawn", any("PowerOn" in l for l in main_line_art) and any("PowerOff" in l for l in main_line_art)) - - # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships From 108aca58be1cd361cf3b825615623ace30690e69 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 05:08:40 +1000 Subject: [PATCH 82/91] draw a contact feeding a block's side pin as that contact A reset or enable a block reads off the rail through a contact was flattened into the pin caption as text - "R(PowerOff)" for a rising-edge contact on RESET. It is now drawn as the contact it is: its label and symbol wired into the pin, the way the editor draws it and the way the power-flow contacts are already drawn. A side pin fed by a literal or a box is unchanged, and the ST - which cannot draw a second wire - keeps the flattened form. The block carries the side pin's feed as structure now, not just a string, so the diagram can render the contact while the ST reads the caption. First step of matching the CODESYS side-pin layout; the contact sits just left of the box for now, not yet out at the rail. --- src/ld_render.py | 34 ++++++++++++++++++- src/model.py | 5 +++ src/parse_ld.py | 23 +++++++++++++ .../fixtures/codesys/LDTesting.expected.txt | 12 +++---- .../fixtures/ld-contact-feeds-a-side-pin.xml | 10 ++++++ tools/ladder/tests/test_ladder.py | 22 ++++++++++++ 6 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld-contact-feeds-a-side-pin.xml diff --git a/src/ld_render.py b/src/ld_render.py index 8e5e211..d591efc 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -151,6 +151,34 @@ def _pin_head(box, pin): return "o> " if pin in box.negated_outputs else "> " +def _pin_feed_symbols(expr): + """A side pin's contact feed as drawn contacts, e.g. "PowerOff |P|". + + The contact a reset or enable reads is shown as the contact it is - its + label and its symbol - wired into the pin, rather than flattened into a + caption like "R(PowerOff)". + """ + chars = charset.active() + if isinstance(expr, Element) and expr.kind == CONTACT: + if expr.edge == "rising": + middle = "P" + elif expr.edge == "falling": + middle = "N" + elif expr.negated: + middle = "/" + else: + middle = " " + symbol = chars["CONTACT_L"] + middle + chars["CONTACT_R"] + label = expr.label or "" + return (label + " " + symbol) if label else symbol + if isinstance(expr, Series): + parts = [_pin_feed_symbols(item) for item in expr.items if not isinstance(item, Empty)] + return " ".join(part for part in parts if part) + if isinstance(expr, Parallel): + return "(" + " / ".join(_pin_feed_symbols(branch) for branch in expr.branches) + ")" + return "" + + def _render_block(element): """Draw a function block as a pin box. @@ -171,7 +199,11 @@ def _render_block(element): left.append(pin or "?") # A label of None is the power pin - it is wired, not parameterised. wired.append(label is None) - values.append("" if label is None else (label or "")) + if pin in element.pin_feeds: + # A contact reset or enable, drawn as the contact it is. + values.append(_pin_feed_symbols(element.pin_feeds[pin])) + else: + values.append("" if label is None else (label or "")) # A store written on an output pin hangs off that pin on a wire of its # own, as CODESYS draws it. Writing it inside the box put the target diff --git a/src/model.py b/src/model.py index f309a6e..dc38d02 100644 --- a/src/model.py +++ b/src/model.py @@ -502,6 +502,7 @@ def __init__( stored_outputs=None, pin_blocks=None, st_code=None, + pin_feeds=None, ): self.kind = kind self.label = label @@ -536,6 +537,10 @@ def __init__( # An EXECUTE box carries inline ST as its whole body. Drawing the box # without it leaves an empty rectangle where the logic should be. self.st_code = st_code if st_code is not None else [] + # Blocks only: {pin: expression} for a side pin fed by a contact chain. + # A contact reset or enable is drawn as the contact it is, wired into + # the pin, rather than flattened into the pin caption as text. + self.pin_feeds = pin_feeds if pin_feeds is not None else {} @property def title(self): diff --git a/src/parse_ld.py b/src/parse_ld.py index 2f4aa5e..ce263be 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -209,6 +209,22 @@ def _block_reference(node, via_pin): ) +def _is_contact_chain(expr): + """True when an expression is nothing but contacts (and bare wire). + + A side pin fed by one is drawn as those contacts, wired into the pin; a + pin fed by a literal, a box or anything else keeps its flattened caption. + """ + if isinstance(expr, Element): + return expr.kind == CONTACT + if isinstance(expr, Series): + parts = [item for item in expr.items if not isinstance(item, Empty)] + return bool(parts) and all(_is_contact_chain(item) for item in parts) + if isinstance(expr, Parallel): + return bool(expr.branches) and all(_is_contact_chain(branch) for branch in expr.branches) + return False + + def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): """Build a block call, separating power flow from parameter inputs. @@ -231,6 +247,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): power_edge = None side_pins = [] pin_blocks = [] + pin_feeds = {} # Several connections landing on one pin are a wired OR into that pin - # the same several--under-one-connectionPointIn shape a coil @@ -279,6 +296,11 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): power_negated = negated power_edge = edge else: + # A side pin fed by contacts - a reset or enable off the rail - is + # drawn as the contacts it is, wired into the pin, rather than + # flattened into the caption. The text form stays for the ST. + if _is_contact_chain(feed): + pin_feeds[pin] = feed side_pins.append((pin, _pin_text(feed, connections[0], pin_blocks))) input_pins = [] @@ -308,6 +330,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): # block at all - a contact reading a block output does not always # name the pin, and the box must still tee where it is consumed. output_wired=via_pin is not None or (consumed is not None and node.local_id in consumed), + pin_feeds=pin_feeds, st_code=list(node.st_code), power_negated=power_negated, power_edge=power_edge, diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt index 8505455..569f162 100644 --- a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt @@ -17,9 +17,9 @@ END_VAR │ └───┤ ├───┘ (* Network 2 *) -│ TON_0 : TON CTU_0 : CTU -│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff -├─────┤ ├──────────┤IN Q├────────────┤CU Q├────(R)──────┤ -│ T#5S──┤PT ET│ PowerOff──┤RESET CV│ -│ └───────────┘ 10────────┤PV │ -│ └───────────┘ +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff +├─────┤ ├──────────┤IN Q├────────────────┤CU Q├────(R)──────┤ +│ T#5S──┤PT ET│ PowerOff ┤ ├──┤RESET CV│ +│ └───────────┘ 10────────────┤PV │ +│ └───────────┘ diff --git a/tools/ladder/tests/fixtures/ld-contact-feeds-a-side-pin.xml b/tools/ladder/tests/fixtures/ld-contact-feeds-a-side-pin.xml new file mode 100644 index 0000000..c49e443 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-feeds-a-side-pin.xml @@ -0,0 +1,10 @@ + + + +xCount +xClear +10 + +xDone + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index b810ba8..bd78ccb 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -579,6 +579,28 @@ def _contact(name): check("unnamed pin: RETURN and the coil are both drawn", any("" in l for l in unnamed_art) and any("( )" in l for l in unnamed_art)) +# --- a side pin fed by a contact is drawn as that contact -------------------- + +# A reset or enable read off the rail by a contact was flattened into the pin +# caption as text ("R(xClear)"). It is now drawn as the contact it is, its +# label and symbol wired into the pin, the way the editor draws it. The ST, +# which cannot draw a second wire, keeps the flattened form. +SIDE_PIN = os.path.join(FIXTURES, "ld-contact-feeds-a-side-pin.xml") +side_pin_pou = parse_pous(SIDE_PIN)[0] +side_pin_art = render_pou(side_pin_pou) +side_pin_st = st_render.render_pou(side_pin_pou) + +reset_row = [l for l in side_pin_art if "RESET" in l][0] +check("side pin: the reset contact is drawn with its symbol", "xClear " + U["CONTACT_L"] + "P" + U["CONTACT_R"] in reset_row) +check("side pin: the reset is not flattened to text", not any("R(xClear)" in l for l in side_pin_art)) +check("side pin: the contact is wired into the pin", "xClear " + U["CONTACT_L"] + "P" + U["CONTACT_R"] + U["H"] * 2 + U["PIN_L"] + "RESET" in reset_row) +# A literal side pin (PV := 10) is untouched. +pv_row = [l for l in side_pin_art if "PV" in l][0] +check("side pin: a literal side pin stays a value", "10" in pv_row and U["PIN_L"] + "PV" in pv_row) +# The ST is unaffected - it still flattens. +check("side pin: the ST keeps the flattened form", any("RESET := R(xClear)" in l for l in side_pin_st)) + + # --- byte order mark ------------------------------------------------------- # CODESYS writes a BOM on every export_xml file, and the ElementTree it ships From c8bf69e9e99a391b59c71e29f77d3708806f5376 Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 06:13:55 +1000 Subject: [PATCH 83/91] run each terminal branch to the right rail instead of rejoining A parallel whose branches all end in a sink - two coils off one contact, or a return beside a coil - was drawn as one wire that split and then merged back before reaching the rail. Those branches are separate rung ends, not a loop: each should run to the right power rail on its own. Branches that end in a coil, a return or another output now split on the left and reach the right rail each on its row, with no closing junction. A parallel of contacts - a seal-in - still rejoins as before, so nothing else changes. --- src/layout.py | 6 ++++- src/ld_render.py | 41 ++++++++++++++++++++++++++++--- tools/ladder/tests/test_ladder.py | 12 +++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/layout.py b/src/layout.py index feaddea..ed06714 100644 --- a/src/layout.py +++ b/src/layout.py @@ -12,13 +12,17 @@ class Block(object): - def __init__(self, lines, connect_row, pin_rows=None): + def __init__(self, lines, connect_row, pin_rows=None, sink_rows=None): self.lines = lines self.connect_row = connect_row # For a box: the row each output pin sits on, so a caller branching # several wires off it can leave each one level with the pin it # reads instead of guessing. self.pin_rows = pin_rows if pin_rows is not None else {} + # Rows that end in a coil, a return or another sink and so run to the + # right power rail on their own, rather than merging back into one + # wire. Two coils off one contact are two rung ends, not a loop. + self.sink_rows = set(sink_rows) if sink_rows is not None else set() @property def width(self): diff --git a/src/ld_render.py b/src/ld_render.py index d591efc..31cdf88 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -315,12 +315,32 @@ def _render_element(element): return Block([label_line, symbol_line], 1) +_SINK_KINDS = (COIL, "jump", "return", "outVariable") + + +def _ends_in_sink(expr): + """True when a rung path ends in a coil, a return or another output. + + Such a path runs to the right power rail on its own. A parallel of them - + two coils off one contact, or a return beside a coil - is a set of rung + ends, not one wire that rejoins and carries on. + """ + if isinstance(expr, Series): + return bool(expr.items) and _ends_in_sink(expr.items[-1]) + if isinstance(expr, Parallel): + return bool(expr.branches) and all(_ends_in_sink(branch) for branch in expr.branches) + if isinstance(expr, Element): + return expr.kind in _SINK_KINDS + return False + + def _render_series(items): blocks = [_render(item) for item in items] connect_row = max(block.connect_row for block in blocks) height = max(connect_row - block.connect_row + len(block.lines) for block in blocks) columns = [] + sink_rows = set() for block in blocks: width = block.width above = connect_row - block.connect_row @@ -331,11 +351,15 @@ def _render_series(items): lines += block.padded(width) lines += [" " * width] * (height - len(lines)) columns.append(lines) + # A block that ends in sinks running to the rail keeps those rows - + # shifted to where it now sits - so the rung can rail each of them. + for row in block.sink_rows: + sink_rows.add(above + row) joined = [] for row in range(height): joined.append("".join(column[row] for column in columns)) - return Block(joined, connect_row) + return Block(joined, connect_row, sink_rows=sink_rows) def _render_parallel(branches): @@ -356,11 +380,15 @@ def _render_parallel(branches): junctions = set(connect_rows) first, last = connect_rows[0], connect_rows[-1] + # When every branch ends in a sink, each runs to the right rail on its own; + # the branch splits on the left but never rejoins on the right. + terminal = all(_ends_in_sink(branch) for branch in branches) + lines = [] for row, line in enumerate(stacked): if row == first: # The main line carries straight on and drops a branch downward. - left, right = chars["T_DOWN"], chars["T_DOWN"] + left = right = chars["T_DOWN"] elif row == last: left, right = chars["BL"], chars["BR"] elif row in junctions: @@ -369,9 +397,10 @@ def _render_parallel(branches): left = right = chars["V"] else: left = right = " " - lines.append(left + line + right) + lines.append((left + line) if terminal else (left + line + right)) - return Block(lines, first) + sink_rows = set(connect_rows) if terminal else set() + return Block(lines, first, sink_rows=sink_rows) def _render(expr): @@ -470,6 +499,10 @@ def _render_wire(expr): for row, line in enumerate(block.lines): if row == block.connect_row: lines.append(chars["T_RIGHT"] + chars["H"] * 2 + line + chars["H"] * 2 + chars["T_LEFT"]) + elif row in block.sink_rows: + # A lower branch that ends in a coil or a return: the left rail runs + # past it, and it reaches the right rail on its own. + lines.append(chars["V"] + " " + line + chars["H"] * 2 + chars["T_LEFT"]) else: lines.append(chars["V"] + " " + line) return lines diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index bd78ccb..26a6987 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -534,6 +534,14 @@ def _contact(name): xa_row = [i for i, l in enumerate(shared_art) if "xA" in l][0] xb_row = [i for i, l in enumerate(shared_art) if "xB" in l][0] check("shared prefix: the coils are on different rows", xa_row != xb_row) +# Each coil is a rung end that reaches the right rail on its own; the branch +# splits on the left and never rejoins on the right. +check("shared prefix: neither coil rejoins the other", not any(U["BR"] in l for l in shared_art)) +check_equal( + "shared prefix: each coil reaches the right rail", + len([l for l in shared_art if "( )" in l and l.rstrip().endswith(U["T_LEFT"])]), + 2, +) # --- separate sinks that share a box are one branched rung ------------------- @@ -557,6 +565,10 @@ def _contact(name): return_row = [i for i, l in enumerate(shared_box_art) if "" in l][0] coil_row = [i for i, l in enumerate(shared_box_art) if "( )" in l][0] check("shared box sinks: RETURN and the coil are on different rows", return_row != coil_row) +# The return and the coil are two rung ends: each reaches the right rail on +# its own row (the box's own corner aside, nothing rejoins into one wire). +check("shared box sinks: the return reaches the right rail", shared_box_art[return_row].rstrip().endswith(U["T_LEFT"])) +check("shared box sinks: the coil reaches the right rail", shared_box_art[coil_row].rstrip().endswith(U["T_LEFT"])) # --- a contact reads a block output without naming the pin ------------------- From 7e769ccfd11a09548390cc41e96275dc6945f70e Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 12:47:23 +1000 Subject: [PATCH 84/91] draw a side pin's own bubble or edge on the box wall A side pin fed by a contact chain is drawn as that contact, but the bubble or P/N the pin itself carries was dropped from the diagram: the caption used to spell it out as "NOT xClear" or "R(xClear)", and the drawn contact said neither, so the diagram inverted what the ST said. The mark now goes on the box wall, where the power pin's already goes. A pin carrying both keeps its caption, which has room for the pair. Co-Authored-By: Claude Opus 5 (1M context) --- src/ld_render.py | 17 ++++++----------- src/model.py | 5 +++++ src/parse_ld.py | 11 +++++++++-- .../ld-contact-feeds-a-marked-side-pin.xml | 11 +++++++++++ tools/ladder/tests/test_ladder.py | 14 ++++++++++++++ 5 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld-contact-feeds-a-marked-side-pin.xml diff --git a/src/ld_render.py b/src/ld_render.py index 31cdf88..3065813 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -158,18 +158,8 @@ def _pin_feed_symbols(expr): label and its symbol - wired into the pin, rather than flattened into a caption like "R(PowerOff)". """ - chars = charset.active() if isinstance(expr, Element) and expr.kind == CONTACT: - if expr.edge == "rising": - middle = "P" - elif expr.edge == "falling": - middle = "N" - elif expr.negated: - middle = "/" - else: - middle = " " - symbol = chars["CONTACT_L"] + middle + chars["CONTACT_R"] - label = expr.label or "" + symbol, label = _symbol_and_label(expr) return (label + " " + symbol) if label else symbol if isinstance(expr, Series): parts = [_pin_feed_symbols(item) for item in expr.items if not isinstance(item, Empty)] @@ -273,6 +263,11 @@ def feed(index): elif wired[index] and element.power_negated: # The negation bubble on the power pin, drawn on the box wall. left_edge = "o" + elif left[index] in element.pin_marks: + # The bubble or P/N a side pin carries itself, on the wall after + # the contact that feeds it - the same place the power pin's goes. + negated, edge = element.pin_marks[left[index]] + left_edge = EDGE_MARKER[edge] if edge in EDGE_MARKER else "o" # Only the active output continues onward, and only if consumed - but # a pin with a store on it breaks the wall for that wire too. onward = index == 0 and element.output_wired diff --git a/src/model.py b/src/model.py index dc38d02..90b4554 100644 --- a/src/model.py +++ b/src/model.py @@ -503,6 +503,7 @@ def __init__( pin_blocks=None, st_code=None, pin_feeds=None, + pin_marks=None, ): self.kind = kind self.label = label @@ -541,6 +542,10 @@ def __init__( # A contact reset or enable is drawn as the contact it is, wired into # the pin, rather than flattened into the pin caption as text. self.pin_feeds = pin_feeds if pin_feeds is not None else {} + # Blocks only: {pin: (negated, edge)} for a drawn-contact side pin that + # carries a bubble or a P/N of its own. The caption form spells those + # out in its text; a drawn contact needs them put on the box wall. + self.pin_marks = pin_marks if pin_marks is not None else {} @property def title(self): diff --git a/src/parse_ld.py b/src/parse_ld.py index ce263be..e4e17a0 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -248,6 +248,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): side_pins = [] pin_blocks = [] pin_feeds = {} + pin_marks = {} # Several connections landing on one pin are a wired OR into that pin - # the same several--under-one-connectionPointIn shape a coil @@ -298,9 +299,14 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): else: # A side pin fed by contacts - a reset or enable off the rail - is # drawn as the contacts it is, wired into the pin, rather than - # flattened into the caption. The text form stays for the ST. - if _is_contact_chain(feed): + # flattened into the caption. The text form stays for the ST. The + # pin's own bubble or P/N goes on the box wall, as the power pin's + # does; a pin carrying both keeps the caption, which spells out + # the two where the wall has room for one. + if _is_contact_chain(feed) and not (negated and edge): pin_feeds[pin] = feed + if negated or edge: + pin_marks[pin] = (negated, edge) side_pins.append((pin, _pin_text(feed, connections[0], pin_blocks))) input_pins = [] @@ -331,6 +337,7 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): # name the pin, and the box must still tee where it is consumed. output_wired=via_pin is not None or (consumed is not None and node.local_id in consumed), pin_feeds=pin_feeds, + pin_marks=pin_marks, st_code=list(node.st_code), power_negated=power_negated, power_edge=power_edge, diff --git a/tools/ladder/tests/fixtures/ld-contact-feeds-a-marked-side-pin.xml b/tools/ladder/tests/fixtures/ld-contact-feeds-a-marked-side-pin.xml new file mode 100644 index 0000000..64fa84b --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-feeds-a-marked-side-pin.xml @@ -0,0 +1,11 @@ + + + +xCount +xClear +xLoad +xDown + +xDone + + diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 26a6987..de0c41b 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -611,6 +611,20 @@ def _contact(name): check("side pin: a literal side pin stays a value", "10" in pv_row and U["PIN_L"] + "PV" in pv_row) # The ST is unaffected - it still flattens. check("side pin: the ST keeps the flattened form", any("RESET := R(xClear)" in l for l in side_pin_st)) +# A bubble or a P/N on the pin itself goes on the box wall, where the power +# pin's goes; dropping it would draw a reset that fires on the opposite value. +# A pin carrying both keeps its caption, which has room to spell out the two. +MARKED_PIN = os.path.join(FIXTURES, "ld-contact-feeds-a-marked-side-pin.xml") +marked_pin_pou = parse_pous(MARKED_PIN)[0] +marked_pin_art = render_pou(marked_pin_pou) +marked_pin_st = st_render.render_pou(marked_pin_pou) +reset_row = [l for l in marked_pin_art if "RESET" in l][0] +load_row = [l for l in marked_pin_art if "LOAD" in l][0] +cd_row = [l for l in marked_pin_art if "CD" in l][0] +check("marked pin: a negated pin draws its bubble on the wall", "xClear " + U["CONTACT_L"] + " " + U["CONTACT_R"] + U["H"] in reset_row and U["H"] + "oRESET" in reset_row) +check("marked pin: an edge pin draws its P on the wall", "xLoad " + U["CONTACT_L"] + " " + U["CONTACT_R"] + U["H"] in load_row and U["H"] + "PLOAD" in load_row) +check("marked pin: a pin with both keeps the caption", "R(NOT xDown)" + U["H"] * 2 + U["PIN_L"] + "CD" in cd_row) +check("marked pin: the ST keeps every mark", any("RESET := NOT xClear, LOAD := R(xLoad), CD := R(NOT xDown)" in l for l in marked_pin_st)) # --- byte order mark ------------------------------------------------------- From 1ff84d30b468877d861a985cc4692721fc5a49ec Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 12:47:43 +1000 Subject: [PATCH 85/91] bring the README's sample network up to date with the rendering The RESET pin in the sample is fed by a contact, which is now drawn as one; the sample is the golden fixture's network 2, copied verbatim. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b98d989..d2f425d 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,12 @@ Ladder and Function Block Diagram POUs have no textual implementation, so they e ``` (* Network 2: header text *) (* Comment *) -│ TON_0 : TON CTU_0 : CTU -│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff -├─────┤ ├──────────┤IN Q├────────────┤CU Q├────(R)──────┤ -│ T#5S──┤PT ET│ PowerOff──┤RESET CV│ -│ └───────────┘ 10────────┤PV │ -│ └───────────┘ +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff +├─────┤ ├──────────┤IN Q├────────────────┤CU Q├────(R)──────┤ +│ T#5S──┤PT ET│ PowerOff ┤ ├──┤RESET CV│ +│ └───────────┘ 10────────────┤PV │ +│ └───────────┘ ``` The declaration is copied from the original CODESYS declaration source, preserving comments, pragmas, safety-qualified types, and literal spelling. The diagram is derived from PLCopen XML. On older CODESYS versions where the plaintext declaration is unavailable, the declaration is rebuilt from the structured interface. That form cannot carry comments, pragmas or attributes, so the rendering says on its first line that it is a rebuilt one, and the export summary counts how many POUs it happened to. A variable whose type the export does not carry reads `UNKNOWN` rather than being assumed to be a `BOOL`. From 8a99c74a0bfd7eb2800fb8e89e00518a603b5daf Mon Sep 17 00:00:00 2001 From: kehinde Date: Tue, 15 Sep 2026 12:53:39 +1000 Subject: [PATCH 86/91] re-export LD_TEST Rendered with the current renderer: network 3's reset contact is drawn as a contact with the pin's own edge on the box wall, and network 5's RETURN and coil branch off one box. Co-Authored-By: Claude Opus 5 (1M context) --- .../StandardPLC/application/LD_TEST.txt | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt index 7951d0f..c3482a7 100644 --- a/GraphicalTesting/StandardPLC/application/LD_TEST.txt +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -25,16 +25,16 @@ END_VAR (* Network 3: header text *) (* Comment *) -│ PowerOff PowerOff PowerOff -├──┬───┤ ├────────────────────────────────────────────────────────────────┬───┤/├───────(R)──────┤ -│ │ TON_0 : TON CTU_0 : CTU │ -│ │ PowerOn ┌───────────┐ ┌───────────┐ PowerOn │ -│ ├───┤ ├──────────┤IN Q├──────────────────┤CU Q│────┤ ├───┤ -│ │ T#5S──┤PT ET│ R(R(PowerOff))──┤RESET CV│ │ -│ │ └───────────┘ 10──────────────┤PV │ │ -│ │ └───────────┘ │ -│ │ PowerOn │ -│ └───┤ ├────────────────────────────────────────────────────────────────┘ +│ PowerOff PowerOff PowerOff +├──┬───┤ ├──────────────────────────────────────────────────────────────┬───┤/├───────(R)──────┤ +│ │ TON_0 : TON CTU_0 : CTU │ +│ │ PowerOn ┌───────────┐ ┌───────────┐ PowerOn │ +│ ├───┤ ├──────────┤IN Q├────────────────┤CU Q├────┤ ├───┤ +│ │ T#5S──┤PT ET│ PowerOff ┤P├──PRESET CV│ │ +│ │ └───────────┘ 10────────────┤PV │ │ +│ │ └───────────┘ │ +│ │ PowerOn │ +│ └───┤ ├──────────────────────────────────────────────────────────────┘ (* Network 4: (Babe in the big City) *) TestJump: @@ -43,15 +43,9 @@ TestJump: (* Network 5 *) │ ADD │ PowerOff PowerOn ┌──────────┐ -├─────┤/├───────┤ ├───────┤EN ENO├──────────────────┤ -│ 1──┤In2 Out2├───> answer -│ 2──┤In3 │ -│ └──────────┘ -│ ADD -│ PowerOff PowerOn ┌──────────┐ edgetrigger PowerOff -├─────┤/├───────┤ ├───────┤EN ENO│─────────────────┤P├────────( )──────┤ -│ 1──┤In2 Out2├───> answer -│ 2──┤In3 │ +├─────┤/├───────┤ ├───────┤EN ENO├────────────┬─────────────────┤ +│ 1──┤In2 Out2├───> answer │ edgetrigger PowerOff +│ 2──┤In3 │ └─────┤P├────────( )──────┤ │ └──────────┘ (* Network 6 *) From 122a0b6009da140c7442d9f87aceb4b817546cf7 Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Wed, 16 Sep 2026 21:08:11 +1000 Subject: [PATCH 87/91] keep CODESYS working files out of the export folder again The re-include of GraphicalTesting/ is the last pattern that matches inside it, so it also cancelled *.project, *.opt and *.~u there, and the staging and backup folders. Those rules exist because a .project and a .opt carry the controller serial and the gateway address. "git add -A" staged all of them. They are excluded again after the re-include. The check asks git itself, with --no-index so that a tracked path is judged by the patterns alone. It skips when git is not available. --- .gitignore | 8 ++++++ tools/ladder/tests/test_export.py | 47 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/.gitignore b/.gitignore index 196851b..ac31672 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,11 @@ cython_debug/ !GraphicalTesting/** GraphicalTesting/*/devices/ GraphicalTesting/*/communication/ +# The re-include above is the last pattern to match inside the export, so it +# cancels the CODESYS working-file rules at the top of this file as well. Those +# carry the controller serial and gateway address; exclude them again here. +GraphicalTesting/**/*.project +GraphicalTesting/**/*.opt +GraphicalTesting/**/*.~u +GraphicalTesting/**/*.codescribe_staging/ +GraphicalTesting/**/*.codescribe_backup/ diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 4c85583..8ca2b9d 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -811,6 +811,53 @@ def locked_rename(source, destination): finally: shutil.rmtree(workspace) + +# --- the re-include keeps CODESYS working files out of the repo -------------- + +# .gitignore re-includes the whole of GraphicalTesting/ so that no generic +# pattern drops a POU from the tracked export. That re-include is the last +# pattern to match there, so it also cancelled *.project, *.opt and *.~u, which +# exist because those files carry the controller serial and gateway address, +# and "git add -A" then staged them. Asked of git itself, since only git knows +# which pattern wins. -q, not -v: -v exits 0 on a negated match as well. +# --no-index, because git never reports a path it already tracks as ignored, +# so without it the check on the tracked template could not fail. +def git_ignores(path): + import subprocess + + devnull = open(os.devnull, "w") + try: + return subprocess.call( + ["git", "check-ignore", "-q", "--no-index", path], cwd=REPO, stdout=devnull, stderr=devnull + ) + finally: + devnull.close() + + +try: + have_git = git_ignores(".gitignore") in (0, 1) +except Exception: + have_git = False + +if not have_git: + print("SKIP .gitignore checks: git is not available here") +else: + APPLICATION = "GraphicalTesting/StandardPLC/application" + for path in ( + APPLICATION + "/Main.project", + APPLICATION + "/Main.opt", + APPLICATION + "/Main.project.~u", + APPLICATION + ".codescribe_staging/Main.xml", + APPLICATION + ".codescribe_backup/Main.xml", + ): + check_equal("ignored inside the export: " + path, git_ignores(path), 0) + for path in ( + APPLICATION + "/PLC_PRG.st", + APPLICATION + "/lib/Helper.st", + "GraphicalTesting_template_v1.project", + ): + check_equal("tracked: " + path, git_ignores(path), 1) + print("") if failures: print("%d check(s) failed" % len(failures)) From 3e867d382b92b8bd2c42f699e645f8b985793faf Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Wed, 16 Sep 2026 21:08:23 +1000 Subject: [PATCH 88/91] treat elements as one only when they are one node, and number ambiguous boxes The rung merge and the shared-prefix factoring decided that two elements were one by comparing what they draw. Two contacts on one variable, or two EXECUTE boxes with one body, draw the same and are still two elements in the editor. The second coil of a double coil was deleted from the drawing, and two EXECUTE boxes were fused into one. Each drawn element now carries the localId of its node, and only copies of one node are merged or factored. An operator read through two different pins was redrawn for the second pin, and the copies could not merge, so one box was drawn twice. That reader now names the pin instead, as a named instance's reader already does. An EXECUTE box has no instance name, so a second reader in FBD redrew it and printed its body again. A box that carries inline ST is now named by its type. The shared box in a joined network also draws its inputs against the network's drawn boxes rather than a fresh set, so a box drawn there is not drawn again. Named by type alone, two unnamed boxes of one type were indistinguishable, and moving a wire from one to the other changed nothing in the export. Where a network holds two or more of them and the text names one, each carries an ordinal on its title and in every reference: ADD #1, [ADD#1.ENO]. A box named anywhere in a side pin's caption counts as named. Networks without that shape render as before. Existing output changes only in 36-5-ld-pin-edge: the ADD box read on ENO and Out1 is drawn and called once instead of twice. Each new check fails against the code before this change. --- src/fbd_render.py | 105 +++++++++++++-- src/ld_render.py | 46 ++++--- src/model.py | 45 ++++++- src/parse_ld.py | 127 ++++++++++++++---- .../fixtures/fbd-execute-feeds-shared-box.xml | 10 ++ .../fixtures/fbd-execute-two-readers.xml | 9 ++ ...two-execute-boxes-feed-one-sub-swapped.xml | 10 ++ .../fbd-two-execute-boxes-feed-one-sub.xml | 10 ++ .../ladder/tests/fixtures/ld-double-coil.xml | 9 ++ .../tests/fixtures/ld-execute-two-coils.xml | 9 ++ ...-named-in-a-nested-pin-caption-swapped.xml | 18 +++ ...operator-named-in-a-nested-pin-caption.xml | 18 +++ .../fixtures/ld-operator-read-on-two-pins.xml | 11 ++ .../ld-operator-with-instance-side-pin.xml | 12 ++ .../fixtures/ld-two-execute-boxes-alike.xml | 10 ++ ...perators-each-read-on-two-pins-swapped.xml | 14 ++ ...ld-two-operators-each-read-on-two-pins.xml | 14 ++ tools/ladder/tests/test_fbd.py | 57 ++++++++ tools/ladder/tests/test_ladder.py | 93 +++++++++++++ 19 files changed, 573 insertions(+), 54 deletions(-) create mode 100644 tools/ladder/tests/fixtures/fbd-execute-feeds-shared-box.xml create mode 100644 tools/ladder/tests/fixtures/fbd-execute-two-readers.xml create mode 100644 tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub-swapped.xml create mode 100644 tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub.xml create mode 100644 tools/ladder/tests/fixtures/ld-double-coil.xml create mode 100644 tools/ladder/tests/fixtures/ld-execute-two-coils.xml create mode 100644 tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption-swapped.xml create mode 100644 tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption.xml create mode 100644 tools/ladder/tests/fixtures/ld-operator-read-on-two-pins.xml create mode 100644 tools/ladder/tests/fixtures/ld-operator-with-instance-side-pin.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-execute-boxes-alike.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins-swapped.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 46f2781..01febc5 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -12,7 +12,7 @@ import charset from layout import Block, centred, stack from ld_render import network_headers, render_declaration -from model import Assign, Call, Jump, Label, OutputRef, Signal +from model import Assign, Call, Jump, Label, OutputRef, Signal, box_name def _render_signal(node): @@ -238,15 +238,31 @@ def _render_call(call, read_pin, drawn, subs=None): return Block(lines, connect_row, pin_rows) +# The boxes without an instance name that the render in progress named in text, +# by id. render_network clears it before each render and reads it after; it is +# module state only because a render copies its set of drawn boxes on the way. +_named = [] + + def _reference(call, pin): """The name of a box already drawn in this network, on the pin being read. - Only an instance can be referred to this way: an operator has no name to - print, and being stateless it costs nothing to draw again. + An instance is named by its instance. An operator has no name to print, + and being stateless it costs nothing to draw again - so it is redrawn. A + box that carries inline ST, such as EXECUTE, has no instance either, but it + is not stateless: its body runs once, and a redraw prints the body again, + which reads as the statements running twice. It is named by its type, the + way the ladder renderer names a box it does not redraw, and numbered by + render_network when its type alone would not say which box it is. """ - if not call.instance_name: + if call.instance_name: + base = call.instance_name + elif call.st_code: + base = box_name(call) + _named.append(id(call)) + else: return None - text = call.instance_name + "." + pin if pin else call.instance_name + text = base + "." + pin if pin else base if pin in call.negated_outputs: text = "NOT " + text return text @@ -664,7 +680,10 @@ def _render_joined(readers, call, drawn): """ chars = charset.active() drawn.add(id(call)) - source = _render_call(call, None, set()) + # Against this network's drawn boxes, not a fresh set: a box drawn in the + # shared box's inputs is on the page, and a reader elsewhere that reaches it + # must name it rather than draw it - and an EXECUTE body - a second time. + source = _render_call(call, None, drawn) default_row = source.connect_row for every_pin in (True, False): @@ -741,10 +760,80 @@ def render_network(network): behind the store its ENO feeds, or behind another box. """ outputs = getattr(network, "outputs", [network]) + unnamed = _unnamed_calls(outputs) + for call in unnamed: + call.ordinal = None + # Per network: a box drawn for one output must not be drawn again for the # next, but a box shared between two networks is two boxes on the page. - drawn = set() - return _render_outputs(outputs, drawn) + del _named[:] + lines = _render_outputs(outputs, set()) + # Which boxes the text names is known only once the network is drawn, and + # a box's title is written when it is drawn - so a numbered network draws + # twice. See box_name. + if _number_named_calls(unnamed, set(_named)): + del _named[:] + lines = _render_outputs(outputs, set()) + return lines + + +def _unnamed_calls(outputs): + """The boxes with no instance name in a network, in the order the trees reach them. + + The output trees in order, and within a tree a box's inputs before the box. + That is fixed by the network, not by where the renderer places a box: a + shared box is drawn first, so a box in its inputs can be drawn above one + numbered before it. + """ + found = [] + seen = set() + + def walk(node): + inner = node.call if isinstance(node, OutputRef) else node + if isinstance(inner, Call): + if id(inner) in seen: + return + seen.add(id(inner)) + for _pin, source in inner.inputs: + if source is not None: + walk(source) + if not inner.instance_name: + found.append(inner) + elif isinstance(node, Assign): + if node.source is not None: + walk(node.source) + elif isinstance(node, Jump): + if node.condition is not None: + walk(node.condition) + + for tree in outputs: + walk(tree) + return found + + +def _number_named_calls(unnamed, named): + """Number the boxes of each type that the text names ambiguously. + + Only a type with two or more such boxes in the network, one of which the + text names. Returns True when any box was numbered. + """ + groups = {} + order = [] + for call in unnamed: + if call.type_name not in groups: + groups[call.type_name] = [] + order.append(call.type_name) + groups[call.type_name].append(call) + + numbered = False + for type_name in order: + group = groups[type_name] + if len(group) < 2 or not any(id(call) in named for call in group): + continue + for index, call in enumerate(group): + call.ordinal = index + 1 + numbered = True + return numbered def render_pou(pou): diff --git a/src/ld_render.py b/src/ld_render.py index 3065813..4543919 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -411,25 +411,26 @@ def _render(expr): raise TypeError("cannot render %r" % (expr,)) -def _signature(expr): - """A hashable structural fingerprint, so equal drawn elements can be spotted. - - Two elements with the same fingerprint render identically, which is what - lets a shared prefix be pulled out of parallel branches without changing - what any branch draws. +def _identity(expr): + """Which elements an expression is made of, so copies of one node can be spotted. + + The parser builds one branch per sink, so a node feeding several sinks + arrives as one copy per branch, and those copies are one element in the + editor. What an element draws does not decide this: two contacts on one + variable, or two EXECUTE boxes with one body, draw the same and are still + two elements. Treating them as one deleted the second coil of a double coil. + So the key is the node's localId - and, for a box, the pin it is read + through, since a box read through two pins does not continue one wire. """ if isinstance(expr, Series): - return ("series",) + tuple(_signature(item) for item in expr.items) + return ("series",) + tuple(_identity(item) for item in expr.items) if isinstance(expr, Parallel): - return ("parallel",) + tuple(_signature(branch) for branch in expr.branches) + return ("parallel",) + tuple(_identity(branch) for branch in expr.branches) if isinstance(expr, Element): - return ( - "element", expr.kind, expr.label, expr.negated, expr.edge, expr.storage, - expr.type_name, expr.instance_name, tuple(expr.input_pins), tuple(expr.output_pins), - expr.active_output, expr.output_wired, expr.power_negated, expr.power_edge, - tuple(sorted(expr.negated_outputs)), tuple(sorted(expr.stored_outputs.items())), - tuple(expr.st_code), tuple(_signature(block) for block in expr.pin_blocks), - ) + if expr.local_id is None: + # Built from no node, such as a cycle marker: it is only itself. + return ("element", id(expr)) + return ("element", expr.kind, expr.local_id, expr.active_output, expr.label) return ("empty",) @@ -442,6 +443,10 @@ def _factor(expr): draws the shared part once and splits after it; factoring the common prefix of the branches produces exactly that. Power flow is unchanged: "(P AND a) OR (P AND b)" and "P AND (a OR b)" drive the same rung. + + Only copies of one node are pulled out. The first copy is the one kept: + a box hoists the boxes feeding its side pins on the rung that builds it + first, and a later copy only names them. """ if isinstance(expr, Series): return series([_factor(item) for item in expr.items]) @@ -455,8 +460,8 @@ def items_of(branch): prefix = [] while all(part for part in parts): first = parts[0][0] - signature = _signature(first) - if any(_signature(part[0]) != signature for part in parts): + identity = _identity(first) + if any(_identity(part[0]) != identity for part in parts): break prefix.append(first) parts = [part[1:] for part in parts] @@ -504,9 +509,10 @@ def _render_wire(expr): def _block_prefix_key(rung): - """The signature of a rung's head up to and including its first block. + """The identity of a rung's head up to and including its first block. - Two rungs with the same key begin with the same chain into the same box. + Two rungs with the same key begin with the same chain into the same box - + the same nodes, not merely nodes that draw alike. A box read by several sinks is one box that runs once, so those rungs are the branches of one wire that splits after it; grouping them by this key lets the split be drawn once. A rung with no block returns None and is @@ -518,7 +524,7 @@ def _block_prefix_key(rung): for item in items: prefix.append(item) if isinstance(item, Element) and item.kind == BLOCK: - return tuple(_signature(part) for part in prefix) + return tuple(_identity(part) for part in prefix) return None diff --git a/src/model.py b/src/model.py index 90b4554..c7043bc 100644 --- a/src/model.py +++ b/src/model.py @@ -112,11 +112,39 @@ def __init__( self.negated_outputs = negated_outputs if negated_outputs is not None else set() # blocks only: {pin: "set" | "reset"} for inline assignments that store self.stored_outputs = dict(stored_outputs) if stored_outputs is not None else {} + # blocks only: set by the network builder; see box_name + self.ordinal = None def __repr__(self): return "Node(%s, %s, %r, inputs=%r)" % (self.local_id, self.kind, self.label, self.inputs) +def box_name(box): + """The name a box is written under when the text names it instead of a wire. + + An instance has its own name. A box without one - an operator, or an + EXECUTE box - is named by its type, and two such boxes of one type in one + network would then be indistinguishable: rewiring a coil from one to the + other would change nothing in the export. So where a network holds two or + more of them and one is named, each carries an ordinal, printed after its + type in its title and in every reference to it. + """ + if box.instance_name: + return box.instance_name + base = box.type_name or "?" + if box.ordinal: + return "%s#%d" % (base, box.ordinal) + return base + + +def _numbered_type(box): + """A box title's type part: 'ADD', or 'ADD #2' when it carries an ordinal.""" + base = box.type_name or "?" + if box.ordinal: + return "%s #%d" % (base, box.ordinal) + return base + + def component_finder(nodes): """Union-find over the wires, ignoring direction. @@ -376,6 +404,8 @@ def __init__( # none; a block read through two of its pins has two, and is still one # box, called once. self.wired_outputs = set(wired_outputs) if wired_outputs is not None else set() + # Set by the renderer; see box_name. + self.ordinal = None @property def output_wired(self): @@ -386,7 +416,7 @@ def output_wired(self): def title(self): if self.instance_name: return self.instance_name + " : " + (self.type_name or "?") - return self.type_name or "?" + return _numbered_type(self) @property def is_operator(self): @@ -504,8 +534,17 @@ def __init__( st_code=None, pin_feeds=None, pin_marks=None, + local_id=None, + ordinal=None, ): self.kind = kind + # Blocks only; see box_name. + self.ordinal = ordinal + # The localId of the PLCopen node this element was built from. The + # parser builds one branch per sink, so one node can arrive as several + # copies; this is what says they are one element in the editor. Two + # nodes that draw the same are still two elements. + self.local_id = local_id self.label = label self.negated = negated self.edge = edge @@ -552,7 +591,9 @@ def title(self): """Caption drawn above a block: 'TON_0 : TON', or just 'GT'.""" if self.instance_name: return self.instance_name + " : " + (self.type_name or "?") - return self.type_name or self.label or "?" + if self.type_name: + return _numbered_type(self) + return self.label or "?" def __repr__(self): return "Element(%s, %r)" % (self.kind, self.label) diff --git a/src/parse_ld.py b/src/parse_ld.py index e4e17a0..1ad59da 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -28,6 +28,7 @@ Pou, Series, assemble_networks, + box_name, component_finder, is_simple_term, parallel, @@ -139,9 +140,16 @@ def _to_element(node): negated=node.negated, edge=node.edge, storage=node.storage, + local_id=node.local_id, ) +# While the networks are built: the set of localIds of boxes with no instance +# name that a caption has named, wherever in the caption they sit. None at any +# other time, such as when the ST renderer flattens an expression. +_naming = None + + def expr_to_text(expr): """Flatten an expression to one line of ST-ish text. @@ -159,7 +167,9 @@ def expr_to_text(expr): return "(" + " OR ".join(parts) + ")" if isinstance(expr, Element): if expr.kind == BLOCK: - base = expr.instance_name or expr.type_name or "?" + base = box_name(expr) + if _naming is not None and not expr.instance_name and expr.local_id is not None: + _naming.add(expr.local_id) text = (base + "." + expr.active_output) if expr.active_output else base # The negation bubble on the consumed output inverts what leaves # the box - on this flattened path just like on the power flow. @@ -201,11 +211,12 @@ def _block_reference(node, via_pin): pin = via_pin if pin is None and node.outputs: pin = node.outputs[0][0] - base = node.instance_name or node.type_name or "?" + base = box_name(node) return Element( kind=IN_VARIABLE, label=(base + "." + pin) if pin else base, negated=pin in node.negated_outputs, + local_id=node.local_id, ) @@ -232,14 +243,25 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): an inVariable are parameters, not power, so the first genuinely wired pin wins and the rest become captions inside the box. """ - if node.local_id in drawn and node.instance_name: + read_pin = via_pin + if read_pin is None and node.outputs: + read_pin = node.outputs[0][0] + if node.local_id in drawn.pins: # Drawn once already. A stateful function block is one box that runs # once, so the next reader names the pin it takes. A stateless - # operator has no instance to name - "OR.Out1" points at no variable, - # and would be ambiguous with a second OR - so it is redrawn instead, - # the rule the FBD renderer already follows. - return _block_reference(node, via_pin) - drawn.add(node.local_id) + # operator has no instance to name - "OR.Out1" points at no variable - + # so a reader of the same pin redraws it, and the rung merge then draws + # the copies as one box. A reader of a different pin cannot merge: the + # copies differ in the pin the wire leaves by, and a redraw is a second + # box where the editor has one. That reader names the pin instead, and + # box_name numbers the box if its type alone would not say which. + if node.instance_name or drawn.pins[node.local_id] != read_pin: + if not node.instance_name: + drawn.named.add(node.local_id) + return _block_reference(node, via_pin) + else: + drawn.order[node.local_id] = len(drawn.order) + drawn.pins[node.local_id] = read_pin power_expr = Empty() power_pin = None @@ -344,6 +366,8 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): negated_outputs=set(node.negated_outputs), stored_outputs=node.stored_outputs, pin_blocks=pin_blocks, + local_id=node.local_id, + ordinal=node.ordinal, ) return series([power_expr, element]) @@ -413,7 +437,7 @@ def _build_expr(node, by_id, visiting, via_pin=None, drawn=None, consumed=None): is drawn and called once. """ if drawn is None: - drawn = set() + drawn = _Built() if node.local_id in visiting: # Feedback loops are not legal in a rung, but a malformed export should # produce a visible marker rather than blow the stack. @@ -440,6 +464,47 @@ def _build_expr(node, by_id, visiting, via_pin=None, drawn=None, consumed=None): return series([incoming, _to_element(node)]) +class _Built(object): + """The blocks built so far for one POU.""" + + def __init__(self): + # {localId: the pin the block was first built for} + self.pins = {} + # {localId: the order blocks were first built in}. That follows the + # rungs in export order; a box hoisted into a side pin is built after + # the box that reads it, though it is drawn above it. + self.order = {} + # localIds of boxes with no instance name that the text names - in a + # reference to a pin, or anywhere in a side pin's caption + self.named = set() + + +def _number_named_boxes(logic, find, built): + """Give an ordinal to each box that its type alone would not identify. + + Only in a network holding two or more boxes of one type with no instance + name, and only where the text names one of them; everywhere else a box is + written as it always was. Numbered in the order the boxes are first built, + which is fixed by the export and so stable from one export to the next. + Returns True when any box was numbered, so the networks must be rebuilt. + """ + groups = {} + for node in logic: + if node.kind == BLOCK and not node.instance_name: + groups.setdefault((find(node.local_id), node.type_name), []).append(node) + + numbered = False + for group in groups.values(): + if len(group) < 2 or not any(node.local_id in built.named for node in group): + continue + # A box never built draws nowhere; it goes last, in export order. + group.sort(key=lambda node: (node.local_id not in built.order, built.order.get(node.local_id, 0))) + for index, node in enumerate(group): + node.ordinal = index + 1 + numbered = True + return numbered + + def build_networks(nodes): """Group a flat node list into Networks, each holding its rungs. @@ -482,21 +547,35 @@ def root_of(node): for connection in node.inputs: consumed.add(connection.ref_id) - # A block read by several outputs is built once, on the first rung that - # reaches it; the rest name its output pin. The set is per POU, and a - # block belongs to one network, so this cannot leak across networks. - drawn = set() - - rungs_by_root = {} - for node in nodes: - if node.local_id in consumed or node.kind in (LEFT_RAIL, COMMENT, TITLE): - # An unconnected left rail is an empty rung, not a terminal. - continue - expr = _build_expr(node, by_id, set(), None, drawn, consumed) - if isinstance(expr, Empty): - # An unconnected rail or a stray element with nothing on it. - continue - rungs_by_root.setdefault(root_of(node), []).append(expr) + def build_rungs(): + # A block read by several outputs is built once, on the first rung that + # reaches it; the rest name its output pin. Per POU, and a block belongs + # to one network, so this cannot leak across networks. + global _naming + built = _Built() + rungs_by_root = {} + _naming = built.named + try: + for node in nodes: + if node.local_id in consumed or node.kind in (LEFT_RAIL, COMMENT, TITLE): + # An unconnected left rail is an empty rung, not a terminal. + continue + expr = _build_expr(node, by_id, set(), None, built, consumed) + if isinstance(expr, Empty): + # An unconnected rail or a stray element with nothing on it. + continue + rungs_by_root.setdefault(root_of(node), []).append(expr) + finally: + _naming = None + return rungs_by_root, built + + for node in logic: + node.ordinal = None + rungs_by_root, built = build_rungs() + # Which boxes the text names is known only once the rungs are built, and + # their names are written while building - so a numbered POU builds twice. + if _number_named_boxes(logic, find, built): + rungs_by_root, built = build_rungs() # A component that is nothing but a jump label is the label of the network # that follows it, not a network of its own. diff --git a/tools/ladder/tests/fixtures/fbd-execute-feeds-shared-box.xml b/tools/ladder/tests/fixtures/fbd-execute-feeds-shared-box.xml new file mode 100644 index 0000000..aca03c0 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-execute-feeds-shared-box.xml @@ -0,0 +1,10 @@ + + +xRun +T#1S +executenCount := nCount + 1; + +xA +tElapsed +xRan + diff --git a/tools/ladder/tests/fixtures/fbd-execute-two-readers.xml b/tools/ladder/tests/fixtures/fbd-execute-two-readers.xml new file mode 100644 index 0000000..35b7e8d --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-execute-two-readers.xml @@ -0,0 +1,9 @@ + + +xRun +executenCount := nCount + 1; +xRan +xOther + +xBoth + diff --git a/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub-swapped.xml b/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub-swapped.xml new file mode 100644 index 0000000..2830e26 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub-swapped.xml @@ -0,0 +1,10 @@ + + +xRun +executenCount := nCount + 1; +executenOther := nOther + 2; +xRan +xRan2 + +xBoth + diff --git a/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub.xml b/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub.xml new file mode 100644 index 0000000..607afc8 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub.xml @@ -0,0 +1,10 @@ + + +xRun +executenCount := nCount + 1; +executenOther := nOther + 2; +xRan +xRan2 + +xBoth + diff --git a/tools/ladder/tests/fixtures/ld-double-coil.xml b/tools/ladder/tests/fixtures/ld-double-coil.xml new file mode 100644 index 0000000..6f955fe --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-double-coil.xml @@ -0,0 +1,9 @@ + + + +xGo +xOut +xGo +xOut + + diff --git a/tools/ladder/tests/fixtures/ld-execute-two-coils.xml b/tools/ladder/tests/fixtures/ld-execute-two-coils.xml new file mode 100644 index 0000000..a391b4d --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-execute-two-coils.xml @@ -0,0 +1,9 @@ + + + +xGo +executenCount := nCount + 1; +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption-swapped.xml b/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption-swapped.xml new file mode 100644 index 0000000..60afa05 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption-swapped.xml @@ -0,0 +1,18 @@ + + + +xGo +xQ +xS +10 + +xA + +xB +xC +xM +xE + +xFull + + diff --git a/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption.xml b/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption.xml new file mode 100644 index 0000000..0b08b33 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption.xml @@ -0,0 +1,18 @@ + + + +xGo +xQ +xS +10 + +xA + +xB +xC +xM +xE + +xFull + + diff --git a/tools/ladder/tests/fixtures/ld-operator-read-on-two-pins.xml b/tools/ladder/tests/fixtures/ld-operator-read-on-two-pins.xml new file mode 100644 index 0000000..c4553c9 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-read-on-two-pins.xml @@ -0,0 +1,11 @@ + + + +xGo +iA +iB + +xDone +iSum + + diff --git a/tools/ladder/tests/fixtures/ld-operator-with-instance-side-pin.xml b/tools/ladder/tests/fixtures/ld-operator-with-instance-side-pin.xml new file mode 100644 index 0000000..cec0a54 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-with-instance-side-pin.xml @@ -0,0 +1,12 @@ + + + +xGo +xT +T#1S + + +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-two-execute-boxes-alike.xml b/tools/ladder/tests/fixtures/ld-two-execute-boxes-alike.xml new file mode 100644 index 0000000..d24d411 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-execute-boxes-alike.xml @@ -0,0 +1,10 @@ + + + +xGo +executenCount := nCount + 1; +xA +executenCount := nCount + 1; +xB + + diff --git a/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins-swapped.xml b/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins-swapped.xml new file mode 100644 index 0000000..c6239cb --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins-swapped.xml @@ -0,0 +1,14 @@ + + + +xGo +iA +iB + +xA +iSum + +xB +iDiff + + diff --git a/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins.xml b/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins.xml new file mode 100644 index 0000000..ab7dbb6 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins.xml @@ -0,0 +1,14 @@ + + + +xGo +iA +iB + +xA +iSum + +xB +iDiff + + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index f944209..644c42c 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -646,6 +646,63 @@ def wire_rows(art): check_equal("execute deep: the body appears once", len([line for line in deep_art if "c := 3;" in line]), 1) +# --- one EXECUTE box read from two places ------------------------------------ + +# An EXECUTE box has no instance name, so a second reader redrew it - and once +# the body moved inside the box, the redraw copied the body too. One box whose +# ENO feeds a store and an AND then showed "nCount := nCount + 1;" twice, which +# reads as a counter that advances by two each scan. The body belongs to the one +# box; a redraw of it must not repeat the statements. +EXECUTE_TWO = os.path.join(HERE, "fixtures", "fbd-execute-two-readers.xml") +execute_two = parse_fbd.parse_pous(EXECUTE_TWO)[0] +execute_two_art = fbd_render.render_network(execute_two.networks[0]) + +check_equal( + "execute two readers: the body is drawn once", + len([line for line in execute_two_art if "nCount := nCount + 1;" in line]), + 1, +) +check("execute two readers: the store is still drawn", any("> xRan" in line for line in execute_two_art)) +check("execute two readers: the AND reader is still drawn", any("> xBoth" in line for line in execute_two_art)) +check( + "execute two readers: the AND reads the box by name", + any("EXECUTE.ENO" + U["H"] * 2 + U["PIN_L"] + "In1" in line for line in execute_two_art), +) + +# The same box feeding a shared timer. The timer's inputs were drawn against a +# fresh set of drawn boxes, so the EXECUTE box inside them was never recorded, +# and the store that also reads it drew the box - and its body - a second time. +EXECUTE_SHARED = os.path.join(HERE, "fixtures", "fbd-execute-feeds-shared-box.xml") +execute_shared = parse_fbd.parse_pous(EXECUTE_SHARED)[0] +execute_shared_art = fbd_render.render_network(execute_shared.networks[0]) + +check_equal( + "execute feeds a shared box: the body is drawn once", + len([line for line in execute_shared_art if "nCount := nCount + 1;" in line]), + 1, +) +check("execute feeds a shared box: the timer is drawn", any("tmr : TON" in line for line in execute_shared_art)) +check( + "execute feeds a shared box: the store reads the box by name", + any("EXECUTE.ENO" + U["H"] * 3 + "> xRan" in line for line in execute_shared_art), +) + +# Two EXECUTE boxes feeding one SUB were both named "EXECUTE.ENO", so the +# export did not say which feeds In1 - and SUB is not commutative. Each is +# numbered in the order the network reaches it, on its title and in the reference. +SUB_FED = os.path.join(HERE, "fixtures", "fbd-two-execute-boxes-feed-one-sub.xml") +SUB_FED_SWAPPED = os.path.join(HERE, "fixtures", "fbd-two-execute-boxes-feed-one-sub-swapped.xml") +sub_fed_art = fbd_render.render_network(parse_fbd.parse_pous(SUB_FED)[0].networks[0]) +sub_fed_swapped_art = fbd_render.render_network(parse_fbd.parse_pous(SUB_FED_SWAPPED)[0].networks[0]) +check("numbered execute boxes: titled #1 and #2", [l.strip() for l in sub_fed_art if l.strip().startswith("EXECUTE #")] == ["EXECUTE #1", "EXECUTE #2"]) +check("numbered execute boxes: In1 names box 1", any("EXECUTE#1.ENO" + U["H"] * 2 + U["PIN_L"] + "In1" in l for l in sub_fed_art)) +check("numbered execute boxes: In2 names box 2", any("EXECUTE#2.ENO" + U["H"] * 2 + U["PIN_L"] + "In2" in l for l in sub_fed_art)) +check("numbered execute boxes: swapping the inputs changes the export", sub_fed_art != sub_fed_swapped_art) +check_equal("numbered execute boxes: each body is drawn once", len([l for l in sub_fed_art if ":=" in l]), 2) +# A lone EXECUTE box read twice keeps its plain name. +check("numbered execute boxes: a lone box is not numbered", not any("#" in l for l in execute_two_art)) + + # --- one expression reading two pins of a shared box ------------------------- # xAlarm := ctr.Q AND (ctr.CV > 5). Each read of the shared box was replaced diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index de0c41b..31f052e 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -591,6 +591,99 @@ def _contact(name): check("unnamed pin: RETURN and the coil are both drawn", any("" in l for l in unnamed_art) and any("( )" in l for l in unnamed_art)) +# --- elements are the same only when they are the same node ------------------ + +# The rung merge and the shared-prefix factoring decided that two elements were +# one by comparing what they draw. Two contacts on one variable, or two boxes of +# one type with one body, draw the same and are still two elements in the +# editor. Treating them as one deleted the second coil of a double coil, and +# fused two EXECUTE boxes into one. Sameness is the node's localId now. +DOUBLE_COIL = os.path.join(FIXTURES, "ld-double-coil.xml") +double_coil_art = render_pou(parse_pous(DOUBLE_COIL)[0]) +check_equal("double coil: both coils are drawn", len([l for l in double_coil_art if "( )" in l]), 2) +check_equal("double coil: both contacts are drawn", len([l for l in double_coil_art if "xGo" in l]), 2) +check_equal( + "double coil: each coil reaches the right rail", + len([l for l in double_coil_art if "( )" in l and l.rstrip().endswith(U["T_LEFT"])]), + 2, +) + +ALIKE = os.path.join(FIXTURES, "ld-two-execute-boxes-alike.xml") +alike_art = render_pou(parse_pous(ALIKE)[0]) +check_equal("alike boxes: two EXECUTE boxes are drawn", len([l for l in alike_art if l.strip(U["V"] + " ") == "EXECUTE"]), 2) +check_equal("alike boxes: each body is drawn", len([l for l in alike_art if "nCount := nCount + 1;" in l]), 2) + +# The control: one EXECUTE box read by two coils is still one box, one body. +EXECUTE_TWO_COILS = os.path.join(FIXTURES, "ld-execute-two-coils.xml") +execute_two_coils_art = render_pou(parse_pous(EXECUTE_TWO_COILS)[0]) +check_equal("one execute, two coils: one box", len([l for l in execute_two_coils_art if l.strip(U["V"] + " ") == "EXECUTE"]), 1) +check_equal("one execute, two coils: the body once", len([l for l in execute_two_coils_art if "nCount := nCount + 1;" in l]), 1) +check("one execute, two coils: both coils", any("xA" in l for l in execute_two_coils_art) and any("xB" in l for l in execute_two_coils_art)) + +# One box read on two different pins was redrawn for the second pin, because an +# operator has no instance to name, and the copies differ in their active pin so +# nothing merged them. The ADD box was drawn twice: two additions for one. +TWO_PINS = os.path.join(FIXTURES, "ld-operator-read-on-two-pins.xml") +two_pins_art = render_pou(parse_pous(TWO_PINS)[0]) +check_equal("operator on two pins: the box is drawn once", len([l for l in two_pins_art if "In3" in l]), 1) +check("operator on two pins: the coil is drawn", any("xDone" in l for l in two_pins_art)) +check("operator on two pins: the store is drawn", any("iSum" in l for l in two_pins_art)) +check( + "operator on two pins: the coil reads the box's ENO by name", + any("[ADD.ENO]" in l and "( )" in l for l in two_pins_art), +) + +# Named by type alone, two ADD boxes in one network were indistinguishable: +# "[ADD.ENO]" twice, and moving a coil from one box to the other changed +# nothing in the export. Where the text names one of two or more unnamed boxes +# of a type, each is numbered in the order it is built, on its title and in +# every reference to it. +TWO_OPERATORS = os.path.join(FIXTURES, "ld-two-operators-each-read-on-two-pins.xml") +TWO_OPERATORS_SWAPPED = os.path.join(FIXTURES, "ld-two-operators-each-read-on-two-pins-swapped.xml") +two_operators_art = render_pou(parse_pous(TWO_OPERATORS)[0]) +two_operators_swapped_art = render_pou(parse_pous(TWO_OPERATORS_SWAPPED)[0]) +check("numbered boxes: the first box is titled #1", any(l.strip(U["V"] + " ") == "ADD #1" for l in two_operators_art)) +check("numbered boxes: the second box is titled #2", any(l.strip(U["V"] + " ") == "ADD #2" for l in two_operators_art)) +check_equal( + "numbered boxes: each coil names its own box", + [ + ("[ADD#1.ENO]" in two_operators_art[i + 1], "[ADD#2.ENO]" in two_operators_art[i + 1]) + for i, l in enumerate(two_operators_art[:-1]) + if l.strip(U["V"] + " ") in ("xA", "xB") + ], + [(True, False), (False, True)], +) +check("numbered boxes: no reference is left unnumbered", not any("[ADD.ENO]" in l for l in two_operators_art)) +check("numbered boxes: swapping the coils between the boxes changes the export", two_operators_art != two_operators_swapped_art) +# A box the text names only inside a side pin's caption - here through a +# parallel branch into a counter's RESET - is named all the same, so it is +# numbered too. It was not, and rewiring the reset from one AND box to the +# other changed nothing in the export. +IN_CAPTION = os.path.join(FIXTURES, "ld-operator-named-in-a-nested-pin-caption.xml") +IN_CAPTION_SWAPPED = os.path.join(FIXTURES, "ld-operator-named-in-a-nested-pin-caption-swapped.xml") +in_caption_art = render_pou(parse_pous(IN_CAPTION)[0]) +in_caption_swapped_art = render_pou(parse_pous(IN_CAPTION_SWAPPED)[0]) +check("numbered in a caption: both boxes are numbered", any(l.strip(U["V"] + " ") == "AND #1" for l in in_caption_art) and any(l.strip(U["V"] + " ") == "AND #2" for l in in_caption_art)) +check("numbered in a caption: the caption names a numbered box", any("RESET" in l and "AND#1.Out1" in l for l in in_caption_art)) +check("numbered in a caption: moving the reset to the other box changes the export", in_caption_art != in_caption_swapped_art) + +# A single box of a type is not numbered, and neither are two that the text never names. +check("numbered boxes: a lone box keeps its plain name", not any("#" in l for l in two_pins_art)) +check("numbered boxes: boxes the text never names are not numbered", not any("#" in l for l in alike_art)) + +# A box whose side pin reads a timer was rebuilt differently for its second +# reader: the first copy hoisted the timer, the second only named it. The copies +# no longer drew the same, so the AND box was drawn twice. +SIDE_INSTANCE = os.path.join(FIXTURES, "ld-operator-with-instance-side-pin.xml") +side_instance_art = render_pou(parse_pous(SIDE_INSTANCE)[0]) +check_equal("operator with an instance side pin: the box is drawn once", len([l for l in side_instance_art if "In1 Out1" in l]), 1) +check_equal("operator with an instance side pin: the timer is drawn once", len([l for l in side_instance_art if "tmr : TON" in l]), 1) +check( + "operator with an instance side pin: both coils", + any("xA" in l for l in side_instance_art) and any("xB" in l for l in side_instance_art), +) + + # --- a side pin fed by a contact is drawn as that contact -------------------- # A reset or enable read off the rail by a contact was flattened into the pin From 0123ce77e60f0ff01fbdf13b57a6e3b385d870e0 Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Thu, 17 Sep 2026 00:28:17 +1000 Subject: [PATCH 89/91] draw every ladder box once, keep the box a hoist reads, and close label networks A box the parser built once per rung was drawn once per copy wherever the rung merge and the shared-prefix factoring could not fuse the copies: rungs of two shapes, a box hoisted into a side pin by two readers, a box both hoisted and on a rung. Per network, the first copy built is now drawn and every other copy is named by the pin it reads, with the wire that powers it, since that wire belongs to the box already drawn. The first copy built is the one kept because only it carries the instance boxes upstream of the box; a later copy names them. The set of boxes to name is widened until naming it leaves nothing drawn twice, since naming one box can stop the copies after it from fusing. Factoring now also joins adjacent branches that begin with the same node, and factors again inside a shared head, so one contact feeding several boxes is drawn once. Rung ends inside a nested parallel reach the rail. Branches that are the same wire twice, from a pin listing one source twice, factor to that wire instead of recursing without end. A box inside a parallel branch feeding a side pin is hoisted and drawn. It was named in the caption and drawn nowhere, and the caption stated the box's own enable as a term of the pin. A label arriving while another is carried, or a comment or title arriving after a carried label, closes the carried label's network. The second of two labels on empty networks was drawn as a rung of the next network. Boxes are numbered also where a box was built more than once, because the renderer may name a copy after the names are written. --- src/ld_render.py | 223 +++++++++++++++--- src/model.py | 22 +- src/parse_ld.py | 28 ++- ...behind-a-timer-hoisted-by-a-later-rung.xml | 18 ++ ...ox-behind-a-timer-read-by-a-later-rung.xml | 16 ++ ...-coils-behind-a-repeated-box-two-nodes.xml | 18 ++ ...eeding-two-coils-behind-a-repeated-box.xml | 17 ++ ...ng-and-in-side-pin-behind-a-timer-pt10.xml | 16 ++ ...on-rung-and-in-side-pin-behind-a-timer.xml | 16 ++ ...-contact-chain-shared-by-part-of-a-run.xml | 15 ++ ...eding-a-box-read-twice-and-another-box.xml | 13 + ...d-execute-read-by-a-coil-and-a-contact.xml | 12 + ...-hoisted-operator-then-read-on-its-pin.xml | 13 + .../ld-hoisted-operator-two-readers.xml | 13 + .../ld-hoisted-or-under-two-coils.xml | 12 + .../ld-label-comment-label.native.xml | 26 ++ .../ld-label-comment-label.plcopen.xml | 10 + .../ld-operator-only-in-a-nested-caption.xml | 17 ++ .../ld-operator-read-by-two-rung-shapes.xml | 12 + .../fixtures/ld-two-boxes-two-coils-each.xml | 16 ++ .../ld-two-connections-from-one-contact.xml | 7 + ...o-connections-from-one-pin-into-a-coil.xml | 10 + .../ld-two-connections-into-one-box-pin.xml | 10 + ...ld-two-labels-on-empty-networks.native.xml | 33 +++ ...d-two-labels-on-empty-networks.plcopen.xml | 12 + tools/ladder/tests/test_export.py | 33 +++ tools/ladder/tests/test_ladder.py | 142 +++++++++++ 27 files changed, 746 insertions(+), 34 deletions(-) create mode 100644 tools/ladder/tests/fixtures/ld-box-behind-a-timer-hoisted-by-a-later-rung.xml create mode 100644 tools/ladder/tests/fixtures/ld-box-behind-a-timer-read-by-a-later-rung.xml create mode 100644 tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml create mode 100644 tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box.xml create mode 100644 tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml create mode 100644 tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer.xml create mode 100644 tools/ladder/tests/fixtures/ld-contact-chain-shared-by-part-of-a-run.xml create mode 100644 tools/ladder/tests/fixtures/ld-contact-feeding-a-box-read-twice-and-another-box.xml create mode 100644 tools/ladder/tests/fixtures/ld-execute-read-by-a-coil-and-a-contact.xml create mode 100644 tools/ladder/tests/fixtures/ld-hoisted-operator-then-read-on-its-pin.xml create mode 100644 tools/ladder/tests/fixtures/ld-hoisted-operator-two-readers.xml create mode 100644 tools/ladder/tests/fixtures/ld-hoisted-or-under-two-coils.xml create mode 100644 tools/ladder/tests/fixtures/ld-label-comment-label.native.xml create mode 100644 tools/ladder/tests/fixtures/ld-label-comment-label.plcopen.xml create mode 100644 tools/ladder/tests/fixtures/ld-operator-only-in-a-nested-caption.xml create mode 100644 tools/ladder/tests/fixtures/ld-operator-read-by-two-rung-shapes.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-boxes-two-coils-each.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-connections-from-one-contact.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-connections-from-one-pin-into-a-coil.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-connections-into-one-box-pin.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.native.xml create mode 100644 tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.plcopen.xml diff --git a/src/ld_render.py b/src/ld_render.py index 4543919..ca0b7b8 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -17,7 +17,7 @@ import charset from layout import Block, centred -from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series, parallel, series +from model import BLOCK, COIL, CONTACT, IN_VARIABLE, Element, Empty, Parallel, Series, box_name, parallel, series # The letter a contact carries for edge detection, reused on a block's power # pin so both read the same. @@ -364,12 +364,16 @@ def _render_parallel(branches): stacked = [] connect_rows = [] + nested_sinks = set() for block in blocks: - connect_rows.append(len(stacked) + block.connect_row) + offset = len(stacked) + connect_rows.append(offset + block.connect_row) + nested_sinks.update(offset + row for row in block.sink_rows) for index, line in enumerate(block.lines): # The wire itself extends horizontally; everything else with # spaces, so short branches still reach the junction on the right. - fill = chars["H"] if index == block.connect_row else " " + # A rung end inside a branch is a wire too, running to the rail. + fill = chars["H"] if index == block.connect_row or index in block.sink_rows else " " stacked.append(line + fill * (width - len(line))) junctions = set(connect_rows) @@ -394,7 +398,9 @@ def _render_parallel(branches): left = right = " " lines.append((left + line) if terminal else (left + line + right)) - sink_rows = set(connect_rows) if terminal else set() + # A branch that splits again into rung ends keeps them: each still runs to + # the rail on its own. + sink_rows = (set(connect_rows) | nested_sinks) if terminal else set() return Block(lines, first, sink_rows=sink_rows) @@ -444,33 +450,61 @@ def _factor(expr): prefix of the branches produces exactly that. Power flow is unchanged: "(P AND a) OR (P AND b)" and "P AND (a OR b)" drive the same rung. - Only copies of one node are pulled out. The first copy is the one kept: - a box hoists the boxes feeding its side pins on the rung that builds it - first, and a later copy only names them. + Only copies of one node are pulled out. Branches that begin with the same + node are factored even when others beside them do not, as long as they + are next to each other: one contact feeding two boxes is one contact, and + keeping the branches in their order keeps the rung ends in the order they + execute. """ if isinstance(expr, Series): return series([_factor(item) for item in expr.items]) if isinstance(expr, Parallel): branches = [_factor(branch) for branch in expr.branches] - - def items_of(branch): - return list(branch.items) if isinstance(branch, Series) else [branch] - - parts = [items_of(branch) for branch in branches] - prefix = [] - while all(part for part in parts): - first = parts[0][0] - identity = _identity(first) - if any(_identity(part[0]) != identity for part in parts): - break - prefix.append(first) - parts = [part[1:] for part in parts] - if not prefix: - return parallel(branches) - return series(prefix + [parallel([series(part) for part in parts])]) + runs = [] + for branch in branches: + items = _items_of(branch) + head = _identity(items[0]) if items else None + if runs and head is not None and runs[-1][0] == head: + runs[-1][1].append(branch) + else: + runs.append((head, [branch])) + if len(runs) == 1: + return _factor_run(branches) + return parallel([_factor_run(run) for _head, run in runs]) return expr +def _items_of(branch): + """The elements along a branch. An empty branch has none, so no head to share.""" + if isinstance(branch, Empty): + return [] + return list(branch.items) if isinstance(branch, Series) else [branch] + + +def _factor_run(branches): + """Pull the leading elements every one of ``branches`` shares out in front.""" + if len(branches) == 1: + return branches[0] + parts = [_items_of(branch) for branch in branches] + prefix = [] + while all(part for part in parts): + first = parts[0][0] + identity = _identity(first) + if any(_identity(part[0]) != identity for part in parts): + break + prefix.append(first) + parts = [part[1:] for part in parts] + if not prefix: + return parallel(branches) + if not any(parts): + # Every branch was the same wire - one pin listing one source twice. + return series(prefix) + # What follows the shared head is a parallel of its own, and some of its + # branches can share a head again. The prefix is not empty, so this works + # on fewer elements each time and ends. + return series(prefix + [_factor(parallel([series(part) for part in parts]))]) + + def _pin_block_rungs(expr, found): """Collect the sub-rungs feeding side pins, in the order they execute. @@ -553,20 +587,151 @@ def _merge_rungs(rungs): return merged -def render_rung(expr): +def _box_key(item): + """(localId, pin) for a copy of a box with no instance name, else None. + + A box with an instance is never built twice: every reader after the first + already names it. + """ + if isinstance(item, Element) and item.kind == BLOCK and item.local_id is not None and not item.instance_name: + return (item.local_id, item.active_output) + return None + + +def _box_keys(expr, found): + """Every box key on the wires of ``expr``, in drawing order, repeats kept. + + The boxes hoisted into a box's side pins are drawn as wires of their own, + so they are not on this wire and are not counted here. + """ + if isinstance(expr, Series): + for item in expr.items: + _box_keys(item, found) + elif isinstance(expr, Parallel): + for branch in expr.branches: + _box_keys(branch, found) + else: + key = _box_key(expr) + if key is not None: + found.append(key) + return found + + +def _reference_to(box): + """The pin of a box drawn elsewhere, named where a copy of the box would be.""" + pin = box.active_output + base = box_name(box) + return Element( + kind=IN_VARIABLE, + label=(base + "." + pin) if pin else base, + negated=pin in box.negated_outputs, + local_id=box.local_id, + ) + + +def _keep_first(expr, repeat): + """``expr`` with every copy of a box in ``repeat`` but the first built replaced. + + The first copy the parser built is kept, wherever it is drawn: only that + copy carries the instance boxes upstream of the box, because each later + build finds them built and names them. Keeping any other copy deleted + them. A replaced copy takes the wire that powers it with it: that wire is + the input of the box, drawn once with the box, and left in front of the + name it would state a condition the program does not have. + """ + if isinstance(expr, Series): + out = [] + produced = [] + for index, item in enumerate(expr.items): + key = _box_key(item) + if key is not None and key in repeat and item.copy: + power = min(item.power_len, index) + dropped = sum(produced[index - power : index]) + if dropped: + del out[len(out) - dropped :] + for position in range(index - power, index): + produced[position] = 0 + out.append(_reference_to(item)) + produced.append(1) + continue + out.append(_keep_first(item, repeat)) + produced.append(1) + return series(out) + if isinstance(expr, Parallel): + return parallel([_keep_first(branch, repeat) for branch in expr.branches]) + key = _box_key(expr) + if key is not None and key in repeat and expr.copy: + return _reference_to(expr) + return expr + + +def _last_box(wire): + """The box a hoisted wire ends in: the one the side pin reads.""" + return wire.items[-1] if isinstance(wire, Series) else wire + + +def _drawn_twice(rungs, repeat): + """The boxes the rungs would draw more than once, with ``repeat`` named. + + Counted over the whole network, on the rungs as laid out and on every + hoisted wire the rungs draw: copies that the rung merge or the + shared-prefix factoring draw as one box count once. + """ + counts = {} + for rung in rungs: + rung = _keep_first(rung, repeat) + wires = [_factor(rung)] + for pin_block in _pin_block_rungs(rung, []): + last = _last_box(pin_block) + if not (_box_key(last) in repeat and last.copy): + wires.append(pin_block) + for wire in wires: + for key in _box_keys(wire, []): + counts[key] = counts.get(key, 0) + 1 + return set(key for key in counts if counts[key] > 1) + + +def _network_repeats(rungs): + """The boxes to name rather than draw again, for one network. + + Naming a copy takes its wire with it, and that can stop the copies of the + box after it from sharing a head, so they are drawn twice in turn. The set + is widened until naming it leaves nothing drawn twice. It only grows, and + there are finitely many boxes, so this ends. + """ + repeat = set() + while True: + wider = repeat | _drawn_twice(rungs, repeat) + if wider == repeat: + return repeat + repeat = wider + + +def render_rung(expr, repeat=None): """Render one rung, bounded by the power rails. Boxes feeding side pins are drawn first, on wires of their own: the caption that reads one names only its output, so without the box the diagram would not say what feeds it. An EXECUTE box's inline ST is drawn inside the box, below its pins, by _render_block. + + ``repeat`` holds the boxes the network would otherwise draw more than once + (see _network_repeats). Every copy of one but the first built is named by + its pin, so one box in the editor is one box on the page. """ + if repeat is None: + repeat = _network_repeats([expr]) lines = [] for pin_block in _pin_block_rungs(expr, []): - lines.extend(_render_wire(pin_block)) + # Every reader of a hoisted box carries the hoist; the first built + # draws the box, and the pin's caption names it for the rest. + last = _last_box(pin_block) + if _box_key(last) in repeat and last.copy: + continue + lines.extend(_render_wire(_keep_first(pin_block, repeat))) # Draw the shared head of parallel branches once, then the split - the way # the editor draws it - instead of repeating it down every branch. - lines.extend(_render_wire(_factor(expr))) + lines.extend(_render_wire(_factor(_keep_first(expr, repeat)))) return lines @@ -625,8 +790,10 @@ def render_pou(pou): for index, network in enumerate(pou.networks): lines.extend(network_headers(index + 1, network)) - for rung in _merge_rungs(network.outputs): - lines.extend(render_rung(rung)) + rungs = _merge_rungs(network.outputs) + repeat = _network_repeats(rungs) + for rung in rungs: + lines.extend(render_rung(rung, repeat)) lines.append("") while lines and lines[-1] == "": diff --git a/src/model.py b/src/model.py index c7043bc..0ff4f4a 100644 --- a/src/model.py +++ b/src/model.py @@ -237,7 +237,10 @@ def assemble_networks(nodes, root_of, outputs_by_root, label_roots=()): for node in nodes: if node.kind in (COMMENT, TITLE): index = 0 if node.kind == COMMENT else 1 - if header[index] is not None: + # A header comes before its network's label, so one arriving after a + # carried label heads the next network: the label's ends here. + carries_label = any(label_name(tree) is not None for tree in carried) + if header[index] is not None or carries_label: networks.append(_network(header, list(carried))) del carried[:] header = [None, None] @@ -249,6 +252,13 @@ def assemble_networks(nodes, root_of, outputs_by_root, label_roots=()): continue seen.add(root) if root in label_roots: + # A network has one label. A second arriving before any logic means + # the first labelled a network with none, which ends here - carried + # on, the second label was left in the next network's body. + if any(label_name(tree) is not None for tree in carried): + networks.append(_network(header, list(carried))) + del carried[:] + header = [None, None] carried.extend(outputs_by_root[root]) continue @@ -536,10 +546,20 @@ def __init__( pin_marks=None, local_id=None, ordinal=None, + power_len=0, + copy=0, ): self.kind = kind + # Blocks only: 0 for the first copy of the box the parser built, 1 for + # the next, and so on. Only the first carries the instance boxes + # upstream of it; a later copy names them. See ld_render._keep_first. + self.copy = copy # Blocks only; see box_name. self.ordinal = ordinal + # Blocks only: how many items directly before the box in its rung are + # the wire that powers it. A later copy of the box is replaced by a + # reference, and that wire goes with it, since it belongs to the box. + self.power_len = power_len # The localId of the PLCopen node this element was built from. The # parser builds one branch per sink, so one node can arrive as several # copies; this is what says they are one element in the editor. Two diff --git a/src/parse_ld.py b/src/parse_ld.py index 1ad59da..63e191a 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -259,9 +259,12 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): if not node.instance_name: drawn.named.add(node.local_id) return _block_reference(node, via_pin) + drawn.copied.add(node.local_id) else: drawn.order[node.local_id] = len(drawn.order) drawn.pins[node.local_id] = read_pin + copy = drawn.builds.get(node.local_id, 0) + drawn.builds[node.local_id] = copy + 1 power_expr = Empty() power_pin = None @@ -368,6 +371,8 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): pin_blocks=pin_blocks, local_id=node.local_id, ordinal=node.ordinal, + power_len=len(power_expr.items) if isinstance(power_expr, Series) else (0 if isinstance(power_expr, Empty) else 1), + copy=copy, ) return series([power_expr, element]) @@ -393,10 +398,15 @@ def _pin_expr_text(expr, hoisted): if isinstance(item, Element) and item.kind == BLOCK: cut = index if cut < 0: - return expr_to_text(expr) + # No box directly on this wire, but a parallel branch on it can + # hold one, and that box is hoisted the same way. Flattening the + # branch whole named the box and never drew it. + parts = [part for part in (_pin_expr_text(item, hoisted) for item in items) if part] + return " AND ".join(parts) hoisted.append(series(items[: cut + 1])) - parts = [part for part in (expr_to_text(item) for item in items[cut:]) if part] - return " AND ".join(parts) + parts = [expr_to_text(items[cut])] + parts += [part for part in (_pin_expr_text(item, hoisted) for item in items[cut + 1 :]) if part] + return " AND ".join([part for part in parts if part]) if isinstance(expr, Parallel): parts = [part for part in (_pin_expr_text(branch, hoisted) for branch in expr.branches) if part] return "(" + " OR ".join(parts) + ")" @@ -477,13 +487,21 @@ def __init__(self): # localIds of boxes with no instance name that the text names - in a # reference to a pin, or anywhere in a side pin's caption self.named = set() + # localIds of boxes built more than once, for readers of one pin. The + # renderer draws the copies as one box where they fuse, and names the + # rest by their pin - so these may be named in the text as well. + self.copied = set() + # {localId: how many copies of the block have been built} + self.builds = {} def _number_named_boxes(logic, find, built): """Give an ordinal to each box that its type alone would not identify. Only in a network holding two or more boxes of one type with no instance - name, and only where the text names one of them; everywhere else a box is + name, and only where the text names one of them or may name one: a box + built more than once can be named by the renderer, which decides that only + when it draws, after the names are written. Everywhere else a box is written as it always was. Numbered in the order the boxes are first built, which is fixed by the export and so stable from one export to the next. Returns True when any box was numbered, so the networks must be rebuilt. @@ -495,7 +513,7 @@ def _number_named_boxes(logic, find, built): numbered = False for group in groups.values(): - if len(group) < 2 or not any(node.local_id in built.named for node in group): + if len(group) < 2 or not any(node.local_id in built.named or node.local_id in built.copied for node in group): continue # A box never built draws nowhere; it goes last, in export order. group.sort(key=lambda node: (node.local_id not in built.order, built.order.get(node.local_id, 0))) diff --git a/tools/ladder/tests/fixtures/ld-box-behind-a-timer-hoisted-by-a-later-rung.xml b/tools/ladder/tests/fixtures/ld-box-behind-a-timer-hoisted-by-a-later-rung.xml new file mode 100644 index 0000000..ac4b6d8 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-behind-a-timer-hoisted-by-a-later-rung.xml @@ -0,0 +1,18 @@ + + + +xGo +iA +iB +T#5S + +xA + + +xM + +xB + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-box-behind-a-timer-read-by-a-later-rung.xml b/tools/ladder/tests/fixtures/ld-box-behind-a-timer-read-by-a-later-rung.xml new file mode 100644 index 0000000..9848275 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-behind-a-timer-read-by-a-later-rung.xml @@ -0,0 +1,16 @@ + + + +xGo +iA +iB +T#5S + +xA + + +xB + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml b/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml new file mode 100644 index 0000000..69eb6ad --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml @@ -0,0 +1,18 @@ + + + +xGo +iA +iB +iC +iD + + + +xA +xB +xM + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box.xml b/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box.xml new file mode 100644 index 0000000..ba50016 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box.xml @@ -0,0 +1,17 @@ + + + +xGo +iA +iB +iC +iD + + +xA +xB +xM + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml b/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml new file mode 100644 index 0000000..ff52edb --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml @@ -0,0 +1,16 @@ + + + +xGo +T#10S + +iA +iB +iC +iD + + + +xA + + diff --git a/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer.xml b/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer.xml new file mode 100644 index 0000000..5715319 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer.xml @@ -0,0 +1,16 @@ + + + +xGo +T#5S + +iA +iB +iC +iD + + + +xA + + diff --git a/tools/ladder/tests/fixtures/ld-contact-chain-shared-by-part-of-a-run.xml b/tools/ladder/tests/fixtures/ld-contact-chain-shared-by-part-of-a-run.xml new file mode 100644 index 0000000..f7ed6ab --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-chain-shared-by-part-of-a-run.xml @@ -0,0 +1,15 @@ + + + +c1 +c2 +c3 +A +c4 +B +c5 +C +c9 +D + + diff --git a/tools/ladder/tests/fixtures/ld-contact-feeding-a-box-read-twice-and-another-box.xml b/tools/ladder/tests/fixtures/ld-contact-feeding-a-box-read-twice-and-another-box.xml new file mode 100644 index 0000000..cfe7ccf --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-feeding-a-box-read-twice-and-another-box.xml @@ -0,0 +1,13 @@ + + + +xGo +iA +iB + +xA +xB + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-execute-read-by-a-coil-and-a-contact.xml b/tools/ladder/tests/fixtures/ld-execute-read-by-a-coil-and-a-contact.xml new file mode 100644 index 0000000..d99a6dc --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-execute-read-by-a-coil-and-a-contact.xml @@ -0,0 +1,12 @@ + + + +xGo +executenA := nA + 1; +executenB := nB + 1; +xA +xB +xK +xC + + diff --git a/tools/ladder/tests/fixtures/ld-hoisted-operator-then-read-on-its-pin.xml b/tools/ladder/tests/fixtures/ld-hoisted-operator-then-read-on-its-pin.xml new file mode 100644 index 0000000..e2b2a7c --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-hoisted-operator-then-read-on-its-pin.xml @@ -0,0 +1,13 @@ + + + +xGo +iA +iB + +xK + +xA +iSum + + diff --git a/tools/ladder/tests/fixtures/ld-hoisted-operator-two-readers.xml b/tools/ladder/tests/fixtures/ld-hoisted-operator-two-readers.xml new file mode 100644 index 0000000..3601167 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-hoisted-operator-two-readers.xml @@ -0,0 +1,13 @@ + + + +xGo +iA +iB +iC + + +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-hoisted-or-under-two-coils.xml b/tools/ladder/tests/fixtures/ld-hoisted-or-under-two-coils.xml new file mode 100644 index 0000000..60c42dc --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-hoisted-or-under-two-coils.xml @@ -0,0 +1,12 @@ + + + +xGo +xP +xQ + + +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-label-comment-label.native.xml b/tools/ladder/tests/fixtures/ld-label-comment-label.native.xml new file mode 100644 index 0000000..9b5a711 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-label-comment-label.native.xml @@ -0,0 +1,26 @@ + + + + + + + LA + False + + + + second one + + LB + False + + + + + + + False + Contact + + + diff --git a/tools/ladder/tests/fixtures/ld-label-comment-label.plcopen.xml b/tools/ladder/tests/fixtures/ld-label-comment-label.plcopen.xml new file mode 100644 index 0000000..efff749 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-label-comment-label.plcopen.xml @@ -0,0 +1,10 @@ + + + +second one + + +xA +oA + + diff --git a/tools/ladder/tests/fixtures/ld-operator-only-in-a-nested-caption.xml b/tools/ladder/tests/fixtures/ld-operator-only-in-a-nested-caption.xml new file mode 100644 index 0000000..a580098 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-only-in-a-nested-caption.xml @@ -0,0 +1,17 @@ + + + +xGo +iA +iB +iC + + +iSum +xC +xM +xE + +iProd + + diff --git a/tools/ladder/tests/fixtures/ld-operator-read-by-two-rung-shapes.xml b/tools/ladder/tests/fixtures/ld-operator-read-by-two-rung-shapes.xml new file mode 100644 index 0000000..19aead6 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-read-by-two-rung-shapes.xml @@ -0,0 +1,12 @@ + + + +xGo +iA +iB + +xA +xK +xB + + diff --git a/tools/ladder/tests/fixtures/ld-two-boxes-two-coils-each.xml b/tools/ladder/tests/fixtures/ld-two-boxes-two-coils-each.xml new file mode 100644 index 0000000..d443d74 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-boxes-two-coils-each.xml @@ -0,0 +1,16 @@ + + + +xGo +iA +iB + +xA +xB + +xC +xD +xK +xE + + diff --git a/tools/ladder/tests/fixtures/ld-two-connections-from-one-contact.xml b/tools/ladder/tests/fixtures/ld-two-connections-from-one-contact.xml new file mode 100644 index 0000000..8528acd --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-connections-from-one-contact.xml @@ -0,0 +1,7 @@ + + + +c +xA + + diff --git a/tools/ladder/tests/fixtures/ld-two-connections-from-one-pin-into-a-coil.xml b/tools/ladder/tests/fixtures/ld-two-connections-from-one-pin-into-a-coil.xml new file mode 100644 index 0000000..584cf36 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-connections-from-one-pin-into-a-coil.xml @@ -0,0 +1,10 @@ + + + +c +iA +iB + +xA + + diff --git a/tools/ladder/tests/fixtures/ld-two-connections-into-one-box-pin.xml b/tools/ladder/tests/fixtures/ld-two-connections-into-one-box-pin.xml new file mode 100644 index 0000000..f902193 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-connections-into-one-box-pin.xml @@ -0,0 +1,10 @@ + + + +c +iA +iB + +xA + + diff --git a/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.native.xml b/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.native.xml new file mode 100644 index 0000000..635137d --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.native.xml @@ -0,0 +1,33 @@ + + + + + + + + False + Contact + + + + + LONELY1 + False + + + + + + LONELY2 + False + + + + + + + False + Contact + + + diff --git a/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.plcopen.xml b/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.plcopen.xml new file mode 100644 index 0000000..89b58e3 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.plcopen.xml @@ -0,0 +1,12 @@ + + + +xGo + + + + +xA +oA + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 8ca2b9d..08f4a74 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -512,6 +512,39 @@ def __init__(self, outputs): check_equal("labelled: and under its header", unlabelled[unlabelled.index("(* Network 2 *)") + 1], "LATER:") graphical_export.reset_stats() +# Two empty labelled networks in a row, with no comment element between them. +# Both label elements were carried to the next network with logic; the first +# became its label and the second stayed in its body, drawn as a rung. A label +# arriving while one is already carried means the first headed a network with +# no logic, and that network is closed there. +TWO_LABELS = os.path.join(HERE, "fixtures", "ld-two-labels-on-empty-networks.plcopen.xml") +TWO_LABELS_NATIVE = os.path.join(HERE, "fixtures", "ld-two-labels-on-empty-networks.native.xml") +graphical_export.reset_stats() +two_labels = graphical_export.render_plcopen(TWO_LABELS, None, None, TWO_LABELS_NATIVE) +check("two labels: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in two_labels) +check_equal("two labels: one header per editor network", len([l for l in two_labels if l.startswith("(* Network ")]), 4) +check_equal("two labels: each label is written once", [two_labels.count("LONELY1:"), two_labels.count("LONELY2:")], [1, 1]) +check("two labels: no label is drawn as a rung", not any("LONELY" in l and ":" in l and l not in ("LONELY1:", "LONELY2:") for l in two_labels)) +fourth = two_labels.index("(* Network 4 *)") +check("two labels: the network with logic carries no label", "xA" in two_labels[fourth + 1]) +two_labels_bare = graphical_export.render_plcopen(TWO_LABELS, None, None, None) +check_equal("two labels: without the native list each label is written once", [two_labels_bare.count("LONELY1:"), two_labels_bare.count("LONELY2:")], [1, 1]) +check("two labels: without the native list no label is drawn as a rung", not any("LONELY" in l and ":" in l and l not in ("LONELY1:", "LONELY2:") for l in two_labels_bare)) +graphical_export.reset_stats() + +# CODESYS writes a network's comment before its label. A comment arriving while +# a label is carried therefore heads the next network, and the carried label's +# network ends first. Closing it only at the next label gave it the comment. +COMMENT_BETWEEN = os.path.join(HERE, "fixtures", "ld-label-comment-label.plcopen.xml") +COMMENT_BETWEEN_NATIVE = os.path.join(HERE, "fixtures", "ld-label-comment-label.native.xml") +graphical_export.reset_stats() +for native, name in ((COMMENT_BETWEEN_NATIVE, "with"), (None, "without")): + between = graphical_export.render_plcopen(COMMENT_BETWEEN, None, None, native) + headed = [between[i + 1] for i, l in enumerate(between) if l.startswith("(* Network ") and "second one" in l] + check_equal("comment between labels, " + name + " the native list: the comment heads the LB network", headed, ["LB:"]) + check("comment between labels, " + name + " the native list: LA carries no comment", not any(l.startswith("(* Network ") and "second one" in l and between[i + 1] == "LA:" for i, l in enumerate(between))) +graphical_export.reset_stats() + # --- the label of an out-commented network ----------------------------------- diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 31f052e..4341ad0 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -671,6 +671,148 @@ def _contact(name): check("numbered boxes: a lone box keeps its plain name", not any("#" in l for l in two_pins_art)) check("numbered boxes: boxes the text never names are not numbered", not any("#" in l for l in alike_art)) + +# --- one box in the editor is one box in the drawing ------------------------- + +# The parser builds a copy of a box for every rung that reaches it, and relies on +# the rung merge and the shared-prefix factoring to draw the copies as one. Where +# they cannot fuse - the copies sit in rungs of different shapes, two readers +# hoist one box into a side pin, or a hoisted box is also read on a rung - the +# box was drawn once per copy. The first copy in drawing order is now drawn, and +# each later copy is named by the pin it reads, without the wire that powers it: +# that wire belongs to the box already drawn. +def title_rows(art, title): + """How many box titles in ``art`` read exactly ``title``. + + Titles of boxes side by side share a row, separated by runs of spaces. + """ + import re + + count = 0 + for line in art: + for part in re.split(r"\s{2,}", line.strip(U["V"] + " ")): + if part == title: + count += 1 + return count + + +def box_art(name): + return render_pou(parse_pous(os.path.join(FIXTURES, name))[0]) + + +hoisted_two_art = box_art("ld-hoisted-operator-two-readers.xml") +check_equal("one box: a box hoisted by two readers is drawn once", title_rows(hoisted_two_art, "MUL"), 1) + +hoisted_then_art = box_art("ld-hoisted-operator-then-read-on-its-pin.xml") +check_equal("one box: a box on a rung and hoisted into a pin is drawn once", title_rows(hoisted_then_art, "ADD"), 1) +check("one box: the rung's store is still drawn", any("iSum" in l for l in hoisted_then_art)) +check("one box: the pin still reads the box", any("ADD.Out1" + U["H"] * 2 + U["PIN_L"] + "In2" in l for l in hoisted_then_art)) + +shapes_art = box_art("ld-operator-read-by-two-rung-shapes.xml") +check_equal("one box: a box read by rungs of two shapes is drawn once", title_rows(shapes_art, "ADD"), 1) +check("one box: the second rung reads the box by name", any("[ADD.ENO]" in l for l in shapes_art)) +check("one box: both coils are drawn", any("xA" in l for l in shapes_art) and any("xB" in l for l in shapes_art)) +check("one box: the parallel contact is kept", any("xK" in l for l in shapes_art)) + +or_art = box_art("ld-hoisted-or-under-two-coils.xml") +check_equal("one box: a hoisted OR under two coils is drawn once", title_rows(or_art, "OR"), 1) +check_equal("one box: the box that reads it is drawn once", title_rows(or_art, "AND"), 1) + +exec_mixed_art = box_art("ld-execute-read-by-a-coil-and-a-contact.xml") +check_equal("one box: two EXECUTE boxes are drawn", title_rows(exec_mixed_art, "EXECUTE #1") + title_rows(exec_mixed_art, "EXECUTE #2"), 2) +check_equal("one box: the first body once", len([l for l in exec_mixed_art if "nA := nA + 1;" in l]), 1) +check_equal("one box: the second body once", len([l for l in exec_mixed_art if "nB := nB + 1;" in l]), 1) +check("one box: the contact's rung reads the first box by name", any("[EXECUTE#1.ENO]" in l and "xK" not in l for l in exec_mixed_art)) +check_equal("one box: the contact feeding both boxes is drawn once", len([l for l in exec_mixed_art if "xGo" in l]), 1) +check_equal( + "one box: every coil still reaches the right rail", + len([l for l in exec_mixed_art if "( )" in l and l.rstrip().endswith(U["T_LEFT"])]), + 3, +) + +# The copy of a box that is kept must be the first one built. Only that copy +# carries the instance boxes upstream of it: every later copy names them, as +# "[tmr.Q]". Keeping the first copy drawn instead deleted the timer, its contact +# and its preset from the export, and two programs that differ only in the +# preset exported identically. +behind_timer_art = box_art("ld-box-on-rung-and-in-side-pin-behind-a-timer.xml") +behind_timer_pt10_art = box_art("ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml") +check_equal("first build: the timer is drawn", title_rows(behind_timer_art, "tmr : TON"), 1) +check("first build: the timer's contact is drawn", any("xGo" in l for l in behind_timer_art)) +check("first build: the timer's preset is drawn", any("T#5S" in l for l in behind_timer_art)) +check_equal("first build: the box behind it is drawn once", title_rows(behind_timer_art, "GT"), 1) +check("first build: a different preset changes the export", behind_timer_art != behind_timer_pt10_art) +check_equal("first build: a later rung merged forward keeps the timer", title_rows(box_art("ld-box-behind-a-timer-read-by-a-later-rung.xml"), "tmr : TON"), 1) +check_equal("first build: a later rung's hoist keeps the timer", title_rows(box_art("ld-box-behind-a-timer-hoisted-by-a-later-rung.xml"), "tmr : TON"), 1) + +# Naming one repeated box can break the fusion of the box after it: the copies +# of MUL no longer shared a head once one copy of ADD was named, and MUL was +# drawn twice, exporting like a program with two MUL boxes. The repeats are +# found again until nothing new is drawn twice. +behind_repeat_art = box_art("ld-box-feeding-two-coils-behind-a-repeated-box.xml") +check_equal("repeats: the box behind a named box is drawn once", title_rows(behind_repeat_art, "MUL"), 1) +check_equal("repeats: the named box is drawn once", title_rows(behind_repeat_art, "ADD"), 1) +check("repeats: one MUL and two MUL boxes export differently", behind_repeat_art != box_art("ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml")) + + +def label_count(art, name): + """How many times a contact or coil label appears in ``art``.""" + import re + + return sum(len(re.findall(r"(? Date: Thu, 17 Sep 2026 00:28:36 +1000 Subject: [PATCH 90/91] give an FBD in-out pin its own row, and join a box read on two pins A VAR_IN_OUT pin read downstream had no row on the right of the box. Its reader hung off the first output pin, and which reader took which row followed set iteration order, so one project exported differently from run to run. The pin now runs through the box to its own row, and the junction sorts break ties by pin name. A box with no instance, read on two pins with one pin read more than once, went to the single-column fan-out, which put the other pin's reader on the box's bottom border. It goes to the joined layout, as an instance does. Where no instance box is shared, the joined layout also takes an unnamed box read on two or more pins. Tests pin that a box tees every pin something reads, also where every reader names the pin in text: that tee is the one mark that the reader takes the box's pin rather than a variable of the same name, and that a copy of an operator is the same box. --- src/fbd_render.py | 71 +++++++++++++++---- .../fbd-function-read-on-two-output-pins.xml | 8 +++ ...fbd-function-read-on-two-pins-one-node.xml | 8 +++ ...bd-function-read-on-two-pins-two-nodes.xml | 10 +++ .../fbd-in-out-pin-read-downstream.xml | 12 ++++ ...d-timer-pin-read-from-a-variable-twice.xml | 32 +++++++++ .../fbd-timer-pin-wired-to-two-readers.xml | 31 ++++++++ tools/ladder/tests/test_fbd.py | 66 +++++++++++++++++ 8 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 tools/ladder/tests/fixtures/fbd-function-read-on-two-output-pins.xml create mode 100644 tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-one-node.xml create mode 100644 tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-two-nodes.xml create mode 100644 tools/ladder/tests/fixtures/fbd-in-out-pin-read-downstream.xml create mode 100644 tools/ladder/tests/fixtures/fbd-timer-pin-read-from-a-variable-twice.xml create mode 100644 tools/ladder/tests/fixtures/fbd-timer-pin-wired-to-two-readers.xml diff --git a/src/fbd_render.py b/src/fbd_render.py index 01febc5..daf5868 100644 --- a/src/fbd_render.py +++ b/src/fbd_render.py @@ -124,14 +124,31 @@ def _render_call(call, read_pin, drawn, subs=None): left.append(line + fill * (left_width - len(line))) input_rows = list(pin_rows) + + # A VAR_IN_OUT pin arrives among the inputs, and a wire can still leave it + # on the right. It runs through the box and leaves level with where it + # enters, so that row is its own, and no output pin is placed on it. + names = [pin for pin, _assigned in call.outputs] + through_rows = {} + for index, pin_and_source in enumerate(call.inputs): + pin = pin_and_source[0] + if pin is not None and pin in call.wired_outputs and pin not in names: + through_rows[pin] = input_rows[index] + reserved = set(through_rows.values()) + output_rows = [] for index in range(len(call.outputs)): if index < len(input_rows): - output_rows.append(input_rows[index]) + row = input_rows[index] else: # More outputs than inputs: the surplus hangs below the last pin. base = input_rows[-1] if input_rows else -1 - output_rows.append(base + index - len(input_rows) + 1) + row = base + index - len(input_rows) + 1 + if output_rows: + row = max(row, output_rows[-1] + 1) + while row in reserved: + row += 1 + output_rows.append(row) all_rows = (input_rows + output_rows) or [0] box_first, box_last = min(all_rows), max(all_rows) @@ -143,6 +160,8 @@ def _render_call(call, read_pin, drawn, subs=None): left = [" " * left_width] * shift + left input_rows = [row + shift for row in input_rows] output_rows = [row + shift for row in output_rows] + for pin in through_rows: + through_rows[pin] += shift box_first += shift box_last += shift @@ -192,12 +211,16 @@ def _render_call(call, read_pin, drawn, subs=None): # An output pin only breaks the box wall with a tee if a consumer is # actually there to receive it - and a box read through two pins breaks - # it twice. + # it twice. The tee stays where every reader names the pin in text: it is + # the one mark that a reader takes the box's pin and not a variable of the + # same name, and that a copy of an operator is the same box. pins = [pin for pin, _assigned in call.outputs] live_output_rows = set() for pin in call.wired_outputs: if pin in pins: live_output_rows.add(output_rows[pins.index(pin)]) + live_output_rows.update(through_rows.values()) + through = set(through_rows.values()) lines = [] for row in range(height): @@ -214,7 +237,8 @@ def _render_call(call, read_pin, drawn, subs=None): wired_out = row in live_output_rows or row in tail_at right_edge = chars["PIN_R"] if wired_out else chars["V"] gap = inner - len(left_pin) - len(right_pin) - box = left_edge + left_pin + " " * gap + right_pin + right_edge + tail_at.get(row, "") + fill = chars["H"] if row in through else " " + box = left_edge + left_pin + fill * gap + right_pin + right_edge + tail_at.get(row, "") elif box_last < row <= body_last: # A body line of an EXECUTE box, inside the box below the pins. text = code[row - box_last - 1] @@ -227,6 +251,7 @@ def _render_call(call, read_pin, drawn, subs=None): pin_rows = {} for index, pin in enumerate(pins): pin_rows[pin] = output_rows[index] + pin_rows.update(through_rows) wanted = read_pin if read_pin is not None else call.active_output connect_row = box_first @@ -448,9 +473,13 @@ def _shared_source(outputs): # reader in a single column and pushes a lower pin's wire onto whatever # row is free, which for a timer read on Q and ET lands the ET store on # the box's bottom border. Hand those to _render_joined instead. - pins = set(source.pin for source in sources if isinstance(source, OutputRef)) - if len(pins) > 1 and getattr(boxes[0], "instance_name", None): - return None + # The same holds for a box with no instance once a pin has two readers: + # those take consecutive rows, and a lower pin's reader is pushed below them. + read = [source.pin for source in sources if isinstance(source, OutputRef)] + pins = set(read) + if len(pins) > 1: + if getattr(boxes[0], "instance_name", None) or len(read) > len(pins): + return None return sources[0] @@ -458,9 +487,12 @@ def _shared_call(outputs): """The one box this network reads from more than one place, or None. A box read twice is one box that runs once, and the second reader is a - branch off its pin - not a second copy, and not a name in text. Only an + branch off its pin - not a second copy, and not a name in text. An instance qualifies: an operator has no name, no state, and nothing is - gained by joining two copies of it. + gained by joining two copies of it read on one pin. An operator read on two + different pins is the exception - no single wire can carry both, and the + fan-out that took it put the second pin's reader on the box's bottom + border - so where no instance is shared, it qualifies too. More than one shared box in a network needs a real two-dimensional layout, which this renderer does not have; those fall back to naming the @@ -468,10 +500,13 @@ def _shared_call(outputs): """ counts = {} order = [] + pins_read = {} def walk(node): call = node.call if isinstance(node, OutputRef) else node if isinstance(call, Call): + pin = node.pin if isinstance(node, OutputRef) else call.active_output + pins_read.setdefault(id(call), set()).add(pin) if id(call) in counts: counts[id(call)] += 1 return @@ -491,6 +526,8 @@ def walk(node): walk(tree) shared = [call for call in order if counts[id(call)] > 1 and call.instance_name] + if not shared: + shared = [call for call in order if counts[id(call)] > 1 and len(pins_read[id(call)]) > 1] return shared[0] if len(shared) == 1 else None @@ -551,6 +588,16 @@ def _split_readers(outputs, call): return readers, others +def _pin_order(pin): + """A tie-breaker for pins that share a row, so a layout never follows set order. + + Two pins share a row only when one has no row of its own on the box. Set and + dict order then decided the layout, and that order differs between runs and + between interpreters. + """ + return "" if pin is None else pin + + def _place_branches(branches, pin_rows, default_row): """{index: top row} for each composed branch. @@ -569,7 +616,7 @@ def row_of(pin): top_pin.append(min(entries, key=lambda entry: (row_of(entry[1]), entry[0]))[1]) order = [] - for pin in sorted(set(top_pin), key=row_of): + for pin in sorted(set(top_pin), key=lambda pin: (row_of(pin), _pin_order(pin))): group = [index for index, read in enumerate(top_pin) if read == pin] group.sort(key=lambda index: len(set(read for _row, read in branches[index][1])) > 1) order.extend(group) @@ -654,7 +701,7 @@ def row_of(pin): reader_rows = _reader_rows(branches, tops) last = None - for pin in sorted(reader_rows, key=row_of): + for pin in sorted(reader_rows, key=lambda pin: (row_of(pin), _pin_order(pin))): rows = reader_rows[pin] if rows[0] < row_of(pin): return True @@ -704,7 +751,7 @@ def row_of(pin): reader_rows = _reader_rows(branches, tops) # Inner to outer: the lowest pin nearest the box, so that no column has # to be crossed by a wire leaving the box above it. - columns = sorted(reader_rows, key=row_of, reverse=True) + columns = sorted(reader_rows, key=lambda pin: (row_of(pin), _pin_order(pin)), reverse=True) leaves = set(row_of(pin) for pin in columns) lines = [" " * source.width] * shift + list(source.lines) diff --git a/tools/ladder/tests/fixtures/fbd-function-read-on-two-output-pins.xml b/tools/ladder/tests/fixtures/fbd-function-read-on-two-output-pins.xml new file mode 100644 index 0000000..8a684e3 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-function-read-on-two-output-pins.xml @@ -0,0 +1,8 @@ + + +xIn + +xA +xB +nC + diff --git a/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-one-node.xml b/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-one-node.xml new file mode 100644 index 0000000..4a09072 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-one-node.xml @@ -0,0 +1,8 @@ + + +xIn + +s2 + +s1 + diff --git a/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-two-nodes.xml b/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-two-nodes.xml new file mode 100644 index 0000000..d6faf10 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-two-nodes.xml @@ -0,0 +1,10 @@ + + +xIn + +xIn + +s2 + +s1 + diff --git a/tools/ladder/tests/fixtures/fbd-in-out-pin-read-downstream.xml b/tools/ladder/tests/fixtures/fbd-in-out-pin-read-downstream.xml new file mode 100644 index 0000000..a95c8ae --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-in-out-pin-read-downstream.xml @@ -0,0 +1,12 @@ + + +xStart +arrData + + + + + +xDone +arrCopy + diff --git a/tools/ladder/tests/fixtures/fbd-timer-pin-read-from-a-variable-twice.xml b/tools/ladder/tests/fixtures/fbd-timer-pin-read-from-a-variable-twice.xml new file mode 100644 index 0000000..c0360e2 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-timer-pin-read-from-a-variable-twice.xml @@ -0,0 +1,32 @@ + + +xIn +T#1s + + + + + + + + + + + + + + + + +s1 + + + + + + + + +s2 +tmr.ET + diff --git a/tools/ladder/tests/fixtures/fbd-timer-pin-wired-to-two-readers.xml b/tools/ladder/tests/fixtures/fbd-timer-pin-wired-to-two-readers.xml new file mode 100644 index 0000000..46ceffd --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-timer-pin-wired-to-two-readers.xml @@ -0,0 +1,31 @@ + + +xIn +T#1s + + + + + + + + + + + + + + + + +s1 + + + + + + + + +s2 + diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 644c42c..1ea4ee7 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -703,6 +703,72 @@ def wire_rows(art): check("numbered execute boxes: a lone box is not numbered", not any("#" in l for l in execute_two_art)) +# --- a VAR_IN_OUT pin read downstream ---------------------------------------- + +# An in-out pin arrives among the inputs and not the outputs, so a wire read +# from it had no row on the right of the box: it was hung off the first output +# pin's row, and which reader took which row followed set iteration order, so +# one project exported differently from one run to the next. The pin now runs +# through the box to its own row on the right, where its readers attach. +IN_OUT = os.path.join(HERE, "fixtures", "fbd-in-out-pin-read-downstream.xml") +in_out_art = fbd_render.render_network(parse_fbd.parse_pous(IN_OUT)[0].networks[0]) +check( + "in-out pin: its reader leaves on the pin's own row", + any(U["PIN_L"] + "pBuf" in l and l.rstrip().endswith("> arrCopy") for l in in_out_art), +) +check("in-out pin: the output's reader stays on the output's row", any("Q" + U["PIN_R"] in l and l.rstrip().endswith("> xDone") for l in in_out_art)) +check("in-out pin: the pin runs through the box", any("pBuf" + U["H"] in l and U["PIN_R"] in l for l in in_out_art)) + + +# --- a box with no instance read on two output pins -------------------------- + +# Only an instance box read on two pins went to the joined layout, which gives +# each pin a column. A function read on Q and R went to the single-column +# fan-out, which pushed the R store onto the next free row: the box's bottom +# border, with no tee on R. Any box read through two pins is joined now. +SPLIT = os.path.join(HERE, "fixtures", "fbd-function-read-on-two-output-pins.xml") +split_art = fbd_render.render_network(parse_fbd.parse_pous(SPLIT)[0].networks[0]) +check_equal("two pins, no instance: the box is drawn once", len([l for l in split_art if l.strip() == "F_SPLIT"]), 1) +check("two pins, no instance: R is teed", any("R" + U["PIN_R"] in l for l in split_art)) +check("two pins, no instance: R's wire leaves the box on R's row", any("R" + U["PIN_R"] + U["H"] in l for l in split_art)) +check("two pins, no instance: nC is drawn below the Q readers", [l.rstrip().endswith("> nC") for l in split_art].index(True) > [l.rstrip().endswith("> xB") for l in split_art].index(True)) +check("two pins, no instance: no wire leaves the bottom border", not any(U["BR"] + U["H"] in l for l in split_art)) +check_equal("two pins, no instance: both Q readers are drawn", [any(l.rstrip().endswith("> xA") for l in split_art), any(l.rstrip().endswith("> xB") for l in split_art)], [True, True]) + + +# --- a box tees every pin something reads ------------------------------------ + +# Here ET is read by two AND boxes whose wires would cross, so both name it in +# text. The pin keeps its tee all the same: it is the one mark that the readers +# take the box's pin, not a variable of the same name. Dropping it made a wired +# ET and an ET read from an input variable export alike. +tee_ctr = Call("CTU", "ctr", inputs=[("CU", Signal("xPulse")), ("PV", Signal("10"))], outputs=[("Q", None), ("ET", None), ("CV", None)], active_output="Q") +tee_ctr.wired_outputs.update(["Q", "ET"]) +tee_and1 = Call("AND", inputs=[("In1", OutputRef(tee_ctr, "Q")), ("In2", OutputRef(tee_ctr, "ET"))], outputs=[("Out1", None)], active_output="Out1", wired_outputs=["Out1"]) +tee_and2 = Call("AND", inputs=[("In1", Signal("v8")), ("In2", Signal("v9"))], outputs=[("Out1", None)], active_output="Out1", wired_outputs=["Out1"]) +tee_or = Call("OR", inputs=[("In1", tee_and1), ("In2", tee_and2)], outputs=[("Out1", None)], active_output="Out1", wired_outputs=["Out1"]) +tee_and3 = Call("AND", inputs=[("In1", OutputRef(tee_ctr, "Q")), ("In2", OutputRef(tee_ctr, "ET"))], outputs=[("Out1", None)], active_output="Out1", wired_outputs=["Out1"]) +tee_art = fbd_render.render_network(Network("", [Assign("s0", Signal("v7")), Assign("s1", tee_or), Assign("s2", tee_and3)])) +check("pin tee: ET is named in text", any("ctr.ET" in l for l in tee_art)) +check("pin tee: ET keeps its connection", any("ET" + U["PIN_R"] in l for l in tee_art)) +check("pin tee: Q shows its connection", any("Q" + U["PIN_R"] in l for l in tee_art)) +wired_twice = fbd_render.render_pou(parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-timer-pin-wired-to-two-readers.xml"))[0]) +variable_twice = fbd_render.render_pou(parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-timer-pin-read-from-a-variable-twice.xml"))[0]) +check("pin tee: a wired pin and a variable of its name export differently", wired_twice != variable_twice) + +# Outside the joined layout a box keeps a tee on every pin something reads. A +# fan-out draws each of those wires; and where an operator is drawn again, the +# tee on a pin the copy does not use is the one sign that it is the same box +# and not a second one with the same inputs. +two_output_pins = parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "36-2-fbd-two-output-pins.xml"))[0] +fanout_art = fbd_render.render_network(two_output_pins.networks[2]) +check("pin tee: a fan-out tees ENO", any("ENO" + U["PIN_R"] + U["H"] in l and l.rstrip().endswith("> xSumOk") for l in fanout_art)) +check("pin tee: a fan-out tees Out1", any("Out1" + U["PIN_R"] + U["H"] in l and l.rstrip().endswith("> iSum") for l in fanout_art)) +one_node = fbd_render.render_pou(parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-function-read-on-two-pins-one-node.xml"))[0]) +two_nodes = fbd_render.render_pou(parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-function-read-on-two-pins-two-nodes.xml"))[0]) +check("pin tee: one box read on two pins and two boxes export differently", one_node != two_nodes) + + # --- one expression reading two pins of a shared box ------------------------- # xAlarm := ctr.Q AND (ctr.CV > 5). Each read of the shared box was replaced From 71632b2bf53c5a096ea33b4882b09a3281ab8fca Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Thu, 17 Sep 2026 00:31:47 +1000 Subject: [PATCH 91/91] apply black and isort to the Python files this PR touches The repo's pre-commit hooks were not run on this branch, so eleven of its files did not match the formatters main is held to. This commit is formatting only: line wraps, import order, quote style, and u prefixes where the file imports unicode_literals. src/import_export.py and src/graphical_export.py are left as they are. Black removes their u"..." prefixes, and neither file imports unicode_literals: those strings are written to UTF-8 text streams under the IronPython that CODESYS runs. main already keeps src/import_export.py outside black for this. Markdown is not reformatted: the pinned mdformat hook fails to start in its own environment (mdformat_mkdocs imports zip_equal, which the installed more_itertools lacks). --- src/ld_render.py | 4 +- src/model.py | 4 +- src/native_networks.py | 6 +- src/parse_fbd.py | 2 +- src/parse_ld.py | 8 +- src/script_export_to_files.py | 2 +- src/script_lib_export_to_files.py | 2 +- src/util.py | 6 +- tools/ladder/render.py | 2 +- tools/ladder/tests/test_export.py | 64 ++++-- tools/ladder/tests/test_fbd.py | 281 ++++++++++++++++++++------ tools/ladder/tests/test_ladder.py | 223 +++++++++++++++----- tools/ladder/tests/test_xmlbackend.py | 10 +- tools/ladder/write_st.py | 3 +- 14 files changed, 476 insertions(+), 141 deletions(-) diff --git a/src/ld_render.py b/src/ld_render.py index ca0b7b8..05c273d 100644 --- a/src/ld_render.py +++ b/src/ld_render.py @@ -232,7 +232,9 @@ def _render_block(element): # expanded to spaces so the box's right wall stays straight - a tab counts # as one character but draws as several. code = [line.expandtabs(4) for line in element.st_code] - inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)] + [len(line) + 2 for line in code]) + inner = max( + [len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)] + [len(line) + 2 for line in code] + ) # Two columns to the left of the box: the widest value, then a short wire # into the pin. The power pin's row is all wire - the rung feeds that one. diff --git a/src/model.py b/src/model.py index 0ff4f4a..dd193ce 100644 --- a/src/model.py +++ b/src/model.py @@ -290,9 +290,7 @@ class Pou(object): editor, and numbering it as three throws every later number out. """ - def __init__( - self, name, pou_type, variables=None, networks=None, language=None, declaration_text=None - ): + def __init__(self, name, pou_type, variables=None, networks=None, language=None, declaration_text=None): self.name = name self.pou_type = pou_type self.language = language diff --git a/src/native_networks.py b/src/native_networks.py index 98ef554..12e3dc2 100644 --- a/src/native_networks.py +++ b/src/native_networks.py @@ -28,9 +28,9 @@ import os +from model import Network import plcopen import xmlbackend -from model import Network # Said in the file itself, under the number the network occupies. A reviewer # reading only the .txt has to learn that logic exists here without @@ -160,7 +160,5 @@ def align(native, parsed): note = NOTE_OUT_COMMENTED else: note = NOTE_EMPTY - aligned.append( - Network(comment=entry.comment, outputs=outputs, title=entry.title, label=entry.label, note=note) - ) + aligned.append(Network(comment=entry.comment, outputs=outputs, title=entry.title, label=entry.label, note=note)) return aligned diff --git a/src/parse_fbd.py b/src/parse_fbd.py index 90d5348..eae954c 100644 --- a/src/parse_fbd.py +++ b/src/parse_fbd.py @@ -29,8 +29,8 @@ block_outputs, block_st_code, child_text, - declaration_text, comment_text, + declaration_text, direct_connections, find_child, is_true, diff --git a/src/parse_ld.py b/src/parse_ld.py index 63e191a..6dc7a2c 100644 --- a/src/parse_ld.py +++ b/src/parse_ld.py @@ -8,7 +8,9 @@ from model import ( BLOCK, + COIL, COMMENT, + CONTACT, EDGE_FUNCTION, IN_VARIABLE, JUMP, @@ -18,8 +20,6 @@ RETURN, RIGHT_RAIL, TITLE, - CONTACT, - COIL, Element, Empty, Network, @@ -371,7 +371,9 @@ def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): pin_blocks=pin_blocks, local_id=node.local_id, ordinal=node.ordinal, - power_len=len(power_expr.items) if isinstance(power_expr, Series) else (0 if isinstance(power_expr, Empty) else 1), + power_len=len(power_expr.items) + if isinstance(power_expr, Series) + else (0 if isinstance(power_expr, Empty) else 1), copy=copy, ) return series([power_expr, element]) diff --git a/src/script_export_to_files.py b/src/script_export_to_files.py index 38b60df..19d838e 100644 --- a/src/script_export_to_files.py +++ b/src/script_export_to_files.py @@ -5,10 +5,10 @@ import scriptengine # type: ignore -import graphical_export from communication_import_export import export_communication from device_tree_import_export import export_device_tree_siblings from entrypoint import find_application, find_communication, get_device_entrypoints, get_src_folder +import graphical_export from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, SERVICE_EXPORT_FUNCTIONS, write_native from object_type import ObjectType, get_object_type from util import * diff --git a/src/script_lib_export_to_files.py b/src/script_lib_export_to_files.py index b298655..33520e0 100644 --- a/src/script_lib_export_to_files.py +++ b/src/script_lib_export_to_files.py @@ -5,8 +5,8 @@ import scriptengine # type: ignore -import graphical_export from entrypoint import get_src_folder +import graphical_export from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, SERVICE_EXPORT_FUNCTIONS, write_native from object_type import ObjectType, get_object_type from util import * diff --git a/src/util.py b/src/util.py index 269dbc6..de0929a 100644 --- a/src/util.py +++ b/src/util.py @@ -116,7 +116,11 @@ def finalize_export_folder(target_folder, staging_folder): + ". Original error: " + str(rename_error) ) - print("Export folder " + target_folder + " is in use; synced the staged files into it instead of swapping folders.") + print( + "Export folder " + + target_folder + + " is in use; synced the staged files into it instead of swapping folders." + ) if stale: print( "WARNING: " diff --git a/tools/ladder/render.py b/tools/ladder/render.py index ad82d55..7a5fa0f 100644 --- a/tools/ladder/render.py +++ b/tools/ladder/render.py @@ -28,8 +28,8 @@ import charset # noqa: E402 import fbd_render # noqa: E402 import ld_render # noqa: E402 -import parse_ld # noqa: E402 import parse_fbd # noqa: E402 +import parse_ld # noqa: E402 import st_render # noqa: E402 FORMATS = ("art", "st", "both") diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py index 08f4a74..07a9301 100644 --- a/tools/ladder/tests/test_export.py +++ b/tools/ladder/tests/test_export.py @@ -22,10 +22,11 @@ sys.path.insert(0, os.path.join(REPO, "tools", "ci")) # stubbed scriptengine sys.path.insert(0, os.path.join(REPO, "tools", "ladder")) +from render import write # noqa: E402 + import graphical_export # noqa: E402 import import_export # noqa: E402 import import_from_files # noqa: E402 -from render import write # noqa: E402 FIXTURES = os.path.join(HERE, "fixtures", "codesys") @@ -193,7 +194,9 @@ def read(path): END_VAR""" source_pou = FakePou("LD_TEST", os.path.join(FIXTURES, "LDTesting.xml"), source_declaration) source_base = os.path.join(workspace, "SOURCE") - check("source declaration is rendered verbatim", graphical_export.write_rendered_text(source_pou, source_base) is True) + check( + "source declaration is rendered verbatim", graphical_export.write_rendered_text(source_pou, source_base) is True + ) source_content = read(source_base + ".txt") check("safety type survives", "S_xSafe : SAFEBOOL;" in source_content) check("declaration comment survives", "OUT0200 is the hardware channel identifier." in source_content) @@ -491,7 +494,9 @@ def __init__(self, outputs): graphical_export.reset_stats() labelled = graphical_export.render_plcopen(LABELLED, None, None, LABELLED_NATIVE) check("labelled: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in labelled) -check_equal("labelled: one header per editor network", len([line for line in labelled if line.startswith("(* Network ")]), 5) +check_equal( + "labelled: one header per editor network", len([line for line in labelled if line.startswith("(* Network ")]), 5 +) second = labelled.index("(* Network 2 *)") check_equal("labelled: the label is under its header", labelled[second + 1], "LATER:") check("labelled: the rung follows it", "xA" in labelled[second + 2] and "oA" in labelled[second + 2]) @@ -508,7 +513,11 @@ def __init__(self, outputs): # The same file without the native list, as the dev CLI renders it: the label # is the network's own either way, so it is written once, under the header. unlabelled = graphical_export.render_plcopen(LABELLED, None, None, None) -check_equal("labelled: without the native list the label still appears once", len([line for line in unlabelled if "LATER:" in line]), 1) +check_equal( + "labelled: without the native list the label still appears once", + len([line for line in unlabelled if "LATER:" in line]), + 1, +) check_equal("labelled: and under its header", unlabelled[unlabelled.index("(* Network 2 *)") + 1], "LATER:") graphical_export.reset_stats() @@ -523,13 +532,25 @@ def __init__(self, outputs): two_labels = graphical_export.render_plcopen(TWO_LABELS, None, None, TWO_LABELS_NATIVE) check("two labels: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in two_labels) check_equal("two labels: one header per editor network", len([l for l in two_labels if l.startswith("(* Network ")]), 4) -check_equal("two labels: each label is written once", [two_labels.count("LONELY1:"), two_labels.count("LONELY2:")], [1, 1]) -check("two labels: no label is drawn as a rung", not any("LONELY" in l and ":" in l and l not in ("LONELY1:", "LONELY2:") for l in two_labels)) +check_equal( + "two labels: each label is written once", [two_labels.count("LONELY1:"), two_labels.count("LONELY2:")], [1, 1] +) +check( + "two labels: no label is drawn as a rung", + not any("LONELY" in l and ":" in l and l not in ("LONELY1:", "LONELY2:") for l in two_labels), +) fourth = two_labels.index("(* Network 4 *)") check("two labels: the network with logic carries no label", "xA" in two_labels[fourth + 1]) two_labels_bare = graphical_export.render_plcopen(TWO_LABELS, None, None, None) -check_equal("two labels: without the native list each label is written once", [two_labels_bare.count("LONELY1:"), two_labels_bare.count("LONELY2:")], [1, 1]) -check("two labels: without the native list no label is drawn as a rung", not any("LONELY" in l and ":" in l and l not in ("LONELY1:", "LONELY2:") for l in two_labels_bare)) +check_equal( + "two labels: without the native list each label is written once", + [two_labels_bare.count("LONELY1:"), two_labels_bare.count("LONELY2:")], + [1, 1], +) +check( + "two labels: without the native list no label is drawn as a rung", + not any("LONELY" in l and ":" in l and l not in ("LONELY1:", "LONELY2:") for l in two_labels_bare), +) graphical_export.reset_stats() # CODESYS writes a network's comment before its label. A comment arriving while @@ -541,8 +562,15 @@ def __init__(self, outputs): for native, name in ((COMMENT_BETWEEN_NATIVE, "with"), (None, "without")): between = graphical_export.render_plcopen(COMMENT_BETWEEN, None, None, native) headed = [between[i + 1] for i, l in enumerate(between) if l.startswith("(* Network ") and "second one" in l] - check_equal("comment between labels, " + name + " the native list: the comment heads the LB network", headed, ["LB:"]) - check("comment between labels, " + name + " the native list: LA carries no comment", not any(l.startswith("(* Network ") and "second one" in l and between[i + 1] == "LA:" for i, l in enumerate(between))) + check_equal( + "comment between labels, " + name + " the native list: the comment heads the LB network", headed, ["LB:"] + ) + check( + "comment between labels, " + name + " the native list: LA carries no comment", + not any( + l.startswith("(* Network ") and "second one" in l and between[i + 1] == "LA:" for i, l in enumerate(between) + ), + ) graphical_export.reset_stats() @@ -561,7 +589,11 @@ def __init__(self, outputs): graphical_export.reset_stats() disabled = graphical_export.render_plcopen(DISABLED, None, None, DISABLED_NATIVE) check("disabled label: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in disabled) -check_equal("disabled label: one header per editor network", len([line for line in disabled if line.startswith("(* Network ")]), 3) +check_equal( + "disabled label: one header per editor network", + len([line for line in disabled if line.startswith("(* Network ")]), + 3, +) first = disabled.index("(* Network 1 *)") second = disabled.index("(* Network 2 *)") third = disabled.index("(* Network 3 *)") @@ -645,7 +677,7 @@ def get_name(self): def export_native(self, path, recursive=False): self.calls.append((path, recursive)) handle = io.open(path, "w", encoding="utf-8") - handle.write(u"\n") + handle.write("\n") handle.close() @@ -743,7 +775,7 @@ def remove(self): target = os.path.join(workspace, "Project") os.mkdir(target) handle = io.open(os.path.join(target, "KEEP.st"), "w", encoding="utf-8") - handle.write(u"PROGRAM Keep\n") + handle.write("PROGRAM Keep\n") handle.close() staging = util.begin_export_folder(target) @@ -759,7 +791,7 @@ def remove(self): # A real export must still swap in exactly as before. staging = util.begin_export_folder(target) handle = io.open(os.path.join(staging, "NEW.st"), "w", encoding="utf-8") - handle.write(u"PROGRAM New\n") + handle.write("PROGRAM New\n") handle.close() util.finalize_export_folder(target, staging) check("a real export still swaps in", os.path.exists(os.path.join(target, "NEW.st"))) @@ -780,13 +812,13 @@ def remove(self): os.makedirs(os.path.join(target, "application")) for name in ("application/GONE.txt", "application/GONE.xml"): handle = io.open(os.path.join(target, name), "w", encoding="utf-8") - handle.write(u"from the previous export\n") + handle.write("from the previous export\n") handle.close() staging = util.begin_export_folder(target) os.makedirs(os.path.join(staging, "application")) handle = io.open(os.path.join(staging, "application", "STAYS.xml"), "w", encoding="utf-8") - handle.write(u"from this export\n") + handle.write("from this export\n") handle.close() real_rename = os.rename diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py index 1ea4ee7..2dc2e65 100644 --- a/tools/ladder/tests/test_fbd.py +++ b/tools/ladder/tests/test_fbd.py @@ -19,14 +19,15 @@ sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) sys.path.insert(0, os.path.join(HERE, "..")) +from render import write # noqa: E402 + import charset # noqa: E402 -import layout # noqa: E402 import fbd_render # noqa: E402 -import parse_ld # noqa: E402 +import layout # noqa: E402 +from model import Assign, Call, Label, Network, OutputRef, Pou, Signal # noqa: E402 import parse_fbd # noqa: E402 +import parse_ld # noqa: E402 import st_render # noqa: E402 -from model import Assign, Call, Label, Network, OutputRef, Pou, Signal # noqa: E402 -from render import write # noqa: E402 # Referenced through the charset table rather than as literal glyphs: this # source file has to stay pure ASCII for IronPython 2.7 to load it at all. @@ -37,6 +38,7 @@ LD_SOURCE = os.path.join(FIXTURES, "LDTesting.xml") SFC_SOURCE = os.path.join(FIXTURES, "SFCTesting.xml") + def box(node): """The Call a wire reads, unwrapping the pin the wire names. @@ -116,9 +118,7 @@ def check_golden(name, rendered, golden_path): hostile_title = "one *) two" check_equal( "network titles cannot break generated block comments", - fbd_render.render_pou( - Pou("HOSTILE", "program", networks=[Network("", [Signal("x")], title=hostile_title)]) - )[3], + fbd_render.render_pou(Pou("HOSTILE", "program", networks=[Network("", [Signal("x")], title=hostile_title)]))[3], "(* Network 1: one * ) two *)", ) @@ -235,7 +235,10 @@ def check_golden(name, rendered, golden_path): # label under its own number and pushed every later number out by one. check_equal("flow: three networks survive", len(flow.networks), 3) check_equal("flow: the label is the network's own", flow.networks[2].label, "END") -check("flow: the label is not in the network's body", not any(isinstance(tree, Label) for tree in flow.networks[2].outputs)) +check( + "flow: the label is not in the network's body", + not any(isinstance(tree, Label) for tree in flow.networks[2].outputs), +) # A jump terminates a network. Leaving it out of SINK_KINDS dropped the entire # guard network, because nothing else consumed the OR feeding it. @@ -298,7 +301,9 @@ def check_golden(name, rendered, golden_path): # The negation bubble on a block's own input pin, distinct from a negated # inVariable element. Dropping it computes AND where the program computes # AND NOT. -check("fidelity: negated input pin inverts in ST", any("xMasked := xRun2 AND (NOT xReady2);" in line for line in fid_st)) +check( + "fidelity: negated input pin inverts in ST", any("xMasked := xRun2 AND (NOT xReady2);" in line for line in fid_st) +) check("fidelity: negated input pin reaches the diagram", any("NOT" in line and "xReady2" in line for line in fid_art)) # The same bubble on an output pin carrying an inline assignment: the stored @@ -311,7 +316,10 @@ def check_golden(name, rendered, golden_path): # NOT binds tighter than OR in IEC 61131-3, so a negated compound expression # must keep its parentheses or the logic regroups. -check("fidelity: negated compound expression keeps its grouping", any("xGuard := NOT (xA OR xB);" in line for line in fid_st)) +check( + "fidelity: negated compound expression keeps its grouping", + any("xGuard := NOT (xA OR xB);" in line for line in fid_st), +) # Expressions are free-form ST and are routinely typed without spaces; NOT # still binds above the comparison, so "NOT iCount>5" states (NOT iCount)>5. @@ -349,7 +357,9 @@ def check_golden(name, rendered, golden_path): # The sharper case: the block is called once in the program, so emitting the # call per output would misstate what runs. check_equal("fanout: the block is called once", len([l for l in fan_st if l.startswith("TON_0(")]), 1) -check("fanout: both stores are still made", "Status.Done := TON_0.Q;" in fan_st and "Status.Latched := TON_0.Q;" in fan_st) +check( + "fanout: both stores are still made", "Status.Done := TON_0.Q;" in fan_st and "Status.Latched := TON_0.Q;" in fan_st +) # The shared source is drawn once and branched, not drawn per output. check_equal("fanout: one OR box is drawn", len([l for l in fan_art if "In1 Out1" in l]), 1) @@ -392,8 +402,14 @@ def check_golden(name, rendered, golden_path): # Two pins are two wires, not one branched wire: a junction column here would # draw ET and Q as the same signal. -check("two pins: Q leaves on its own row", any(l.rstrip().endswith("> xQ") and "Q" + U["PIN_R"] in l for l in two_pins_art)) -check("two pins: ET leaves on its own row", any(l.rstrip().endswith("> tEt") and "ET" + U["PIN_R"] in l for l in two_pins_art)) +check( + "two pins: Q leaves on its own row", + any(l.rstrip().endswith("> xQ") and "Q" + U["PIN_R"] in l for l in two_pins_art), +) +check( + "two pins: ET leaves on its own row", + any(l.rstrip().endswith("> tEt") and "ET" + U["PIN_R"] in l for l in two_pins_art), +) check("two pins: no junction between different pins", not any(U["T_DOWN"] in l and "xQ" in l for l in two_pins_art)) @@ -413,7 +429,10 @@ def check_golden(name, rendered, golden_path): check_equal("shared box: two outputs", len(shared.networks[0].outputs), 2) check_equal("shared box: the timer is called once", len([l for l in shared_st if l.startswith("fbTimer(")]), 1) check_equal("shared box: one box is drawn", len([l for l in shared_art if "fbTimer : TON" in l]), 1) -check("shared box: both stores are still made", "xDone := fbTimer.Q;" in shared_st and "xAny := fbTimer.Q OR xManual;" in shared_st) +check( + "shared box: both stores are still made", + "xDone := fbTimer.Q;" in shared_st and "xAny := fbTimer.Q OR xManual;" in shared_st, +) # The second reader hangs off the pin on a junction, not on a copy of the box # and not on its name in text: the wire is what says the two readers are the @@ -438,7 +457,9 @@ def check_golden(name, rendered, golden_path): check("enable: EN is not an operand", "IF xEn THEN iSum := iA + iB + iC; END_IF" in enable_st) check("enable: no four-way sum survives", not any("xEn + iA" in line for line in enable_st)) check("enable: ENO reports the enable", "xSumOk := xEn;" in enable_st) -check("enable: the ENO store is not itself guarded", not any(line.startswith("IF xEn THEN xSumOk") for line in enable_st)) +check( + "enable: the ENO store is not itself guarded", not any(line.startswith("IF xEn THEN xSumOk") for line in enable_st) +) # A box with no EN wired keeps its plain expression and no guard. check("enable: an unguarded operator is unchanged", "xAny := fbTimer.Q OR xManual;" in shared_st) @@ -501,7 +522,9 @@ def check_golden(name, rendered, golden_path): pin_edge_st = st_render.render_pou(pin_edge) pin_edge_art = fbd_render.render_network(pin_edge.networks[0]) -check_equal("pin edge: the edge reaches the tree", box(pin_edge.networks[0].outputs[0].source).inputs[0][1].edge, "rising") +check_equal( + "pin edge: the edge reaches the tree", box(pin_edge.networks[0].outputs[0].source).inputs[0][1].edge, "rising" +) check("pin edge: the ST shows the trigger", "ctr(CU := R(xPulse), RESET := xRst);" in pin_edge_st) check("pin edge: the diagram marks the pin", any("R(xPulse)" in line for line in pin_edge_art)) check("pin edge: an unmarked pin stays unmarked", not any("R(xRst)" in line for line in pin_edge_st)) @@ -584,7 +607,10 @@ def wire_rows(art): ) # The store cannot sit level with ET, because the OR box is in the way; so # ET's wire turns down a column of its own and the store hangs off that. - check(order + ": the ET wire turns down its own column", "ET" + U["PIN_R"] + U["H"] * 2 + U["TR"] in art_two[et_rows[0]]) + check( + order + ": the ET wire turns down its own column", + "ET" + U["PIN_R"] + U["H"] * 2 + U["TR"] in art_two[et_rows[0]], + ) check_equal(order + ": the store hangs below the ET row", len(store_rows) == 1 and store_rows[0] > et_rows[0], True) check(order + ": the store is fed from the ET column", art_two[store_rows[0]].lstrip().startswith(U["BL"])) check(order + ": the ET row does not feed the OR box", U["PIN_L"] + "In1" not in art_two[et_rows[0]]) @@ -606,7 +632,14 @@ def wire_rows(art): [ Assign("xOther", Signal("xIn")), Assign("xDone", OutputRef(aside_timer, "Q")), - Assign("xAny", Call("OR", inputs=[("In1", OutputRef(aside_timer, "Q")), ("In2", Signal("xManual"))], outputs=[("Out1", None)])), + Assign( + "xAny", + Call( + "OR", + inputs=[("In1", OutputRef(aside_timer, "Q")), ("In2", Signal("xManual"))], + outputs=[("Out1", None)], + ), + ), ], ) aside_art = fbd_render.render_network(aside) @@ -627,9 +660,19 @@ def wire_rows(art): eno_art = fbd_render.render_network(eno.networks[0]) check("execute eno: the box is drawn", any(line.strip() == "EXECUTE" for line in eno_art)) -check("execute eno: the wire to the store is drawn", any("ENO" + U["PIN_R"] + U["H"] * 3 + "> xRan" in line for line in eno_art)) -check("execute eno: the body is inside the box", any(U["V"] + " a := 1;" in line for line in eno_art) and any(U["V"] + " b := 2;" in line for line in eno_art)) -check_equal("execute eno: each body line appears once", [len([l for l in eno_art if "a := 1;" in l]), len([l for l in eno_art if "b := 2;" in l])], [1, 1]) +check( + "execute eno: the wire to the store is drawn", + any("ENO" + U["PIN_R"] + U["H"] * 3 + "> xRan" in line for line in eno_art), +) +check( + "execute eno: the body is inside the box", + any(U["V"] + " a := 1;" in line for line in eno_art) and any(U["V"] + " b := 2;" in line for line in eno_art), +) +check_equal( + "execute eno: each body line appears once", + [len([l for l in eno_art if "a := 1;" in l]), len([l for l in eno_art if "b := 2;" in l])], + [1, 1], +) check("execute eno: the box closes below the body", eno_art[-1].strip().startswith(U["BL"])) check("execute eno: the ST guards the body with EN", "IF xRun THEN" in eno_st and " a := 1;" in eno_st) check("execute eno: the ENO store reads the enable", "xRan := xRun;" in eno_st) @@ -639,7 +682,16 @@ def wire_rows(art): deep_execute.wired_outputs.add("ENO") deep = Network( "", - [Assign("xBoth", Call("AND", inputs=[("In1", OutputRef(deep_execute, "ENO")), ("In2", Signal("xOk"))], outputs=[("Out1", None)]))], + [ + Assign( + "xBoth", + Call( + "AND", + inputs=[("In1", OutputRef(deep_execute, "ENO")), ("In2", Signal("xOk"))], + outputs=[("Out1", None)], + ), + ) + ], ) deep_art = fbd_render.render_network(deep) check("execute deep: a nested box shows its body inside", any(U["V"] + " c := 3;" in line for line in deep_art)) @@ -694,9 +746,18 @@ def wire_rows(art): SUB_FED_SWAPPED = os.path.join(HERE, "fixtures", "fbd-two-execute-boxes-feed-one-sub-swapped.xml") sub_fed_art = fbd_render.render_network(parse_fbd.parse_pous(SUB_FED)[0].networks[0]) sub_fed_swapped_art = fbd_render.render_network(parse_fbd.parse_pous(SUB_FED_SWAPPED)[0].networks[0]) -check("numbered execute boxes: titled #1 and #2", [l.strip() for l in sub_fed_art if l.strip().startswith("EXECUTE #")] == ["EXECUTE #1", "EXECUTE #2"]) -check("numbered execute boxes: In1 names box 1", any("EXECUTE#1.ENO" + U["H"] * 2 + U["PIN_L"] + "In1" in l for l in sub_fed_art)) -check("numbered execute boxes: In2 names box 2", any("EXECUTE#2.ENO" + U["H"] * 2 + U["PIN_L"] + "In2" in l for l in sub_fed_art)) +check( + "numbered execute boxes: titled #1 and #2", + [l.strip() for l in sub_fed_art if l.strip().startswith("EXECUTE #")] == ["EXECUTE #1", "EXECUTE #2"], +) +check( + "numbered execute boxes: In1 names box 1", + any("EXECUTE#1.ENO" + U["H"] * 2 + U["PIN_L"] + "In1" in l for l in sub_fed_art), +) +check( + "numbered execute boxes: In2 names box 2", + any("EXECUTE#2.ENO" + U["H"] * 2 + U["PIN_L"] + "In2" in l for l in sub_fed_art), +) check("numbered execute boxes: swapping the inputs changes the export", sub_fed_art != sub_fed_swapped_art) check_equal("numbered execute boxes: each body is drawn once", len([l for l in sub_fed_art if ":=" in l]), 2) # A lone EXECUTE box read twice keeps its plain name. @@ -716,7 +777,10 @@ def wire_rows(art): "in-out pin: its reader leaves on the pin's own row", any(U["PIN_L"] + "pBuf" in l and l.rstrip().endswith("> arrCopy") for l in in_out_art), ) -check("in-out pin: the output's reader stays on the output's row", any("Q" + U["PIN_R"] in l and l.rstrip().endswith("> xDone") for l in in_out_art)) +check( + "in-out pin: the output's reader stays on the output's row", + any("Q" + U["PIN_R"] in l and l.rstrip().endswith("> xDone") for l in in_out_art), +) check("in-out pin: the pin runs through the box", any("pBuf" + U["H"] in l and U["PIN_R"] in l for l in in_out_art)) @@ -730,10 +794,20 @@ def wire_rows(art): split_art = fbd_render.render_network(parse_fbd.parse_pous(SPLIT)[0].networks[0]) check_equal("two pins, no instance: the box is drawn once", len([l for l in split_art if l.strip() == "F_SPLIT"]), 1) check("two pins, no instance: R is teed", any("R" + U["PIN_R"] in l for l in split_art)) -check("two pins, no instance: R's wire leaves the box on R's row", any("R" + U["PIN_R"] + U["H"] in l for l in split_art)) -check("two pins, no instance: nC is drawn below the Q readers", [l.rstrip().endswith("> nC") for l in split_art].index(True) > [l.rstrip().endswith("> xB") for l in split_art].index(True)) +check( + "two pins, no instance: R's wire leaves the box on R's row", any("R" + U["PIN_R"] + U["H"] in l for l in split_art) +) +check( + "two pins, no instance: nC is drawn below the Q readers", + [l.rstrip().endswith("> nC") for l in split_art].index(True) + > [l.rstrip().endswith("> xB") for l in split_art].index(True), +) check("two pins, no instance: no wire leaves the bottom border", not any(U["BR"] + U["H"] in l for l in split_art)) -check_equal("two pins, no instance: both Q readers are drawn", [any(l.rstrip().endswith("> xA") for l in split_art), any(l.rstrip().endswith("> xB") for l in split_art)], [True, True]) +check_equal( + "two pins, no instance: both Q readers are drawn", + [any(l.rstrip().endswith("> xA") for l in split_art), any(l.rstrip().endswith("> xB") for l in split_art)], + [True, True], +) # --- a box tees every pin something reads ------------------------------------ @@ -742,18 +816,54 @@ def wire_rows(art): # text. The pin keeps its tee all the same: it is the one mark that the readers # take the box's pin, not a variable of the same name. Dropping it made a wired # ET and an ET read from an input variable export alike. -tee_ctr = Call("CTU", "ctr", inputs=[("CU", Signal("xPulse")), ("PV", Signal("10"))], outputs=[("Q", None), ("ET", None), ("CV", None)], active_output="Q") +tee_ctr = Call( + "CTU", + "ctr", + inputs=[("CU", Signal("xPulse")), ("PV", Signal("10"))], + outputs=[("Q", None), ("ET", None), ("CV", None)], + active_output="Q", +) tee_ctr.wired_outputs.update(["Q", "ET"]) -tee_and1 = Call("AND", inputs=[("In1", OutputRef(tee_ctr, "Q")), ("In2", OutputRef(tee_ctr, "ET"))], outputs=[("Out1", None)], active_output="Out1", wired_outputs=["Out1"]) -tee_and2 = Call("AND", inputs=[("In1", Signal("v8")), ("In2", Signal("v9"))], outputs=[("Out1", None)], active_output="Out1", wired_outputs=["Out1"]) -tee_or = Call("OR", inputs=[("In1", tee_and1), ("In2", tee_and2)], outputs=[("Out1", None)], active_output="Out1", wired_outputs=["Out1"]) -tee_and3 = Call("AND", inputs=[("In1", OutputRef(tee_ctr, "Q")), ("In2", OutputRef(tee_ctr, "ET"))], outputs=[("Out1", None)], active_output="Out1", wired_outputs=["Out1"]) -tee_art = fbd_render.render_network(Network("", [Assign("s0", Signal("v7")), Assign("s1", tee_or), Assign("s2", tee_and3)])) +tee_and1 = Call( + "AND", + inputs=[("In1", OutputRef(tee_ctr, "Q")), ("In2", OutputRef(tee_ctr, "ET"))], + outputs=[("Out1", None)], + active_output="Out1", + wired_outputs=["Out1"], +) +tee_and2 = Call( + "AND", + inputs=[("In1", Signal("v8")), ("In2", Signal("v9"))], + outputs=[("Out1", None)], + active_output="Out1", + wired_outputs=["Out1"], +) +tee_or = Call( + "OR", + inputs=[("In1", tee_and1), ("In2", tee_and2)], + outputs=[("Out1", None)], + active_output="Out1", + wired_outputs=["Out1"], +) +tee_and3 = Call( + "AND", + inputs=[("In1", OutputRef(tee_ctr, "Q")), ("In2", OutputRef(tee_ctr, "ET"))], + outputs=[("Out1", None)], + active_output="Out1", + wired_outputs=["Out1"], +) +tee_art = fbd_render.render_network( + Network("", [Assign("s0", Signal("v7")), Assign("s1", tee_or), Assign("s2", tee_and3)]) +) check("pin tee: ET is named in text", any("ctr.ET" in l for l in tee_art)) check("pin tee: ET keeps its connection", any("ET" + U["PIN_R"] in l for l in tee_art)) check("pin tee: Q shows its connection", any("Q" + U["PIN_R"] in l for l in tee_art)) -wired_twice = fbd_render.render_pou(parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-timer-pin-wired-to-two-readers.xml"))[0]) -variable_twice = fbd_render.render_pou(parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-timer-pin-read-from-a-variable-twice.xml"))[0]) +wired_twice = fbd_render.render_pou( + parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-timer-pin-wired-to-two-readers.xml"))[0] +) +variable_twice = fbd_render.render_pou( + parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-timer-pin-read-from-a-variable-twice.xml"))[0] +) check("pin tee: a wired pin and a variable of its name export differently", wired_twice != variable_twice) # Outside the joined layout a box keeps a tee on every pin something reads. A @@ -762,10 +872,20 @@ def wire_rows(art): # and not a second one with the same inputs. two_output_pins = parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "36-2-fbd-two-output-pins.xml"))[0] fanout_art = fbd_render.render_network(two_output_pins.networks[2]) -check("pin tee: a fan-out tees ENO", any("ENO" + U["PIN_R"] + U["H"] in l and l.rstrip().endswith("> xSumOk") for l in fanout_art)) -check("pin tee: a fan-out tees Out1", any("Out1" + U["PIN_R"] + U["H"] in l and l.rstrip().endswith("> iSum") for l in fanout_art)) -one_node = fbd_render.render_pou(parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-function-read-on-two-pins-one-node.xml"))[0]) -two_nodes = fbd_render.render_pou(parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-function-read-on-two-pins-two-nodes.xml"))[0]) +check( + "pin tee: a fan-out tees ENO", + any("ENO" + U["PIN_R"] + U["H"] in l and l.rstrip().endswith("> xSumOk") for l in fanout_art), +) +check( + "pin tee: a fan-out tees Out1", + any("Out1" + U["PIN_R"] + U["H"] in l and l.rstrip().endswith("> iSum") for l in fanout_art), +) +one_node = fbd_render.render_pou( + parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-function-read-on-two-pins-one-node.xml"))[0] +) +two_nodes = fbd_render.render_pou( + parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-function-read-on-two-pins-two-nodes.xml"))[0] +) check("pin tee: one box read on two pins and two boxes export differently", one_node != two_nodes) @@ -794,7 +914,10 @@ def control_free(lines): check("two reads: the box is not named in text", not any("ctr." in line for line in two_reads_art)) q_line = [line for line in two_reads_art if "Q" + U["PIN_R"] in line][0] cv_line = [line for line in two_reads_art if "CV" + U["PIN_R"] in line][0] -check("two reads: Q is wired straight into the AND box", U["PIN_L"] + "In1 Out1" + U["PIN_R"] + U["H"] * 3 + "> xAlarm" in q_line) +check( + "two reads: Q is wired straight into the AND box", + U["PIN_L"] + "In1 Out1" + U["PIN_R"] + U["H"] * 3 + "> xAlarm" in q_line, +) check("two reads: CV is wired, down a column of its own", "CV" + U["PIN_R"] + U["H"] * 2 + U["TR"] in cv_line) gt_lines = [line for line in two_reads_art if line.lstrip().startswith(U["BL"]) and U["PIN_L"] + "In1 Out1" in line] check_equal("two reads: the CV column feeds the GT box", len(gt_lines), 1) @@ -803,16 +926,34 @@ def control_free(lines): # The same two reads the other way up: (ctr.CV > 5) AND ctr.Q puts the CV # read above the Q read, where a wire from CV would have to cross the wire # from Q. Then the first read keeps its wire and the second is named. -crossing_counter = Call("CTU", "ctr", inputs=[("CU", Signal("xPulse")), ("PV", Signal("10"))], outputs=[("Q", None), ("CV", None)]) +crossing_counter = Call( + "CTU", "ctr", inputs=[("CU", Signal("xPulse")), ("PV", Signal("10"))], outputs=[("Q", None), ("CV", None)] +) crossing_counter.wired_outputs.update(["Q", "CV"]) -crossing_gt = Call("GT", inputs=[("In1", OutputRef(crossing_counter, "CV")), ("In2", Signal("5"))], outputs=[("Out1", None)], wired_outputs=["Out1"]) -crossing_and = Call("AND", inputs=[("In1", crossing_gt), ("In2", OutputRef(crossing_counter, "Q"))], outputs=[("Out1", None)], wired_outputs=["Out1"]) +crossing_gt = Call( + "GT", + inputs=[("In1", OutputRef(crossing_counter, "CV")), ("In2", Signal("5"))], + outputs=[("Out1", None)], + wired_outputs=["Out1"], +) +crossing_and = Call( + "AND", + inputs=[("In1", crossing_gt), ("In2", OutputRef(crossing_counter, "Q"))], + outputs=[("Out1", None)], + wired_outputs=["Out1"], +) crossing = Network("", [Assign("xAlarm", crossing_and)]) crossing_art = fbd_render.render_network(crossing) check("crossing reads: no control character in the output", control_free(crossing_art)) check_equal("crossing reads: one box is drawn", len([line for line in crossing_art if "ctr : CTU" in line]), 1) -check("crossing reads: the first read is wired", any("CV" + U["PIN_R"] + U["H"] in line and U["PIN_L"] + "In1 Out1" in line for line in crossing_art)) -check("crossing reads: the second read is named", any("ctr.Q" + U["H"] * 2 in line and U["PIN_L"] + "In2" in line for line in crossing_art)) +check( + "crossing reads: the first read is wired", + any("CV" + U["PIN_R"] + U["H"] in line and U["PIN_L"] + "In1 Out1" in line for line in crossing_art), +) +check( + "crossing reads: the second read is named", + any("ctr.Q" + U["H"] * 2 in line and U["PIN_L"] + "In2" in line for line in crossing_art), +) check("crossing reads: no wire runs from Q", not any("Q" + U["PIN_R"] + U["H"] in line for line in crossing_art)) @@ -851,21 +992,38 @@ def control_free(lines): move_st = st_render.network_to_statements(Network("", [move])) check("operator store: the guarded store is emitted", "IF xCond THEN iDst := MOVE(iSrc); END_IF" in move_st) -add = Call("ADD", inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], outputs=[("ENO", None), ("Out1", "iSum")]) +add = Call( + "ADD", + inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], + outputs=[("ENO", None), ("Out1", "iSum")], +) add_st = st_render.network_to_statements(Network("", [add])) check("operator store: the sum is stored under its guard", "IF xEn THEN iSum := iA + iB; END_IF" in add_st) # A bubble on Out1 while ENO is listed first: the box's active output is ENO, # so the negation used to be looked for on the wrong pin and lost. The bubble # is on the pin the reader takes. -neg_out = Call("ADD", inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], - outputs=[("ENO", None), ("Out1", None)], negated_outputs=set(["Out1"]), wired_outputs=["Out1"]) +neg_out = Call( + "ADD", + inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], + outputs=[("ENO", None), ("Out1", None)], + negated_outputs=set(["Out1"]), + wired_outputs=["Out1"], +) neg_out_st = st_render.network_to_statements(Network("", [Assign("iSum", OutputRef(neg_out, "Out1"))])) -check("operator negate: a negated Out1 inverts though ENO is first", "IF xEn THEN iSum := NOT (iA + iB); END_IF" in neg_out_st) +check( + "operator negate: a negated Out1 inverts though ENO is first", + "IF xEn THEN iSum := NOT (iA + iB); END_IF" in neg_out_st, +) # A negated ENO reports the inverse of the enable. -neg_eno = Call("ADD", inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], - outputs=[("ENO", None), ("Out1", None)], negated_outputs=set(["ENO"]), wired_outputs=["ENO"]) +neg_eno = Call( + "ADD", + inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], + outputs=[("ENO", None), ("Out1", None)], + negated_outputs=set(["ENO"]), + wired_outputs=["ENO"], +) neg_eno_st = st_render.network_to_statements(Network("", [Assign("ok", OutputRef(neg_eno, "ENO"))])) check("operator negate: a negated ENO inverts the enable", "ok := NOT xEn;" in neg_eno_st) @@ -879,14 +1037,23 @@ def control_free(lines): # A body line indented with a tab: the tab draws as several columns but counts # as one character, so it left the box's right wall ragged. Tabs are expanded # to spaces, and every body row ends its wall in the same column. -tab_box = Call("EXECUTE", inputs=[("EN", Signal("xRun"))], outputs=[("ENO", "dude")], - st_code=["IF dude THEN", "\twhereismycar := TRUE;", "END_IF"]) +tab_box = Call( + "EXECUTE", + inputs=[("EN", Signal("xRun"))], + outputs=[("ENO", "dude")], + st_code=["IF dude THEN", "\twhereismycar := TRUE;", "END_IF"], +) tab_art = fbd_render.render_network(Network("", [tab_box])) check("execute tab: no tab survives into the box", not any("\t" in line for line in tab_art)) -check("execute tab: the tabbed line is inside the box", any(U["V"] + " whereismycar := TRUE;" in line for line in tab_art)) +check( + "execute tab: the tabbed line is inside the box", + any(U["V"] + " whereismycar := TRUE;" in line for line in tab_art), +) body_rows = [line for line in tab_art if (U["V"] + " ") in line and line.rstrip().endswith(U["V"])] check("execute tab: the box has body rows", len(body_rows) >= 3) -check_equal("execute tab: every body row's wall ends in one column", len(set(len(line.rstrip()) for line in body_rows)), 1) +check_equal( + "execute tab: every body row's wall ends in one column", len(set(len(line.rstrip()) for line in body_rows)), 1 +) # --- language dispatch ----------------------------------------------------- diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py index 4341ad0..e854a3b 100644 --- a/tools/ladder/tests/test_ladder.py +++ b/tools/ladder/tests/test_ladder.py @@ -20,11 +20,12 @@ sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) sys.path.insert(0, os.path.join(HERE, "..")) +from render import write # noqa: E402 + import charset # noqa: E402 from ld_render import render_declaration, render_pou # noqa: E402 from model import COIL, CONTACT, LABEL, Element, Parallel, Series # noqa: E402 from parse_ld import parse_pous # noqa: E402 -from render import write # noqa: E402 FIXTURES = os.path.join(HERE, "fixtures") SOURCE = os.path.join(FIXTURES, "motor_control.plcopen.xml") @@ -178,6 +179,7 @@ def check_equal(name, actual, expected): check("unicode is restored afterwards", any(U["V"] in line for line in render_pou(pou))) check_equal("both charsets produce the same shape", len(ascii_rendered), len(rendered)) + def check_golden(name, rendered_lines, golden_path): # Goldens hold box-drawing characters, so the encoding cannot be left to # the platform default - and neither can printing them on a mismatch. @@ -244,11 +246,16 @@ def check_golden(name, rendered_lines, golden_path): # A label is the network's own, so it is written under the network's header # the way the export writes it - and never as a rung. check("fidelity: label heads its network", "SKIP:" in fidelity_art) -check("fidelity: label is not drawn as a rung", not any("SKIP:" in line and U["T_RIGHT"] in line for line in fidelity_art)) +check( + "fidelity: label is not drawn as a rung", not any("SKIP:" in line and U["T_RIGHT"] in line for line in fidelity_art) +) # A jump target is program structure, not documentation: it is written the # way ST writes it, and not inside the delimiters this file uses for comments. check("fidelity: label reaches ST", "SKIP:" in fidelity_st) -check("fidelity: the label is not dressed as a comment", not any("(* label" in line for line in fidelity_st + fidelity_art)) +check( + "fidelity: the label is not dressed as a comment", + not any("(* label" in line for line in fidelity_st + fidelity_art), +) # model.Signal's docstring warns that dropping negated inverts the logic; the # LD block-pin path did exactly that. @@ -277,7 +284,9 @@ def check_golden(name, rendered_lines, golden_path): # The negation bubble on the block's own pins: a negated power input and a # negated, assigned output pin. Both inverted silently. check("fidelity: negated power pin inverts in ST", any("tmr2(IN := NOT xRun);" in line for line in fidelity_st)) -check("fidelity: negated output pin inverts its assignment", any("xCool := NOT tmr2.Q;" in line for line in fidelity_st)) +check( + "fidelity: negated output pin inverts its assignment", any("xCool := NOT tmr2.Q;" in line for line in fidelity_st) +) check("fidelity: negated output pin is marked in the diagram", any("Q =o> xCool" in line for line in fidelity_art)) # A negated output consumed through a SIDE PIN goes via expr_to_text, a @@ -354,7 +363,10 @@ def check_golden(name, rendered_lines, golden_path): # A value feeding a side pin is drawn to the left of the box on a wire into # the pin, the way the editor draws it. Written inside as "PT := T#2S" it # reads as part of the pin name, and widens the box by every value in it. -check("two coils: the pin value sits outside the box", any("T#2S" + U["H"] * 2 + U["PIN_L"] + "PT" in line for line in two_coils_art)) +check( + "two coils: the pin value sits outside the box", + any("T#2S" + U["H"] * 2 + U["PIN_L"] + "PT" in line for line in two_coils_art), +) check("two coils: no value is left inside a box", not any(" := " in line for line in two_coils_art)) # The same shape in a real SP11 export: LDTesting with one coil added to its @@ -407,7 +419,10 @@ def check_golden(name, rendered_lines, golden_path): # The contact form, which already worked, must keep working: same spelling in # the ST, same letter in the diagram. check("ld pin edge: a contact still triggers", "xEdge := R(xA);" in pin_edge_st) -check("ld pin edge: a contact still draws its P", any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in line for line in pin_edge_art)) +check( + "ld pin edge: a contact still draws its P", + any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in line for line in pin_edge_art), +) # --- network comments, and a network that holds only one --------------------- @@ -479,9 +494,14 @@ def check_golden(name, rendered_lines, golden_path): def _execute(power_negated=False, power_edge=None): return Element( - kind="block", type_name="EXECUTE", input_pins=[("EN", None)], - output_pins=[("ENO", None)], st_code=["a := 1;"], - power_negated=power_negated, power_edge=power_edge, active_output="ENO", + kind="block", + type_name="EXECUTE", + input_pins=[("EN", None)], + output_pins=[("ENO", None)], + st_code=["a := 1;"], + power_negated=power_negated, + power_edge=power_edge, + active_output="ENO", ) @@ -512,7 +532,9 @@ def _contact(name): check("operator reuse: the operator is not referenced in brackets", not any("[OR" in line for line in opr_art)) check_equal("operator reuse: the shared box is drawn once", len([l for l in opr_art if "In1 Out1" in l]), 1) check("operator reuse: the readers branch off the box", any(U["T_DOWN"] in l for l in opr_art)) -check("operator reuse: both coils are still driven", any("xA" in l for l in opr_art) and any("xB" in l for l in opr_art)) +check( + "operator reuse: both coils are still driven", any("xA" in l for l in opr_art) and any("xB" in l for l in opr_art) +) # --- a head shared by several branches is drawn once ------------------------- @@ -528,7 +550,10 @@ def _contact(name): # xGo feeds two coils. It is drawn once, then the wire branches to each coil. check_equal("shared prefix: the shared contact is drawn once", len([l for l in shared_art if "xGo" in l]), 1) -check("shared prefix: both coils are still drawn", any("xA" in l for l in shared_art) and any("xB" in l for l in shared_art)) +check( + "shared prefix: both coils are still drawn", + any("xA" in l for l in shared_art) and any("xB" in l for l in shared_art), +) check("shared prefix: the wire branches after the contact", any(U["T_DOWN"] in l for l in shared_art)) # The two coils sit on their own rows, one per branch. xa_row = [i for i, l in enumerate(shared_art) if "xA" in l][0] @@ -556,11 +581,16 @@ def _contact(name): shared_box_art = render_pou(shared_box_pou) check_equal("shared box sinks: the box is drawn once", len([l for l in shared_box_art if "In2 Out2" in l]), 1) -check_equal("shared box sinks: one rung header, one network", len([l for l in shared_box_art if l.startswith("(* Network")]), 1) +check_equal( + "shared box sinks: one rung header, one network", len([l for l in shared_box_art if l.startswith("(* Network")]), 1 +) check("shared box sinks: the box output branches", any(U["T_DOWN"] in l for l in shared_box_art)) check("shared box sinks: the RETURN is drawn", any("" in l for l in shared_box_art)) check("shared box sinks: the coil branch is drawn", any("( )" in l for l in shared_box_art)) -check("shared box sinks: the edge contact is on the coil branch", any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in l for l in shared_box_art)) +check( + "shared box sinks: the edge contact is on the coil branch", + any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in l for l in shared_box_art), +) # The two sinks are on their own rows, not stacked into one. return_row = [i for i, l in enumerate(shared_box_art) if "" in l][0] coil_row = [i for i, l in enumerate(shared_box_art) if "( )" in l][0] @@ -588,7 +618,10 @@ def _contact(name): check("unnamed pin: the box output branches", any(U["T_DOWN"] in l for l in unnamed_art)) check("unnamed pin: the consumed output is teed", any("ENO" + U["PIN_R"] in l for l in unnamed_art)) check("unnamed pin: no untee'd wire runs from the box", not any("ENO" + U["V"] + U["H"] in l for l in unnamed_art)) -check("unnamed pin: RETURN and the coil are both drawn", any("" in l for l in unnamed_art) and any("( )" in l for l in unnamed_art)) +check( + "unnamed pin: RETURN and the coil are both drawn", + any("" in l for l in unnamed_art) and any("( )" in l for l in unnamed_art), +) # --- elements are the same only when they are the same node ------------------ @@ -610,15 +643,24 @@ def _contact(name): ALIKE = os.path.join(FIXTURES, "ld-two-execute-boxes-alike.xml") alike_art = render_pou(parse_pous(ALIKE)[0]) -check_equal("alike boxes: two EXECUTE boxes are drawn", len([l for l in alike_art if l.strip(U["V"] + " ") == "EXECUTE"]), 2) +check_equal( + "alike boxes: two EXECUTE boxes are drawn", len([l for l in alike_art if l.strip(U["V"] + " ") == "EXECUTE"]), 2 +) check_equal("alike boxes: each body is drawn", len([l for l in alike_art if "nCount := nCount + 1;" in l]), 2) # The control: one EXECUTE box read by two coils is still one box, one body. EXECUTE_TWO_COILS = os.path.join(FIXTURES, "ld-execute-two-coils.xml") execute_two_coils_art = render_pou(parse_pous(EXECUTE_TWO_COILS)[0]) -check_equal("one execute, two coils: one box", len([l for l in execute_two_coils_art if l.strip(U["V"] + " ") == "EXECUTE"]), 1) -check_equal("one execute, two coils: the body once", len([l for l in execute_two_coils_art if "nCount := nCount + 1;" in l]), 1) -check("one execute, two coils: both coils", any("xA" in l for l in execute_two_coils_art) and any("xB" in l for l in execute_two_coils_art)) +check_equal( + "one execute, two coils: one box", len([l for l in execute_two_coils_art if l.strip(U["V"] + " ") == "EXECUTE"]), 1 +) +check_equal( + "one execute, two coils: the body once", len([l for l in execute_two_coils_art if "nCount := nCount + 1;" in l]), 1 +) +check( + "one execute, two coils: both coils", + any("xA" in l for l in execute_two_coils_art) and any("xB" in l for l in execute_two_coils_art), +) # One box read on two different pins was redrawn for the second pin, because an # operator has no instance to name, and the copies differ in their active pin so @@ -654,7 +696,10 @@ def _contact(name): [(True, False), (False, True)], ) check("numbered boxes: no reference is left unnumbered", not any("[ADD.ENO]" in l for l in two_operators_art)) -check("numbered boxes: swapping the coils between the boxes changes the export", two_operators_art != two_operators_swapped_art) +check( + "numbered boxes: swapping the coils between the boxes changes the export", + two_operators_art != two_operators_swapped_art, +) # A box the text names only inside a side pin's caption - here through a # parallel branch into a counter's RESET - is named all the same, so it is # numbered too. It was not, and rewiring the reset from one AND box to the @@ -663,9 +708,19 @@ def _contact(name): IN_CAPTION_SWAPPED = os.path.join(FIXTURES, "ld-operator-named-in-a-nested-pin-caption-swapped.xml") in_caption_art = render_pou(parse_pous(IN_CAPTION)[0]) in_caption_swapped_art = render_pou(parse_pous(IN_CAPTION_SWAPPED)[0]) -check("numbered in a caption: both boxes are numbered", any(l.strip(U["V"] + " ") == "AND #1" for l in in_caption_art) and any(l.strip(U["V"] + " ") == "AND #2" for l in in_caption_art)) -check("numbered in a caption: the caption names a numbered box", any("RESET" in l and "AND#1.Out1" in l for l in in_caption_art)) -check("numbered in a caption: moving the reset to the other box changes the export", in_caption_art != in_caption_swapped_art) +check( + "numbered in a caption: both boxes are numbered", + any(l.strip(U["V"] + " ") == "AND #1" for l in in_caption_art) + and any(l.strip(U["V"] + " ") == "AND #2" for l in in_caption_art), +) +check( + "numbered in a caption: the caption names a numbered box", + any("RESET" in l and "AND#1.Out1" in l for l in in_caption_art), +) +check( + "numbered in a caption: moving the reset to the other box changes the export", + in_caption_art != in_caption_swapped_art, +) # A single box of a type is not numbered, and neither are two that the text never names. check("numbered boxes: a lone box keeps its plain name", not any("#" in l for l in two_pins_art)) @@ -706,7 +761,10 @@ def box_art(name): hoisted_then_art = box_art("ld-hoisted-operator-then-read-on-its-pin.xml") check_equal("one box: a box on a rung and hoisted into a pin is drawn once", title_rows(hoisted_then_art, "ADD"), 1) check("one box: the rung's store is still drawn", any("iSum" in l for l in hoisted_then_art)) -check("one box: the pin still reads the box", any("ADD.Out1" + U["H"] * 2 + U["PIN_L"] + "In2" in l for l in hoisted_then_art)) +check( + "one box: the pin still reads the box", + any("ADD.Out1" + U["H"] * 2 + U["PIN_L"] + "In2" in l for l in hoisted_then_art), +) shapes_art = box_art("ld-operator-read-by-two-rung-shapes.xml") check_equal("one box: a box read by rungs of two shapes is drawn once", title_rows(shapes_art, "ADD"), 1) @@ -719,10 +777,17 @@ def box_art(name): check_equal("one box: the box that reads it is drawn once", title_rows(or_art, "AND"), 1) exec_mixed_art = box_art("ld-execute-read-by-a-coil-and-a-contact.xml") -check_equal("one box: two EXECUTE boxes are drawn", title_rows(exec_mixed_art, "EXECUTE #1") + title_rows(exec_mixed_art, "EXECUTE #2"), 2) +check_equal( + "one box: two EXECUTE boxes are drawn", + title_rows(exec_mixed_art, "EXECUTE #1") + title_rows(exec_mixed_art, "EXECUTE #2"), + 2, +) check_equal("one box: the first body once", len([l for l in exec_mixed_art if "nA := nA + 1;" in l]), 1) check_equal("one box: the second body once", len([l for l in exec_mixed_art if "nB := nB + 1;" in l]), 1) -check("one box: the contact's rung reads the first box by name", any("[EXECUTE#1.ENO]" in l and "xK" not in l for l in exec_mixed_art)) +check( + "one box: the contact's rung reads the first box by name", + any("[EXECUTE#1.ENO]" in l and "xK" not in l for l in exec_mixed_art), +) check_equal("one box: the contact feeding both boxes is drawn once", len([l for l in exec_mixed_art if "xGo" in l]), 1) check_equal( "one box: every coil still reaches the right rail", @@ -742,8 +807,16 @@ def box_art(name): check("first build: the timer's preset is drawn", any("T#5S" in l for l in behind_timer_art)) check_equal("first build: the box behind it is drawn once", title_rows(behind_timer_art, "GT"), 1) check("first build: a different preset changes the export", behind_timer_art != behind_timer_pt10_art) -check_equal("first build: a later rung merged forward keeps the timer", title_rows(box_art("ld-box-behind-a-timer-read-by-a-later-rung.xml"), "tmr : TON"), 1) -check_equal("first build: a later rung's hoist keeps the timer", title_rows(box_art("ld-box-behind-a-timer-hoisted-by-a-later-rung.xml"), "tmr : TON"), 1) +check_equal( + "first build: a later rung merged forward keeps the timer", + title_rows(box_art("ld-box-behind-a-timer-read-by-a-later-rung.xml"), "tmr : TON"), + 1, +) +check_equal( + "first build: a later rung's hoist keeps the timer", + title_rows(box_art("ld-box-behind-a-timer-hoisted-by-a-later-rung.xml"), "tmr : TON"), + 1, +) # Naming one repeated box can break the fusion of the box after it: the copies # of MUL no longer shared a head once one copy of ADD was named, and MUL was @@ -752,7 +825,10 @@ def box_art(name): behind_repeat_art = box_art("ld-box-feeding-two-coils-behind-a-repeated-box.xml") check_equal("repeats: the box behind a named box is drawn once", title_rows(behind_repeat_art, "MUL"), 1) check_equal("repeats: the named box is drawn once", title_rows(behind_repeat_art, "ADD"), 1) -check("repeats: one MUL and two MUL boxes export differently", behind_repeat_art != box_art("ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml")) +check( + "repeats: one MUL and two MUL boxes export differently", + behind_repeat_art != box_art("ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml"), +) def label_count(art, name): @@ -769,12 +845,24 @@ def label_count(art, name): check_equal("inner runs: the shared contact is drawn once", label_count(split_art, "xGo"), 1) check_equal("inner runs: the box read twice is drawn once", title_rows(split_art, "ADD"), 1) check_equal("inner runs: the other box is drawn once", title_rows(split_art, "SUB"), 1) -check_equal("inner runs: every coil reaches the right rail", len([l for l in split_art if "( )" in l and l.rstrip().endswith(U["T_LEFT"])]), 3) +check_equal( + "inner runs: every coil reaches the right rail", + len([l for l in split_art if "( )" in l and l.rstrip().endswith(U["T_LEFT"])]), + 3, +) chain_art = box_art("ld-contact-chain-shared-by-part-of-a-run.xml") -check_equal("inner runs: every contact in a partly shared chain is drawn once", [label_count(chain_art, name) for name in ("c1", "c2", "c3", "c4", "c5", "c9")], [1, 1, 1, 1, 1, 1]) +check_equal( + "inner runs: every contact in a partly shared chain is drawn once", + [label_count(chain_art, name) for name in ("c1", "c2", "c3", "c4", "c5", "c9")], + [1, 1, 1, 1, 1, 1], +) each_art = box_art("ld-two-boxes-two-coils-each.xml") check_equal("inner runs: two boxes with two coils each, contact once", label_count(each_art, "xGo"), 1) -check_equal("inner runs: two boxes with two coils each, one of each box", [title_rows(each_art, "ADD"), title_rows(each_art, "SUB")], [1, 1]) +check_equal( + "inner runs: two boxes with two coils each, one of each box", + [title_rows(each_art, "ADD"), title_rows(each_art, "SUB")], + [1, 1], +) # A pin can list the same source twice. Its branches are then one node sequence # twice, the whole of each is the shared head, and nothing is left after it. @@ -795,8 +883,15 @@ def factors(expr): twin = Element(kind=CONTACT, label="xGo", local_id="1") check("identical branches: two empty branches factor", factors(Parallel([Empty(), Empty()]))) check("identical branches: two copies of one contact factor", factors(Parallel([twin, twin]))) -check("identical branches: two copies of one chain factor", factors(Parallel([Series([twin, twin]), Series([twin, twin])]))) -for name in ("ld-two-connections-from-one-pin-into-a-coil.xml", "ld-two-connections-from-one-contact.xml", "ld-two-connections-into-one-box-pin.xml"): +check( + "identical branches: two copies of one chain factor", + factors(Parallel([Series([twin, twin]), Series([twin, twin])])), +) +for name in ( + "ld-two-connections-from-one-pin-into-a-coil.xml", + "ld-two-connections-from-one-contact.xml", + "ld-two-connections-into-one-box-pin.xml", +): try: rendered = box_art(name) except RuntimeError: @@ -807,7 +902,11 @@ def factors(expr): # branch, was named in the caption and drawn nowhere, and the caption listed the # box's own enable as a term of the pin's condition. It is hoisted and drawn. nested_only_art = box_art("ld-operator-only-in-a-nested-caption.xml") -check_equal("nested caption: both ADD boxes are drawn", title_rows(nested_only_art, "ADD #1") + title_rows(nested_only_art, "ADD #2"), 2) +check_equal( + "nested caption: both ADD boxes are drawn", + title_rows(nested_only_art, "ADD #1") + title_rows(nested_only_art, "ADD #2"), + 2, +) check( "nested caption: the caption reads the box, not its enable", any("(ADD#2.Out1 OR xC) AND xM" + U["H"] * 2 + U["PIN_L"] + "In1" in l for l in nested_only_art), @@ -818,8 +917,16 @@ def factors(expr): # no longer drew the same, so the AND box was drawn twice. SIDE_INSTANCE = os.path.join(FIXTURES, "ld-operator-with-instance-side-pin.xml") side_instance_art = render_pou(parse_pous(SIDE_INSTANCE)[0]) -check_equal("operator with an instance side pin: the box is drawn once", len([l for l in side_instance_art if "In1 Out1" in l]), 1) -check_equal("operator with an instance side pin: the timer is drawn once", len([l for l in side_instance_art if "tmr : TON" in l]), 1) +check_equal( + "operator with an instance side pin: the box is drawn once", + len([l for l in side_instance_art if "In1 Out1" in l]), + 1, +) +check_equal( + "operator with an instance side pin: the timer is drawn once", + len([l for l in side_instance_art if "tmr : TON" in l]), + 1, +) check( "operator with an instance side pin: both coils", any("xA" in l for l in side_instance_art) and any("xB" in l for l in side_instance_art), @@ -838,9 +945,15 @@ def factors(expr): side_pin_st = st_render.render_pou(side_pin_pou) reset_row = [l for l in side_pin_art if "RESET" in l][0] -check("side pin: the reset contact is drawn with its symbol", "xClear " + U["CONTACT_L"] + "P" + U["CONTACT_R"] in reset_row) +check( + "side pin: the reset contact is drawn with its symbol", + "xClear " + U["CONTACT_L"] + "P" + U["CONTACT_R"] in reset_row, +) check("side pin: the reset is not flattened to text", not any("R(xClear)" in l for l in side_pin_art)) -check("side pin: the contact is wired into the pin", "xClear " + U["CONTACT_L"] + "P" + U["CONTACT_R"] + U["H"] * 2 + U["PIN_L"] + "RESET" in reset_row) +check( + "side pin: the contact is wired into the pin", + "xClear " + U["CONTACT_L"] + "P" + U["CONTACT_R"] + U["H"] * 2 + U["PIN_L"] + "RESET" in reset_row, +) # A literal side pin (PV := 10) is untouched. pv_row = [l for l in side_pin_art if "PV" in l][0] check("side pin: a literal side pin stays a value", "10" in pv_row and U["PIN_L"] + "PV" in pv_row) @@ -856,10 +969,19 @@ def factors(expr): reset_row = [l for l in marked_pin_art if "RESET" in l][0] load_row = [l for l in marked_pin_art if "LOAD" in l][0] cd_row = [l for l in marked_pin_art if "CD" in l][0] -check("marked pin: a negated pin draws its bubble on the wall", "xClear " + U["CONTACT_L"] + " " + U["CONTACT_R"] + U["H"] in reset_row and U["H"] + "oRESET" in reset_row) -check("marked pin: an edge pin draws its P on the wall", "xLoad " + U["CONTACT_L"] + " " + U["CONTACT_R"] + U["H"] in load_row and U["H"] + "PLOAD" in load_row) +check( + "marked pin: a negated pin draws its bubble on the wall", + "xClear " + U["CONTACT_L"] + " " + U["CONTACT_R"] + U["H"] in reset_row and U["H"] + "oRESET" in reset_row, +) +check( + "marked pin: an edge pin draws its P on the wall", + "xLoad " + U["CONTACT_L"] + " " + U["CONTACT_R"] + U["H"] in load_row and U["H"] + "PLOAD" in load_row, +) check("marked pin: a pin with both keeps the caption", "R(NOT xDown)" + U["H"] * 2 + U["PIN_L"] + "CD" in cd_row) -check("marked pin: the ST keeps every mark", any("RESET := NOT xClear, LOAD := R(xLoad), CD := R(NOT xDown)" in l for l in marked_pin_st)) +check( + "marked pin: the ST keeps every mark", + any("RESET := NOT xClear, LOAD := R(xLoad), CD := R(NOT xDown)" in l for l in marked_pin_st), +) # --- byte order mark ------------------------------------------------------- @@ -907,7 +1029,7 @@ def factors(expr): # The parser CODESYS ships works byte-wise and rejects UTF-8 multi-byte # sequences, so one degree sign in a comment loses the whole POU. Numeric # character references are ASCII and every parser expands them identically. -DEGREE = b'Temp \xc2\xb0C' +DEGREE = b"Temp \xc2\xb0C" check_equal( "non-ASCII becomes a numeric character reference", @@ -922,7 +1044,7 @@ def factors(expr): check_equal( "the character survives the round trip", ET.fromstring(plcopen.read_document(io.BytesIO(DEGREE)))[0].text, - u"Temp \u00b0C", + "Temp \u00b0C", ) check_equal( "pure ASCII documents are left alone", @@ -979,11 +1101,13 @@ def with_interface(interface): PLAINTEXT_INTERFACE = ( - "" + '' '' "" + DECLARATION + "" ) -STRUCTURED_INTERFACE = '' +STRUCTURED_INTERFACE = ( + '' +) plain_pou = parse_pous(with_interface(PLAINTEXT_INTERFACE))[0] check_equal("the plaintext declaration is picked up", plain_pou.declaration_text, DECLARATION) @@ -1002,7 +1126,10 @@ def with_interface(interface): check_equal("no plaintext means none is invented", structured_pou.declaration_text, None) check_equal("the structured interface is the fallback", render_declaration(structured_pou)[1], "PROGRAM PLAIN") check("the fallback says it is one", render_declaration(structured_pou)[0].startswith("(* Declaration rebuilt")) -check("the fallback still lists the variable", any("xStart : BOOL;" in line for line in render_declaration(structured_pou))) +check( + "the fallback still lists the variable", + any("xStart : BOOL;" in line for line in render_declaration(structured_pou)), +) # The shape CODESYS actually writes, confirmed by diagnosing a real project: # a data element named ".../interfaceasplaintext", sitting at POU level rather @@ -1010,8 +1137,8 @@ def with_interface(interface): # The first two attempts at this searched only inside , and then # only two levels down. REAL_SHAPE = ( - "" - "' + '' + DECLARATION + "" diff --git a/tools/ladder/tests/test_xmlbackend.py b/tools/ladder/tests/test_xmlbackend.py index 8070114..0c9e8d2 100644 --- a/tools/ladder/tests/test_xmlbackend.py +++ b/tools/ladder/tests/test_xmlbackend.py @@ -19,9 +19,10 @@ sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) sys.path.insert(0, os.path.join(HERE, "..")) +from render import write # noqa: E402 + import plcopen # noqa: E402 import xmlbackend # noqa: E402 -from render import write # noqa: E402 FIXTURES = os.path.join(HERE, "fixtures") CODESYS = os.path.join(FIXTURES, "codesys") @@ -72,7 +73,7 @@ def every_fixture(): SAMPLE = ( b'' b'' - b"texttail" + b'texttail' b"" b"" ) @@ -199,7 +200,10 @@ def attribute_values(elem, names): rendered[backend] = lines finally: xmlbackend.use(previous) - check(name + ": both backends render identically", rendered[xmlbackend.ELEMENT_TREE] == rendered[xmlbackend.SYSTEM_XML]) + check( + name + ": both backends render identically", + rendered[xmlbackend.ELEMENT_TREE] == rendered[xmlbackend.SYSTEM_XML], + ) print("") if failures: diff --git a/tools/ladder/write_st.py b/tools/ladder/write_st.py index fc77db7..e953d5a 100644 --- a/tools/ladder/write_st.py +++ b/tools/ladder/write_st.py @@ -41,11 +41,12 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "..", "src")) +from render import write # noqa: E402 + import parse_fbd # noqa: E402 import parse_ld # noqa: E402 import plcopen # noqa: E402 import st_render # noqa: E402 -from render import write # noqa: E402 # Language -> parser. Kept here rather than imported from graphical_export, # which needs the CODESYS scriptengine module to load at all.