process-bigraph 1.8.0__tar.gz → 1.8.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {process_bigraph-1.8.0/process_bigraph.egg-info → process_bigraph-1.8.2}/PKG-INFO +1 -1
  2. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/__init__.py +5 -1
  3. process_bigraph-1.8.2/process_bigraph/visualizations/__init__.py +30 -0
  4. process_bigraph-1.8.2/process_bigraph/visualizations/distribution.py +44 -0
  5. process_bigraph-1.8.2/process_bigraph/visualizations/heatmap.py +44 -0
  6. process_bigraph-1.8.2/process_bigraph/visualizations/param_vs_observable.py +51 -0
  7. process_bigraph-1.8.2/process_bigraph/visualizations/phase_space.py +40 -0
  8. process_bigraph-1.8.2/process_bigraph/visualizations/time_series.py +91 -0
  9. process_bigraph-1.8.2/process_bigraph/visualizations/timeseries_from_observables.py +349 -0
  10. {process_bigraph-1.8.0 → process_bigraph-1.8.2/process_bigraph.egg-info}/PKG-INFO +1 -1
  11. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph.egg-info/SOURCES.txt +8 -1
  12. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/pyproject.toml +2 -1
  13. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/AUTHORS.md +0 -0
  14. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/LICENSE +0 -0
  15. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/README.md +0 -0
  16. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/artifacts.py +0 -0
  17. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/bundle.py +0 -0
  18. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/composite.py +0 -0
  19. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/composite_discovery.py +0 -0
  20. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/composite_generator.py +0 -0
  21. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/composite_spec.py +0 -0
  22. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/config_helpers.py +0 -0
  23. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/core_introspection.py +0 -0
  24. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/emitter.py +0 -0
  25. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/experiments/__init__.py +0 -0
  26. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/experiments/minimal_gillespie.py +0 -0
  27. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/nextflow.py +0 -0
  28. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/plumbing.py +0 -0
  29. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/__init__.py +0 -0
  30. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/bigraphical_reactive_system.py +0 -0
  31. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/cached.py +0 -0
  32. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/dynamic_structure.py +0 -0
  33. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/examples.py +0 -0
  34. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/growth_division.py +0 -0
  35. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/parameter_scan.py +0 -0
  36. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/reaction.py +0 -0
  37. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/processes/simulation.py +0 -0
  38. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/__init__.py +0 -0
  39. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/_git_worker.py +0 -0
  40. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/clusters/__init__.py +0 -0
  41. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/clusters/ec2_ssm.py +0 -0
  42. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/git.py +0 -0
  43. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/parallel.py +0 -0
  44. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/pool.py +0 -0
  45. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/ray.py +0 -0
  46. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/rest.py +0 -0
  47. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/protocols/session.py +0 -0
  48. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/run.py +0 -0
  49. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/run_step.py +0 -0
  50. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/scheduling.py +0 -0
  51. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/server/__init__.py +0 -0
  52. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/server/rest.py +0 -0
  53. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/server/start.py +0 -0
  54. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/templates.py +0 -0
  55. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/types/__init__.py +0 -0
  56. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/types/process.py +0 -0
  57. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph/visualization.py +0 -0
  58. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph.egg-info/dependency_links.txt +0 -0
  59. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph.egg-info/requires.txt +0 -0
  60. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/process_bigraph.egg-info/top_level.txt +0 -0
  61. {process_bigraph-1.8.0 → process_bigraph-1.8.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.8.0
3
+ Version: 1.8.2
4
4
  Summary: protocol and execution for compositional systems biology
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -27,8 +27,12 @@ from process_bigraph.bundle import load_bundle # noqa: F401
27
27
  from process_bigraph.emitter import Emitter, gather_emitter_results, generate_emitter_state # noqa: F401
28
28
  from process_bigraph.types import StepLink, ProcessLink, CompositeLink # noqa: F401
29
29
  from process_bigraph import core_introspection # noqa: F401
30
+ # NOTE: do NOT re-export the `composite_generator` decorator here — that name
31
+ # is the submodule `process_bigraph.composite_generator`, and exporting the
32
+ # same-named function shadows it (breaks `import process_bigraph.composite_generator`).
33
+ # Get the decorator via `from process_bigraph.composite_generator import composite_generator`.
30
34
  from process_bigraph.composite_generator import ( # noqa: F401
31
- composite_generator, discover_generators, build_generator,
35
+ discover_generators, build_generator,
32
36
  install_default_emitters, emitter_defaults,
33
37
  )
34
38
  from process_bigraph.composite_discovery import discover_all # noqa: F401
@@ -0,0 +1,30 @@
1
+ """Default Visualization classes shipped with process-bigraph.
2
+
3
+ All five inherit ``process_bigraph.visualization.Visualization`` and implement
4
+ ``render_final(results, *, config) -> str``. They are auto-discovered via
5
+ ``bigraph_schema.package.discover`` so workspaces don't need to register them
6
+ manually.
7
+
8
+ Usage (from a composite or investigation spec):
9
+ visualizations:
10
+ - name: trajectory
11
+ address: "local:TimeSeriesPlot"
12
+ config: {observable: free_DnaA, sources: [baseline]}
13
+ """
14
+ from process_bigraph.visualizations.time_series import TimeSeriesPlot
15
+ from process_bigraph.visualizations.timeseries_from_observables import (
16
+ TimeSeriesFromObservables,
17
+ )
18
+ from process_bigraph.visualizations.param_vs_observable import ParamVsObservable
19
+ from process_bigraph.visualizations.distribution import Distribution
20
+ from process_bigraph.visualizations.phase_space import PhaseSpace
21
+ from process_bigraph.visualizations.heatmap import Heatmap
22
+
23
+ __all__ = [
24
+ "TimeSeriesPlot",
25
+ "TimeSeriesFromObservables",
26
+ "ParamVsObservable",
27
+ "Distribution",
28
+ "PhaseSpace",
29
+ "Heatmap",
30
+ ]
@@ -0,0 +1,44 @@
1
+ """Distribution — histogram of a sample list.
2
+
3
+ Orchestrator collects the samples (e.g., final-step values across N seed
4
+ runs) into a flat list; this Step renders a histogram.
5
+ """
6
+ from __future__ import annotations
7
+ import html as _html
8
+ import json
9
+
10
+ from process_bigraph.visualization import Visualization
11
+
12
+
13
+ class Distribution(Visualization):
14
+ """Histogram of an observable's distribution.
15
+
16
+ Inputs (declared types):
17
+ samples: list[float] — the values to bin
18
+ """
19
+
20
+ def inputs(self):
21
+ return {'samples': 'list[float]'}
22
+
23
+ def update(self, state):
24
+ samples = state.get('samples') or []
25
+ title = (getattr(self, 'config', None) or {}).get('title', '')
26
+ traces = [{
27
+ 'x': samples, 'type': 'histogram',
28
+ 'marker': {'color': '#6366f1'},
29
+ 'opacity': 0.85,
30
+ }]
31
+ layout = {
32
+ 'title': {'text': _html.escape(title), 'font': {'size': 14}},
33
+ 'yaxis': {'title': {'text': 'count'}},
34
+ 'margin': {'l': 55, 'r': 15, 't': 40, 'b': 40},
35
+ 'showlegend': False,
36
+ 'bargap': 0.05,
37
+ }
38
+ return {'html': (
39
+ '<div id="viz" style="height:380px"></div>'
40
+ '<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>'
41
+ '<script>Plotly.newPlot("viz", '
42
+ + json.dumps(traces) + ', ' + json.dumps(layout)
43
+ + ', {responsive:true, displayModeBar:false});</script>'
44
+ )}
@@ -0,0 +1,44 @@
1
+ """Heatmap — 2D parameter sweep, color = reduced observable."""
2
+ from __future__ import annotations
3
+ import html as _html
4
+ import json
5
+
6
+ from process_bigraph.visualization import Visualization
7
+
8
+
9
+ class Heatmap(Visualization):
10
+ """Color matrix over a 2D parameter sweep.
11
+
12
+ Inputs (declared types):
13
+ x_params: list[float]
14
+ y_params: list[float]
15
+ z_values: list[list[float]] — z[y_idx][x_idx]
16
+ """
17
+
18
+ def inputs(self):
19
+ return {
20
+ 'x_params': 'list[float]',
21
+ 'y_params': 'list[float]',
22
+ 'z_values': 'list[list[float]]',
23
+ }
24
+
25
+ def update(self, state):
26
+ xs = state.get('x_params') or []
27
+ ys = state.get('y_params') or []
28
+ zs = state.get('z_values') or []
29
+ title = (getattr(self, 'config', None) or {}).get('title', '')
30
+ traces = [{
31
+ 'z': zs, 'x': xs, 'y': ys, 'type': 'heatmap',
32
+ 'colorscale': 'Viridis',
33
+ }]
34
+ layout = {
35
+ 'title': {'text': _html.escape(title), 'font': {'size': 14}},
36
+ 'margin': {'l': 55, 'r': 60, 't': 40, 'b': 40},
37
+ }
38
+ return {'html': (
39
+ '<div id="viz" style="height:420px"></div>'
40
+ '<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>'
41
+ '<script>Plotly.newPlot("viz", '
42
+ + json.dumps(traces) + ', ' + json.dumps(layout)
43
+ + ', {responsive:true, displayModeBar:false});</script>'
44
+ )}
@@ -0,0 +1,51 @@
1
+ """ParamVsObservable — sweep parameter value vs reduced observable.
2
+
3
+ The orchestrator does the reduction (final/mean/max/...) before populating
4
+ the input store; this Step just plots ``y vs x`` as a line+marker chart.
5
+ """
6
+ from __future__ import annotations
7
+ import html as _html
8
+ import json
9
+
10
+ from process_bigraph.visualization import Visualization
11
+
12
+
13
+ class ParamVsObservable(Visualization):
14
+ """Plot reduced observable values across a sweep.
15
+
16
+ Inputs (declared types):
17
+ sweep_param_values: list[float] — x-axis (one value per run in the sweep)
18
+ reduced_observable: list[float] — y-axis (reduced trajectory per run)
19
+ """
20
+
21
+ def inputs(self):
22
+ return {
23
+ 'sweep_param_values': 'list[float]',
24
+ 'reduced_observable': 'list[float]',
25
+ }
26
+
27
+ def update(self, state):
28
+ xs = state.get('sweep_param_values') or []
29
+ ys = state.get('reduced_observable') or []
30
+ title = (getattr(self, 'config', None) or {}).get('title', '')
31
+ if xs and ys:
32
+ pairs = sorted(zip(xs, ys))
33
+ xs = [p[0] for p in pairs]
34
+ ys = [p[1] for p in pairs]
35
+ traces = [{
36
+ 'x': xs, 'y': ys, 'type': 'scatter', 'mode': 'lines+markers',
37
+ 'line': {'color': '#6366f1', 'width': 2},
38
+ 'marker': {'color': '#6366f1', 'size': 8},
39
+ }]
40
+ layout = {
41
+ 'title': {'text': _html.escape(title), 'font': {'size': 14}},
42
+ 'margin': {'l': 55, 'r': 15, 't': 40, 'b': 40},
43
+ 'showlegend': False,
44
+ }
45
+ return {'html': (
46
+ '<div id="viz" style="height:380px"></div>'
47
+ '<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>'
48
+ '<script>Plotly.newPlot("viz", '
49
+ + json.dumps(traces) + ', ' + json.dumps(layout)
50
+ + ', {responsive:true, displayModeBar:false});</script>'
51
+ )}
@@ -0,0 +1,40 @@
1
+ """PhaseSpace — two observables plotted against each other."""
2
+ from __future__ import annotations
3
+ import html as _html
4
+ import json
5
+
6
+ from process_bigraph.visualization import Visualization
7
+
8
+
9
+ class PhaseSpace(Visualization):
10
+ """XY trajectory of two observables.
11
+
12
+ Inputs (declared types):
13
+ x: list[float]
14
+ y: list[float]
15
+ """
16
+
17
+ def inputs(self):
18
+ return {'x': 'list[float]', 'y': 'list[float]'}
19
+
20
+ def update(self, state):
21
+ xs = state.get('x') or []
22
+ ys = state.get('y') or []
23
+ title = (getattr(self, 'config', None) or {}).get('title', '')
24
+ traces = [{
25
+ 'x': xs, 'y': ys, 'type': 'scatter', 'mode': 'lines+markers',
26
+ 'line': {'color': '#6366f1', 'width': 2},
27
+ 'marker': {'size': 5},
28
+ }]
29
+ layout = {
30
+ 'title': {'text': _html.escape(title), 'font': {'size': 14}},
31
+ 'margin': {'l': 55, 'r': 15, 't': 40, 'b': 40},
32
+ 'showlegend': False,
33
+ }
34
+ return {'html': (
35
+ '<div id="viz" style="height:380px"></div>'
36
+ '<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>'
37
+ '<script>Plotly.newPlot("viz", '
38
+ + json.dumps(traces) + ', ' + json.dumps(layout)
39
+ + ', {responsive:true, displayModeBar:false});</script>'
40
+ )}
@@ -0,0 +1,91 @@
1
+ """TimeSeriesPlot — observable(s) vs time, one line per run."""
2
+ from __future__ import annotations
3
+ import html as _html
4
+ import json
5
+
6
+ from process_bigraph.visualization import Visualization
7
+
8
+
9
+ _PALETTE = ['#6366f1', '#10b981', '#f43f5e', '#f59e0b',
10
+ '#8b5cf6', '#06b6d4', '#84cc16', '#ec4899']
11
+
12
+
13
+ class TimeSeriesPlot(Visualization):
14
+ """Plot one or more observables vs time.
15
+
16
+ Inputs (declared types):
17
+ observable: list[float] — trajectory values, or list-of-lists for multi-run
18
+ time: list[float] — same shape as observable
19
+ """
20
+
21
+ def inputs(self):
22
+ return {'observable': 'list[float]', 'time': 'list[float]'}
23
+
24
+ def update(self, state):
25
+ obs = state.get('observable')
26
+ ts = state.get('time')
27
+ if obs is None or ts is None:
28
+ return {'html': '<p style="color:#991b1b">missing observable or time input</p>'}
29
+
30
+ if obs and isinstance(obs[0], list):
31
+ runs = list(zip(ts, obs))
32
+ else:
33
+ runs = [(ts, obs)]
34
+ labels = state.get('_run_labels') or [''] * len(runs)
35
+ overlays = state.get('_overlays') or []
36
+
37
+ traces = []
38
+ for i, (xs, ys) in enumerate(runs):
39
+ traces.append({
40
+ 'x': xs, 'y': ys, 'type': 'scatter', 'mode': 'lines',
41
+ 'name': labels[i] if i < len(labels) else f'run {i}',
42
+ 'line': {'color': _PALETTE[i % len(_PALETTE)], 'width': 2},
43
+ })
44
+
45
+ shapes = []
46
+ annotations = []
47
+ for ov in overlays:
48
+ kind = ov.get('kind')
49
+ if kind == 'reference-range':
50
+ y_min, y_max = ov.get('y_min'), ov.get('y_max')
51
+ if y_min is not None and y_max is not None:
52
+ shapes.append({
53
+ 'type': 'rect', 'xref': 'paper', 'yref': 'y',
54
+ 'x0': 0, 'x1': 1, 'y0': y_min, 'y1': y_max,
55
+ 'fillcolor': '#fef3c7', 'opacity': 0.3,
56
+ 'line': {'width': 0},
57
+ })
58
+ annotations.append({
59
+ 'xref': 'paper', 'yref': 'y',
60
+ 'x': 0.02, 'y': y_max,
61
+ 'text': _html.escape(ov.get('label', 'reference range')),
62
+ 'showarrow': False,
63
+ 'font': {'size': 11, 'color': '#92400e'},
64
+ })
65
+ elif kind == 'experimental-points':
66
+ pts = ov.get('points') or []
67
+ if pts:
68
+ traces.append({
69
+ 'x': [p['x'] for p in pts],
70
+ 'y': [p['y'] for p in pts],
71
+ 'type': 'scatter', 'mode': 'markers',
72
+ 'name': ov.get('label', 'experimental'),
73
+ 'marker': {'color': '#000', 'size': 8, 'symbol': 'circle-open'},
74
+ })
75
+
76
+ title = (getattr(self, 'config', None) or {}).get('title', '')
77
+ layout = {
78
+ 'title': {'text': _html.escape(title), 'font': {'size': 14}},
79
+ 'xaxis': {'title': {'text': 'time'}},
80
+ 'margin': {'l': 55, 'r': 15, 't': 40, 'b': 40},
81
+ 'legend': {'orientation': 'h', 'y': -0.2},
82
+ 'shapes': shapes,
83
+ 'annotations': annotations,
84
+ }
85
+ return {'html': (
86
+ '<div id="viz" style="height:380px"></div>'
87
+ '<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>'
88
+ '<script>Plotly.newPlot("viz", '
89
+ + json.dumps(traces) + ', ' + json.dumps(layout)
90
+ + ', {responsive:true, displayModeBar:false});</script>'
91
+ )}
@@ -0,0 +1,349 @@
1
+ """TimeSeriesFromObservables — self-contained multi-observable time-series.
2
+
3
+ Backs Finding #26 in the v2ecoli investigation walkthrough notes:
4
+
5
+ > Critical missing piece: a generic ``TimeSeriesFromObservables``
6
+ > Visualization Step that consumes any list of observable specs +
7
+ > a runs.db and produces a time-series plot. Without this, studies
8
+ > that declare ``address: local:TimeSeriesPlot`` have no backing
9
+ > code and can't be previewed.
10
+
11
+ The existing :class:`TimeSeriesPlot` is the right *renderer*, but its
12
+ ``observable: list[float]`` / ``time: list[float]`` inputs need to be
13
+ plumbed in by an upstream Step. The dashboard's ``build_viz_composite``
14
+ handles that plumbing for *single*-observable cases via an
15
+ ``inputs_map`` convention, but most study visualizations want to plot
16
+ several observables together with shared time + per-run colors — and
17
+ the wiring boilerplate has to be written per study, which the field
18
+ notes flagged as the main reason "Visualizations tab is empty" was
19
+ the default state.
20
+
21
+ This class fixes it by being **self-contained**:
22
+
23
+ - Its only required config is ``observables: list[str]`` — the names
24
+ of observables (matching ``study.yaml.observables[].name``) to plot.
25
+ - The dashboard's renderer injects ``_runs_db_path`` and
26
+ ``_study_yaml_path`` (the two private config keys this class
27
+ reads) when it builds the composite. Both are optional; the class
28
+ degrades gracefully when either is missing.
29
+ - The class reads runs.db itself (stdlib sqlite3 — no extra deps)
30
+ and renders one Plotly trace per (observable × run) pair with
31
+ units pulled from ``study.yaml.observables[].units`` when present.
32
+
33
+ The result is a single Viz address (``local:TimeSeriesFromObservables``)
34
+ that lights up most studies' declared visualizations without any
35
+ per-study code. The note recommends shipping this in the framework so
36
+ every workspace gets it; that's why this lives in
37
+ ``process_bigraph.visualizations`` alongside the other defaults.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import html as _html
42
+ import json
43
+ import sqlite3
44
+ from pathlib import Path
45
+
46
+ from process_bigraph.visualization import Visualization
47
+
48
+
49
+ _PALETTE = [
50
+ "#6366f1", "#10b981", "#f43f5e", "#f59e0b",
51
+ "#8b5cf6", "#06b6d4", "#84cc16", "#ec4899",
52
+ "#14b8a6", "#dc2626", "#3b82f6", "#a855f7",
53
+ ]
54
+
55
+
56
+ class TimeSeriesFromObservables(Visualization):
57
+ """Multi-observable time series, self-contained.
58
+
59
+ Config keys (user-set in ``study.yaml.visualizations[].config``):
60
+
61
+ - ``observables: list[str]`` (required) — observable names to plot.
62
+ Matched against ``study.yaml.observables[].name`` for units
63
+ + against the ``observables`` map in runs.db ``history.state``
64
+ for data.
65
+ - ``title: str`` (optional) — chart title. Defaults to ``""``.
66
+ - ``sources: list[str]`` (optional) — restrict to runs whose
67
+ ``sim_name`` is in this list. Default: all runs.
68
+
69
+ Config keys injected by the renderer (private; users don't set
70
+ these):
71
+
72
+ - ``_runs_db_path: str`` — absolute path to the study's runs.db.
73
+ When absent, the chart renders an empty-data placeholder.
74
+ - ``_study_yaml_path: str`` — absolute path to study.yaml. Used
75
+ to look up per-observable ``units``. When absent, units are
76
+ omitted from axis labels.
77
+
78
+ Inputs: none (the class plumbs its own data). The renderer's
79
+ ``build_viz_composite`` skips inputs_map plumbing when a viz class
80
+ declares no inputs.
81
+ """
82
+
83
+ config_schema = {
84
+ # Inherits title, render_mode, sample_every from Visualization.
85
+ **Visualization.config_schema,
86
+ "observables": {"_type": "list[string]", "_default": []},
87
+ "sources": {"_type": "list[string]", "_default": []},
88
+ # Private — injected by the dashboard renderer; the schema
89
+ # accepts string so YAML stays clean.
90
+ "runs_db_path": {"_type": "string", "_default": ""},
91
+ "run_id": {"_type": "string", "_default": ""},
92
+ "study_yaml_path": {"_type": "string", "_default": ""},
93
+ }
94
+
95
+ def inputs(self) -> dict:
96
+ # Self-contained: no bigraph inputs. The renderer detects this
97
+ # and skips inputs_map plumbing.
98
+ return {}
99
+
100
+ # The class is rendered in one shot (legacy contract). Override
101
+ # update() directly so the baseclass orchestrator stays out of the
102
+ # way — we don't accumulate per-tick state; runs.db already has it.
103
+ def update(self, state: dict) -> dict:
104
+ cfg = dict(getattr(self, "config", None) or {})
105
+ return {"html": _render_html(cfg)}
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Pure helpers (no Visualization runtime — easy to unit-test)
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def _load_study_observable_meta(study_yaml_path: str | None) -> dict[str, dict]:
114
+ """Return ``{observable_name: {units, description, store_path}}``.
115
+
116
+ Tolerant of missing file, malformed YAML, or absent ``observables``
117
+ block — returns an empty dict in any of those cases.
118
+ """
119
+ if not study_yaml_path:
120
+ return {}
121
+ p = Path(study_yaml_path)
122
+ if not p.is_file():
123
+ return {}
124
+ # PyYAML is not a hard process-bigraph dependency (same convention as
125
+ # composite_spec.py); import lazily so importing this module never
126
+ # requires it unless a caller actually supplies a study_yaml_path.
127
+ import yaml
128
+ try:
129
+ # study.yaml is UTF-8 (often non-ASCII prose); decode explicitly so a
130
+ # bare-CLI render under an ASCII locale doesn't crash on read.
131
+ spec = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
132
+ except yaml.YAMLError:
133
+ return {}
134
+ out: dict[str, dict] = {}
135
+ for o in (spec.get("observables") or []):
136
+ if isinstance(o, dict) and isinstance(o.get("name"), str):
137
+ out[o["name"]] = {
138
+ "units": o.get("units") or "",
139
+ "description": o.get("description") or "",
140
+ "store_path": o.get("store_path") or "",
141
+ }
142
+ return out
143
+
144
+
145
+ def _load_runs(runs_db_path: str | None, sources: list[str], run_id: str = "") -> list[dict]:
146
+ """Read runs.db and return a list of ``{run_id, sim_name, params,
147
+ observables, time}`` dicts.
148
+
149
+ ``observables`` is ``{name: list[number]}`` keyed by the names the
150
+ emitter wrote (typically the observable name as declared in
151
+ ``study.yaml``). ``time`` is the trajectory's time axis.
152
+
153
+ Returns ``[]`` if the db file is missing, malformed, or empty.
154
+ Restricts to runs in ``sources`` when provided (matching
155
+ ``runs_meta.sim_name``).
156
+ """
157
+ if not runs_db_path:
158
+ return []
159
+ p = Path(runs_db_path)
160
+ if not p.is_file():
161
+ return []
162
+
163
+ sources_set = set(sources or [])
164
+
165
+ try:
166
+ conn = sqlite3.connect(str(p))
167
+ except sqlite3.Error:
168
+ return []
169
+ conn.row_factory = sqlite3.Row
170
+ try:
171
+ try:
172
+ meta_rows = conn.execute(
173
+ "SELECT run_id, sim_name, params_json FROM runs_meta"
174
+ ).fetchall()
175
+ except sqlite3.OperationalError:
176
+ return []
177
+
178
+ has_history = conn.execute(
179
+ "SELECT name FROM sqlite_master "
180
+ "WHERE type='table' AND name='history'"
181
+ ).fetchone() is not None
182
+ if not has_history:
183
+ return []
184
+
185
+ out: list[dict] = []
186
+ for r in meta_rows:
187
+ if run_id and r["run_id"] != run_id:
188
+ continue
189
+ sim_name = r["sim_name"] or "default"
190
+ if sources_set and sim_name not in sources_set:
191
+ continue
192
+ try:
193
+ params = json.loads(r["params_json"] or "{}")
194
+ except (json.JSONDecodeError, TypeError):
195
+ params = {}
196
+ history_rows = conn.execute(
197
+ "SELECT step, global_time, state FROM history "
198
+ "WHERE simulation_id=? ORDER BY step ASC",
199
+ (r["run_id"],),
200
+ ).fetchall()
201
+ observables: dict[str, list] = {}
202
+ time_axis: list[float] = []
203
+ for hrow in history_rows:
204
+ try:
205
+ s = json.loads(hrow["state"]) if hrow["state"] else {}
206
+ except (json.JSONDecodeError, TypeError):
207
+ continue
208
+ for k, v in s.items():
209
+ observables.setdefault(k, []).append(v)
210
+ if "time" in s:
211
+ # Already captured under observables["time"]; mirror
212
+ # to the dedicated axis for convenience.
213
+ time_axis.append(s["time"])
214
+ else:
215
+ time_axis.append(hrow["global_time"])
216
+ out.append({
217
+ "run_id": r["run_id"],
218
+ "sim_name": sim_name,
219
+ "params": params,
220
+ "observables": observables,
221
+ "time": time_axis,
222
+ })
223
+ return out
224
+ finally:
225
+ conn.close()
226
+
227
+
228
+ def _label_for_run(run: dict, index: int) -> str:
229
+ """Compact label for a run, preferring params; falling back to
230
+ sim_name; finally the last 6 chars of run_id."""
231
+ params = run.get("params") or {}
232
+ if params:
233
+ return ", ".join(f"{k}={v}" for k, v in sorted(params.items()))
234
+ sim_name = run.get("sim_name") or ""
235
+ if sim_name and sim_name != "default":
236
+ return sim_name
237
+ rid = run.get("run_id") or f"run-{index}"
238
+ return rid[-6:]
239
+
240
+
241
+ def _build_traces(
242
+ runs: list[dict],
243
+ observable_names: list[str],
244
+ ) -> list[dict]:
245
+ """Build Plotly traces — one line per (observable × run) pair.
246
+
247
+ Lines are colored by observable; runs of the same observable share
248
+ a color but get distinct dash patterns when there are multiple
249
+ runs, so an expert can tell apart calibration sweeps at a glance.
250
+ """
251
+ traces: list[dict] = []
252
+ dash_patterns = ["solid", "dash", "dot", "dashdot", "longdash"]
253
+ for obs_idx, name in enumerate(observable_names):
254
+ color = _PALETTE[obs_idx % len(_PALETTE)]
255
+ runs_with_obs = [
256
+ (i, r) for i, r in enumerate(runs)
257
+ if name in (r.get("observables") or {})
258
+ ]
259
+ for trace_idx, (run_idx, run) in enumerate(runs_with_obs):
260
+ y = run["observables"][name]
261
+ x = run.get("time") or list(range(len(y)))
262
+ dash = dash_patterns[trace_idx % len(dash_patterns)] \
263
+ if len(runs_with_obs) > 1 else "solid"
264
+ label = name
265
+ if len(runs_with_obs) > 1:
266
+ label = f"{name} — {_label_for_run(run, run_idx)}"
267
+ traces.append({
268
+ "x": x, "y": y, "type": "scatter", "mode": "lines",
269
+ "name": label,
270
+ "line": {"color": color, "width": 2, "dash": dash},
271
+ })
272
+ return traces
273
+
274
+
275
+ def _y_axis_label(meta: dict[str, dict], names: list[str]) -> str:
276
+ """Compose a Y axis label from the requested observables.
277
+
278
+ - Single observable, units known: ``"<name> (<units>)"``.
279
+ - Multiple observables, all sharing a unit: ``"<units>"``.
280
+ - Multiple observables, mixed units: ``"value"`` (no label).
281
+ - No units known: ``""``.
282
+ """
283
+ units = [meta.get(n, {}).get("units") or "" for n in names]
284
+ units_set = {u for u in units if u}
285
+ if len(names) == 1:
286
+ if units[0]:
287
+ return f"{names[0]} ({units[0]})"
288
+ return names[0]
289
+ if len(units_set) == 1:
290
+ return next(iter(units_set))
291
+ return "value" if any(units) else ""
292
+
293
+
294
+ def _render_html(cfg: dict) -> str:
295
+ """Pure renderer: build the Plotly HTML from a viz config dict.
296
+
297
+ Exposed as a free function so tests can drive it without
298
+ constructing a Visualization instance.
299
+ """
300
+ observable_names = list(cfg.get("observables") or [])
301
+ if not observable_names:
302
+ return (
303
+ '<div style="padding:12px;color:#991b1b">'
304
+ 'TimeSeriesFromObservables: no observables declared in config. '
305
+ 'Set <code>observables: [name1, name2]</code> in the viz config.'
306
+ '</div>'
307
+ )
308
+
309
+ runs = _load_runs(cfg.get("runs_db_path") or cfg.get("_runs_db_path"), cfg.get("sources") or [], cfg.get("run_id") or "")
310
+ meta = _load_study_observable_meta(cfg.get("study_yaml_path") or cfg.get("_study_yaml_path"))
311
+
312
+ if not runs:
313
+ return (
314
+ '<div style="padding:12px;color:#92400e;background:#fef3c7;'
315
+ 'border:1px solid #fcd34d;border-radius:4px">'
316
+ '<strong>No run data yet.</strong><br>'
317
+ 'Run a baseline (or wait for a Simulate-phase run to complete) '
318
+ 'and re-render. The viz config is valid; the data side just '
319
+ 'isn\'t populated.'
320
+ '</div>'
321
+ )
322
+
323
+ traces = _build_traces(runs, observable_names)
324
+ if not traces:
325
+ return (
326
+ '<div style="padding:12px;color:#991b1b">'
327
+ 'None of the declared observables '
328
+ f'({", ".join(observable_names)}) were found in any run\'s '
329
+ 'emitted state. Check that the run\'s emitter is configured '
330
+ 'to record these names.'
331
+ '</div>'
332
+ )
333
+
334
+ title = cfg.get("title") or ""
335
+ y_label = _y_axis_label(meta, observable_names)
336
+ layout = {
337
+ "title": {"text": _html.escape(title), "font": {"size": 14}},
338
+ "xaxis": {"title": {"text": "time"}},
339
+ "yaxis": {"title": {"text": _html.escape(y_label)}},
340
+ "margin": {"l": 60, "r": 15, "t": 40, "b": 50},
341
+ "legend": {"orientation": "h", "y": -0.2},
342
+ }
343
+ return (
344
+ '<div id="viz" style="height:380px"></div>'
345
+ '<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>'
346
+ '<script>Plotly.newPlot("viz", '
347
+ + json.dumps(traces) + ", " + json.dumps(layout)
348
+ + ", {responsive:true, displayModeBar:false});</script>"
349
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-bigraph
3
- Version: 1.8.0
3
+ Version: 1.8.2
4
4
  Summary: protocol and execution for compositional systems biology
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -49,4 +49,11 @@ process_bigraph/server/__init__.py
49
49
  process_bigraph/server/rest.py
50
50
  process_bigraph/server/start.py
51
51
  process_bigraph/types/__init__.py
52
- process_bigraph/types/process.py
52
+ process_bigraph/types/process.py
53
+ process_bigraph/visualizations/__init__.py
54
+ process_bigraph/visualizations/distribution.py
55
+ process_bigraph/visualizations/heatmap.py
56
+ process_bigraph/visualizations/param_vs_observable.py
57
+ process_bigraph/visualizations/phase_space.py
58
+ process_bigraph/visualizations/time_series.py
59
+ process_bigraph/visualizations/timeseries_from_observables.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "process-bigraph"
7
- version = "1.8.0"
7
+ version = "1.8.2"
8
8
  description = "protocol and execution for compositional systems biology"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -93,4 +93,5 @@ packages = [
93
93
  "process_bigraph.server",
94
94
  "process_bigraph.experiments",
95
95
  "process_bigraph.types",
96
+ "process_bigraph.visualizations",
96
97
  ]
File without changes