Skip to content

Shrink timed_belief value columns from float8 to float4 - #2332

Open
Ahmad-Wahid wants to merge 6 commits into
mainfrom
reduce-timed-belief-float-precision
Open

Shrink timed_belief value columns from float8 to float4#2332
Ahmad-Wahid wants to merge 6 commits into
mainfrom
reduce-timed-belief-float-precision

Conversation

@Ahmad-Wahid

Copy link
Copy Markdown
Contributor

Closes #2331.

What

Store timed_belief's cumulative_probability and event_value as single-precision floats (float4/real, 4 bytes) instead of the double-precision (float8, 8 bytes) that timely_beliefs' TimedBeliefDBMixin declares. These are the two per-row numeric value columns on what is typically the largest table, so halving their width shrinks it on disk by roughly 15-20%.

Single precision keeps ~7 significant decimal digits, which is plenty for sensor readings and for a cumulative probability in [0, 1].

Changes

  • ORM (flexmeasures/data/models/time_series.py): override both columns on FlexMeasures' TimedBelief subclass with db.Float(precision=24) (→ real), preserving cumulative_probability's primary-key membership, nullable=False, and default=0.5. This keeps fresh installs and the create_all()-based test schema consistent with migrated databases (tests build the schema from the ORM, not from migrations).
  • Migration (9f2c1d7b3a44_shrink_timed_belief_values_to_float4.py): alter the existing columns via op.alter_column — upgrade to real, downgrade back to double precision. float8float4 casts are implicit in PostgreSQL, so no USING clause is needed.
  • Changelog: entry under Infrastructure / Support.

Notes / caveats

  • ALTER COLUMN ... TYPE rewrites the whole table and rebuilds the primary key (since cumulative_probability is part of it), so on a large timed_belief this is a heavy, table-locking operation — run during a maintenance window. Documented in the migration docstring.
  • Existing stored values are rounded to the nearest float4 on upgrade. Any use case genuinely needing >7 significant digits would lose precision.

Verification

  • Confirmed the ORM columns compile to FLOAT(24) (→ Postgres real) with the primary key intact.
  • Confirmed the migration's op.alter_column emits ALTER COLUMN ... TYPE FLOAT(24) on upgrade and ... TYPE FLOAT on downgrade.
  • black + flake8 + mypy pass (pre-commit).

🤖 Generated with Claude Code

Store timed_belief's cumulative_probability and event_value as
single-precision floats (float4/REAL, 4 bytes) instead of the
double-precision (float8, 8 bytes) that timely_beliefs' mixin declares.
These are the two per-row numeric value columns on what is typically the
largest table, so halving their width shrinks it on disk by ~15-20%.

Single precision keeps ~7 significant digits, which is plenty for sensor
readings and for a cumulative probability in [0, 1].

- Override both columns on flexmeasures' TimedBelief subclass so fresh
  installs and the create_all()-based test schema match migrated DBs.
- Add an Alembic migration altering the existing columns via
  op.alter_column (upgrade to real, downgrade to double precision).

See #2331

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Ahmad-Wahid
Ahmad-Wahid requested a review from Flix6x July 20, 2026 11:52
@Ahmad-Wahid Ahmad-Wahid self-assigned this Jul 20, 2026
@Ahmad-Wahid Ahmad-Wahid added this to the 1.0.0 milestone Jul 20, 2026
Ahmad-Wahid and others added 2 commits July 20, 2026 14:13
Now that timed_belief stores cumulative_probability and event_value as
single-precision floats (float4), values read back from the database are
rounded, while an incoming candidate belief still carries full double
precision. The exact-equality check in _drop_unchanged_beliefs_compared_to_db
therefore saw a re-submission of already-stored data as a value change,
which surfaced as a rejected replacement (403) instead of an idempotent
"data has already been received" (200).

Round the candidate (and, symmetrically, the DB) values to float4 before the
comparison so re-ingesting identical data is correctly recognised as
unchanged. Fixes test_post_sensor_data_twice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
…cy, tests

Follow-up on the float4 storage change.

Alembic head: the merge of main brought in 3c2f9e5a1d47, which already revises
4b0f2e9c1a6d, but this migration still pointed at 4b0f2e9c1a6d too. The branch
therefore carried two heads. Repoint down_revision at 3c2f9e5a1d47.

