Skip to content

Commit 2c37edf

Browse files
committed
inspect/kinetic_scheme: validate config values and align docs
Add strict numeric validation to kinetic-scheme config fields. Fix ground-state bar sizing to account for per-node width overrides. Update tests for new validation and bar-span behavior. Sync dev/docs/notebook defaults and option descriptions with current implementation.
1 parent 9856aa8 commit 2c37edf

5 files changed

Lines changed: 150 additions & 65 deletions

File tree

docs/notebooks/kinetic_scheme_visualization.ipynb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -640,16 +640,20 @@
640640
"| `node_height` | `float` | `0.6` | Default node height |\n",
641641
"| `edge_color` | `str` | `\"#555555\"` | Arrow color |\n",
642642
"| `edge_linewidth` | `float` | `1.5` | Arrow line width |\n",
643+
"| `rate_fontsize` | `int` | `9` | Font size for rate labels |\n",
644+
"| `color_edges_by_rate` | `bool` | `True` | Color transfer edges by per-source rate rank |\n",
643645
"| `rate_unit` | `\"ps\"` \\| `\"ns\"` | `\"ns\"` | Rate constant display unit |\n",
644646
"| `rate_decimal_places` | `int \\| None` | `None` | Decimal places (None = smart rounding) |\n",
645647
"| `show_rate_labels` | `bool` | `False` | Show parameter name prefix on edges |\n",
648+
"| `show_rate_unit_per_label` | `bool` | `False` | Show unit on each edge label |\n",
646649
"| `show_ground_state` | `False` \\| `\"shared\"` \\| `\"per_megacomplex\"` | `False` | Ground state bar mode |\n",
647650
"| `layout_algorithm` | `\"hierarchical\"` \\| `\"spring\"` \\| `\"manual\"` | `\"hierarchical\"` | Layout algorithm |\n",
648651
"| `horizontal_layout_preference` | `str \\| None` | `None` | Pipe-delimited node order hint |\n",
649652
"| `manual_positions` | `dict \\| None` | `None` | User-supplied positions for manual layout |\n",
650-
"| `horizontal_spacing` | `float` | `2.0` | Horizontal distance between nodes |\n",
651-
"| `vertical_spacing` | `float` | `1.5` | Vertical distance between layers |\n",
653+
"| `horizontal_spacing` | `float` | `0.0` | Horizontal spacing (`0` = auto `3 × node_width`) |\n",
654+
"| `vertical_spacing` | `float` | `2.0` | Vertical distance between layers |\n",
652655
"| `ground_state_offset` | `float` | `1.2` | Vertical offset for ground state |\n",
656+
"| `component_gap` | `float` | `3.0` | Gap between disconnected components |\n",
653657
"| `figsize` | `tuple[float, float]` | `(10.0, 8.0)` | Default figure size |\n",
654658
"| `title` | `str \\| None` | `None` | Plot title |\n",
655659
"| `omit_parameters` | `set[str]` | `set()` | Parameter labels to exclude |"

