Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.8.0] — 2026-06-03

Active/standby load-state split for bimodal loads — classify each record by
current, report the two states separately, correct the session energy three
ways, headline the active-state power factor, and harden the auto reverse-CTs
heuristic to decide on the dominant high-current state. Parity-tested Python↔JS.
Built from the real P115RE coating-rectifier session.

### Added — load-state split (`load_states`)
- **Current-gated classifier.** Each record is **active** when its mean
per-phase average current `(I_a_avg+I_b_avg+I_c_avg)/3` is **≥ the threshold**
(default **50 A**), else **standby**. Current — not power — because the power
*sign* at low current is exactly the thing in question. Configurable with
**`--standby-threshold-a N`**.
- **`load_states.csv` + `load_states.json`** — one row per state (active,
standby): records, hours, duty %, kWh, P avg/min/max (kW), I avg (A), S avg
(kVA), PF avg, V_LN avg, V_THD p95. JSON also carries the threshold, the three
energy figures, and the standby-sign caveat. Emitted automatically (cheap);
`--load-states` is an explicit opt-in. A compact table is embedded in
`summary.txt` and the HTML report.
- **Three explicit energy figures** (never silently changing the historic
number): `energy_as_measured_kWh` (signed sum — current behavior),
`energy_active_kWh` (active records only), and
`energy_net_clip_standby_kWh` (standby real power clipped to ≥0). The standby
real-power sign is unreliable at low current, so the active/clip figures are
the defensible consumption.
- **Headline PF is the active-state PF.** The narrative, summary, and HTML
report now headline the active-state power factor (the blended whole-session
PF is meaningless for a bimodal load); the raw whole-session PF is kept but
de-emphasized.

### Changed — magnitude-weighted reverse-CTs auto-detect
- `detect_ct_reversal` / `--auto-reverse-cts` now decide on the **dominant
high-current (active) state** — *is real power negative when current is
high?* — instead of the fragile whole-session negative-P count, which a
bimodal load defeats. The whole-session count fields are still reported for
context (`basis: "active"` vs `"whole_session"`, with `active_records`,
`active_frac_negative`, `active_mean_p_w`); the operator notice keys off the
active state. **Manual `--reverse-cts` behavior is unchanged** — only the AUTO
heuristic + its printed notice were improved.

### Changed — shift integration
- `shift_comparison.csv/json` rows gain **`active_records`**,
**`active_duty_pct`**, **`active_kWh`**, and **`active_PF_avg`** so each shift
shows its active load too. `summary.txt`'s shift table shows the new columns.

### Parity / tests / docs
- JS port in `web/analysis.js` (`classifyLoadStates`, `loadStateRows`,
`sessionEnergy`, `activeStatePf`; active-state `detectCtReversal`; active
columns on `shiftComparisonRows`) with a Python↔JS parity test
(`load_states_parity.test.js`) against a shared bimodal golden fixture.
- New Python tests: classifier (threshold, balanced bimodal fixture), the three
energy figures, the magnitude-weighted reverse-CTs decision (active-positive
and active-negative cases + fallback), shift active columns, narrative
active-PF/energy, and CLI `load_states` outputs.
- `docs/LOAD_STATES.md` (concept, the energy caveat, the standby-sign
explanation); README options; version bump to 0.8.0.

## [0.7.0] — 2026-06-03

Generalized, named, configurable shift/period splitting — so usage can be
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This project gives you:
- **A Python CLI** for scripting, batch jobs, and publication-quality gnuplot output. `pip install -e .` and you're going.
- **Auto event detection** — outages, voltage dips, swells, high-current peaks, frequency excursions, NEMA imbalance spikes, sudden load steps.
- **CT reversal correction** — `--reverse-cts` flag handles iFlex probes installed backwards (extremely common mistake). See [`docs/CT_REVERSAL.md`](docs/CT_REVERSAL.md).
- **Active/standby load-state split** — for bimodal loads (e.g. a rectifier that toggles between a heavy active draw and a light standby), classify each record by current, report the two states separately, headline the active-state power factor, and correct the session energy three ways. See [`docs/LOAD_STATES.md`](docs/LOAD_STATES.md).

## Web app — no install

