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
100 changes: 100 additions & 0 deletions bin/bme680_iaq_replay.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Replays a captured BME680 CSV trace (gas_ohms,rh[,bsec_iaq]) through
// BME680IaqEstimator for offline tuning. See docs/bme680_iaq_replay.md.

#include "modules/Telemetry/Sensor/BME680IaqEstimator.h"

#include <cmath>
#include <cstdio>

Comment thread
thebentern marked this conversation as resolved.
namespace
{
// Same buckets the device UI uses (EnvironmentTelemetry drawFrame)
int band(int iaq)
{
if (iaq <= 25)
return 0; // Excellent
if (iaq <= 50)
return 1; // Good
if (iaq <= 100)
return 2; // Moderate
if (iaq <= 150)
return 3; // Poor
if (iaq <= 200)
return 4; // Unhealthy
if (iaq <= 300)
return 5; // Very Unhealthy
return 6; // Hazardous
}
} // namespace

int main(int argc, char **argv)
{
FILE *in = stdin;
if (argc > 1) {
in = fopen(argv[1], "r");
if (!in) {
fprintf(stderr, "cannot open %s\n", argv[1]);
return 1;
}
}

BME680IaqEstimator est;
char line[256];
long lineNo = 0, n = 0, skipped = 0, produced = 0, compared = 0, bandHits = 0;
double absErrSum = 0;

printf("n,gas_ohms,rh,est_iaq,bsec_iaq\n");
while (fgets(line, sizeof(line), in)) {
lineNo++;
if (line[0] == '#' || line[0] == '\n')
continue;
float gas, rh, bsec = NAN;
int fields = sscanf(line, "%f,%f,%f", &gas, &rh, &bsec);
if (fields < 2) {
// Tolerate one header row silently; anything else malformed is
// reported so a damaged trace can't produce a quiet, biased summary
if (lineNo > 1) {
skipped++;
fprintf(stderr, "skipping malformed line %ld: %s", lineNo, line);
}
continue;
}
n++;
Comment thread
thebentern marked this conversation as resolved.
uint16_t iaq;
bool got = est.update(gas, rh, &iaq);
bool haveBsec = fields >= 3 && std::isfinite(bsec);

printf("%ld,%.0f,%.2f,", n, gas, rh);
if (got)
printf("%u", (unsigned)iaq);
if (haveBsec)
printf(",%.0f\n", bsec);
else
printf(",\n");

if (got) {
produced++;
if (haveBsec) {
compared++;
absErrSum += std::fabs((double)iaq - (double)bsec);
if (band(iaq) == band((int)std::lround(bsec)))
bandHits++;
}
}
}
if (ferror(in)) {
fprintf(stderr, "input read error at line %ld\n", lineNo);
if (in != stdin)
fclose(in);
return 1;
}

fprintf(stderr, "samples: %ld, estimator outputs: %ld, malformed lines skipped: %ld\n", n, produced, skipped);
if (compared) {
fprintf(stderr, "vs BSEC (%ld comparable): mean abs error %.1f IAQ points, band agreement %.1f%%\n", compared,
absErrSum / compared, 100.0 * bandHits / compared);
}
if (in != stdin)
fclose(in);
return 0;
}
4 changes: 2 additions & 2 deletions bin/ram_budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"description."
],
"rak4631": {
"ram_bytes": 113000,
"flash_bytes": 786000
"ram_bytes": 108000,
"flash_bytes": 746000
}
}
54 changes: 54 additions & 0 deletions docs/bme680_iaq_replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# BME680 IAQ replay harness

`bin/bme680_iaq_replay.cpp` replays a captured sensor trace through the in-tree
`BME680IaqEstimator` on a dev machine, for tuning the estimator's constants
against recorded Bosch BSEC output. The estimator is pure math with no platform
dependencies, so a trace replays in milliseconds - edit the constants in
`src/modules/Telemetry/Sensor/BME680IaqEstimator.h`, recompile, rerun.

## Build

From the repo root:

```bash
c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay \
bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp
```

## Input

CSV on stdin or as a file argument, one sample per line:

```text
gas_ohms,relative_humidity[,bsec_iaq]
```

Lines starting with `#` are ignored; a single non-numeric header row is
tolerated; any other malformed line is reported on stderr and skipped.

## Capturing a trace

On a firmware build that still links BSEC (any release tag before the BSEC
removal), add one log line to `BME680Sensor::getMetrics` in the BSEC branch:

```cpp
LOG_INFO("IAQCSV,%.0f,%.2f,%.0f", bme680.getData(BSEC_OUTPUT_RAW_GAS).signal,
bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal,
bme680.getData(BSEC_OUTPUT_IAQ).signal);
```

then extract the columns from the serial log:

```bash
grep -o 'IAQCSV,.*' serial.log | cut -d, -f2- > trace.csv
```

