tnfr 4.5.1__py3-none-any.whl → 6.0.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.
Files changed (170) hide show
  1. tnfr/__init__.py +270 -90
  2. tnfr/__init__.pyi +40 -0
  3. tnfr/_compat.py +11 -0
  4. tnfr/_version.py +7 -0
  5. tnfr/_version.pyi +7 -0
  6. tnfr/alias.py +631 -0
  7. tnfr/alias.pyi +140 -0
  8. tnfr/cache.py +732 -0
  9. tnfr/cache.pyi +232 -0
  10. tnfr/callback_utils.py +381 -0
  11. tnfr/callback_utils.pyi +105 -0
  12. tnfr/cli/__init__.py +89 -0
  13. tnfr/cli/__init__.pyi +47 -0
  14. tnfr/cli/arguments.py +199 -0
  15. tnfr/cli/arguments.pyi +33 -0
  16. tnfr/cli/execution.py +322 -0
  17. tnfr/cli/execution.pyi +80 -0
  18. tnfr/cli/utils.py +34 -0
  19. tnfr/cli/utils.pyi +8 -0
  20. tnfr/config/__init__.py +12 -0
  21. tnfr/config/__init__.pyi +8 -0
  22. tnfr/config/constants.py +104 -0
  23. tnfr/config/constants.pyi +12 -0
  24. tnfr/config/init.py +36 -0
  25. tnfr/config/init.pyi +8 -0
  26. tnfr/config/operator_names.py +106 -0
  27. tnfr/config/operator_names.pyi +28 -0
  28. tnfr/config/presets.py +104 -0
  29. tnfr/config/presets.pyi +7 -0
  30. tnfr/constants/__init__.py +228 -0
  31. tnfr/constants/__init__.pyi +104 -0
  32. tnfr/constants/core.py +158 -0
  33. tnfr/constants/core.pyi +17 -0
  34. tnfr/constants/init.py +31 -0
  35. tnfr/constants/init.pyi +12 -0
  36. tnfr/constants/metric.py +102 -0
  37. tnfr/constants/metric.pyi +19 -0
  38. tnfr/constants_glyphs.py +16 -0
  39. tnfr/constants_glyphs.pyi +12 -0
  40. tnfr/dynamics/__init__.py +136 -0
  41. tnfr/dynamics/__init__.pyi +83 -0
  42. tnfr/dynamics/adaptation.py +201 -0
  43. tnfr/dynamics/aliases.py +22 -0
  44. tnfr/dynamics/coordination.py +343 -0
  45. tnfr/dynamics/dnfr.py +2315 -0
  46. tnfr/dynamics/dnfr.pyi +33 -0
  47. tnfr/dynamics/integrators.py +561 -0
  48. tnfr/dynamics/integrators.pyi +35 -0
  49. tnfr/dynamics/runtime.py +521 -0
  50. tnfr/dynamics/sampling.py +34 -0
  51. tnfr/dynamics/sampling.pyi +7 -0
  52. tnfr/dynamics/selectors.py +680 -0
  53. tnfr/execution.py +216 -0
  54. tnfr/execution.pyi +65 -0
  55. tnfr/flatten.py +283 -0
  56. tnfr/flatten.pyi +28 -0
  57. tnfr/gamma.py +320 -89
  58. tnfr/gamma.pyi +40 -0
  59. tnfr/glyph_history.py +337 -0
  60. tnfr/glyph_history.pyi +53 -0
  61. tnfr/grammar.py +23 -153
  62. tnfr/grammar.pyi +13 -0
  63. tnfr/helpers/__init__.py +151 -0
  64. tnfr/helpers/__init__.pyi +66 -0
  65. tnfr/helpers/numeric.py +88 -0
  66. tnfr/helpers/numeric.pyi +12 -0
  67. tnfr/immutable.py +214 -0
  68. tnfr/immutable.pyi +37 -0
  69. tnfr/initialization.py +199 -0
  70. tnfr/initialization.pyi +73 -0
  71. tnfr/io.py +311 -0
  72. tnfr/io.pyi +11 -0
  73. tnfr/locking.py +37 -0
  74. tnfr/locking.pyi +7 -0
  75. tnfr/metrics/__init__.py +41 -0
  76. tnfr/metrics/__init__.pyi +20 -0
  77. tnfr/metrics/coherence.py +1469 -0
  78. tnfr/metrics/common.py +149 -0
  79. tnfr/metrics/common.pyi +15 -0
  80. tnfr/metrics/core.py +259 -0
  81. tnfr/metrics/core.pyi +13 -0
  82. tnfr/metrics/diagnosis.py +840 -0
  83. tnfr/metrics/diagnosis.pyi +89 -0
  84. tnfr/metrics/export.py +151 -0
  85. tnfr/metrics/glyph_timing.py +369 -0
  86. tnfr/metrics/reporting.py +152 -0
  87. tnfr/metrics/reporting.pyi +12 -0
  88. tnfr/metrics/sense_index.py +294 -0
  89. tnfr/metrics/sense_index.pyi +9 -0
  90. tnfr/metrics/trig.py +216 -0
  91. tnfr/metrics/trig.pyi +12 -0
  92. tnfr/metrics/trig_cache.py +105 -0
  93. tnfr/metrics/trig_cache.pyi +10 -0
  94. tnfr/node.py +255 -177
  95. tnfr/node.pyi +161 -0
  96. tnfr/observers.py +154 -150
  97. tnfr/observers.pyi +46 -0
  98. tnfr/ontosim.py +135 -134
  99. tnfr/ontosim.pyi +33 -0
  100. tnfr/operators/__init__.py +452 -0
  101. tnfr/operators/__init__.pyi +31 -0
  102. tnfr/operators/definitions.py +181 -0
  103. tnfr/operators/definitions.pyi +92 -0
  104. tnfr/operators/jitter.py +266 -0
  105. tnfr/operators/jitter.pyi +11 -0
  106. tnfr/operators/registry.py +80 -0
  107. tnfr/operators/registry.pyi +15 -0
  108. tnfr/operators/remesh.py +569 -0
  109. tnfr/presets.py +10 -23
  110. tnfr/presets.pyi +7 -0
  111. tnfr/py.typed +0 -0
  112. tnfr/rng.py +440 -0
  113. tnfr/rng.pyi +14 -0
  114. tnfr/selector.py +217 -0
  115. tnfr/selector.pyi +19 -0
  116. tnfr/sense.py +307 -142
  117. tnfr/sense.pyi +30 -0
  118. tnfr/structural.py +69 -164
  119. tnfr/structural.pyi +46 -0
  120. tnfr/telemetry/__init__.py +13 -0
  121. tnfr/telemetry/verbosity.py +37 -0
  122. tnfr/tokens.py +61 -0
  123. tnfr/tokens.pyi +41 -0
  124. tnfr/trace.py +520 -95
  125. tnfr/trace.pyi +68 -0
  126. tnfr/types.py +382 -17
  127. tnfr/types.pyi +145 -0
  128. tnfr/utils/__init__.py +158 -0
  129. tnfr/utils/__init__.pyi +133 -0
  130. tnfr/utils/cache.py +755 -0
  131. tnfr/utils/cache.pyi +156 -0
  132. tnfr/utils/data.py +267 -0
  133. tnfr/utils/data.pyi +73 -0
  134. tnfr/utils/graph.py +87 -0
  135. tnfr/utils/graph.pyi +10 -0
  136. tnfr/utils/init.py +746 -0
  137. tnfr/utils/init.pyi +85 -0
  138. tnfr/utils/io.py +157 -0
  139. tnfr/utils/io.pyi +10 -0
  140. tnfr/utils/validators.py +130 -0
  141. tnfr/utils/validators.pyi +19 -0
  142. tnfr/validation/__init__.py +25 -0
  143. tnfr/validation/__init__.pyi +17 -0
  144. tnfr/validation/compatibility.py +59 -0
  145. tnfr/validation/compatibility.pyi +8 -0
  146. tnfr/validation/grammar.py +149 -0
  147. tnfr/validation/grammar.pyi +11 -0
  148. tnfr/validation/rules.py +194 -0
  149. tnfr/validation/rules.pyi +18 -0
  150. tnfr/validation/syntax.py +151 -0
  151. tnfr/validation/syntax.pyi +7 -0
  152. tnfr-6.0.0.dist-info/METADATA +135 -0
  153. tnfr-6.0.0.dist-info/RECORD +157 -0
  154. tnfr/cli.py +0 -322
  155. tnfr/config.py +0 -41
  156. tnfr/constants.py +0 -277
  157. tnfr/dynamics.py +0 -814
  158. tnfr/helpers.py +0 -264
  159. tnfr/main.py +0 -47
  160. tnfr/metrics.py +0 -597
  161. tnfr/operators.py +0 -525
  162. tnfr/program.py +0 -176
  163. tnfr/scenarios.py +0 -34
  164. tnfr/validators.py +0 -38
  165. tnfr-4.5.1.dist-info/METADATA +0 -221
  166. tnfr-4.5.1.dist-info/RECORD +0 -28
  167. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
  168. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
  169. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
  170. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,152 @@