Expand Down Expand Up @@ -100,7 +101,9 @@ fluke-analyze path/to/ES.NNN -o output/ \
| `--tod-bin MINS` | `1` | Time-of-day bin width in minutes. |
| `--demand-window SECS` | `900` | Rolling peak-demand window. Reports peak demand + the window it occurred in (`demand.json` + XLSX). See [`docs/DEMAND.md`](docs/DEMAND.md). |
| `--tz ZONE` | UTC | Render report timestamps in local + UTC for an IANA zone (e.g. `America/Chicago`). Default UTC only. |
| `--auto-reverse-cts` | off | Auto-detect a reversed-CT install (sustained negative real power) and apply `--reverse-cts` automatically, with a loud notice. See `docs/CT_REVERSAL.md`. |
| `--auto-reverse-cts` | off | Auto-detect a reversed-CT install and apply `--reverse-cts` automatically, with a loud notice. Decides on the dominant **high-current (active)** state — robust for bimodal loads. See `docs/CT_REVERSAL.md`. |
| `--standby-threshold-a A` | `50` | Per-phase mean current (A) at/above which a record is **active** load (else standby). Drives the active/standby split (`load_states.csv`/`.json`), the energy correction (three figures), and the magnitude-weighted reverse-CTs decision. See [`docs/LOAD_STATES.md`](docs/LOAD_STATES.md). |
| `--load-states` | auto | Force the active/standby load-state report (emitted by default; flag is an explicit opt-in). |
| `--rules-file FILE` | off | JSON/TOML EventRules overrides keyed by asset name. See [`docs/RULES_FILE.md`](docs/RULES_FILE.md). |
| `--no-stats` | | Skip whole-session statistics (`stats.json`/`stats.csv` + XLSX sheet). |
| `--format` | `png` | `png` or `svg` |
Expand Down
22 changes: 22 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ Shipped releases live in [CHANGELOG.md](CHANGELOG.md). This file is the
backlog of ideas we've discussed but not yet committed to a specific
release.

## Shipped in v0.8 — active/standby load-state split

Bimodal-load handling, built from the real P115RE coating-rectifier session,
parity-tested Python↔JS:

- **Current-gated classifier** (`--standby-threshold-a`, default 50 A) splitting
each record into active vs standby, reported separately in
`load_states.csv`/`.json`.
- **Three explicit energy figures** (as-measured / active-only / net-clip
standby) — the standby real-power sign is unreliable at low current, so the
historic signed sum understates consumption; the report never silently
changes it.
- **Headline PF = active-state PF** in the narrative/summary/HTML (the blended
whole-session PF is meaningless for a bimodal load).
- **Magnitude-weighted reverse-CTs auto-detect** — decides on the dominant
high-current (active) state instead of the fragile whole-session negative-P
count. Manual `--reverse-cts` unchanged.
- Shift rows gain `active_duty_pct` / `active_kWh` / `active_PF_avg`.
- New `analysis.py` funcs (`classify_load_states`, `load_state_rows`,
`session_energy`, `active_state_pf`) + JS port + parity test +
`docs/LOAD_STATES.md`.

## Shipped in v0.7 — generalized shift splitting

Operator-defined, named, midnight-wrapping shift windows so usage can be
Expand Down
129 changes: 129 additions & 0 deletions docs/LOAD_STATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Load states: active vs standby (`load_states`)

Many factory loads are **bimodal** — they alternate between a heavy *active*
draw and a light *standby* state. Blending the two into one session mean buries
the real consumption and produces a meaningless average power factor. The
load-state report classifies every record as **active** or **standby**, reports
the two states separately, surfaces the **active-state PF**, and corrects the
session energy.

This was built from the real P115RE coating-rectifier session (ES.004,
`--reverse-cts`, `America/Chicago`):

| state | duty | I/phase | P_total | P1 (fundamental) | PF |
|---------|------|---------|---------|------------------|-------|
| active | ~49% | ~239 A | +97 kW | — | +0.47 |
| standby | ~47% | ~16 A | −7.6 kW | −7.7 kW | −0.64 |

## Why classify by *current*, not power

The whole question is whether the **power sign** is trustworthy. With the global
`--reverse-cts`, the active state reads +97 kW (correct — that is the coating
draw), but the standby state reads −7.6 kW. A rectifier in standby should draw
small *positive* core/copper losses, not export. The standby reading is balanced
across all three phases and the **fundamental** P1 is also negative (−7.7 kW), so
it is not a harmonic artifact — it is simply that **no single CT polarity makes
both states physical**, and the low-current sign is unreliable.

So we gate on **current**, which is unambiguous:

> A record is **active** when its mean per-phase average current
> `(I_a_avg + I_b_avg + I_c_avg) / 3` is **≥ the threshold** (default **50 A**),
> otherwise **standby**.

A single dropped phase is ignored (the mean is taken over the finite phases), so
one NaN does not drag a record into standby. The threshold is configurable with
`--standby-threshold-a N`. A single threshold is used (no transition band); set
it between the two clusters — for the P115RE the active state is ~239 A and the
standby state is ~16 A, so the 50 A default sits comfortably between them.

## The three energy figures

Because the standby sign is unreliable, the as-measured signed energy is
**understated** (the bogus −7.6 kW standby subtracts). The report surfaces all
three figures explicitly — it never silently changes the historic number:

- **`energy_as_measured_kWh`** — the signed sum of `P_total_avg_W` over all
records. This is the existing/historic behavior. *(P115RE: ~6,054 kWh.)*
- **`energy_active_kWh`** — energy from the **active** records only.
*(P115RE: ~6,638 kWh.)*
- **`energy_net_clip_standby_kWh`** — active records pass through unchanged;
standby real power is **clipped to ≥ 0** (a rectifier in standby never
exports). *(P115RE: ~6,684 kWh.)*

All three use the tool's standard convention: per record (1 s) energy =
`P_total_avg_W / 1000 / 3600`, summed; non-finite samples are skipped.

> **Caveat (carried in every output):** standby real-power sign is unreliable at
> low current, so the **active** and **clip** figures are the defensible
> consumption — not the as-measured signed sum.

## Headline power factor

For a bimodal load the blended whole-session PF is meaningless (the P115RE
blended PF is −0.09). The **meaningful** figure is the **active-state PF**
(~0.47), so that is what the narrative, summary, and HTML report headline. The
raw whole-session PF is still reported, but de-emphasized and labeled.

## Outputs

`load_states.csv` — one row per state (active, then standby):

| column | meaning |
|-----------------|------------------------------------------|
| `state` | `active` or `standby` |
| `records` | record count in the state |
| `hours` | hours (records / 3600) |
| `duty_pct` | percent of all records in the state |
| `kWh` | mean P × hours for the state |
| `P_avg_kW` | mean real power (kW) |
| `P_min_kW` | min real power (kW) |
| `P_max_kW` | max real power (kW) |
| `I_avg_A` | mean per-phase current (A) |
| `S_avg_kVA` | mean apparent power (kVA) |
| `PF_avg` | mean power factor |
| `V_LN_avg_V` | mean L-N voltage (outage zeros excluded) |
| `V_THD_p95_pct` | 95th-percentile V_THD |

`load_states.json` carries the same `states` rows plus `standby_threshold_a`,
the three `energy` figures, and the caveat `note`. A compact load-state table
(and the three energy figures) is embedded in `summary.txt` and the HTML report.

The report is emitted automatically (it is cheap — a couple of streaming
passes); `--load-states` is accepted as an explicit opt-in but is not required.

## Integration with shifts

When `--split-by shifts` is used, each shift row in `shift_comparison.csv/json`
also carries its **active** load, using the same current cut:

- `active_records` — active record count in the shift
- `active_duty_pct` — percent of the shift's records that are active
- `active_kWh` — active-only energy for the shift
- `active_PF_avg` — active-state PF for the shift

## The magnitude-weighted reverse-CTs decision

The same active/standby insight hardens the **auto** reverse-CTs heuristic
(`--auto-reverse-cts`). A naive count-based test ("is P negative for ≥ 50 % of
records?") is fragile for bimodal loads: the P115RE reads negative more than half
the time (standby) while the real consumption — the active state — is clearly
positive. So the auto-detect now decides on the **dominant high-current (active)
state**: *is real power negative when current is high?* The whole-session count
fields are still reported for context, but `reversed` and the operator notice key
off the active state (`basis: "active"`). If there is no active population at all
(everything below the threshold), it falls back to the whole-session count
(`basis: "whole_session"`). The manual `--reverse-cts` behavior is unchanged —
only the AUTO heuristic was improved.

## Tuning the threshold

```bash
# Default 50 A active/standby cut
fluke-analyze ES.004 --reverse-cts --tz America/Chicago

# Lower the cut so a small steady draw counts as active
fluke-analyze ES.004 --reverse-cts --standby-threshold-a 25 --tz America/Chicago
```

See also [CT_REVERSAL.md](CT_REVERSAL.md) and [SHIFTS.md](SHIFTS.md).
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "fluke-3540-analyzer"
version = "0.7.0"
version = "0.8.0"
description = "Parser, event detector, and chart generator for Fluke 3540 FC three-phase power-quality sessions. See https://github.com/GrumpyTanker/fluke-3540-analyzer."
requires-python = ">=3.10"
license = {text = "MIT"}
Expand Down
2 changes: 1 addition & 1 deletion python/src/fluke_3540/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
The canonical field map and binary layout live in spec/field_map.json at
the repo root and are shared with the JavaScript port. See README.md.
"""
__version__ = "0.7.0"
__version__ = "0.8.0"
Loading