Skip to content

Remove proprietary Bosch BSEC blob; open in-tree IAQ estimator for BME680 - #11381

Merged
vidplace7 merged 6 commits into
developfrom
feature/open-iaq-remove-bsec
Aug 13, 2026
Merged

Remove proprietary Bosch BSEC blob; open in-tree IAQ estimator for BME680#11381
vidplace7 merged 6 commits into
developfrom
feature/open-iaq-remove-bsec

Conversation

@thebentern

@thebentern thebentern commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Why

The Bosch BSEC2 blob is one of our largest dependencies: measured ~37-39 KB flash and ~4-5 KB static RAM on every image that links it (~190 of ~240 targets), paid whether or not a BME680 is attached. It's also a no-source proprietary archive statically linked into GPLv3 release binaries, and it's been the recurring culprit in our nRF52 flash-pressure incidents (#11363 left a "TEMPORARY" lib_ignore hack on nrf52_promicro_diy_tcxo with a TODO to make the roster opt-in).

The firmware only ever consumed one BSEC-exclusive output: the iaq value. Temperature, humidity, pressure, and gas resistance all come from the plain sensor.

What

  • Remove BSEC2 + Bosch-BME68x-Library from all targets. environmental_extra_common / environmental_extra / environmental_extra_no_bsec collapse into a single environmental_extra (common sensors + Adafruit_BME680 2.0.6, which vendors Bosch's open BSD-3 bme68x driver). The per-variant BSEC link-path hacks (ThinkNode-M3, muzi_base) and the promicro lib_ignore block are gone.
  • New in-tree IAQ estimator (BME680IaqEstimator): humidity-compensated log-resistance tracked against a rise-fast/decay-slow rolling ceiling, scored onto the same 0-500 scale and bands the UI already uses. Pure math, no platform dependencies, fully unit-tested on native (15 tests, including a deep-sleep reboot-cycle simulation). Warm-up/burn-in progress persists to /prefs/bme680.dat (atomic SafeFile), so one-sample-per-wake SENSOR nodes converge across reboots; the orphaned /prefs/bsec.dat is deleted once.
  • BME680Sensor rewrite: async non-blocking sampling once per minute (~0.25% heater duty vs ~5% under BSEC LP - a real battery win), a hard 2-minute publish-freshness bound (a dead sensor stops reporting instead of freezing its last reading on the wire), and suppression of bogus gas_resistance=0 points from heater-unstable cycles.
  • UI: iaq rendering now gates on has_iaq, so a legitimate IAQ of 0 displays instead of vanishing. Alert thresholds (banner >150, beep >200) unchanged.
  • Size budgets: rak4631 tightened (RAM 113000→108000, flash 786000→746000) to lock in the reclaimed headroom.
  • Tuning harness: bin/bme680_iaq_replay.cpp replays a CSV captured from a BSEC build through the estimator on a dev machine and reports mean absolute error + UI-band agreement, so the constants can be tuned against real BSEC traces without reflashing (capture instructions in the file header).

Measured (develop → this branch, same commit)

Target Flash Static RAM Notes
rak4631 -38.8 KB -4.9 KB image ends 0xD7188, 75 KB clear of the warm-store cap; on nRF52 static RAM converts 1:1 into heap
heltec-v3 -36.4 KB -4.0 KB representative of the ESP32-S3/C3/C6 family
nrf52_promicro_diy_tcxo +7.0 KB 0 regains BME680 support (absent on develop since #11363), 36 KB clear of the cap
tlora-v2-1-1_6 +1.3 KB 0 OG ESP32, already on the Adafruit path

Behavior changes (release-notes material)

  • IAQ is now a relative-to-recent-baseline estimate rather than Bosch's calibrated output. Values will read differently; the 0-500 scale and qualitative bands are preserved. There is a ~33-minute burn-in on first boot (or ~33 wakes for deep-sleep nodes) before IAQ appears.
  • The OG-ESP32/native "IAQ approximation" from Fake IAQ values on Non-BSEC2 platforms like Platformio and the original ESP32 #9663 never actually ran (inverted isfinite check made it dead code). Those targets now report IAQ for the first time.
  • gas_resistance stays kOhm on the wire for fleet continuity; the proto comment claiming MOhm gets a separate docs fix in meshtastic/protobufs.

Testing

  • test_bme680_iaq (new, 15 cases) passes; full native suite shows no regressions vs develop (the only failure, test_packet_signing B11/B12, fails identically on clean develop).
  • Local builds green on rak4631, nrf52_promicro_diy_tcxo, tlora-v2-1-1_6, heltec-v3; both nRF52 targets pass the warm-region post-link guard; the rak4631 budget gate passes with the tightened limits.
  • Estimator numerics verified against the design targets (band mapping at 1.31x/1.7x/3x/15x baseline ratios, humidity deadband, burn-in persistence, stale/corrupt state rejection).

Hardware soak on a RAK4631+RAK1906 against a BSEC-build reference node is the remaining validation; the estimator constants are centralized and documented as tunables for exactly that.

Summary by CodeRabbit

  • New Features

    • Added standalone BME680/BME688 indoor air quality estimation with warm-up tracking, humidity compensation, readiness status, and IAQ scores from 0–500.
    • IAQ state persists across restarts and sleep cycles, with validation for corrupted or outdated data.
    • Improved sensor sampling, freshness handling, and IAQ availability reporting.
  • Documentation

    • Added instructions and tooling for replaying sensor data and comparing estimated IAQ results.
  • Chores

    • Removed reliance on proprietary BSEC components and reduced RAK4631 memory budgets.

…E680

BSEC2 cost ~37-39 KB flash and ~4-5 KB static RAM on ~190 of ~240 build
targets, linked whether or not a BME680 was attached, and was a no-source
proprietary archive inside GPLv3 release binaries. The firmware consumed
exactly one BSEC-exclusive output: the IAQ value.

- New BME680IaqEstimator: clean-room log-domain baseline tracker
  (humidity-compensated gas resistance vs a rise-fast/decay-slow ceiling,
  0-500 scale matching the existing UI bands), pure math, unit-tested on
  native (test_bme680_iaq, 15 tests incl. a deep-sleep reboot simulation).
  Warm-up/burn-in progress persists to /prefs/bme680.dat via SafeFile so
  one-sample-per-wake SENSOR nodes converge across reboots; stale
  /prefs/bsec.dat is removed once.
- BME680Sensor: single-path rewrite on Adafruit_BME680 with async
  once-per-minute sampling (~20x lower heater duty than BSEC LP mode),
  a hard 2-minute publish-freshness bound (a dead sensor stops reporting
  instead of freezing its last reading on the wire), and suppression of
  bogus gas_resistance=0 points from heater-unstable cycles.
- platformio.ini: environmental_extra_common/_extra/_no_bsec collapsed
  into one section; Bosch BSEC2 + BME68x deps deleted; per-variant BSEC
  link-path hacks and the TEMPORARY promicro lib_ignore removed.
  nrf52_promicro_diy_tcxo regains BME680 support at 36 KB clear of the
  warm-store cap; rak4631 lands at 75 KB clear.
- EnvironmentTelemetry: iaq rendering gates on has_iaq (a genuine IAQ of
  0 now displays); stale BSEC comments rewritten.
- rak4631 size budgets tightened (113000->108000 RAM, 786000->746000
  flash) to lock in the reclaimed headroom.
- bin/bme680_iaq_replay.cpp: host-side replay harness for tuning the
  estimator against captured BSEC traces (mean abs error + band
  agreement), no reflashing needed.

Measured (develop -> this branch): rak4631 -38.8 KB flash / -4.9 KB RAM;
heltec-v3 -36.4 KB / -4.0 KB; tlora-v2-1-1_6 +1.3 KB (its IAQ
approximation had been dead code since #9663 due to an inverted isfinite
check and now actually runs).

Note: gas_resistance stays kOhm on the wire for fleet compatibility; the
proto comment claiming MOhm gets a separate meshtastic/protobufs docs PR.
@github-actions

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Note

Building this pull request… the flash button, badges and supported-board
list will appear here automatically once CI finishes.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c83831f-8471-4a11-a5e1-6716cf82ec25

📥 Commits

Reviewing files that changed from the base of the PR and between b5fcc59 and 9d98351.

📒 Files selected for processing (3)
  • platformio.ini
  • src/modules/Telemetry/EnvironmentTelemetry.cpp
  • src/modules/Telemetry/Sensor/BME680Sensor.cpp
🚧 Files skipped from review as they are similar to previous changes (3)
  • platformio.ini
  • src/modules/Telemetry/EnvironmentTelemetry.cpp
  • src/modules/Telemetry/Sensor/BME680Sensor.cpp

📝 Walkthrough

Walkthrough

The PR replaces BSEC2-based BME680 IAQ processing with an in-tree estimator. It adds asynchronous sampling, persisted estimator state, telemetry integration, build configuration updates, automated tests, and a host-side CSV replay harness.

Changes

BME680 IAQ replacement

Layer / File(s) Summary
Estimator model and persistence
src/modules/Telemetry/Sensor/BME680IaqEstimator.*, test/test_bme680_iaq/test_main.cpp
Adds humidity-compensated IAQ scoring, warm-up and burn-in handling, adaptive baselines, state serialization, restoration, corruption checks, and staleness checks.
Asynchronous sensor integration
src/modules/Telemetry/Sensor/BME680Sensor.*
Replaces BSEC2 processing with Adafruit BME680 acquisition, cached readings, estimator updates, freshness checks, IAQ validity tracking, and atomic state saves.
Telemetry and build migration
src/modules/Telemetry/EnvironmentTelemetry.cpp, platformio.ini, variants/**, bin/ram_budgets.json
Removes BSEC2 configuration and linkage, updates BME680 registration and IAQ presence handling, consolidates environmental dependencies, and lowers rak4631 resource limits.
Replay harness and validation
bin/bme680_iaq_replay.cpp, docs/bme680_iaq_replay.md
Adds CSV replay, malformed-input reporting, per-sample estimates, optional BSEC comparison metrics, and usage documentation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: ⚪ Minimal · up to 9d983

The current change is merge-ready after normal checks and review; no actionable merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant EnvironmentTelemetry
  participant BME680Sensor
  participant AdafruitBME680
  participant BME680IaqEstimator
  participant SafeFile
  EnvironmentTelemetry->>BME680Sensor: request telemetry metrics
  BME680Sensor->>AdafruitBME680: start and complete asynchronous reading
  AdafruitBME680-->>BME680Sensor: temperature, humidity, pressure, gas resistance
  BME680Sensor->>BME680IaqEstimator: update gas resistance and humidity
  BME680IaqEstimator-->>BME680Sensor: IAQ value and readiness
  BME680Sensor-->>EnvironmentTelemetry: cached metrics and IAQ presence
  BME680Sensor->>SafeFile: serialize and atomically save estimator state
Loading

Possibly related PRs

Suggested labels: cleanup, tech debt

Suggested reviewers: oscgonfer, caveman99

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: removing Bosch BSEC and adding an in-tree BME680 IAQ estimator.
Description check ✅ Passed The description is detailed, relevant, and covers motivation, implementation, behavior changes, measurements, and testing; only template attestations are omitted.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/open-iaq-remove-bsec

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thebentern thebentern added the enhancement New feature or request label Aug 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/modules/Telemetry/Sensor/BME680Sensor.h (1)

33-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the file-name members static.

stateFileName and legacyBsecStateFileName are instance members, so each holds a pointer in RAM and is initialized at construction. Declare them static constexpr const char * to move them out of the object. This PR tightens the rak4631 RAM budget, so the saving is aligned with the goal.

♻️ Proposed change
-    const char *stateFileName = "/prefs/bme680.dat";
-    const char *legacyBsecStateFileName = "/prefs/bsec.dat"; // left behind by pre-open-IAQ firmware
+    static constexpr const char *stateFileName = "/prefs/bme680.dat";
+    // left behind by pre-open-IAQ firmware
+    static constexpr const char *legacyBsecStateFileName = "/prefs/bsec.dat";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/Telemetry/Sensor/BME680Sensor.h` around lines 33 - 34, Update the
BME680Sensor file-name members stateFileName and legacyBsecStateFileName to
static constexpr const char * declarations, preserving their existing string
values and removing per-instance storage.
src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp (1)

6-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use meshUtils.h’s clamp helper instead of duplicating clampf.

src/meshUtils.h already provides a C++17 clamp template, and other native-tested sources use it directly. Replace clampf with clamp from the existing repository helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp` around lines 6 - 9,
Remove the local clampf helper and update its call sites in the BME680 IAQ
estimator to use the existing clamp template from meshUtils.h. Include the
appropriate helper header and preserve the current lower and upper bounds.

Source: Coding guidelines

bin/bme680_iaq_replay.cpp (1)

1-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the replay workflow out of the source file.

This 23-line block contains a multi-paragraph build, capture, input, and output guide. Keep a one- or two-line usage summary here and move the full workflow to a README or tool document.

As per coding guidelines, code comments must be minimal, normally one or two lines, and must not contain multi-paragraph explanatory blocks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/bme680_iaq_replay.cpp` around lines 1 - 23, Reduce the file-level comment
above the replay harness to a one- or two-line usage summary, retaining only its
purpose and basic invocation. Move the detailed build, input format,
trace-capture procedure, and output description to an appropriate README or tool
document, without changing the replay implementation.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bin/bme680_iaq_replay.cpp`:
- Around line 68-75: Update the replay input loop around fgets and sscanf to
distinguish comment/header lines from malformed data, report each skipped
malformed row (including its row number or content), and track the input stream
with ferror(in) after the loop. Return a non-zero status when a read error
occurs while preserving normal EOF and valid-row processing.
- Around line 27-29: The printf formatting for iaq in the replay output is
incompatible with its uint16_t type. Update the relevant format string and
argument in the replay code to use a matching representation, either casting iaq
to uintmax_t with %ju or using %hu, and include <cstdint> as needed.

In `@src/modules/Telemetry/Sensor/BME680Sensor.cpp`:
- Around line 56-57: Replace the raw elapsed-time rate-limit predicates in
BME680Sensor with Throttle::isWithinTimespanMs, covering the sampling check near
haveSample, IAQ carry check, sample-freshness checks, and state-save check. Keep
the existing subtraction in the sampling branch to calculate the returned
remaining delay, and leave the absolute beginReading deadline calculations
unchanged.

---

Nitpick comments:
In `@bin/bme680_iaq_replay.cpp`:
- Around line 1-23: Reduce the file-level comment above the replay harness to a
one- or two-line usage summary, retaining only its purpose and basic invocation.
Move the detailed build, input format, trace-capture procedure, and output
description to an appropriate README or tool document, without changing the
replay implementation.

In `@src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp`:
- Around line 6-9: Remove the local clampf helper and update its call sites in
the BME680 IAQ estimator to use the existing clamp template from meshUtils.h.
Include the appropriate helper header and preserve the current lower and upper
bounds.

In `@src/modules/Telemetry/Sensor/BME680Sensor.h`:
- Around line 33-34: Update the BME680Sensor file-name members stateFileName and
legacyBsecStateFileName to static constexpr const char * declarations,
preserving their existing string values and removing per-instance storage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a56c69db-fdac-4298-a11a-cf23924a2628

📥 Commits

Reviewing files that changed from the base of the PR and between 5692518 and d56009f.

📒 Files selected for processing (15)
  • bin/bme680_iaq_replay.cpp
  • bin/ram_budgets.json
  • platformio.ini
  • src/modules/Telemetry/EnvironmentTelemetry.cpp
  • src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp
  • src/modules/Telemetry/Sensor/BME680IaqEstimator.h
  • src/modules/Telemetry/Sensor/BME680Sensor.cpp
  • src/modules/Telemetry/Sensor/BME680Sensor.h
  • test/native-suite-count
  • test/test_bme680_iaq/test_main.cpp
  • variants/esp32/esp32.ini
  • variants/esp32p4/esp32p4.ini
  • variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini
  • variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini
  • variants/nrf52840/muzi_base/platformio.ini
💤 Files with no reviewable changes (4)
  • variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini
  • variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini
  • variants/nrf52840/muzi_base/platformio.ini
  • variants/esp32p4/esp32p4.ini

Comment thread bin/bme680_iaq_replay.cpp
Comment thread bin/bme680_iaq_replay.cpp
Comment thread src/modules/Telemetry/Sensor/BME680Sensor.cpp Outdated
- Use Throttle::isWithinTimespanMs for all elapsed-time predicates in
  BME680Sensor per coding guidelines (deadline math for the async reading
  completion stays raw, as it targets an absolute timestamp)
- Make the state file name members static constexpr
- Replay tool: cast uint16_t before %u (default argument promotion), report
  malformed input lines instead of silently skipping, and fail non-zero on
  stream read errors
- Replace the local clampf helper with std::clamp (meshUtils.h's clamp drags
  in Arduino.h, which would break the estimator's standalone host build that
  the replay harness depends on)
- Trim the replay tool's file header to a two-line summary; the full build,
  capture, and tuning workflow moves to docs/bme680_iaq_replay.md
…emove-bsec

# Conflicts:
#	platformio.ini
#	src/modules/Telemetry/Sensor/BME680Sensor.cpp
#	test/native-suite-count
# Conflicts:
#	src/modules/Telemetry/Sensor/BME680Sensor.cpp
#	src/modules/Telemetry/Sensor/BME680Sensor.h
@thebentern

Copy link
Copy Markdown
Contributor Author

Hardware A/B: open estimator vs. BSEC on two RAK4631 + BME680

14-hour side-by-side soak, 2026-08-12T18:492026-08-13T08:53, 49,635 raw trace samples. Posting the full result for posterity, including where it does not meet the targets I set out with.

Method

Two RAK4631s with BME680 at 0x76:

  • A (reference)origin/develop + BSEC, plus a local-only patch logging IAQCSV,<raw_gas>,<heat_comp_rh>,<bsec_iaq>,<bsec_accuracy> from getMetrics().
  • B (candidate) — this branch, open estimator.

The number that matters is the replay, not the live A/B: device A's own logged readings are fed offline through BME680IaqEstimator via bin/bme680_iaq_replay.cpp, so both algorithms see byte-identical input and every difference is algorithmic. The live two-board comparison is a gross-breakage check only — unit-to-unit BME680 gas-resistance spread is about an order of magnitude, and it bit me during this run (device B read 107 while device A's gas fell 90%, purely because they saw different IPA concentrations).

Two resampling details that decide whether the replay is valid at all:

  • The BSEC build logs at ~1 Hz, but this branch feeds its estimator once per SAMPLE_INTERVAL_MS = 60 s. Replaying raw would run the adaptation ~60× fast and collapse ALPHA_DOWN's ~12 h decay to ~12 min. The harness resamples to one row per wall-clock minute.
  • Scoring is restricted to rows where BSEC's own accuracy >= 2, but the full trace is replayed — the estimator is a stateful baseline tracker, so filtering its input would corrupt its history.

Size claim: independently confirmed

Same board, same toolchain, from the linker:

Flash Static RAM
develop + BSEC 755,496 B (92.7%) 103,012 B (41.4%)
this branch 715,608 B (87.8%) 97,996 B (39.4%)
saved 39,888 B (38.9 KB) 5,016 B (4.9 KB)

Matches the ~37–39 KB / ~4–5 KB in the PR description. (A carries the trace patch, so the true BSEC-only flash delta is a hair under 39,888 B. Note a UF2 size delta is 2× the flash delta — UF2 carries 256 payload bytes per 512-byte block — so don't quote UF2 bytes.)

What passed

Responsiveness — r = 0.92 across 93 scored rows. A dry-VOC event (IPA gauze) took the estimator 13 → 430 while BSEC went 107 → 500:

 n    gas     rh     est   bsec
763   31541   43.25   13    107    <- baseline
764    3050   44.33  430    500    <- IPA
767    3315   44.50  412    500

No false alarms in clean air — 718 overnight rows, max IAQ 7, zero excursions above the 150 banner or 200 buzzer thresholds.

Baseline resists sustained pollution — unplanned, but I left the source in place for 45 minutes: the estimator held at 242 → 255 rather than normalizing the pollution away. ALPHA_DOWN behaves as designed.

Burn-in and persistence work on real hardware — first output at 33 samples as specified, state written to /prefs/bme680.dat each minute thereafter.

What did not pass, and why I don't think the numbers mean what they look like

Mean absolute error 123.1 (target < 50). Band agreement 23.7% (target ≥ 80%).

Two reasons to discount those specific figures:

  1. 93 scored rows, and BSEC is pegged at its 500 ceiling in a large fraction of them. You cannot measure error against a saturated reference — at est 430 vs BSEC 500, BSEC simply cannot report higher.
  2. BSEC took 13 hours to reach accuracy >= 2 at all, and only got there after an environmental excursion. Per Bosch's own header, calibration needs exposure to clean and polluted air (~30 min each); it is exposure-driven, not time-driven. In a still room it sits at accuracy 1 indefinitely — and while it did, it drifted from 51 → 99 over twelve hours while raw gas rose 28%, i.e. it reported worsening air as the air measurably cleaned.

The real finding is that the disagreement is a shape mismatch, not a scale error:

gas est BSEC est/BSEC
31,541 13 107 0.12
13,136 152 440 0.35
8,917 216 466 0.46
3,050 430 500 0.86

The curves converge under heavy pollution and diverge at the clean end — which is where users live and where the 150/200 thresholds sit. No single LN_RANGE closes this: fitting BSEC at gas 7,926 implies LN_RANGE ≈ ln(9.3), fitting at 15,827 implies ln(2.4). Those are irreconcilable, so it's a mapping-shape question (offset-plus-gain, or a non-log-linear curve), not a retune of the existing constants.

Whether that's worth chasing is a judgement call I'd rather surface than silently optimize toward: the estimator is relative-to-baseline by design, and it demonstrably responds to real pollution, resists sustained pollution, and never false-alarmed in 13 hours of clean air. If the goal is "usable IAQ without the blob," matching BSEC's absolute banding may be the wrong target.

One real bug found, with a fix

A humid transient could ratchet the clean-air baseline upward.

x = logf(gasOhms) + KH * (rh - RH_REF) is unbounded in the humidity term. Exhaled breath drops raw gas (real VOCs) while RH jumps far enough that exp(KH*dRH) more than cancels the drop — so the compensated sample lands above the ceiling and pulls it up via ALPHA_UP. The inflated baseline then decays only at ALPHA_DOWN (~12 h).

Measured, from this run — a breath event at 07:20 (RH 41.9 → 56.5, raw gas −26%):

 n    gas     rh     est   bsec
751   32986   41.85    0    102   <- before
752   24450   56.52    0    252   <- breath: KH cancels it (by design)
763   31541   43.25   13    107   <- after: same air, now reads 13

Compensated gas went 24% above the pre-event baseline during a pollution event. Humidity compensation normalizes a reading for comparison; it should not be able to mint a new "cleanest air seen" record.

Fix — gate upward ceiling movement on the direction of the raw change:

const bool rawImproved = !haveLastRaw || xRaw >= lastXRaw;
const float alpha = (x > lnCeiling && rawImproved) ? ALPHA_UP : ALPHA_DOWN;
lnCeiling = std::clamp(lnCeiling + alpha * (x - lnCeiling), LN_FLOOR, LN_CEIL_MAX);

Gating on direction rather than clamping the ceiling to the raw value matters: clamping mixes compensated and uncompensated space and would under-report by ~65 IAQ points at any steadily elevated RH. The guard state is deliberately not persisted — BME680IaqState's 24-byte on-disk layout is fixed, and after a restore the first sample is simply ungated.

Replayed against the same captured trace, post-event false elevation drops 13 → 6 and the IPA response is preserved (436 → 430). It halves rather than eliminates the effect: raw gas is also recovering during the tail of a humid event, so some legitimate-by-this-rule rise remains. Given the residual is ~6 points against a 150 threshold, I stopped there rather than adding more state.

Two regression tests added to test_bme680_iaq using the measured samples — one pinning the ratchet, one pinning that a genuine improvement at steady elevated RH still re-baselines unbiased. Suite green at 17 cases.

Incidental

develop's non-BSEC fallback is dead code: if (gasRaw > 0.0f && !isfinite(gasRaw)) can never be true, so boards on the Adafruit path have never reported IAQ at all (introduced in #9663). This PR is what makes that path work, not merely what makes it free.

Reproducing

bin/bme680_iaq_replay.cpp + docs/bme680_iaq_replay.md are in-tree and sufficient to redo this; the only out-of-tree piece is the one-line LOG_INFO on the BSEC build, documented in that same file. Tuning is a rebuild-and-rerun against a captured trace with no reflashing.

Caveats worth stating plainly: single sensor pair, one room, one night, 93 scored rows, and the strongest event saturated the reference. The pass/fail items above I'd consider settled; the absolute-agreement numbers I would not, until there's a sweep that holds BSEC in the 200–350 range without pegging.

@thebentern
thebentern enabled auto-merge August 13, 2026 15:13
@vidplace7
vidplace7 disabled auto-merge August 13, 2026 17:21
@vidplace7
vidplace7 merged commit b565a07 into develop Aug 13, 2026
80 of 215 checks passed
@caveman99
caveman99 deleted the feature/open-iaq-remove-bsec branch August 26, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants