Skip to content

Commit bce9e26

Browse files
scripts: add the generator for the pipeline explanation diagrams
The three figures on the data-pipelines and relational-workflow-model pages were committed SVGs with no recoverable source: the 14-table pipeline behind them existed nowhere in the repo, so 'regenerate against a new release' meant reconstructing the schemas by reading the picture. scripts/pipeline_example/ defines that pipeline as four modules, one schema each -- the correspondence the page describes -- and scripts/gen_pipeline_diagrams.py renders the three figures from it. --check reports any committed figure that differs and exits non-zero. Verified to reproduce the committed figures' nodes, tiers, shapes, edge weights and styles, tooltips, clusters and labels. Three caveats are documented in the module docstring: the generator needs an empty database, tooltip padding entities vary by pydot version, and the collapsed lab -> session edge is traversal-order dependent in the renderer.
1 parent 4458ae7 commit bce9e26

6 files changed

Lines changed: 343 additions & 0 deletions

File tree

scripts/gen_pipeline_diagrams.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Generate the pipeline diagrams used in the explanation pages.
2+
3+
Three committed figures come from one four-module example pipeline, defined in
4+
``scripts/pipeline_example/``:
5+
6+
- ``pipeline-modules.svg`` — the whole pipeline at the table level, dashed
7+
clusters grouping each module (``src/explanation/data-pipelines.md``).
8+
- ``pipeline-modules-collapsed.svg`` — the same pipeline at the module level,
9+
one node per schema, via ``Diagram.collapse()`` (same page).
10+
- ``imaging-schema.svg`` — the ``imaging`` module on its own
11+
(``src/explanation/relational-workflow-model.md``, ``src/index.md``).
12+
13+
Keeping the pipeline in the repo makes the figures reproducible. The prose
14+
describes specific edges, tiers, and table counts; those claims are only
15+
checkable if the pipeline that produced them can be rebuilt.
16+
17+
Usage
18+
-----
19+
Needs a database and a graphviz ``dot`` on PATH::
20+
21+
docker compose up -d postgres
22+
DJ_HOST=localhost DJ_PORT=5432 DJ_USER=postgres DJ_PASS=tutorial \
23+
DJ_BACKEND=postgresql DJ_USE_TLS=false \
24+
python scripts/gen_pipeline_diagrams.py
25+
26+
``--check`` renders without writing and exits non-zero if any committed figure
27+
differs — suitable for CI. The example schemas are dropped afterwards unless
28+
``--keep-schemas`` is given.
29+
30+
This reproduces the committed figures' nodes, tiers, edges, tooltips, clusters
31+
and labels exactly, with the caveats below.
32+
33+
Reproducibility caveats
34+
-----------------------
35+
- **Needs an empty database.** The schema names are unprefixed (``reference``,
36+
``lab``, ``session``, ``imaging``) because ``dj.Diagram`` takes each cluster
37+
label from the Python module name and these must agree. On a server that
38+
already holds a schema by one of those names, the diagram silently picks up the
39+
foreign tables instead. Point ``DJ_HOST``/``DJ_PORT`` at a throwaway instance.
40+
- **Padding entities depend on pydot.** Tooltip padding is emitted as `` ``
41+
by the pydot that produced the committed figures and as literal spaces by
42+
4.0.1, which shows up as a whole-file diff with no visual change. Compare
43+
rendered content, not bytes, when the pydot version moves. Nothing pins pydot.
44+
- **One collapsed edge is traversal-order dependent.** A collapsed edge inherits
45+
the attributes of whichever foreign key in its bundle is visited first
46+
(``diagram.py``, ``_collapse_graph``: ``if not new_graph.has_edge(...)``), with
47+
no aggregation over the bundle. Where a bundle mixes a primary and a secondary
48+
foreign key — ``lab -> session`` here, which bundles ``Subject -> Session``
49+
(primary) and ``User -> Session`` (secondary) — the edge renders solid or
50+
dashed depending on order alone. The committed figure has it solid; this script
51+
produces dashed. Both are outputs of the same renderer.
52+
53+
A non-empty diff after a DataJoint upgrade is the signal to review the notation
54+
and the surrounding prose together — see issue #246.
55+
"""
56+
57+
import argparse
58+
import os
59+
import sys
60+
import tempfile
61+
from pathlib import Path
62+
63+
import datajoint as dj
64+
65+
sys.path.insert(0, str(Path(__file__).resolve().parent))
66+
67+
from pipeline_example import imaging, lab, reference, session # noqa: E402
68+
69+
IMAGES = Path(__file__).resolve().parent.parent / "src" / "images"
70+
71+
MODULES = (reference, lab, session, imaging)
72+
73+
# dj.Diagram labels each node by resolving its table against this context. Passing
74+
# the classes under their bare names keeps node labels unqualified ("Session", not
75+
# "session.Session") while the cluster labels still come from the module names.
76+
CONTEXT = {
77+
name: obj
78+
for module in MODULES
79+
for name, obj in vars(module).items()
80+
if isinstance(obj, type) and issubclass(obj, dj.Table)
81+
}
82+
83+
84+
def diagram(schema) -> dj.Diagram:
85+
return dj.Diagram(schema, context=CONTEXT)
86+
87+
88+
def whole_pipeline() -> dj.Diagram:
89+
"""The four modules unioned into one diagram."""
90+
result = diagram(reference.schema)
91+
for module in MODULES[1:]:
92+
result += diagram(module.schema)
93+
return result
94+
95+
96+
FIGURES = {
97+
# Whole pipeline, table level: every module expanded.
98+
"pipeline-modules.svg": whole_pipeline,
99+
# Same pipeline, module level: one node per schema.
100+
"pipeline-modules-collapsed.svg": lambda: whole_pipeline().collapse(),
101+
# The imaging module on its own.
102+
"imaging-schema.svg": lambda: diagram(imaging.schema),
103+
}
104+
105+
106+
def main() -> int:
107+
parser = argparse.ArgumentParser(
108+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
109+
)
110+
parser.add_argument(
111+
"--check",
112+
action="store_true",
113+
help="report figures that differ from the committed SVGs without writing them; "
114+
"exits 1 if any differ",
115+
)
116+
parser.add_argument(
117+
"--keep-schemas",
118+
action="store_true",
119+
help="leave the example schemas in the database (default: drop them)",
120+
)
121+
args = parser.parse_args()
122+
123+
# Left-to-right layout, matching scripts/execute-notebooks.sh.
124+
with tempfile.TemporaryDirectory() as tmp:
125+
with dj.config.override(display__diagram_direction="LR"):
126+
rendered = {}
127+
for name, build in FIGURES.items():
128+
staged = Path(tmp) / name
129+
build().save(str(staged))
130+
rendered[name] = staged.read_text()
131+
132+
if not args.keep_schemas:
133+
for module in reversed(MODULES):
134+
module.schema.drop(prompt=False)
135+
136+
differs = []
137+
for name, svg in rendered.items():
138+
target = IMAGES / name
139+
old = target.read_text() if target.exists() else None
140+
if old == svg:
141+
print(f" unchanged {name}")
142+
elif args.check:
143+
differs.append(name)
144+
print(f" DIFFERS {name}")
145+
else:
146+
differs.append(name)
147+
target.write_text(svg)
148+
print(f" written {name}")
149+
150+
if args.check and differs:
151+
print(
152+
f"\n{len(differs)} figure(s) differ from the committed SVGs. Re-run "
153+
"without --check to update them, then review the notation and the "
154+
"prose in src/explanation/ together (see #246). If only tooltip "
155+
"padding moved, check the pydot version first — see the module "
156+
"docstring.",
157+
file=sys.stderr,
158+
)
159+
return 1
160+
return 0
161+
162+
163+
if __name__ == "__main__":
164+
os.environ.setdefault("DJ_USE_TLS", "false")
165+
raise SystemExit(main())
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""The example pipeline behind the diagrams in the explanation pages.
2+
3+
Four modules, one database schema each — the correspondence
4+
``src/explanation/data-pipelines.md`` describes. ``dj.Diagram`` takes the group
5+
label for each cluster from the Python module name, so these module names are
6+
what put ``reference`` / ``lab`` / ``session`` / ``imaging`` on the figures.
7+
8+
Rendered by ``scripts/gen_pipeline_diagrams.py``; not imported by the site build.
9+
"""
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Computed results, including two master-part pairs.
2+
3+
``ScanQuality`` depends on ``session.Scan`` and ``MotionCorrection`` on
4+
``session.ScanInfo`` — the two foreign keys bundled into the ``session → imaging``
5+
edge at the module level.
6+
"""
7+
8+
import datajoint as dj
9+
10+
from .reference import SegmentationMethod
11+
from .session import Scan, ScanInfo
12+
13+
schema = dj.Schema("imaging")
14+
15+
16+
@schema
17+
class ScanQuality(dj.Computed):
18+
definition = """
19+
-> Scan
20+
---
21+
quality_score : float64
22+
"""
23+
24+
25+
@schema
26+
class MotionCorrection(dj.Computed):
27+
definition = """
28+
-> ScanInfo
29+
---
30+
x_shifts : bytes
31+
y_shifts : bytes
32+
"""
33+
34+
35+
@schema
36+
class Segmentation(dj.Computed):
37+
definition = """
38+
-> MotionCorrection
39+
-> SegmentationMethod
40+
---
41+
num_rois : int32
42+
"""
43+
44+
class Roi(dj.Part):
45+
definition = """
46+
-> master
47+
roi_idx : int32
48+
---
49+
mask : bytes
50+
"""
51+
52+
53+
@schema
54+
class Fluorescence(dj.Computed):
55+
definition = """
56+
-> Segmentation
57+
---
58+
timestamps : bytes
59+
"""
60+
61+
class Trace(dj.Part):
62+
definition = """
63+
-> master
64+
-> Segmentation.Roi
65+
---
66+
trace : bytes
67+
"""

scripts/pipeline_example/lab.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Who runs the experiments, and what they are run on."""
2+
3+
import datajoint as dj
4+
5+
schema = dj.Schema("lab")
6+
7+
8+
@schema
9+
class Lab(dj.Manual):
10+
definition = """
11+
lab_name : varchar(32)
12+
---
13+
institution : varchar(64)
14+
"""
15+
16+
17+
@schema
18+
class User(dj.Manual):
19+
definition = """
20+
-> Lab
21+
user_name : varchar(32)
22+
---
23+
email : varchar(64)
24+
"""
25+
26+
27+
@schema
28+
class Subject(dj.Manual):
29+
definition = """
30+
subject_id : int32
31+
---
32+
species : varchar(64)
33+
date_of_birth : date
34+
"""
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Lookup tables: the shared vocabulary the rest of the pipeline refers to."""
2+
3+
import datajoint as dj
4+
5+
schema = dj.Schema("reference")
6+
7+
8+
@schema
9+
class ScannerModel(dj.Lookup):
10+
definition = """
11+
scanner_model : varchar(32)
12+
---
13+
manufacturer : varchar(64)
14+
"""
15+
16+
17+
@schema
18+
class SegmentationMethod(dj.Lookup):
19+
definition = """
20+
seg_method : varchar(32)
21+
---
22+
method_notes : varchar(255)
23+
"""
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""The experimental record: sessions, scans, and what the scanner reported.
2+
3+
``Session`` depends on ``lab.Subject`` in its primary key and on ``lab.User`` as
4+
a secondary reference — the two foreign keys that the module-level figure bundles
5+
into the single ``lab → session`` edge.
6+
"""
7+
8+
import datajoint as dj
9+
10+
from .lab import Subject, User
11+
from .reference import ScannerModel
12+
13+
schema = dj.Schema("session")
14+
15+
16+
@schema
17+
class Session(dj.Manual):
18+
definition = """
19+
-> Subject
20+
session_date : date
21+
---
22+
-> User
23+
session_notes : varchar(255)
24+
"""
25+
26+
27+
@schema
28+
class Scan(dj.Manual):
29+
definition = """
30+
-> Session
31+
scan_idx : int32
32+
---
33+
-> ScannerModel
34+
depth : float64
35+
"""
36+
37+
38+
@schema
39+
class ScanInfo(dj.Imported):
40+
definition = """
41+
-> Scan
42+
---
43+
nframes : int32
44+
fps : float64
45+
"""

0 commit comments

Comments
 (0)