rafkit 0.6.0__tar.gz → 0.7.0__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 (43) hide show
  1. {rafkit-0.6.0/src/rafkit.egg-info → rafkit-0.7.0}/PKG-INFO +1 -1
  2. {rafkit-0.6.0 → rafkit-0.7.0}/pyproject.toml +1 -1
  3. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/__init__.py +9 -1
  4. rafkit-0.7.0/src/rafkit/andl.py +301 -0
  5. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/binary_polymer.py +27 -13
  6. rafkit-0.7.0/src/rafkit/templated_polymer.py +441 -0
  7. {rafkit-0.6.0 → rafkit-0.7.0/src/rafkit.egg-info}/PKG-INFO +1 -1
  8. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit.egg-info/SOURCES.txt +4 -0
  9. rafkit-0.7.0/tests/test_andl.py +274 -0
  10. rafkit-0.7.0/tests/test_templated_polymer.py +386 -0
  11. {rafkit-0.6.0 → rafkit-0.7.0}/LICENSE +0 -0
  12. {rafkit-0.6.0 → rafkit-0.7.0}/README.md +0 -0
  13. {rafkit-0.6.0 → rafkit-0.7.0}/setup.cfg +0 -0
  14. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/autocatalysis.py +0 -0
  15. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/catalysis.py +0 -0
  16. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/complementary_polymer.py +0 -0
  17. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/crs.py +0 -0
  18. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/dilution.py +0 -0
  19. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/firing_disk.py +0 -0
  20. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/gillespie.py +0 -0
  21. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/inhibition.py +0 -0
  22. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/network.py +0 -0
  23. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/permeation.py +0 -0
  24. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/pnml.py +0 -0
  25. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/raf.py +0 -0
  26. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit/thermo.py +0 -0
  27. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit.egg-info/dependency_links.txt +0 -0
  28. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit.egg-info/requires.txt +0 -0
  29. {rafkit-0.6.0 → rafkit-0.7.0}/src/rafkit.egg-info/top_level.txt +0 -0
  30. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_autocatalysis.py +0 -0
  31. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_complementary_polymer.py +0 -0
  32. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_crs.py +0 -0
  33. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_dilution.py +0 -0
  34. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_firing_disk.py +0 -0
  35. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_gillespie.py +0 -0
  36. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_inhibition.py +0 -0
  37. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_permeation.py +0 -0
  38. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_pnml.py +0 -0
  39. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_published_examples.py +0 -0
  40. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_raf.py +0 -0
  41. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_seeding.py +0 -0
  42. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_thermo.py +0 -0
  43. {rafkit-0.6.0 → rafkit-0.7.0}/tests/test_thermo_kinetics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafkit
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models.
5
5
  Author: James P. Galasyn, Claude Théodore
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rafkit"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -29,6 +29,10 @@ from rafkit.binary_polymer import BinaryPolymerNetwork, binary_polymer
29
29
  from rafkit.complementary_polymer import (complement,
30
30
  complementary_polymer)
31
31
  from rafkit.firing_disk import firing_disk_polymer
32
+ from rafkit.templated_polymer import (catalysis_motifs, degree_preserving_null,
33
+ matched_f_cbpm, matched_f_random,
34
+ motif_matched_null, templated_catalysts,
35
+ templated_polymer)
32
36
  from rafkit.catalysis import catalysing_molecules, is_catalysed, normalise
33
37
  from rafkit.crs import parse_crs, read_crs, to_crs, write_crs