1
+ """Reporting helpers for collected metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from heapq import nlargest
9
+ from statistics import mean, fmean, StatisticsError
10
+
11
+ from ..glyph_history import ensure_history
12
+ from ..types import NodeId, TNFRGraph
13
+ from ..sense import sigma_rose
14
+ from .glyph_timing import for_each_glyph
15
+
16
+ __all__ = [
17
+ "Tg_global",
18
+ "Tg_by_node",
19
+ "latency_series",
20
+ "glyphogram_series",
21
+ "glyph_top",
22
+ "build_metrics_summary",
23
+ ]
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Reporting functions
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def Tg_global(G: TNFRGraph, normalize: bool = True) -> dict[str, float]:
32
+ """Total glyph dwell time per class."""
33
+
34
+ hist = ensure_history(G)
35
+ tg_total: dict[str, float] = hist.get("Tg_total", {})
36
+ total = sum(tg_total.values()) or 1.0
37
+ out: dict[str, float] = {}
38
+
39
+ def add(g: str) -> None:
40
+ val = float(tg_total.get(g, 0.0))
41
+ out[g] = val / total if normalize else val
42
+
43
+ for_each_glyph(add)
44
+ return out
45
+
46
+
47
+ def Tg_by_node(
48
+ G: TNFRGraph, n: NodeId, normalize: bool = False
49
+ ) -> dict[str, float] | dict[str, list[float]]:
50
+ """Per-node glyph dwell summary."""
51
+
52
+ hist = ensure_history(G)
53
+ rec = hist.get("Tg_by_node", {}).get(n, {})
54
+ if not normalize:
55
+ runs_out: dict[str, list[float]] = {}
56
+
57
+ def copy_runs(g: str) -> None:
58
+ runs_out[g] = list(rec.get(g, []))
59
+
60
+ for_each_glyph(copy_runs)
61
+ return runs_out
62
+ mean_out: dict[str, float] = {}
63
+
64
+ def add(g: str) -> None:
65
+ runs = rec.get(g, [])
66
+ mean_out[g] = float(mean(runs)) if runs else 0.0
67
+
68
+ for_each_glyph(add)
69
+ return mean_out
70
+
71
+
72
+ def latency_series(G: TNFRGraph) -> dict[str, list[float]]:
73
+ hist = ensure_history(G)
74
+ xs = hist.get("latency_index", [])
75
+ return {
76
+ "t": [float(x.get("t", i)) for i, x in enumerate(xs)],
77
+ "value": [float(x.get("value", 0.0)) for x in xs],
78
+ }
79
+
80
+
81
+ def glyphogram_series(G: TNFRGraph) -> dict[str, list[float]]:
82
+ hist = ensure_history(G)
83
+ xs = hist.get("glyphogram", [])
84
+ if not xs:
85
+ return {"t": []}
86
+ out: dict[str, list[float]] = {"t": [float(x.get("t", i)) for i, x in enumerate(xs)]}
87
+
88
+ def add(g: str) -> None:
89
+ out[g] = [float(x.get(g, 0.0)) for x in xs]
90
+
91
+ for_each_glyph(add)
92
+ return out
93
+
94
+
95
+ def glyph_top(G: TNFRGraph, k: int = 3) -> list[tuple[str, float]]:
96
+ """Top-k structural operators by ``Tg_global`` fraction."""
97
+
98
+ k = int(k)
99
+ if k <= 0:
100
+ raise ValueError("k must be a positive integer")
101
+ tg = Tg_global(G, normalize=True)
102
+ return nlargest(k, tg.items(), key=lambda kv: kv[1])
103
+
104
+
105
+ def build_metrics_summary(
106
+ G: TNFRGraph, *, series_limit: int | None = None
107
+ ) -> tuple[dict[str, float | dict[str, float] | dict[str, list[float]] | dict[str, int]], bool]:
108
+ """Collect a compact metrics summary for CLI reporting.
109
+
110
+ Parameters
111
+ ----------
112
+ G:
113
+ Graph containing the recorded metrics.
114
+ series_limit:
115
+ Maximum number of samples to keep for each glyphogram series. ``None`` or
116
+ non-positive values disable trimming and return the full history.
117
+ """
118
+
119
+ tg = Tg_global(G, normalize=True)
120
+ latency = latency_series(G)
121
+ glyph = glyphogram_series(G)
122
+ rose = sigma_rose(G)
123
+
124
+ latency_values = latency.get("value", [])
125
+ try:
126
+ latency_mean = fmean(latency_values)
127
+ except StatisticsError:
128
+ latency_mean = 0.0
129
+
130
+ limit: int | None
131
+ if series_limit is None:
132
+ limit = None
133
+ else:
134
+ limit = int(series_limit)
135
+ if limit <= 0:
136
+ limit = None
137
+
138
+ def _trim(values: Sequence[Any]) -> list[Any]:
139
+ seq = list(values)
140
+ if limit is None:
141
+ return seq
142
+ return seq[:limit]
143
+
144
+ glyph_summary = {k: _trim(v) for k, v in glyph.items()}
145
+
146
+ summary = {
147
+ "Tg_global": tg,
148
+ "latency_mean": latency_mean,
149
+ "rose": rose,
150
+ "glyphogram": glyph_summary,
151
+ }
152
+ return summary, bool(latency_values)
@@ -0,0 +1,12 @@
1
+ from typing import Any
2
+
3
+ __all__: Any
4
+
5
+ def __getattr__(name: str) -> Any: ...
6
+
7
+ Tg_by_node: Any
8
+ Tg_global: Any
9
+ build_metrics_summary: Any
10
+ glyph_top: Any
11
+ glyphogram_series: Any
12
+ latency_series: Any
@@ -0,0 +1,294 @@
1
+ """Sense index helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from concurrent.futures import ProcessPoolExecutor
7
+ from functools import partial
8
+ from typing import Any, Iterable, Mapping
9
+
10
+ from ..alias import get_attr, set_attr
11
+ from ..constants import get_aliases
12
+ from ..helpers.numeric import angle_diff, clamp01
13
+ from ..types import GraphLike
14
+ from ..utils import (
15
+ edge_version_cache,
16
+ get_numpy,
17
+ normalize_weights,
18
+ stable_json,
19
+ )
20
+ from .trig import neighbor_phase_mean_list
21
+
22
+ from .common import (
23
+ ensure_neighbors_map,
24
+ merge_graph_weights,
25
+ _get_vf_dnfr_max,
26
+ )
27
+ from .trig_cache import get_trig_cache
28
+
29
+ ALIAS_VF = get_aliases("VF")
30
+ ALIAS_DNFR = get_aliases("DNFR")
31
+ ALIAS_SI = get_aliases("SI")
32
+
33
+ PHASE_DISPERSION_KEY = "dSi_dphase_disp"
34
+ _VALID_SENSITIVITY_KEYS = frozenset(
35
+ {"dSi_dvf_norm", PHASE_DISPERSION_KEY, "dSi_ddnfr_norm"}
36
+ )
37
+ __all__ = ("get_Si_weights", "compute_Si_node", "compute_Si")
38
+
39
+
40
+ def _normalise_si_sensitivity_mapping(
41
+ mapping: Mapping[str, float], *, warn: bool
42
+ ) -> dict[str, float]:
43
+ """Return a mapping containing only supported Si sensitivity keys."""
44
+
45
+ normalised = dict(mapping)
46
+ _ = warn # kept for API compatibility with trace helpers
47
+ unexpected = sorted(k for k in normalised if k not in _VALID_SENSITIVITY_KEYS)
48
+ if unexpected:
49
+ allowed = ", ".join(sorted(_VALID_SENSITIVITY_KEYS))
50
+ received = ", ".join(unexpected)
51
+ raise ValueError(
52
+ "Si sensitivity mappings accept only {%s}; unexpected key(s): %s"
53
+ % (allowed, received)
54
+ )
55
+ return normalised
56
+
57
+
58
+ def _cache_weights(G: GraphLike) -> tuple[float, float, float]:
59
+ """Normalise and cache Si weights, delegating persistence."""
60
+
61
+ w = merge_graph_weights(G, "SI_WEIGHTS")
62
+ cfg_key = stable_json(w)
63
+
64
+ existing = G.graph.get("_Si_sensitivity")
65
+ if isinstance(existing, Mapping):
66
+ migrated = _normalise_si_sensitivity_mapping(existing, warn=True)
67
+ if migrated != existing:
68
+ G.graph["_Si_sensitivity"] = migrated
69
+
70
+ def builder() -> tuple[float, float, float]:
71
+ weights = normalize_weights(w, ("alpha", "beta", "gamma"), default=0.0)
72
+ alpha = weights["alpha"]
73
+ beta = weights["beta"]
74
+ gamma = weights["gamma"]
75
+ G.graph["_Si_weights"] = weights
76
+ G.graph["_Si_weights_key"] = cfg_key
77
+ G.graph["_Si_sensitivity"] = {
78
+ "dSi_dvf_norm": alpha,
79
+ PHASE_DISPERSION_KEY: -beta,
80
+ "dSi_ddnfr_norm": -gamma,
81
+ }
82
+ return alpha, beta, gamma
83
+
84
+ return edge_version_cache(G, ("_Si_weights", cfg_key), builder)
85
+
86
+
87
+ def get_Si_weights(G: GraphLike) -> tuple[float, float, float]:
88
+ """Obtain and normalise weights for the sense index."""
89
+
90
+ return _cache_weights(G)
91
+
92
+
93
+ def compute_Si_node(
94
+ n: Any,
95
+ nd: dict[str, Any],
96
+ *,
97
+ alpha: float,
98
+ beta: float,
99
+ gamma: float,
100
+ vfmax: float,
101
+ dnfrmax: float,
102
+ phase_dispersion: float | None = None,
103
+ inplace: bool,
104
+ **kwargs: Any,
105
+ ) -> float:
106
+ """Compute ``Si`` for a single node."""
107
+
108
+ if kwargs:
109
+ unexpected = ", ".join(sorted(kwargs))
110
+ raise TypeError(f"Unexpected keyword argument(s): {unexpected}")
111
+
112
+ if phase_dispersion is None:
113
+ raise TypeError("Missing required keyword-only argument: 'phase_dispersion'")
114
+
115
+ vf = get_attr(nd, ALIAS_VF, 0.0)
116
+ vf_norm = clamp01(abs(vf) / vfmax)
117
+
118
+ dnfr = get_attr(nd, ALIAS_DNFR, 0.0)
119
+ dnfr_norm = clamp01(abs(dnfr) / dnfrmax)
120
+
121
+ Si = (
122
+ alpha * vf_norm
123
+ + beta * (1.0 - phase_dispersion)
124
+ + gamma * (1.0 - dnfr_norm)
125
+ )
126
+ Si = clamp01(Si)
127
+ if inplace:
128
+ set_attr(nd, ALIAS_SI, Si)
129
+ return Si
130
+
131
+
132
+ def _coerce_jobs(raw_jobs: Any | None) -> int | None:
133
+ """Normalise ``n_jobs`` values coming from user configuration."""
134
+
135
+ try:
136
+ jobs = None if raw_jobs is None else int(raw_jobs)
137
+ except (TypeError, ValueError):
138
+ return None
139
+ if jobs is not None and jobs <= 0:
140
+ return None
141
+ return jobs
142
+
143
+
144
+ def _compute_si_python_chunk(
145
+ chunk: Iterable[tuple[Any, tuple[Any, ...], float, float, float]],
146
+ *,
147
+ cos_th: dict[Any, float],
148
+ sin_th: dict[Any, float],
149
+ alpha: float,
150
+ beta: float,
151
+ gamma: float,
152
+ vfmax: float,
153
+ dnfrmax: float,
154
+ ) -> dict[Any, float]:
155
+ """Compute Si values for a chunk of nodes using pure Python math."""
156
+
157
+ results: dict[Any, float] = {}
158
+ for n, neigh, theta, vf, dnfr in chunk:
159
+ th_bar = neighbor_phase_mean_list(
160
+ neigh, cos_th=cos_th, sin_th=sin_th, np=None, fallback=theta
161
+ )
162
+ phase_dispersion = abs(angle_diff(theta, th_bar)) / math.pi
163
+ vf_norm = clamp01(abs(vf) / vfmax)
164
+ dnfr_norm = clamp01(abs(dnfr) / dnfrmax)
165
+ Si = (
166
+ alpha * vf_norm
167
+ + beta * (1.0 - phase_dispersion)
168
+ + gamma * (1.0 - dnfr_norm)
169
+ )
170
+ results[n] = clamp01(Si)
171
+ return results
172
+
173
+
174
+ def compute_Si(
175
+ G: GraphLike,
176
+ *,
177
+ inplace: bool = True,
178
+ n_jobs: int | None = None,
179
+ ) -> dict[Any, float]:
180
+ """Compute ``Si`` per node and optionally store it on the graph."""
181
+
182
+ neighbors = ensure_neighbors_map(G)
183
+ alpha, beta, gamma = get_Si_weights(G)
184
+ vfmax, dnfrmax = _get_vf_dnfr_max(G)
185
+
186
+ np = get_numpy()
187
+ trig = get_trig_cache(G, np=np)
188
+ cos_th, sin_th, thetas = trig.cos, trig.sin, trig.theta
189
+
190
+ pm_fn = partial(
191
+ neighbor_phase_mean_list, cos_th=cos_th, sin_th=sin_th, np=np
192
+ )
193
+
194
+ if n_jobs is None:
195
+ n_jobs = _coerce_jobs(G.graph.get("SI_N_JOBS"))
196
+ else:
197
+ n_jobs = _coerce_jobs(n_jobs)
198
+
199
+ supports_vector = (
200
+ np is not None
201
+ and hasattr(np, "ndarray")
202
+ and all(hasattr(np, attr) for attr in ("fromiter", "abs", "clip", "remainder"))
203
+ )
204
+
205
+ nodes_data = list(G.nodes(data=True))
206
+ if not nodes_data:
207
+ return {}
208
+
209
+ if supports_vector:
210
+ node_ids: list[Any] = []
211
+ theta_vals: list[float] = []
212
+ mean_vals: list[float] = []
213
+ vf_vals: list[float] = []
214
+ dnfr_vals: list[float] = []
215
+ for n, nd in nodes_data:
216
+ theta = thetas.get(n, 0.0)
217
+ neigh = neighbors[n]
218
+ node_ids.append(n)
219
+ theta_vals.append(theta)
220
+ mean_vals.append(pm_fn(neigh, fallback=theta))
221
+ vf_vals.append(get_attr(nd, ALIAS_VF, 0.0))
222
+ dnfr_vals.append(get_attr(nd, ALIAS_DNFR, 0.0))
223
+
224
+ count = len(node_ids)
225
+ theta_arr = np.fromiter(theta_vals, dtype=float, count=count)
226
+ mean_arr = np.fromiter(mean_vals, dtype=float, count=count)
227
+ diff = np.remainder(theta_arr - mean_arr + math.pi, math.tau) - math.pi
228
+ phase_dispersion_arr = np.abs(diff) / math.pi
229
+
230
+ vf_arr = np.fromiter(vf_vals, dtype=float, count=count)
231
+ dnfr_arr = np.fromiter(dnfr_vals, dtype=float, count=count)
232
+ vf_norm = np.clip(np.abs(vf_arr) / vfmax, 0.0, 1.0)
233
+ dnfr_norm = np.clip(np.abs(dnfr_arr) / dnfrmax, 0.0, 1.0)
234
+
235
+ si_arr = np.clip(
236
+ alpha * vf_norm + beta * (1.0 - phase_dispersion_arr)
237
+ + gamma * (1.0 - dnfr_norm),
238
+ 0.0,
239
+ 1.0,
240
+ )
241
+
242
+ out = {node_ids[i]: float(si_arr[i]) for i in range(count)}
243
+ else:
244
+ out: dict[Any, float] = {}
245
+ if n_jobs is not None and n_jobs > 1:
246
+ node_payload: list[tuple[Any, tuple[Any, ...], float, float, float]] = []
247
+ for n, nd in nodes_data:
248
+ theta = thetas.get(n, 0.0)
249
+ vf = float(get_attr(nd, ALIAS_VF, 0.0))
250
+ dnfr = float(get_attr(nd, ALIAS_DNFR, 0.0))
251
+ neigh = neighbors[n]
252
+ node_payload.append((n, tuple(neigh), theta, vf, dnfr))
253
+
254
+ if node_payload:
255
+ chunk_size = math.ceil(len(node_payload) / n_jobs)
256
+ with ProcessPoolExecutor(max_workers=n_jobs) as executor:
257
+ futures = [
258
+ executor.submit(
259
+ _compute_si_python_chunk,
260
+ node_payload[idx:idx + chunk_size],
261
+ cos_th=cos_th,
262
+ sin_th=sin_th,
263
+ alpha=alpha,
264
+ beta=beta,
265
+ gamma=gamma,
266
+ vfmax=vfmax,
267
+ dnfrmax=dnfrmax,
268
+ )
269
+ for idx in range(0, len(node_payload), chunk_size)
270
+ ]
271
+ for future in futures:
272
+ out.update(future.result())
273
+ else:
274
+ for n, nd in nodes_data:
275
+ theta = thetas.get(n, 0.0)
276
+ neigh = neighbors[n]
277
+ th_bar = pm_fn(neigh, fallback=theta)
278
+ phase_dispersion = abs(angle_diff(theta, th_bar)) / math.pi
279
+ out[n] = compute_Si_node(
280
+ n,
281
+ nd,
282
+ alpha=alpha,
283
+ beta=beta,
284
+ gamma=gamma,
285
+ vfmax=vfmax,
286
+ dnfrmax=dnfrmax,
287
+ phase_dispersion=phase_dispersion,
288
+ inplace=False,
289
+ )
290
+
291
+ if inplace:
292
+ for n, value in out.items():
293
+ set_attr(G.nodes[n], ALIAS_SI, value)
294
+ return out
@@ -0,0 +1,9 @@
1
+ from typing import Any
2
+
3
+ __all__: Any
4
+
5
+ def __getattr__(name: str) -> Any: ...
6
+
7
+ compute_Si: Any
8
+ compute_Si_node: Any
9
+ get_Si_weights: Any
tnfr/metrics/trig.py ADDED
@@ -0,0 +1,216 @@
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, Iterator, Sequence
11
+ from itertools import tee
12
+ from typing import TYPE_CHECKING, Any, overload, cast
13
+
14
+ from ..helpers.numeric import kahan_sum_nd
15
+ from ..utils import cached_import, get_numpy
16
+ from ..types import NodeId, Phase, TNFRGraph
17
+
18
+ if TYPE_CHECKING: # pragma: no cover - typing only
19
+ from ..node import NodeProtocol
20
+
21
+ __all__ = (
22
+ "accumulate_cos_sin",
23
+ "_phase_mean_from_iter",
24
+ "_neighbor_phase_mean_core",
25
+ "_neighbor_phase_mean_generic",
26
+ "neighbor_phase_mean_list",
27
+ "neighbor_phase_mean",
28
+ )
29
+
30
+
31
+ def accumulate_cos_sin(
32
+ it: Iterable[tuple[float, float] | None],
33
+ ) -> tuple[float, float, bool]:
34
+ """Accumulate cosine and sine pairs with compensated summation.
35
+
36
+ ``it`` yields optional ``(cos, sin)`` tuples. Entries with ``None``
37
+ components are ignored. The returned values are the compensated sums of
38
+ cosines and sines along with a flag indicating whether any pair was
39
+ processed.
40
+ """
41
+
42
+ processed = False
43
+
44
+ def iter_real_pairs() -> Iterator[tuple[float, float]]:
45
+ nonlocal processed
46
+ for cs in it:
47
+ if cs is None:
48
+ continue
49
+ c, s = cs
50
+ if c is None or s is None:
51
+ continue
52
+ try:
53
+ c_val = float(c)
54
+ s_val = float(s)
55
+ except (TypeError, ValueError):
56
+ continue
57
+ if not (math.isfinite(c_val) and math.isfinite(s_val)):
58
+ continue
59
+ processed = True
60
+ yield (c_val, s_val)
61
+
62
+ sum_cos, sum_sin = kahan_sum_nd(iter_real_pairs(), dims=2)
63
+
64
+ if not processed:
65
+ return 0.0, 0.0, False
66
+
67
+ return sum_cos, sum_sin, True
68
+
69
+
70
+ def _phase_mean_from_iter(
71
+ it: Iterable[tuple[float, float] | None], fallback: float
72
+ ) -> float:
73
+ """Return circular mean from an iterator of cosine/sine pairs.
74
+
75
+ ``it`` yields optional ``(cos, sin)`` tuples. ``fallback`` is returned if
76
+ no valid pairs are processed.
77
+ """
78
+
79
+ sum_cos, sum_sin, processed = accumulate_cos_sin(it)
80
+ if not processed:
81
+ return fallback
82
+ return math.atan2(sum_sin, sum_cos)
83
+
84
+
85
+ def _neighbor_phase_mean_core(
86
+ neigh: Sequence[Any],
87
+ cos_map: dict[Any, float],
88
+ sin_map: dict[Any, float],
89
+ np: Any | None,
90
+ fallback: float,
91
+ ) -> float:
92
+ """Return circular mean of neighbour phases given trig mappings."""
93
+
94
+ def _iter_pairs() -> Iterator[tuple[float, float]]:
95
+ for v in neigh:
96
+ c = cos_map.get(v)
97
+ s = sin_map.get(v)
98
+ if c is not None and s is not None:
99
+ yield c, s
100
+
101
+ pairs = _iter_pairs()
102
+
103
+ if np is not None:
104
+ cos_iter, sin_iter = tee(pairs, 2)
105
+ cos_arr = np.fromiter((c for c, _ in cos_iter), dtype=float)
106
+ sin_arr = np.fromiter((s for _, s in sin_iter), dtype=float)
107
+ if cos_arr.size:
108
+ mean_cos = float(np.mean(cos_arr))
109
+ mean_sin = float(np.mean(sin_arr))
110
+ return float(np.arctan2(mean_sin, mean_cos))
111
+ return fallback
112
+
113
+ sum_cos, sum_sin, processed = accumulate_cos_sin(pairs)
114
+ if not processed:
115
+ return fallback
116
+ return math.atan2(sum_sin, sum_cos)
117
+
118
+
119
+ def _neighbor_phase_mean_generic(
120
+ obj: "NodeProtocol" | Sequence[Any],
121
+ cos_map: dict[Any, float] | None = None,
122
+ sin_map: dict[Any, float] | None = None,
123
+ np: Any | None = None,
124
+ fallback: float = 0.0,
125
+ ) -> float:
126
+ """Internal helper delegating to :func:`_neighbor_phase_mean_core`.
127
+
128
+ ``obj`` may be either a node bound to a graph or a sequence of neighbours.
129
+ When ``cos_map`` and ``sin_map`` are ``None`` the function assumes ``obj`` is
130
+ a node and obtains the required trigonometric mappings from the cached
131
+ structures. Otherwise ``obj`` is treated as an explicit neighbour
132
+ sequence and ``cos_map``/``sin_map`` must be provided.
133
+ """
134
+
135
+ if np is None:
136
+ np = get_numpy()
137
+
138
+ if cos_map is None or sin_map is None:
139
+ node = cast("NodeProtocol", obj)
140
+ if getattr(node, "G", None) is None:
141
+ raise TypeError(
142
+ "neighbor_phase_mean requires nodes bound to a graph"
143
+ )
144
+ from .trig_cache import get_trig_cache
145
+
146
+ trig = get_trig_cache(node.G)
147
+ fallback = trig.theta.get(node.n, fallback)
148
+ cos_map = trig.cos
149
+ sin_map = trig.sin
150
+ neigh = node.G[node.n]
151
+ else:
152
+ neigh = cast(Sequence[Any], obj)
153
+
154
+ return _neighbor_phase_mean_core(neigh, cos_map, sin_map, np, fallback)
155
+
156
+
157
+ def neighbor_phase_mean_list(
158
+ neigh: Sequence[Any],
159
+ cos_th: dict[Any, float],
160
+ sin_th: dict[Any, float],
161
+ np: Any | None = None,
162
+ fallback: float = 0.0,
163
+ ) -> float:
164
+ """Return circular mean of neighbour phases from cosine/sine mappings.
165
+
166
+ This is a thin wrapper over :func:`_neighbor_phase_mean_generic` that
167
+ operates on explicit neighbour lists.
168
+ """
169
+
170
+ return _neighbor_phase_mean_generic(
171
+ neigh, cos_map=cos_th, sin_map=sin_th, np=np, fallback=fallback
172
+ )
173
+
174
+
175
+ @overload
176
+ def neighbor_phase_mean(obj: "NodeProtocol", n: None = ...) -> Phase:
177
+ ...
178
+
179
+
180
+ @overload
181
+ def neighbor_phase_mean(obj: TNFRGraph, n: NodeId) -> Phase:
182
+ ...
183
+
184
+
185
+ def neighbor_phase_mean(
186
+ obj: "NodeProtocol" | TNFRGraph, n: NodeId | None = None
187
+ ) -> Phase:
188
+ """Circular mean of neighbour phases for ``obj``.
189
+
190
+ Parameters
191
+ ----------
192
+ obj:
193
+ Either a :class:`~tnfr.node.NodeProtocol` instance bound to a graph or a
194
+ :class:`~tnfr.types.TNFRGraph` from which the node ``n`` will be wrapped.
195
+ n:
196
+ Optional node identifier. Required when ``obj`` is a graph. Providing a
197
+ node identifier for a node object raises :class:`TypeError`.
198
+ """
199
+
200
+ NodeNX = cached_import("tnfr.node", "NodeNX")
201
+ if NodeNX is None:
202
+ raise ImportError("NodeNX is unavailable")
203
+ if n is None:
204
+ if hasattr(obj, "nodes"):
205
+ raise TypeError(
206
+ "neighbor_phase_mean requires a node identifier when passing a graph"
207
+ )
208
+ node = obj
209
+ else:
210
+ if hasattr(obj, "nodes"):
211
+ node = NodeNX(obj, n)
212
+ else:
213
+ raise TypeError(
214
+ "neighbor_phase_mean received a node and an explicit identifier"
215
+ )
216
+ return _neighbor_phase_mean_generic(node)
tnfr/metrics/trig.pyi ADDED
@@ -0,0 +1,12 @@
1
+ from typing import Any
2
+
3
+ __all__: Any
4
+
5
+ def __getattr__(name: str) -> Any: ...
6
+
7
+ _neighbor_phase_mean_core: Any
8
+ _neighbor_phase_mean_generic: Any
9
+ _phase_mean_from_iter: Any
10
+ accumulate_cos_sin: Any
11
+ neighbor_phase_mean: Any
12
+ neighbor_phase_mean_list: Any