rafkit 0.6.0__tar.gz → 0.7.1__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.
- {rafkit-0.6.0/src/rafkit.egg-info → rafkit-0.7.1}/PKG-INFO +100 -4
- {rafkit-0.6.0 → rafkit-0.7.1}/README.md +99 -3
- {rafkit-0.6.0 → rafkit-0.7.1}/pyproject.toml +1 -1
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/__init__.py +9 -1
- rafkit-0.7.1/src/rafkit/andl.py +301 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/binary_polymer.py +47 -13
- rafkit-0.7.1/src/rafkit/templated_polymer.py +447 -0
- {rafkit-0.6.0 → rafkit-0.7.1/src/rafkit.egg-info}/PKG-INFO +100 -4
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit.egg-info/SOURCES.txt +4 -0
- rafkit-0.7.1/tests/test_andl.py +274 -0
- rafkit-0.7.1/tests/test_templated_polymer.py +407 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/LICENSE +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/setup.cfg +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/autocatalysis.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/catalysis.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/complementary_polymer.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/crs.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/dilution.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/firing_disk.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/gillespie.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/inhibition.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/network.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/permeation.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/pnml.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/raf.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit/thermo.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit.egg-info/dependency_links.txt +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit.egg-info/requires.txt +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/src/rafkit.egg-info/top_level.txt +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_autocatalysis.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_complementary_polymer.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_crs.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_dilution.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_firing_disk.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_gillespie.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_inhibition.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_permeation.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_pnml.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_published_examples.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_raf.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_seeding.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_thermo.py +0 -0
- {rafkit-0.6.0 → rafkit-0.7.1}/tests/test_thermo_kinetics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rafkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
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
|
|
@@ -39,8 +39,9 @@ Dynamic: license-file
|
|
|
39
39
|
[](https://doi.org/10.5281/zenodo.21954795)
|
|
40
40
|
|
|
41
41
|
Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
|
|
42
|
-
cores,
|
|
43
|
-
[CatReNet](https://github.com/husonlab/catrenet)
|
|
42
|
+
cores, binary polymer chemistries whose catalysis is drawn, structured or templated, and
|
|
43
|
+
interoperability with [CatReNet](https://github.com/husonlab/catrenet) and the Petri net
|
|
44
|
+
tools.
|
|
44
45
|
|
|
45
46
|
Pure Python and NumPy. No Java, no GUI, no install beyond `pip`.
|
|
46
47
|
|
|
@@ -115,10 +116,17 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
|
|
|
115
116
|
| `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
|
|
116
117
|
| `core_raf` / `has_unique_irraf` | Huson, Xavier & Steel's polynomial test for a *unique* irreducible RAF |
|
|
117
118
|
| `catalytically_reachable` | what can be made without any spontaneous reaction |
|
|
118
|
-
| `binary_polymer` | Kauffman
|
|
119
|
+
| `binary_polymer` | Kauffman's K-BPM: catalysis by coin flip, with optional cleavage |
|
|
120
|
+
| `complementary_polymer` | Serra & Villani's C-BPM: catalysis by active-site complementarity, not by coin flip |
|
|
121
|
+
| `firing_disk_polymer` | their firing-disk construction — a chemistry *grown* from a seed, closed under its own production |
|
|
122
|
+
| `templated_polymer` / `templated_catalysts` | templated ligation: a species catalyses `a + b -> ab` iff it carries the product's complement — nothing is drawn |
|
|
123
|
+
| `degree_preserving_null` / `motif_matched_null` | same reactions, rewired catalysis — the nulls a structured chemistry is compared against |
|
|
124
|
+
| `matched_f_random` / `matched_f_cbpm` | the K-BPM and C-BPM at a structured chemistry's catalysis level |
|
|
125
|
+
| `catalysis_motifs` | what a chemistry and its nulls are compared on |
|
|
119
126
|
| `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
|
|
120
127
|
| `read_crs` / `write_crs` | CatReNet's CRS interchange format |
|
|
121
128
|
| `to_pnml` / `write_pnml` | PNML export (ISO/IEC 15909-2) for the Petri net ecosystem |
|
|
129
|
+
| `to_andl` / `write_andl` | ANDL export with mass-action rate constants — runs unchanged in Spike |
|
|
122
130
|
| `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
|
|
123
131
|
| `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
|
|
124
132
|
| `run_serial_dilution` / `run_cstr` | dilution protocols for growing–dividing compartments — **not a RAF algorithm**, see below |
|
|
@@ -133,6 +141,67 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
|
|
|
133
141
|
Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
|
|
134
142
|
is subtly wrong produces plausible numbers rather than errors.
|
|
135
143
|
|
|
144
|
+
## Catalysis three ways: drawn, structured, and templated
|
|
145
|
+
|
|
146
|
+
`binary_polymer` is Kauffman's K-BPM: every string up to `max_len` exists, and whether a
|
|
147
|
+
species catalyses a reaction is an independent coin flip at probability `p`. Two more
|
|
148
|
+
ensembles keep that reaction set and change the one thing that matters — *which* catalyst
|
|
149
|
+
catalyses *which* reaction.
|
|
150
|
+
|
|
151
|
+
**`complementary_polymer`** is Serra & Villani's C-BPM (*Entropy* 28(2), 184, 2026),
|
|
152
|
+
reproduced rather than invented. A catalyst carries an active site — a substring of itself,
|
|
153
|
+
3–4 residues long by default — and acts on whatever is complementary to that site. A
|
|
154
|
+
K-catalyst's targets are independent draws; a C-catalyst's targets all share one template,
|
|
155
|
+
so they are structurally correlated, and Serra & Villani measure the signature of that as a
|
|
156
|
+
far higher and far more irregular reactions-per-catalyst distribution (~400 against ~20).
|
|
157
|
+
**`firing_disk_polymer`** is their other construction. Rather than enumerating every string
|
|
158
|
+
and sprinkling catalysis over the result, it grows the chemistry outward from a small seed,
|
|
159
|
+
so a species exists only if some reaction actually makes it: an enumerated chemistry is
|
|
160
|
+
full of species nothing can reach, and a grown one is closed under its own production by
|
|
161
|
+
construction. ⚠ Food is taken to be the firing disk, an assumption the paper leaves open.
|
|
162
|
+
|
|
163
|
+
**`templated_polymer`** draws nothing. A species templates the ligation `a + b -> ab`
|
|
164
|
+
exactly when it contains the (reverse) complement of the product — or, under
|
|
165
|
+
`rule="junction"`, of the `2h` residues spanning the junction — given both reactants reach
|
|
166
|
+
the per-side overlap `h`. The catalysis graph is a *function* of the sequence set, which is
|
|
167
|
+
the content of a template world and the reason this generator has no `rng` argument. The
|
|
168
|
+
cleavage carries its ligation's templates, so a template changes a reversible reaction's
|
|
169
|
+
rate and never its equilibrium. `templated_catalysts` is the same rule as a pure function
|
|
170
|
+
over whatever strands exist, for a simulator that holds explicit strands.
|
|
171
|
+
|
|
172
|
+
Sized on the complete `max_len` 7 set, the two rules behave very differently, and the
|
|
173
|
+
numbers are pinned as known answers:
|
|
174
|
+
|
|
175
|
+
| | |
|
|
176
|
+
|---|---|
|
|
177
|
+
| junction rule, `h` ≤ 2 | **saturates** — every eligible reaction templated by 194 or 46 species, f = 978 and 142 against ~5 for a random chemistry at the RAF threshold: a uniform speed-up with no specificity left |
|
|
178
|
+
| product rule, `h` 2 | f = 12.9, with the template count *falling* with product length — 46 for a 4-mer, exactly one for a 7-mer, its own complement |
|
|
179
|
+
| either rule, `h` 3 | the **maximal RAF is empty**: templated reactions need reactants of length ≥ `h`, and only untemplated reactions make those from a food set shorter than `h` |
|
|
180
|
+
|
|
181
|
+
The last row is the one to remember. `max_raf` remains the right question about closure
|
|
182
|
+
and the wrong one about whether templating acts — the chemistry is perfectly runnable on an
|
|
183
|
+
uncatalysed background.
|
|
184
|
+
|
|
185
|
+
**A structured chemistry needs something to be compared against**, and "random at the same
|
|
186
|
+
f" is a poor choice: at a templated f, a random chemistry spreads its edges over nearly
|
|
187
|
+
every reaction (reach ~0.92 against the product rule's 0.61). So each null holds something
|
|
188
|
+
different fixed, and `catalysis_motifs` reports what they are compared on — f, reach,
|
|
189
|
+
catalysts per reaction by product length, self-catalysed reactions, mutual pairs, and the
|
|
190
|
+
maximal RAF's size, all counted on the reversible pair:
|
|
191
|
+
|
|
192
|
+
- `degree_preserving_null` — a double-edge-swap chain keeping every degree;
|
|
193
|
+
`stratified=True` also keeps the *lengths* of each reaction's templates, which the plain
|
|
194
|
+
shuffle breaks along with the sequence tie.
|
|
195
|
+
- `motif_matched_null` — random catalysis with exactly the network's edge count,
|
|
196
|
+
self-catalysed reactions and mutual pairs; `match_lengths=True` plants them at the
|
|
197
|
+
network's own lengths, without which it is a second random chemistry with two counts
|
|
198
|
+
pinned.
|
|
199
|
+
- `matched_f_random` / `matched_f_cbpm` — the K-BPM and C-BPM at the network's catalysis
|
|
200
|
+
level, in expectation.
|
|
201
|
+
|
|
202
|
+
Every one of these returns the same `BinaryPolymerNetwork`, so everything in the table
|
|
203
|
+
above runs on them unchanged.
|
|
204
|
+
|
|
136
205
|
## Four modules are deliberately off-theme: `dilution`, `permeation`, `thermo` and `autocatalysis`
|
|
137
206
|
|
|
138
207
|
Everything above takes a `ReactionNetwork` and asks a RAF question of it. `rafkit.dilution`
|
|
@@ -388,6 +457,33 @@ with a warning in the file: a reader that ignores it gets a *different system*.
|
|
|
388
457
|
Reactions requiring a catalyst that nothing provides are omitted and counted, since
|
|
389
458
|
emitting them unconstrained would make them freely fireable — the opposite of the intent.
|
|
390
459
|
|
|
460
|
+
### Readable is not runnable: `to_andl`
|
|
461
|
+
|
|
462
|
+
PNML's `ptnet` grammar has no place for a rate constant, so a PNML file documents a
|
|
463
|
+
network without being able to run it. `to_andl` / `write_andl` export the same network to
|
|
464
|
+
ANDL, the PetriNuts format shared by Snoopy, Spike and Marcie, with mass-action rate
|
|
465
|
+
constants — a complete stochastic Petri net that Spike executes directly. An independently
|
|
466
|
+
developed simulator re-running this chemistry from its definition is the entire point.
|
|
467
|
+
|
|
468
|
+
The semantics are mass action and nothing else, and they were **measured** against Spike
|
|
469
|
+
1.6.0rc2 rather than read from its documentation: a catalyst is a consume-and-produce
|
|
470
|
+
self-loop, so the propensity scales with catalyst count; alternative catalyst sets are
|
|
471
|
+
separate transitions whose propensities sum; and `a + a -> aa` is a weight-2 arc counted
|
|
472
|
+
as unordered pairs, `n(n−1)/2`, with the alternatives 19–25 standard errors away.
|
|
473
|
+
⚠ Catalysis scaling with catalyst count is *not* what `simulate` does — there any catalyst
|
|
474
|
+
present buys the full rate — so this export runs a different system from the in-library
|
|
475
|
+
simulator, deliberately; the pair convention, on the other hand, is `simulate`'s exactly.
|
|
476
|
+
Rate constants are emitted as named constants, so a Spike `.spc` configuration can override
|
|
477
|
+
any single rate without regenerating the file.
|
|
478
|
+
|
|
479
|
+
Refused rather than silently altered, because an executable file that drops a feature does
|
|
480
|
+
not *document* a different system, it **runs** one: inhibition (PNML may annotate it, since
|
|
481
|
+
a reader sees the annotation; a simulator would not), and a catalyst that is also a
|
|
482
|
+
reactant of the same reaction, whose self-loop merges with the consuming arc into a
|
|
483
|
+
weight-2 pre-arc with implementation-dependent combinatorics. The latter means a
|
|
484
|
+
`templated_polymer` network, in which a template can be its own reactant, is refused until
|
|
485
|
+
someone measures what Spike does with it.
|
|
486
|
+
|
|
391
487
|
## Catalysis is a relation, not a list
|
|
392
488
|
|
|
393
489
|
`catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
[](https://doi.org/10.5281/zenodo.21954795)
|
|
9
9
|
|
|
10
10
|
Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
|
|
11
|
-
cores,
|
|
12
|
-
[CatReNet](https://github.com/husonlab/catrenet)
|
|
11
|
+
cores, binary polymer chemistries whose catalysis is drawn, structured or templated, and
|
|
12
|
+
interoperability with [CatReNet](https://github.com/husonlab/catrenet) and the Petri net
|
|
13
|
+
tools.
|
|
13
14
|
|
|
14
15
|
Pure Python and NumPy. No Java, no GUI, no install beyond `pip`.
|
|
15
16
|
|
|
@@ -84,10 +85,17 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
|
|
|
84
85
|
| `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
|
|
85
86
|
| `core_raf` / `has_unique_irraf` | Huson, Xavier & Steel's polynomial test for a *unique* irreducible RAF |
|
|
86
87
|
| `catalytically_reachable` | what can be made without any spontaneous reaction |
|
|
87
|
-
| `binary_polymer` | Kauffman
|
|
88
|
+
| `binary_polymer` | Kauffman's K-BPM: catalysis by coin flip, with optional cleavage |
|
|
89
|
+
| `complementary_polymer` | Serra & Villani's C-BPM: catalysis by active-site complementarity, not by coin flip |
|
|
90
|
+
| `firing_disk_polymer` | their firing-disk construction — a chemistry *grown* from a seed, closed under its own production |
|
|
91
|
+
| `templated_polymer` / `templated_catalysts` | templated ligation: a species catalyses `a + b -> ab` iff it carries the product's complement — nothing is drawn |
|
|
92
|
+
| `degree_preserving_null` / `motif_matched_null` | same reactions, rewired catalysis — the nulls a structured chemistry is compared against |
|
|
93
|
+
| `matched_f_random` / `matched_f_cbpm` | the K-BPM and C-BPM at a structured chemistry's catalysis level |
|
|
94
|
+
| `catalysis_motifs` | what a chemistry and its nulls are compared on |
|
|
88
95
|
| `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
|
|
89
96
|
| `read_crs` / `write_crs` | CatReNet's CRS interchange format |
|
|
90
97
|
| `to_pnml` / `write_pnml` | PNML export (ISO/IEC 15909-2) for the Petri net ecosystem |
|
|
98
|
+
| `to_andl` / `write_andl` | ANDL export with mass-action rate constants — runs unchanged in Spike |
|
|
91
99
|
| `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
|
|
92
100
|
| `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
|
|
93
101
|
| `run_serial_dilution` / `run_cstr` | dilution protocols for growing–dividing compartments — **not a RAF algorithm**, see below |
|
|
@@ -102,6 +110,67 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
|
|
|
102
110
|
Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
|
|
103
111
|
is subtly wrong produces plausible numbers rather than errors.
|
|
104
112
|
|
|
113
|
+
## Catalysis three ways: drawn, structured, and templated
|
|
114
|
+
|
|
115
|
+
`binary_polymer` is Kauffman's K-BPM: every string up to `max_len` exists, and whether a
|
|
116
|
+
species catalyses a reaction is an independent coin flip at probability `p`. Two more
|
|
117
|
+
ensembles keep that reaction set and change the one thing that matters — *which* catalyst
|
|
118
|
+
catalyses *which* reaction.
|
|
119
|
+
|
|
120
|
+
**`complementary_polymer`** is Serra & Villani's C-BPM (*Entropy* 28(2), 184, 2026),
|
|
121
|
+
reproduced rather than invented. A catalyst carries an active site — a substring of itself,
|
|
122
|
+
3–4 residues long by default — and acts on whatever is complementary to that site. A
|
|
123
|
+
K-catalyst's targets are independent draws; a C-catalyst's targets all share one template,
|
|
124
|
+
so they are structurally correlated, and Serra & Villani measure the signature of that as a
|
|
125
|
+
far higher and far more irregular reactions-per-catalyst distribution (~400 against ~20).
|
|
126
|
+
**`firing_disk_polymer`** is their other construction. Rather than enumerating every string
|
|
127
|
+
and sprinkling catalysis over the result, it grows the chemistry outward from a small seed,
|
|
128
|
+
so a species exists only if some reaction actually makes it: an enumerated chemistry is
|
|
129
|
+
full of species nothing can reach, and a grown one is closed under its own production by
|
|
130
|
+
construction. ⚠ Food is taken to be the firing disk, an assumption the paper leaves open.
|
|
131
|
+
|
|
132
|
+
**`templated_polymer`** draws nothing. A species templates the ligation `a + b -> ab`
|
|
133
|
+
exactly when it contains the (reverse) complement of the product — or, under
|
|
134
|
+
`rule="junction"`, of the `2h` residues spanning the junction — given both reactants reach
|
|
135
|
+
the per-side overlap `h`. The catalysis graph is a *function* of the sequence set, which is
|
|
136
|
+
the content of a template world and the reason this generator has no `rng` argument. The
|
|
137
|
+
cleavage carries its ligation's templates, so a template changes a reversible reaction's
|
|
138
|
+
rate and never its equilibrium. `templated_catalysts` is the same rule as a pure function
|
|
139
|
+
over whatever strands exist, for a simulator that holds explicit strands.
|
|
140
|
+
|
|
141
|
+
Sized on the complete `max_len` 7 set, the two rules behave very differently, and the
|
|
142
|
+
numbers are pinned as known answers:
|
|
143
|
+
|
|
144
|
+
| | |
|
|
145
|
+
|---|---|
|
|
146
|
+
| junction rule, `h` ≤ 2 | **saturates** — every eligible reaction templated by 194 or 46 species, f = 978 and 142 against ~5 for a random chemistry at the RAF threshold: a uniform speed-up with no specificity left |
|
|
147
|
+
| product rule, `h` 2 | f = 12.9, with the template count *falling* with product length — 46 for a 4-mer, exactly one for a 7-mer, its own complement |
|
|
148
|
+
| either rule, `h` 3 | the **maximal RAF is empty**: templated reactions need reactants of length ≥ `h`, and only untemplated reactions make those from a food set shorter than `h` |
|
|
149
|
+
|
|
150
|
+
The last row is the one to remember. `max_raf` remains the right question about closure
|
|
151
|
+
and the wrong one about whether templating acts — the chemistry is perfectly runnable on an
|
|
152
|
+
uncatalysed background.
|
|
153
|
+
|
|
154
|
+
**A structured chemistry needs something to be compared against**, and "random at the same
|
|
155
|
+
f" is a poor choice: at a templated f, a random chemistry spreads its edges over nearly
|
|
156
|
+
every reaction (reach ~0.92 against the product rule's 0.61). So each null holds something
|
|
157
|
+
different fixed, and `catalysis_motifs` reports what they are compared on — f, reach,
|
|
158
|
+
catalysts per reaction by product length, self-catalysed reactions, mutual pairs, and the
|
|
159
|
+
maximal RAF's size, all counted on the reversible pair:
|
|
160
|
+
|
|
161
|
+
- `degree_preserving_null` — a double-edge-swap chain keeping every degree;
|
|
162
|
+
`stratified=True` also keeps the *lengths* of each reaction's templates, which the plain
|
|
163
|
+
shuffle breaks along with the sequence tie.
|
|
164
|
+
- `motif_matched_null` — random catalysis with exactly the network's edge count,
|
|
165
|
+
self-catalysed reactions and mutual pairs; `match_lengths=True` plants them at the
|
|
166
|
+
network's own lengths, without which it is a second random chemistry with two counts
|
|
167
|
+
pinned.
|
|
168
|
+
- `matched_f_random` / `matched_f_cbpm` — the K-BPM and C-BPM at the network's catalysis
|
|
169
|
+
level, in expectation.
|
|
170
|
+
|
|
171
|
+
Every one of these returns the same `BinaryPolymerNetwork`, so everything in the table
|
|
172
|
+
above runs on them unchanged.
|
|
173
|
+
|
|
105
174
|
## Four modules are deliberately off-theme: `dilution`, `permeation`, `thermo` and `autocatalysis`
|
|
106
175
|
|
|
107
176
|
Everything above takes a `ReactionNetwork` and asks a RAF question of it. `rafkit.dilution`
|
|
@@ -357,6 +426,33 @@ with a warning in the file: a reader that ignores it gets a *different system*.
|
|
|
357
426
|
Reactions requiring a catalyst that nothing provides are omitted and counted, since
|
|
358
427
|
emitting them unconstrained would make them freely fireable — the opposite of the intent.
|
|
359
428
|
|
|
429
|
+
### Readable is not runnable: `to_andl`
|
|
430
|
+
|
|
431
|
+
PNML's `ptnet` grammar has no place for a rate constant, so a PNML file documents a
|
|
432
|
+
network without being able to run it. `to_andl` / `write_andl` export the same network to
|
|
433
|
+
ANDL, the PetriNuts format shared by Snoopy, Spike and Marcie, with mass-action rate
|
|
434
|
+
constants — a complete stochastic Petri net that Spike executes directly. An independently
|
|
435
|
+
developed simulator re-running this chemistry from its definition is the entire point.
|
|
436
|
+
|
|
437
|
+
The semantics are mass action and nothing else, and they were **measured** against Spike
|
|
438
|
+
1.6.0rc2 rather than read from its documentation: a catalyst is a consume-and-produce
|
|
439
|
+
self-loop, so the propensity scales with catalyst count; alternative catalyst sets are
|
|
440
|
+
separate transitions whose propensities sum; and `a + a -> aa` is a weight-2 arc counted
|
|
441
|
+
as unordered pairs, `n(n−1)/2`, with the alternatives 19–25 standard errors away.
|
|
442
|
+
⚠ Catalysis scaling with catalyst count is *not* what `simulate` does — there any catalyst
|
|
443
|
+
present buys the full rate — so this export runs a different system from the in-library
|
|
444
|
+
simulator, deliberately; the pair convention, on the other hand, is `simulate`'s exactly.
|
|
445
|
+
Rate constants are emitted as named constants, so a Spike `.spc` configuration can override
|
|
446
|
+
any single rate without regenerating the file.
|
|
447
|
+
|
|
448
|
+
Refused rather than silently altered, because an executable file that drops a feature does
|
|
449
|
+
not *document* a different system, it **runs** one: inhibition (PNML may annotate it, since
|
|
450
|
+
a reader sees the annotation; a simulator would not), and a catalyst that is also a
|
|
451
|
+
reactant of the same reaction, whose self-loop merges with the consuming arc into a
|
|
452
|
+
weight-2 pre-arc with implementation-dependent combinatorics. The latter means a
|
|
453
|
+
`templated_polymer` network, in which a template can be its own reactant, is refused until
|
|
454
|
+
someone measures what Spike does with it.
|
|
455
|
+
|
|
360
456
|
## Catalysis is a relation, not a list
|
|
361
457
|
|
|
362
458
|
`catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
|
|
@@ -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.
|
|
62
|
+
__version__ = "0.7.1"
|
|
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")
|