Status: implemented.
src/layout/andlayout_v2.py. Sections 1-5, 7, 8 (as_enforce_separation), 9, and §6's meta-tiling (src/layout/compose.py) are live; §6's compartment hulls are not. Measured results are inbenchmarks/results/. Where the design below turned out to be wrong once tested, the correction is marked [revised].
Target: given any subset of a metabolic model, produce a drawing that reads the way a curated KEGG or Escher map does — linear pathway backbones, cycles as rings, cofactors as side branches — rather than the way a force-directed layout does.
The binding requirements the implementation is held to. Code that enforces one of these cites this section.
Decomposition. Cluster from the model's own pathway annotation where it has one, and only fall back to structural community detection where it does not. Clusters hold at most 60 reactions and at least 6; a cluster below the floor is merged rather than drawn.
Geometry. Edges run horizontally or vertically. Two nodes joined by an edge share an x or a y coordinate. Nodes do not overlap, and edges overlap as little as the drawing allows.
Labels. A label never overlaps a node, an edge, or another label. Where a dense cluster leaves no free position at any size in the font ladder, a cofactor label is dropped rather than forced, and the collision is counted (§9) rather than hidden.
Output. One map per cluster, plus an optional whole-model map that keeps global
coordinates. Per-cluster maps are centred and trimmed so the canvas fits the content and its
padding. Every map carries the attribution label
Created by Tianyu Wu (GitHub: forxhunter).
The current engine (process_subsystems.py) minimizes a continuous energy function
(calculate_energy) by simulated annealing over [0,1]² from a random initial position, then
tries to repair the result with snap_to_grid. Three structural reasons this cannot produce t1:
- It optimizes the wrong object. It lays out the raw bipartite reaction–metabolite graph. Curated maps lay out the primary-compound digraph: one directed edge per reaction, between that reaction's main substrate and main product. Everything else is decoration. Without that reduction, every reaction node has degree 4–8 and no planar/orthogonal drawing exists.
- No direction.
nx.Graphis undirected throughout, and stoichiometric signs are discarded (src/export.pyhardcodescoefficient: 1). Every positive template has a global flow axis: catabolism down, inputs at the top/periphery. Flow direction is not an aesthetic preference, it is the thing that makes a map readable. - Continuous optimization cannot produce discrete structure. A ring, a straight 8-node
backbone, and a cofactor stub are combinatorial objects. SA over positions finds a local
minimum that is a blob, and
snap_to_grid's per-edge averaging then collapses nodes together (a degree-4 node cannot axis-align all four edges) and the spiral de-overlap scatters them.
The fix is not a better energy function. It is to make the layout constructive: recognize biological motifs, place them with a motif-specific rule, and use combinatorial layering (Sugiyama) for the glue. Optimization is demoted to a final local polish.
The single highest-leverage step. Turns the hairball into a sparse digraph.
For each reaction r, pick one main substrate → main product pair; classify all other
participants as side metabolites.
def main_pair(rxn):
subs = [m for m,c in rxn.metabolites.items() if c < 0]
prods = [m for m,c in rxn.metabolites.items() if c > 0]
# score each (s, p) candidate pair by conserved-moiety similarity
best = argmax over (s,p) of carbon_overlap(s, p)
return best
carbon_overlap(s, p) without atom mapping, in priority order:
- If both have
met.formula, compute shared heavy-atom counts —min(C_s, C_p) + 0.3 * min(N_s, N_p) + 0.1 * min(P_s, P_p). The pair transferring the most carbon is the KEGG "RPAIR main" pair. This alone gets glycolysis right. - Tie-break by lower global degree (a metabolite appearing in 200 reactions is a cofactor, not a backbone member).
- Tie-break by same compartment.
[revised] A multiplicative or additive cofactor penalty is not enough on its own: CoA → acetyl-CoA shares 21 carbons and outscores pyruvate → acetyl-CoA under any weighting that does not also break other reactions. The implementation applies cofactor-ness as a tier — a cofactor is never a main-pair member while a non-cofactor alternative exists on its side — and falls back to raw chemistry when every participant is currency. Chemistry also cannot break the citrate-synthase tie (acetyl-CoA → citrate vs. oxaloacetate → citrate score within 2%), so near-tied candidates are re-scored by what they do to the backbone: close a cycle, join two components, or continue an existing chain. That is the criterion a curator applies, and it picks oxaloacetate → citrate.
Replace is_currency's hardcoded prefix list with a learned/computed cofactor set:
degree(m) > percentile_90(degree) or m in the curated list or m was never selected
as a main-pair member. The prefix list stays as a prior, not as the rule — it currently misses
q8/q8h2, fad/fadh2, amp, ppi, thf variants, so4, nad(p) compartment variants.
Output: D = DiGraph over primary metabolites, one edge per reaction, plus a
side[r] = (consumed[], produced[]) table. For a 60-reaction subsystem D typically has
~50 nodes and ~60 edges — sparse and drawable.
Edge direction for reversible reactions. In priority order: (a) FBA flux sign from
src/fba.py (revive it — cobra.flux_analysis.pfba on the parent model, cache per model);
(b) direction that minimizes back-edges in a global topological order; (c) rxn.lower_bound >= 0.
Run on D, in this order, each consuming its nodes:
| Motif | Detection | Drawing rule |
|---|---|---|
| Ring | chordless directed cycle, length 3–12, ≥2 external connections | circle/rounded-rect; radius ∝ length; entry node faces incoming layer, exit faces outgoing |
| Backbone chain | maximal path where every interior node has in-deg = out-deg = 1 | straight vertical run, uniform pitch |
| Fan | node with out-deg ≥ 4 into leaves (aminoacyl-tRNA, biomass) | radial fan, leaves on an arc |
| Grid/ladder | repeated 2-step motifs (fatty-acid elongation, nucleotide series) | serpentine rows, as in t4 |
| Residual | everything else | handed to Sugiyama as ordinary nodes |
Contract each detected motif into a super-node with a known bounding box and a set of
labelled ports (entry/exit). t1's TCA ring and t2's cycle collection are exactly this.
Detection notes: use nx.simple_cycles(D, length_bound=12) (NetworkX ≥3.1), keep cycles that are
chordless and node-disjoint, prefer longer ones. cycle_basis on the undirected graph — what v1
uses in get_cycle_nodes — returns a basis, not the biologically meaningful rings, and v1 then
drops them into five hardcoded canvas slots regardless of connectivity.
This replaces optimize_layout_sa entirely. It is deterministic, O(V·E)-ish, and its known
output shape is the target aesthetic: layered flow + long straight vertical chains.
- Cycle removal. Greedy feedback-arc-set (Eades–Lin–Smyth) on the condensed graph; reversed edges are restored at the end and drawn with reversed arrowheads.
- Layer assignment. Network-simplex (
nx.drawing.nx_agraphif Graphviz is available, else implement longest-path + tightening). Layer index = y coordinate. Constraints:- exchange / extracellular metabolites pinned to layer 0 (sources) or layer max (sinks) —
this is the hard-constraint version of v1's soft
peripheral_energy; - same-compartment nodes get a layer-span penalty so compartments stay contiguous.
- exchange / extracellular metabolites pinned to layer 0 (sources) or layer max (sinks) —
this is the hard-constraint version of v1's soft
- Crossing minimization. Median/barycenter sweeps + adjacent-transpose, 8–16 iterations, cluster-constrained so nodes of the same pathway/compartment stay adjacent within a layer.
- X-coordinate assignment. Brandes–Köpf (four-pass alignment + median balancing). This is the step that produces the straight vertical glycolysis spine — it explicitly aligns chains of degree-1-in/degree-1-out nodes. Do not substitute a simple barycenter; it will not straighten.
Layer pitch and in-layer pitch are set from node label widths so labels never collide by construction, rather than being repaired afterwards.
Expand each super-node at its assigned position:
- Ring: place members on a circle of radius
r = pitch * n / (2π). Rotate the ring so the angular position of the entry port points at the incoming edge's direction and the exit port at the outgoing edge's. This is why TCA int1andt2always reads correctly. - Chain: emit at uniform pitch along the layer axis.
- Re-route the edges that entered the super-node to the corresponding member node.
v1 places duplicated currency metabolites at random.random() * 2π around the reaction node,
which is why the output looks like noise. Replace with a fixed convention, which is what Escher
itself and every positive template use:
For reaction edge s → p with unit direction u and left normal n:
midmarker at m = (pos[s] + pos[p]) / 2
consumed side at m - 0.30*L*u + 0.45*L*n # e.g. ATP, H2O in
produced side at m + 0.30*L*u + 0.45*L*n # e.g. ADP, Pi out
Both members of a cofactor pair go on the same side n, producing the classic curved
double-arrow. Side selection for n: alternate per layer, or choose the side with more free space
(cheap: sample a 3×3 occupancy grid). Each cofactor instance is a fresh duplicate node — never a
shared hub. Segments get Bezier control points b1, b2 at ±⅓ along a quadratic bulge so the
stub is drawn as an arc.
- Compartment = the
_c/_m/_e/_psuffix. After coordinates are fixed, draw a rounded convex hull (Escher has no group primitive — emit it as atext_labelplus a reserved margin, or post-process the SVG). Transport reactions are the only edges allowed to cross a hull. - Module (subsystem) packing [implemented, revised]:
compose.pyruns the layered pass on the meta-graph, but then discards its x-coordinates. Brandes-Koepf aligns nodes into columns, which is what straightens a pathway backbone and what wastes space at tile scale -- a column sized for the widest tile leaves a hole wherever a small tile sits in it. What is worth keeping is the layer assignment and the within-layer order (the flow, and the crossing-minimised arrangement), so tiles are shelf-packed in that order into a poster-shaped block, top-aligned per row. Original plan, kept for the record: keep v1's variable row-height/column-width grid but replacekamada_kawai+ gravity compaction with Sugiyama on the meta-graph — pathways have a real hierarchy (central carbon in the middle, biosynthesis radiating outward), and a layered meta-layout reproduces it. Route inter-module edges as orthogonal buses down the gutters, as int2.
Every segment becomes a polyline with ≤2 bends, encoded in Escher's b1/b2. For same-layer or
adjacent-layer edges the bends are trivial; for long edges introduced by layering, insert dummy
nodes during step 3 (standard Sugiyama) and emit them as multimarker nodes — which is also
exactly what Escher's schema expects.
Only after the constructive layout exists, run a short, position-constrained refinement:
- nodes may move at most ±½ pitch;
- objective = label overlaps + edge crossings + edge length, with a hard barrier on node overlap;
- 100–200 steps, not 1000 from random init.
Compute on every emitted map. This table is the code (metrics.py:14-25); where it once
differed from the code, the code won and this was corrected. Metric names are the dict keys
score() returns.
| Key | Definition | Target |
|---|---|---|
axis_aligned |
fraction of straight segments within 1.0 unit of horizontal or vertical | > 0.90 |
crossings_per_edge |
segment crossings / segments | < 0.05 |
longest_run_ratio |
longest merged axis-aligned run, in length units, / total segment length | > 0.15 |
min_separation_ratio |
min pairwise distance between primary metabolites / pitch | ≥ 1.0 |
label_overlaps |
overlapping label-box pairs, boxes eroded 3.0 units | 0 |
label_on_node |
label boxes intersecting a node disc, excluding their own anchors | 0 |
label_on_edge |
label boxes struck by a straight segment | 0 |
hairball_index |
peak / mean bin count over occupied bins of a 20×20 histogram of metabolites | < 3 |
occupancy |
content bbox area / canvas area | 0.10 – 0.85 |
aspect_ratio |
content width / content height | 0.35 – 3.0 |
Also computed, deliberately ungated and reported for context only: nodes, reactions,
connectors, label_shrunk.
Three corrections to what this section claimed before, all in the direction of the code:
- Axis-aligned is measured over straight segments only — a cofactor stub and a ring arc are
curved on purpose, and charging them with being non-orthogonal measures the design rather than
a defect. Inter-tile
link_connectors are curved too but are measured, by their chord, because a connector crossing the network is exactly what the gate exists to catch. The old wording, "axis-aligned or ≤1 bend", described neither. - Longest straight chain is normalised by total segment length, not node count.
- Label overlaps is three metrics, not one: box-vs-box, box-vs-node, box-vs-edge.
Calibration is the weak point of this table, and is being replaced. Every threshold above
was set from two images — nt1/nt2 score ~0.1 axis-aligned and hairball > 15, the t* set
scores well — with no sensitivity analysis and no sample to speak of. A threshold justified by
two JPEGs is not publishable. data/kegg/ holds 1,010 KGML pathways carrying human-drawn
coordinates, which gives an empirical distribution of what curated layout actually scores;
these targets should be re-derived from it and this note removed once they are.
[revised] Two of these targets were also wrong as first written:
- Aspect ratio legitimately sits near 0.15 for an unbranched pathway. Glycolysis is a tall column in every textbook; folding a ten-step chain to hit a target would make it worse. Folding is applied only past 26 layers, where a drawing stops fitting on a page.
- Minimum separation has to be enforced, not just measured. Brandes-Köpf's balancing step takes a per-node median across four alignment runs; because different nodes take their median from different runs it is not a convex combination, and it does produce overlaps on large models. A single left-to-right sweep per layer afterwards makes the guarantee unconditional.
Implemented in src/layout/metrics.py and reported per map by layout_v2.py.
Each step is independently shippable and visibly improves output:
- Blockers first — guard the
torch_geometricimport insrc/parsing.py; emitmidmarker/multimarkernode types and signed coefficients insrc/export.py; strip the(2500, 2500)centering side effect out ofsnap_to_grid. Until (2) is done, Escher renders these maps incorrectly no matter how good the coordinates are. - §1 primary-compound reduction + §5 deterministic cofactor stubs. Biggest visual gain per line of code; works even with the existing SA placement.
- §3 Sugiyama replacing
optimize_layout_saandsnap_to_grid. - §2/§4 motifs (rings first — TCA, urea, Calvin are the most recognizable).
- §7 routing, §6 compartments, §9 metrics gate, §8 polish.