|
| 1 | +# Kinetic Scheme Visualizer -- Architecture & Design |
| 2 | + |
| 3 | +## What This Module Does |
| 4 | + |
| 5 | +Renders kinetic decay schemes from [pyglotaran](https://github.com/glotaran/pyglotaran) models as node-and-arrow diagrams using matplotlib. Given a `Model` and `Parameters`, it extracts rate constant transitions from k-matrices, builds a graph, computes a layout, and draws the result. |
| 6 | + |
| 7 | +## Module Location |
| 8 | + |
| 9 | +``` |
| 10 | +pyglotaran_extras/inspect/kinetic_scheme/ |
| 11 | +``` |
| 12 | + |
| 13 | +## Public API |
| 14 | + |
| 15 | +Exported from `__init__.py`: |
| 16 | + |
| 17 | +| Symbol | Type | Purpose | |
| 18 | +| ------------------------------- | -------------- | ------------------------------------------------ | |
| 19 | +| `show_kinetic_scheme()` | function | Visualize one or more named megacomplexes | |
| 20 | +| `show_dataset_kinetic_scheme()` | function | Visualize all decay megacomplexes for a dataset | |
| 21 | +| `KineticSchemeConfig` | Pydantic model | Full configuration (styling, layout, formatting) | |
| 22 | +| `NodeStyleConfig` | Pydantic model | Per-node style overrides | |
| 23 | + |
| 24 | +Both `show_*` functions return `(Figure, Axes)`. |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +## Four-Layer Architecture |
| 29 | + |
| 30 | +Data flows through four layers in strict sequence. Each layer has exactly one responsibility and one source file. |
| 31 | + |
| 32 | +``` |
| 33 | +Model + Parameters |
| 34 | + | |
| 35 | + v |
| 36 | + Layer 1: EXTRACT _k_matrix_parser.py |
| 37 | + | Transition[] |
| 38 | + v |
| 39 | + Layer 2: BUILD GRAPH _kinetic_graph.py |
| 40 | + | KineticGraph |
| 41 | + v |
| 42 | + Layer 3: LAYOUT _layout.py |
| 43 | + | NodePositions {label: (x,y)} |
| 44 | + v |
| 45 | + Layer 4: RENDER plot_kinetic_scheme.py |
| 46 | + | Figure + Axes |
| 47 | + v |
| 48 | + matplotlib output |
| 49 | +``` |
| 50 | + |
| 51 | +### Layer 1 -- Extract (`_k_matrix_parser.py`) |
| 52 | + |
| 53 | +**Input**: Megacomplex labels + `Model` + `Parameters` |
| 54 | + |
| 55 | +**Output**: `list[Transition]` |
| 56 | + |
| 57 | +Reads pyglotaran k-matrices via `fill_item()` + `get_k_matrix()`. Each matrix entry becomes a `Transition` dataclass: |
| 58 | + |
| 59 | +- **Off-diagonal** `(to, from)`: compartment-to-compartment transfer |
| 60 | +- **Diagonal** `(comp, comp)`: ground state decay (generates a synthetic `"GS{n}"` label) |
| 61 | + |
| 62 | +Key behaviors: |
| 63 | + |
| 64 | +- `extract_transitions()` accepts explicit megacomplex labels |
| 65 | +- `extract_dataset_transitions()` auto-discovers decay megacomplexes for a dataset (silently skips non-decay types) |
| 66 | +- `omit_parameters` filters out specified parameter labels |
| 67 | +- Rate constants are stored in raw ps^-1 units, never rounded at extraction |
| 68 | + |
| 69 | +### Layer 2 -- Build Graph (`_kinetic_graph.py`) |
| 70 | + |
| 71 | +**Input**: `list[Transition]` |
| 72 | + |
| 73 | +**Output**: `KineticGraph` |
| 74 | + |
| 75 | +Three dataclasses: |
| 76 | + |
| 77 | +| Class | Fields | Role | |
| 78 | +| -------------- | -------------------------------------------------------------------------- | ----------------------- | |
| 79 | +| `KineticNode` | `label`, `display_label`, `is_ground_state`, `megacomplex_labels`, `color` | Node in the scheme | |
| 80 | +| `KineticEdge` | `source`, `target`, `rate_constant_ps_inverse`, `parameter_label` | Directed edge with rate | |
| 81 | +| `KineticGraph` | `nodes`, `edges`, `_adjacency`, `_reverse_adjacency` | Lightweight digraph | |
| 82 | + |
| 83 | +**Design decision: no networkx dependency.** Kinetic schemes are typically 3-20 nodes. A custom graph with adjacency lists keeps the dependency tree minimal and gives full control over the API. |
| 84 | + |
| 85 | +`KineticGraph` methods: |
| 86 | + |
| 87 | +- `successors()`, `predecessors()` -- adjacency queries |
| 88 | +- `compartment_nodes()`, `ground_state_nodes()` -- filtered node lists |
| 89 | +- `edges_between()`, `ground_state_edges_for_node()` -- edge queries |
| 90 | +- `is_dag()` -- DFS-based cycle detection (WHITE/GRAY/BLACK coloring) |
| 91 | +- `topological_sort()` -- Kahn's algorithm |
| 92 | +- `from_transitions()` -- factory that merges duplicate GS decays and auto-creates nodes |
| 93 | + |
| 94 | +`KineticEdge.format_rate()` handles display formatting: |
| 95 | + |
| 96 | +- Unit conversion (ps^-1 to ns^-1 via `* 1e3`) |
| 97 | +- Smart rounding (`>=1` -> integer, `<1` -> 2 decimals, or explicit `decimal_places`) |
| 98 | +- Optional parameter label prefix (`show_label`) |
| 99 | +- Optional unit suffix suppression (`include_unit=False`) |
| 100 | + |
| 101 | +### Layer 3 -- Layout (`_layout.py`) |
| 102 | + |
| 103 | +**Input**: `KineticGraph` + algorithm choice + spacing params |
| 104 | + |
| 105 | +**Output**: `NodePositions` = `dict[str, tuple[float, float]]` |
| 106 | + |
| 107 | +Three layout algorithms via `LayoutAlgorithm` enum: |
| 108 | + |
| 109 | +| Algorithm | When to use | |
| 110 | +| ------------------------ | ----------------------------------------------------------- | |
| 111 | +| `HIERARCHICAL` (default) | DAGs and simple cyclic schemes. Layered top-down layout | |
| 112 | +| `SPRING` | Complex cyclic schemes. Fruchterman-Reingold force-directed | |
| 113 | +| `MANUAL` | Full user control. Validates and passes through | |
| 114 | + |
| 115 | +**Hierarchical layout pipeline:** |
| 116 | + |
| 117 | +1. **Connected component detection** (`_find_connected_components`): BFS treating edges as undirected. Separate megacomplexes that share no compartments become separate components laid out side-by-side. |
| 118 | + |
| 119 | +2. **Back-edge detection** (`_find_back_edges`): DFS cycle finder. Back edges are temporarily removed so the graph can be layered as a DAG. |
| 120 | + |
| 121 | +3. **Layer assignment** (`_assign_layers`): Longest-path method. Source nodes (in-degree 0) get layer 0; each successor gets `parent_layer + 1`. |
| 122 | + |
| 123 | +4. **Within-layer ordering** (`_order_within_layer`): |
| 124 | + - If `horizontal_layout_preference` is set (e.g. `"S2|S1|T1"`), uses that order. |
| 125 | + - Otherwise, barycenter heuristic: order by average position of predecessors to minimize crossings. |
| 126 | + |
| 127 | +5. **Coordinate assignment**: Center each layer horizontally. Y = `-layer * vertical_spacing`. |
| 128 | + |
| 129 | +6. **Ground state positioning** (`_position_ground_state_nodes`): GS nodes placed directly below their parent at `parent_y - ground_state_offset`. |
| 130 | + |
| 131 | +7. **Multi-component placement**: Each component is laid out independently, then shifted so they appear side-by-side with `component_gap` spacing. Components are ordered by `horizontal_layout_preference` if provided. |
| 132 | + |
| 133 | +**Spring layout**: Standard Fruchterman-Reingold with repulsive (all pairs) and attractive (edges) forces, simulated annealing, deterministic seed. |
| 134 | + |
| 135 | +### Layer 4 -- Render (`plot_kinetic_scheme.py`) |
| 136 | + |
| 137 | +**Input**: `KineticGraph` + `NodePositions` + `KineticSchemeConfig` |
| 138 | + |
| 139 | +**Output**: matplotlib `(Figure, Axes)` |
| 140 | + |
| 141 | +Rendering elements: |
| 142 | + |
| 143 | +| Element | Function | z-order | |
| 144 | +| ----------------- | ----------------------------------------------------------------------------------- | ------- | |
| 145 | +| Ground state bars | `_draw_shared_ground_state_bar`, `_draw_per_megacomplex_ground_state_bars` | 1 | |
| 146 | +| Edge arrows | `_draw_transfer_edge`, `_draw_ground_state_decay_arrow`, `_draw_ground_state_arrow` | 2 | |
| 147 | +| Node rectangles | `_draw_node` (via `FancyBboxPatch`) | 3 | |
| 148 | +| Node labels | `ax.text()` inside `_draw_node` | 4 | |
| 149 | +| Rate labels | `ax.text()` with white background bbox | 5 | |
| 150 | +| Unit annotation | `ax.annotate()` in bottom-right corner | -- | |
| 151 | + |
| 152 | +Key rendering behaviors: |
| 153 | + |
| 154 | +**Arrow endpoints**: `_compute_arrow_endpoints()` uses `_rect_edge_intersection()` to compute where the arrow exits/enters node rectangles, so arrows connect at boundaries rather than centers. |
| 155 | + |
| 156 | +**Parallel edge curvature**: When edges exist in both directions (A->B and B->A), `edge_index` assigns alternating curvature via `arc3,rad=...` connection style. |
| 157 | + |
| 158 | +**Label anti-overlap**: When multiple edges converge on the same target node, labels are spread along each edge at parametric positions t in [0.20, 0.50] instead of all clustering at t=0.35. Each label also has a perpendicular offset of 0.25 data units from the arrow line. |
| 159 | + |
| 160 | +**Text contrast**: `_compute_text_color()` uses the W3C relative luminance formula on linearized sRGB to decide white vs. black text on node backgrounds. |
| 161 | + |
| 162 | +**Unit annotation**: By default (`show_rate_unit_per_label=False`), the unit suffix (e.g., "ns^-1") is omitted from individual edge labels and shown once as an italic annotation in the bottom-right figure corner. |
| 163 | + |
| 164 | +--- |
| 165 | + |
| 166 | +## Configuration Reference |
| 167 | + |
| 168 | +### `KineticSchemeConfig` Fields |
| 169 | + |
| 170 | +All fields have defaults. Config uses `extra="forbid"` to catch typos. |
| 171 | + |
| 172 | +| Field | Type | Default | Purpose | |
| 173 | +| ------------------------------ | ---------------------------------------- | ---------------- | ----------------------------------- | |
| 174 | +| `node_styles` | `dict[str, NodeStyleConfig]` | `{}` | Per-node style overrides | |
| 175 | +| `color_mapping` | `dict[str, list[str]]` | `{}` | Batch color assignment | |
| 176 | +| `node_facecolor` | `str` | `"#4A90D9"` | Default node fill | |
| 177 | +| `node_edgecolor` | `str` | `"#2C3E50"` | Default node border | |
| 178 | +| `node_width` | `float` | `1.2` | Default node width | |
| 179 | +| `node_height` | `float` | `0.6` | Default node height | |
| 180 | +| `edge_color` | `str` | `"#555555"` | Arrow color | |
| 181 | +| `edge_linewidth` | `float` | `1.5` | Arrow thickness | |
| 182 | +| `rate_fontsize` | `int` | `9` | Rate label font size | |
| 183 | +| `rate_unit` | `"ps" \| "ns"` | `"ns"` | Display unit for rates | |
| 184 | +| `rate_decimal_places` | `int \| None` | `None` | Fixed decimals (None = smart) | |
| 185 | +| `show_rate_labels` | `bool` | `False` | Show parameter name prefix | |
| 186 | +| `show_rate_unit_per_label` | `bool` | `False` | Unit on every label vs. legend | |
| 187 | +| `show_ground_state` | `False \| "shared" \| "per_megacomplex"` | `False` | Ground state bar mode | |
| 188 | +| `layout_algorithm` | `str` | `"hierarchical"` | Layout algorithm | |
| 189 | +| `horizontal_layout_preference` | `str \| None` | `None` | Left-to-right ordering hint | |
| 190 | +| `manual_positions` | `dict \| None` | `None` | For manual layout | |
| 191 | +| `horizontal_spacing` | `float` | `2.0` | Node horizontal gap | |
| 192 | +| `vertical_spacing` | `float` | `1.5` | Layer vertical gap | |
| 193 | +| `ground_state_offset` | `float` | `1.2` | GS bar vertical offset | |
| 194 | +| `component_gap` | `float` | `3.0` | Gap between disconnected components | |
| 195 | +| `figsize` | `tuple[float, float]` | `(10.0, 8.0)` | Figure size in inches | |
| 196 | +| `title` | `str \| None` | `None` | Plot title | |
| 197 | +| `omit_parameters` | `set[str]` | `set()` | Parameters to exclude | |
| 198 | + |
| 199 | +### `NodeStyleConfig` Fields |
| 200 | + |
| 201 | +| Field | Type | Default | Purpose | |
| 202 | +| --------------- | ------------- | ------- | ------------------------ | |
| 203 | +| `display_label` | `str \| None` | `None` | Custom display name | |
| 204 | +| `width` | `float` | `1.2` | Node width override | |
| 205 | +| `height` | `float` | `0.6` | Node height override | |
| 206 | +| `facecolor` | `str \| None` | `None` | Fill color override | |
| 207 | +| `fontsize` | `int` | `10` | Label font size override | |
| 208 | + |
| 209 | +### Node Color Resolution Order |
| 210 | + |
| 211 | +1. `NodeStyleConfig.facecolor` (per-node override) |
| 212 | +2. `KineticNode.color` (set programmatically) |
| 213 | +3. `KineticSchemeConfig.color_mapping` (batch assignment) |
| 214 | +4. `KineticSchemeConfig.node_facecolor` (global default) |
| 215 | + |
| 216 | +--- |
| 217 | + |
| 218 | +## File Map |
| 219 | + |
| 220 | +``` |
| 221 | +pyglotaran_extras/inspect/kinetic_scheme/ |
| 222 | + __init__.py # Public API exports |
| 223 | + _constants.py # Named defaults (dimensions, colors, thresholds) |
| 224 | + _k_matrix_parser.py # Layer 1: Transition extraction |
| 225 | + _kinetic_graph.py # Layer 2: Graph datastructure |
| 226 | + _layout.py # Layer 3: Layout algorithms |
| 227 | + plot_kinetic_scheme.py # Layer 4: Rendering + public functions |
| 228 | + devdocs/ # This documentation |
| 229 | + architecture.md # Architecture and design (this file) |
| 230 | +
|
| 231 | +tests/inspect/kinetic_scheme/ |
| 232 | + test_k_matrix_parser.py # Transition extraction tests |
| 233 | + test_kinetic_graph.py # Graph construction, format_rate, DAG detection |
| 234 | + test_layout.py # Layout algorithms, connected components |
| 235 | + test_plot_kinetic_scheme.py # Rendering, config validation, integration |
| 236 | +``` |
| 237 | + |
| 238 | +--- |
| 239 | + |
| 240 | +## Design Decisions |
| 241 | + |
| 242 | +### Why no networkx? |
| 243 | + |
| 244 | +Kinetic schemes in pyglotaran are small (3-20 nodes). A custom 100-line `KineticGraph` with adjacency/reverse-adjacency dicts provides all needed operations (successors, predecessors, cycle detection, topological sort) without pulling in networkx + scipy as dependencies. |
| 245 | + |
| 246 | +### Why Pydantic for config? |
| 247 | + |
| 248 | +`extra="forbid"` catches typos at construction time. Field defaults make the zero-config case trivial. Type validation ensures valid values without manual checks. |
| 249 | + |
| 250 | +### Why connected component detection? |
| 251 | + |
| 252 | +A single model often contains multiple independent megacomplexes (e.g., three reaction centers). Without component detection, all nodes get interleaved into one layout. BFS-based component detection renders them side-by-side with `component_gap` spacing. |
| 253 | + |
| 254 | +### Why parametric t-value spreading? |
| 255 | + |
| 256 | +When 4 edges converge on the same target node, all labels would cluster at `t=0.35` along their respective arrows. Spreading to `t in [0.20, 0.50]` naturally separates labels since each arrow has a different source position. Combined with perpendicular offset, this eliminates label overlap without force-based layout for text. |
| 257 | + |
| 258 | +### Why unit-as-legend? |
| 259 | + |
| 260 | +Showing "ns^-1" on every edge label adds visual clutter without information. A single italic annotation in the corner communicates the unit once. The `show_rate_unit_per_label=True` option restores per-label units for users who prefer them. |
| 261 | + |
| 262 | +### Why ground state deduplication? |
| 263 | + |
| 264 | +Multiple megacomplexes sharing a compartment may have identical diagonal k-matrix entries. `merge_ground_state_decays=True` (default) deduplicates these to avoid rendering duplicate GS decay arrows from the same node. |
| 265 | + |
| 266 | +--- |
| 267 | + |
| 268 | +## Testing Strategy |
| 269 | + |
| 270 | +251 tests across 4 test files. All tests use in-memory mock models (no I/O). Test categories: |
| 271 | + |
| 272 | +- **Unit tests**: Individual functions (`format_rate`, `_compute_text_color`, `_rect_edge_intersection`, etc.) |
| 273 | +- **Graph construction**: `from_transitions()` with various edge cases (empty, cycles, parallel edges, multi-megacomplex) |
| 274 | +- **Layout correctness**: Layer assignment, ordering, component detection, ground state positioning |
| 275 | +- **Rendering integration**: Full pipeline from transitions to matplotlib patches/artists |
| 276 | +- **Config validation**: Pydantic `extra="forbid"` rejection, field defaults, field threading |
| 277 | +- **Edge cases**: Single-node graphs, empty graphs, fully disconnected components, all-GS-decay models |
| 278 | + |
| 279 | +Quality gates: |
| 280 | + |
| 281 | +- `uv run pytest tests/inspect/kinetic_scheme/ -v` -- all pass |
| 282 | +- `uv run mypy pyglotaran_extras/inspect/kinetic_scheme/` -- clean |
| 283 | +- `uv run ruff check pyglotaran_extras/inspect/kinetic_scheme/` -- clean |
| 284 | +- `uv run interrogate pyglotaran_extras/inspect/kinetic_scheme/` -- 100% docstring coverage |
0 commit comments