tnfr 4.5.0__py3-none-any.whl → 4.5.2__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.
Potentially problematic release.
This version of tnfr might be problematic. Click here for more details.
- tnfr/__init__.py +91 -89
- tnfr/alias.py +546 -0
- tnfr/cache.py +578 -0
- tnfr/callback_utils.py +388 -0
- tnfr/cli/__init__.py +75 -0
- tnfr/cli/arguments.py +177 -0
- tnfr/cli/execution.py +288 -0
- tnfr/cli/utils.py +36 -0
- tnfr/collections_utils.py +300 -0
- tnfr/config.py +19 -28
- tnfr/constants/__init__.py +174 -0
- tnfr/constants/core.py +159 -0
- tnfr/constants/init.py +31 -0
- tnfr/constants/metric.py +110 -0
- tnfr/constants_glyphs.py +98 -0
- tnfr/dynamics/__init__.py +658 -0
- tnfr/dynamics/dnfr.py +733 -0
- tnfr/dynamics/integrators.py +267 -0
- tnfr/dynamics/sampling.py +31 -0
- tnfr/execution.py +201 -0
- tnfr/flatten.py +283 -0
- tnfr/gamma.py +302 -88
- tnfr/glyph_history.py +290 -0
- tnfr/grammar.py +285 -96
- tnfr/graph_utils.py +84 -0
- tnfr/helpers/__init__.py +71 -0
- tnfr/helpers/numeric.py +87 -0
- tnfr/immutable.py +178 -0
- tnfr/import_utils.py +228 -0
- tnfr/initialization.py +197 -0
- tnfr/io.py +246 -0
- tnfr/json_utils.py +162 -0
- tnfr/locking.py +37 -0
- tnfr/logging_utils.py +116 -0
- tnfr/metrics/__init__.py +41 -0
- tnfr/metrics/coherence.py +829 -0
- tnfr/metrics/common.py +151 -0
- tnfr/metrics/core.py +101 -0
- tnfr/metrics/diagnosis.py +234 -0
- tnfr/metrics/export.py +137 -0
- tnfr/metrics/glyph_timing.py +189 -0
- tnfr/metrics/reporting.py +148 -0
- tnfr/metrics/sense_index.py +120 -0
- tnfr/metrics/trig.py +181 -0
- tnfr/metrics/trig_cache.py +109 -0
- tnfr/node.py +214 -159
- tnfr/observers.py +126 -128
- tnfr/ontosim.py +134 -134
- tnfr/operators/__init__.py +420 -0
- tnfr/operators/jitter.py +203 -0
- tnfr/operators/remesh.py +485 -0
- tnfr/presets.py +46 -14
- tnfr/rng.py +254 -0
- tnfr/selector.py +210 -0
- tnfr/sense.py +284 -131
- tnfr/structural.py +207 -79
- tnfr/tokens.py +60 -0
- tnfr/trace.py +329 -94
- tnfr/types.py +43 -17
- tnfr/validators.py +70 -24
- tnfr/value_utils.py +59 -0
- tnfr-4.5.2.dist-info/METADATA +379 -0
- tnfr-4.5.2.dist-info/RECORD +67 -0
- tnfr/cli.py +0 -322
- tnfr/constants.py +0 -277
- tnfr/dynamics.py +0 -814
- tnfr/helpers.py +0 -264
- tnfr/main.py +0 -47
- tnfr/metrics.py +0 -597
- tnfr/operators.py +0 -525
- tnfr/program.py +0 -176
- tnfr/scenarios.py +0 -34
- tnfr-4.5.0.dist-info/METADATA +0 -109
- tnfr-4.5.0.dist-info/RECORD +0 -28
- {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/WHEEL +0 -0
- {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/entry_points.txt +0 -0
- {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/licenses/LICENSE.md +0 -0
- {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/top_level.txt +0 -0
tnfr/dynamics/dnfr.py
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""ΔNFR (dynamic network field response) utilities and strategies.
|
|
2
|
+
|
|
3
|
+
This module provides helper functions to configure, cache and apply ΔNFR
|
|
4
|
+
components such as phase, epidemiological state and vortex fields during
|
|
5
|
+
simulations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from ..collections_utils import normalize_weights
|
|
15
|
+
from ..constants import DEFAULTS, get_aliases, get_param
|
|
16
|
+
from ..cache import cached_nodes_and_A
|
|
17
|
+
from ..helpers.numeric import angle_diff
|
|
18
|
+
from ..metrics.trig import neighbor_phase_mean, _phase_mean_from_iter
|
|
19
|
+
from ..alias import (
|
|
20
|
+
get_attr,
|
|
21
|
+
set_dnfr,
|
|
22
|
+
)
|
|
23
|
+
from ..metrics.trig_cache import compute_theta_trig
|
|
24
|
+
from ..metrics.common import merge_and_normalize_weights
|
|
25
|
+
from ..import_utils import get_numpy
|
|
26
|
+
ALIAS_THETA = get_aliases("THETA")
|
|
27
|
+
ALIAS_EPI = get_aliases("EPI")
|
|
28
|
+
ALIAS_VF = get_aliases("VF")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DnfrCache:
|
|
35
|
+
idx: dict[Any, int]
|
|
36
|
+
theta: list[float]
|
|
37
|
+
epi: list[float]
|
|
38
|
+
vf: list[float]
|
|
39
|
+
cos_theta: list[float]
|
|
40
|
+
sin_theta: list[float]
|
|
41
|
+
degs: dict[Any, float] | None = None
|
|
42
|
+
deg_list: list[float] | None = None
|
|
43
|
+
theta_np: Any | None = None
|
|
44
|
+
epi_np: Any | None = None
|
|
45
|
+
vf_np: Any | None = None
|
|
46
|
+
cos_theta_np: Any | None = None
|
|
47
|
+
sin_theta_np: Any | None = None
|
|
48
|
+
deg_array: Any | None = None
|
|
49
|
+
checksum: Any | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = (
|
|
53
|
+
"default_compute_delta_nfr",
|
|
54
|
+
"set_delta_nfr_hook",
|
|
55
|
+
"dnfr_phase_only",
|
|
56
|
+
"dnfr_epi_vf_mixed",
|
|
57
|
+
"dnfr_laplacian",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _write_dnfr_metadata(
|
|
62
|
+
G, *, weights: dict, hook_name: str, note: str | None = None
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Write a ``_DNFR_META`` block in ``G.graph`` with the mix and hook name.
|
|
65
|
+
|
|
66
|
+
``weights`` may include arbitrary components (phase/epi/vf/topo/etc.).
|
|
67
|
+
"""
|
|
68
|
+
weights_norm = normalize_weights(weights, weights.keys())
|
|
69
|
+
meta = {
|
|
70
|
+
"hook": hook_name,
|
|
71
|
+
"weights_raw": dict(weights),
|
|
72
|
+
"weights_norm": weights_norm,
|
|
73
|
+
"components": [k for k, v in weights_norm.items() if v != 0.0],
|
|
74
|
+
"doc": "ΔNFR = Σ w_i·g_i",
|
|
75
|
+
}
|
|
76
|
+
if note:
|
|
77
|
+
meta["note"] = str(note)
|
|
78
|
+
G.graph["_DNFR_META"] = meta
|
|
79
|
+
G.graph["_dnfr_hook_name"] = hook_name # string friendly
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _configure_dnfr_weights(G) -> dict:
|
|
83
|
+
"""Normalise and store ΔNFR weights in ``G.graph['_dnfr_weights']``.
|
|
84
|
+
|
|
85
|
+
Uses ``G.graph['DNFR_WEIGHTS']`` or default values. The result is a
|
|
86
|
+
dictionary of normalised components reused at each simulation step
|
|
87
|
+
without recomputing the mix.
|
|
88
|
+
"""
|
|
89
|
+
weights = merge_and_normalize_weights(
|
|
90
|
+
G, "DNFR_WEIGHTS", ("phase", "epi", "vf", "topo"), default=0.0
|
|
91
|
+
)
|
|
92
|
+
G.graph["_dnfr_weights"] = weights
|
|
93
|
+
return weights
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _init_dnfr_cache(G, nodes, prev_cache: DnfrCache | None, checksum, dirty):
|
|
97
|
+
"""Initialise or reuse cached ΔNFR arrays."""
|
|
98
|
+
if prev_cache and prev_cache.checksum == checksum and not dirty:
|
|
99
|
+
return (
|
|
100
|
+
prev_cache,
|
|
101
|
+
prev_cache.idx,
|
|
102
|
+
prev_cache.theta,
|
|
103
|
+
prev_cache.epi,
|
|
104
|
+
prev_cache.vf,
|
|
105
|
+
prev_cache.cos_theta,
|
|
106
|
+
prev_cache.sin_theta,
|
|
107
|
+
False,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
idx = {n: i for i, n in enumerate(nodes)}
|
|
111
|
+
theta = [0.0] * len(nodes)
|
|
112
|
+
epi = [0.0] * len(nodes)
|
|
113
|
+
vf = [0.0] * len(nodes)
|
|
114
|
+
cos_theta = [1.0] * len(nodes)
|
|
115
|
+
sin_theta = [0.0] * len(nodes)
|
|
116
|
+
cache = DnfrCache(
|
|
117
|
+
idx=idx,
|
|
118
|
+
theta=theta,
|
|
119
|
+
epi=epi,
|
|
120
|
+
vf=vf,
|
|
121
|
+
cos_theta=cos_theta,
|
|
122
|
+
sin_theta=sin_theta,
|
|
123
|
+
degs=prev_cache.degs if prev_cache else None,
|
|
124
|
+
checksum=checksum,
|
|
125
|
+
)
|
|
126
|
+
G.graph["_dnfr_prep_cache"] = cache
|
|
127
|
+
return (
|
|
128
|
+
cache,
|
|
129
|
+
cache.idx,
|
|
130
|
+
cache.theta,
|
|
131
|
+
cache.epi,
|
|
132
|
+
cache.vf,
|
|
133
|
+
cache.cos_theta,
|
|
134
|
+
cache.sin_theta,
|
|
135
|
+
True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _ensure_numpy_vectors(cache: DnfrCache, np):
|
|
140
|
+
"""Ensure NumPy copies of cached vectors are initialised and up to date."""
|
|
141
|
+
|
|
142
|
+
if cache is None:
|
|
143
|
+
return (None, None, None, None, None)
|
|
144
|
+
|
|
145
|
+
arrays = []
|
|
146
|
+
for attr_np, source_attr in (
|
|
147
|
+
("theta_np", "theta"),
|
|
148
|
+
("epi_np", "epi"),
|
|
149
|
+
("vf_np", "vf"),
|
|
150
|
+
("cos_theta_np", "cos_theta"),
|
|
151
|
+
("sin_theta_np", "sin_theta"),
|
|
152
|
+
):
|
|
153
|
+
src = getattr(cache, source_attr)
|
|
154
|
+
arr = getattr(cache, attr_np)
|
|
155
|
+
if src is None:
|
|
156
|
+
setattr(cache, attr_np, None)
|
|
157
|
+
arrays.append(None)
|
|
158
|
+
continue
|
|
159
|
+
if arr is None or len(arr) != len(src):
|
|
160
|
+
arr = np.array(src, dtype=float)
|
|
161
|
+
else:
|
|
162
|
+
np.copyto(arr, src, casting="unsafe")
|
|
163
|
+
setattr(cache, attr_np, arr)
|
|
164
|
+
arrays.append(arr)
|
|
165
|
+
return tuple(arrays)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _ensure_numpy_degrees(cache: DnfrCache, deg_list, np):
|
|
169
|
+
"""Initialise/update NumPy array mirroring ``deg_list``."""
|
|
170
|
+
|
|
171
|
+
if cache is None or deg_list is None:
|
|
172
|
+
if cache is not None:
|
|
173
|
+
cache.deg_array = None
|
|
174
|
+
return None
|
|
175
|
+
arr = cache.deg_array
|
|
176
|
+
if arr is None or len(arr) != len(deg_list):
|
|
177
|
+
arr = np.array(deg_list, dtype=float)
|
|
178
|
+
else:
|
|
179
|
+
np.copyto(arr, deg_list, casting="unsafe")
|
|
180
|
+
cache.deg_array = arr
|
|
181
|
+
return arr
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _refresh_dnfr_vectors(G, nodes, cache: DnfrCache):
|
|
185
|
+
"""Update cached angle and state vectors for ΔNFR."""
|
|
186
|
+
np = get_numpy()
|
|
187
|
+
trig = compute_theta_trig(((n, G.nodes[n]) for n in nodes), np=np)
|
|
188
|
+
use_numpy = np is not None and G.graph.get("vectorized_dnfr")
|
|
189
|
+
for i, n in enumerate(nodes):
|
|
190
|
+
nd = G.nodes[n]
|
|
191
|
+
cache.theta[i] = trig.theta[n]
|
|
192
|
+
cache.epi[i] = get_attr(nd, ALIAS_EPI, 0.0)
|
|
193
|
+
cache.vf[i] = get_attr(nd, ALIAS_VF, 0.0)
|
|
194
|
+
cache.cos_theta[i] = trig.cos[n]
|
|
195
|
+
cache.sin_theta[i] = trig.sin[n]
|
|
196
|
+
if use_numpy and np is not None:
|
|
197
|
+
_ensure_numpy_vectors(cache, np)
|
|
198
|
+
else:
|
|
199
|
+
cache.theta_np = None
|
|
200
|
+
cache.epi_np = None
|
|
201
|
+
cache.vf_np = None
|
|
202
|
+
cache.cos_theta_np = None
|
|
203
|
+
cache.sin_theta_np = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _prepare_dnfr_data(G, *, cache_size: int | None = 128) -> dict:
|
|
207
|
+
"""Precompute common data for ΔNFR strategies."""
|
|
208
|
+
weights = G.graph.get("_dnfr_weights")
|
|
209
|
+
if weights is None:
|
|
210
|
+
weights = _configure_dnfr_weights(G)
|
|
211
|
+
|
|
212
|
+
np = get_numpy()
|
|
213
|
+
use_numpy = np is not None and G.graph.get("vectorized_dnfr")
|
|
214
|
+
|
|
215
|
+
nodes, A = cached_nodes_and_A(G, cache_size=cache_size)
|
|
216
|
+
cache: DnfrCache | None = G.graph.get("_dnfr_prep_cache")
|
|
217
|
+
checksum = G.graph.get("_dnfr_nodes_checksum")
|
|
218
|
+
dirty = bool(G.graph.pop("_dnfr_prep_dirty", False))
|
|
219
|
+
cache, idx, theta, epi, vf, cos_theta, sin_theta, refreshed = (
|
|
220
|
+
_init_dnfr_cache(G, nodes, cache, checksum, dirty)
|
|
221
|
+
)
|
|
222
|
+
if cache is not None:
|
|
223
|
+
_refresh_dnfr_vectors(G, nodes, cache)
|
|
224
|
+
|
|
225
|
+
w_phase = float(weights.get("phase", 0.0))
|
|
226
|
+
w_epi = float(weights.get("epi", 0.0))
|
|
227
|
+
w_vf = float(weights.get("vf", 0.0))
|
|
228
|
+
w_topo = float(weights.get("topo", 0.0))
|
|
229
|
+
degs = cache.degs if cache else None
|
|
230
|
+
if w_topo != 0 and (dirty or degs is None):
|
|
231
|
+
degs = dict(G.degree())
|
|
232
|
+
cache.degs = degs
|
|
233
|
+
elif w_topo == 0:
|
|
234
|
+
degs = None
|
|
235
|
+
if cache is not None:
|
|
236
|
+
cache.degs = None
|
|
237
|
+
|
|
238
|
+
G.graph["_dnfr_prep_dirty"] = False
|
|
239
|
+
|
|
240
|
+
deg_list: list[float] | None = None
|
|
241
|
+
if w_topo != 0.0 and degs is not None:
|
|
242
|
+
if cache.deg_list is None or dirty or len(cache.deg_list) != len(nodes):
|
|
243
|
+
cache.deg_list = [float(degs.get(node, 0.0)) for node in nodes]
|
|
244
|
+
deg_list = cache.deg_list
|
|
245
|
+
else:
|
|
246
|
+
cache.deg_list = None
|
|
247
|
+
|
|
248
|
+
if use_numpy and np is not None:
|
|
249
|
+
theta_np, epi_np, vf_np, cos_theta_np, sin_theta_np = _ensure_numpy_vectors(
|
|
250
|
+
cache, np
|
|
251
|
+
)
|
|
252
|
+
deg_array = _ensure_numpy_degrees(cache, deg_list, np)
|
|
253
|
+
else:
|
|
254
|
+
theta_np = None
|
|
255
|
+
epi_np = None
|
|
256
|
+
vf_np = None
|
|
257
|
+
cos_theta_np = None
|
|
258
|
+
sin_theta_np = None
|
|
259
|
+
deg_array = None
|
|
260
|
+
cache.deg_array = None
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"weights": weights,
|
|
264
|
+
"nodes": nodes,
|
|
265
|
+
"idx": idx,
|
|
266
|
+
"theta": theta,
|
|
267
|
+
"epi": epi,
|
|
268
|
+
"vf": vf,
|
|
269
|
+
"cos_theta": cos_theta,
|
|
270
|
+
"sin_theta": sin_theta,
|
|
271
|
+
"theta_np": theta_np,
|
|
272
|
+
"epi_np": epi_np,
|
|
273
|
+
"vf_np": vf_np,
|
|
274
|
+
"cos_theta_np": cos_theta_np,
|
|
275
|
+
"sin_theta_np": sin_theta_np,
|
|
276
|
+
"w_phase": w_phase,
|
|
277
|
+
"w_epi": w_epi,
|
|
278
|
+
"w_vf": w_vf,
|
|
279
|
+
"w_topo": w_topo,
|
|
280
|
+
"degs": degs,
|
|
281
|
+
"deg_list": deg_list,
|
|
282
|
+
"deg_array": deg_array,
|
|
283
|
+
"A": A,
|
|
284
|
+
"cache_size": cache_size,
|
|
285
|
+
"cache": cache,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _apply_dnfr_gradients(
|
|
290
|
+
G,
|
|
291
|
+
data,
|
|
292
|
+
th_bar,
|
|
293
|
+
epi_bar,
|
|
294
|
+
vf_bar,
|
|
295
|
+
deg_bar=None,
|
|
296
|
+
degs=None,
|
|
297
|
+
):
|
|
298
|
+
"""Combine precomputed gradients and write ΔNFR to each node."""
|
|
299
|
+
nodes = data["nodes"]
|
|
300
|
+
theta = data["theta"]
|
|
301
|
+
epi = data["epi"]
|
|
302
|
+
vf = data["vf"]
|
|
303
|
+
w_phase = data["w_phase"]
|
|
304
|
+
w_epi = data["w_epi"]
|
|
305
|
+
w_vf = data["w_vf"]
|
|
306
|
+
w_topo = data["w_topo"]
|
|
307
|
+
if degs is None:
|
|
308
|
+
degs = data.get("degs")
|
|
309
|
+
|
|
310
|
+
for i, n in enumerate(nodes):
|
|
311
|
+
g_phase = -angle_diff(theta[i], th_bar[i]) / math.pi
|
|
312
|
+
g_epi = epi_bar[i] - epi[i]
|
|
313
|
+
g_vf = vf_bar[i] - vf[i]
|
|
314
|
+
if w_topo != 0.0 and deg_bar is not None and degs is not None:
|
|
315
|
+
if isinstance(degs, dict):
|
|
316
|
+
deg_i = float(degs.get(n, 0))
|
|
317
|
+
else:
|
|
318
|
+
deg_i = float(degs[i])
|
|
319
|
+
g_topo = deg_bar[i] - deg_i
|
|
320
|
+
else:
|
|
321
|
+
g_topo = 0.0
|
|
322
|
+
dnfr = (
|
|
323
|
+
w_phase * g_phase + w_epi * g_epi + w_vf * g_vf + w_topo * g_topo
|
|
324
|
+
)
|
|
325
|
+
set_dnfr(G, n, float(dnfr))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _init_bar_arrays(data, *, degs=None, np=None):
|
|
329
|
+
"""Prepare containers for neighbour means.
|
|
330
|
+
|
|
331
|
+
If ``np`` is provided, NumPy arrays are created; otherwise lists are used.
|
|
332
|
+
``degs`` is optional and only initialised when the topological term is
|
|
333
|
+
active.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
theta = data["theta"]
|
|
337
|
+
epi = data["epi"]
|
|
338
|
+
vf = data["vf"]
|
|
339
|
+
w_topo = data["w_topo"]
|
|
340
|
+
if np is None:
|
|
341
|
+
np = get_numpy()
|
|
342
|
+
if np is not None:
|
|
343
|
+
th_bar = np.array(theta, dtype=float)
|
|
344
|
+
epi_bar = np.array(epi, dtype=float)
|
|
345
|
+
vf_bar = np.array(vf, dtype=float)
|
|
346
|
+
deg_bar = (
|
|
347
|
+
np.array(degs, dtype=float)
|
|
348
|
+
if w_topo != 0.0 and degs is not None
|
|
349
|
+
else None
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
th_bar = list(theta)
|
|
353
|
+
epi_bar = list(epi)
|
|
354
|
+
vf_bar = list(vf)
|
|
355
|
+
deg_bar = list(degs) if w_topo != 0.0 and degs is not None else None
|
|
356
|
+
return th_bar, epi_bar, vf_bar, deg_bar
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _compute_neighbor_means(
|
|
360
|
+
G,
|
|
361
|
+
data,
|
|
362
|
+
*,
|
|
363
|
+
x,
|
|
364
|
+
y,
|
|
365
|
+
epi_sum,
|
|
366
|
+
vf_sum,
|
|
367
|
+
count,
|
|
368
|
+
deg_sum=None,
|
|
369
|
+
degs=None,
|
|
370
|
+
np=None,
|
|
371
|
+
):
|
|
372
|
+
"""Return neighbour mean arrays for ΔNFR."""
|
|
373
|
+
w_topo = data["w_topo"]
|
|
374
|
+
theta = data["theta"]
|
|
375
|
+
is_numpy = np is not None and isinstance(count, np.ndarray)
|
|
376
|
+
th_bar, epi_bar, vf_bar, deg_bar = _init_bar_arrays(
|
|
377
|
+
data, degs=degs, np=np if is_numpy else None
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if is_numpy:
|
|
381
|
+
mask = count > 0
|
|
382
|
+
if np.any(mask):
|
|
383
|
+
th_bar[mask] = np.arctan2(
|
|
384
|
+
y[mask] / count[mask], x[mask] / count[mask]
|
|
385
|
+
)
|
|
386
|
+
epi_bar[mask] = epi_sum[mask] / count[mask]
|
|
387
|
+
vf_bar[mask] = vf_sum[mask] / count[mask]
|
|
388
|
+
if w_topo != 0.0 and deg_bar is not None and deg_sum is not None:
|
|
389
|
+
deg_bar[mask] = deg_sum[mask] / count[mask]
|
|
390
|
+
return th_bar, epi_bar, vf_bar, deg_bar
|
|
391
|
+
|
|
392
|
+
n = len(theta)
|
|
393
|
+
cos_th = data["cos_theta"]
|
|
394
|
+
sin_th = data["sin_theta"]
|
|
395
|
+
idx = data["idx"]
|
|
396
|
+
nodes = data["nodes"]
|
|
397
|
+
deg_list = data.get("deg_list")
|
|
398
|
+
for i in range(n):
|
|
399
|
+
c = count[i]
|
|
400
|
+
if c:
|
|
401
|
+
node = nodes[i]
|
|
402
|
+
th_bar[i] = _phase_mean_from_iter(
|
|
403
|
+
((cos_th[idx[v]], sin_th[idx[v]]) for v in G.neighbors(node)),
|
|
404
|
+
theta[i],
|
|
405
|
+
)
|
|
406
|
+
epi_bar[i] = epi_sum[i] / c
|
|
407
|
+
vf_bar[i] = vf_sum[i] / c
|
|
408
|
+
if w_topo != 0.0 and deg_bar is not None and deg_sum is not None:
|
|
409
|
+
deg_bar[i] = deg_sum[i] / c
|
|
410
|
+
return th_bar, epi_bar, vf_bar, deg_bar
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _compute_dnfr_common(
|
|
414
|
+
G,
|
|
415
|
+
data,
|
|
416
|
+
*,
|
|
417
|
+
x,
|
|
418
|
+
y,
|
|
419
|
+
epi_sum,
|
|
420
|
+
vf_sum,
|
|
421
|
+
count,
|
|
422
|
+
deg_sum=None,
|
|
423
|
+
degs=None,
|
|
424
|
+
):
|
|
425
|
+
"""Compute neighbour means and apply ΔNFR gradients."""
|
|
426
|
+
np = get_numpy()
|
|
427
|
+
th_bar, epi_bar, vf_bar, deg_bar = _compute_neighbor_means(
|
|
428
|
+
G,
|
|
429
|
+
data,
|
|
430
|
+
x=x,
|
|
431
|
+
y=y,
|
|
432
|
+
epi_sum=epi_sum,
|
|
433
|
+
vf_sum=vf_sum,
|
|
434
|
+
count=count,
|
|
435
|
+
deg_sum=deg_sum,
|
|
436
|
+
degs=degs,
|
|
437
|
+
np=np,
|
|
438
|
+
)
|
|
439
|
+
_apply_dnfr_gradients(G, data, th_bar, epi_bar, vf_bar, deg_bar, degs)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _init_neighbor_sums(data, *, np=None):
|
|
443
|
+
"""Initialise containers for neighbour sums."""
|
|
444
|
+
nodes = data["nodes"]
|
|
445
|
+
n = len(nodes)
|
|
446
|
+
w_topo = data["w_topo"]
|
|
447
|
+
if np is not None:
|
|
448
|
+
x = np.zeros(n, dtype=float)
|
|
449
|
+
y = np.zeros(n, dtype=float)
|
|
450
|
+
epi_sum = np.zeros(n, dtype=float)
|
|
451
|
+
vf_sum = np.zeros(n, dtype=float)
|
|
452
|
+
count = np.zeros(n, dtype=float)
|
|
453
|
+
deg_sum = np.zeros(n, dtype=float) if w_topo != 0.0 else None
|
|
454
|
+
degs = None
|
|
455
|
+
else:
|
|
456
|
+
x = [0.0] * n
|
|
457
|
+
y = [0.0] * n
|
|
458
|
+
epi_sum = [0.0] * n
|
|
459
|
+
vf_sum = [0.0] * n
|
|
460
|
+
count = [0] * n
|
|
461
|
+
deg_list = data.get("deg_list")
|
|
462
|
+
if w_topo != 0 and deg_list is not None:
|
|
463
|
+
deg_sum = [0.0] * n
|
|
464
|
+
degs = list(deg_list)
|
|
465
|
+
else:
|
|
466
|
+
deg_sum = None
|
|
467
|
+
degs = None
|
|
468
|
+
return x, y, epi_sum, vf_sum, count, deg_sum, degs
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _build_neighbor_sums_common(G, data, *, use_numpy: bool):
|
|
472
|
+
np = get_numpy()
|
|
473
|
+
nodes = data["nodes"]
|
|
474
|
+
w_topo = data["w_topo"]
|
|
475
|
+
if use_numpy:
|
|
476
|
+
if np is None: # pragma: no cover - runtime check
|
|
477
|
+
raise RuntimeError(
|
|
478
|
+
"numpy no disponible para la versión vectorizada",
|
|
479
|
+
)
|
|
480
|
+
if not nodes:
|
|
481
|
+
return None
|
|
482
|
+
x, y, epi_sum, vf_sum, count, deg_sum, degs = _init_neighbor_sums(
|
|
483
|
+
data, np=np
|
|
484
|
+
)
|
|
485
|
+
A = data.get("A")
|
|
486
|
+
if A is None:
|
|
487
|
+
_, A = cached_nodes_and_A(G, cache_size=data.get("cache_size"))
|
|
488
|
+
data["A"] = A
|
|
489
|
+
epi = data.get("epi_np")
|
|
490
|
+
vf = data.get("vf_np")
|
|
491
|
+
cos_th = data.get("cos_theta_np")
|
|
492
|
+
sin_th = data.get("sin_theta_np")
|
|
493
|
+
cache = data.get("cache")
|
|
494
|
+
if epi is None or vf is None or cos_th is None or sin_th is None:
|
|
495
|
+
epi = np.array(data["epi"], dtype=float)
|
|
496
|
+
vf = np.array(data["vf"], dtype=float)
|
|
497
|
+
cos_th = np.array(data["cos_theta"], dtype=float)
|
|
498
|
+
sin_th = np.array(data["sin_theta"], dtype=float)
|
|
499
|
+
data["epi_np"] = epi
|
|
500
|
+
data["vf_np"] = vf
|
|
501
|
+
data["cos_theta_np"] = cos_th
|
|
502
|
+
data["sin_theta_np"] = sin_th
|
|
503
|
+
if cache is not None:
|
|
504
|
+
cache.epi_np = epi
|
|
505
|
+
cache.vf_np = vf
|
|
506
|
+
cache.cos_theta_np = cos_th
|
|
507
|
+
cache.sin_theta_np = sin_th
|
|
508
|
+
x[:] = A @ cos_th
|
|
509
|
+
y[:] = A @ sin_th
|
|
510
|
+
epi_sum[:] = A @ epi
|
|
511
|
+
vf_sum[:] = A @ vf
|
|
512
|
+
count[:] = A.sum(axis=1)
|
|
513
|
+
if w_topo != 0.0:
|
|
514
|
+
deg_array = data.get("deg_array")
|
|
515
|
+
if deg_array is None:
|
|
516
|
+
deg_list = data.get("deg_list")
|
|
517
|
+
if deg_list is not None:
|
|
518
|
+
deg_array = np.array(deg_list, dtype=float)
|
|
519
|
+
data["deg_array"] = deg_array
|
|
520
|
+
if cache is not None:
|
|
521
|
+
cache.deg_array = deg_array
|
|
522
|
+
else:
|
|
523
|
+
deg_array = count
|
|
524
|
+
deg_sum[:] = A @ deg_array
|
|
525
|
+
degs = deg_array
|
|
526
|
+
return x, y, epi_sum, vf_sum, count, deg_sum, degs
|
|
527
|
+
else:
|
|
528
|
+
x, y, epi_sum, vf_sum, count, deg_sum, degs_list = _init_neighbor_sums(
|
|
529
|
+
data
|
|
530
|
+
)
|
|
531
|
+
idx = data["idx"]
|
|
532
|
+
epi = data["epi"]
|
|
533
|
+
vf = data["vf"]
|
|
534
|
+
cos_th = data["cos_theta"]
|
|
535
|
+
sin_th = data["sin_theta"]
|
|
536
|
+
deg_list = data.get("deg_list")
|
|
537
|
+
for i, node in enumerate(nodes):
|
|
538
|
+
deg_i = degs_list[i] if degs_list is not None else 0.0
|
|
539
|
+
for v in G.neighbors(node):
|
|
540
|
+
j = idx[v]
|
|
541
|
+
x[i] += cos_th[j]
|
|
542
|
+
y[i] += sin_th[j]
|
|
543
|
+
epi_sum[i] += epi[j]
|
|
544
|
+
vf_sum[i] += vf[j]
|
|
545
|
+
count[i] += 1
|
|
546
|
+
if deg_sum is not None:
|
|
547
|
+
deg_sum[i] += deg_list[j] if deg_list is not None else deg_i
|
|
548
|
+
return x, y, epi_sum, vf_sum, count, deg_sum, degs_list
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _compute_dnfr(G, data, *, use_numpy: bool = False) -> None:
|
|
552
|
+
"""Compute ΔNFR using neighbour sums.
|
|
553
|
+
|
|
554
|
+
Parameters
|
|
555
|
+
----------
|
|
556
|
+
G : nx.Graph
|
|
557
|
+
Graph on which the computation is performed.
|
|
558
|
+
data : dict
|
|
559
|
+
Precomputed ΔNFR data as returned by :func:`_prepare_dnfr_data`.
|
|
560
|
+
use_numpy : bool, optional
|
|
561
|
+
When ``True`` the vectorised ``numpy`` strategy is used. Defaults to
|
|
562
|
+
``False`` to fall back to the loop-based implementation.
|
|
563
|
+
"""
|
|
564
|
+
res = _build_neighbor_sums_common(G, data, use_numpy=use_numpy)
|
|
565
|
+
if res is None:
|
|
566
|
+
return
|
|
567
|
+
x, y, epi_sum, vf_sum, count, deg_sum, degs = res
|
|
568
|
+
_compute_dnfr_common(
|
|
569
|
+
G,
|
|
570
|
+
data,
|
|
571
|
+
x=x,
|
|
572
|
+
y=y,
|
|
573
|
+
epi_sum=epi_sum,
|
|
574
|
+
vf_sum=vf_sum,
|
|
575
|
+
count=count,
|
|
576
|
+
deg_sum=deg_sum,
|
|
577
|
+
degs=degs,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def default_compute_delta_nfr(G, *, cache_size: int | None = 1) -> None:
|
|
582
|
+
"""Compute ΔNFR by mixing phase, EPI, νf and a topological term.
|
|
583
|
+
|
|
584
|
+
Parameters
|
|
585
|
+
----------
|
|
586
|
+
G : nx.Graph
|
|
587
|
+
Graph on which the computation is performed.
|
|
588
|
+
cache_size : int | None, optional
|
|
589
|
+
Maximum number of edge configurations cached in ``G.graph``. Values
|
|
590
|
+
``None`` or <= 0 imply unlimited cache. Defaults to ``1`` to keep the
|
|
591
|
+
previous behaviour.
|
|
592
|
+
"""
|
|
593
|
+
data = _prepare_dnfr_data(G, cache_size=cache_size)
|
|
594
|
+
_write_dnfr_metadata(
|
|
595
|
+
G,
|
|
596
|
+
weights=data["weights"],
|
|
597
|
+
hook_name="default_compute_delta_nfr",
|
|
598
|
+
)
|
|
599
|
+
np = get_numpy()
|
|
600
|
+
use_numpy = np is not None and G.graph.get("vectorized_dnfr")
|
|
601
|
+
_compute_dnfr(G, data, use_numpy=use_numpy)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def set_delta_nfr_hook(
|
|
605
|
+
G, func, *, name: str | None = None, note: str | None = None
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Set a stable hook to compute ΔNFR.
|
|
608
|
+
Required signature: ``func(G) -> None`` and it must write ``ALIAS_DNFR``
|
|
609
|
+
in each node. Basic metadata in ``G.graph`` is updated accordingly.
|
|
610
|
+
"""
|
|
611
|
+
G.graph["compute_delta_nfr"] = func
|
|
612
|
+
G.graph["_dnfr_hook_name"] = str(
|
|
613
|
+
name or getattr(func, "__name__", "custom_dnfr")
|
|
614
|
+
)
|
|
615
|
+
if "_dnfr_weights" not in G.graph:
|
|
616
|
+
_configure_dnfr_weights(G)
|
|
617
|
+
if note:
|
|
618
|
+
meta = G.graph.get("_DNFR_META", {})
|
|
619
|
+
meta["note"] = str(note)
|
|
620
|
+
G.graph["_DNFR_META"] = meta
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _apply_dnfr_hook(
|
|
624
|
+
G,
|
|
625
|
+
grads: dict[str, Callable[[Any, Any], float]],
|
|
626
|
+
*,
|
|
627
|
+
weights: dict[str, float],
|
|
628
|
+
hook_name: str,
|
|
629
|
+
note: str | None = None,
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Generic helper to compute and store ΔNFR using ``grads``.
|
|
632
|
+
|
|
633
|
+
``grads`` maps component names to functions ``(G, n, nd) -> float``.
|
|
634
|
+
Each gradient is multiplied by its corresponding weight from ``weights``.
|
|
635
|
+
Metadata is recorded through :func:`_write_dnfr_metadata`.
|
|
636
|
+
"""
|
|
637
|
+
|
|
638
|
+
for n, nd in G.nodes(data=True):
|
|
639
|
+
total = 0.0
|
|
640
|
+
for name, func in grads.items():
|
|
641
|
+
w = weights.get(name, 0.0)
|
|
642
|
+
if w:
|
|
643
|
+
total += w * func(G, n, nd)
|
|
644
|
+
set_dnfr(G, n, total)
|
|
645
|
+
|
|
646
|
+
_write_dnfr_metadata(G, weights=weights, hook_name=hook_name, note=note)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# --- Hooks de ejemplo (opcionales) ---
|
|
650
|
+
def dnfr_phase_only(G) -> None:
|
|
651
|
+
"""Example: ΔNFR from phase only (Kuramoto-like)."""
|
|
652
|
+
|
|
653
|
+
def g_phase(G, n, nd):
|
|
654
|
+
th_i = get_attr(nd, ALIAS_THETA, 0.0)
|
|
655
|
+
th_bar = neighbor_phase_mean(G, n)
|
|
656
|
+
return -angle_diff(th_i, th_bar) / math.pi
|
|
657
|
+
|
|
658
|
+
_apply_dnfr_hook(
|
|
659
|
+
G,
|
|
660
|
+
{"phase": g_phase},
|
|
661
|
+
weights={"phase": 1.0},
|
|
662
|
+
hook_name="dnfr_phase_only",
|
|
663
|
+
note="Hook de ejemplo.",
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def dnfr_epi_vf_mixed(G) -> None:
|
|
668
|
+
"""Example: ΔNFR without phase, mixing EPI and νf."""
|
|
669
|
+
|
|
670
|
+
def g_epi(G, n, nd):
|
|
671
|
+
epi_i = get_attr(nd, ALIAS_EPI, 0.0)
|
|
672
|
+
neighbors = list(G.neighbors(n))
|
|
673
|
+
if neighbors:
|
|
674
|
+
total = 0.0
|
|
675
|
+
for v in neighbors:
|
|
676
|
+
total += float(get_attr(G.nodes[v], ALIAS_EPI, epi_i))
|
|
677
|
+
epi_bar = total / len(neighbors)
|
|
678
|
+
else:
|
|
679
|
+
epi_bar = float(epi_i)
|
|
680
|
+
return epi_bar - epi_i
|
|
681
|
+
|
|
682
|
+
def g_vf(G, n, nd):
|
|
683
|
+
vf_i = get_attr(nd, ALIAS_VF, 0.0)
|
|
684
|
+
neighbors = list(G.neighbors(n))
|
|
685
|
+
if neighbors:
|
|
686
|
+
total = 0.0
|
|
687
|
+
for v in neighbors:
|
|
688
|
+
total += float(get_attr(G.nodes[v], ALIAS_VF, vf_i))
|
|
689
|
+
vf_bar = total / len(neighbors)
|
|
690
|
+
else:
|
|
691
|
+
vf_bar = float(vf_i)
|
|
692
|
+
return vf_bar - vf_i
|
|
693
|
+
|
|
694
|
+
_apply_dnfr_hook(
|
|
695
|
+
G,
|
|
696
|
+
{"epi": g_epi, "vf": g_vf},
|
|
697
|
+
weights={"phase": 0.0, "epi": 0.5, "vf": 0.5},
|
|
698
|
+
hook_name="dnfr_epi_vf_mixed",
|
|
699
|
+
note="Hook de ejemplo.",
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def dnfr_laplacian(G) -> None:
|
|
704
|
+
"""Explicit topological gradient using Laplacian over EPI and νf."""
|
|
705
|
+
weights_cfg = get_param(G, "DNFR_WEIGHTS")
|
|
706
|
+
wE = float(weights_cfg.get("epi", DEFAULTS["DNFR_WEIGHTS"]["epi"]))
|
|
707
|
+
wV = float(weights_cfg.get("vf", DEFAULTS["DNFR_WEIGHTS"]["vf"]))
|
|
708
|
+
|
|
709
|
+
def g_epi(G, n, nd):
|
|
710
|
+
epi = get_attr(nd, ALIAS_EPI, 0.0)
|
|
711
|
+
neigh = list(G.neighbors(n))
|
|
712
|
+
deg = len(neigh) or 1
|
|
713
|
+
epi_bar = (
|
|
714
|
+
sum(get_attr(G.nodes[v], ALIAS_EPI, epi) for v in neigh) / deg
|
|
715
|
+
)
|
|
716
|
+
return epi_bar - epi
|
|
717
|
+
|
|
718
|
+
def g_vf(G, n, nd):
|
|
719
|
+
vf = get_attr(nd, ALIAS_VF, 0.0)
|
|
720
|
+
neigh = list(G.neighbors(n))
|
|
721
|
+
deg = len(neigh) or 1
|
|
722
|
+
vf_bar = sum(get_attr(G.nodes[v], ALIAS_VF, vf) for v in neigh) / deg
|
|
723
|
+
return vf_bar - vf
|
|
724
|
+
|
|
725
|
+
_apply_dnfr_hook(
|
|
726
|
+
G,
|
|
727
|
+
{"epi": g_epi, "vf": g_vf},
|
|
728
|
+
weights={"epi": wE, "vf": wV},
|
|
729
|
+
hook_name="dnfr_laplacian",
|
|
730
|
+
note="Gradiente topológico",
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|