BSEC's `RAW_GAS` and heat-compensated humidity are exactly the estimator's
inputs, so one physical sensor feeds both algorithms identically.

## Output

Per-sample CSV `n,gas_ohms,rh,est_iaq,bsec_iaq` on stdout (empty `est_iaq`
during the estimator's warm-up/burn-in window), plus a stderr summary with the
mean absolute error and UI-band agreement against the `bsec_iaq` column, using
the same 0-500 band thresholds the device screen applies.
23 changes: 6 additions & 17 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,11 @@ lib_deps =
# renovate: datasource=github-tags depName=Seeed_PM2_5_sensor_HM3301 packageName=meshtastic/Seeed_PM2_5_sensor_HM3301
https://github.com/meshtastic/Seeed_PM2_5_sensor_HM3301/archive/2704ca254c7e2136c52ac23198dd05f5ba1e2f04.zip

; Common environmental sensor libraries (not included in native / portduino)
[environmental_extra_common]
; Extra environmental sensor libraries (not included in native / portduino).
; BME680/BME688 IAQ comes from the in-tree open estimator (BME680IaqEstimator);
; the proprietary Bosch BSEC blob (measured ~37-39 KB flash + ~4-5 KB static
; RAM per image) is intentionally not linked anywhere.
[environmental_extra]
lib_deps =
# renovate: datasource=github-tags depName=Adafruit BMP3XX packageName=adafruit/Adafruit_BMP3XX
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
Expand Down Expand Up @@ -260,20 +263,6 @@ lib_deps =
# renovate: datasource=custom.pio depName=Adafruit ADS1X15 packageName=adafruit/library/Adafruit ADS1X15 Library
https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip
# renovate: datasource=github-tags depName=Adafruit DS248x packageName=adafruit/Adafruit_DS248x
https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip

; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
[environmental_extra]
lib_deps =
${environmental_extra_common.lib_deps}
# renovate: datasource=github-tags depName=Bosch BSEC2 packageName=boschsensortec/Bosch-BSEC2-Library
https://github.com/boschsensortec/Bosch-BSEC2-Library/archive/refs/tags/1.10.2610.zip
# renovate: datasource=github-tags depName=Bosch BME68x packageName=boschsensortec/Bosch-BME68x-Library
https://github.com/boschsensortec/Bosch-BME68x-Library/archive/refs/tags/v1.3.40408.zip

; Environmental sensors without BSEC (saves ~3.5KB DRAM for original ESP32 targets)
[environmental_extra_no_bsec]
lib_deps =
${environmental_extra_common.lib_deps}
https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip
# renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680
https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip
13 changes: 7 additions & 6 deletions src/modules/Telemetry/EnvironmentTelemetry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c
#include "Sensor/LTR390UVSensor.h"
#endif

#if __has_include(<bsec2.h>) || __has_include(<Adafruit_BME680.h>)
#if __has_include(<Adafruit_BME680.h>)
#include "Sensor/BME680Sensor.h"
#endif

Expand Down Expand Up @@ -306,7 +306,7 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner)
#if __has_include(<Adafruit_LTR390.h>)
addSensor<LTR390UVSensor>(i2cScanner, ScanI2C::DeviceType::LTR390UV);
#endif
#if __has_include(<bsec2.h>) || __has_include(<Adafruit_BME680.h>)
#if __has_include(<Adafruit_BME680.h>)
addSensor<BME680Sensor>(i2cScanner, ScanI2C::DeviceType::BME_680);
#endif
#if __has_include(<Adafruit_BMP280.h>)
Expand Down Expand Up @@ -457,7 +457,8 @@ int32_t EnvironmentTelemetryModule::runOnce()
if (sleepOnNextExecution) {
// Honor the pre-sleep grace period armed in sendTelemetry(): OSThread reschedules with
// this return value, which would otherwise override setIntervalFromNow() with the sensor
// polling interval (35 ms for BSEC2) and trigger deep sleep while the TX is still on air
// polling interval (sub-second while a BME680 reading is in flight) and trigger deep sleep
// while the TX is still on air
return FIVE_SECONDS_MS;
}
return min(sendToPhoneIntervalMs, result);
Expand Down Expand Up @@ -520,7 +521,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt
const auto &m = telemetry.variant.environment_metrics;

// Check if any telemetry field has valid data
bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.iaq != 0 || m.voltage != 0 ||
bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.has_iaq || m.voltage != 0 ||
m.current != 0 || m.lux != 0 || m.white_lux != 0 || m.weight != 0 || m.distance != 0 || m.radiation != 0;

