pykappa 0.1.9__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.
- pykappa/__init__.py +0 -0
- pykappa/analysis.py +281 -0
- pykappa/expression.py +237 -0
- pykappa/kappa.lark +201 -0
- pykappa/mixture.py +428 -0
- pykappa/parsing.py +323 -0
- pykappa/pattern.py +575 -0
- pykappa/rule.py +397 -0
- pykappa/system.py +483 -0
- pykappa/utils.py +187 -0
- pykappa-0.1.9.dist-info/METADATA +47 -0
- pykappa-0.1.9.dist-info/RECORD +14 -0
- pykappa-0.1.9.dist-info/WHEEL +5 -0
- pykappa-0.1.9.dist-info/top_level.txt +1 -0
pykappa/__init__.py
ADDED
|
File without changes
|
pykappa/analysis.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import colorsys
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from graphviz import Source
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import matplotlib.figure
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pykappa.pattern import Component
|
|
13
|
+
from pykappa.system import System
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ComponentPlot:
|
|
17
|
+
"""Stable visualization of a Component across simulation steps."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, component: "Component"):
|
|
20
|
+
self.component = component
|
|
21
|
+
self._positions: dict[int, tuple[float, float]] = {}
|
|
22
|
+
|
|
23
|
+
def _compute_positions(self) -> dict[int, tuple[float, float]]:
|
|
24
|
+
"""Assign each agent a fixed position based on identity, computed once."""
|
|
25
|
+
new_agents = [a for a in self.component.agents if id(a) not in self._positions]
|
|
26
|
+
|
|
27
|
+
if new_agents:
|
|
28
|
+
# Place new agents on a sunflower spiral (evenly distributed, deterministic)
|
|
29
|
+
n = len(self._positions)
|
|
30
|
+
golden_angle = math.pi * (3 - math.sqrt(5))
|
|
31
|
+
for i, agent in enumerate(new_agents):
|
|
32
|
+
k = n + i
|
|
33
|
+
r = math.sqrt(k + 1)
|
|
34
|
+
angle = k * golden_angle
|
|
35
|
+
self._positions[id(agent)] = (r * math.cos(angle), r * math.sin(angle))
|
|
36
|
+
|
|
37
|
+
return {id(a): self._positions[id(a)] for a in self.component.agents}
|
|
38
|
+
|
|
39
|
+
def __call__(self, legend: bool = True):
|
|
40
|
+
agent_types = sorted(dict.fromkeys(a.type for a in self.component.agents))
|
|
41
|
+
type_color = {
|
|
42
|
+
t: "#{:02x}{:02x}{:02x}".format(
|
|
43
|
+
*[
|
|
44
|
+
int(c * 255)
|
|
45
|
+
for c in colorsys.hls_to_rgb(i / len(agent_types), 0.4, 0.8)
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
for i, t in enumerate(agent_types)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
edges = set()
|
|
52
|
+
for a in self.component.agents:
|
|
53
|
+
for b in a.neighbors:
|
|
54
|
+
if a is b:
|
|
55
|
+
continue
|
|
56
|
+
edges.add(tuple(sorted((id(a), id(b)))))
|
|
57
|
+
|
|
58
|
+
pos = self._compute_positions()
|
|
59
|
+
|
|
60
|
+
lines = [
|
|
61
|
+
"graph {",
|
|
62
|
+
" graph [overlap=false];",
|
|
63
|
+
' node [shape=circle, width=0.05, height=0.05, fixedsize=true, label="", style=filled];',
|
|
64
|
+
" edge [penwidth=0.3];",
|
|
65
|
+
]
|
|
66
|
+
if legend:
|
|
67
|
+
min_y = min(y for x, y in pos.values())
|
|
68
|
+
max_x = max(x for x, y in pos.values())
|
|
69
|
+
lx = max_x + 2.0
|
|
70
|
+
legend_vertical_spacing = 0.5
|
|
71
|
+
for i, (t, color) in enumerate(reversed(type_color.items())):
|
|
72
|
+
ly = min_y + i * legend_vertical_spacing
|
|
73
|
+
lines.append(
|
|
74
|
+
f' legend_{t} [shape=box, style=filled, fillcolor="{color}", '
|
|
75
|
+
f'label="{t}", fontsize=8, fixedsize=false, margin="0.05,0.02", pos="{lx:.3f},{ly:.3f}!"];'
|
|
76
|
+
)
|
|
77
|
+
for a in self.component.agents:
|
|
78
|
+
color = type_color[a.type]
|
|
79
|
+
x, y = pos[id(a)]
|
|
80
|
+
lines.append(f' a{id(a)} [fillcolor="{color}", pos="{x:.3f},{y:.3f}!"];')
|
|
81
|
+
for u, v in edges:
|
|
82
|
+
lines.append(f" a{u} -- a{v};")
|
|
83
|
+
lines.append("}")
|
|
84
|
+
|
|
85
|
+
return Source("\n".join(lines), engine="neato")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Monitor:
|
|
89
|
+
"""Records the history of the values of observables in a system.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
system: The system being monitored.
|
|
93
|
+
history: Dictionary mapping observable names to their value history.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
system: "System"
|
|
97
|
+
history: dict[str, list[Optional[float]]]
|
|
98
|
+
|
|
99
|
+
def __init__(self, system: "System"):
|
|
100
|
+
"""Initialize a monitor for the given system."""
|
|
101
|
+
self.system = system
|
|
102
|
+
self.history = {"time": []} | {obs_name: [] for obs_name in system.observables}
|
|
103
|
+
|
|
104
|
+
def __len__(self) -> int:
|
|
105
|
+
"""The number of records."""
|
|
106
|
+
return len(self.history["time"])
|
|
107
|
+
|
|
108
|
+
def update(self) -> None:
|
|
109
|
+
"""Record current time and observable values."""
|
|
110
|
+
self.history["time"].append(self.system.time)
|
|
111
|
+
for obs_name in self.system.observables:
|
|
112
|
+
if obs_name not in self.history:
|
|
113
|
+
self.history[obs_name] = [None] * (len(self.history["time"]) - 1)
|
|
114
|
+
self.history[obs_name].append(self.system[obs_name])
|
|
115
|
+
|
|
116
|
+
def measure(self, observable_name: str, time: Optional[float] = None):
|
|
117
|
+
"""Get the value of an observable at a specific time.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
AssertionError: If simulation hasn't reached the specified time.
|
|
121
|
+
"""
|
|
122
|
+
import bisect
|
|
123
|
+
|
|
124
|
+
times: list[int] = list(self.history["time"])
|
|
125
|
+
if time is None:
|
|
126
|
+
time = times[-1]
|
|
127
|
+
assert time <= max(times), "Simulation hasn't reached time {time}"
|
|
128
|
+
|
|
129
|
+
return self.history[observable_name][bisect.bisect_right(times, time) - 1]
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def dataframe(self) -> pd.DataFrame:
|
|
133
|
+
"""Get the history of observable values as a pandas DataFrame.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
DataFrame with time and observable columns.
|
|
137
|
+
"""
|
|
138
|
+
return pd.DataFrame(self.history)
|
|
139
|
+
|
|
140
|
+
def tail_mean(
|
|
141
|
+
self,
|
|
142
|
+
observable_name: str,
|
|
143
|
+
tail_fraction: float = 0.1,
|
|
144
|
+
) -> float:
|
|
145
|
+
"""
|
|
146
|
+
Calculate the average value of an observable over a fraction of the tail.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
observable_name: Name of the observable to measure.
|
|
150
|
+
tail_fraction: Fraction of the history to consider (from the end).
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
AssertionError: If there are not enough measurements.
|
|
154
|
+
"""
|
|
155
|
+
window_len = int(tail_fraction * len(self))
|
|
156
|
+
assert (
|
|
157
|
+
len(self) >= window_len and window_len >= 1
|
|
158
|
+
), f"Not enough measurements ({len(self)}) to calculate tail mean for {observable_name}"
|
|
159
|
+
|
|
160
|
+
values = np.asarray(self.history[observable_name][-window_len:], dtype=float)
|
|
161
|
+
return float(np.mean(values))
|
|
162
|
+
|
|
163
|
+
def equilibrated(
|
|
164
|
+
self,
|
|
165
|
+
observable_name: Optional[str] = None,
|
|
166
|
+
**equilibration_kwargs,
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""
|
|
169
|
+
Check if an observable (or all observables) has equilibrated based on
|
|
170
|
+
whether the slope of recent values is sufficiently small relative to the mean.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
observable_name: Name of the observable to check. If None, checks all observables.
|
|
174
|
+
tail_fraction: Fraction of the history to consider.
|
|
175
|
+
tolerance: Maximum allowed fraction slope deviation from the mean.
|
|
176
|
+
"""
|
|
177
|
+
if observable_name is None:
|
|
178
|
+
return all(
|
|
179
|
+
self.equilibrated(obs_name, **equilibration_kwargs)
|
|
180
|
+
for obs_name in self.system.observables
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
values = self.history[observable_name]
|
|
184
|
+
times = self.history["time"]
|
|
185
|
+
assert all(v is not None for v in values)
|
|
186
|
+
assert all(t is not None for t in times)
|
|
187
|
+
return equilibrated(values=values, times=times, **equilibration_kwargs)
|
|
188
|
+
|
|
189
|
+
def plot(self, combined: bool = False) -> matplotlib.figure.Figure:
|
|
190
|
+
"""Make a plot of all observables over time.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
combined: Whether to plot all observables on the same axes.
|
|
194
|
+
"""
|
|
195
|
+
if combined:
|
|
196
|
+
fig, ax = plt.subplots()
|
|
197
|
+
for obs_name in self.system.observables:
|
|
198
|
+
ax.plot(self.history["time"], self.history[obs_name], label=obs_name)
|
|
199
|
+
plt.legend()
|
|
200
|
+
plt.xlabel("Time")
|
|
201
|
+
plt.ylabel("Observable")
|
|
202
|
+
plt.margins(0, 0)
|
|
203
|
+
else:
|
|
204
|
+
fig, axs = plt.subplots(
|
|
205
|
+
len(self.system.observables), 1, sharex=True, layout="constrained"
|
|
206
|
+
)
|
|
207
|
+
if len(self.system.observables) == 1:
|
|
208
|
+
axs = [axs]
|
|
209
|
+
for i, obs_name in enumerate(self.system.observables):
|
|
210
|
+
axs[i].plot(self.history["time"], self.history[obs_name], color="black")
|
|
211
|
+
axs[i].set_title(obs_name)
|
|
212
|
+
if i == len(self.system.observables) - 1:
|
|
213
|
+
axs[i].set_xlabel("Time")
|
|
214
|
+
return fig
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def relative_slope(
|
|
218
|
+
values: list[float], times: Optional[list[float]] = None, tail_fraction: float = 0.1
|
|
219
|
+
) -> float:
|
|
220
|
+
"""
|
|
221
|
+
Computes the magnitude of the slope of the tail of the series.
|
|
222
|
+
Time can be provided to account for non-uniform sampling intervals.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
AssertionError: If there are not enough measurements to compute the slope.
|
|
226
|
+
"""
|
|
227
|
+
times = times if times is not None else list(range(len(values)))
|
|
228
|
+
|
|
229
|
+
t_tail = times[-1] - tail_fraction * (times[-1] - times[0])
|
|
230
|
+
tail_indices = [i for i, t in enumerate(times) if t >= t_tail]
|
|
231
|
+
|
|
232
|
+
assert (
|
|
233
|
+
len(tail_indices) >= 2
|
|
234
|
+
), f"Not enough measurements ({len(tail_indices)}) to compute slope"
|
|
235
|
+
|
|
236
|
+
tail_times = [times[i] for i in tail_indices]
|
|
237
|
+
tail_values = [values[i] for i in tail_indices]
|
|
238
|
+
slope, _ = np.polyfit(tail_times, tail_values, deg=1)
|
|
239
|
+
|
|
240
|
+
return float(slope / np.mean(tail_values))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def equilibrated(
|
|
244
|
+
values: list[float],
|
|
245
|
+
times: Optional[list[float]] = None,
|
|
246
|
+
tail_fraction: float = 0.1,
|
|
247
|
+
tolerance: float = 0.01,
|
|
248
|
+
) -> bool:
|
|
249
|
+
"""
|
|
250
|
+
Checks whether the magnitude of the slope of the tail of the series relative to the mean
|
|
251
|
+
is sufficiently small (below tolerance). Time can be provided to account for non-uniform
|
|
252
|
+
sampling intervals.
|
|
253
|
+
"""
|
|
254
|
+
return abs(relative_slope(values, times, tail_fraction)) <= tolerance
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def equilibration_time(
|
|
258
|
+
values: list[float],
|
|
259
|
+
times: Optional[list[float]] = None,
|
|
260
|
+
min_tail_length: int = 2,
|
|
261
|
+
tolerance: float = 0.01,
|
|
262
|
+
) -> float:
|
|
263
|
+
"""Earliest time from which the remaining series is equilibrated."""
|
|
264
|
+
times = times if times is not None else list(range(len(values)))
|
|
265
|
+
for i in range(len(values) - min_tail_length + 1):
|
|
266
|
+
if abs(relative_slope(values[i:], times[i:], tail_fraction=1.0)) <= tolerance:
|
|
267
|
+
return times[i]
|
|
268
|
+
raise ValueError(f"Equilibrium not detected (tolerance={tolerance})")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def equilibrium_value(
|
|
272
|
+
values: list[float],
|
|
273
|
+
times: Optional[list[float]] = None,
|
|
274
|
+
min_tail_length: int = 2,
|
|
275
|
+
tolerance: float = 0.01,
|
|
276
|
+
) -> float:
|
|
277
|
+
"""Mean of the series from the equilibration point onward."""
|
|
278
|
+
times = times if times is not None else list(range(len(values)))
|
|
279
|
+
eq_time = equilibration_time(values, times, min_tail_length, tolerance)
|
|
280
|
+
eq_index = next(i for i, t in enumerate(times) if t >= eq_time)
|
|
281
|
+
return float(np.mean(values[eq_index:]))
|
pykappa/expression.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import operator
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import Self, Optional, Callable, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pykappa.pattern import Component
|
|
8
|
+
from pykappa.system import System
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
string_to_operator = {
|
|
12
|
+
# Unary
|
|
13
|
+
"[log]": math.log,
|
|
14
|
+
"[exp]": math.exp,
|
|
15
|
+
"[sin]": math.sin,
|
|
16
|
+
"[cos]": math.cos,
|
|
17
|
+
"[tan]": math.tan,
|
|
18
|
+
"[sqrt]": math.sqrt,
|
|
19
|
+
# Binary
|
|
20
|
+
"+": operator.add,
|
|
21
|
+
"-": operator.sub,
|
|
22
|
+
"*": operator.mul,
|
|
23
|
+
"/": operator.truediv,
|
|
24
|
+
"^": operator.pow,
|
|
25
|
+
"mod": operator.mod,
|
|
26
|
+
# Comparisons
|
|
27
|
+
"=": operator.eq,
|
|
28
|
+
"<": operator.lt,
|
|
29
|
+
">": operator.gt,
|
|
30
|
+
# List
|
|
31
|
+
"[max]": max,
|
|
32
|
+
"[min]": min,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_operator(kappa_operator: str) -> Callable:
|
|
37
|
+
"""Convert a Kappa string operator to a Python function.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If the operator is not recognized.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
return string_to_operator[kappa_operator]
|
|
44
|
+
except KeyError:
|
|
45
|
+
raise ValueError(f"Unknown operator: {kappa_operator}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Expression:
|
|
49
|
+
"""Algebraic expressions as specified by the Kappa language.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
type: Type of expression (literal, variable, binary_op, etc.).
|
|
53
|
+
attrs: Dictionary of attributes specific to the expression type.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_kappa(cls, kappa_str: str) -> "Expression":
|
|
58
|
+
"""Parse an Expression from a Kappa string.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
AssertionError: If the string doesn't represent a valid expression.
|
|
62
|
+
"""
|
|
63
|
+
from pykappa.parsing import kappa_parser, ExpressionTransformer
|
|
64
|
+
|
|
65
|
+
input_tree = kappa_parser.parse(kappa_str)
|
|
66
|
+
assert input_tree.data == "kappa_input"
|
|
67
|
+
expr_tree = input_tree.children[0]
|
|
68
|
+
assert expr_tree.data in ["!algebraic_expression", "algebraic_expression"]
|
|
69
|
+
return ExpressionTransformer.from_tree(expr_tree)
|
|
70
|
+
|
|
71
|
+
def __init__(self, type, **attrs):
|
|
72
|
+
self.type = type
|
|
73
|
+
self.attrs = attrs
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def kappa_str(self) -> str:
|
|
77
|
+
"""Get the expression representation in Kappa format.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: If expression type is not supported for string conversion.
|
|
81
|
+
"""
|
|
82
|
+
if self.type == "literal":
|
|
83
|
+
return str(self.evaluate())
|
|
84
|
+
|
|
85
|
+
elif self.type == "boolean_literal":
|
|
86
|
+
return "[true]" if self.attrs["value"] else "[false]"
|
|
87
|
+
|
|
88
|
+
elif self.type == "variable":
|
|
89
|
+
return f"'{self.attrs["name"]}'"
|
|
90
|
+
|
|
91
|
+
elif self.type in ("binary_op", "comparison"):
|
|
92
|
+
left_str = self.attrs["left"].kappa_str
|
|
93
|
+
right_str = self.attrs["right"].kappa_str
|
|
94
|
+
return f"({left_str}) {self.attrs['operator']} ({right_str})"
|
|
95
|
+
|
|
96
|
+
elif self.type == "unary_op":
|
|
97
|
+
return f"{self.attrs['operator']} ({self.attrs['child'].kappa_str})"
|
|
98
|
+
|
|
99
|
+
elif self.type == "list_op":
|
|
100
|
+
children_str = " ".join(
|
|
101
|
+
f"({child.kappa_str})" for child in self.attrs["children"]
|
|
102
|
+
)
|
|
103
|
+
return f"{self.attrs["operator"]} {children_str}"
|
|
104
|
+
|
|
105
|
+
elif self.type == "defined_constant":
|
|
106
|
+
return f"{self.attrs["name"]}"
|
|
107
|
+
|
|
108
|
+
elif self.type == "parentheses":
|
|
109
|
+
return self.attrs["child"].kappa_str
|
|
110
|
+
|
|
111
|
+
elif self.type == "conditional":
|
|
112
|
+
true_expr_str = self.attrs["true_expr"].kappa_str
|
|
113
|
+
false_expr_str = self.attrs["false_expr"].kappa_str
|
|
114
|
+
return f"{self.attrs["condition"].kappa_str} [?] {true_expr_str} [:] {false_expr_str}"
|
|
115
|
+
|
|
116
|
+
elif self.type in ("logical_or", "logical_and"):
|
|
117
|
+
left_str = self.attrs["left"].kappa_str
|
|
118
|
+
right_str = self.attrs["right"].kappa_str
|
|
119
|
+
op = {"logical_or": "||", "logical_and": "&&"}
|
|
120
|
+
return f"({left_str}) {op[self.type]} ({right_str})"
|
|
121
|
+
|
|
122
|
+
elif self.type == "logical_not":
|
|
123
|
+
return f"[not] ({self.attrs['child'].kappa_str})"
|
|
124
|
+
|
|
125
|
+
elif self.type == "reserved_variable":
|
|
126
|
+
return self.attrs["value"].kappa_str
|
|
127
|
+
|
|
128
|
+
elif self.type == "component_pattern":
|
|
129
|
+
return f"|{self.attrs['value'].kappa_str}|"
|
|
130
|
+
|
|
131
|
+
raise ValueError(f"Unsupported node type: {self.type}")
|
|
132
|
+
|
|
133
|
+
def evaluate(self, system: Optional["System"] = None) -> int | float:
|
|
134
|
+
"""Evaluate the expression to get its value.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
system: System context for variable evaluation (required for variables).
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ValueError: If evaluation fails due to missing context or unsupported type.
|
|
141
|
+
"""
|
|
142
|
+
if self.type in ("literal", "boolean_literal"):
|
|
143
|
+
return self.attrs["value"]
|
|
144
|
+
|
|
145
|
+
elif self.type == "variable":
|
|
146
|
+
name = self.attrs["name"]
|
|
147
|
+
if system is None:
|
|
148
|
+
raise ValueError(f"{self} needs a System to evaluate variable '{name}'")
|
|
149
|
+
return system[name]
|
|
150
|
+
|
|
151
|
+
elif self.type in ("binary_op", "comparison"):
|
|
152
|
+
left_val = self.attrs["left"].evaluate(system)
|
|
153
|
+
right_val = self.attrs["right"].evaluate(system)
|
|
154
|
+
return parse_operator(self.attrs["operator"])(left_val, right_val)
|
|
155
|
+
|
|
156
|
+
elif self.type == "unary_op":
|
|
157
|
+
child_val = self.attrs["child"].evaluate(system)
|
|
158
|
+
return parse_operator(self.attrs["operator"])(child_val)
|
|
159
|
+
|
|
160
|
+
elif self.type == "list_op":
|
|
161
|
+
children_vals = [child.evaluate(system) for child in self.attrs["children"]]
|
|
162
|
+
return parse_operator(self.attrs["operator"])(children_vals)
|
|
163
|
+
|
|
164
|
+
elif self.type == "defined_constant":
|
|
165
|
+
const = self.attrs["name"]
|
|
166
|
+
if const == "[pi]":
|
|
167
|
+
return math.pi
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError(f"Unknown constant: {const}")
|
|
170
|
+
|
|
171
|
+
elif self.type == "parentheses":
|
|
172
|
+
return self.attrs["child"].evaluate(system)
|
|
173
|
+
|
|
174
|
+
elif self.type == "conditional":
|
|
175
|
+
cond_val = self.attrs["condition"].evaluate(system)
|
|
176
|
+
return (
|
|
177
|
+
self.attrs["true_expr"].evaluate(system)
|
|
178
|
+
if cond_val
|
|
179
|
+
else self.attrs["false_expr"].evaluate(system)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
elif self.type == "logical_or":
|
|
183
|
+
left_val = self.attrs["left"].evaluate(system)
|
|
184
|
+
right_val = self.attrs["right"].evaluate(system)
|
|
185
|
+
return left_val or right_val
|
|
186
|
+
|
|
187
|
+
elif self.type == "logical_and":
|
|
188
|
+
left_val = self.attrs["left"].evaluate(system)
|
|
189
|
+
right_val = self.attrs["right"].evaluate(system)
|
|
190
|
+
return left_val and right_val
|
|
191
|
+
|
|
192
|
+
elif self.type == "logical_not":
|
|
193
|
+
return not self.attrs["child"].evaluate(system)
|
|
194
|
+
|
|
195
|
+
elif self.type == "reserved_variable":
|
|
196
|
+
value = self.attrs["value"]
|
|
197
|
+
if value.type == "component_pattern":
|
|
198
|
+
component: Component = value.attrs["value"]
|
|
199
|
+
if system is None:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"{self} needs a System to evaluate pattern {component}"
|
|
202
|
+
)
|
|
203
|
+
return (
|
|
204
|
+
len(system.mixture.embeddings(component))
|
|
205
|
+
// component.n_automorphisms
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
raise NotImplementedError(
|
|
209
|
+
f"Reserved variable {value.type} not implemented yet."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
raise ValueError(f"Unsupported node type: {self.type}")
|
|
213
|
+
|
|
214
|
+
def filter(self, type_str: str) -> list[Self]:
|
|
215
|
+
"""
|
|
216
|
+
Returns all nodes in the expression tree whose type matches the provided string.
|
|
217
|
+
|
|
218
|
+
Note:
|
|
219
|
+
Doesn't detect nodes indirectly nested in named variables.
|
|
220
|
+
"""
|
|
221
|
+
result = []
|
|
222
|
+
stack = deque([self]) # DFS from the root
|
|
223
|
+
|
|
224
|
+
while stack:
|
|
225
|
+
node = stack.pop()
|
|
226
|
+
if node.type == type_str:
|
|
227
|
+
result.append(node)
|
|
228
|
+
|
|
229
|
+
# Add child nodes to the stack
|
|
230
|
+
if hasattr(node, "attrs"):
|
|
231
|
+
for attr_value in node.attrs.values():
|
|
232
|
+
if isinstance(attr_value, type(self)):
|
|
233
|
+
stack.append(attr_value)
|
|
234
|
+
elif isinstance(attr_value, (list, tuple)):
|
|
235
|
+
stack.extend(v for v in attr_value if isinstance(v, type(self)))
|
|
236
|
+
|
|
237
|
+
return result
|