tnfr 4.3.0__py3-none-any.whl → 4.5.1__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 +41 -12
- tnfr/cli.py +53 -1
- tnfr/config.py +41 -0
- tnfr/constants.py +82 -25
- tnfr/dynamics.py +191 -42
- tnfr/gamma.py +17 -0
- tnfr/helpers.py +33 -21
- tnfr/metrics.py +368 -5
- tnfr/node.py +202 -0
- tnfr/observers.py +9 -1
- tnfr/operators.py +298 -125
- tnfr/structural.py +201 -0
- tnfr/types.py +2 -1
- tnfr/validators.py +38 -0
- tnfr-4.5.1.dist-info/METADATA +221 -0
- tnfr-4.5.1.dist-info/RECORD +28 -0
- tnfr-4.3.0.dist-info/METADATA +0 -109
- tnfr-4.3.0.dist-info/RECORD +0 -24
- {tnfr-4.3.0.dist-info → tnfr-4.5.1.dist-info}/WHEEL +0 -0
- {tnfr-4.3.0.dist-info → tnfr-4.5.1.dist-info}/entry_points.txt +0 -0
- {tnfr-4.3.0.dist-info → tnfr-4.5.1.dist-info}/licenses/LICENSE.md +0 -0
- {tnfr-4.3.0.dist-info → tnfr-4.5.1.dist-info}/top_level.txt +0 -0
tnfr/dynamics.py
CHANGED
|
@@ -11,23 +11,29 @@ Incluye:
|
|
|
11
11
|
- default_glyph_selector, step, run
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
|
-
from typing import Dict, Any, Iterable
|
|
14
|
+
from typing import Dict, Any, Iterable, Literal
|
|
15
15
|
import math
|
|
16
16
|
from collections import deque
|
|
17
17
|
import networkx as nx
|
|
18
18
|
|
|
19
19
|
from .observers import sincronía_fase, carga_glifica, orden_kuramoto, sigma_vector
|
|
20
20
|
from .operators import aplicar_remesh_si_estabilizacion_global
|
|
21
|
-
from .grammar import
|
|
21
|
+
from .grammar import (
|
|
22
|
+
enforce_canonical_grammar,
|
|
23
|
+
on_applied_glifo,
|
|
24
|
+
AL,
|
|
25
|
+
EN,
|
|
26
|
+
)
|
|
22
27
|
from .constants import (
|
|
23
28
|
DEFAULTS,
|
|
24
29
|
ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI,
|
|
25
30
|
ALIAS_dEPI, ALIAS_D2EPI, ALIAS_dVF, ALIAS_D2VF, ALIAS_dSI,
|
|
31
|
+
ALIAS_EPI_KIND,
|
|
26
32
|
)
|
|
27
33
|
from .gamma import eval_gamma
|
|
28
34
|
from .helpers import (
|
|
29
35
|
clamp, clamp01, list_mean, phase_distance,
|
|
30
|
-
_get_attr, _set_attr, media_vecinal, fase_media,
|
|
36
|
+
_get_attr, _set_attr, _get_attr_str, _set_attr_str, media_vecinal, fase_media,
|
|
31
37
|
invoke_callbacks, reciente_glifo
|
|
32
38
|
)
|
|
33
39
|
|
|
@@ -36,20 +42,21 @@ from .helpers import (
|
|
|
36
42
|
# -------------------------
|
|
37
43
|
|
|
38
44
|
def _write_dnfr_metadata(G, *, weights: dict, hook_name: str, note: str | None = None) -> None:
|
|
39
|
-
"""Escribe en G.graph un bloque _DNFR_META con la mezcla y el nombre del hook.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
"""Escribe en G.graph un bloque _DNFR_META con la mezcla y el nombre del hook.
|
|
46
|
+
|
|
47
|
+
`weights` puede incluir componentes arbitrarias (phase/epi/vf/topo/etc.)."""
|
|
48
|
+
total = sum(float(v) for v in weights.values())
|
|
49
|
+
if total <= 0:
|
|
50
|
+
# si no hay pesos, normalizamos a componentes iguales
|
|
51
|
+
n = max(1, len(weights))
|
|
52
|
+
weights = {k: 1.0 / n for k in weights}
|
|
53
|
+
total = 1.0
|
|
47
54
|
meta = {
|
|
48
55
|
"hook": hook_name,
|
|
49
56
|
"weights_raw": dict(weights),
|
|
50
|
-
"weights_norm": {
|
|
51
|
-
"components": [k for k, v in
|
|
52
|
-
"doc": "ΔNFR =
|
|
57
|
+
"weights_norm": {k: float(v) / total for k, v in weights.items()},
|
|
58
|
+
"components": [k for k, v in weights.items() if float(v) != 0.0],
|
|
59
|
+
"doc": "ΔNFR = Σ w_i·g_i",
|
|
53
60
|
}
|
|
54
61
|
if note:
|
|
55
62
|
meta["note"] = str(note)
|
|
@@ -58,26 +65,35 @@ def _write_dnfr_metadata(G, *, weights: dict, hook_name: str, note: str | None =
|
|
|
58
65
|
|
|
59
66
|
|
|
60
67
|
def default_compute_delta_nfr(G) -> None:
|
|
61
|
-
"""Calcula ΔNFR mezclando gradientes de fase, EPI
|
|
68
|
+
"""Calcula ΔNFR mezclando gradientes de fase, EPI, νf y un término topológico."""
|
|
62
69
|
w = G.graph.get("DNFR_WEIGHTS", DEFAULTS["DNFR_WEIGHTS"]) # dict
|
|
63
70
|
w_phase = float(w.get("phase", 0.34))
|
|
64
71
|
w_epi = float(w.get("epi", 0.33))
|
|
65
72
|
w_vf = float(w.get("vf", 0.33))
|
|
66
|
-
|
|
73
|
+
w_topo = float(w.get("topo", 0.0))
|
|
74
|
+
s = w_phase + w_epi + w_vf + w_topo
|
|
67
75
|
if s <= 0:
|
|
68
76
|
w_phase = w_epi = w_vf = 1/3
|
|
77
|
+
w_topo = 0.0
|
|
78
|
+
s = 1.0
|
|
69
79
|
else:
|
|
70
|
-
w_phase, w_epi, w_vf = w_phase/s, w_epi/s, w_vf/s
|
|
80
|
+
w_phase, w_epi, w_vf, w_topo = (w_phase/s, w_epi/s, w_vf/s, w_topo/s)
|
|
71
81
|
|
|
72
82
|
# Documentar mezcla y hook activo
|
|
73
|
-
_write_dnfr_metadata(
|
|
74
|
-
|
|
83
|
+
_write_dnfr_metadata(
|
|
84
|
+
G,
|
|
85
|
+
weights={"phase": w_phase, "epi": w_epi, "vf": w_vf, "topo": w_topo},
|
|
86
|
+
hook_name="default_compute_delta_nfr",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
degs = dict(G.degree()) if w_topo != 0 else None
|
|
90
|
+
|
|
75
91
|
for n in G.nodes():
|
|
76
92
|
nd = G.nodes[n]
|
|
77
93
|
th_i = _get_attr(nd, ALIAS_THETA, 0.0)
|
|
78
94
|
th_bar = fase_media(G, n)
|
|
79
95
|
# Gradiente de fase: empuja hacia la fase media (signo envuelto)
|
|
80
|
-
g_phase = -
|
|
96
|
+
g_phase = -((th_i - th_bar + math.pi) % (2 * math.pi) - math.pi) / math.pi # ~[-1,1]
|
|
81
97
|
|
|
82
98
|
epi_i = _get_attr(nd, ALIAS_EPI, 0.0)
|
|
83
99
|
epi_bar = media_vecinal(G, n, ALIAS_EPI, default=epi_i)
|
|
@@ -87,7 +103,14 @@ def default_compute_delta_nfr(G) -> None:
|
|
|
87
103
|
vf_bar = media_vecinal(G, n, ALIAS_VF, default=vf_i)
|
|
88
104
|
g_vf = (vf_bar - vf_i)
|
|
89
105
|
|
|
90
|
-
|
|
106
|
+
if w_topo != 0 and degs is not None:
|
|
107
|
+
deg_i = float(degs.get(n, 0))
|
|
108
|
+
deg_bar = list_mean(degs.get(v, deg_i) for v in G.neighbors(n)) if G.degree(n) else deg_i
|
|
109
|
+
g_topo = deg_bar - deg_i
|
|
110
|
+
else:
|
|
111
|
+
g_topo = 0.0
|
|
112
|
+
|
|
113
|
+
dnfr = w_phase * g_phase + w_epi * g_epi + w_vf * g_vf + w_topo * g_topo
|
|
91
114
|
_set_attr(nd, ALIAS_DNFR, dnfr)
|
|
92
115
|
|
|
93
116
|
def set_delta_nfr_hook(G, func, *, name: str | None = None, note: str | None = None) -> None:
|
|
@@ -107,9 +130,9 @@ def dnfr_phase_only(G) -> None:
|
|
|
107
130
|
nd = G.nodes[n]
|
|
108
131
|
th_i = _get_attr(nd, ALIAS_THETA, 0.0)
|
|
109
132
|
th_bar = fase_media(G, n)
|
|
110
|
-
g_phase = -
|
|
133
|
+
g_phase = -((th_i - th_bar + math.pi) % (2 * math.pi) - math.pi) / math.pi
|
|
111
134
|
_set_attr(nd, ALIAS_DNFR, g_phase)
|
|
112
|
-
_write_dnfr_metadata(G, weights={"phase":1.0
|
|
135
|
+
_write_dnfr_metadata(G, weights={"phase": 1.0}, hook_name="dnfr_phase_only", note="Hook de ejemplo.")
|
|
113
136
|
|
|
114
137
|
def dnfr_epi_vf_mixed(G) -> None:
|
|
115
138
|
"""Ejemplo: ΔNFR sin fase, mezclando EPI y νf."""
|
|
@@ -124,11 +147,40 @@ def dnfr_epi_vf_mixed(G) -> None:
|
|
|
124
147
|
_set_attr(nd, ALIAS_DNFR, 0.5*g_epi + 0.5*g_vf)
|
|
125
148
|
_write_dnfr_metadata(G, weights={"phase":0.0, "epi":0.5, "vf":0.5}, hook_name="dnfr_epi_vf_mixed", note="Hook de ejemplo.")
|
|
126
149
|
|
|
150
|
+
|
|
151
|
+
def dnfr_laplacian(G) -> None:
|
|
152
|
+
"""Gradiente topológico explícito usando Laplaciano sobre EPI y νf."""
|
|
153
|
+
wE = float(G.graph.get("DNFR_WEIGHTS", {}).get("epi", 0.33))
|
|
154
|
+
wV = float(G.graph.get("DNFR_WEIGHTS", {}).get("vf", 0.33))
|
|
155
|
+
for n in G.nodes():
|
|
156
|
+
nd = G.nodes[n]
|
|
157
|
+
epi = _get_attr(nd, ALIAS_EPI, 0.0)
|
|
158
|
+
vf = _get_attr(nd, ALIAS_VF, 0.0)
|
|
159
|
+
neigh = list(G.neighbors(n))
|
|
160
|
+
deg = len(neigh) or 1
|
|
161
|
+
epi_bar = sum(_get_attr(G.nodes[v], ALIAS_EPI, epi) for v in neigh) / deg
|
|
162
|
+
vf_bar = sum(_get_attr(G.nodes[v], ALIAS_VF, vf) for v in neigh) / deg
|
|
163
|
+
g_epi = epi_bar - epi
|
|
164
|
+
g_vf = vf_bar - vf
|
|
165
|
+
_set_attr(nd, ALIAS_DNFR, wE * g_epi + wV * g_vf)
|
|
166
|
+
_write_dnfr_metadata(
|
|
167
|
+
G,
|
|
168
|
+
weights={"epi": wE, "vf": wV},
|
|
169
|
+
hook_name="dnfr_laplacian",
|
|
170
|
+
note="Gradiente topológico",
|
|
171
|
+
)
|
|
172
|
+
|
|
127
173
|
# -------------------------
|
|
128
174
|
# Ecuación nodal
|
|
129
175
|
# -------------------------
|
|
130
176
|
|
|
131
|
-
def update_epi_via_nodal_equation(
|
|
177
|
+
def update_epi_via_nodal_equation(
|
|
178
|
+
G,
|
|
179
|
+
*,
|
|
180
|
+
dt: float = None,
|
|
181
|
+
t: float | None = None,
|
|
182
|
+
method: Literal["euler", "rk4"] | None = None,
|
|
183
|
+
) -> None:
|
|
132
184
|
"""Ecuación nodal TNFR.
|
|
133
185
|
|
|
134
186
|
Implementa la forma extendida de la ecuación nodal:
|
|
@@ -159,18 +211,50 @@ def update_epi_via_nodal_equation(G, *, dt: float = None, t: float | None = None
|
|
|
159
211
|
t = float(G.graph.get("_t", 0.0))
|
|
160
212
|
else:
|
|
161
213
|
t = float(t)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
214
|
+
|
|
215
|
+
method = (method or G.graph.get("INTEGRATOR_METHOD", DEFAULTS.get("INTEGRATOR_METHOD", "euler"))).lower()
|
|
216
|
+
dt_min = float(G.graph.get("DT_MIN", DEFAULTS.get("DT_MIN", 0.0)))
|
|
217
|
+
if dt_min > 0 and dt > dt_min:
|
|
218
|
+
steps = int(math.ceil(dt / dt_min))
|
|
219
|
+
else:
|
|
220
|
+
steps = 1
|
|
221
|
+
dt_step = dt / steps if steps else 0.0
|
|
222
|
+
|
|
223
|
+
t_local = t
|
|
224
|
+
for _ in range(steps):
|
|
225
|
+
for n in G.nodes():
|
|
226
|
+
nd = G.nodes[n]
|
|
227
|
+
vf = _get_attr(nd, ALIAS_VF, 0.0)
|
|
228
|
+
dnfr = _get_attr(nd, ALIAS_DNFR, 0.0)
|
|
229
|
+
dEPI_dt_prev = _get_attr(nd, ALIAS_dEPI, 0.0)
|
|
230
|
+
epi_i = _get_attr(nd, ALIAS_EPI, 0.0)
|
|
231
|
+
|
|
232
|
+
def _f(time: float) -> float:
|
|
233
|
+
return vf * dnfr + eval_gamma(G, n, time)
|
|
234
|
+
|
|
235
|
+
if method == "rk4":
|
|
236
|
+
k1 = _f(t_local)
|
|
237
|
+
k2 = _f(t_local + dt_step / 2.0)
|
|
238
|
+
k3 = _f(t_local + dt_step / 2.0)
|
|
239
|
+
k4 = _f(t_local + dt_step)
|
|
240
|
+
epi = epi_i + (dt_step / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
|
|
241
|
+
dEPI_dt = k4
|
|
242
|
+
else:
|
|
243
|
+
if method != "euler":
|
|
244
|
+
raise ValueError("method must be 'euler' or 'rk4'")
|
|
245
|
+
dEPI_dt = _f(t_local)
|
|
246
|
+
epi = epi_i + dt_step * dEPI_dt
|
|
247
|
+
|
|
248
|
+
epi_kind = _get_attr_str(nd, ALIAS_EPI_KIND, "")
|
|
249
|
+
_set_attr(nd, ALIAS_EPI, epi)
|
|
250
|
+
if epi_kind:
|
|
251
|
+
_set_attr_str(nd, ALIAS_EPI_KIND, epi_kind)
|
|
252
|
+
_set_attr(nd, ALIAS_dEPI, dEPI_dt)
|
|
253
|
+
_set_attr(nd, ALIAS_D2EPI, (dEPI_dt - dEPI_dt_prev) / dt_step if dt_step != 0 else 0.0)
|
|
254
|
+
|
|
255
|
+
t_local += dt_step
|
|
256
|
+
|
|
257
|
+
G.graph["_t"] = t_local
|
|
174
258
|
|
|
175
259
|
|
|
176
260
|
# -------------------------
|
|
@@ -188,7 +272,7 @@ def aplicar_dnfr_campo(G, w_theta=None, w_epi=None, w_vf=None) -> None:
|
|
|
188
272
|
|
|
189
273
|
|
|
190
274
|
def integrar_epi_euler(G, dt: float | None = None) -> None:
|
|
191
|
-
update_epi_via_nodal_equation(G, dt=dt)
|
|
275
|
+
update_epi_via_nodal_equation(G, dt=dt, method="euler")
|
|
192
276
|
|
|
193
277
|
|
|
194
278
|
def aplicar_clamps_canonicos(nd: Dict[str, Any], G=None, node=None) -> None:
|
|
@@ -326,6 +410,40 @@ def coordinar_fase_global_vecinal(G, fuerza_global: float | None = None, fuerza_
|
|
|
326
410
|
dL = ((thL - th + math.pi) % (2*math.pi) - math.pi)
|
|
327
411
|
_set_attr(nd, ALIAS_THETA, th + kG*dG + kL*dL)
|
|
328
412
|
|
|
413
|
+
# -------------------------
|
|
414
|
+
# Adaptación de νf por coherencia
|
|
415
|
+
# -------------------------
|
|
416
|
+
|
|
417
|
+
def adaptar_vf_por_coherencia(G) -> None:
|
|
418
|
+
"""Ajusta νf hacia la media vecinal en nodos con estabilidad sostenida."""
|
|
419
|
+
tau = int(G.graph.get("VF_ADAPT_TAU", DEFAULTS.get("VF_ADAPT_TAU", 5)))
|
|
420
|
+
mu = float(G.graph.get("VF_ADAPT_MU", DEFAULTS.get("VF_ADAPT_MU", 0.1)))
|
|
421
|
+
eps_dnfr = float(G.graph.get("EPS_DNFR_STABLE", DEFAULTS["EPS_DNFR_STABLE"]))
|
|
422
|
+
thr_sel = G.graph.get("SELECTOR_THRESHOLDS", DEFAULTS.get("SELECTOR_THRESHOLDS", {}))
|
|
423
|
+
thr_def = G.graph.get("GLYPH_THRESHOLDS", DEFAULTS.get("GLYPH_THRESHOLDS", {"hi": 0.66}))
|
|
424
|
+
si_hi = float(thr_sel.get("si_hi", thr_def.get("hi", 0.66)))
|
|
425
|
+
vf_min = float(G.graph.get("VF_MIN", DEFAULTS["VF_MIN"]))
|
|
426
|
+
vf_max = float(G.graph.get("VF_MAX", DEFAULTS["VF_MAX"]))
|
|
427
|
+
|
|
428
|
+
updates = {}
|
|
429
|
+
for n in G.nodes():
|
|
430
|
+
nd = G.nodes[n]
|
|
431
|
+
Si = _get_attr(nd, ALIAS_SI, 0.0)
|
|
432
|
+
dnfr = abs(_get_attr(nd, ALIAS_DNFR, 0.0))
|
|
433
|
+
if Si >= si_hi and dnfr <= eps_dnfr:
|
|
434
|
+
nd["stable_count"] = nd.get("stable_count", 0) + 1
|
|
435
|
+
else:
|
|
436
|
+
nd["stable_count"] = 0
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
if nd["stable_count"] >= tau:
|
|
440
|
+
vf = _get_attr(nd, ALIAS_VF, 0.0)
|
|
441
|
+
vf_bar = media_vecinal(G, n, ALIAS_VF, default=vf)
|
|
442
|
+
updates[n] = vf + mu * (vf_bar - vf)
|
|
443
|
+
|
|
444
|
+
for n, vf_new in updates.items():
|
|
445
|
+
_set_attr(G.nodes[n], ALIAS_VF, clamp(vf_new, vf_min, vf_max))
|
|
446
|
+
|
|
329
447
|
# -------------------------
|
|
330
448
|
# Selector glífico por defecto
|
|
331
449
|
# -------------------------
|
|
@@ -489,21 +607,45 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
|
|
|
489
607
|
# 2b) Normalizadores para selector paramétrico (por paso)
|
|
490
608
|
_norms_para_selector(G) # no molesta si luego se usa el selector por defecto
|
|
491
609
|
|
|
492
|
-
# 3) Selección glífica + aplicación
|
|
610
|
+
# 3) Selección glífica + aplicación (con lags obligatorios A’L/E’N)
|
|
493
611
|
if apply_glyphs:
|
|
494
612
|
selector = G.graph.get("glyph_selector", default_glyph_selector)
|
|
495
613
|
from .operators import aplicar_glifo
|
|
496
614
|
window = int(G.graph.get("GLYPH_HYSTERESIS_WINDOW", DEFAULTS["GLYPH_HYSTERESIS_WINDOW"]))
|
|
497
615
|
use_canon = bool(G.graph.get("GRAMMAR_CANON", DEFAULTS.get("GRAMMAR_CANON", {})).get("enabled", False))
|
|
616
|
+
|
|
617
|
+
al_max = int(G.graph.get("AL_MAX_LAG", DEFAULTS["AL_MAX_LAG"]))
|
|
618
|
+
en_max = int(G.graph.get("EN_MAX_LAG", DEFAULTS["EN_MAX_LAG"]))
|
|
619
|
+
h_al = _hist0.setdefault("since_AL", {})
|
|
620
|
+
h_en = _hist0.setdefault("since_EN", {})
|
|
621
|
+
|
|
498
622
|
for n in G.nodes():
|
|
499
|
-
|
|
500
|
-
|
|
623
|
+
h_al[n] = int(h_al.get(n, 0)) + 1
|
|
624
|
+
h_en[n] = int(h_en.get(n, 0)) + 1
|
|
625
|
+
|
|
626
|
+
if h_al[n] > al_max:
|
|
627
|
+
g = AL
|
|
628
|
+
elif h_en[n] > en_max:
|
|
629
|
+
g = EN
|
|
501
630
|
else:
|
|
502
631
|
g = selector(G, n)
|
|
503
|
-
|
|
632
|
+
if use_canon:
|
|
633
|
+
g = enforce_canonical_grammar(G, n, g)
|
|
634
|
+
|
|
635
|
+
aplicar_glifo(G, n, g, window=window)
|
|
636
|
+
if use_canon:
|
|
637
|
+
on_applied_glifo(G, n, g)
|
|
638
|
+
|
|
639
|
+
if g == AL:
|
|
640
|
+
h_al[n] = 0
|
|
641
|
+
h_en[n] = min(h_en[n], en_max)
|
|
642
|
+
elif g == EN:
|
|
643
|
+
h_en[n] = 0
|
|
504
644
|
|
|
505
645
|
# 4) Ecuación nodal
|
|
506
|
-
|
|
646
|
+
_dt = float(G.graph.get("DT", DEFAULTS["DT"])) if dt is None else float(dt)
|
|
647
|
+
method = G.graph.get("INTEGRATOR_METHOD", DEFAULTS.get("INTEGRATOR_METHOD", "euler"))
|
|
648
|
+
update_epi_via_nodal_equation(G, dt=_dt, method=method)
|
|
507
649
|
|
|
508
650
|
# 5) Clamps
|
|
509
651
|
for n in G.nodes():
|
|
@@ -512,6 +654,9 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
|
|
|
512
654
|
# 6) Coordinación de fase
|
|
513
655
|
coordinar_fase_global_vecinal(G, None, None)
|
|
514
656
|
|
|
657
|
+
# 6b) Adaptación de νf por coherencia
|
|
658
|
+
adaptar_vf_por_coherencia(G)
|
|
659
|
+
|
|
515
660
|
# 7) Observadores ligeros
|
|
516
661
|
_update_history(G)
|
|
517
662
|
# dynamics.py — dentro de step(), justo antes del punto 8)
|
|
@@ -528,6 +673,10 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
|
|
|
528
673
|
# 8) RE’MESH condicionado
|
|
529
674
|
aplicar_remesh_si_estabilizacion_global(G)
|
|
530
675
|
|
|
676
|
+
# 8b) Validadores de invariantes
|
|
677
|
+
from .validators import run_validators
|
|
678
|
+
run_validators(G)
|
|
679
|
+
|
|
531
680
|
# Contexto final (últimas métricas del paso)
|
|
532
681
|
h = G.graph.get("history", {})
|
|
533
682
|
ctx = {"step": step_idx}
|
tnfr/gamma.py
CHANGED
|
@@ -92,11 +92,28 @@ def gamma_kuramoto_tanh(G, node, t, cfg: Dict[str, Any]) -> float:
|
|
|
92
92
|
return beta * math.tanh(k * (R - R0)) * math.cos(th_i - psi)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def gamma_harmonic(G, node, t, cfg: Dict[str, Any]) -> float:
|
|
96
|
+
"""Forzamiento armónico coherente con el campo global de fase.
|
|
97
|
+
|
|
98
|
+
Fórmula: Γ = β · sin(ω·t + φ) · cos(θ_i - ψ)
|
|
99
|
+
- β: ganancia del acoplamiento
|
|
100
|
+
- ω: frecuencia angular del forzante
|
|
101
|
+
- φ: fase inicial del forzante
|
|
102
|
+
"""
|
|
103
|
+
beta = float(cfg.get("beta", 0.0))
|
|
104
|
+
omega = float(cfg.get("omega", 1.0))
|
|
105
|
+
phi = float(cfg.get("phi", 0.0))
|
|
106
|
+
R, psi = kuramoto_R_psi(G)
|
|
107
|
+
th = _get_attr(G.nodes[node], ALIAS_THETA, 0.0)
|
|
108
|
+
return beta * math.sin(omega * t + phi) * math.cos(th - psi)
|
|
109
|
+
|
|
110
|
+
|
|
95
111
|
GAMMA_REGISTRY = {
|
|
96
112
|
"none": gamma_none,
|
|
97
113
|
"kuramoto_linear": gamma_kuramoto_linear,
|
|
98
114
|
"kuramoto_bandpass": gamma_kuramoto_bandpass,
|
|
99
115
|
"kuramoto_tanh": gamma_kuramoto_tanh,
|
|
116
|
+
"harmonic": gamma_harmonic,
|
|
100
117
|
}
|
|
101
118
|
|
|
102
119
|
|
tnfr/helpers.py
CHANGED
|
@@ -14,7 +14,7 @@ try:
|
|
|
14
14
|
except Exception: # pragma: no cover
|
|
15
15
|
nx = None # type: ignore
|
|
16
16
|
|
|
17
|
-
from .constants import DEFAULTS, ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI
|
|
17
|
+
from .constants import DEFAULTS, ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI, ALIAS_EPI_KIND
|
|
18
18
|
|
|
19
19
|
# -------------------------
|
|
20
20
|
# Utilidades numéricas
|
|
@@ -76,6 +76,24 @@ def _set_attr(d, aliases, value: float) -> None:
|
|
|
76
76
|
return
|
|
77
77
|
d[next(iter(aliases))] = float(value)
|
|
78
78
|
|
|
79
|
+
|
|
80
|
+
def _get_attr_str(d: Dict[str, Any], aliases: Iterable[str], default: str = "") -> str:
|
|
81
|
+
for k in aliases:
|
|
82
|
+
if k in d:
|
|
83
|
+
try:
|
|
84
|
+
return str(d[k])
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
return str(default)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _set_attr_str(d, aliases, value: str) -> None:
|
|
91
|
+
for k in aliases:
|
|
92
|
+
if k in d:
|
|
93
|
+
d[k] = str(value)
|
|
94
|
+
return
|
|
95
|
+
d[next(iter(aliases))] = str(value)
|
|
96
|
+
|
|
79
97
|
# -------------------------
|
|
80
98
|
# Estadísticos vecinales
|
|
81
99
|
# -------------------------
|
|
@@ -111,18 +129,14 @@ def push_glifo(nd: Dict[str, Any], glifo: str, window: int) -> None:
|
|
|
111
129
|
|
|
112
130
|
|
|
113
131
|
def reciente_glifo(nd: Dict[str, Any], glifo: str, ventana: int) -> bool:
|
|
114
|
-
"""Indica si ``glifo`` apareció en las últimas ``ventana`` emisiones
|
|
132
|
+
"""Indica si ``glifo`` apareció en las últimas ``ventana`` emisiones"""
|
|
115
133
|
hist = nd.get("hist_glifos")
|
|
116
|
-
if not hist:
|
|
117
|
-
return False
|
|
118
134
|
gl = str(glifo)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
break
|
|
125
|
-
return False
|
|
135
|
+
from itertools import islice
|
|
136
|
+
if hist and any(g == gl for g in islice(reversed(hist), ventana)):
|
|
137
|
+
return True
|
|
138
|
+
# fallback al glifo dominante actual
|
|
139
|
+
return _get_attr_str(nd, ALIAS_EPI_KIND, "") == gl
|
|
126
140
|
|
|
127
141
|
# -------------------------
|
|
128
142
|
# Utilidades de historial global
|
|
@@ -130,18 +144,19 @@ def reciente_glifo(nd: Dict[str, Any], glifo: str, ventana: int) -> bool:
|
|
|
130
144
|
|
|
131
145
|
def ensure_history(G) -> Dict[str, Any]:
|
|
132
146
|
"""Garantiza G.graph['history'] y la devuelve."""
|
|
133
|
-
|
|
134
|
-
G.graph["history"] = {}
|
|
135
|
-
return G.graph["history"]
|
|
147
|
+
return G.graph.setdefault("history", {})
|
|
136
148
|
|
|
137
149
|
|
|
138
150
|
def last_glifo(nd: Dict[str, Any]) -> str | None:
|
|
139
151
|
"""Retorna el glifo más reciente del nodo o ``None``."""
|
|
152
|
+
kind = _get_attr_str(nd, ALIAS_EPI_KIND, "")
|
|
153
|
+
if kind:
|
|
154
|
+
return kind
|
|
140
155
|
hist = nd.get("hist_glifos")
|
|
141
156
|
if not hist:
|
|
142
157
|
return None
|
|
143
158
|
try:
|
|
144
|
-
return
|
|
159
|
+
return hist[-1]
|
|
145
160
|
except Exception:
|
|
146
161
|
return None
|
|
147
162
|
|
|
@@ -223,12 +238,9 @@ def compute_Si(G, *, inplace: bool = True) -> Dict[Any, float]:
|
|
|
223
238
|
G.graph["_Si_weights"] = {"alpha": alpha, "beta": beta, "gamma": gamma}
|
|
224
239
|
G.graph["_Si_sensitivity"] = {"dSi_dvf_norm": alpha, "dSi_ddisp_fase": -beta, "dSi_ddnfr_norm": -gamma}
|
|
225
240
|
|
|
226
|
-
# Normalización de νf en red
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
# Normalización de ΔNFR
|
|
230
|
-
dnfrs = [abs(_get_attr(G.nodes[n], ALIAS_DNFR, 0.0)) for n in G.nodes()]
|
|
231
|
-
dnfrmax = max(dnfrs) if dnfrs else 1.0
|
|
241
|
+
# Normalización de νf y ΔNFR en red
|
|
242
|
+
vfmax = max((abs(_get_attr(G.nodes[n], ALIAS_VF, 0.0)) for n in G.nodes()), default=1.0)
|
|
243
|
+
dnfrmax = max((abs(_get_attr(G.nodes[n], ALIAS_DNFR, 0.0)) for n in G.nodes()), default=1.0)
|
|
232
244
|
|
|
233
245
|
out: Dict[Any, float] = {}
|
|
234
246
|
for n in G.nodes():
|