Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
107 changes: 107 additions & 0 deletions bin/bme680_iaq_replay.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Host-side replay harness for tuning BME680IaqEstimator against recorded
// Bosch BSEC output. The estimator is pure math with no platform
// dependencies, so a captured trace replays in milliseconds on any dev
// machine -- edit the constants in BME680IaqEstimator.h, recompile, rerun.
//
// Build (from the repo root):
// 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 a file argument, one sample per line:
// gas_ohms,relative_humidity[,bsec_iaq]
// Lines starting with '#' and non-numeric header lines are 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:
// 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: grep -o 'IAQCSV,.*' serial.log | cut -d, -f2- > trace.csv
//
// Output: per-sample "n,gas_ohms,rh,est_iaq,bsec_iaq" plus a summary with
// mean absolute error and UI-band agreement against the BSEC column.

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

#include <cmath>
#include <cstdio>

Comment thread
thebentern marked this conversation as resolved.
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 n = 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)) {
if (line[0] == '#')
continue;
float gas, rh, bsec = NAN;
int fields = sscanf(line, "%f,%f,%f", &gas, &rh, &bsec);
if (fields < 2)
continue; // header or malformed line
n++;
Comment thread
thebentern marked this conversation as resolved.
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", 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++;
}
}
}

fprintf(stderr, "samples: %ld, estimator outputs: %ld\n", n, produced);
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
}
}
21 changes: 5 additions & 16 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 All @@ -249,19 +252,5 @@ lib_deps =
https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.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}
# 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 @@ -302,7 +302,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 @@ -449,7 +449,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 @@ -512,7 +513,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 @@ -547,7 +548,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 @@ -809,7 +810,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
95 changes: 95 additions & 0 deletions src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#include "BME680IaqEstimator.h"

#include <math.h>
#include <string.h>

static float clampf(float v, float lo, float hi)
{
return v < lo ? lo : (v > hi ? hi : v);
}

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) ? clampf(relativeHumidity, 0.0f, 100.0f) : RH_REF;

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

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

if (!seeded) {
lnCeiling = clampf(x, LN_FLOOR, LN_CEIL_MAX);
seeded = true;
} else {
float alpha = (x > lnCeiling) ? ALPHA_UP : ALPHA_DOWN;
lnCeiling = clampf(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 = clampf(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 = clampf(humDeviation / RH_DEV_NORM, 0.0f, 1.0f) * 500.0f;

*iaqOut = (uint16_t)lroundf(clampf(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