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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Glyph timing utilities and advanced metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from ..alias import get_attr
|
|
10
|
+
from ..constants import get_aliases, get_param
|
|
11
|
+
from ..constants_glyphs import GLYPH_GROUPS, GLYPHS_CANONICAL
|
|
12
|
+
from ..glyph_history import append_metric, last_glyph
|
|
13
|
+
from ..types import Glyph
|
|
14
|
+
|
|
15
|
+
ALIAS_EPI = get_aliases("EPI")
|
|
16
|
+
|
|
17
|
+
LATENT_GLYPH = Glyph.SHA.value
|
|
18
|
+
DEFAULT_EPI_SUPPORT_LIMIT = 0.05
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class GlyphTiming:
|
|
23
|
+
curr: str | None = None
|
|
24
|
+
run: float = 0.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"LATENT_GLYPH",
|
|
29
|
+
"GlyphTiming",
|
|
30
|
+
"_tg_state",
|
|
31
|
+
"for_each_glyph",
|
|
32
|
+
"_update_tg_node",
|
|
33
|
+
"_update_tg",
|
|
34
|
+
"_update_glyphogram",
|
|
35
|
+
"_update_latency_index",
|
|
36
|
+
"_update_epi_support",
|
|
37
|
+
"_update_morph_metrics",
|
|
38
|
+
"_compute_advanced_metrics",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Internal utilities
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _tg_state(nd: dict[str, Any]) -> GlyphTiming:
|
|
48
|
+
"""Expose per-node glyph timing state."""
|
|
49
|
+
|
|
50
|
+
return nd.setdefault("_Tg", GlyphTiming())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def for_each_glyph(fn: Callable[[str], Any]) -> None:
|
|
54
|
+
"""Apply ``fn`` to each canonical structural operator."""
|
|
55
|
+
|
|
56
|
+
for g in GLYPHS_CANONICAL:
|
|
57
|
+
fn(g)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Glyph timing helpers
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _update_tg_node(n, nd, dt, tg_total, tg_by_node):
|
|
66
|
+
"""Track a node's glyph transition and accumulate run time."""
|
|
67
|
+
|
|
68
|
+
g = last_glyph(nd)
|
|
69
|
+
if not g:
|
|
70
|
+
return None, False
|
|
71
|
+
st = _tg_state(nd)
|
|
72
|
+
curr = st.curr
|
|
73
|
+
if curr is None:
|
|
74
|
+
st.curr = g
|
|
75
|
+
st.run = dt
|
|
76
|
+
elif g == curr:
|
|
77
|
+
st.run += dt
|
|
78
|
+
else:
|
|
79
|
+
dur = st.run
|
|
80
|
+
tg_total[curr] += dur
|
|
81
|
+
if tg_by_node is not None:
|
|
82
|
+
tg_by_node[n][curr].append(dur)
|
|
83
|
+
st.curr = g
|
|
84
|
+
st.run = dt
|
|
85
|
+
return g, g == LATENT_GLYPH
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _update_tg(G, hist, dt, save_by_node: bool):
|
|
89
|
+
"""Accumulate glyph dwell times for the entire graph."""
|
|
90
|
+
|
|
91
|
+
counts = Counter()
|
|
92
|
+
tg_total = hist.setdefault("Tg_total", defaultdict(float))
|
|
93
|
+
tg_by_node = (
|
|
94
|
+
hist.setdefault("Tg_by_node", defaultdict(lambda: defaultdict(list)))
|
|
95
|
+
if save_by_node
|
|
96
|
+
else None
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
n_total = 0
|
|
100
|
+
n_latent = 0
|
|
101
|
+
for n, nd in G.nodes(data=True):
|
|
102
|
+
g, is_latent = _update_tg_node(n, nd, dt, tg_total, tg_by_node)
|
|
103
|
+
if g is None:
|
|
104
|
+
continue
|
|
105
|
+
n_total += 1
|
|
106
|
+
if is_latent:
|
|
107
|
+
n_latent += 1
|
|
108
|
+
counts[g] += 1
|
|
109
|
+
return counts, n_total, n_latent
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _update_glyphogram(G, hist, counts, t, n_total):
|
|
113
|
+
"""Record glyphogram row from glyph counts."""
|
|
114
|
+
|
|
115
|
+
normalize_series = bool(get_param(G, "METRICS").get("normalize_series", False))
|
|
116
|
+
row = {"t": t}
|
|
117
|
+
total = max(1, n_total)
|
|
118
|
+
for g in GLYPHS_CANONICAL:
|
|
119
|
+
c = counts.get(g, 0)
|
|
120
|
+
row[g] = (c / total) if normalize_series else c
|
|
121
|
+
append_metric(hist, "glyphogram", row)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _update_latency_index(G, hist, n_total, n_latent, t):
|
|
125
|
+
"""Record latency index for the current step."""
|
|
126
|
+
|
|
127
|
+
li = n_latent / max(1, n_total)
|
|
128
|
+
append_metric(hist, "latency_index", {"t": t, "value": li})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _update_epi_support(
|
|
132
|
+
G,
|
|
133
|
+
hist,
|
|
134
|
+
t,
|
|
135
|
+
threshold: float = DEFAULT_EPI_SUPPORT_LIMIT,
|
|
136
|
+
):
|
|
137
|
+
"""Measure EPI support and normalized magnitude."""
|
|
138
|
+
|
|
139
|
+
total = 0.0
|
|
140
|
+
count = 0
|
|
141
|
+
for _, nd in G.nodes(data=True):
|
|
142
|
+
epi_val = abs(get_attr(nd, ALIAS_EPI, 0.0))
|
|
143
|
+
if epi_val >= threshold:
|
|
144
|
+
total += epi_val
|
|
145
|
+
count += 1
|
|
146
|
+
epi_norm = (total / count) if count else 0.0
|
|
147
|
+
append_metric(
|
|
148
|
+
hist,
|
|
149
|
+
"EPI_support",
|
|
150
|
+
{"t": t, "size": count, "epi_norm": float(epi_norm)},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _update_morph_metrics(G, hist, counts, t):
|
|
155
|
+
"""Capture morphosyntactic distribution of glyphs."""
|
|
156
|
+
|
|
157
|
+
def get_count(keys):
|
|
158
|
+
return sum(counts.get(k, 0) for k in keys)
|
|
159
|
+
|
|
160
|
+
total = max(1, sum(counts.values()))
|
|
161
|
+
id_val = get_count(GLYPH_GROUPS.get("ID", ())) / total
|
|
162
|
+
cm_val = get_count(GLYPH_GROUPS.get("CM", ())) / total
|
|
163
|
+
ne_val = get_count(GLYPH_GROUPS.get("NE", ())) / total
|
|
164
|
+
num = get_count(GLYPH_GROUPS.get("PP_num", ()))
|
|
165
|
+
den = get_count(GLYPH_GROUPS.get("PP_den", ()))
|
|
166
|
+
pp_val = 0.0 if den == 0 else num / den
|
|
167
|
+
append_metric(
|
|
168
|
+
hist,
|
|
169
|
+
"morph",
|
|
170
|
+
{"t": t, "ID": id_val, "CM": cm_val, "NE": ne_val, "PP": pp_val},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _compute_advanced_metrics(
|
|
175
|
+
G,
|
|
176
|
+
hist,
|
|
177
|
+
t,
|
|
178
|
+
dt,
|
|
179
|
+
cfg,
|
|
180
|
+
threshold: float = DEFAULT_EPI_SUPPORT_LIMIT,
|
|
181
|
+
):
|
|
182
|
+
"""Compute glyph timing derived metrics."""
|
|
183
|
+
|
|
184
|
+
save_by_node = bool(cfg.get("save_by_node", True))
|
|
185
|
+
counts, n_total, n_latent = _update_tg(G, hist, dt, save_by_node)
|
|
186
|
+
_update_glyphogram(G, hist, counts, t, n_total)
|
|
187
|
+
_update_latency_index(G, hist, n_total, n_latent, t)
|
|
188
|
+
_update_epi_support(G, hist, t, threshold)
|
|
189
|
+
_update_morph_metrics(G, hist, counts, t)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Reporting helpers for collected metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from heapq import nlargest
|
|
8
|
+
from statistics import mean, fmean, StatisticsError
|
|
9
|
+
|
|
10
|
+
from ..glyph_history import ensure_history
|
|
11
|
+
from ..sense import sigma_rose
|
|
12
|
+
from .glyph_timing import for_each_glyph
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Tg_global",
|
|
16
|
+
"Tg_by_node",
|
|
17
|
+
"latency_series",
|
|
18
|
+
"glyphogram_series",
|
|
19
|
+
"glyph_top",
|
|
20
|
+
"build_metrics_summary",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Reporting functions
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def Tg_global(G, normalize: bool = True) -> dict[str, float]:
|
|
30
|
+
"""Total glyph dwell time per class."""
|
|
31
|
+
|
|
32
|
+
hist = ensure_history(G)
|
|
33
|
+
tg_total: dict[str, float] = hist.get("Tg_total", {})
|
|
34
|
+
total = sum(tg_total.values()) or 1.0
|
|
35
|
+
out: dict[str, float] = {}
|
|
36
|
+
|
|
37
|
+
def add(g):
|
|
38
|
+
val = float(tg_total.get(g, 0.0))
|
|
39
|
+
out[g] = val / total if normalize else val
|
|
40
|
+
|
|
41
|
+
for_each_glyph(add)
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def Tg_by_node(G, n, normalize: bool = False) -> dict[str, float | list[float]]:
|
|
46
|
+
"""Per-node glyph dwell summary."""
|
|
47
|
+
|
|
48
|
+
hist = ensure_history(G)
|
|
49
|
+
rec = hist.get("Tg_by_node", {}).get(n, {})
|
|
50
|
+
if not normalize:
|
|
51
|
+
out: dict[str, list[float]] = {}
|
|
52
|
+
|
|
53
|
+
def copy_runs(g):
|
|
54
|
+
out[g] = list(rec.get(g, []))
|
|
55
|
+
|
|
56
|
+
for_each_glyph(copy_runs)
|
|
57
|
+
return out
|
|
58
|
+
out: dict[str, float] = {}
|
|
59
|
+
|
|
60
|
+
def add(g):
|
|
61
|
+
runs = rec.get(g, [])
|
|
62
|
+
out[g] = float(mean(runs)) if runs else 0.0
|
|
63
|
+
|
|
64
|
+
for_each_glyph(add)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def latency_series(G) -> dict[str, list[float]]:
|
|
69
|
+
hist = ensure_history(G)
|
|
70
|
+
xs = hist.get("latency_index", [])
|
|
71
|
+
return {
|
|
72
|
+
"t": [float(x.get("t", i)) for i, x in enumerate(xs)],
|
|
73
|
+
"value": [float(x.get("value", 0.0)) for x in xs],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def glyphogram_series(G) -> dict[str, list[float]]:
|
|
78
|
+
hist = ensure_history(G)
|
|
79
|
+
xs = hist.get("glyphogram", [])
|
|
80
|
+
if not xs:
|
|
81
|
+
return {"t": []}
|
|
82
|
+
out: dict[str, list[float]] = {"t": [float(x.get("t", i)) for i, x in enumerate(xs)]}
|
|
83
|
+
|
|
84
|
+
def add(g):
|
|
85
|
+
out[g] = [float(x.get(g, 0.0)) for x in xs]
|
|
86
|
+
|
|
87
|
+
for_each_glyph(add)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def glyph_top(G, k: int = 3) -> list[tuple[str, float]]:
|
|
92
|
+
"""Top-k structural operators by ``Tg_global`` fraction."""
|
|
93
|
+
|
|
94
|
+
k = int(k)
|
|
95
|
+
if k <= 0:
|
|
96
|
+
raise ValueError("k must be a positive integer")
|
|
97
|
+
tg = Tg_global(G, normalize=True)
|
|
98
|
+
return nlargest(k, tg.items(), key=lambda kv: kv[1])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_metrics_summary(
|
|
102
|
+
G, *, series_limit: int | None = None
|
|
103
|
+
) -> tuple[dict[str, Any], bool]:
|
|
104
|
+
"""Collect a compact metrics summary for CLI reporting.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
G:
|
|
109
|
+
Graph containing the recorded metrics.
|
|
110
|
+
series_limit:
|
|
111
|
+
Maximum number of samples to keep for each glyphogram series. ``None`` or
|
|
112
|
+
non-positive values disable trimming and return the full history.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
tg = Tg_global(G, normalize=True)
|
|
116
|
+
latency = latency_series(G)
|
|
117
|
+
glyph = glyphogram_series(G)
|
|
118
|
+
rose = sigma_rose(G)
|
|
119
|
+
|
|
120
|
+
latency_values = latency.get("value", [])
|
|
121
|
+
try:
|
|
122
|
+
latency_mean = fmean(latency_values)
|
|
123
|
+
except StatisticsError:
|
|
124
|
+
latency_mean = 0.0
|
|
125
|
+
|
|
126
|
+
limit: int | None
|
|
127
|
+
if series_limit is None:
|
|
128
|
+
limit = None
|
|
129
|
+
else:
|
|
130
|
+
limit = int(series_limit)
|
|
131
|
+
if limit <= 0:
|
|
132
|
+
limit = None
|
|
133
|
+
|
|
134
|
+
def _trim(values: list[Any]) -> list[Any]:
|
|
135
|
+
seq = list(values)
|
|
136
|
+
if limit is None:
|
|
137
|
+
return seq
|
|
138
|
+
return seq[:limit]
|
|
139
|
+
|
|
140
|
+
glyph_summary = {k: _trim(v) for k, v in glyph.items()}
|
|
141
|
+
|
|
142
|
+
summary = {
|
|
143
|
+
"Tg_global": tg,
|
|
144
|
+
"latency_mean": latency_mean,
|
|
145
|
+
"rose": rose,
|
|
146
|
+
"glyphogram": glyph_summary,
|
|
147
|
+
}
|
|
148
|
+
return summary, bool(latency_values)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Sense index helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from functools import partial
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..alias import get_attr, set_attr
|
|
10
|
+
from ..collections_utils import normalize_weights
|
|
11
|
+
from ..constants import get_aliases
|
|
12
|
+
from ..cache import edge_version_cache, stable_json
|
|
13
|
+
from ..helpers.numeric import angle_diff, clamp01
|
|
14
|
+
from .trig import neighbor_phase_mean_list
|
|
15
|
+
from ..import_utils import get_numpy
|
|
16
|
+
from ..types import GraphLike
|
|
17
|
+
|
|
18
|
+
from .common import (
|
|
19
|
+
ensure_neighbors_map,
|
|
20
|
+
merge_graph_weights,
|
|
21
|
+
_get_vf_dnfr_max,
|
|
22
|
+
)
|
|
23
|
+
from .trig_cache import get_trig_cache
|
|
24
|
+
|
|
25
|
+
ALIAS_VF = get_aliases("VF")
|
|
26
|
+
ALIAS_DNFR = get_aliases("DNFR")
|
|
27
|
+
ALIAS_SI = get_aliases("SI")
|
|
28
|
+
ALIAS_THETA = get_aliases("THETA")
|
|
29
|
+
|
|
30
|
+
__all__ = ("get_Si_weights", "compute_Si_node", "compute_Si")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cache_weights(G: GraphLike) -> tuple[float, float, float]:
|
|
34
|
+
"""Normalise and cache Si weights, delegating persistence."""
|
|
35
|
+
|
|
36
|
+
w = merge_graph_weights(G, "SI_WEIGHTS")
|
|
37
|
+
cfg_key = stable_json(w)
|
|
38
|
+
|
|
39
|
+
def builder() -> tuple[float, float, float]:
|
|
40
|
+
weights = normalize_weights(w, ("alpha", "beta", "gamma"), default=0.0)
|
|
41
|
+
alpha = weights["alpha"]
|
|
42
|
+
beta = weights["beta"]
|
|
43
|
+
gamma = weights["gamma"]
|
|
44
|
+
G.graph["_Si_weights"] = weights
|
|
45
|
+
G.graph["_Si_weights_key"] = cfg_key
|
|
46
|
+
G.graph["_Si_sensitivity"] = {
|
|
47
|
+
"dSi_dvf_norm": alpha,
|
|
48
|
+
"dSi_ddisp_fase": -beta,
|
|
49
|
+
"dSi_ddnfr_norm": -gamma,
|
|
50
|
+
}
|
|
51
|
+
return alpha, beta, gamma
|
|
52
|
+
|
|
53
|
+
return edge_version_cache(G, ("_Si_weights", cfg_key), builder)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_Si_weights(G: GraphLike) -> tuple[float, float, float]:
|
|
57
|
+
"""Obtain and normalise weights for the sense index."""
|
|
58
|
+
|
|
59
|
+
return _cache_weights(G)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compute_Si_node(
|
|
63
|
+
n: Any,
|
|
64
|
+
nd: dict[str, Any],
|
|
65
|
+
*,
|
|
66
|
+
alpha: float,
|
|
67
|
+
beta: float,
|
|
68
|
+
gamma: float,
|
|
69
|
+
vfmax: float,
|
|
70
|
+
dnfrmax: float,
|
|
71
|
+
disp_fase: float,
|
|
72
|
+
inplace: bool,
|
|
73
|
+
) -> float:
|
|
74
|
+
"""Compute ``Si`` for a single node."""
|
|
75
|
+
|
|
76
|
+
vf = get_attr(nd, ALIAS_VF, 0.0)
|
|
77
|
+
vf_norm = clamp01(abs(vf) / vfmax)
|
|
78
|
+
|
|
79
|
+
dnfr = get_attr(nd, ALIAS_DNFR, 0.0)
|
|
80
|
+
dnfr_norm = clamp01(abs(dnfr) / dnfrmax)
|
|
81
|
+
|
|
82
|
+
Si = alpha * vf_norm + beta * (1.0 - disp_fase) + gamma * (1.0 - dnfr_norm)
|
|
83
|
+
Si = clamp01(Si)
|
|
84
|
+
if inplace:
|
|
85
|
+
set_attr(nd, ALIAS_SI, Si)
|
|
86
|
+
return Si
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compute_Si(G: GraphLike, *, inplace: bool = True) -> dict[Any, float]:
|
|
90
|
+
"""Compute ``Si`` per node and optionally store it on the graph."""
|
|
91
|
+
|
|
92
|
+
neighbors = ensure_neighbors_map(G)
|
|
93
|
+
alpha, beta, gamma = get_Si_weights(G)
|
|
94
|
+
vfmax, dnfrmax = _get_vf_dnfr_max(G)
|
|
95
|
+
|
|
96
|
+
np = get_numpy()
|
|
97
|
+
trig = get_trig_cache(G, np=np)
|
|
98
|
+
cos_th, sin_th, thetas = trig.cos, trig.sin, trig.theta
|
|
99
|
+
|
|
100
|
+
pm_fn = partial(
|
|
101
|
+
neighbor_phase_mean_list, cos_th=cos_th, sin_th=sin_th, np=np
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
out: dict[Any, float] = {}
|
|
105
|
+
for n, nd in G.nodes(data=True):
|
|
106
|
+
neigh = neighbors[n]
|
|
107
|
+
th_bar = pm_fn(neigh, fallback=thetas[n])
|
|
108
|
+
disp_fase = abs(angle_diff(thetas[n], th_bar)) / math.pi
|
|
109
|
+
out[n] = compute_Si_node(
|
|
110
|
+
n,
|
|
111
|
+
nd,
|
|
112
|
+
alpha=alpha,
|
|
113
|
+
beta=beta,
|
|
114
|
+
gamma=gamma,
|
|
115
|
+
vfmax=vfmax,
|
|
116
|
+
dnfrmax=dnfrmax,
|
|
117
|
+
disp_fase=disp_fase,
|
|
118
|
+
inplace=inplace,
|
|
119
|
+
)
|
|
120
|
+
return out
|
tnfr/metrics/trig.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Trigonometric helpers shared across metrics and helpers.
|
|
2
|
+
|
|
3
|
+
This module focuses on mathematical utilities (means, compensated sums, etc.).
|
|
4
|
+
Caching of cosine/sine values lives in :mod:`tnfr.metrics.trig_cache`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
from itertools import tee
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..import_utils import cached_import, get_numpy
|
|
15
|
+
from ..helpers.numeric import kahan_sum_nd
|
|
16
|
+
|
|
17
|
+
__all__ = (
|
|
18
|
+
"accumulate_cos_sin",
|
|
19
|
+
"_phase_mean_from_iter",
|
|
20
|
+
"_neighbor_phase_mean_core",
|
|
21
|
+
"_neighbor_phase_mean_generic",
|
|
22
|
+
"neighbor_phase_mean_list",
|
|
23
|
+
"neighbor_phase_mean",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def accumulate_cos_sin(
|
|
28
|
+
it: Iterable[tuple[float, float] | None],
|
|
29
|
+
) -> tuple[float, float, bool]:
|
|
30
|
+
"""Accumulate cosine and sine pairs with compensated summation.
|
|
31
|
+
|
|
32
|
+
``it`` yields optional ``(cos, sin)`` tuples. Entries with ``None``
|
|
33
|
+
components are ignored. The returned values are the compensated sums of
|
|
34
|
+
cosines and sines along with a flag indicating whether any pair was
|
|
35
|
+
processed.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
processed = False
|
|
39
|
+
|
|
40
|
+
def iter_real_pairs():
|
|
41
|
+
nonlocal processed
|
|
42
|
+
for cs in it:
|
|
43
|
+
if cs is None:
|
|
44
|
+
continue
|
|
45
|
+
c, s = cs
|
|
46
|
+
if c is None or s is None:
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
c_val = float(c)
|
|
50
|
+
s_val = float(s)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
continue
|
|
53
|
+
if not (math.isfinite(c_val) and math.isfinite(s_val)):
|
|
54
|
+
continue
|
|
55
|
+
processed = True
|
|
56
|
+
yield (c_val, s_val)
|
|
57
|
+
|
|
58
|
+
sum_cos, sum_sin = kahan_sum_nd(iter_real_pairs(), dims=2)
|
|
59
|
+
|
|
60
|
+
if not processed:
|
|
61
|
+
return 0.0, 0.0, False
|
|
62
|
+
|
|
63
|
+
return sum_cos, sum_sin, True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _phase_mean_from_iter(
|
|
67
|
+
it: Iterable[tuple[float, float] | None], fallback: float
|
|
68
|
+
) -> float:
|
|
69
|
+
"""Return circular mean from an iterator of cosine/sine pairs.
|
|
70
|
+
|
|
71
|
+
``it`` yields optional ``(cos, sin)`` tuples. ``fallback`` is returned if
|
|
72
|
+
no valid pairs are processed.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
sum_cos, sum_sin, processed = accumulate_cos_sin(it)
|
|
76
|
+
if not processed:
|
|
77
|
+
return fallback
|
|
78
|
+
return math.atan2(sum_sin, sum_cos)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _neighbor_phase_mean_core(
|
|
82
|
+
neigh: Sequence[Any],
|
|
83
|
+
cos_map: dict[Any, float],
|
|
84
|
+
sin_map: dict[Any, float],
|
|
85
|
+
np,
|
|
86
|
+
fallback: float,
|
|
87
|
+
) -> float:
|
|
88
|
+
"""Return circular mean of neighbour phases given trig mappings."""
|
|
89
|
+
|
|
90
|
+
def _iter_pairs():
|
|
91
|
+
for v in neigh:
|
|
92
|
+
c = cos_map.get(v)
|
|
93
|
+
s = sin_map.get(v)
|
|
94
|
+
if c is not None and s is not None:
|
|
95
|
+
yield c, s
|
|
96
|
+
|
|
97
|
+
pairs = _iter_pairs()
|
|
98
|
+
|
|
99
|
+
if np is not None:
|
|
100
|
+
cos_iter, sin_iter = tee(pairs, 2)
|
|
101
|
+
cos_arr = np.fromiter((c for c, _ in cos_iter), dtype=float)
|
|
102
|
+
sin_arr = np.fromiter((s for _, s in sin_iter), dtype=float)
|
|
103
|
+
if cos_arr.size:
|
|
104
|
+
mean_cos = float(np.mean(cos_arr))
|
|
105
|
+
mean_sin = float(np.mean(sin_arr))
|
|
106
|
+
return float(np.arctan2(mean_sin, mean_cos))
|
|
107
|
+
return fallback
|
|
108
|
+
|
|
109
|
+
sum_cos, sum_sin, processed = accumulate_cos_sin(pairs)
|
|
110
|
+
if not processed:
|
|
111
|
+
return fallback
|
|
112
|
+
return math.atan2(sum_sin, sum_cos)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _neighbor_phase_mean_generic(
|
|
116
|
+
obj,
|
|
117
|
+
cos_map: dict[Any, float] | None = None,
|
|
118
|
+
sin_map: dict[Any, float] | None = None,
|
|
119
|
+
np=None,
|
|
120
|
+
fallback: float = 0.0,
|
|
121
|
+
) -> float:
|
|
122
|
+
"""Internal helper delegating to :func:`_neighbor_phase_mean_core`.
|
|
123
|
+
|
|
124
|
+
``obj`` may be either a node bound to a graph or a sequence of neighbours.
|
|
125
|
+
When ``cos_map`` and ``sin_map`` are ``None`` the function assumes ``obj`` is
|
|
126
|
+
a node and obtains the required trigonometric mappings from the cached
|
|
127
|
+
structures. Otherwise ``obj`` is treated as an explicit neighbour
|
|
128
|
+
sequence and ``cos_map``/``sin_map`` must be provided.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
if np is None:
|
|
132
|
+
np = get_numpy()
|
|
133
|
+
|
|
134
|
+
if cos_map is None or sin_map is None:
|
|
135
|
+
node = obj
|
|
136
|
+
if getattr(node, "G", None) is None:
|
|
137
|
+
raise TypeError(
|
|
138
|
+
"neighbor_phase_mean requires nodes bound to a graph"
|
|
139
|
+
)
|
|
140
|
+
from .trig_cache import get_trig_cache
|
|
141
|
+
|
|
142
|
+
trig = get_trig_cache(node.G)
|
|
143
|
+
fallback = trig.theta.get(node.n, fallback)
|
|
144
|
+
cos_map = trig.cos
|
|
145
|
+
sin_map = trig.sin
|
|
146
|
+
neigh = node.G[node.n]
|
|
147
|
+
else:
|
|
148
|
+
neigh = obj
|
|
149
|
+
|
|
150
|
+
return _neighbor_phase_mean_core(neigh, cos_map, sin_map, np, fallback)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def neighbor_phase_mean_list(
|
|
154
|
+
neigh: Sequence[Any],
|
|
155
|
+
cos_th: dict[Any, float],
|
|
156
|
+
sin_th: dict[Any, float],
|
|
157
|
+
np=None,
|
|
158
|
+
fallback: float = 0.0,
|
|
159
|
+
) -> float:
|
|
160
|
+
"""Return circular mean of neighbour phases from cosine/sine mappings.
|
|
161
|
+
|
|
162
|
+
This is a thin wrapper over :func:`_neighbor_phase_mean_generic` that
|
|
163
|
+
operates on explicit neighbour lists.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
return _neighbor_phase_mean_generic(
|
|
167
|
+
neigh, cos_map=cos_th, sin_map=sin_th, np=np, fallback=fallback
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def neighbor_phase_mean(obj, n=None) -> float:
|
|
172
|
+
"""Circular mean of neighbour phases.
|
|
173
|
+
|
|
174
|
+
The :class:`NodoNX` import is cached after the first call.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
NodoNX = cached_import("tnfr.node", "NodoNX")
|
|
178
|
+
if NodoNX is None:
|
|
179
|
+
raise ImportError("NodoNX is unavailable")
|
|
180
|
+
node = NodoNX(obj, n) if n is not None else obj
|
|
181
|
+
return _neighbor_phase_mean_generic(node)
|