Configurable state precision and last_update attribute mode to reduce recorder/exporter write volume - #25
Conversation
…reduce recorder/exporter write volume by reducing noise in the data (rounding) and configuring the frequency of emmitting the last update attribute. This allows for a signifficant reduction in data volume logged to InfluxDB, without scrificing relevant data. - State precision: optional per-quantity rounding (voltage/current/power/ temperature/percent/energy), applied at the entity boundary. Default off = unchanged behaviour. - Last Update Attribute: raw/quantized/off mode for the last_update timestamp, with sparse write-once emission in quantized mode. Default raw = unchanged behaviour. Both measured together on a 23-module production installation; rounding alone has negligible effect and only reduces writes once the attribute fix is active (see PR description for measurements).
There was a problem hiding this comment.
🟡 Changes recommended
There are correctness/robustness issues in precision rounding behavior and config validation/error-handling that should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces two independently configurable mechanisms to reduce Home Assistant recorder/exporter write volume for PyTap entities: (1) optional per-physical-quantity rounding of float sensor states at the entity boundary, and (2) a configurable last_update attribute mode (raw / quantized / off) to avoid attribute-driven writes on every coordinator update.
Changes:
- Add state-precision (rounding) configuration via options flow and apply rounding at sensor update time.
- Add a Home-Assistant-free
last_updatehelper module and integrate sparse/quantizedlast_updateemission into the coordinator. - Add unit tests covering both rounding helpers and sparse
last_updatecadence/normalization.
File summaries
| File | Description |
|---|---|
custom_components/pytap/config_flow.py |
Adds options-flow steps for state precision and last_update mode/quantization. |
custom_components/pytap/const.py |
Introduces constants for state precision groups and last_update configuration. |
custom_components/pytap/coordinator.py |
Implements quantized/off last_update behavior and sparse emission at push boundaries. |
custom_components/pytap/last_update.py |
Adds pure helper functions for computing/normalizing last_update behavior. |
custom_components/pytap/sensor.py |
Applies configured rounding to entity native values at the state boundary. |
custom_components/pytap/manifest.json |
Bumps integration version. |
custom_components/pytap/strings.json |
Adds UI strings for new options flow steps. |
custom_components/pytap/translations/en.json |
Adds English translations for the new options flow steps and descriptions. |
tests/test_last_update.py |
Adds unit tests for last_update helper logic and sparse cadence. |
tests/test_state_precision.py |
Adds unit tests for state precision mapping/validation/rounding helpers. |
Review details
Suppressed comments (3)
custom_components/pytap/sensor.py:703
- Aggregate
performanceis pre-rounded to 2 decimals before applying configurable state precision, which prevents users from selecting 3-decimal precision for the percent group and makes precision settings inconsistent across sensors.
if total_peak_power > 0:
self._attr_native_value = _apply_state_precision(
round((total_power / total_peak_power) * 100.0, 2),
self._state_precision,
)
custom_components/pytap/config_flow.py:579
current_quant = int(...)can raise on invalid stored data, which would prevent the options form from rendering. It’s safer to catch conversion errors and fall back to the default (and optionally normalize for the current reporting mode).
current_mode = data.get(CONF_LAST_UPDATE_MODE, DEFAULT_LAST_UPDATE_MODE)
current_quant = int(
data.get(
CONF_LAST_UPDATE_QUANTIZATION, DEFAULT_LAST_UPDATE_QUANTIZATION
)
)
custom_components/pytap/coordinator.py:150
last_update_quantizationis only parsed as a positive int, but it is not clamped/normalized (min/max bounds, and the averaged-mode multiple-of-write-interval rule). This can make coordinator behavior diverge from what the options flow enforces and can break assumptions in sparse quantized mode.
try:
self._last_update_quantization: int = int(
entry.data.get(
CONF_LAST_UPDATE_QUANTIZATION,
DEFAULT_LAST_UPDATE_QUANTIZATION,
)
)
except (TypeError, ValueError):
self._last_update_quantization = DEFAULT_LAST_UPDATE_QUANTIZATION
if self._last_update_quantization <= 0:
self._last_update_quantization = DEFAULT_LAST_UPDATE_QUANTIZATION
- Files reviewed: 10/10 changed files
- Comments generated: 7
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| data = self._config_entry.data | ||
| write_interval = int(data.get(CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL)) | ||
| live_reporting = bool( | ||
| data.get(CONF_LIVE_REPORTING, DEFAULT_LIVE_REPORTING) | ||
| ) |
| self._last_update_mode: str = entry.data.get( | ||
| CONF_LAST_UPDATE_MODE, DEFAULT_LAST_UPDATE_MODE | ||
| ) |
| The caller owns the per-module ``last_cell`` state and must feed back the | ||
| returned cell. ``compute_last_update`` must be called exactly once per | ||
| push boundary per module (live: per reading; averaged: per flush) so the | ||
| tracker advances in step with what is actually pushed to Home Assistant. | ||
| """ | ||
| if mode == LAST_UPDATE_MODE_OFF: | ||
| return None, last_cell | ||
| if mode == LAST_UPDATE_MODE_RAW: | ||
| return now.isoformat(), last_cell | ||
| # quantized, sparse write-once per raster cell | ||
| cell = int(now.timestamp()) // quant | ||
| if cell == last_cell: | ||
| return None, last_cell | ||
| floored = cell * quant | ||
| return datetime.fromtimestamp(floored, tz=now.tzinfo).isoformat(), cell |
| # Convert duty cycle from 0.0-1.0 to percentage | ||
| if self.entity_description.key == "dc_dc_duty_cycle" and value is not None: | ||
| value = round(value * 100, 2) | ||
| self._attr_native_value = value | ||
| self._attr_native_value = _apply_state_precision( | ||
| value, self._state_precision |
| if __name__ == "__main__": | ||
| unittest.main(verbosity=2) | ||
|
|
||
|
|
||
| class PushBoundaryCadence(unittest.TestCase): |
| Must be called exactly once per push per module — in live reporting | ||
| that is per reading, in averaged reporting per flush — so the tracker | ||
| stays in step with what is actually pushed to Home Assistant. |
| # A fixed epoch that is NOT on a 60 s boundary (…000 is; …030 is mid-cell). | ||
| BASE = 1_700_000_040 # 60 s-aligned (epoch % 60 == 0) so cell math is exact |
PR: State precision (rounding) + configurable
last_updateattribute mode, to cut recorder/exporter write volumeSummary
Purpose: reduce recorder/exporter write volume by reducing noise in the data (rounding)
and configuring the frequency of emitting the last update attribute.
This allows for a significant reduction in data volume logged to InfluxDB -
without sacrificing data quality and details.
Two complementary, independently-configurable options-flow features that together
address a large, measured source of redundant recorder/InfluxDB writes on
multi-module installations:
states (voltage, current, power, temperature, percent, energy), applied at
the entity boundary. Default
offper group = bit-identical old behaviour.raw/quantized/off)for the
last_updatetimestamp attribute PyTap exposes on every moduleentity, which today is set on every coordinator update and therefore
forces a write on every poll even when the measured value hasn't changed.
quantizedwrites it once per raster period (sparse write-once) instead ofon every reading;
offremoves it entirely. Defaultraw= currentbehaviour, unchanged.
Why these two are in one PR: they were designed, deployed and measured
together on a 23-module production installation, and the measurements below
show they are not really independent — rounding alone has negligible effect,
and only delivers its benefit once the attribute fix is also active (see
"Why bundling isn't arbitrary" below).
Root cause
Reconstructed from the running source:
coordinator.py:"last_update": now.isoformat()— a microsecond ISOtimestamp, re-set on every processed reading and attached to every module
entity through
extra_state_attributes.state_changed— and the recorder writes — whenanything on the entity changes, attributes included. The ever-changing
timestamp guarantees a write on every coordinator update, even when the
(rounded) measured value is identical, and each such write carries the
two timestamp fields (
last_update+last_update_str) in addition to thevalue field.
force_updatein the code, andthe InfluxDB integration's
ignore_attributesdoes not change whether awrite happens — it only filters what gets serialised afterwards. HA's
_unrecorded_attributesbehaves the same way.Float sensor states also carry raw sub-decimal noise (e.g.
34.4999961853)inherited from the underlying protocol math, which by itself changes on
almost every reading regardless of any real change in the panel.
Feature 1: State precision
Add module: six dropdowns by physical quantity (Voltage, Current, Power,
Temperature, Percent, Energy —
voltage_in/voltage_outshare onesetting), each with identical levels off · 10 · 1 · 0.1 · 0.01 · 0.001
(
round()semantics, decimals-1..3), with a worked example in the label(e.g. "0.01 → 48.77 V"). Default off for every group — key absent from
entry.data→ non-breaking, no config migration.(
_apply_state_precisioninsensor.py's_handle_coordinator_updatepaths for both per-module and aggregate entities): aggregates sum the
raw coordinator data, so rounding a module's displayed value never
propagates a rounding error into string/installation totals; one setting
per physical quantity applies to both the module entity and its
aggregate(s).
rssi,readings_today) are deliberately excluded fromthe key→group map and are never rounded.
dc_dc_duty_cyclekeeps itsexisting ×100 conversion, then rounds. Applies in both reporting modes
(live and interval/averaged).
{group: decimals}dict is validated on load —unknown groups or non-numeric values are dropped with a warning (not a
crash), decimals are clamped to
[-1, 3], and a non-dict value is treatedas
off.starting points (voltage 0.1, current 0.01, power 1, temperature 0.1,
percent 0.1, energy 0.1) — constant relative resolution in the ~0.1–0.5 %
range, targeting the sub-percent current/voltage jitter that otherwise
changes on nearly every poll.
add_update_listener→async_reload— no HA restart needed.Feature 2: Last Update Attribute mode
See the design notes, implementation details (including the push-boundary
subtlety for sparse
quantizedmode), and full measurements already writtenup in
pytap-pr-text-v1_1.md— unchanged, reproduced in full below for asingle self-contained PR body.
raw(default) — current behaviour, byte-for-byte unchanged (non-breaking)quantized— the timestamp is floored to an n-second grid and writtenonce per grid period per module (sparse write-once); the attribute is
omitted in between
off— attribute not exposed at allThe write-trigger logic is factored into a small Home-Assistant-free module,
last_update.py, unit-testable in isolation (compute_last_update,normalize_quantization). The sparse decision is made at the actual pushboundary (per barcode updated since the last live push, or per module in an
averaged-mode flush) rather than per reading — the naive per-reading approach
silently drops the emission in both live mode (chunked socket reads collapse
multiple readings of a module to the last one before pushing) and
averaged/interval mode (the flush inherits
last_updatefrom the lastbuffered reading). This was found live, not by inspection — see
pytap-pr-text-v1_1.mdfor the full bug writeup and the regression test(
PushBoundaryCadence) it produced.Why bundling isn't arbitrary — the measurements show they interact
All measured on the same 23-module production installation, from raw
InfluxDB field counts over matched windows (the write rate here is
configuration-driven — polling ceiling × sensor count — not weather- or
time-of-day-dependent; a linear weather correction used in an early pass was
dropped once the hourly profile showed the rate barely moves between ~4.3 kW
and ~0.5 kW of panel output).
1. Rounding alone does essentially nothing. With the attribute still
raw(unchanged), a matched-window comparison of rounding off vs. on showeda change within measurement noise (≈ −2 %). The write trigger is the
ever-changing timestamp attribute, not the value — so stabilising the value
alone doesn't stop the write.
2. The attribute fix alone, net effect (
raw→quantized/60 s,rounding held constant, matched 08–10 UTC windows):
valuepointsraw, rounding onquantized/60 s, rounding on−29.9 % raw at +2.8 % power → ≈ −32 % power-normalised.
3.
offis the single largest lever on its own (attribute removedentirely), matched 20-minute windows, counting all fields per write
(
value+last_update+last_update_str, verified 1:1:1 per entity — avalue-only count understates storage by that factor):rawoff−59.5 % fields (2.47×) — bigger than trigger-quantisation and value
rounding combined.
4. Rounding, with the attribute fix active (the actual interaction):
with the fix active,
off(no rounding) produces more writes than anyrounded configuration — i.e. rounding only has an effect once the
attribute fix is active, confirming #1. A blunt 0-decimal rounding reached
up to −54 % (on the value field) but asymmetrically — current
x.xx→xcosts~6 % relative resolution, power barely any — so the recommended
constant-relative-resolution precision above was chosen instead, keeping
most of the reduction while preserving the current/voltage precision that
actually matters.
5. Sparse
quantized(v2.3.2 refinement — write-once, not dense).Over a 5-minute window at 60 s grid with ~10 s polling:
valuelast_updatelast_update_strvalueis unchanged (every reading is still a poll); the timestamp fieldsdrop to
9,788 / 1,584 ≈ 6.18— once per grid period per module. Against adense baseline (timestamp on every write, same window): timestamp-field
writes −83.8 % (3,168 vs 19,576), total field volume for these entities
−55.9 % (12,956 vs 29,364).
offalone would be −66.7 %; sparse thereforecaptures ~84 % of the
offsaving while retaining a once-per-periodtimestamp for outage detection.
Note on a remaining, related overhead (not addressed by this PR)
After
last_updateis disabled/quantized, a residual string overheadremains, traced to
last_reset_stron the total-type sensors (daily_energy,readings_todayat module/string/installation level). This only changesonce per day (at the counter reset), so it does not trigger writes — it is
dead weight carried on every write of those entities. It is inherent to HA's
totalstate_classsemantics (the Energy Dashboard/statistics engine useslast_resetto detect resets), so it is best handled at the exporter level(recorder / InfluxDB
ignore_attributes) rather than in integration code, toavoid breaking the Energy Dashboard. Mentioned here only for completeness.
Compatibility & tests
(
state_precisionabsent →offper group;last_update_modeabsent →raw); no config migration for either.last_updatesparselogic accounts for both live (per-chunk push) and averaged
(per-flush) reporting.
values dropped with a warning (decimals clamped to
[-1, 3]); unknownlast_update_modevalues rejected, quantization seconds clamped to[5, 300]and interval-normalised (≥ 2× in averaged mode).underlying code allows:
last_update.pyis fully Home-Assistant-free, soits tests run standalone with plain
unittest.sensor.py's precisionhelpers still require Home Assistant to be importable (they're not
HA-free), so those tests run under the project's standard
pytest-homeassistant-custom-componentsetup instead:tests/test_last_update.py— 15 tests againstlast_update.py's purehelpers:
rawreproduces the originalisoformat()(regression);offalways
None; grid flooring matches the previous dense formula; thesparse write-once cadence (emit / suppress / next cell); exactly one
emission per cell over a reading stream; per-module independence; the
push-boundary cadence regression; and quantization normalisation
(live clamp, averaged multiples, the ≥2× floor, max-cap precedence).
tests/test_state_precision.py— 23 tests againstsensor.py'sprecision helpers: pass-through behaviour for
off/non-float values;correct rounding at every supported decimal level including the
-1(tens) case; every
_PRECISION_GROUP_BY_KEYmapping (includingdeliberately-unmapped integer sensors always returning
None);config validation (missing key, non-dict, unknown group dropped,
non-coercible value dropped, numeric-string/int/float coercion, clamping
above/below range); and an explicit regression that an empty/missing
config is bit-identical to pre-feature behaviour.
Notes
Changes were developed with AI assistance; The design decisions, code review, testing and
live validation were performed by me on a 23-module production installation,
including the matched-window measurements above and confirming both the
rounding/attribute interaction and the sparse write-once cadence live in
InfluxDB.