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.
- tnfr/__init__.py +270 -90
- tnfr/__init__.pyi +40 -0
- tnfr/_compat.py +11 -0
- tnfr/_version.py +7 -0
- tnfr/_version.pyi +7 -0
- tnfr/alias.py +631 -0
- tnfr/alias.pyi +140 -0
- tnfr/cache.py +732 -0
- tnfr/cache.pyi +232 -0
- tnfr/callback_utils.py +381 -0
- tnfr/callback_utils.pyi +105 -0
- tnfr/cli/__init__.py +89 -0
- tnfr/cli/__init__.pyi +47 -0
- tnfr/cli/arguments.py +199 -0
- tnfr/cli/arguments.pyi +33 -0
- tnfr/cli/execution.py +322 -0
- tnfr/cli/execution.pyi +80 -0
- tnfr/cli/utils.py +34 -0
- tnfr/cli/utils.pyi +8 -0
- tnfr/config/__init__.py +12 -0
- tnfr/config/__init__.pyi +8 -0
- tnfr/config/constants.py +104 -0
- tnfr/config/constants.pyi +12 -0
- tnfr/config/init.py +36 -0
- tnfr/config/init.pyi +8 -0
- tnfr/config/operator_names.py +106 -0
- tnfr/config/operator_names.pyi +28 -0
- tnfr/config/presets.py +104 -0
- tnfr/config/presets.pyi +7 -0
- tnfr/constants/__init__.py +228 -0
- tnfr/constants/__init__.pyi +104 -0
- tnfr/constants/core.py +158 -0
- tnfr/constants/core.pyi +17 -0
- tnfr/constants/init.py +31 -0
- tnfr/constants/init.pyi +12 -0
- tnfr/constants/metric.py +102 -0
- tnfr/constants/metric.pyi +19 -0
- tnfr/constants_glyphs.py +16 -0
- tnfr/constants_glyphs.pyi +12 -0
- tnfr/dynamics/__init__.py +136 -0
- tnfr/dynamics/__init__.pyi +83 -0
- tnfr/dynamics/adaptation.py +201 -0
- tnfr/dynamics/aliases.py +22 -0
- tnfr/dynamics/coordination.py +343 -0
- tnfr/dynamics/dnfr.py +2315 -0
- tnfr/dynamics/dnfr.pyi +33 -0
- tnfr/dynamics/integrators.py +561 -0
- tnfr/dynamics/integrators.pyi +35 -0
- tnfr/dynamics/runtime.py +521 -0
- tnfr/dynamics/sampling.py +34 -0
- tnfr/dynamics/sampling.pyi +7 -0
- tnfr/dynamics/selectors.py +680 -0
- tnfr/execution.py +216 -0
- tnfr/execution.pyi +65 -0
- tnfr/flatten.py +283 -0
- tnfr/flatten.pyi +28 -0
- tnfr/gamma.py +320 -89
- tnfr/gamma.pyi +40 -0
- tnfr/glyph_history.py +337 -0
- tnfr/glyph_history.pyi +53 -0
- tnfr/grammar.py +23 -153
- tnfr/grammar.pyi +13 -0
- tnfr/helpers/__init__.py +151 -0
- tnfr/helpers/__init__.pyi +66 -0
- tnfr/helpers/numeric.py +88 -0
- tnfr/helpers/numeric.pyi +12 -0
- tnfr/immutable.py +214 -0
- tnfr/immutable.pyi +37 -0
- tnfr/initialization.py +199 -0
- tnfr/initialization.pyi +73 -0
- tnfr/io.py +311 -0
- tnfr/io.pyi +11 -0
- tnfr/locking.py +37 -0
- tnfr/locking.pyi +7 -0
- tnfr/metrics/__init__.py +41 -0
- tnfr/metrics/__init__.pyi +20 -0
- tnfr/metrics/coherence.py +1469 -0
- tnfr/metrics/common.py +149 -0
- tnfr/metrics/common.pyi +15 -0
- tnfr/metrics/core.py +259 -0
- tnfr/metrics/core.pyi +13 -0
- tnfr/metrics/diagnosis.py +840 -0
- tnfr/metrics/diagnosis.pyi +89 -0
- tnfr/metrics/export.py +151 -0
- tnfr/metrics/glyph_timing.py +369 -0
- tnfr/metrics/reporting.py +152 -0
- tnfr/metrics/reporting.pyi +12 -0
- tnfr/metrics/sense_index.py +294 -0
- tnfr/metrics/sense_index.pyi +9 -0
- tnfr/metrics/trig.py +216 -0
- tnfr/metrics/trig.pyi +12 -0
- tnfr/metrics/trig_cache.py +105 -0
- tnfr/metrics/trig_cache.pyi +10 -0
- tnfr/node.py +255 -177
- tnfr/node.pyi +161 -0
- tnfr/observers.py +154 -150
- tnfr/observers.pyi +46 -0
- tnfr/ontosim.py +135 -134
- tnfr/ontosim.pyi +33 -0
- tnfr/operators/__init__.py +452 -0
- tnfr/operators/__init__.pyi +31 -0
- tnfr/operators/definitions.py +181 -0
- tnfr/operators/definitions.pyi +92 -0
- tnfr/operators/jitter.py +266 -0
- tnfr/operators/jitter.pyi +11 -0
- tnfr/operators/registry.py +80 -0
- tnfr/operators/registry.pyi +15 -0
- tnfr/operators/remesh.py +569 -0
- tnfr/presets.py +10 -23
- tnfr/presets.pyi +7 -0
- tnfr/py.typed +0 -0
- tnfr/rng.py +440 -0
- tnfr/rng.pyi +14 -0
- tnfr/selector.py +217 -0
- tnfr/selector.pyi +19 -0
- tnfr/sense.py +307 -142
- tnfr/sense.pyi +30 -0
- tnfr/structural.py +69 -164
- tnfr/structural.pyi +46 -0
- tnfr/telemetry/__init__.py +13 -0
- tnfr/telemetry/verbosity.py +37 -0
- tnfr/tokens.py +61 -0
- tnfr/tokens.pyi +41 -0
- tnfr/trace.py +520 -95
- tnfr/trace.pyi +68 -0
- tnfr/types.py +382 -17
- tnfr/types.pyi +145 -0
- tnfr/utils/__init__.py +158 -0
- tnfr/utils/__init__.pyi +133 -0
- tnfr/utils/cache.py +755 -0
- tnfr/utils/cache.pyi +156 -0
- tnfr/utils/data.py +267 -0
- tnfr/utils/data.pyi +73 -0
- tnfr/utils/graph.py +87 -0
- tnfr/utils/graph.pyi +10 -0
- tnfr/utils/init.py +746 -0
- tnfr/utils/init.pyi +85 -0
- tnfr/utils/io.py +157 -0
- tnfr/utils/io.pyi +10 -0
- tnfr/utils/validators.py +130 -0
- tnfr/utils/validators.pyi +19 -0
- tnfr/validation/__init__.py +25 -0
- tnfr/validation/__init__.pyi +17 -0
- tnfr/validation/compatibility.py +59 -0
- tnfr/validation/compatibility.pyi +8 -0
- tnfr/validation/grammar.py +149 -0
- tnfr/validation/grammar.pyi +11 -0
- tnfr/validation/rules.py +194 -0
- tnfr/validation/rules.pyi +18 -0
- tnfr/validation/syntax.py +151 -0
- tnfr/validation/syntax.pyi +7 -0
- tnfr-6.0.0.dist-info/METADATA +135 -0
- tnfr-6.0.0.dist-info/RECORD +157 -0
- tnfr/cli.py +0 -322
- tnfr/config.py +0 -41
- 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/validators.py +0 -38
- tnfr-4.5.1.dist-info/METADATA +0 -221
- tnfr-4.5.1.dist-info/RECORD +0 -28
- {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
- {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
- {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
- {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,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
|
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
|