Skip to content

Render graphical POUs as readable text on export - #36

Merged
gsokoll merged 92 commits into
greenforge-labs:mainfrom
kehindepeters:feat/ladder-renderer
Sep 16, 2026
Merged

gsokoll merged 92 commits into
greenforge-labs:mainfrom
kehindepeters:feat/ladder-renderer

Conversation

@kehindepeters

@kehindepeters kehindepeters commented Aug 5, 2026

Copy link
Copy Markdown

Ladder and Function Block Diagram POUs export as native CODESYS xml, which git can store but nobody can review. This renders them to text alongside that xml, so graphical logic gets the same diffable history the .st files already have.

A graphical POU now exports Main.xml as before, plus Main.txt:

PROGRAM LD_TEST
VAR
	Sensor1: BOOL;
	PowerOn: BOOL;
	TON_0: TON;
	CTU_0: CTU;
	PowerOff: BOOL;
END_VAR

(* Network 1: Try me Codesys I swear *)
(* comment without backslash *)
│    Sensor1   Sensor2  PowerOn
├──┬───┤ ├───┬───┤/├──────(S)─────┤
│  │ sensor3 │
│  └───┤ ├───┘

(* 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         │
│                                           └───────────┘

Each network is headed by its title, as the editor heads it, with the network's comment on the line below. Variables are greppable for the first time: Ctrl+Shift+F for a tag now finds every network that touches it.

Numbering matches the editor

A network in the file lines up with the one in CODESYS, so opening "Network 5" in the editor and reading "Network 5" in the file gives you the same logic.

That takes reading the native xml as well as the PLCopen 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 survives 1..n drifts from what the editor shows, silently.

So the native xml written beside the rendering is the authority for the structure — how many networks there are, their order, and each one's comment, title and label — and the PLCopen export supplies only the logic. The two are joined by matching the networks that carry logic, in order, so there is no position to guess at. Networks the export left out 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.

The contract

The .txt is derived and read-only. The native xml stays the only thing Import From Files reads, so the round trip is untouched and editing the .txt achieves nothing.

That safety is incidental — import_directory_child dispatches on .xml and .st, so .txt matches no branch. Incidental safety breaks silently, so test_export.py pins it down, with a control asserting the native xml still imports (otherwise the test would pass even if dispatch were broken outright).

Design notes

  • Layout comes from topology, not coordinates. Moving a block in the CODESYS editor produces no diff. A test shifts every <position x=> by 500px and asserts byte-identical output. Real CODESYS exports write x="0" y="0" on everything anyway, so coordinates were never usable.
  • Rendering goes via export_xml (PLCopen), not the native format. PLCopen has a published schema for graphical bodies; the native format does not. The native xml is read only for the network list described above.
  • A rendering failure is reported, not raised. The native xml is already written and complete; a diagram we cannot draw is not a reason to fail an otherwise good export. This bends the repo's usual loud-failure stance deliberately, because the artifact is optional. It paid off: the first real-project run failed on every POU and still produced a correct export. The summary counts how many POUs were rendered, how many failed, and which were skipped for having no renderable body.
  • Glyphs are escape sequences, never literal characters. IronPython 2.7 enforces PEP 263 and would refuse to load a source file containing a non-ASCII byte, so a literal box character anywhere in src/ stops CODESYS loading the scripts at all. --charset ascii is built and tested for terminals that mangle box drawing.
  • The declaration is taken verbatim from CODESYS, not rebuilt. It comes from obj.textual_declaration.text, which is the only form carrying comments, pragmas and attributes — and a pragma like qualified_only changes what the code means. The structured interface has nowhere to put any of the three, so it is kept only as a fallback for builds that do not offer the plaintext declaration. A rendering that falls back says so on its first line, and a variable whose type the export omits reads UNKNOWN rather than being assumed to be a BOOL. Confirmed on a real project: 22 of 25 POUs carry their header block, inline comments and the literals as the author typed them (T#10S, not the normalised TIME#10s0ms).
  • A block read through two of its output pins is one block. It is called once in the program, so it is drawn once and called once in the rendering, with its readers branching off the pin they read. Where a reader sits behind another box, the box is still drawn once and the branch runs to it.
  • Values and stores are drawn outside the box. A value feeding a side pin, and a store written on an output pin, sit on wires either side of the box the way the editor draws them, rather than being written inside among the pin names.
  • Jumps and labels are written the way ST writes themJMP ByeBye; and ByeBye: — not inside comment delimiters. They are program structure: something jumps to the label, and removing it changes what runs.
  • An equivalent-ST rendering is available but not written by the export. The ST says things a single-wire diagram cannot, but showing each network twice reads worse than showing it once. tools/ladder/write_st.py writes it on demand, and its docstring says how to put it back on the export path.

What only a real project could find

None of these could have been caught by CI. CODESYS puts its own ScriptLib ahead of the standard library, and that XML parser is stricter and slower than anything runnable outside CODESYS.

  • A UTF-8 BOM broke every POU. export_xml writes one; CODESYS's bundled ElementTree rejects it as illegal data at start of file. CPython's expat and stock IronPython both accept it silently.
  • A single non-ASCII character broke one POU. That parser works byte-wise, so one degree sign in a comment lost the whole POU. Non-ASCII is now rewritten as numeric character references, with a test asserting the character survives the round trip rather than merely that parsing stopped failing. A character outside the Basic Multilingual Plane needs its surrogate pair recombining first, or the two halves are written separately and no parser outside CODESYS accepts the result.
  • Jumps, inline ST and negated inputs were silently dropped — the rendering looked complete while a guard clause and a body of inline ST were simply absent. Worse than an error.
  • Every fan-out was split into two networks. A block driving two outputs is one network in the editor; treating each output as its own network duplicated the shared expression and left the numbering disagreeing with CODESYS.
  • An action rendered its parent POU. PLCopen has no top-level element for an action, so CODESYS exports the parent with the member nested inside it, parent body included. Rendering the POU's own body drew the parent's networks under the action's filename — a file named for one POU describing another.

Tests now assert on the bytes handed to the parser, not on whether a parse succeeds, so this class of bug is checkable in CI.

Performance

Measured on a real 25-POU project, then reduced from 6.8s to 1.7s:

first measurement now
CODESYS export_xml 0.2s 0.2s
parsing 6.4s 1.2s
drawing 0.3s 0.3s

Every export prints that split. It is worth saying that the first guess — that the CODESYS-side export_xml would dominate — was wrong by an order of magnitude, and so was the second; only the measurements were right, which is why the instrumentation ships rather than being a debugging aid that got stripped out.

Parsing goes through .NET's System.Xml under CODESYS, because the ElementTree shipped in ScriptLib parses in pure Python. test_xmlbackend.py compares the two backends element for element, attribute for attribute, and on rendered output — it can only run where both exist, which is the IronPython CI job, and it prints a SKIPPED banner rather than passing quietly anywhere else. It caught two real divergences on its first two runs, neither of which changed output yet: whitespace nodes being dropped, and CR surviving line-ending normalisation.

Not covered

  • SFC and CFC are not rendered. They parse to nothing renderable and are skipped rather than writing an empty file, and the export summary names them. SFC is the obvious follow-up and SFCTesting.xml is already a fixture.
  • A library reference pinned at version * shows the * rather than what it resolves to. A placeholder reference does resolve.

Tests

439 checks under CPython and 511 under IronPython 2.7, across four suites, run in CI under both. Fixtures include real CODESYS V3.5 SP11 exports for LD, FBD and SFC, plus hand-authored ones for shapes the real exports happen not to contain. The GraphicalTesting folder is a worked example of what the export writes, and a test reads its native xml and asserts the committed renderings still agree with it, so a renderer change cannot rewrite those files unnoticed.

src/script_diagnose_xml.py is included deliberately: it diagnosed the BOM in a single run, and CODESYS's parser has surprised us three times. Happy to drop it if you would rather not carry it.

kehinde and others added 10 commits August 4, 2026 15:58
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
gsokoll and others added 19 commits August 5, 2026 19:45
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.
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.
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.
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The declaration was rebuilt from the structured <interface>, 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <interface> at all, so the search for </interface> 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
<interface> 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 <noreply@anthropic.com>
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
<interface>, 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 <noreply@anthropic.com>
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 <interface>. 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 <noreply@anthropic.com>
kehinde and others added 21 commits September 12, 2026 02:24
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Refreshed the LD_TEST rendering and native xml from CODESYS.
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.
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.
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.
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.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@kehindepeters

Copy link
Copy Markdown
Author

Pushed nine more commits (20eeb1f..1ff84d3) from reviewing a fresh re-export of the test project. No changes to the ST side; these are all about the ladder drawing matching what the CODESYS editor shows.

What changed

  • A contact wired to a block output without naming the pin now tees the box. CODESYS often writes that connection without a formalParameter. The renderer took "no pin named" to mean "nothing reads this box", so the box's wall stayed closed while a rung carried on from it, and two rungs off one ENO (a RETURN beside a coil) were drawn as two copies of the box. They now build identically and merge into one branched rung.
  • A contact feeding a block's side pin is drawn as that contact. A reset or enable read off the rail used to be flattened into the pin caption (R(PowerOff)──┤RESET); it is now drawn as PowerOff ┤P├──┤RESET, the way the editor draws it. Literals and expressions on side pins are unchanged. The ST keeps the flattened form, since it cannot draw a second wire.
  • A pin's own bubble or edge marker is kept. Follow-up to the above: where the pin itself carries negated or edge in the PLCopen (a bubble or P/N on the pin, separate from the contact), the drawn contact had dropped it while the ST still said NOT. It now goes on the box wall (o, P or N), in the same place the power pin's already goes; a pin carrying both keeps its text caption, which has room for the pair.
  • Branches that all end in outputs run to the right rail on their own. Two coils off one contact, or a RETURN beside a coil, were drawn as a loop that rejoined on the right. Each is a rung end, so each now reaches the rail separately, and the branch splits on the left only.
  • README sample network updated to match; test project re-exported.

Tests: four new fixtures/checks in test_ladder.py covering each case; all four suites pass under CPython 3 and IronPython 2.7.12. The golden CODESYS fixture changed only where the RESET pin is now drawn as a contact.

One thing still to come: LD_TEST.txt in the tree was exported before the last fix, so its network 3 RESET pin reads ┤RESET where the renderer now writes PRESET (that pin has its own rising edge on top of the P contact). I'll re-export and push that.

kehinde and others added 6 commits September 15, 2026 12:53
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) <noreply@anthropic.com>
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.
…us 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.
…el 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.
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.
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).

@gsokoll gsokoll left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@gsokoll
gsokoll merged commit ee404ce into greenforge-labs:main Sep 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants