Skip to content

Commit 689b355

Browse files
authored
Merge pull request #29 from m4r1k/feat/voltage-grid-quality-reporting
feat(voltage): grid-quality reporting (clamp + severity bypass) — rc9
2 parents 9006c61 + 96a5470 commit 689b355

9 files changed

Lines changed: 716 additions & 84 deletions

File tree

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
**UPS monitoring and shutdown orchestration for NUT**
66

7-
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8-
[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
9-
[![NUT Compatible](https://img.shields.io/badge/NUT-compatible-green.svg)](https://networkupstools.org/)
10-
[![codecov](https://codecov.io/gh/m4r1k/Eneru/branch/main/graph/badge.svg)](https://codecov.io/gh/m4r1k/Eneru)
11-
[![Documentation](https://img.shields.io/badge/docs-Read%20The%20Docs-blue.svg)](https://eneru.readthedocs.io/)
12-
[![PyPI](https://img.shields.io/pypi/v/eneru.svg)](https://pypi.org/project/eneru/)
7+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-e8e8ed?style=for-the-badge&labelColor=090909" alt="MIT"></a>
8+
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/Python-3.9+-e8e8ed?style=for-the-badge&labelColor=090909" alt="Python 3.9+"></a>
9+
<a href="https://networkupstools.org/"><img src="https://img.shields.io/badge/NUT-compatible-e8e8ed?style=for-the-badge&labelColor=090909" alt="NUT compatible"></a>
10+
<a href="https://codecov.io/gh/m4r1k/Eneru"><img src="https://img.shields.io/codecov/c/github/m4r1k/Eneru?style=for-the-badge&labelColor=090909&color=e8e8ed&label=Coverage" alt="Coverage"></a>
11+
<a href="https://eneru.readthedocs.io/"><img src="https://img.shields.io/badge/Docs-Read%20The%20Docs-e8e8ed?style=for-the-badge&labelColor=090909" alt="Documentation"></a>
12+
<a href="https://pypi.org/project/eneru/"><img src="https://img.shields.io/pypi/v/eneru?style=for-the-badge&labelColor=090909&color=e8e8ed&label=PyPI" alt="PyPI"></a>
1313

1414
<p align="center">
1515
<img src="https://raw.githubusercontent.com/m4r1k/Eneru/main/docs/images/eneru-diagram.svg" alt="Eneru Architecture" width="600">

docs/changelog.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12-
> Status: v5.1.0-rc8 is the final candidate before v5.1.0 GA. The rc8
13-
> changes (`tui` CLI alias, TUI right-edge fix, graph axes/units,
14-
> events-list decoupling, time-windowed graph X positioning, parallel
15-
> E2E matrix, shell completion) are the last polish items from
16-
> hardware testing on rc7. Once GA the entry below is promoted from
12+
> Status: v5.1.0-rc9 is the final candidate before v5.1.0 GA. rc9
13+
> reframes the voltage-monitoring path for grid-quality reporting
14+
> (clamp warnings to ±10% nominal, add severity-aware notification
15+
> bypass) on top of rc8's TUI/E2E/completion polish. After Federico's
16+
> live-mains testing on rc9 the entry below is promoted from
1717
> `[Unreleased]` to `[5.1.0] - YYYY-MM-DD` with no other changes.
1818
1919
### Added
20+
- **rc9 — Grid-quality voltage reporting.** Voltage warning thresholds (`BROWNOUT_DETECTED` / `OVER_VOLTAGE_DETECTED`) are now derived as the **tighter** of the EN 50160 / IEC 60038 ±10% envelope and `input.transfer.{low,high}` ± 5V — never wider than ±10%. Wide UPS firmware defaults (typical APC: 170 / 280 on 230V) used to make warnings fire only ~5V before the UPS itself switched to battery; the clamp ensures warnings serve the operator's grid-quality question instead. Managed UPSes with narrow transfer points (e.g. 215 / 245) keep their existing tighter thresholds via the same clamp. New `MonitorState.ups_transfer_low/high` fields track the UPS firmware switch points separately for use in notification context. The startup log line is reformatted to show both grid-quality warnings and UPS battery-switch points on separate lines.
21+
- **rc9 — Severity-aware notification bypass.** Voltage deviations greater than ±15% from nominal now bypass `voltage_hysteresis_seconds` (default 30s) and notify **immediately**, with a `(severe, X.X% below/above nominal)` tag and an `Approaching UPS battery-switch threshold` callout when the UPS is likely to react soon. Mild deviations (10–15%) still go through the dwell so neighbour-appliance flap doesn't spam. The threshold (15%) is hard-coded — same reasoning as warning thresholds, no config knob to misconfigure.
22+
- **rc9 — Notification text overhaul.** `BROWNOUT_DETECTED` / `OVER_VOLTAGE_DETECTED` notifications now carry `% deviation`, the warning threshold, and (when NUT exposes them) the UPS firmware's switch points so the operator can distinguish "grid is wobbly" from "UPS is about to switch". Mild events read e.g. `input voltage 200.0V is 13.0% below 230V nominal (warning threshold 207.0V). Persisted 30s. UPS will not switch to battery until 170.0V (firmware setting); this is a grid-quality issue, not an imminent power loss.`
2023
- **rc8 — `eneru tui` CLI alias.** First-class subcommand, identical options + dispatch to `eneru monitor`. Both spellings show in top-level help; the same `--config / --once / --interval / --graph / --time / --events-only` options work for either.
2124
- **rc8 — Graph Y-axis labels, units, and now/min/max header in the live TUI.** Each graph panel prints a stat row under the title (`now: 100% min: 95% max: 100%`) and labels the top/middle/bottom rows of the chart with values + units (`100% ┤`, `50% ┤`, `0% ┤`). Voltage and runtime graphs use observed bounds (e.g. `235.4V` / `233.1V`); runtime values render as `45m 12s` instead of raw seconds.
2225
- **rc8 — Time-windowed X positioning in `BrailleGraph.plot`.** New optional `x_values` / `x_min` / `x_max` arguments place each sample at its actual time within the requested window instead of spreading evenly across the chart. Without this, a 12h dataset in a 30d view looked like 30 days of data; sparse data now stays in the leftmost slice and a `data: 12h of 30d requested` footer surfaces the gap. `--once` mode keeps the legacy behaviour by not passing the new arguments.
@@ -60,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6063
- **rc7 — Stats schema bumped 2 → 3.** Added `events.notification_sent INTEGER DEFAULT 1`. Auto-migrates v2 DBs via idempotent `ALTER TABLE` on first start; existing rows default to `1` (the v2 daemon always notified). Pattern documented as the second exercise of the schema-evolution mechanic introduced in rc6.
6164

6265
### Changed
66+
- **rc9 — Voltage warning thresholds may produce more notifications on wide-transfer UPSes.** Operators with default-wide UPS firmware (e.g. APC Smart-UPS 170/280 on 230V) previously got essentially zero `BROWNOUT_DETECTED` / `OVER_VOLTAGE_DETECTED` notifications because the warnings fired only just before the UPS itself reacted. After rc9, the same setup will fire warnings at the EN 50160 ±10% band (207 / 253 on 230V) — that is the goal. If your mains is consistently outside that envelope, you'll learn about it. If you don't want per-event pings for sub-30s flap, the existing `notifications.voltage_hysteresis_seconds` (default 30s) still filters mild events; severe events (>±15%) bypass it and notify immediately.
6367
- **rc8 — TUI events panel is decoupled from the graph timescale.** Pressing `<T>` to cycle the graph window (1h → 6h → 24h → 7d → 30d) no longer re-queries the events list. The events panel uses a fixed `EVENTS_TIME_WINDOW = 24 * 3600` regardless of `<T>`, and `<M>` keeps toggling between 8 and 50 max rows. The two controls are now orthogonal — the graph is the variable view, the events list is the stable recent-history view.
6468
- **rc8 — E2E workflow runs as a 4-way parallel matrix.** The single `e2e-test` job is replaced by four matrix jobs visible in the GitHub Checks tab as `E2E CLI`, `E2E UPS Single`, `E2E UPS Multi`, and `E2E Redundancy and Stats`. Setup steps are a composite action at `.github/actions/e2e-setup/`; test bodies live in `tests/e2e/groups/{group}.sh`. Total wall-clock is bounded by the slowest group instead of the sum of all 32 tests. **Operator action (one-time, manual):** branch protection on `main` previously required `e2e-test`. Replace it with the four new checks listed above. GitHub allows adding checks before they exist (status: *expected*), so do this immediately before/after the merge so PRs aren't blocked on a check that no longer runs.
6569
- **`shutdown_order` and `parallel` are now mutually exclusive.** Setting both on the same server is a hard validation error (previously a warning). Pick one model: `shutdown_order` for multi-phase ordering, or `parallel` for the legacy two-group behaviour.

docs/notifications.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,27 @@ sqlite3 /var/lib/eneru/<UPS>.db \
290290
```
291291

292292
If the condition persists past the dwell, the notification fires
293-
with a `(persisted Ns)` annotation so you can see it was held.
293+
with a `Persisted Ns.` annotation so you can see it was held.
294+
295+
**Severity bypass (rc9, 5.1.0).** Non-severe voltage warnings (up
296+
to and including ±15% from nominal) go through the dwell as
297+
described above. **Severe deviations** (greater than ±15% from
298+
nominal) bypass the dwell entirely and notify immediately, with a
299+
`(severe, X.X% below/above nominal)` tag and an
300+
`Approaching UPS battery-switch threshold` callout when NUT
301+
exposes the UPS's transfer points. The reasoning:
302+
- Non-severe events are usually flap from neighbour appliances
303+
cycling — the 30s filter is the right call. Note that with
304+
narrow UPS transfer points the warning thresholds may be tighter
305+
than ±10%, so the "non-severe" band can fire at less than 10%
306+
deviation; the dwell still applies.
307+
- Severe deviations indicate real grid trouble (utility fault,
308+
generator instability, site wiring issue) — the operator wants
309+
to know immediately, not 30s later.
310+
311+
The bypass threshold (15%) is hard-coded — there's deliberately no
312+
config knob for it, same reasoning as the warning thresholds: a
313+
misconfiguration there would mask real over-voltage events.
294314

295315
### `notifications.suppress` — mute specific event types
296316

docs/triggers.md

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,33 @@ What Eneru does instead:
3737
1. **Auto-detect at startup.** Read `input.voltage.nominal` from NUT
3838
and snap it to the nearest standard grid voltage from
3939
`(100, 110, 115, 120, 127, 200, 208, 220, 230, 240)` if within 15V
40-
tolerance. `warning_low` / `warning_high` derive as ±10% of the
41-
snapped nominal (or from `input.transfer.{low,high}` when NUT
42-
exposes those *and* they sit within sanity bounds of the snapped
43-
nominal).
44-
2. **Cross-check with observed reality.** Some UPS firmwares (notably
40+
tolerance.
41+
2. **Derive grid-quality warning thresholds.** `warning_low` /
42+
`warning_high` are computed as the **tighter** of:
43+
- the EN 50160 / IEC 60038 ±10% envelope (`nominal × 0.9` /
44+
`nominal × 1.1`), and
45+
- NUT's `input.transfer.{low,high}` ± 5V buffer (when NUT exposes
46+
those *and* they sit within sanity bounds of the snapped nominal).
47+
48+
Tighter = warns earlier = better grid-quality signal. UPS firmware
49+
defaults are often *wide* (typical APC: `transfer.low=170` /
50+
`transfer.high=280` on 230V) to avoid switching to battery for
51+
routine grid wiggles; if Eneru honored those without clamping, a
52+
real-world brownout where mains drops to 200V (a 13% sag, definitely
53+
operator-relevant) would never log `BROWNOUT_DETECTED`. The clamp
54+
ensures Eneru's warnings serve the operator's grid-quality question
55+
while the UPS firmware's switch points remain available separately
56+
for context.
57+
58+
Behaviour table for a 230V nominal:
59+
60+
| UPS transfer points | warning_low / warning_high | Why |
61+
|---------------------|----------------------------|-----|
62+
| 170 / 280 (wide, APC default) | 207 / 253 | ±10% wins (transfer too wide) |
63+
| 215 / 245 (narrow, managed) | 220 / 240 | transfer ± 5V wins (tighter) |
64+
| not reported | 207 / 253 | ±10% fallback |
65+
66+
3. **Cross-check with observed reality.** Some UPS firmwares (notably
4567
on US 120V grids) mis-report `input.voltage.nominal=230`. After
4668
~10 polls Eneru takes the median of observed `input.voltage`
4769
readings and re-snaps the nominal if the readings disagree with
@@ -53,16 +75,30 @@ What Eneru does instead:
5375

5476
If you see one of these rows, your UPS firmware is mis-reporting
5577
nominal — the daemon corrected for you, but it's worth filing a
56-
NUT driver bug upstream.
57-
3. **Hysteresis on notifications, not on logs.** The state log line
58-
for `OVER_VOLTAGE_DETECTED` / `BROWNOUT_DETECTED` is always
59-
written immediately on transition. The *notification* dispatch is
60-
debounced by `notifications.voltage_hysteresis_seconds` (default
61-
30s). A 2-second flap to 122V on a 120V grid no longer pages you;
62-
a sustained 30s over-voltage still does — and arrives with a
63-
`(persisted Ns)` annotation. See
64-
[Notifications → Tuning alert noise](notifications.md#tuning-alert-noise).
65-
4. **Per-event mute, with a safety blocklist.**
78+
NUT driver bug upstream. The re-snap also re-applies the
79+
tighter-of-±10%-and-transfer clamp against the new nominal.
80+
81+
4. **Severity-aware notification hysteresis.** The state log line for
82+
`OVER_VOLTAGE_DETECTED` / `BROWNOUT_DETECTED` is always written
83+
immediately on transition. The *notification* dispatch is gated by
84+
severity:
85+
- **Non-severe voltage warnings** (up to and including ±15% from
86+
nominal) go through `notifications.voltage_hysteresis_seconds`
87+
(default 30s). A 2-second flap to 105V on a 120V grid (12.5%
88+
under nominal — past the warning threshold but not severe) no
89+
longer pages you; a sustained event still does, and arrives
90+
with a `Persisted Ns.` annotation. With narrow UPS transfer
91+
points the warning band can be tighter than ±10%, so non-severe
92+
events can fire at less than 10% deviation.
93+
- **Severe deviations** (`>±15%` from nominal) bypass the dwell
94+
and notify **immediately** with a `(severe, X.X% below/above
95+
nominal)` tag. These signal real grid trouble — utility fault,
96+
generator instability, site wiring — that the operator wants to
97+
know about NOW, not 30 seconds from now.
98+
99+
See [Notifications → Tuning alert noise](notifications.md#tuning-alert-noise).
100+
101+
5. **Per-event mute, with a safety blocklist.**
66102
`notifications.suppress: [...]` mutes specific informational
67103
events (AVR cycling, voltage normalized) but rejects safety-
68104
critical event names at config-load time. There is no way to
@@ -72,16 +108,40 @@ What Eneru does instead:
72108

73109
### What you'll see in the log
74110

75-
```
76-
📊 Voltage Monitoring Active. Nominal: 230V (NUT=230). Low Warning: 207.0V. High Warning: 253.0V.
111+
```text
112+
📊 Voltage Monitoring Active.
113+
Nominal: 230.0V (NUT=230.0).
114+
Grid-quality warnings: 207.0V / 253.0V (±10% nominal, EN 50160 envelope).
115+
UPS battery-switch points: 170.0V / 280.0V (from NUT input.transfer.{low,high}).
77116
📊 Voltage auto-detect re-snap: NUT=230V disagreed with observed median 120.0V (window=[120.5, 119.0, ...]V). Re-snapped to 120V; new thresholds 108.0V / 132.0V.
78117
⚡ POWER EVENT: VOLTAGE_AUTODETECT_MISMATCH - NUT nominal=230V, observed median=120.0V, re-snapped to 120V
79118
```
80119

81-
The bottom row also lands in the SQLite `events` table with
120+
The autodetect-mismatch row lands in the SQLite `events` table with
82121
`notification_sent=0` so it doesn't ping you (it's startup
83122
information, not an active power event).
84123

124+
### What you'll see in a brownout notification
125+
126+
**Mild brownout (UPS won't switch):**
127+
128+
```text
129+
🔻 BROWNOUT_DETECTED: input voltage 200.0V is 13.0% below 230V nominal
130+
(warning threshold 207.0V). Persisted 30s.
131+
UPS will not switch to battery until 170.0V (firmware setting);
132+
this is a grid-quality issue (outside the EN 50160 ±10% envelope),
133+
not an imminent power loss.
134+
```
135+
136+
**Severe brownout (UPS may switch shortly):**
137+
138+
```text
139+
🔻 BROWNOUT_DETECTED: (severe, 21.7% below nominal): input voltage 180.0V.
140+
Notifying immediately (bypassed hysteresis).
141+
Approaching UPS battery-switch threshold (170.0V) -- battery may
142+
engage shortly.
143+
```
144+
85145
---
86146

87147
## Low battery threshold

0 commit comments

Comments
 (0)