tnfr 4.1.0__py3-none-any.whl → 4.5.0__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 +34 -4
- tnfr/cli.py +138 -9
- tnfr/config.py +41 -0
- tnfr/constants.py +102 -41
- tnfr/dynamics.py +255 -49
- tnfr/gamma.py +35 -8
- tnfr/helpers.py +50 -17
- tnfr/metrics.py +416 -30
- tnfr/node.py +202 -0
- tnfr/operators.py +341 -146
- tnfr/presets.py +3 -0
- tnfr/scenarios.py +9 -3
- tnfr/sense.py +6 -21
- tnfr/structural.py +201 -0
- tnfr/trace.py +4 -20
- tnfr/types.py +2 -1
- tnfr/validators.py +38 -0
- {tnfr-4.1.0.dist-info → tnfr-4.5.0.dist-info}/METADATA +10 -4
- tnfr-4.5.0.dist-info/RECORD +28 -0
- tnfr-4.1.0.dist-info/RECORD +0 -24
- {tnfr-4.1.0.dist-info → tnfr-4.5.0.dist-info}/WHEEL +0 -0
- {tnfr-4.1.0.dist-info → tnfr-4.5.0.dist-info}/entry_points.txt +0 -0
- {tnfr-4.1.0.dist-info → tnfr-4.5.0.dist-info}/licenses/LICENSE.md +0 -0
- {tnfr-4.1.0.dist-info → tnfr-4.5.0.dist-info}/top_level.txt +0 -0
tnfr/metrics.py
CHANGED
|
@@ -2,9 +2,20 @@ from __future__ import annotations
|
|
|
2
2
|
from typing import Dict, Any, List, Tuple
|
|
3
3
|
from collections import defaultdict, Counter
|
|
4
4
|
import statistics
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
from
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from math import cos
|
|
8
|
+
|
|
9
|
+
from .constants import DEFAULTS, ALIAS_EPI, ALIAS_THETA, ALIAS_DNFR
|
|
10
|
+
from .helpers import (
|
|
11
|
+
register_callback,
|
|
12
|
+
ensure_history,
|
|
13
|
+
last_glifo,
|
|
14
|
+
_get_attr,
|
|
15
|
+
clamp01,
|
|
16
|
+
list_mean,
|
|
17
|
+
fmean,
|
|
18
|
+
)
|
|
8
19
|
from .sense import GLYPHS_CANONICAL
|
|
9
20
|
|
|
10
21
|
# -------------
|
|
@@ -16,25 +27,12 @@ DEFAULTS.setdefault("METRICS", {
|
|
|
16
27
|
"normalize_series": False # glifograma normalizado a fracción por paso
|
|
17
28
|
})
|
|
18
29
|
|
|
30
|
+
|
|
31
|
+
|
|
19
32
|
# -------------
|
|
20
33
|
# Utilidades internas
|
|
21
34
|
# -------------
|
|
22
35
|
|
|
23
|
-
def _ensure_history(G):
|
|
24
|
-
if "history" not in G.graph:
|
|
25
|
-
G.graph["history"] = {}
|
|
26
|
-
return G.graph["history"]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _last_glifo(nd: Dict[str, Any]) -> str | None:
|
|
30
|
-
hist = nd.get("hist_glifos")
|
|
31
|
-
if not hist:
|
|
32
|
-
return None
|
|
33
|
-
try:
|
|
34
|
-
return list(hist)[-1]
|
|
35
|
-
except Exception:
|
|
36
|
-
return None
|
|
37
|
-
|
|
38
36
|
|
|
39
37
|
# -------------
|
|
40
38
|
# Estado nodal para Tg
|
|
@@ -44,10 +42,7 @@ def _tg_state(nd: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
44
42
|
"""Estructura interna por nodo para acumular tiempos de corrida por glifo.
|
|
45
43
|
Campos: curr (glifo actual), run (tiempo acumulado en el glifo actual)
|
|
46
44
|
"""
|
|
47
|
-
|
|
48
|
-
st.setdefault("curr", None)
|
|
49
|
-
st.setdefault("run", 0.0)
|
|
50
|
-
return st
|
|
45
|
+
return nd.setdefault("_Tg", {"curr": None, "run": 0.0})
|
|
51
46
|
|
|
52
47
|
|
|
53
48
|
# -------------
|
|
@@ -66,7 +61,7 @@ def _metrics_step(G, *args, **kwargs):
|
|
|
66
61
|
if not G.graph.get("METRICS", DEFAULTS.get("METRICS", {})).get("enabled", True):
|
|
67
62
|
return
|
|
68
63
|
|
|
69
|
-
hist =
|
|
64
|
+
hist = ensure_history(G)
|
|
70
65
|
dt = float(G.graph.get("DT", 1.0))
|
|
71
66
|
t = float(G.graph.get("_t", 0.0))
|
|
72
67
|
|
|
@@ -84,7 +79,7 @@ def _metrics_step(G, *args, **kwargs):
|
|
|
84
79
|
|
|
85
80
|
for n in G.nodes():
|
|
86
81
|
nd = G.nodes[n]
|
|
87
|
-
g =
|
|
82
|
+
g = last_glifo(nd)
|
|
88
83
|
if not g:
|
|
89
84
|
continue
|
|
90
85
|
|
|
@@ -128,6 +123,23 @@ def _metrics_step(G, *args, **kwargs):
|
|
|
128
123
|
li = (n_latent / max(1, n_total)) if n_total else 0.0
|
|
129
124
|
hist.setdefault("latency_index", []).append({"t": t, "value": li})
|
|
130
125
|
|
|
126
|
+
# --- Soporte y norma de la EPI ---
|
|
127
|
+
thr = float(G.graph.get("EPI_SUPPORT_THR", DEFAULTS.get("EPI_SUPPORT_THR", 0.0)))
|
|
128
|
+
supp_nodes = [n for n in G.nodes() if abs(_get_attr(G.nodes[n], ALIAS_EPI, 0.0)) >= thr]
|
|
129
|
+
norm = (
|
|
130
|
+
sum(abs(_get_attr(G.nodes[n], ALIAS_EPI, 0.0)) for n in supp_nodes) / len(supp_nodes)
|
|
131
|
+
if supp_nodes else 0.0
|
|
132
|
+
)
|
|
133
|
+
hist.setdefault("EPI_support", []).append({"t": t, "size": len(supp_nodes), "norm": float(norm)})
|
|
134
|
+
|
|
135
|
+
# --- Métricas morfosintácticas ---
|
|
136
|
+
total = max(1, sum(counts.values()))
|
|
137
|
+
id_val = counts.get("O’Z", 0) / total
|
|
138
|
+
cm_val = (counts.get("Z’HIR", 0) + counts.get("NA’V", 0)) / total
|
|
139
|
+
ne_val = (counts.get("I’L", 0) + counts.get("T’HOL", 0)) / total
|
|
140
|
+
pp_val = counts.get("SH’A", 0) / max(1, counts.get("RE’MESH", 0))
|
|
141
|
+
hist.setdefault("morph", []).append({"t": t, "ID": id_val, "CM": cm_val, "NE": ne_val, "PP": pp_val})
|
|
142
|
+
|
|
131
143
|
|
|
132
144
|
# -------------
|
|
133
145
|
# Registro del callback
|
|
@@ -135,6 +147,9 @@ def _metrics_step(G, *args, **kwargs):
|
|
|
135
147
|
|
|
136
148
|
def register_metrics_callbacks(G) -> None:
|
|
137
149
|
register_callback(G, when="after_step", func=_metrics_step, name="metrics_step")
|
|
150
|
+
# Nuevas funcionalidades canónicas
|
|
151
|
+
register_coherence_callbacks(G)
|
|
152
|
+
register_diagnosis_callbacks(G)
|
|
138
153
|
|
|
139
154
|
|
|
140
155
|
# -------------
|
|
@@ -143,7 +158,7 @@ def register_metrics_callbacks(G) -> None:
|
|
|
143
158
|
|
|
144
159
|
def Tg_global(G, normalize: bool = True) -> Dict[str, float]:
|
|
145
160
|
"""Tiempo glífico total por clase. Si normalize=True, devuelve fracciones del total."""
|
|
146
|
-
hist =
|
|
161
|
+
hist = ensure_history(G)
|
|
147
162
|
tg_total: Dict[str, float] = hist.get("Tg_total", {})
|
|
148
163
|
total = sum(tg_total.values()) or 1.0
|
|
149
164
|
if normalize:
|
|
@@ -153,7 +168,7 @@ def Tg_global(G, normalize: bool = True) -> Dict[str, float]:
|
|
|
153
168
|
|
|
154
169
|
def Tg_by_node(G, n, normalize: bool = False) -> Dict[str, float | List[float]]:
|
|
155
170
|
"""Resumen por nodo: si normalize, devuelve medias por glifo; si no, lista de corridas."""
|
|
156
|
-
hist =
|
|
171
|
+
hist = ensure_history(G)
|
|
157
172
|
rec = hist.get("Tg_by_node", {}).get(n, {})
|
|
158
173
|
if not normalize:
|
|
159
174
|
# convertir default dict → list para serializar
|
|
@@ -167,7 +182,7 @@ def Tg_by_node(G, n, normalize: bool = False) -> Dict[str, float | List[float]]:
|
|
|
167
182
|
|
|
168
183
|
|
|
169
184
|
def latency_series(G) -> Dict[str, List[float]]:
|
|
170
|
-
hist =
|
|
185
|
+
hist = ensure_history(G)
|
|
171
186
|
xs = hist.get("latency_index", [])
|
|
172
187
|
return {
|
|
173
188
|
"t": [float(x.get("t", i)) for i, x in enumerate(xs)],
|
|
@@ -176,7 +191,7 @@ def latency_series(G) -> Dict[str, List[float]]:
|
|
|
176
191
|
|
|
177
192
|
|
|
178
193
|
def glifogram_series(G) -> Dict[str, List[float]]:
|
|
179
|
-
hist =
|
|
194
|
+
hist = ensure_history(G)
|
|
180
195
|
xs = hist.get("glifogram", [])
|
|
181
196
|
if not xs:
|
|
182
197
|
return {"t": []}
|
|
@@ -187,14 +202,14 @@ def glifogram_series(G) -> Dict[str, List[float]]:
|
|
|
187
202
|
|
|
188
203
|
|
|
189
204
|
def glyph_top(G, k: int = 3) -> List[Tuple[str, float]]:
|
|
190
|
-
"""Top-k
|
|
205
|
+
"""Top-k operadores estructurales por Tg_global (fracción)."""
|
|
191
206
|
tg = Tg_global(G, normalize=True)
|
|
192
207
|
return sorted(tg.items(), key=lambda kv: kv[1], reverse=True)[:max(1, int(k))]
|
|
193
208
|
|
|
194
209
|
|
|
195
210
|
def glyph_dwell_stats(G, n) -> Dict[str, Dict[str, float]]:
|
|
196
211
|
"""Estadísticos por nodo: mean/median/max de corridas por glifo."""
|
|
197
|
-
hist =
|
|
212
|
+
hist = ensure_history(G)
|
|
198
213
|
rec = hist.get("Tg_by_node", {}).get(n, {})
|
|
199
214
|
out = {}
|
|
200
215
|
for g in GLYPHS_CANONICAL:
|
|
@@ -209,3 +224,374 @@ def glyph_dwell_stats(G, n) -> Dict[str, Dict[str, float]]:
|
|
|
209
224
|
"count": int(len(runs)),
|
|
210
225
|
}
|
|
211
226
|
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# -----------------------------
|
|
230
|
+
# Export history to CSV/JSON
|
|
231
|
+
# -----------------------------
|
|
232
|
+
|
|
233
|
+
def export_history(G, base_path: str, fmt: str = "csv") -> None:
|
|
234
|
+
"""Vuelca glifograma y traza σ(t) a archivos CSV o JSON compactos."""
|
|
235
|
+
hist = ensure_history(G)
|
|
236
|
+
glifo = glifogram_series(G)
|
|
237
|
+
sigma_mag = hist.get("sense_sigma_mag", [])
|
|
238
|
+
sigma = {
|
|
239
|
+
"t": list(range(len(sigma_mag))),
|
|
240
|
+
"sigma_x": hist.get("sense_sigma_x", []),
|
|
241
|
+
"sigma_y": hist.get("sense_sigma_y", []),
|
|
242
|
+
"mag": sigma_mag,
|
|
243
|
+
"angle": hist.get("sense_sigma_angle", []),
|
|
244
|
+
}
|
|
245
|
+
morph = hist.get("morph", [])
|
|
246
|
+
epi_supp = hist.get("EPI_support", [])
|
|
247
|
+
fmt = fmt.lower()
|
|
248
|
+
if fmt == "csv":
|
|
249
|
+
with open(base_path + "_glifogram.csv", "w", newline="") as f:
|
|
250
|
+
writer = csv.writer(f)
|
|
251
|
+
writer.writerow(["t", *GLYPHS_CANONICAL])
|
|
252
|
+
ts = glifo.get("t", [])
|
|
253
|
+
default_col = [0] * len(ts)
|
|
254
|
+
for i, t in enumerate(ts):
|
|
255
|
+
row = [t] + [glifo.get(g, default_col)[i] for g in GLYPHS_CANONICAL]
|
|
256
|
+
writer.writerow(row)
|
|
257
|
+
with open(base_path + "_sigma.csv", "w", newline="") as f:
|
|
258
|
+
writer = csv.writer(f)
|
|
259
|
+
writer.writerow(["t", "x", "y", "mag", "angle"])
|
|
260
|
+
for i, t in enumerate(sigma["t"]):
|
|
261
|
+
writer.writerow([t, sigma["sigma_x"][i], sigma["sigma_y"][i], sigma["mag"][i], sigma["angle"][i]])
|
|
262
|
+
if morph:
|
|
263
|
+
with open(base_path + "_morph.csv", "w", newline="") as f:
|
|
264
|
+
writer = csv.writer(f)
|
|
265
|
+
writer.writerow(["t", "ID", "CM", "NE", "PP"])
|
|
266
|
+
for row in morph:
|
|
267
|
+
writer.writerow([row.get("t"), row.get("ID"), row.get("CM"), row.get("NE"), row.get("PP")])
|
|
268
|
+
if epi_supp:
|
|
269
|
+
with open(base_path + "_epi_support.csv", "w", newline="") as f:
|
|
270
|
+
writer = csv.writer(f)
|
|
271
|
+
writer.writerow(["t", "size", "norm"])
|
|
272
|
+
for row in epi_supp:
|
|
273
|
+
writer.writerow([row.get("t"), row.get("size"), row.get("norm")])
|
|
274
|
+
else:
|
|
275
|
+
data = {"glifogram": glifo, "sigma": sigma, "morph": morph, "epi_support": epi_supp}
|
|
276
|
+
with open(base_path + ".json", "w") as f:
|
|
277
|
+
json.dump(data, f)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# =========================
|
|
281
|
+
# COHERENCIA W_ij^t (TNFR)
|
|
282
|
+
# =========================
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _norm01(x, lo, hi):
|
|
286
|
+
if hi <= lo:
|
|
287
|
+
return 0.0
|
|
288
|
+
v = (float(x) - float(lo)) / (float(hi) - float(lo))
|
|
289
|
+
return 0.0 if v < 0 else (1.0 if v > 1.0 else v)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _similarity_abs(a, b, lo, hi):
|
|
293
|
+
return 1.0 - _norm01(abs(float(a) - float(b)), 0.0, float(hi - lo) if hi > lo else 1.0)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _coherence_components(G, ni, nj, epi_min, epi_max, vf_min, vf_max):
|
|
297
|
+
ndi = G.nodes[ni]
|
|
298
|
+
ndj = G.nodes[nj]
|
|
299
|
+
th_i = _get_attr(ndi, ALIAS_THETA, 0.0)
|
|
300
|
+
th_j = _get_attr(ndj, ALIAS_THETA, 0.0)
|
|
301
|
+
s_phase = 0.5 * (1.0 + cos(th_i - th_j))
|
|
302
|
+
epi_i = _get_attr(ndi, ALIAS_EPI, 0.0)
|
|
303
|
+
epi_j = _get_attr(ndj, ALIAS_EPI, 0.0)
|
|
304
|
+
s_epi = _similarity_abs(epi_i, epi_j, epi_min, epi_max)
|
|
305
|
+
vf_i = float(_get_attr(ndi, "νf", 0.0))
|
|
306
|
+
vf_j = float(_get_attr(ndj, "νf", 0.0))
|
|
307
|
+
s_vf = _similarity_abs(vf_i, vf_j, vf_min, vf_max)
|
|
308
|
+
si_i = clamp01(float(_get_attr(ndi, "Si", 0.0)))
|
|
309
|
+
si_j = clamp01(float(_get_attr(ndj, "Si", 0.0)))
|
|
310
|
+
s_si = 1.0 - abs(si_i - si_j)
|
|
311
|
+
return s_phase, s_epi, s_vf, s_si
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def coherence_matrix(G):
|
|
315
|
+
cfg = G.graph.get("COHERENCE", DEFAULTS["COHERENCE"])
|
|
316
|
+
if not cfg.get("enabled", True):
|
|
317
|
+
return None, None
|
|
318
|
+
|
|
319
|
+
nodes = list(G.nodes())
|
|
320
|
+
n = len(nodes)
|
|
321
|
+
if n == 0:
|
|
322
|
+
return nodes, []
|
|
323
|
+
|
|
324
|
+
# Precompute indices to avoid repeated list.index calls within loops
|
|
325
|
+
node_to_index = {node: idx for idx, node in enumerate(nodes)}
|
|
326
|
+
|
|
327
|
+
epi_vals = [float(_get_attr(G.nodes[v], ALIAS_EPI, 0.0)) for v in nodes]
|
|
328
|
+
vf_vals = [float(_get_attr(G.nodes[v], "νf", 0.0)) for v in nodes]
|
|
329
|
+
epi_min, epi_max = min(epi_vals), max(epi_vals)
|
|
330
|
+
vf_min, vf_max = min(vf_vals), max(vf_vals)
|
|
331
|
+
|
|
332
|
+
wdict = dict(cfg.get("weights", {}))
|
|
333
|
+
for k in ("phase", "epi", "vf", "si"):
|
|
334
|
+
wdict.setdefault(k, 0.0)
|
|
335
|
+
wsum = sum(float(v) for v in wdict.values()) or 1.0
|
|
336
|
+
wnorm = {k: float(v) / wsum for k, v in wdict.items()}
|
|
337
|
+
|
|
338
|
+
scope = str(cfg.get("scope", "neighbors")).lower()
|
|
339
|
+
neighbors_only = scope != "all"
|
|
340
|
+
self_diag = bool(cfg.get("self_on_diag", True))
|
|
341
|
+
mode = str(cfg.get("store_mode", "sparse")).lower()
|
|
342
|
+
thr = float(cfg.get("threshold", 0.0))
|
|
343
|
+
if mode not in ("sparse", "dense"):
|
|
344
|
+
mode = "sparse"
|
|
345
|
+
|
|
346
|
+
if mode == "dense":
|
|
347
|
+
W = [[0.0] * n for _ in range(n)]
|
|
348
|
+
else:
|
|
349
|
+
W = []
|
|
350
|
+
|
|
351
|
+
row_sum = [0.0] * n
|
|
352
|
+
row_count = [0] * n
|
|
353
|
+
|
|
354
|
+
for i, ni in enumerate(nodes):
|
|
355
|
+
if self_diag:
|
|
356
|
+
if mode == "dense":
|
|
357
|
+
W[i][i] = 1.0
|
|
358
|
+
else:
|
|
359
|
+
W.append((i, i, 1.0))
|
|
360
|
+
row_sum[i] += 1.0
|
|
361
|
+
row_count[i] += 1
|
|
362
|
+
|
|
363
|
+
neighs = G.neighbors(ni) if neighbors_only else nodes
|
|
364
|
+
for nj in neighs:
|
|
365
|
+
if nj == ni:
|
|
366
|
+
continue
|
|
367
|
+
j = node_to_index[nj]
|
|
368
|
+
s_phase, s_epi, s_vf, s_si = _coherence_components(
|
|
369
|
+
G, ni, nj, epi_min, epi_max, vf_min, vf_max
|
|
370
|
+
)
|
|
371
|
+
wij = (
|
|
372
|
+
wnorm["phase"] * s_phase
|
|
373
|
+
+ wnorm["epi"] * s_epi
|
|
374
|
+
+ wnorm["vf"] * s_vf
|
|
375
|
+
+ wnorm["si"] * s_si
|
|
376
|
+
)
|
|
377
|
+
wij = clamp01(wij)
|
|
378
|
+
if mode == "dense":
|
|
379
|
+
W[i][j] = wij
|
|
380
|
+
else:
|
|
381
|
+
if wij >= thr:
|
|
382
|
+
W.append((i, j, wij))
|
|
383
|
+
row_sum[i] += wij
|
|
384
|
+
row_count[i] += 1
|
|
385
|
+
|
|
386
|
+
Wi = [row_sum[i] / max(1, row_count[i]) for i in range(n)]
|
|
387
|
+
vals = []
|
|
388
|
+
if mode == "dense":
|
|
389
|
+
for i in range(n):
|
|
390
|
+
for j in range(n):
|
|
391
|
+
if i == j:
|
|
392
|
+
continue
|
|
393
|
+
vals.append(W[i][j])
|
|
394
|
+
else:
|
|
395
|
+
for (i, j, w) in W:
|
|
396
|
+
if i == j:
|
|
397
|
+
continue
|
|
398
|
+
vals.append(w)
|
|
399
|
+
|
|
400
|
+
stats = {
|
|
401
|
+
"min": min(vals) if vals else 0.0,
|
|
402
|
+
"max": max(vals) if vals else 0.0,
|
|
403
|
+
"mean": (sum(vals) / len(vals)) if vals else 0.0,
|
|
404
|
+
"n_edges": len(vals),
|
|
405
|
+
"mode": mode,
|
|
406
|
+
"scope": scope,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
hist = ensure_history(G)
|
|
410
|
+
hist.setdefault(cfg.get("history_key", "W_sparse"), []).append(W)
|
|
411
|
+
hist.setdefault(cfg.get("Wi_history_key", "W_i"), []).append(Wi)
|
|
412
|
+
hist.setdefault(cfg.get("stats_history_key", "W_stats"), []).append(stats)
|
|
413
|
+
|
|
414
|
+
return nodes, W
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def local_phase_sync_weighted(G, n, nodes_order=None, W_row=None):
|
|
418
|
+
import cmath
|
|
419
|
+
|
|
420
|
+
cfg = G.graph.get("COHERENCE", DEFAULTS["COHERENCE"])
|
|
421
|
+
scope = str(cfg.get("scope", "neighbors")).lower()
|
|
422
|
+
neighbors_only = scope != "all"
|
|
423
|
+
|
|
424
|
+
if W_row is None or nodes_order is None:
|
|
425
|
+
vec = [
|
|
426
|
+
cmath.exp(1j * float(_get_attr(G.nodes[v], ALIAS_THETA, 0.0)))
|
|
427
|
+
for v in (G.neighbors(n) if neighbors_only else (set(G.nodes()) - {n}))
|
|
428
|
+
]
|
|
429
|
+
if not vec:
|
|
430
|
+
return 0.0
|
|
431
|
+
mean = sum(vec) / len(vec)
|
|
432
|
+
return abs(mean)
|
|
433
|
+
|
|
434
|
+
i = nodes_order.index(n)
|
|
435
|
+
if isinstance(W_row, list) and W_row and isinstance(W_row[0], (int, float)):
|
|
436
|
+
weights = W_row
|
|
437
|
+
else:
|
|
438
|
+
weights = [0.0] * len(nodes_order)
|
|
439
|
+
for (ii, jj, w) in W_row:
|
|
440
|
+
if ii == i:
|
|
441
|
+
weights[jj] = w
|
|
442
|
+
|
|
443
|
+
num = 0 + 0j
|
|
444
|
+
den = 0.0
|
|
445
|
+
for j, nj in enumerate(nodes_order):
|
|
446
|
+
if nj == n:
|
|
447
|
+
continue
|
|
448
|
+
w = weights[j]
|
|
449
|
+
den += w
|
|
450
|
+
th_j = float(_get_attr(G.nodes[nj], ALIAS_THETA, 0.0))
|
|
451
|
+
num += w * cmath.exp(1j * th_j)
|
|
452
|
+
return abs(num / den) if den else 0.0
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _coherence_step(G, ctx=None):
|
|
456
|
+
if not G.graph.get("COHERENCE", DEFAULTS["COHERENCE"]).get("enabled", True):
|
|
457
|
+
return
|
|
458
|
+
coherence_matrix(G)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def register_coherence_callbacks(G) -> None:
|
|
462
|
+
register_callback(G, when="after_step", func=_coherence_step, name="coherence_step")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# =========================
|
|
466
|
+
# DIAGNÓSTICO NODAL (TNFR)
|
|
467
|
+
# =========================
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _dnfr_norm(nd, dnfr_max):
|
|
471
|
+
val = abs(float(_get_attr(nd, ALIAS_DNFR, 0.0)))
|
|
472
|
+
if dnfr_max <= 0:
|
|
473
|
+
return 0.0
|
|
474
|
+
x = val / dnfr_max
|
|
475
|
+
return 1.0 if x > 1 else x
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _symmetry_index(G, n, k=3, epi_min=None, epi_max=None):
|
|
479
|
+
nd = G.nodes[n]
|
|
480
|
+
epi_i = float(_get_attr(nd, ALIAS_EPI, 0.0))
|
|
481
|
+
vec = list(G.neighbors(n))
|
|
482
|
+
if not vec:
|
|
483
|
+
return 1.0
|
|
484
|
+
epi_bar = fmean(float(_get_attr(G.nodes[v], ALIAS_EPI, epi_i)) for v in vec)
|
|
485
|
+
if epi_min is None or epi_max is None:
|
|
486
|
+
epis = [float(_get_attr(G.nodes[v], ALIAS_EPI, 0.0)) for v in G.nodes()]
|
|
487
|
+
epi_min, epi_max = min(epis), max(epis)
|
|
488
|
+
return _similarity_abs(epi_i, epi_bar, epi_min, epi_max)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _state_from_thresholds(Rloc, dnfr_n, cfg):
|
|
492
|
+
stb = cfg.get("stable", {"Rloc_hi": 0.8, "dnfr_lo": 0.2, "persist": 3})
|
|
493
|
+
dsr = cfg.get("dissonance", {"Rloc_lo": 0.4, "dnfr_hi": 0.5, "persist": 3})
|
|
494
|
+
if (Rloc >= float(stb["Rloc_hi"])) and (dnfr_n <= float(stb["dnfr_lo"])):
|
|
495
|
+
return "estable"
|
|
496
|
+
if (Rloc <= float(dsr["Rloc_lo"])) and (dnfr_n >= float(dsr["dnfr_hi"])):
|
|
497
|
+
return "disonante"
|
|
498
|
+
return "transicion"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _recommendation(state, cfg):
|
|
502
|
+
adv = cfg.get("advice", {})
|
|
503
|
+
key = {"estable": "stable", "transicion": "transition", "disonante": "dissonant"}[state]
|
|
504
|
+
return list(adv.get(key, []))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _diagnosis_step(G, ctx=None):
|
|
508
|
+
dcfg = G.graph.get("DIAGNOSIS", DEFAULTS["DIAGNOSIS"])
|
|
509
|
+
if not dcfg.get("enabled", True):
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
hist = ensure_history(G)
|
|
513
|
+
key = dcfg.get("history_key", "nodal_diag")
|
|
514
|
+
|
|
515
|
+
dnfr_vals = [abs(float(_get_attr(G.nodes[v], ALIAS_DNFR, 0.0))) for v in G.nodes()]
|
|
516
|
+
dnfr_max = max(dnfr_vals) if dnfr_vals else 1.0
|
|
517
|
+
epi_vals = [float(_get_attr(G.nodes[v], ALIAS_EPI, 0.0)) for v in G.nodes()]
|
|
518
|
+
epi_min, epi_max = (min(epi_vals) if epi_vals else 0.0), (max(epi_vals) if epi_vals else 1.0)
|
|
519
|
+
|
|
520
|
+
CfgW = G.graph.get("COHERENCE", DEFAULTS["COHERENCE"])
|
|
521
|
+
Wkey = CfgW.get("Wi_history_key", "W_i")
|
|
522
|
+
Wm_key = CfgW.get("history_key", "W_sparse")
|
|
523
|
+
Wi_series = hist.get(Wkey, [])
|
|
524
|
+
Wi_last = Wi_series[-1] if Wi_series else None
|
|
525
|
+
Wm_series = hist.get(Wm_key, [])
|
|
526
|
+
Wm_last = Wm_series[-1] if Wm_series else None
|
|
527
|
+
|
|
528
|
+
nodes = list(G.nodes())
|
|
529
|
+
diag = {}
|
|
530
|
+
for i, n in enumerate(nodes):
|
|
531
|
+
nd = G.nodes[n]
|
|
532
|
+
Si = clamp01(float(_get_attr(nd, "Si", 0.0)))
|
|
533
|
+
EPI = float(_get_attr(nd, ALIAS_EPI, 0.0))
|
|
534
|
+
vf = float(_get_attr(nd, "νf", 0.0))
|
|
535
|
+
dnfr_n = _dnfr_norm(nd, dnfr_max)
|
|
536
|
+
|
|
537
|
+
Rloc = 0.0
|
|
538
|
+
if Wm_last is not None:
|
|
539
|
+
if Wm_last and isinstance(Wm_last[0], list):
|
|
540
|
+
row = Wm_last[i]
|
|
541
|
+
else:
|
|
542
|
+
row = Wm_last
|
|
543
|
+
Rloc = local_phase_sync_weighted(G, n, nodes_order=nodes, W_row=row)
|
|
544
|
+
else:
|
|
545
|
+
Rloc = local_phase_sync_weighted(G, n)
|
|
546
|
+
|
|
547
|
+
symm = _symmetry_index(G, n, epi_min=epi_min, epi_max=epi_max) if dcfg.get("compute_symmetry", True) else None
|
|
548
|
+
state = _state_from_thresholds(Rloc, dnfr_n, dcfg)
|
|
549
|
+
|
|
550
|
+
alerts = []
|
|
551
|
+
if state == "disonante" and dnfr_n >= float(dcfg.get("dissonance", {}).get("dnfr_hi", 0.5)):
|
|
552
|
+
alerts.append("tensión estructural alta")
|
|
553
|
+
|
|
554
|
+
advice = _recommendation(state, dcfg)
|
|
555
|
+
|
|
556
|
+
rec = {
|
|
557
|
+
"node": n,
|
|
558
|
+
"Si": Si,
|
|
559
|
+
"EPI": EPI,
|
|
560
|
+
"νf": vf,
|
|
561
|
+
"dnfr_norm": dnfr_n,
|
|
562
|
+
"W_i": (Wi_last[i] if (Wi_last and i < len(Wi_last)) else None),
|
|
563
|
+
"R_local": Rloc,
|
|
564
|
+
"symmetry": symm,
|
|
565
|
+
"state": state,
|
|
566
|
+
"advice": advice,
|
|
567
|
+
"alerts": alerts,
|
|
568
|
+
}
|
|
569
|
+
diag[n] = rec
|
|
570
|
+
|
|
571
|
+
hist.setdefault(key, []).append(diag)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def dissonance_events(G, ctx=None):
|
|
575
|
+
"""Emite eventos de inicio/fin de disonancia estructural por nodo."""
|
|
576
|
+
hist = ensure_history(G)
|
|
577
|
+
evs = hist.setdefault("events", [])
|
|
578
|
+
norms = G.graph.get("_sel_norms", {})
|
|
579
|
+
dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
|
|
580
|
+
step_idx = len(hist.get("C_steps", []))
|
|
581
|
+
for n in G.nodes():
|
|
582
|
+
nd = G.nodes[n]
|
|
583
|
+
dn = abs(_get_attr(nd, ALIAS_DNFR, 0.0)) / dnfr_max
|
|
584
|
+
Rloc = local_phase_sync_weighted(G, n)
|
|
585
|
+
st = bool(nd.get("_disr_state", False))
|
|
586
|
+
if (not st) and dn >= 0.5 and Rloc <= 0.4:
|
|
587
|
+
nd["_disr_state"] = True
|
|
588
|
+
evs.append(("disonance_start", {"node": n, "step": step_idx}))
|
|
589
|
+
elif st and dn <= 0.2 and Rloc >= 0.7:
|
|
590
|
+
nd["_disr_state"] = False
|
|
591
|
+
evs.append(("disonance_end", {"node": n, "step": step_idx}))
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def register_diagnosis_callbacks(G) -> None:
|
|
595
|
+
register_callback(G, when="after_step", func=_diagnosis_step, name="diagnosis_step")
|
|
596
|
+
register_callback(G, when="after_step", func=dissonance_events, name="dissonance_events")
|
|
597
|
+
|