if (!hasAny) {
Expand Down Expand Up @@ -555,7 +556,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt
entries.push_back("Hum: " + String(m.relative_humidity, 0) + "%");
if (m.barometric_pressure != 0)
entries.push_back("Prss: " + String(m.barometric_pressure, 0) + " hPa");
if (m.iaq != 0) {
if (m.has_iaq) {
String aqi = "IAQ: " + String(m.iaq);
const char *bannerMsg = nullptr; // Default: no banner

Expand Down Expand Up @@ -844,7 +845,7 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
}

// Arm the pre-sleep sequence even when no valid reading was available this cycle (e.g. a
// BSEC2 call timing violation): a power-saving SENSOR node must still return to deep sleep,
// failed sensor read): a power-saving SENSOR node must still return to deep sleep,
// otherwise it stays awake until the next telemetry interval and drains its battery
if (!phoneOnly && isPowerSavingSensor()) {
if (!validTelemetry)
Expand Down
94 changes: 94 additions & 0 deletions src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#include "BME680IaqEstimator.h"

// std::clamp rather than meshUtils.h's clamp: that header drags in Arduino.h,
// and this file must stay compilable standalone on a dev host (see the replay
// harness in bin/bme680_iaq_replay.cpp)
#include <algorithm>
#include <math.h>
#include <string.h>

bool BME680IaqEstimator::update(float gasOhms, float relativeHumidity, uint16_t *iaqOut)
{
if (!(isfinite(gasOhms) && gasOhms > 0.0f))
return false;

// A failed humidity read must not poison the baseline: fall back to the
// reference, which makes both compensation terms no-ops
float rh = isfinite(relativeHumidity) ? std::clamp(relativeHumidity, 0.0f, 100.0f) : RH_REF;

if (warmupRemaining > 0) {
warmupRemaining--;
return false;
}

float x = logf(gasOhms) + KH * (rh - RH_REF);
x = std::clamp(x, LN_FLOOR - LN_RANGE, LN_CEIL_MAX);

if (!seeded) {
lnCeiling = std::clamp(x, LN_FLOOR, LN_CEIL_MAX);
seeded = true;
} else {
float alpha = (x > lnCeiling) ? ALPHA_UP : ALPHA_DOWN;
lnCeiling = std::clamp(lnCeiling + alpha * (x - lnCeiling), LN_FLOOR, LN_CEIL_MAX);
}

if (sampleCount < UINT32_MAX)
sampleCount++;
if (sampleCount < BURN_IN_SAMPLES)
return false;

float below = lnCeiling - x;
if (below < 0.0f)
below = 0.0f;
float gasScore = std::clamp(below / LN_RANGE, 0.0f, 1.0f) * 500.0f;

// Comfort-band penalty: only outside the band, so ordinary indoor humidity
// can't keep IAQ away from the "Excellent" band
float humDeviation = rh < RH_COMFORT_MIN ? RH_COMFORT_MIN - rh : (rh > RH_COMFORT_MAX ? rh - RH_COMFORT_MAX : 0.0f);
float humScore = std::clamp(humDeviation / RH_DEV_NORM, 0.0f, 1.0f) * 500.0f;

*iaqOut = (uint16_t)lroundf(std::clamp(gasScore + HUM_WEIGHT * humScore, 0.0f, 500.0f));
return true;
}

uint32_t BME680IaqEstimator::computeHash(const BME680IaqState &s)
{
uint32_t words[5];
memcpy(words, &s, sizeof(words));
return words[0] ^ words[1] ^ words[2] ^ words[3] ^ words[4];
}

void BME680IaqEstimator::serialize(BME680IaqState *out, uint32_t nowSecs) const
{
memset(out, 0, sizeof(*out));
out->magic = MAGIC;
out->version = VERSION;
out->warmupRemaining = (uint8_t)warmupRemaining;
out->lnCeiling = lnCeiling;
out->savedAtSecs = nowSecs;
out->sampleCount = sampleCount;
out->xorHash = computeHash(*out);
}

bool BME680IaqEstimator::restore(const BME680IaqState &in, uint32_t nowSecs)
{
if (in.magic != MAGIC || in.version != VERSION)
return false;
if (in.xorHash != computeHash(in))
return false;
// The ceiling only exists once a sample has been accepted (sampleCount > 0);
// pure warm-up progress is persisted with lnCeiling still at 0
bool hasBaseline = in.sampleCount > 0;
if (hasBaseline && !(isfinite(in.lnCeiling) && in.lnCeiling >= LN_FLOOR && in.lnCeiling <= LN_CEIL_MAX))
return false;
// Staleness is only judgeable when the state was stamped with a valid RTC
// and we have one now; a week-old baseline says nothing about today's air
if (in.savedAtSecs != 0 && nowSecs != 0 && nowSecs >= in.savedAtSecs && (nowSecs - in.savedAtSecs) > STATE_MAX_AGE_SECS)
return false;

lnCeiling = in.lnCeiling;
sampleCount = in.sampleCount;
warmupRemaining = in.warmupRemaining <= WARMUP_DISCARD ? in.warmupRemaining : WARMUP_DISCARD;
seeded = hasBaseline;
return true;
}
Loading
Loading