tnfr 4.5.1__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.

Files changed (78) hide show
  1. tnfr/__init__.py +91 -90
  2. tnfr/alias.py +546 -0
  3. tnfr/cache.py +578 -0
  4. tnfr/callback_utils.py +388 -0
  5. tnfr/cli/__init__.py +75 -0
  6. tnfr/cli/arguments.py +177 -0
  7. tnfr/cli/execution.py +288 -0
  8. tnfr/cli/utils.py +36 -0
  9. tnfr/collections_utils.py +300 -0
  10. tnfr/config.py +19 -28
  11. tnfr/constants/__init__.py +174 -0
  12. tnfr/constants/core.py +159 -0
  13. tnfr/constants/init.py +31 -0
  14. tnfr/constants/metric.py +110 -0
  15. tnfr/constants_glyphs.py +98 -0
  16. tnfr/dynamics/__init__.py +658 -0
  17. tnfr/dynamics/dnfr.py +733 -0
  18. tnfr/dynamics/integrators.py +267 -0
  19. tnfr/dynamics/sampling.py +31 -0
  20. tnfr/execution.py +201 -0
  21. tnfr/flatten.py +283 -0
  22. tnfr/gamma.py +302 -88
  23. tnfr/glyph_history.py +290 -0
  24. tnfr/grammar.py +285 -96
  25. tnfr/graph_utils.py +84 -0
  26. tnfr/helpers/__init__.py +71 -0
  27. tnfr/helpers/numeric.py +87 -0
  28. tnfr/immutable.py +178 -0
  29. tnfr/import_utils.py +228 -0
  30. tnfr/initialization.py +197 -0
  31. tnfr/io.py +246 -0
  32. tnfr/json_utils.py +162 -0
  33. tnfr/locking.py +37 -0
  34. tnfr/logging_utils.py +116 -0
  35. tnfr/metrics/__init__.py +41 -0
  36. tnfr/metrics/coherence.py +829 -0
  37. tnfr/metrics/common.py +151 -0
  38. tnfr/metrics/core.py +101 -0
  39. tnfr/metrics/diagnosis.py +234 -0
  40. tnfr/metrics/export.py +137 -0
  41. tnfr/metrics/glyph_timing.py +189 -0
  42. tnfr/metrics/reporting.py +148 -0
  43. tnfr/metrics/sense_index.py +120 -0
  44. tnfr/metrics/trig.py +181 -0
  45. tnfr/metrics/trig_cache.py +109 -0
  46. tnfr/node.py +214 -159
  47. tnfr/observers.py +126 -136
  48. tnfr/ontosim.py +134 -134
  49. tnfr/operators/__init__.py +420 -0
  50. tnfr/operators/jitter.py +203 -0
  51. tnfr/operators/remesh.py +485 -0
  52. tnfr/presets.py +46 -14
  53. tnfr/rng.py +254 -0
  54. tnfr/selector.py +210 -0
  55. tnfr/sense.py +284 -131
  56. tnfr/structural.py +207 -79
  57. tnfr/tokens.py +60 -0
  58. tnfr/trace.py +329 -94
  59. tnfr/types.py +43 -17
  60. tnfr/validators.py +70 -24
  61. tnfr/value_utils.py +59 -0
  62. tnfr-4.5.2.dist-info/METADATA +379 -0
  63. tnfr-4.5.2.dist-info/RECORD +67 -0
  64. tnfr/cli.py +0 -322
  65. tnfr/constants.py +0 -277
  66. tnfr/dynamics.py +0 -814
  67. tnfr/helpers.py +0 -264
  68. tnfr/main.py +0 -47
  69. tnfr/metrics.py +0 -597
  70. tnfr/operators.py +0 -525
  71. tnfr/program.py +0 -176
  72. tnfr/scenarios.py +0 -34
  73. tnfr-4.5.1.dist-info/METADATA +0 -221
  74. tnfr-4.5.1.dist-info/RECORD +0 -28
  75. {tnfr-4.5.1.dist-info → tnfr-4.5.2.dist-info}/WHEEL +0 -0
  76. {tnfr-4.5.1.dist-info → tnfr-4.5.2.dist-info}/entry_points.txt +0 -0
  77. {tnfr-4.5.1.dist-info → tnfr-4.5.2.dist-info}/licenses/LICENSE.md +0 -0
  78. {tnfr-4.5.1.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)