process-bigraph 0.0.43__py3-none-any.whl

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.
@@ -0,0 +1,326 @@
1
+ """
2
+ ===========================
3
+ Emitter Utilities & Classes
4
+ ===========================
5
+
6
+ Emitters are steps that observe a composite simulation's state and emit data to an external source
7
+ (e.g., console, memory, or file). This module provides tools to:
8
+ - Define emitter steps programmatically
9
+ - Insert emitters into a running composite
10
+ - Collect data from emitter steps
11
+ - Implement concrete emitters (RAM, console, JSON)
12
+ """
13
+
14
+ import os
15
+ import json
16
+ import copy
17
+ import uuid
18
+ import pytest
19
+ import numpy as np
20
+ from typing import Dict
21
+
22
+ from bigraph_schema import get_path, set_path, is_schema_key, Edge
23
+ from process_bigraph.composite import Composite, Step, find_instance_paths
24
+
25
+
26
+ # ==========================
27
+ # Emitter Spec Construction
28
+ # ==========================
29
+
30
+ def anyize_paths(tree):
31
+ """Recursively convert all leaves of a nested path tree to 'any'."""
32
+ if isinstance(tree, dict):
33
+ return {key: anyize_paths(value) for key, value in tree.items()}
34
+ return 'any'
35
+
36
+ def emitter_from_wires(wires, address='local:ram-emitter'):
37
+ """Create an emitter step spec from wire mappings."""
38
+ return {
39
+ '_type': 'step',
40
+ 'address': address,
41
+ 'config': {
42
+ 'emit': anyize_paths(wires)},
43
+ 'inputs': wires}
44
+
45
+ def collect_input_ports(state, path=None):
46
+ """Recursively collect all valid input ports from state tree, skipping processes and schema keys."""
47
+ process_paths = find_instance_paths(state, 'process_bigraph.composite.Process')
48
+ step_paths = find_instance_paths(state, 'process_bigraph.composite.Step')
49
+ path = path or ()
50
+ input_ports = {}
51
+ for key, value in state.items():
52
+ full_path = path + (key,) if path else (key,)
53
+ full_key = '/'.join(full_path)
54
+
55
+ if is_schema_key(key):
56
+ continue
57
+ if full_path in process_paths or full_path in step_paths:
58
+ continue
59
+ if isinstance(value, dict):
60
+ input_ports.update(collect_input_ports(value, full_path))
61
+ else:
62
+ input_ports[full_key] = list(full_path)
63
+ return input_ports
64
+
65
+ def generate_emitter_state(composite, emitter_mode="all", address="local:ram-emitter"):
66
+ """
67
+ Generate emitter state for a given composite and mode.
68
+ Modes:
69
+ - "all": observe all valid inputs
70
+ - "none": observe nothing
71
+ - {"paths": [...]}: custom paths to observe
72
+ """
73
+ config = {}
74
+ input_ports = {}
75
+
76
+ if emitter_mode == "all":
77
+ input_ports = collect_input_ports(composite.state)
78
+ elif emitter_mode == "none":
79
+ input_ports = {}
80
+ elif isinstance(emitter_mode, dict) and "paths" in emitter_mode:
81
+ for path in emitter_mode["paths"]:
82
+ if isinstance(path, str):
83
+ input_ports[path] = [path]
84
+ elif isinstance(path, list):
85
+ input_ports[path[0]] = path
86
+ else:
87
+ raise ValueError(f"Invalid mode: {emitter_mode}.")
88
+
89
+ if "global_time" not in input_ports:
90
+ input_ports["global_time"] = ["global_time"]
91
+
92
+ if "emit" not in config:
93
+ config["emit"] = {port: "any" for port in input_ports}
94
+
95
+ return {
96
+ "_type": "step",
97
+ "address": address,
98
+ "config": config,
99
+ "inputs": input_ports
100
+ }
101
+
102
+ def gather_emitter_results(composite, queries=None):
103
+ """Retrieve query results from all emitter steps in a composite."""
104
+ emitter_paths = find_instance_paths(composite.state, 'process_bigraph.emitter.Emitter')
105
+ queries = queries or {path: None for path in emitter_paths}
106
+
107
+ results = {}
108
+ for path, query in queries.items():
109
+ emitter = get_path(composite.state, path)
110
+ results[path] = emitter['instance'].query(query)
111
+ return results
112
+
113
+ def add_emitter_to_composite(composite, core, emitter_mode='all', address="local:ram-emitter"):
114
+ """Insert an emitter into a composite and rebuild the step network."""
115
+ path = ('emitter',)
116
+ emitter_state = generate_emitter_state(composite, emitter_mode=emitter_mode, address=address)
117
+ composite.merge({}, set_path({}, path, emitter_state))
118
+
119
+ # TODO -- this is a hack to get the emitter to show up in the state
120
+ _, instance = core.slice(composite.composition, composite.state, path)
121
+ composite.step_paths[path] = instance
122
+ composite.build_step_network()
123
+ return composite
124
+
125
+
126
+ # =====================
127
+ # Emitter Base Classes
128
+ # =====================
129
+
130
+ class Emitter(Step):
131
+ """Base emitter class: defines schema and stub methods."""
132
+ config_schema = {'emit': 'schema'}
133
+
134
+ def inputs(self) -> Dict:
135
+ return self.config['emit']
136
+
137
+ def query(self, query=None):
138
+ """
139
+ Query the history of the emitter.
140
+ :param query: a list of paths to query from the history. If None, the entire history is returned.
141
+ :return: results of the query in a list
142
+ """
143
+ return {}
144
+
145
+ def update(self, state) -> Dict:
146
+ return {}
147
+
148
+
149
+ # ========================
150
+ # Emitter Implementations
151
+ # ========================
152
+
153
+ class ConsoleEmitter(Emitter):
154
+ """Print state to console each timestep."""
155
+ def update(self, state) -> Dict:
156
+ print(state)
157
+ return {}
158
+
159
+ def tree_copy(state):
160
+ """Deep copy utility for nested simulation state (excluding Edge instances)."""
161
+ if isinstance(state, dict):
162
+ return {k: v for k, v in ((k, tree_copy(v)) for k, v in state.items()) if v is not None}
163
+ if isinstance(state, np.ndarray):
164
+ return state.copy()
165
+ if isinstance(state, Edge):
166
+ return None
167
+ return copy.deepcopy(state)
168
+
169
+
170
+ class RAMEmitter(Emitter):
171
+ """Store historical states in memory."""
172
+ def __init__(self, config, core):
173
+ super().__init__(config, core)
174
+ self.history = []
175
+
176
+ def update(self, state) -> Dict:
177
+ self.history.append(tree_copy(state))
178
+ return {}
179
+
180
+ def query(self, query=None, schema=None):
181
+ schema = schema or self.inputs()
182
+ if isinstance(query, list):
183
+ results = []
184
+ for t in self.history:
185
+ result = {}
186
+ for path in query:
187
+ _, value = self.core.slice(schema, t, path)
188
+ result = set_path(result, path, value)
189
+ results.append(result)
190
+ return results
191
+ return self.history
192
+
193
+
194
+ class JSONEmitter(Emitter):
195
+ """Append simulation state to a persistent JSON file each timestep."""
196
+ config_schema = {
197
+ **Emitter.config_schema,
198
+ 'file_path': {'_type': 'string', '_default': './out'},
199
+ 'simulation_id': {'_type': 'string', '_default': None}
200
+ }
201
+
202
+ def __init__(self, config, core):
203
+ super().__init__(config, core)
204
+ self.simulation_id = config.get('simulation_id') or str(uuid.uuid4())
205
+ self.file_path = config.get('file_path', './out')
206
+ os.makedirs(self.file_path, exist_ok=True)
207
+ self.filepath = os.path.join(self.file_path, f"history_{self.simulation_id}.json")
208
+ if not os.path.exists(self.filepath):
209
+ with open(self.filepath, 'w') as f:
210
+ json.dump([], f)
211
+
212
+ def update(self, state) -> dict:
213
+ with open(self.filepath, 'r+') as f:
214
+ try:
215
+ data = json.load(f)
216
+ except json.JSONDecodeError:
217
+ data = []
218
+ data.append(copy.deepcopy(state))
219
+ f.seek(0)
220
+ json.dump(data, f, indent=4)
221
+ return {}
222
+
223
+ def query(self, query=None):
224
+ if not os.path.exists(self.filepath):
225
+ return []
226
+ with open(self.filepath, 'r') as f:
227
+ try:
228
+ data = json.load(f)
229
+ except json.JSONDecodeError:
230
+ return []
231
+
232
+ if isinstance(query, list):
233
+ results = []
234
+ for t in data:
235
+ result = {}
236
+ for path in query:
237
+ element = get_path(t, path)
238
+ result = set_path(result, path, element)
239
+ results.append(result)
240
+ return results
241
+ return data
242
+
243
+
244
+ # ====================
245
+ # Base Emitter Mapping
246
+ # ====================
247
+
248
+ BASE_EMITTERS = {
249
+ 'console-emitter': ConsoleEmitter,
250
+ 'ram-emitter': RAMEmitter,
251
+ 'json-emitter': JSONEmitter,
252
+ }
253
+
254
+
255
+ # ==========
256
+ # Unit Tests
257
+ # ==========
258
+
259
+ @pytest.fixture
260
+ def core():
261
+ from process_bigraph import register_types, ProcessTypes
262
+ core = ProcessTypes()
263
+ return register_types(core)
264
+
265
+ def test_ram_emitter(core):
266
+ composite_spec = {
267
+ 'increase': {
268
+ '_type': 'process',
269
+ 'address': 'local:!process_bigraph.tests.IncreaseProcess',
270
+ 'config': {'rate': 0.3},
271
+ 'inputs': {'level': ['valueA']},
272
+ 'outputs': {'level': ['valueA']}},
273
+ 'increase2': {
274
+ '_type': 'process',
275
+ 'address': 'local:!process_bigraph.tests.IncreaseProcess',
276
+ 'config': {'rate': 0.1},
277
+ 'inputs': {'level': ['valueB']},
278
+ 'outputs': {'level': ['valueB']}},
279
+ 'emitter': emitter_from_wires({
280
+ 'time': ['global_time'],
281
+ 'valueA': ['valueA'],
282
+ 'valueB': ['valueB']})}
283
+
284
+ composite = Composite({'state': composite_spec}, core=core)
285
+ composite.run(10)
286
+
287
+ results = composite.state['emitter']['instance'].query()
288
+ assert len(results) == 11
289
+ assert results[-1]['time'] == 10
290
+ assert 'valueA' in results[0] and 'valueB' in results[0]
291
+
292
+ composite_spec['emitter'] = emitter_from_wires({
293
+ 'time': ['global_time'],
294
+ 'valueA': ['valueA']})
295
+ composite2 = Composite({'state': composite_spec}, core=core)
296
+ composite2.run(10)
297
+
298
+ results2 = composite2.state['emitter']['instance'].query()
299
+ assert 'valueA' in results2[0] and 'valueB' not in results2[0]
300
+ print(results2)
301
+
302
+ def test_json_emitter(core):
303
+ composite_spec = {
304
+ 'increase': {
305
+ '_type': 'process',
306
+ 'address': 'local:!process_bigraph.tests.IncreaseProcess',
307
+ 'config': {'rate': 0.3},
308
+ 'interval': 1.0,
309
+ 'inputs': {'level': ['value']},
310
+ 'outputs': {'level': ['value']}}}
311
+ composite = Composite({'state': composite_spec}, core)
312
+ composite = add_emitter_to_composite(composite, core, emitter_mode='all', address='local:json-emitter')
313
+ composite.run(10)
314
+
315
+ results = composite.state['emitter']['instance'].query()
316
+ assert len(results) == 10
317
+ assert results[-1]['global_time'] == 10
318
+ print(results)
319
+
320
+
321
+ if __name__ == '__main__':
322
+ from process_bigraph import register_types, ProcessTypes
323
+ core = ProcessTypes()
324
+ core = register_types(core)
325
+ test_ram_emitter(core)
326
+ test_json_emitter(core)
File without changes
@@ -0,0 +1,207 @@
1
+ """ Toy Stochastic Transcription Process
2
+ Toy model of Gillespie algorithm-based transcription,
3
+ and a composite with deterministic translation.
4
+
5
+ Note: This Process is primarily for testing multi-timestepping.
6
+ variables and parameters are hard-coded. Do not use this as a
7
+ general stochastic transcription.
8
+ """
9
+
10
+
11
+ import numpy as np
12
+ import pytest
13
+
14
+ from process_bigraph.composite import Step, Process, Composite, ProcessEnsemble
15
+
16
+
17
+ class GillespieInterval(Step):
18
+ config_schema = {
19
+ 'ktsc': {
20
+ '_type': 'float',
21
+ '_default': '5e0'},
22
+ 'kdeg': {
23
+ '_type': 'float',
24
+ '_default': '1e-1'}}
25
+
26
+
27
+ def inputs(self):
28
+ return {
29
+ 'DNA': 'map[default 1]',
30
+ 'mRNA': {
31
+ 'A mRNA': 'default 1',
32
+ 'B mRNA': 'default 1'}}
33
+
34
+ # {
35
+ # '_type': 'map',
36
+ # '_value': 'float(default:1.0)'},
37
+
38
+ # 'G': {
39
+ # '_type': 'float',
40
+ # '_default': '1.0'}},
41
+
42
+
43
+ def outputs(self):
44
+ return {
45
+ 'interval': 'interval'}
46
+
47
+
48
+ def initial_state(self):
49
+ return {
50
+ 'mRNA': {
51
+ 'A mRNA': 2.0,
52
+ 'B mRNA': 3.0}}
53
+
54
+
55
+ def update(self, input):
56
+ # retrieve the state values
57
+ g = input['DNA']['A gene']
58
+ c = input['mRNA']['A mRNA']
59
+
60
+ array_state = np.array([g, c])
61
+
62
+ # Calculate propensities
63
+ propensities = [
64
+ self.config['ktsc'] * array_state[0],
65
+ self.config['kdeg'] * array_state[1]]
66
+ prop_sum = sum(propensities)
67
+
68
+ # The wait time is distributed exponentially
69
+ interval = np.random.exponential(scale=prop_sum)
70
+
71
+ output = {
72
+ 'interval': interval}
73
+
74
+ # print(f'produced interval: {output}')
75
+
76
+ return output
77
+
78
+
79
+ class GillespieEvent(Process):
80
+ """stochastic toy transcription"""
81
+ config_schema = {
82
+ 'ktsc': {
83
+ '_type': 'float',
84
+ '_default': '5e0'},
85
+ 'kdeg': {
86
+ '_type': 'float',
87
+ '_default': '1e-1'}}
88
+
89
+
90
+ def initialize(self, config=None):
91
+ self.stoichiometry = np.array([[0, 1], [0, -1]])
92
+
93
+
94
+ def initial_state(self):
95
+ return {
96
+ 'mRNA': {
97
+ 'C mRNA': 11.111},
98
+ 'DNA': {
99
+ 'A gene': 3.0,
100
+ 'B gene': 5.0}}
101
+
102
+
103
+ def inputs(self):
104
+ return {
105
+ 'mRNA': 'map[float]',
106
+ 'DNA': {
107
+ 'A gene': 'float',
108
+ 'B gene': 'float'}}
109
+
110
+ def outputs(self):
111
+ return {
112
+ 'mRNA': 'map[float]'}
113
+
114
+
115
+ def next_reaction(self, x):
116
+ """get the next reaction and return a new state"""
117
+
118
+ propensities = [
119
+ self.config['ktsc'] * x[0],
120
+ self.config['kdeg'] * x[1]]
121
+ prop_sum = sum(propensities)
122
+
123
+ # Choose the next reaction
124
+ r_rxn = np.random.uniform()
125
+ i = 0
126
+ for i, _ in enumerate(propensities):
127
+ if r_rxn < propensities[i] / prop_sum:
128
+ # This means propensity i fires
129
+ break
130
+ x += self.stoichiometry[i]
131
+ return x
132
+
133
+
134
+ def update(self, state, interval):
135
+
136
+ # retrieve the state values, put them in array
137
+ g = state['DNA']['A gene']
138
+ c = state['mRNA']['A mRNA']
139
+ array_state = np.array([g, c])
140
+
141
+ # calculate the next reaction
142
+ new_state = self.next_reaction(array_state)
143
+
144
+ # get delta mRNA
145
+ c1 = new_state[1]
146
+ d_c = c1 - c
147
+
148
+ update = {
149
+ 'mRNA': {
150
+ 'A mRNA': d_c}}
151
+
152
+ # print(f'received interval: {interval}')
153
+
154
+ return update
155
+
156
+
157
+ class GillespieSimulation(ProcessEnsemble):
158
+ def __init__(self, config=None, core=None):
159
+ super.__init__(config, core)
160
+
161
+
162
+ def inputs_interval(self):
163
+ return {
164
+ 'DNA': 'map[default 1]',
165
+ 'mRNA': {
166
+ 'A mRNA': 'default 1',
167
+ 'B mRNA': 'default 1'}}
168
+
169
+ # {
170
+ # '_type': 'map',
171
+ # '_value': 'float(default:1.0)'},
172
+
173
+ # 'G': {
174
+ # '_type': 'float',
175
+ # '_default': '1.0'}},
176
+
177
+
178
+ def outputs_interval(self):
179
+ return {
180
+ 'interval': 'interval'}
181
+
182
+
183
+ # def interface_interval(self):
184
+
185
+
186
+ def calculate_interval(self, inputs):
187
+ # retrieve the state values
188
+ g = input['DNA']['A gene']
189
+ c = input['mRNA']['A mRNA']
190
+
191
+ array_state = np.array([g, c])
192
+
193
+ # Calculate propensities
194
+ propensities = [
195
+ self.config['ktsc'] * array_state[0],
196
+ self.config['kdeg'] * array_state[1]]
197
+ prop_sum = sum(propensities)
198
+
199
+ # The wait time is distributed exponentially
200
+ interval = np.random.exponential(scale=prop_sum)
201
+
202
+ output = {
203
+ 'interval': interval}
204
+
205
+ print(f'produced interval: {output}')
206
+
207
+ return output