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 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