pyglotaran_extras/inspect/kinetic_scheme/_layout.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ def compute_layout(
6060
algorithm : LayoutAlgorithm
6161
The layout algorithm to use. Defaults to ``HIERARCHICAL``.
6262
horizontal_spacing : float
63-
Horizontal distance between adjacent nodes. Defaults to 2.0.
63+
Horizontal distance between adjacent nodes. Defaults to 0.0
64+
(sentinel: auto-computed as ``3 × DEFAULT_NODE_WIDTH``).
6465
vertical_spacing : float
65-
Vertical distance between layers. Defaults to 1.5.
66+
Vertical distance between layers. Defaults to 2.0.
6667
ground_state_offset : float
6768
Vertical offset for ground state nodes below their parent. Defaults to 1.2.
6869
component_gap : float

pyglotaran_extras/inspect/kinetic_scheme/devdocs/architecture.md

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ Key rendering behaviors:
155155

156156
**Parallel edge curvature**: When edges exist in both directions (A->B and B->A), `edge_index` assigns alternating curvature via `arc3,rad=...` connection style.
157157

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.
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.35, 0.65]` instead of all clustering at the midpoint. Each label uses a base perpendicular offset of `0.35` data units from the arrow line, with additional outward bumps when needed to avoid collisions.
159159

160160
**Text contrast**: `_compute_text_color()` uses the W3C relative luminance formula on linearized sRGB to decide white vs. black text on node backgrounds.
161161

@@ -169,32 +169,33 @@ Key rendering behaviors:
169169

170170
All fields have defaults. Config uses `extra="forbid"` to catch typos.
171171

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 |
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+
| `color_edges_by_rate` | `bool` | `True` | Color transfer edges by per-source rank |
184+
| `rate_unit` | `"ps" \| "ns"` | `"ns"` | Display unit for rates |
185+
| `rate_decimal_places` | `int \| None` | `None` | Fixed decimals (None = smart) |
186+
| `show_rate_labels` | `bool` | `False` | Show parameter name prefix |
187+
| `show_rate_unit_per_label` | `bool` | `False` | Unit on every label vs. legend |
188+
| `show_ground_state` | `False \| "shared" \| "per_megacomplex"` | `False` | Ground state bar mode |
189+
| `layout_algorithm` | `str` | `"hierarchical"` | Layout algorithm |
190+
| `horizontal_layout_preference` | `str \| None` | `None` | Left-to-right ordering hint |
191+
| `manual_positions` | `dict \| None` | `None` | For manual layout |
192+
| `horizontal_spacing` | `float` | `0.0` | Node spacing (`0` = auto `3 × node_width`) |
193+
| `vertical_spacing` | `float` | `2.0` | Layer vertical gap |
194+
| `ground_state_offset` | `float` | `1.2` | GS bar vertical offset |
195+
| `component_gap` | `float` | `3.0` | Gap between disconnected components |
196+
| `figsize` | `tuple[float, float]` | `(10.0, 8.0)` | Figure size in inches |
197+
| `title` | `str \| None` | `None` | Plot title |
198+
| `omit_parameters` | `set[str]` | `set()` | Parameters to exclude |
198199

199200
### `NodeStyleConfig` Fields
200201

@@ -253,7 +254,7 @@ A single model often contains multiple independent megacomplexes (e.g., three re
253254

254255
### Why parametric t-value spreading?
255256

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+
When several edges converge on the same target node, midpoint labels would collide. Spreading labels along each edge with `t in [0.35, 0.65]` naturally separates them since each arrow has a different source position. Combined with a perpendicular base offset (plus outward bumps when both sides are crowded), this avoids overlap without text-force simulation.
257258

258259
### Why unit-as-legend?
259260

pyglotaran_extras/inspect/kinetic_scheme/plot_kinetic_scheme.py

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ class NodeStyleConfig(BaseModel):
7070
model_config = ConfigDict(extra="forbid")
7171

7272
display_label: str | None = None
73-
width: float = DEFAULT_NODE_WIDTH
74-
height: float = DEFAULT_NODE_HEIGHT
73+
width: float = Field(default=DEFAULT_NODE_WIDTH, gt=0)
74+
height: float = Field(default=DEFAULT_NODE_HEIGHT, gt=0)
7575
facecolor: str | None = None
76-
fontsize: int = DEFAULT_FONTSIZE
76+
fontsize: int = Field(default=DEFAULT_FONTSIZE, ge=1)
7777

7878

7979
class KineticSchemeConfig(BaseModel):
@@ -100,9 +100,10 @@ class KineticSchemeConfig(BaseModel):
100100
rate_fontsize : int
101101
Font size for rate constant labels on edges.
102102
color_edges_by_rate : bool
103-
When True (the default), edges are coloured by descending rate
104-
magnitude using the standard glotaran colour palette (black for
105-
the fastest, red for the second-fastest, etc.).
103+
When True (the default), transfer edges are coloured by descending
104+
rate magnitude per source node using the standard glotaran colour
105+
palette (black for the fastest outgoing edge from that source, red
106+
for the second-fastest, etc.).
106107
rate_unit : Literal["ps", "ns"]
107108
Unit for displaying rate constants.
108109
rate_decimal_places : int | None
@@ -146,18 +147,18 @@ class KineticSchemeConfig(BaseModel):
146147
color_mapping: dict[str, list[str]] = Field(default_factory=dict)
147148
node_facecolor: str = DEFAULT_NODE_FACECOLOR
148149
node_edgecolor: str = DEFAULT_NODE_EDGECOLOR
149-
node_width: float = DEFAULT_NODE_WIDTH
150-
node_height: float = DEFAULT_NODE_HEIGHT
150+
node_width: float = Field(default=DEFAULT_NODE_WIDTH, gt=0)
151+
node_height: float = Field(default=DEFAULT_NODE_HEIGHT, gt=0)
151152

152153
# Edge styling
153154
edge_color: str = DEFAULT_EDGE_COLOR
154-
edge_linewidth: float = DEFAULT_EDGE_LINEWIDTH
155-
rate_fontsize: int = DEFAULT_RATE_FONTSIZE
155+
edge_linewidth: float = Field(default=DEFAULT_EDGE_LINEWIDTH, gt=0)
156+
rate_fontsize: int = Field(default=DEFAULT_RATE_FONTSIZE, ge=1)
156157
color_edges_by_rate: bool = True
157158

158159
# Rate formatting
159160
rate_unit: Literal["ps", "ns"] = "ns"
160-
rate_decimal_places: int | None = None
161+
rate_decimal_places: int | None = Field(default=None, ge=0)
161162
show_rate_labels: bool = False
162163
show_rate_unit_per_label: bool = False
163164

@@ -168,10 +169,10 @@ class KineticSchemeConfig(BaseModel):
168169
layout_algorithm: Literal["hierarchical", "spring", "manual"] = "hierarchical"
169170
horizontal_layout_preference: str | None = None
170171
manual_positions: dict[str, tuple[float, float]] | None = None
171-
horizontal_spacing: float = DEFAULT_HORIZONTAL_SPACING
172-
vertical_spacing: float = DEFAULT_VERTICAL_SPACING
173-
ground_state_offset: float = DEFAULT_GROUND_STATE_OFFSET
174-
component_gap: float = DEFAULT_COMPONENT_GAP
172+
horizontal_spacing: float = Field(default=DEFAULT_HORIZONTAL_SPACING, ge=0)
173+
vertical_spacing: float = Field(default=DEFAULT_VERTICAL_SPACING, gt=0)
174+
ground_state_offset: float = Field(default=DEFAULT_GROUND_STATE_OFFSET, gt=0)
175+
component_gap: float = Field(default=DEFAULT_COMPONENT_GAP, ge=0)
175176

176177
# Figure
177178
figsize: tuple[float, float] = DEFAULT_FIGSIZE
@@ -1288,18 +1289,12 @@ def _draw_shared_ground_state_bar(
12881289
config : KineticSchemeConfig
12891290
Configuration.
12901291
"""
1291-
compartment_positions = [
1292-
positions[n.label] for n in graph.compartment_nodes() if n.label in positions
1293-
]
1294-
if not compartment_positions:
1292+
compartment_labels = [n.label for n in graph.compartment_nodes() if n.label in positions]
1293+
if not compartment_labels:
12951294
return
12961295

1297-
x_coords = [p[0] for p in compartment_positions]
1298-
y_coords = [p[1] for p in compartment_positions]
1299-
1300-
margin = config.node_width * 0.8
1301-
x_min = min(x_coords) - margin
1302-
x_max = max(x_coords) + margin
1296+
y_coords = [positions[label][1] for label in compartment_labels]
1297+
x_min, x_max = _compute_ground_state_bar_x_range(compartment_labels, positions, config)
13031298
y_bar = min(y_coords) - config.ground_state_offset
13041299

13051300
# Update GS node positions to be at the bar level
@@ -1346,15 +1341,9 @@ def _draw_per_megacomplex_ground_state_bars(
13461341
for mc_label in node.megacomplex_labels:
13471342
mc_groups.setdefault(mc_label, []).append(node.label)
13481343

1349-
margin = config.node_width * 0.5
1350-
13511344
for mc_label, node_labels in mc_groups.items():
1352-
mc_positions = [positions[label] for label in node_labels]
1353-
x_coords = [p[0] for p in mc_positions]
1354-
y_coords = [p[1] for p in mc_positions]
1355-
1356-
x_min = min(x_coords) - margin
1357-
x_max = max(x_coords) + margin
1345+
y_coords = [positions[label][1] for label in node_labels]
1346+
x_min, x_max = _compute_ground_state_bar_x_range(node_labels, positions, config)
13581347
y_bar = min(y_coords) - config.ground_state_offset
13591348

13601349
# Update GS node positions for this megacomplex
@@ -1373,3 +1362,44 @@ def _draw_per_megacomplex_ground_state_bars(
13731362
solid_capstyle="butt",
13741363
zorder=1,
13751364
)
1365+
1366+
1367+
def _compute_ground_state_bar_x_range(
1368+
node_labels: list[str],
1369+
positions: NodePositions,
1370+
config: KineticSchemeConfig,
1371+
) -> tuple[float, float]:
1372+
"""Compute x-range for a ground state bar covering custom-width nodes.
1373+
1374+
Parameters
1375+
----------
1376+
node_labels : list[str]
1377+
Compartment labels that the bar should span.
1378+
positions : NodePositions
1379+
Node positions in data coordinates.
1380+
config : KineticSchemeConfig
1381+
Visualization configuration.
1382+
1383+
Returns
1384+
-------
1385+
tuple[float, float]
1386+
``(x_min, x_max)`` span for the bar.
1387+
"""
1388+
left_edges: list[float] = []
1389+
right_edges: list[float] = []
1390+
widths: list[float] = []
1391+
1392+
for label in node_labels:
1393+
if label not in positions:
1394+
continue
1395+
x, _ = positions[label]
1396+
width, _ = _get_node_dimensions(label, config)
1397+
left_edges.append(x - width / 2)
1398+
right_edges.append(x + width / 2)
1399+
widths.append(width)
1400+
1401+
if not left_edges or not right_edges or not widths:
1402+
return (0.0, 0.0)
1403+
1404+
margin = max(widths) * 0.3
1405+
return min(left_edges) - margin, max(right_edges) + margin

0 commit comments

Comments
 (0)