Skip to content

Commit afb6dfc

Browse files
committed
Add generated architecture documentation
Opus 4.6
1 parent 2e71580 commit afb6dfc

1 file changed

Lines changed: 284 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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

Comments
 (0)