sum() over a float4 column: PostgreSQL's sum(real) both returns and accumulates
in real, so the sensor-stats sum silently drops whole units once the running
total passes 2^24. Sum over an explicit float8 cast instead. avg(real) already
accumulates in float8 and min()/max() return an existing value, so only sum()
needed this.

Dedupe consistency: the comparison against the database rounded to float4, but
the in-batch drop_duplicates just above it did not, so "unchanged" meant two
different things in one function. Pull the rounding into a shared
round_to_stored_precision() helper and use it in both places. The helper works
on a copy, so the frame we return keeps the caller's original values rather
than the rounded ones.

Tests (the change had none):
- both value columns are declared float4 -- this is what catches a future
  timely_beliefs release quietly reclaiming these columns;
- round_to_stored_precision does not mutate its input;
- re-submitting a value needing more than 7 significant digits is dropped as
  unchanged. Without the rounding this raises a duplicate key violation on
  timed_belief_pkey, so the dedupe fix is load-bearing, not cosmetic;
- the sensor-stats sum does not accumulate in float4. Both values used are
  exactly representable in float4, isolating the accumulator from storage.

Also: cite the PR rather than the issue in the changelog, add a release warning
covering the table rewrite and the irreversibility, note in the migration
docstring that downgrading restores the type but not the lost digits, and leave
a TODO to swap the column override for timely_beliefs' value_column_type hook
(SeitaBV/timely-beliefs#242) once a release carries it -- redeclaring the
columns here makes cumulative_probability the leading primary-key column in
create_all()-built schemas.

Verified against PostgreSQL 15: data suite 248 passed, API suite 441 passed
(the two test_closest_sensor failures pre-date this branch). The migration's
upgrade and downgrade were also run against a real table: columns become real,
the primary key survives with its order intact, NaN is preserved, and the
downgrade confirms the rounding is not reversible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: F.N. Claessen <felix@seita.nl>
@Flix6x

Flix6x commented Jul 31, 2026

Copy link
Copy Markdown
Member

Reviewed this and pushed the fixes directly to the branch (a219646) rather than leaving a list of asks — details below so you can check my work. The direction is right, and the dedupe fix in particular turned out to be load-bearing rather than defensive: without it, re-submitting a high-precision value raises a duplicate key violation on timed_belief_pkey. I added a test that fails without it.

Fixed

1. The branch had two Alembic heads. The merge of main brought in 3c2f9e5a1d47, which already revises 4b0f2e9c1a6d — and this migration pointed at 4b0f2e9c1a6d too. Repointed down_revision at 3c2f9e5a1d47; there is a single head again.

2. sum() over a float4 column accumulates in float4. _get_sensor_stats does sa.func.sum(TimedBelief.event_value). PostgreSQL's sum(real) both returns and accumulates in real, so once the running total passes 2^24 it stops registering small addends entirely — a sensor's "Sum over values" silently drops whole units. Now sums over an explicit float8 cast. (avg(real) already accumulates in float8, and min/max return an existing value, so sum was the only one affected.)

3. "Unchanged" meant two different things in one function. drop_unchanged_beliefs deduplicated within the incoming batch on raw values, while the comparison against the database rounded to float4. Pulled the rounding into a shared round_to_stored_precision() and used it in both places. The helper works on a copy, so the frame we return now keeps the caller's original values instead of the rounded ones — previously the returned bdf was derived from the mutated a_df.

4. Tests (there were none):

  • both value columns are declared float4 — this is the one that catches a future timely_beliefs release quietly reclaiming these columns;
  • round_to_stored_precision does not mutate its input;
  • re-submitting a value needing >7 significant digits is dropped as unchanged (fails with UniqueViolation without the fix);
  • the sensor-stats sum does not accumulate in float4. Both values used are exactly representable in float4, so the test isolates the accumulator from storage rounding.

5. Docs. Changelog now cites the PR rather than the issue (matching the surrounding entries), plus a release warning covering the table rewrite and the irreversibility. The migration docstring now says the downgrade restores the type, not the lost digits.

Verification

Beyond "the SQL compiles", I ran the upgrade and downgrade against a real PostgreSQL 15 table shaped like timed_belief:

  • columns become real, and the primary key survives with its column order intact;
  • NaN is preserved;
  • 0.1 still round-trips exactly — PG ≥12 emits shortest-round-trip text for real and we use psycopg2, so short decimals are safe. Worth remembering that this would not hold under psycopg3's binary protocol, if a driver migration ever comes up;
  • the downgrade demonstrates the irreversibility concretely: 0.1 comes back as 0.10000000149011612.

Test suites against PG 15: flexmeasures/data 248 passed, flexmeasures/api 441 passed. The two test_closest_sensor failures pre-date this branch.

One thing I did not fix, deliberately

Redeclaring these columns on the subclass works, but SQLAlchemy orders mixin columns by creation order, so it moves both to the front of the table and of the primary key — making the nearly-always-0.5 cumulative_probability the leading key column:

primary key column order
migrated DB (04f0e2d2924a) event_start, belief_horizon, cumulative_probability, sensor_id, source_id
create_all() before this PR source_id, event_start, belief_horizon, cumulative_probability, sensor_id
create_all() with this PR cumulative_probability, source_id, event_start, belief_horizon, sensor_id

You can see it in the failure output of the new dedupe test: Key (cumulative_probability, source_id, event_start, belief_horizon, sensor_id)=....

Some drift already existed (from the source_id declared_attr override), and this only affects schemas built by create_all() — deployments run flexmeasures db upgrade — so it is a tolerable interim. But it does mean the test suite exercises a different index shape than production.

The clean fix is upstream, so I opened SeitaBV/timely-beliefs#242: a value_column_type hook on TimedBeliefDBMixin, defaulting to today's Float. Measured, it keeps column and primary-key order byte-identical for the default type and a narrowed one alike. Once a tb release carries it, this override becomes:

value_column_type = db.Float(precision=24)

There is a quieter reason to prefer it too: a subclass-body override silently discards whatever tb declares on those columns, so a future server_default, check constraint or index there would vanish with no error and no failing test. I left a TODO in time_series.py pointing at the upstream PR.

Worth deciding before merge

The changelog warning now tells hosts to check their data first, but we can't check it for them, and "is float4 lossless for what we already store?" currently has no better answer than ad-hoc SQL. I filed SeitaBV/timely-beliefs#243 for an audit helper that reports the max relative error a float4 cast would introduce, per sensor. For our own deployments it'd be good to run that before shipping this — sensors holding cumulative meter readings or currency totals in the millions are the ones with no headroom at ~7 significant digits.

🤖 Generated with Claude Code

Flix6x and others added 2 commits July 31, 2026 12:37
round_to_stored_precision() hardcoded float4, which is only true once the
9f2c1d7b3a44 migration has run. In the window between deploying this code and
running that migration -- the very maintenance window the changelog tells hosts
to plan for -- the column is still float8, and rounding to float4 anyway would
classify a genuine update as "unchanged" and silently drop it. That is real
data loss, not a skipped redundant write.

Read the precision off the mapped column instead, via a cached lookup. A Float
with precision <= 24 is single precision on PostgreSQL; anything wider needs no
rounding at all, so pre-migration the helper becomes a no-op. This also drops
the "keep in sync with the migration" coupling, and makes the helper correct
for free once the timely_beliefs value_column_type hook lands.

The lookup is an attribute read on already-loaded table metadata, not a query,
and it is lru_cached anyway. The import of TimedBelief is function-local
because flexmeasures.data.models.time_series imports this module.

Adds two tests: that no rounding happens when the column is not narrowed, and
that the dtype is derived from the mapped column rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: F.N. Claessen <felix@seita.nl>
The PR claimed roughly 15-20%. Measured against 12.9M rows of the ems
production dump of 2026-07-28 (sensors 5 and 14), the real figure is
substantially smaller:

  heap only                    1037 MB -> 940 MB   (-9.4%)
  heap + primary key           1760 MB -> 1663 MB  (-5.5%)
  heap + all three indexes     2475 MB -> 2378 MB  (-3.9%)

Narrowing both columns saves 8 bytes of an 84.5-byte row. The estimate was
high because it treated the two value columns as a larger share of the row
than they are -- the 23-byte tuple header and the 16-byte belief_horizon
interval dominate -- and because none of the indexes shrink: neither of the
timely_beliefs search indexes contains a value column, and in the primary key
the 4 bytes saved on cumulative_probability are already absorbed by btree
deduplication, since every production row has cumulative_probability = 0.5.

Update the changelog, the migration docstring and the ORM comment to the
measured numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reduce storage by changing cumulative_probability and event_value from float8 to float4

2 participants