fluke-3540-analyzer 0.2.0__tar.gz
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.
- fluke_3540_analyzer-0.2.0/PKG-INFO +22 -0
- fluke_3540_analyzer-0.2.0/pyproject.toml +50 -0
- fluke_3540_analyzer-0.2.0/setup.cfg +4 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/__init__.py +7 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/cli.py +443 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/cli_compare.py +119 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/events.py +298 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/interactive.py +73 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/parser.py +376 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/plots/__init__.py +13 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/plots/compare.py +225 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/plots/event_zoom.py +212 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/plots/full_session.py +237 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/plots/gnuplot.py +58 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/plots/html_report.py +237 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/plots/xlsx.py +286 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/snapshots.py +102 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540/spec/field_map.json +935 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540_analyzer.egg-info/PKG-INFO +22 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540_analyzer.egg-info/SOURCES.txt +28 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540_analyzer.egg-info/dependency_links.txt +1 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540_analyzer.egg-info/entry_points.txt +2 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540_analyzer.egg-info/requires.txt +4 -0
- fluke_3540_analyzer-0.2.0/src/fluke_3540_analyzer.egg-info/top_level.txt +1 -0
- fluke_3540_analyzer-0.2.0/tests/test_cli.py +173 -0
- fluke_3540_analyzer-0.2.0/tests/test_compare.py +114 -0
- fluke_3540_analyzer-0.2.0/tests/test_events.py +168 -0
- fluke_3540_analyzer-0.2.0/tests/test_html_report.py +105 -0
- fluke_3540_analyzer-0.2.0/tests/test_parser.py +316 -0
- fluke_3540_analyzer-0.2.0/tests/test_snapshots.py +63 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fluke-3540-analyzer
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Parser, event detector, and chart generator for Fluke 3540 FC three-phase power-quality sessions. See https://github.com/GrumpyTanker/fluke-3540-analyzer.
|
|
5
|
+
Author: GrumpyTanker
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/GrumpyTanker/fluke-3540-analyzer
|
|
8
|
+
Project-URL: Repository, https://github.com/GrumpyTanker/fluke-3540-analyzer
|
|
9
|
+
Project-URL: Issues, https://github.com/GrumpyTanker/fluke-3540-analyzer/issues
|
|
10
|
+
Keywords: fluke,3540,power-quality,trend.bin,energy-analyze
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: openpyxl>=3.1
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fluke-3540-analyzer"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Parser, event detector, and chart generator for Fluke 3540 FC three-phase power-quality sessions. See https://github.com/GrumpyTanker/fluke-3540-analyzer."
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [{name = "GrumpyTanker"}]
|
|
12
|
+
keywords = ["fluke", "3540", "power-quality", "trend.bin", "energy-analyze"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Science/Research",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Scientific/Engineering",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"openpyxl >= 3.1",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest >= 7.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
fluke-analyze = "fluke_3540.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/GrumpyTanker/fluke-3540-analyzer"
|
|
37
|
+
Repository = "https://github.com/GrumpyTanker/fluke-3540-analyzer"
|
|
38
|
+
Issues = "https://github.com/GrumpyTanker/fluke-3540-analyzer/issues"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.package-data]
|
|
44
|
+
# Bundle the field-map spec into the wheel. The release workflow copies
|
|
45
|
+
# spec/field_map.json (at the repo root) into src/fluke_3540/spec/ before
|
|
46
|
+
# building, so the installed package is self-contained.
|
|
47
|
+
fluke_3540 = ["spec/field_map.json"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""fluke_3540 — generic parser, event detector, and chart generator for
|
|
2
|
+
Fluke 3540 FC three-phase power-quality logger sessions.
|
|
3
|
+
|
|
4
|
+
The canonical field map and binary layout live in spec/field_map.json at
|
|
5
|
+
the repo root and are shared with the JavaScript port. See README.md.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""fluke-analyze — orchestrate parse → detect → render for a Fluke session.
|
|
2
|
+
|
|
3
|
+
Modes:
|
|
4
|
+
--auto (default) parse, detect, render everything
|
|
5
|
+
--interactive prompt-driven pickers
|
|
6
|
+
--parse-only produce CSV + events.json, no plots
|
|
7
|
+
--plot-only reuse existing CSV + events.json, just render
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, Sequence
|
|
18
|
+
|
|
19
|
+
from .events import Event, detect_events
|
|
20
|
+
from .parser import (
|
|
21
|
+
_parse_reverse_cts_arg, export_csv, find_session_files, iter_records,
|
|
22
|
+
open_session,
|
|
23
|
+
)
|
|
24
|
+
from .plots import (
|
|
25
|
+
GnuplotNotFound, render_event_zoom, render_full_session,
|
|
26
|
+
render_snapshot_zoom, write_html_report, write_xlsx,
|
|
27
|
+
)
|
|
28
|
+
from .plots.full_session import QUANTITY_SPECS as FULL_QUANTITIES
|
|
29
|
+
from .snapshots import Snapshot, pick_snapshots
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DEFAULT_PLOTS = ("voltage", "current", "power", "thd", "pf", "frequency")
|
|
33
|
+
DEFAULT_ZOOM_PLOTS = ("voltage", "current", "power")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _isoformat(d: dt.datetime) -> str:
|
|
37
|
+
return d.isoformat()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_time(text: str) -> dt.datetime:
|
|
41
|
+
"""Accept ISO-8601 with or without timezone; treat naive as UTC."""
|
|
42
|
+
t = dt.datetime.fromisoformat(text)
|
|
43
|
+
if t.tzinfo is None:
|
|
44
|
+
t = t.replace(tzinfo=dt.timezone.utc)
|
|
45
|
+
return t
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _event_to_json(ev: Event) -> dict:
|
|
49
|
+
d = asdict(ev)
|
|
50
|
+
d["t_start"] = _isoformat(ev.t_start)
|
|
51
|
+
d["t_end"] = _isoformat(ev.t_end)
|
|
52
|
+
d["affected_phases"] = list(ev.affected_phases)
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _event_from_json(d: dict) -> Event:
|
|
57
|
+
return Event(
|
|
58
|
+
id=d["id"], kind=d["kind"],
|
|
59
|
+
t_start=_parse_time(d["t_start"]),
|
|
60
|
+
t_end=_parse_time(d["t_end"]),
|
|
61
|
+
severity=d["severity"],
|
|
62
|
+
affected_phases=tuple(d["affected_phases"]),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _snapshot_to_json(s: Snapshot) -> dict:
|
|
67
|
+
return {
|
|
68
|
+
"id": s.id,
|
|
69
|
+
"t_start": _isoformat(s.t_start),
|
|
70
|
+
"t_end": _isoformat(s.t_end),
|
|
71
|
+
"t_center": _isoformat(s.t_center),
|
|
72
|
+
"p_total_mean_w": s.p_total_mean_w,
|
|
73
|
+
"p_total_stdev_w": s.p_total_stdev_w,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_argparser() -> argparse.ArgumentParser:
|
|
78
|
+
ap = argparse.ArgumentParser(
|
|
79
|
+
prog="fluke-analyze", description=__doc__,
|
|
80
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
81
|
+
)
|
|
82
|
+
ap.add_argument("session_dir", type=Path,
|
|
83
|
+
help="Path to an ES.NNN session directory OR a .fel zip-bundle")
|
|
84
|
+
ap.add_argument("-o", "--output", type=Path, default=None,
|
|
85
|
+
help="Output directory (default: <session>_out next to the session)")
|
|
86
|
+
|
|
87
|
+
mode = ap.add_mutually_exclusive_group()
|
|
88
|
+
mode.add_argument("--auto", action="store_true",
|
|
89
|
+
help="Default — parse, detect, render everything")
|
|
90
|
+
mode.add_argument("--interactive", action="store_true",
|
|
91
|
+
help="Prompt for event/quantity/phase picks")
|
|
92
|
+
mode.add_argument("--parse-only", action="store_true",
|
|
93
|
+
help="Write CSV + events.json + summary; no charts")
|
|
94
|
+
mode.add_argument("--plot-only", action="store_true",
|
|
95
|
+
help="Reuse existing CSV + events.json; render charts only")
|
|
96
|
+
mode.add_argument("--json", action="store_true", dest="json_mode",
|
|
97
|
+
help="Parse + detect, emit a single JSON blob to stdout, "
|
|
98
|
+
"no charts, no logs. Useful for piping into jq.")
|
|
99
|
+
|
|
100
|
+
# Filters
|
|
101
|
+
ap.add_argument("--from", dest="from_time", type=_parse_time, default=None,
|
|
102
|
+
metavar="ISO_TIME", help="Restrict event scan & zooms to ≥ this time")
|
|
103
|
+
ap.add_argument("--to", dest="to_time", type=_parse_time, default=None,
|
|
104
|
+
metavar="ISO_TIME", help="Restrict event scan & zooms to ≤ this time")
|
|
105
|
+
ap.add_argument("--pre", type=int, default=30, metavar="SECS",
|
|
106
|
+
help="Pre-event zoom padding in seconds (default 30)")
|
|
107
|
+
ap.add_argument("--post", type=int, default=60, metavar="SECS",
|
|
108
|
+
help="Post-event zoom padding in seconds (default 60)")
|
|
109
|
+
ap.add_argument("--events", type=str, default=None, metavar="IDS",
|
|
110
|
+
help="Comma-separated event IDs to render (default: all)")
|
|
111
|
+
ap.add_argument("--plot", type=str, default=None, metavar="QTYS",
|
|
112
|
+
help=f"Comma-separated quantities to chart "
|
|
113
|
+
f"(default: {','.join(DEFAULT_PLOTS)}). "
|
|
114
|
+
f"Valid: {','.join(sorted(FULL_QUANTITIES))}")
|
|
115
|
+
ap.add_argument("--phase", type=str, default=None, metavar="PHASES",
|
|
116
|
+
help="Comma-separated phases (a,b,c,total). Reserved for future "
|
|
117
|
+
"per-phase chart filtering; currently informational.")
|
|
118
|
+
ap.add_argument("--snapshots", type=int, default=3, metavar="N",
|
|
119
|
+
help="Number of normal-operation snapshots to pick (default 3)")
|
|
120
|
+
|
|
121
|
+
# Parse options
|
|
122
|
+
ap.add_argument("--reverse-cts", nargs="?", const="all", default=None,
|
|
123
|
+
metavar="PHASES",
|
|
124
|
+
help="Negate P/Q/PF/DPF/Wh/VARh for backwards iFlex CTs. "
|
|
125
|
+
"Bare flag = all phases; pass a comma list like 'a,c' to "
|
|
126
|
+
"only flip those phases (plus totals).")
|
|
127
|
+
ap.add_argument("--every", type=int, default=1, metavar="K",
|
|
128
|
+
help="Emit every K-th record into the CSV (default 1, all)")
|
|
129
|
+
|
|
130
|
+
# Output knobs
|
|
131
|
+
ap.add_argument("--no-xlsx", action="store_true", help="Skip XLSX report")
|
|
132
|
+
ap.add_argument("--no-html", action="store_true",
|
|
133
|
+
help="Skip the self-contained HTML report (default writes report.html)")
|
|
134
|
+
ap.add_argument("--no-overview", action="store_true",
|
|
135
|
+
help="Skip the overview multiplot")
|
|
136
|
+
ap.add_argument("--format", choices=("png", "svg"), default="png",
|
|
137
|
+
help="Chart image format (default png)")
|
|
138
|
+
ap.add_argument("--nominal-ln-v", type=float, default=None, metavar="V",
|
|
139
|
+
help="Nominal L-N voltage (auto-inferred if omitted)")
|
|
140
|
+
|
|
141
|
+
return ap
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resolve_outdir(args: argparse.Namespace) -> Path:
|
|
145
|
+
if args.output:
|
|
146
|
+
return args.output
|
|
147
|
+
name = args.session_dir.name
|
|
148
|
+
if name.lower().endswith(".fel"):
|
|
149
|
+
name = name[:-4]
|
|
150
|
+
return args.session_dir.parent / (name + "_out")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_session(args: argparse.Namespace, outdir: Path,
|
|
154
|
+
session_dir: Path) -> tuple[Path, Path, dict]:
|
|
155
|
+
"""Phase 1A: parse trend.bin → session.csv + session_1min.csv."""
|
|
156
|
+
full_csv = outdir / "session.csv"
|
|
157
|
+
min_csv = outdir / "session_1min.csv"
|
|
158
|
+
reverse_cts = _parse_reverse_cts_arg(args.reverse_cts)
|
|
159
|
+
print(f"[parse] {args.session_dir} → {full_csv}")
|
|
160
|
+
parse_result = export_csv(
|
|
161
|
+
session_dir, full_csv,
|
|
162
|
+
every=args.every, reverse_cts=reverse_cts,
|
|
163
|
+
)
|
|
164
|
+
print(f" {parse_result['rows_written']:,} rows, {parse_result['columns']} cols")
|
|
165
|
+
print(f"[parse] downsampling to 1-min → {min_csv}")
|
|
166
|
+
export_csv(
|
|
167
|
+
session_dir, min_csv,
|
|
168
|
+
every=max(60, args.every), reverse_cts=reverse_cts,
|
|
169
|
+
)
|
|
170
|
+
return full_csv, min_csv, parse_result["config"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _detect_and_save(args: argparse.Namespace, outdir: Path,
|
|
174
|
+
trend_path: Path) -> tuple[list[Event], list[Snapshot]]:
|
|
175
|
+
"""Phase 1B: run event + snapshot detection, write JSON."""
|
|
176
|
+
print("[detect] running event scan…")
|
|
177
|
+
recs = list(iter_records(trend_path))
|
|
178
|
+
if args.from_time or args.to_time:
|
|
179
|
+
before = len(recs)
|
|
180
|
+
recs = [
|
|
181
|
+
r for r in recs
|
|
182
|
+
if (not args.from_time or r.start >= args.from_time)
|
|
183
|
+
and (not args.to_time or r.end <= args.to_time)
|
|
184
|
+
]
|
|
185
|
+
print(f" window filter: {before:,} → {len(recs):,} records")
|
|
186
|
+
events = detect_events(recs, nominal_ln_v=args.nominal_ln_v)
|
|
187
|
+
snaps = pick_snapshots(recs, events, n=args.snapshots)
|
|
188
|
+
print(f" {len(events)} events, {len(snaps)} snapshots")
|
|
189
|
+
|
|
190
|
+
events_path = outdir / "events.json"
|
|
191
|
+
snaps_path = outdir / "snapshots.json"
|
|
192
|
+
events_path.write_text(json.dumps(
|
|
193
|
+
[_event_to_json(e) for e in events], indent=2,
|
|
194
|
+
), encoding="utf-8")
|
|
195
|
+
snaps_path.write_text(json.dumps(
|
|
196
|
+
[_snapshot_to_json(s) for s in snaps], indent=2,
|
|
197
|
+
), encoding="utf-8")
|
|
198
|
+
print(f" wrote {events_path.name}, {snaps_path.name}")
|
|
199
|
+
return events, snaps
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _write_summary_txt(outdir: Path, events: Sequence[Event],
|
|
203
|
+
snaps: Sequence[Snapshot], config: dict) -> None:
|
|
204
|
+
lines: list[str] = ["Fluke 3540 FC Session Summary", "=" * 32, ""]
|
|
205
|
+
if config:
|
|
206
|
+
if config.get("asset_name"):
|
|
207
|
+
lines.append(f"Asset: {config['asset_name']}")
|
|
208
|
+
if config.get("team_name"):
|
|
209
|
+
lines.append(f"Team: {config['team_name']}")
|
|
210
|
+
if config.get("type"):
|
|
211
|
+
lines.append(f"Instrument: {config['type']} fw={config.get('firmware_version', '?')}")
|
|
212
|
+
lines.append("")
|
|
213
|
+
lines.append(f"Events detected: {len(events)}")
|
|
214
|
+
for ev in events:
|
|
215
|
+
phases = "/".join(ev.affected_phases) or "—"
|
|
216
|
+
lines.append(f" #{ev.id:>3} {ev.kind:18s} {ev.t_start.isoformat()} "
|
|
217
|
+
f"phases={phases} severity={ev.severity:.3f}")
|
|
218
|
+
lines.append("")
|
|
219
|
+
lines.append(f"Quiet snapshots: {len(snaps)}")
|
|
220
|
+
for s in snaps:
|
|
221
|
+
lines.append(f" #{s.id:>3} {s.t_start.isoformat()} "
|
|
222
|
+
f"mean P={s.p_total_mean_w / 1000:+.2f} kW σ={s.p_total_stdev_w:.1f} W")
|
|
223
|
+
(outdir / "summary.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _filter_events(events: Sequence[Event], ids_arg: str | None) -> list[Event]:
|
|
227
|
+
if not ids_arg:
|
|
228
|
+
return list(events)
|
|
229
|
+
wanted = {int(x.strip()) for x in ids_arg.split(",") if x.strip()}
|
|
230
|
+
return [e for e in events if e.id in wanted]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_quantities(arg: str | None, default: Sequence[str],
|
|
234
|
+
valid: Iterable[str]) -> list[str]:
|
|
235
|
+
if not arg:
|
|
236
|
+
return list(default)
|
|
237
|
+
picked = [q.strip() for q in arg.split(",") if q.strip()]
|
|
238
|
+
bad = [q for q in picked if q not in valid]
|
|
239
|
+
if bad:
|
|
240
|
+
raise SystemExit(
|
|
241
|
+
f"Unknown chart quantities: {bad}. Valid: {sorted(valid)}"
|
|
242
|
+
)
|
|
243
|
+
return picked
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _render_phase(args: argparse.Namespace, outdir: Path, full_csv: Path,
|
|
247
|
+
min_csv: Path, events: Sequence[Event],
|
|
248
|
+
snaps: Sequence[Snapshot], config: dict) -> None:
|
|
249
|
+
full_qtys = _parse_quantities(args.plot, DEFAULT_PLOTS, FULL_QUANTITIES.keys())
|
|
250
|
+
# Subset of zoom quantities that overlap with the user's --plot selection.
|
|
251
|
+
zoom_qtys = [q for q in DEFAULT_ZOOM_PLOTS if q in full_qtys] or DEFAULT_ZOOM_PLOTS
|
|
252
|
+
selected_events = _filter_events(events, args.events)
|
|
253
|
+
|
|
254
|
+
charts_dir = outdir / "charts"
|
|
255
|
+
charts_dir.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
print(f"[render] full-session charts → {charts_dir}/ ({', '.join(full_qtys)})")
|
|
257
|
+
fs_result = render_full_session(
|
|
258
|
+
full_csv, charts_dir,
|
|
259
|
+
quantities=full_qtys,
|
|
260
|
+
include_overview=not args.no_overview,
|
|
261
|
+
image_format=args.format,
|
|
262
|
+
overview_title=(
|
|
263
|
+
f"Fluke 3540 FC — {config.get('asset_name', 'Session')} Overview"
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
print(f" {len(fs_result.chart_paths)} chart(s) written")
|
|
267
|
+
|
|
268
|
+
if selected_events:
|
|
269
|
+
print(f"[render] event zooms ({len(selected_events)} event(s))")
|
|
270
|
+
for ev in selected_events:
|
|
271
|
+
res = render_event_zoom(
|
|
272
|
+
ev, full_csv, charts_dir,
|
|
273
|
+
pre_secs=args.pre, post_secs=args.post,
|
|
274
|
+
quantities=zoom_qtys, image_format=args.format,
|
|
275
|
+
)
|
|
276
|
+
print(f" event #{ev.id} {ev.kind}: "
|
|
277
|
+
f"{len(res.chart_paths)} chart(s)")
|
|
278
|
+
else:
|
|
279
|
+
print("[render] no events to zoom")
|
|
280
|
+
|
|
281
|
+
if snaps:
|
|
282
|
+
print(f"[render] snapshot zooms ({len(snaps)} snapshot(s))")
|
|
283
|
+
for s in snaps:
|
|
284
|
+
res = render_snapshot_zoom(
|
|
285
|
+
s, full_csv, charts_dir,
|
|
286
|
+
quantities=zoom_qtys, image_format=args.format,
|
|
287
|
+
)
|
|
288
|
+
print(f" snapshot #{s.id}: {len(res.chart_paths)} chart(s)")
|
|
289
|
+
|
|
290
|
+
if not args.no_xlsx:
|
|
291
|
+
xlsx_path = outdir / "report.xlsx"
|
|
292
|
+
print(f"[render] xlsx workbook → {xlsx_path}")
|
|
293
|
+
write_xlsx(min_csv, xlsx_path, config=config, csv_per_second_path=full_csv)
|
|
294
|
+
|
|
295
|
+
if not args.no_html:
|
|
296
|
+
html_path = outdir / "report.html"
|
|
297
|
+
print(f"[render] html report → {html_path}")
|
|
298
|
+
write_html_report(
|
|
299
|
+
html_path, charts_dir=charts_dir,
|
|
300
|
+
config=config,
|
|
301
|
+
summary_stats=_build_summary_stats(events, snaps),
|
|
302
|
+
events=events, snapshots=snaps,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _load_existing(outdir: Path) -> tuple[Path, Path, list[Event], list[Snapshot], dict]:
|
|
307
|
+
full_csv = outdir / "session.csv"
|
|
308
|
+
min_csv = outdir / "session_1min.csv"
|
|
309
|
+
events_path = outdir / "events.json"
|
|
310
|
+
snaps_path = outdir / "snapshots.json"
|
|
311
|
+
for p in (full_csv, min_csv, events_path):
|
|
312
|
+
if not p.exists():
|
|
313
|
+
raise SystemExit(f"--plot-only: required file missing: {p}")
|
|
314
|
+
events = [_event_from_json(d) for d in json.loads(events_path.read_text())]
|
|
315
|
+
snaps = []
|
|
316
|
+
if snaps_path.exists():
|
|
317
|
+
snaps = [
|
|
318
|
+
Snapshot(
|
|
319
|
+
id=d["id"], t_start=_parse_time(d["t_start"]),
|
|
320
|
+
t_end=_parse_time(d["t_end"]),
|
|
321
|
+
t_center=_parse_time(d["t_center"]),
|
|
322
|
+
p_total_mean_w=d["p_total_mean_w"],
|
|
323
|
+
p_total_stdev_w=d["p_total_stdev_w"],
|
|
324
|
+
)
|
|
325
|
+
for d in json.loads(snaps_path.read_text())
|
|
326
|
+
]
|
|
327
|
+
# Synthesize a minimal config dict from the first row of session.csv.
|
|
328
|
+
config: dict = {}
|
|
329
|
+
return full_csv, min_csv, events, snaps, config
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _interactive(events: Sequence[Event], snaps: Sequence[Snapshot],
|
|
333
|
+
default_qtys: Sequence[str]) -> tuple[list[Event], list[str]]:
|
|
334
|
+
"""Lightweight plain-input picker. Returns (picked_events, picked_quantities)."""
|
|
335
|
+
from .interactive import pick_events, pick_quantities
|
|
336
|
+
picked_events = pick_events(events)
|
|
337
|
+
picked_qtys = pick_quantities(default_qtys, list(FULL_QUANTITIES.keys()))
|
|
338
|
+
return picked_events, picked_qtys
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _build_summary_stats(events: Sequence[Event], snaps: Sequence[Snapshot]) -> dict:
|
|
342
|
+
by_kind: dict[str, int] = {}
|
|
343
|
+
for ev in events:
|
|
344
|
+
by_kind[ev.kind] = by_kind.get(ev.kind, 0) + 1
|
|
345
|
+
return {
|
|
346
|
+
"event_count": len(events),
|
|
347
|
+
"events_by_kind": by_kind,
|
|
348
|
+
"snapshot_count": len(snaps),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _emit_json(events: Sequence[Event], snaps: Sequence[Snapshot],
|
|
353
|
+
config: dict) -> None:
|
|
354
|
+
"""Print a single JSON object to stdout. No trailing newline beyond json.dump's."""
|
|
355
|
+
payload = {
|
|
356
|
+
"config": config,
|
|
357
|
+
"summary_stats": _build_summary_stats(events, snaps),
|
|
358
|
+
"events": [_event_to_json(e) for e in events],
|
|
359
|
+
"snapshots": [_snapshot_to_json(s) for s in snaps],
|
|
360
|
+
}
|
|
361
|
+
json.dump(payload, sys.stdout)
|
|
362
|
+
sys.stdout.write("\n")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
366
|
+
# Subcommand dispatch — `fluke-analyze compare ...` routes to cli_compare.
|
|
367
|
+
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
|
368
|
+
if raw_argv and raw_argv[0] == "compare":
|
|
369
|
+
from .cli_compare import compare_main
|
|
370
|
+
return compare_main(raw_argv[1:])
|
|
371
|
+
|
|
372
|
+
# Make console output UTF-8 safe on Windows (default cp1252 chokes on →, σ, etc.)
|
|
373
|
+
for stream in (sys.stdout, sys.stderr):
|
|
374
|
+
if hasattr(stream, "reconfigure"):
|
|
375
|
+
try:
|
|
376
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
377
|
+
except (AttributeError, ValueError):
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
args = build_argparser().parse_args(argv)
|
|
381
|
+
|
|
382
|
+
# In --json mode, suppress every other stdout print so the output stays
|
|
383
|
+
# a single valid JSON blob. We do this by replacing the module-level
|
|
384
|
+
# print() with a no-op for the duration of this call.
|
|
385
|
+
if getattr(args, "json_mode", False):
|
|
386
|
+
global print
|
|
387
|
+
_original_print = print
|
|
388
|
+
def print(*a, **kw): # noqa: A001 — intentional shadow
|
|
389
|
+
kw.setdefault("file", sys.stderr)
|
|
390
|
+
_original_print(*a, **kw)
|
|
391
|
+
|
|
392
|
+
if not args.session_dir.exists():
|
|
393
|
+
print(f"ERROR: {args.session_dir} does not exist", file=sys.stderr)
|
|
394
|
+
return 1
|
|
395
|
+
if not (args.session_dir.is_dir() or
|
|
396
|
+
(args.session_dir.is_file() and args.session_dir.suffix.lower() == ".fel")):
|
|
397
|
+
print(f"ERROR: {args.session_dir} is neither a directory nor a .fel file",
|
|
398
|
+
file=sys.stderr)
|
|
399
|
+
return 1
|
|
400
|
+
|
|
401
|
+
outdir = _resolve_outdir(args)
|
|
402
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
if args.plot_only:
|
|
406
|
+
full_csv, min_csv, events, snaps, config = _load_existing(outdir)
|
|
407
|
+
_render_phase(args, outdir, full_csv, min_csv, events, snaps, config)
|
|
408
|
+
return 0
|
|
409
|
+
|
|
410
|
+
# Phase 1: parse + detect (open_session transparently unpacks .fel)
|
|
411
|
+
with open_session(args.session_dir) as session_dir:
|
|
412
|
+
full_csv, min_csv, config = _parse_session(args, outdir, session_dir)
|
|
413
|
+
trend = find_session_files(session_dir)["trend"]
|
|
414
|
+
events, snaps = _detect_and_save(args, outdir, trend)
|
|
415
|
+
_write_summary_txt(outdir, events, snaps, config)
|
|
416
|
+
|
|
417
|
+
if getattr(args, "json_mode", False):
|
|
418
|
+
_emit_json(events, snaps, config)
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
if args.parse_only:
|
|
422
|
+
print(f"[done] parse-only: {outdir}")
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
if args.interactive:
|
|
426
|
+
full_qtys_default = _parse_quantities(
|
|
427
|
+
args.plot, DEFAULT_PLOTS, FULL_QUANTITIES.keys(),
|
|
428
|
+
)
|
|
429
|
+
picked_events, picked_qtys = _interactive(events, snaps, full_qtys_default)
|
|
430
|
+
events = picked_events
|
|
431
|
+
args.plot = ",".join(picked_qtys)
|
|
432
|
+
|
|
433
|
+
_render_phase(args, outdir, full_csv, min_csv, events, snaps, config)
|
|
434
|
+
print(f"[done] {outdir}")
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
except GnuplotNotFound as e:
|
|
438
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
439
|
+
return 2
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
if __name__ == "__main__":
|
|
443
|
+
sys.exit(main())
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""``fluke-analyze compare`` subcommand — overlay charts across N sessions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
from .parser import _parse_reverse_cts_arg, export_csv, open_session
|
|
10
|
+
from .plots import GnuplotNotFound
|
|
11
|
+
from .plots.compare import COMPARE_QUANTITIES, render_compare
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_compare_argparser() -> argparse.ArgumentParser:
|
|
15
|
+
ap = argparse.ArgumentParser(
|
|
16
|
+
prog="fluke-analyze compare",
|
|
17
|
+
description=__doc__,
|
|
18
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
19
|
+
)
|
|
20
|
+
ap.add_argument("sessions", nargs="+", type=Path,
|
|
21
|
+
help="Two or more session inputs (ES.NNN/ dirs or .fel files)")
|
|
22
|
+
ap.add_argument("-o", "--output", required=True, type=Path,
|
|
23
|
+
help="Output directory (will be created)")
|
|
24
|
+
ap.add_argument("--labels", type=str, default=None,
|
|
25
|
+
help="Comma-separated display labels per session "
|
|
26
|
+
"(default: derived from each input's name)")
|
|
27
|
+
ap.add_argument("--plot", type=str, default=None,
|
|
28
|
+
help=f"Quantities to compare (default all). "
|
|
29
|
+
f"Valid: {','.join(sorted(COMPARE_QUANTITIES))}")
|
|
30
|
+
ap.add_argument("--reverse-cts", nargs="?", const="all", default=None,
|
|
31
|
+
metavar="PHASES",
|
|
32
|
+
help="Apply same reverse-CTS phases to every session")
|
|
33
|
+
ap.add_argument("--format", choices=("png", "svg"), default="png",
|
|
34
|
+
help="Image format (default png)")
|
|
35
|
+
return ap
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_labels(arg: str | None, sessions: Sequence[Path]) -> list[str]:
|
|
39
|
+
if arg:
|
|
40
|
+
labels = [s.strip() for s in arg.split(",")]
|
|
41
|
+
if len(labels) != len(sessions):
|
|
42
|
+
raise SystemExit(
|
|
43
|
+
f"--labels count ({len(labels)}) does not match sessions count "
|
|
44
|
+
f"({len(sessions)})"
|
|
45
|
+
)
|
|
46
|
+
return labels
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
seen: dict[str, int] = {}
|
|
49
|
+
for s in sessions:
|
|
50
|
+
base = s.name
|
|
51
|
+
if base.lower().endswith(".fel"):
|
|
52
|
+
base = base[:-4]
|
|
53
|
+
if base in seen:
|
|
54
|
+
seen[base] += 1
|
|
55
|
+
base = f"{base}-{seen[base]}"
|
|
56
|
+
else:
|
|
57
|
+
seen[base] = 1
|
|
58
|
+
out.append(base)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compare_main(argv: Sequence[str] | None = None) -> int:
|
|
63
|
+
for stream in (sys.stdout, sys.stderr):
|
|
64
|
+
if hasattr(stream, "reconfigure"):
|
|
65
|
+
try:
|
|
66
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
67
|
+
except (AttributeError, ValueError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
args = build_compare_argparser().parse_args(argv)
|
|
71
|
+
if len(args.sessions) < 2:
|
|
72
|
+
print("ERROR: compare requires at least 2 sessions", file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
for s in args.sessions:
|
|
75
|
+
if not s.exists():
|
|
76
|
+
print(f"ERROR: session not found: {s}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
labels = _resolve_labels(args.labels, args.sessions)
|
|
80
|
+
outdir: Path = args.output
|
|
81
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
reverse_cts = _parse_reverse_cts_arg(args.reverse_cts)
|
|
83
|
+
quantities = (
|
|
84
|
+
[q.strip() for q in args.plot.split(",") if q.strip()]
|
|
85
|
+
if args.plot else list(COMPARE_QUANTITIES.keys())
|
|
86
|
+
)
|
|
87
|
+
bad = [q for q in quantities if q not in COMPARE_QUANTITIES]
|
|
88
|
+
if bad:
|
|
89
|
+
print(f"ERROR: unknown quantities {bad}. "
|
|
90
|
+
f"Valid: {sorted(COMPARE_QUANTITIES)}", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
# Phase 1: parse each session into outdir/session_N.csv
|
|
94
|
+
session_csvs: list[Path] = []
|
|
95
|
+
for i, session_input in enumerate(args.sessions):
|
|
96
|
+
csv_out = outdir / f"session_{i}.csv"
|
|
97
|
+
print(f"[parse] {session_input} → {csv_out} (label: {labels[i]})")
|
|
98
|
+
with open_session(session_input) as session_dir:
|
|
99
|
+
result = export_csv(
|
|
100
|
+
session_dir, csv_out, reverse_cts=reverse_cts,
|
|
101
|
+
)
|
|
102
|
+
print(f" {result['rows_written']:,} rows")
|
|
103
|
+
session_csvs.append(csv_out)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# Phase 2: overlay charts + summary CSV
|
|
107
|
+
print(f"[render] overlay charts → {outdir}/ ({', '.join(quantities)})")
|
|
108
|
+
cmp = render_compare(
|
|
109
|
+
session_csvs, labels, outdir,
|
|
110
|
+
quantities=quantities, image_format=args.format,
|
|
111
|
+
)
|
|
112
|
+
print(f" {len(cmp.chart_paths)} chart(s) written")
|
|
113
|
+
print(f" summary → {cmp.summary_csv}")
|
|
114
|
+
except GnuplotNotFound as e:
|
|
115
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
print(f"[done] {outdir}")
|
|
119
|
+
return 0
|