hepyy-workflows 0.1.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.
- hepyy_workflows/__init__.py +3 -0
- hepyy_workflows/agent/__init__.py +5 -0
- hepyy_workflows/agent/service.py +80 -0
- hepyy_workflows/agent/stdio.py +54 -0
- hepyy_workflows/analysis/__init__.py +5 -0
- hepyy_workflows/analysis/expressions.py +184 -0
- hepyy_workflows/analysis/histograms.py +166 -0
- hepyy_workflows/analysis/runner.py +738 -0
- hepyy_workflows/analysis/views.py +142 -0
- hepyy_workflows/cli.py +196 -0
- hepyy_workflows/core/__init__.py +28 -0
- hepyy_workflows/core/config.py +152 -0
- hepyy_workflows/core/contracts.py +188 -0
- hepyy_workflows/core/source.py +110 -0
- hepyy_workflows/data/particles.parquet +0 -0
- hepyy_workflows/examples.py +53 -0
- hepyy_workflows/export/__init__.py +5 -0
- hepyy_workflows/export/standalone.py +156 -0
- hepyy_workflows/processing.py +65 -0
- hepyy_workflows/project.py +38 -0
- hepyy_workflows/readers/__init__.py +17 -0
- hepyy_workflows/readers/formats.py +666 -0
- hepyy_workflows/readers/mapped.py +608 -0
- hepyy_workflows/readers/scaffold.py +68 -0
- hepyy_workflows/readers/service.py +271 -0
- hepyy_workflows/reporting/__init__.py +5 -0
- hepyy_workflows/reporting/report.py +77 -0
- hepyy_workflows/schemas/reader-draft.schema.json +45 -0
- hepyy_workflows/schemas/reader.schema.json +31 -0
- hepyy_workflows/schemas/recipe.schema.json +87 -0
- hepyy_workflows/starter/README.md +22 -0
- hepyy_workflows/starter/reader.yaml +20 -0
- hepyy_workflows/starter/recipe.yaml +25 -0
- hepyy_workflows/ui/__init__.py +5 -0
- hepyy_workflows/ui/service.py +65 -0
- hepyy_workflows/ui/tui.py +23 -0
- hepyy_workflows/ui/web.py +103 -0
- hepyy_workflows-0.1.0.dist-info/METADATA +113 -0
- hepyy_workflows-0.1.0.dist-info/RECORD +42 -0
- hepyy_workflows-0.1.0.dist-info/WHEEL +5 -0
- hepyy_workflows-0.1.0.dist-info/entry_points.txt +2 -0
- hepyy_workflows-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Stable JSON operations for agents and ordinary automation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from hepyy_workflows.analysis.runner import prepare_recipe, run_recipe
|
|
9
|
+
from hepyy_workflows.export.standalone import export_recipe
|
|
10
|
+
from hepyy_workflows.readers.scaffold import scaffold_reader
|
|
11
|
+
from hepyy_workflows.readers.service import (
|
|
12
|
+
draft_reader,
|
|
13
|
+
inspect_input,
|
|
14
|
+
preview_reader,
|
|
15
|
+
validate_reader,
|
|
16
|
+
write_draft,
|
|
17
|
+
)
|
|
18
|
+
from hepyy_workflows.reporting.report import build_report
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _paths(values: list[str] | None) -> list[Path]:
|
|
22
|
+
return [Path(value) for value in values or []]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _error(error: Exception, code: str = "AGENT_FAILED") -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"valid": False,
|
|
28
|
+
"diagnostics": [
|
|
29
|
+
{
|
|
30
|
+
"code": getattr(error, "code", code),
|
|
31
|
+
"path": getattr(error, "path", "/"),
|
|
32
|
+
"message": str(error),
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dispatch(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
try:
|
|
40
|
+
if method == "inspect_input":
|
|
41
|
+
return inspect_input(Path(params["input"]))
|
|
42
|
+
if method == "draft_reader":
|
|
43
|
+
if "output" in params:
|
|
44
|
+
return write_draft(Path(params["input"]), Path(params["output"]))
|
|
45
|
+
draft, inspection = draft_reader(Path(params["input"]))
|
|
46
|
+
return {**inspection, "draft": draft}
|
|
47
|
+
if method == "validate_reader":
|
|
48
|
+
return validate_reader(Path(params["reader"]), _paths(params.get("inputs")))
|
|
49
|
+
if method == "preview_reader":
|
|
50
|
+
return preview_reader(
|
|
51
|
+
Path(params["reader"]), _paths(params.get("inputs")), int(params.get("events", 10))
|
|
52
|
+
)
|
|
53
|
+
if method == "validate_recipe":
|
|
54
|
+
_, _, prepared = prepare_recipe(Path(params["recipe"]), params.get("inputs"))
|
|
55
|
+
return {
|
|
56
|
+
"valid": True,
|
|
57
|
+
"inputs": {k: [str(p) for p in v] for k, v in prepared["paths"].items()},
|
|
58
|
+
}
|
|
59
|
+
if method == "run_analysis":
|
|
60
|
+
return run_recipe(
|
|
61
|
+
Path(params["recipe"]),
|
|
62
|
+
Path(params["output"]),
|
|
63
|
+
params.get("inputs"),
|
|
64
|
+
params.get("max_events"),
|
|
65
|
+
)
|
|
66
|
+
if method == "export_standalone":
|
|
67
|
+
return export_recipe(
|
|
68
|
+
Path(params["recipe"]), Path(params["output"]), params.get("inputs")
|
|
69
|
+
)
|
|
70
|
+
if method == "build_report":
|
|
71
|
+
return build_report(Path(params["results"]), Path(params["output"]))
|
|
72
|
+
if method == "scaffold_reader":
|
|
73
|
+
return scaffold_reader(params["name"], Path(params["output"]))
|
|
74
|
+
if method == "get_status":
|
|
75
|
+
return {"valid": True, "status": "idle", "diagnostics": []}
|
|
76
|
+
if method == "cancel_run":
|
|
77
|
+
return _error(ValueError("no asynchronous run is active"), "NO_ACTIVE_RUN")
|
|
78
|
+
return _error(ValueError(f"unknown method: {method}"), "METHOD_UNKNOWN")
|
|
79
|
+
except Exception as error:
|
|
80
|
+
return _error(error)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Line-delimited JSON agent interface; no shell or arbitrary code endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .service import dispatch
|
|
9
|
+
|
|
10
|
+
_METHODS = (
|
|
11
|
+
"inspect_input",
|
|
12
|
+
"draft_reader",
|
|
13
|
+
"validate_reader",
|
|
14
|
+
"preview_reader",
|
|
15
|
+
"validate_recipe",
|
|
16
|
+
"run_analysis",
|
|
17
|
+
"export_standalone",
|
|
18
|
+
"build_report",
|
|
19
|
+
"scaffold_reader",
|
|
20
|
+
"get_status",
|
|
21
|
+
"cancel_run",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def serve_stdio() -> int:
|
|
26
|
+
for line in sys.stdin:
|
|
27
|
+
if not line.strip():
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
request = json.loads(line)
|
|
31
|
+
if not isinstance(request, dict) or not isinstance(request.get("method"), str):
|
|
32
|
+
raise ValueError("request needs a method")
|
|
33
|
+
method = request["method"]
|
|
34
|
+
if method == "initialize":
|
|
35
|
+
result = {
|
|
36
|
+
"protocolVersion": "2025-03-26",
|
|
37
|
+
"serverInfo": {"name": "hwf", "version": "1"},
|
|
38
|
+
}
|
|
39
|
+
elif method == "tools/list":
|
|
40
|
+
result = {
|
|
41
|
+
"tools": [
|
|
42
|
+
{"name": name, "description": f"HWF {name} operation"} for name in _METHODS
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
elif method == "tools/call":
|
|
46
|
+
params = request.get("params", {})
|
|
47
|
+
result = dispatch(params["name"], params.get("arguments", {}))
|
|
48
|
+
else:
|
|
49
|
+
result = dispatch(method, request.get("params", {}))
|
|
50
|
+
response = {"id": request.get("id"), "result": result}
|
|
51
|
+
except Exception as error:
|
|
52
|
+
response = {"id": None, "error": {"code": "REQUEST_INVALID", "message": str(error)}}
|
|
53
|
+
print(json.dumps(response, default=str), flush=True)
|
|
54
|
+
return 0
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Small, side-effect-free expression evaluator for recipe values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import math
|
|
7
|
+
import operator
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExpressionError(ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def wrapped_angle(value: float) -> float:
|
|
17
|
+
return math.atan2(math.sin(value), math.cos(value))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_FUNCTIONS = {
|
|
21
|
+
"abs": abs,
|
|
22
|
+
"sqrt": math.sqrt,
|
|
23
|
+
"log": math.log,
|
|
24
|
+
"log10": math.log10,
|
|
25
|
+
"sin": math.sin,
|
|
26
|
+
"cos": math.cos,
|
|
27
|
+
"tan": math.tan,
|
|
28
|
+
"atan2": math.atan2,
|
|
29
|
+
"hypot": math.hypot,
|
|
30
|
+
"wrapped_angle": wrapped_angle,
|
|
31
|
+
"isfinite": math.isfinite,
|
|
32
|
+
"min": min,
|
|
33
|
+
"max": max,
|
|
34
|
+
}
|
|
35
|
+
_BINARY = {
|
|
36
|
+
ast.Add: operator.add,
|
|
37
|
+
ast.Sub: operator.sub,
|
|
38
|
+
ast.Mult: operator.mul,
|
|
39
|
+
ast.Div: operator.truediv,
|
|
40
|
+
ast.Pow: math.pow,
|
|
41
|
+
ast.Mod: operator.mod,
|
|
42
|
+
}
|
|
43
|
+
_COMPARE = {
|
|
44
|
+
ast.Eq: operator.eq,
|
|
45
|
+
ast.NotEq: operator.ne,
|
|
46
|
+
ast.Lt: operator.lt,
|
|
47
|
+
ast.LtE: operator.le,
|
|
48
|
+
ast.Gt: operator.gt,
|
|
49
|
+
ast.GtE: operator.ge,
|
|
50
|
+
ast.Is: operator.is_,
|
|
51
|
+
ast.IsNot: operator.is_not,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@lru_cache(maxsize=1024)
|
|
56
|
+
def compile_expression(source: str) -> ast.Expression:
|
|
57
|
+
try:
|
|
58
|
+
tree = ast.parse(source, mode="eval")
|
|
59
|
+
except SyntaxError as exc:
|
|
60
|
+
raise ExpressionError(f"invalid expression {source!r}: {exc.msg}") from exc
|
|
61
|
+
_check(tree.body)
|
|
62
|
+
return tree
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def referenced_names(source: str) -> set[str]:
|
|
66
|
+
"""Return field/parameter roots, excluding whitelisted function names."""
|
|
67
|
+
|
|
68
|
+
tree = compile_expression(source)
|
|
69
|
+
names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
|
|
70
|
+
calls = {
|
|
71
|
+
node.func.id
|
|
72
|
+
for node in ast.walk(tree)
|
|
73
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
|
74
|
+
}
|
|
75
|
+
return names - calls
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _path(node: ast.AST) -> str | None:
|
|
79
|
+
if isinstance(node, ast.Name):
|
|
80
|
+
return node.id
|
|
81
|
+
if isinstance(node, ast.Attribute):
|
|
82
|
+
parent = _path(node.value)
|
|
83
|
+
return None if parent is None else parent + "." + node.attr
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check(node: ast.AST) -> None:
|
|
88
|
+
if isinstance(node, ast.Constant):
|
|
89
|
+
if not isinstance(node.value, (int, float, str, bool, type(None))):
|
|
90
|
+
raise ExpressionError("unsupported literal")
|
|
91
|
+
elif isinstance(node, (ast.Name, ast.Attribute)):
|
|
92
|
+
if _path(node) is None or any(part.startswith("_") for part in _path(node).split(".")):
|
|
93
|
+
raise ExpressionError("private or dynamic field access is forbidden")
|
|
94
|
+
elif isinstance(node, ast.BinOp) and type(node.op) in _BINARY:
|
|
95
|
+
_check(node.left)
|
|
96
|
+
_check(node.right)
|
|
97
|
+
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd, ast.Not)):
|
|
98
|
+
_check(node.operand)
|
|
99
|
+
elif isinstance(node, ast.BoolOp) and isinstance(node.op, (ast.And, ast.Or)):
|
|
100
|
+
for value in node.values:
|
|
101
|
+
_check(value)
|
|
102
|
+
elif isinstance(node, ast.Compare) and all(type(op) in _COMPARE for op in node.ops):
|
|
103
|
+
_check(node.left)
|
|
104
|
+
for item in node.comparators:
|
|
105
|
+
_check(item)
|
|
106
|
+
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
107
|
+
if node.func.id not in _FUNCTIONS or node.keywords:
|
|
108
|
+
raise ExpressionError("function is not allowed")
|
|
109
|
+
for item in node.args:
|
|
110
|
+
_check(item)
|
|
111
|
+
else:
|
|
112
|
+
raise ExpressionError(f"unsupported expression element: {type(node).__name__}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def field(context: dict, path: str) -> Any:
|
|
116
|
+
if path in context:
|
|
117
|
+
return context[path]
|
|
118
|
+
parts = path.split(".")
|
|
119
|
+
for start in range(len(parts), 0, -1):
|
|
120
|
+
prefix = ".".join(parts[:start])
|
|
121
|
+
if prefix in context:
|
|
122
|
+
value = context[prefix]
|
|
123
|
+
for part in parts[start:]:
|
|
124
|
+
if not isinstance(value, dict):
|
|
125
|
+
return None
|
|
126
|
+
value = value.get(part)
|
|
127
|
+
return value
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def evaluate(source: str, context: dict) -> Any:
|
|
132
|
+
tree = compile_expression(source)
|
|
133
|
+
|
|
134
|
+
def visit(node: ast.AST) -> Any:
|
|
135
|
+
if isinstance(node, ast.Constant):
|
|
136
|
+
return node.value
|
|
137
|
+
if isinstance(node, (ast.Name, ast.Attribute)):
|
|
138
|
+
return field(context, _path(node))
|
|
139
|
+
if isinstance(node, ast.BinOp):
|
|
140
|
+
left, right = visit(node.left), visit(node.right)
|
|
141
|
+
if left is None or right is None:
|
|
142
|
+
return None
|
|
143
|
+
if not isinstance(left, (int, float)) or not isinstance(right, (int, float)):
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
value = _BINARY[type(node.op)](left, right)
|
|
147
|
+
except (ArithmeticError, OverflowError, TypeError, ValueError):
|
|
148
|
+
return None
|
|
149
|
+
return value if not isinstance(value, float) or math.isfinite(value) else None
|
|
150
|
+
if isinstance(node, ast.UnaryOp):
|
|
151
|
+
value = visit(node.operand)
|
|
152
|
+
if isinstance(node.op, ast.Not):
|
|
153
|
+
return not bool(value)
|
|
154
|
+
return None if value is None else (-value if isinstance(node.op, ast.USub) else +value)
|
|
155
|
+
if isinstance(node, ast.BoolOp):
|
|
156
|
+
if isinstance(node.op, ast.And):
|
|
157
|
+
return all(bool(visit(item)) for item in node.values)
|
|
158
|
+
return any(bool(visit(item)) for item in node.values)
|
|
159
|
+
if isinstance(node, ast.Compare):
|
|
160
|
+
left = visit(node.left)
|
|
161
|
+
for op, right_node in zip(node.ops, node.comparators, strict=True):
|
|
162
|
+
right = visit(right_node)
|
|
163
|
+
if left is None or right is None:
|
|
164
|
+
if not isinstance(op, (ast.Is, ast.IsNot, ast.Eq, ast.NotEq)):
|
|
165
|
+
return False
|
|
166
|
+
try:
|
|
167
|
+
if not _COMPARE[type(op)](left, right):
|
|
168
|
+
return False
|
|
169
|
+
except TypeError:
|
|
170
|
+
return False
|
|
171
|
+
left = right
|
|
172
|
+
return True
|
|
173
|
+
if isinstance(node, ast.Call):
|
|
174
|
+
args = [visit(item) for item in node.args]
|
|
175
|
+
if any(item is None for item in args):
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
value = _FUNCTIONS[node.func.id](*args)
|
|
179
|
+
except (ArithmeticError, OverflowError, TypeError, ValueError):
|
|
180
|
+
return None
|
|
181
|
+
return value if not isinstance(value, float) or math.isfinite(value) else None
|
|
182
|
+
raise AssertionError("expression was not checked")
|
|
183
|
+
|
|
184
|
+
return visit(tree.body)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Streaming weighted histograms with event-clustered covariance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HistogramError(ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def axis_edges(spec: dict, observed: tuple[float, float] | None = None) -> np.ndarray:
|
|
17
|
+
bins = spec["bins"]
|
|
18
|
+
if "edges" in bins:
|
|
19
|
+
if len(bins["edges"]) > 4097:
|
|
20
|
+
raise HistogramError("axis has more than 4096 bins")
|
|
21
|
+
edges = np.asarray(bins["edges"], dtype=float)
|
|
22
|
+
else:
|
|
23
|
+
if "automatic" in bins:
|
|
24
|
+
if observed is None:
|
|
25
|
+
raise HistogramError("automatic axis requires a bounded discovery pass")
|
|
26
|
+
definition = bins["automatic"]
|
|
27
|
+
low, high = observed
|
|
28
|
+
if low == high:
|
|
29
|
+
low, high = (
|
|
30
|
+
(low / math.sqrt(10), high * math.sqrt(10))
|
|
31
|
+
if low > 0
|
|
32
|
+
else (low - 0.5, high + 0.5)
|
|
33
|
+
)
|
|
34
|
+
else:
|
|
35
|
+
definition = bins
|
|
36
|
+
low, high = float(definition["low"]), float(definition["high"])
|
|
37
|
+
if definition["count"] > 4096:
|
|
38
|
+
raise HistogramError("axis has more than 4096 bins")
|
|
39
|
+
if definition.get("scale", "linear") == "log":
|
|
40
|
+
if low <= 0:
|
|
41
|
+
raise HistogramError("logarithmic axis requires positive limits")
|
|
42
|
+
edges = np.geomspace(low, high, definition["count"] + 1)
|
|
43
|
+
else:
|
|
44
|
+
edges = np.linspace(low, high, definition["count"] + 1)
|
|
45
|
+
if len(edges) < 2 or not np.all(np.isfinite(edges)) or not np.all(np.diff(edges) > 0):
|
|
46
|
+
raise HistogramError("axis edges must be finite and strictly increasing")
|
|
47
|
+
return edges
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class Flow:
|
|
52
|
+
count: int = 0
|
|
53
|
+
sumw: float = 0.0
|
|
54
|
+
sumw2: float = 0.0
|
|
55
|
+
|
|
56
|
+
def add(self, weight: float) -> None:
|
|
57
|
+
self.count += 1
|
|
58
|
+
self.sumw += weight
|
|
59
|
+
self.sumw2 += weight * weight
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Histogram:
|
|
63
|
+
"""One reducer keyed by histogram, categories and optional slice."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, axes: list[np.ndarray]) -> None:
|
|
66
|
+
self.axes = axes
|
|
67
|
+
self.shape = tuple(len(axis) - 1 for axis in axes)
|
|
68
|
+
self.size = math.prod(self.shape)
|
|
69
|
+
if not 1 <= self.size <= 256:
|
|
70
|
+
raise HistogramError("histogram must have 1 to 256 cells for covariance")
|
|
71
|
+
self.count = np.zeros(self.size, dtype=np.int64)
|
|
72
|
+
self.sumw = np.zeros(self.size)
|
|
73
|
+
self.sumw2 = np.zeros(self.size)
|
|
74
|
+
self.event_cross = np.zeros((self.size, self.size))
|
|
75
|
+
self.event_total_cross = np.zeros(self.size)
|
|
76
|
+
self.event_total2 = 0.0
|
|
77
|
+
self.events = 0
|
|
78
|
+
self.entries = 0
|
|
79
|
+
self.flows = {name: Flow() for name in ("underflow", "overflow", "invalid")}
|
|
80
|
+
|
|
81
|
+
def _cell(self, values: tuple[Any, ...]) -> int | str:
|
|
82
|
+
if len(values) != len(self.axes):
|
|
83
|
+
return "invalid"
|
|
84
|
+
if any(not isinstance(value, (int, float)) or not math.isfinite(value) for value in values):
|
|
85
|
+
return "invalid"
|
|
86
|
+
indices = []
|
|
87
|
+
for value, edges in zip(values, self.axes, strict=True):
|
|
88
|
+
if value < edges[0]:
|
|
89
|
+
return "underflow"
|
|
90
|
+
if value > edges[-1]:
|
|
91
|
+
return "overflow"
|
|
92
|
+
indices.append(
|
|
93
|
+
min(int(np.searchsorted(edges, value, side="right") - 1), len(edges) - 2)
|
|
94
|
+
)
|
|
95
|
+
return int(np.ravel_multi_index(tuple(indices), self.shape))
|
|
96
|
+
|
|
97
|
+
def add_event(self, entries: list[tuple[tuple[Any, ...], float]]) -> None:
|
|
98
|
+
contribution = np.zeros(self.size)
|
|
99
|
+
for values, weight in entries:
|
|
100
|
+
self.entries += 1
|
|
101
|
+
if not isinstance(weight, (int, float)) or not math.isfinite(weight):
|
|
102
|
+
self.flows["invalid"].count += 1
|
|
103
|
+
continue
|
|
104
|
+
cell = self._cell(values)
|
|
105
|
+
if isinstance(cell, str):
|
|
106
|
+
self.flows[cell].add(float(weight))
|
|
107
|
+
else:
|
|
108
|
+
self.count[cell] += 1
|
|
109
|
+
self.sumw[cell] += weight
|
|
110
|
+
self.sumw2[cell] += weight * weight
|
|
111
|
+
contribution[cell] += weight
|
|
112
|
+
active = np.flatnonzero(contribution)
|
|
113
|
+
if len(active):
|
|
114
|
+
self.event_cross[np.ix_(active, active)] += np.outer(
|
|
115
|
+
contribution[active], contribution[active]
|
|
116
|
+
)
|
|
117
|
+
total = float(contribution.sum())
|
|
118
|
+
self.event_total_cross += contribution * total
|
|
119
|
+
self.event_total2 += total * total
|
|
120
|
+
self.events += 1
|
|
121
|
+
|
|
122
|
+
def result(self) -> dict:
|
|
123
|
+
total = float(self.sumw.sum())
|
|
124
|
+
widths = np.diff(self.axes[0])
|
|
125
|
+
if len(self.axes) == 2:
|
|
126
|
+
widths = np.outer(widths, np.diff(self.axes[1])).reshape(-1)
|
|
127
|
+
if total == 0:
|
|
128
|
+
normalized = density = normalized_error = density_error = None
|
|
129
|
+
covariance = None
|
|
130
|
+
else:
|
|
131
|
+
normalized_values = self.sumw / total
|
|
132
|
+
normalized = normalized_values.tolist()
|
|
133
|
+
density = (normalized_values / widths).tolist()
|
|
134
|
+
if self.events > 1:
|
|
135
|
+
residual = (
|
|
136
|
+
self.event_cross
|
|
137
|
+
- np.outer(self.event_total_cross, normalized_values)
|
|
138
|
+
- np.outer(normalized_values, self.event_total_cross)
|
|
139
|
+
+ self.event_total2 * np.outer(normalized_values, normalized_values)
|
|
140
|
+
)
|
|
141
|
+
covariance_array = self.events / (self.events - 1) * residual / total**2
|
|
142
|
+
covariance = covariance_array.tolist()
|
|
143
|
+
normalized_error = np.sqrt(np.maximum(np.diag(covariance_array), 0)).tolist()
|
|
144
|
+
density_error = (np.asarray(normalized_error) / widths).tolist()
|
|
145
|
+
else:
|
|
146
|
+
covariance = normalized_error = density_error = None
|
|
147
|
+
return {
|
|
148
|
+
"edges": [axis.tolist() for axis in self.axes],
|
|
149
|
+
"shape": self.shape,
|
|
150
|
+
"count": self.count.tolist(),
|
|
151
|
+
"sumw": self.sumw.tolist(),
|
|
152
|
+
"sumw2": self.sumw2.tolist(),
|
|
153
|
+
"error": np.sqrt(np.diag(self.event_cross)).tolist(),
|
|
154
|
+
"normalized": normalized,
|
|
155
|
+
"normalized_error": normalized_error,
|
|
156
|
+
"density": density,
|
|
157
|
+
"density_error": density_error,
|
|
158
|
+
"normalized_covariance": covariance,
|
|
159
|
+
"flows": {
|
|
160
|
+
name: {"count": flow.count, "sumw": flow.sumw, "sumw2": flow.sumw2}
|
|
161
|
+
for name, flow in self.flows.items()
|
|
162
|
+
},
|
|
163
|
+
"entries": self.entries,
|
|
164
|
+
"events": self.events,
|
|
165
|
+
"uncertainty": "event-clustered",
|
|
166
|
+
}
|