34
38
  from rafkit.dilution import (DilutionResult, flux_linear, flux_quadratic,
@@ -37,6 +41,7 @@ from rafkit.dilution import (DilutionResult, flux_linear, flux_quadratic,
37
41
  from rafkit.gillespie import Trajectory, propensities, simulate
38
42
  from rafkit.inhibition import (classes_from_inhibitors, is_uninhibited, is_uraf,
39
43
  max_urafs, support)
44
+ from rafkit.andl import to_andl, write_andl
40
45
  from rafkit.network import ReactionNetwork
41
46
  from rafkit.permeation import permeable_by_length, permeation_flux
42
47
  from rafkit.pnml import to_pnml, write_pnml
@@ -54,13 +59,15 @@ from rafkit.raf import (
54
59
  sample_irrraf,
55
60
  )
56
61
 
57
- __version__ = "0.6.0"
62
+ __version__ = "0.7.0"
58
63
 
59
64
  __all__ = [
60
65
  "BinaryPolymerNetwork", "binary_polymer",
61
66
  "Consistency", "is_thermodynamically_consistent", "stoichiometry", "affinities",
62
67
  "complementary_polymer",
63
68
  "firing_disk_polymer",
69
+ "templated_polymer", "templated_catalysts", "catalysis_motifs",
70
+ "degree_preserving_null", "motif_matched_null", "matched_f_random", "matched_f_cbpm",
64
71
  "complement", "ReactionNetwork",
65
72
  "RafResult", "max_raf", "max_raf_strict", "sample_irrraf", "irrraf_census",
66
73
  "exploitability", "is_food_catalysed", "catrenet_strictly_autocatalytic",
@@ -77,6 +84,7 @@ __all__ = [
77
84
  "detailed_balance_residual", "transfer_matrix", "elongation_ratio", "mean_length",
78
85
  "sequence_correlation_length", "unpaired_catalysis",
79
86
  "Kinetics", "kinetics_from_energies",
87
+ "to_andl", "write_andl",
80
88
  "to_pnml", "write_pnml",
81
89
  "simulate", "propensities", "Trajectory",
82
90
  "max_urafs", "is_uraf", "is_uninhibited", "support", "classes_from_inhibitors",
@@ -0,0 +1,301 @@
1
+ """Export to ANDL, the PetriNuts framework's executable Petri net format.
2
+
3
+ `rafkit.pnml` makes a network *readable* by the Petri net ecosystem; this module makes it
4
+ *runnable*. ANDL (the plain-text format shared by Snoopy, Spike and Marcie) carries what
5
+ PNML's ``ptnet`` grammar cannot: stochastic rate constants. A file written here is a
6
+ complete stochastic Petri net that Spike executes directly -- an independently developed
7
+ simulator re-running this chemistry from its definition, which is the entire point.
8
+
9
+ **The semantics exported are mass action, and nothing else.** Every transition's
10
+ propensity is its rate constant times the falling-factorial count product over its
11
+ pre-places -- what Gillespie's direct method computes and what Spike's ``MassAction``
12
+ was *measured* to compute (2026-08-29, three-toy calibration; see
13
+ ``docs/DESIGN_candl_exporter.md`` in the abiogenesis repository):
14
+
15
+ * a catalyst becomes a **consume-and-produce self-loop**, so it multiplies the
16
+ propensity by its count and is required to be present. This matches chemistries whose
17
+ catalysis is mass action in the catalyst count. It does **not** match
18
+ `rafkit.gillespie`, whose catalysis is a threshold -- any catalyst present buys the
19
+ full rate, and the propensity does not scale with catalyst count. That is a different
20
+ system, and this module makes no attempt to encode it.
21
+ * alternative catalyst sets become **separate transitions** whose propensities sum --
22
+ which *is* the mass-action reading of "either catalyses": each catalytic channel is
23
+ its own elementary reaction. ⚠ Channel names are MANGLED into the emitted ids
24
+ (``r1``'s second channel becomes ``t_4_r1_2``, not ``r1#2``): the authoritative
25
+ spelling of every id and rate constant is the generated file itself, and any id that
26
+ needed a disambiguating suffix is listed in the file's header. Read the file before
27
+ writing a ``.spc`` override.
28
+ * a catalyst set that is EMPTY (``chi = {∅}``, CRS ``[{}]`` -- "may proceed
29
+ uncatalysed") emits its spontaneous channel at the reaction's own ``k``, with no
30
+ self-loops. It therefore CONFLICTS with ``k_uncat``, which would be a second
31
+ spontaneous rate for the same reaction: that combination is refused.
32
+ * ``a + a -> aa`` is emitted with a weight-2 arc, and Spike computes the standard
33
+ unordered pair count ``n(n-1)/2`` (measured, not assumed -- the alternatives sat
34
+ 19-25 SE away). ⚠ This is `rafkit.gillespie`'s convention exactly. It is **not**
35
+ `abiogenesis.stochastic`'s, which counts ordered pairs ``n(n-1)``: a caller mapping
36
+ that chemistry must pre-double identical-reactant rate constants. Two in-house
37
+ conventions differing by 2x on self-pairs -- state which one your rates mean.
38
+
39
+ Rate constants are emitted as **named constants** (``k_<transition>``), so a Spike
40
+ ``.spc`` configuration can override any single rate without regenerating the file --
41
+ which is what makes a mutation control a config change rather than a code path.
42
+
43
+ Refused rather than silently altered -- an executable file that drops a feature does not
44
+ *document* a different system, it **runs** one:
45
+
46
+ * **inhibition**: no ANDL representation; `to_andl` raises. (`rafkit.pnml` may annotate
47
+ instead, because a PNML reader sees the annotation; a simulator would not.)
48
+ * **a catalyst that is also a reactant of the same reaction**: the self-loop and the
49
+ consuming arc merge into a weight-2 pre-arc, and implementations disagree on the
50
+ combinatorics that implies. Raise, until someone measures what the target tool does.
51
+ * ``χ = ∅`` ("must be catalysed, nothing does") with no uncatalysed channel: omitted and
52
+ counted in the header, as in `rafkit.pnml` -- emitting it unconstrained would make an
53
+ impossible reaction fireable.
54
+
55
+ Stoichiometry is read from the **multiplicity of the reactant/product tuples**.
56
+ `ReactionNetwork`'s contract treats those as sets ("it would be for stoichiometry, which
57
+ this class does not model"). `parse_crs` and `BinaryPolymerNetwork` preserve duplicates
58
+ in the tuples they build -- but ⚠ **`to_crs` does not**: it deduplicates reactants, so an
59
+ in-library ``write_crs -> read_crs -> to_andl`` round-trip silently halves self-pair
60
+ stoichiometry (``a + a -> aa`` exports with a weight-1 arc, wrong kinetics AND wrong
61
+ mass balance). Nothing here can detect a deduplicated tuple; do not route stoichiometric
62
+ exports through the CRS text format.
63
+ """
64
+ from __future__ import annotations
65
+
66
+ import math
67
+ import re
68
+ from collections import Counter
69
+ from collections.abc import Mapping
70
+ from pathlib import Path
71
+
72
+ __all__ = ["to_andl", "write_andl"]
73
+
74
+
75
+ def _identifier(name: str, taken: dict[str, object], prefix: str, key: object) -> str:
76
+ """A deterministic ANDL identifier for `name`, unique within `taken`.
77
+
78
+ ⚠ Length-prefixed (``s_1_a``, ``s_2_ab``), and it is load-bearing: Spike 1.6.0rc2
79
+ silently MISROUTES a transition's products when one place name is a proper prefix
80
+ of another (measured 2026-08-29: with places ``s_0`` and ``s_00``, tokens landed in
81
+ unrelated places, exceeding their own maximum possible production). The length
82
+ prefix makes a proper prefix relation between distinct names impossible; applied
83
+ to transition names too, where ``t_r1`` / ``t_r1_u`` had the same shape.
84
+ """
85
+ clean = re.sub(r"[^0-9a-zA-Z_]", "_", name) # ASCII-only: Spike rejects
86
+ base = f"{prefix}{len(clean)}_{clean}" # non-ASCII identifiers
87
+ cand, n = base, 1
88
+ while cand in taken and taken[cand] != key:
89
+ n += 1
90
+ cand = f"{base}__{n}"
91
+ taken[cand] = key
92
+ return cand
93
+
94
+
95
+ def _rate(value, what: str) -> float:
96
+ """A rate constant fit to be executed: finite and non-negative, loudly otherwise.
97
+
98
+ Spike loads ``nan`` without complaint (measured), so garbage here surfaces only as
99
+ meaningless simulation output -- the silent-wrong-system failure this module's
100
+ refusals exist to prevent."""
101
+ v = float(value)
102
+ if not math.isfinite(v) or v < 0.0:
103
+ raise ValueError(f"{what} = {value!r} is not a finite non-negative rate")
104
+ return v
105
+
106
+
107
+ def _per_reaction(value, n_reactions: int, what: str) -> list[float]:
108
+ if value is None:
109
+ raise ValueError(f"{what} is required: ANDL exists to carry rate constants")
110
+ if isinstance(value, Mapping):
111
+ raise ValueError(
112
+ f"{what} must be a scalar or a per-reaction sequence, not a mapping -- "
113
+ "iterating a dict would silently use its KEYS as rates")
114
+ try:
115
+ seq = [_rate(value, what)] * n_reactions
116
+ except TypeError:
117
+ seq = [_rate(v, f"{what}[{i}]") for i, v in enumerate(value)]
118
+ if len(seq) != n_reactions:
119
+ raise ValueError(f"{what} covers {len(seq)} reactions, "
120
+ f"but the network has {n_reactions}")
121
+ return seq
122
+
123
+
124
+ def _per_molecule(value, net, what: str) -> dict[int, float]:
125
+ """None -> {}; scalar -> every food molecule; mapping name->value -> those."""
126
+ if value is None:
127
+ return {}
128
+ if isinstance(value, dict):
129
+ index = {m: i for i, m in enumerate(net.molecules)}
130
+ missing = [n for n in value if n not in index]
131
+ if missing:
132
+ raise ValueError(f"{what} names unknown molecules: {sorted(missing)}")
133
+ return {index[n]: _rate(v, f"{what}[{n}]") for n, v in value.items()}
134
+ return {m: _rate(value, what) for m in sorted(net.food)}
135
+
136
+
137
+ def to_andl(net, k, *, name: str = "rafkit", k_uncat=None, marking=None,
138
+ food_influx=None, washout=None) -> str:
139
+ """Serialise a network plus mass-action rate constants to ANDL text.
140
+
141
+ `k` (scalar or per-reaction sequence) is the rate constant of each **catalysed
142
+ channel**: one transition per catalyst set, propensity ``k * prod(reactants) *
143
+ prod(catalyst set members)``, summed across alternative sets by construction.
144
+
145
+ `k_uncat` (optional, scalar or per-reaction) adds one **uncatalysed transition per
146
+ reaction** at that constant -- the background channel, ``prod(reactants)`` only.
147
+ With it, a ``χ = ∅`` reaction exports as its background channel alone; without it,
148
+ such reactions are omitted and counted in the header.
149
+
150
+ `marking` is a ``{molecule name: count}`` dict; molecules not named start at 0,
151
+ except food, which starts at 1 unless overridden (the `rafkit.pnml` default).
152
+
153
+ `food_influx` / `washout`: scalar (applied to every food molecule / every molecule
154
+ respectively) or ``{molecule name: rate}``. Influx becomes a source transition with
155
+ an empty preset -- constant propensity equal to the rate. Washout becomes a sink
156
+ transition per molecule, propensity ``rate * count``.
157
+ """
158
+ n_r = net.n_reactions
159
+ ks = _per_reaction(k, n_r, "k")
160
+ kus = _per_reaction(k_uncat, n_r, "k_uncat") if k_uncat is not None else None
161
+ names = getattr(net, "names", None) or [f"r{i + 1}" for i in range(n_r)]
162
+
163
+ inhibitors = getattr(net, "inhibitors", ()) or ()
164
+ inhibited = [names[r] for r in range(n_r) if r < len(inhibitors) and inhibitors[r]]
165
+ if inhibited:
166
+ raise ValueError(
167
+ "inhibition has no ANDL representation, and an executable export that "
168
+ "drops it RUNS a different system rather than documenting one; strip the "
169
+ f"inhibitors first if that system is what you want: {inhibited}")
170
+
171
+ dup_m = sorted({m for m in net.molecules if list(net.molecules).count(m) > 1})
172
+ if dup_m:
173
+ raise ValueError(f"duplicate molecule names {dup_m}: two distinct places "
174
+ "would share one identifier, and the file would lie")
175
+ dup_r = sorted({n for n in names if list(names).count(n) > 1})
176
+ if dup_r:
177
+ raise ValueError(f"duplicate reaction names {dup_r}: their transitions and "
178
+ "rate constants would collide")
179
+
180
+ taken: dict[str, object] = {}
181
+ place = [_identifier(m, taken, "s_", ("mol", i))
182
+ for i, m in enumerate(net.molecules)]
183
+
184
+ marking = dict(marking or {})
185
+ index = {m: i for i, m in enumerate(net.molecules)}
186
+ unknown = [n for n in marking if n not in index]
187
+ if unknown:
188
+ raise ValueError(f"marking names unknown molecules: {sorted(unknown)}")
189
+ counts = {i: 0 for i in range(net.n_molecules)}
190
+ for m in net.food:
191
+ counts[m] = 1
192
+ for n, v in marking.items():
193
+ iv = int(v)
194
+ if iv != v or iv < 0:
195
+ raise ValueError(f"marking[{n!r}] = {v!r}: initial markings are "
196
+ "non-negative integers; refusing to truncate or "
197
+ "go negative (Spike loads a negative marking silently)")
198
+ counts[index[n]] = iv
199
+
200
+ influx = _per_molecule(food_influx, net, "food_influx")
201
+ outflux = _per_molecule(washout, net, "washout") if isinstance(washout, dict) \
202
+ else ({m: _rate(washout, "washout") for m in range(net.n_molecules)}
203
+ if washout is not None else {})
204
+
205
+ constants: list[tuple[str, float]] = []
206
+ transitions: list[tuple[str, str, str]] = [] # (tid, arcs, kname)
207
+ skipped = 0
208
+
209
+ def arcs_text(consumed: Counter, produced: Counter) -> str:
210
+ parts = []
211
+ for p in sorted(set(consumed) | set(produced)):
212
+ if consumed.get(p):
213
+ parts.append(f"[{place[p]} - {consumed[p]}]")
214
+ if produced.get(p):
215
+ parts.append(f"[{place[p]} + {produced[p]}]")
216
+ return " & ".join(parts)
217
+
218
+ for r in range(n_r):
219
+ reactants = Counter(net.reactants(r))
220
+ products = Counter(net.products(r))
221
+ chi = net.catalysts[r]
222
+ if frozenset() in chi and kus is not None:
223
+ raise ValueError(
224
+ f"reaction {names[r]!r} has an EMPTY catalyst set (may proceed "
225
+ "uncatalysed): its spontaneous channel already runs at k, and "
226
+ "k_uncat would add a second spontaneous rate on identical arcs. "
227
+ "One spontaneous rate per reaction; pick one.")
228
+ for j, cat_set in enumerate(sorted(chi, key=lambda u: sorted(u))):
229
+ clash = sorted(set(cat_set) & set(reactants))
230
+ if clash:
231
+ raise ValueError(
232
+ f"reaction {names[r]!r}: catalyst(s) "
233
+ f"{[net.molecules[c] for c in clash]} are also reactants. The "
234
+ "self-loop would merge with the consuming arc into a weight-2 "
235
+ "pre-arc, whose combinatorics differ between implementations; "
236
+ "refusing until the target tool's convention is measured.")
237
+ tid = _identifier(names[r] if j == 0 else f"{names[r]}#{j + 1}",
238
+ taken, "t_", ("rxn", r, j))
239
+ kname = f"k_{tid}"
240
+ constants.append((kname, ks[r]))
241
+ consumed = reactants + Counter(cat_set)
242
+ produced = products + Counter(cat_set)
243
+ transitions.append((tid, arcs_text(consumed, produced), kname))
244
+ if kus is not None:
245
+ tid = _identifier(f"{names[r]}_u", taken, "t_", ("unc", r))
246
+ kname = f"k_{tid}"
247
+ constants.append((kname, kus[r]))
248
+ transitions.append((tid, arcs_text(reactants, products), kname))
249
+ elif not chi:
250
+ skipped += 1
251
+
252
+ for m, rate in sorted(influx.items()):
253
+ tid = _identifier(f"src_{net.molecules[m]}", taken, "t_", ("src", m))
254
+ kname = f"k_{tid}"
255
+ constants.append((kname, rate))
256
+ transitions.append((tid, f"[{place[m]} + 1]", kname))
257
+ for m, rate in sorted(outflux.items()):
258
+ tid = _identifier(f"out_{net.molecules[m]}", taken, "t_", ("out", m))
259
+ kname = f"k_{tid}"
260
+ constants.append((kname, rate))
261
+ transitions.append((tid, f"[{place[m]} - 1]", kname))
262
+
263
+ notes = [f"Generated by rafkit. {net.n_molecules} species, {n_r} reactions.",
264
+ "Semantics: mass action; catalysts are consume-and-produce self-loops",
265
+ "(propensity scales with catalyst count); identical-reactant pairs use",
266
+ "the unordered convention n(n-1)/2 (Spike, measured 2026-08-29)."]
267
+ if skipped:
268
+ notes.append(f"{skipped} reaction(s) omitted: they require a catalyst, "
269
+ "nothing catalyses them, and no k_uncat was given, so they "
270
+ "can never fire.")
271
+ if not influx:
272
+ notes.append("NO FOOD SOURCES: food is limited to its initial marking and "
273
+ "WILL deplete. This is a different system from RAF semantics "
274
+ "(rafkit.pnml defaults sources ON); pass food_influx for a "
275
+ "driven run.")
276
+ suffixed = sorted(t for t in taken if "__" in t)
277
+ if suffixed:
278
+ notes.append("Disambiguated ids (name collisions across kinds): "
279
+ + ", ".join(suffixed))
280
+
281
+ safe_name = re.sub(r"[^0-9a-zA-Z_]", "_", name) or "rafkit"
282
+ lines = ["/*"] + [f" * {n}" for n in notes] + [" */", "",
283
+ f"spn [{safe_name}]", "{"]
284
+ if constants:
285
+ lines += ["constants:", "all:"]
286
+ lines += [f" double {kn} = {kv!r};" for kn, kv in constants]
287
+ lines.append("")
288
+ lines += ["places:", "discrete:"]
289
+ lines += [f" {place[m]} = {counts[m]};" for m in range(net.n_molecules)]
290
+ lines.append("")
291
+ lines += ["transitions:", "stochastic:"]
292
+ for tid, arcs, kname in transitions:
293
+ lines += [f" {tid}", " :", f" : {arcs}",
294
+ f" : MassAction({kname})", " ;"]
295
+ lines += ["}", ""]
296
+ return "\n".join(lines)
297
+
298
+
299
+ def write_andl(net, k, path: str | Path, **kwargs) -> None:
300
+ """Write a network plus rate constants to an ANDL file."""
301
+ Path(path).write_text(to_andl(net, k, **kwargs), encoding="utf-8")
@@ -102,6 +102,30 @@ class BinaryPolymerNetwork:
102
102
  def n_cleavages(self) -> int:
103
103
  return sum(1 for d in self.directions if d < 0)
104
104
 
105
+ @property
106
+ def n_pairs(self) -> int:
107
+ """Reversible pairs, under the ONE layout every pair-wise consumer assumes: every
108
+ ligation first, then (optionally) every cleavage, reaction ``i`` paired with
109
+ ``i + n``. Refused otherwise -- a network with the right count and interleaved
110
+ directions would otherwise be read with unrelated reactions unioned."""
111
+ n = self.n_reactions - self.n_cleavages
112
+ if self.n_cleavages not in (0, n):
113
+ raise ValueError("expected every ligation, optionally followed by every cleavage: "
114
+ f"{n} ligations and {self.n_cleavages} cleavages")
115
+ if tuple(self.directions[:n]) != (1,) * n or tuple(self.directions[n:]) != (-1,) * self.n_cleavages:
116
+ raise ValueError("expected every ligation first, then every cleavage, reaction i "
117
+ "paired with i + n; the directions are interleaved")
118
+ return n
119
+
120
+ def pair_catalysts(self) -> list[frozenset]:
121
+ """Per reversible pair, the union of the two directions' catalyst sets (the raw
122
+ conjunctive groups). Under `paired_catalysis` the halves are identical and this is
123
+ each ligation's own set; it differs only where the directions were drawn separately
124
+ -- as in C-BPM, where a catalyst acts on one direction only."""
125
+ n = self.n_pairs
126
+ return [self.catalysts[i] | self.catalysts[i + n] if self.n_cleavages else self.catalysts[i]
127
+ for i in range(n)]
128
+
105
129
  @property
106
130
  def catalysis_level(self) -> float:
107
131
  """`f` in the published convention: catalysed reactions per molecule.
@@ -115,20 +139,10 @@ class BinaryPolymerNetwork:
115
139
  """
116
140
  if not self.molecules:
117
141
  return 0.0
118
- n_pairs = self.n_reactions - self.n_cleavages
119
142
  # Counted over the reversible PAIR: a cleavage-ligation pair is one reaction, so
120
- # take the union of the two directions' catalysts. Under `paired_catalysis` the
121
- # halves are identical and this is exactly the old count; it differs only where
122
- # the directions were drawn separately -- as in C-BPM, where a catalyst acts on
123
- # one direction only and counting the ligation half alone would miss every
124
- # cleavage catalyst.
125
- total = 0
126
- for i in range(n_pairs):
127
- both = self.catalysts[i]
128
- if i + n_pairs < self.n_reactions:
129
- both = both | self.catalysts[i + n_pairs]
130
- total += len(both)
131
- return total / len(self.molecules)
143
+ # the union of the two directions' catalysts (`pair_catalysts`); counting the
144
+ # ligation half alone would miss every C-BPM cleavage catalyst.
145
+ return sum(len(both) for both in self.pair_catalysts()) / len(self.molecules)
132
146
 
133
147
  @property
134
148
  def n_inhibiting_molecules(self) -> int: